From 388031da3ab7aea9f12e936cc9f0ff6e5d986c9b Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 13 Aug 2026 10:57:21 +1200 Subject: [PATCH 1/5] Add PuLP example --- .github/workflows/python.yaml | 30 ++++----- .gitignore | 1 + README.md | 12 ++-- python/environment.yml | 6 ++ python/mof.py | 13 ++-- python/run_pulp.py | 121 ++++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 python/environment.yml create mode 100644 python/run_pulp.py diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index d97599a..607e132 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -1,22 +1,20 @@ name: Test Python -on: [push] +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.10'] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v7 + - uses: conda-incubator/setup-miniconda@v4 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install jsonschema - - name: Test - run: | - cd python - python mof.py + activate-environment: mathoptformat + environment-file: python/environment.yml + - name: Test PuLP + run: pytest ./python/run_pulp.py + - name: Test Validator + run: python ./python/mof.py diff --git a/.gitignore b/.gitignore index 722d5e7..c09c58e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode +python/__pycache__ diff --git a/README.md b/README.md index 37b78ad..cb0d051 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # MathOptFormat -This repository describes a file-format for mathematical optimization problems -called _MathOptFormat_ with the file extension `.mof.json`. +This [repository](https://github.com/jump-dev/MathOptFormat) describes a +file-format for mathematical optimization problems called _MathOptFormat_, with +the file extension `.mof.json`. MathOptFormat is rigidly defined by the [JSON schema](http://json-schema.org/) available at @@ -23,10 +24,11 @@ structure for mathematical optimization problems. INFORMS Journal on Computing. ## Implementations -- Julia +- The [MathOptInterface.jl](https://github.com/jump-dev/MathOptInterface.jl) + package supports reading and writing MathOptFormat files. - - The [MathOptInterface.jl](https://github.com/jump-dev/MathOptInterface.jl) package - supports reading and writing MathOptFormat files. +- Pedagogical Python code for parsing MathOptFormat files into + [PuLP](https://coin-or.github.io/pulp/) is available in the [python directory](https://github.com/jump-dev/MathOptFormat/tree/master/python). ## Standard form diff --git a/python/environment.yml b/python/environment.yml new file mode 100644 index 0000000..b242356 --- /dev/null +++ b/python/environment.yml @@ -0,0 +1,6 @@ +name: mathoptformat +dependencies: + - python=3.10 + - jsonschema=4.26.0 + - pulp=3.3.2 + - pytest=9.1.1 diff --git a/python/mof.py b/python/mof.py index 7edd8ca..0baf284 100644 --- a/python/mof.py +++ b/python/mof.py @@ -1,9 +1,14 @@ +# Copyright (c) 2020: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + import json import jsonschema import os - -SCHEMA_FILENAME = '../schemas/mof.1.schema.json' +ROOT = os.path.dirname(os.path.abspath(__file__)) + "/../" +SCHEMA_FILENAME = ROOT + 'schemas/mof.1.schema.json' def validate(filename): with open(filename, 'r', encoding='utf-8') as io: @@ -88,8 +93,8 @@ def summarize_nonlinear(schema): ### Validate all the files in the examples directory. ### -for filename in os.listdir('../examples'): - validate(os.path.join('../examples', filename)) +for filename in os.listdir(ROOT + 'examples'): + validate(os.path.join(ROOT + 'examples', filename)) ### ### Summarize the schema for the README table. diff --git a/python/run_pulp.py b/python/run_pulp.py new file mode 100644 index 0000000..7d4afe8 --- /dev/null +++ b/python/run_pulp.py @@ -0,0 +1,121 @@ +# Copyright (c) 2020: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +import json +import pulp +import pytest +import os + +ROOT = os.path.dirname(os.path.abspath(__file__)) + "/../" + +class UnsupportedObjective(Exception): + def __init__(self, F): + self.F = F + return + +class UnsupportedConstraint(Exception): + def __init__(self, F, S): + self.F, self.S = F, S + return + +def parse_func(f, var_by_name): + match f["type"]: + case "Variable": + return var_by_name[f["name"]] + case "ScalarAffineFunction": + expr = pulp.LpAffineExpression() + for term in f.get("terms", []): + expr += term["coefficient"] * var_by_name[term["variable"]] + if "constant" in f: + expr += f["constant"] + return expr + case _: + return None + +def add_variable_constraint(prob, f, s, var_by_name): + x = var_by_name[f["name"]] + match s["type"]: + case "LessThan": + x.upBound = s["upper"] + case "GreaterThan": + x.lowBound = s["lower"] + case "EqualTo": + x.lowBound = x.upBound = s["value"] + case "Interval": + x.lowBound, x.upBound = s["lower"], s["upper"] + case "ZeroOne": + x.cat = pulp.LpBinary + case "Integer": + x.cat = pulp.LpInteger + case _: + raise UnsupportedConstraint(f["type"], s["type"]) + return + +def add_constraint(prob, c, var_by_name): + f, s = c["function"], c["set"] + match f["type"]: + case "Variable": + add_variable_constraint(prob, f, s, var_by_name) + case _: + expr = parse_func(f, var_by_name) + if expr is None: + raise UnsupportedConstraint(f["type"], s["type"]) + match s["type"]: + case "LessThan": + prob += (expr <= s["upper"]) + case "GreaterThan": + prob += (expr >= s["lower"]) + case "EqualTo": + prob += (expr == s["value"]) + case _: + raise UnsupportedConstraint(f["type"], s["type"]) + return + +def read_from_file(filename): + with open(filename, "r") as f: + data = json.load(f) + return read_from_dict(data) + +def read_from_dict(data): + name = data.get("name", "") + obj = data.get("objective", {}) + is_max = obj.get("sense", "MIN_SENSE") == "MAX_SENSE" + sense = pulp.LpMaximize if is_max else pulp.LpMinimize + prob = pulp.LpProblem(name=name, sense=sense) + prob.var_by_name = {} + for x in data.get("variables", []): + name = x["name"] + prob.var_by_name[name] = \ + prob.add_variable(name, lowBound=None, upBound=None) + if "function" in obj: + obj_f = parse_func(obj["function"], prob.var_by_name) + if obj_f is None: + raise UnsupportedObjective(obj["function"]["type"]) + prob += obj_f + for c in data.get("constraints", []): + add_constraint(prob, c, prob.var_by_name) + return prob + +def solve_from_file(filename): + prob = read_from_file(filename) + print(prob) + prob.solve() + return { + "status": pulp.LpStatus[prob.status], + "primal": {k: v.value() for (k, v) in prob.var_by_name.items()} + } + +# Usage: + +def test_success(): + ret = solve_from_file(ROOT + 'examples/milp.mof.json') + assert ret["status"] == "Optimal" + assert ret["primal"] == {'x': 0.0, 'y': 1.0} + return + +def test_failure(): + with pytest.raises(UnsupportedObjective): + solve_from_file(ROOT + 'examples/nlp.mof.json') + return From cba036014fcac2a5da332413604f04b0e4bdcbc1 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 13 Aug 2026 12:00:06 +1200 Subject: [PATCH 2/5] Update --- python/environment.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/environment.yml b/python/environment.yml index b242356..599183f 100644 --- a/python/environment.yml +++ b/python/environment.yml @@ -1,6 +1,8 @@ name: mathoptformat dependencies: - python=3.10 - - jsonschema=4.26.0 - - pulp=3.3.2 - - pytest=9.1.1 + - pulp + - pytest + - pip + - pip: + - jsonschema From 6b50b22219b212eaa03d707a4c19b1d7d84f66b0 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 13 Aug 2026 12:04:20 +1200 Subject: [PATCH 3/5] Update --- .github/workflows/python.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 607e132..f30b367 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -14,7 +14,5 @@ jobs: with: activate-environment: mathoptformat environment-file: python/environment.yml - - name: Test PuLP - run: pytest ./python/run_pulp.py - - name: Test Validator - run: python ./python/mof.py + - run: python -m pytest ./python/run_pulp.py + - run: python ./python/mof.py From 5431e8abbe0a6eaff453a275e8c072c8131011bf Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 13 Aug 2026 12:16:58 +1200 Subject: [PATCH 4/5] Updateg --- .github/workflows/python.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index f30b367..3cc14c1 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -8,6 +8,9 @@ on: jobs: build: runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} # Essential for Conda environment activation steps: - uses: actions/checkout@v7 - uses: conda-incubator/setup-miniconda@v4 From 1c2257f24d2cb5e26377dce537107bd344dca070 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 13 Aug 2026 12:28:49 +1200 Subject: [PATCH 5/5] Update --- python/environment.yml | 2 +- python/run_pulp.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/python/environment.yml b/python/environment.yml index 599183f..1aa1c0e 100644 --- a/python/environment.yml +++ b/python/environment.yml @@ -1,8 +1,8 @@ name: mathoptformat dependencies: - python=3.10 - - pulp - pytest - pip - pip: + - pulp==3.* - jsonschema diff --git a/python/run_pulp.py b/python/run_pulp.py index 7d4afe8..6a0983a 100644 --- a/python/run_pulp.py +++ b/python/run_pulp.py @@ -87,8 +87,7 @@ def read_from_dict(data): prob.var_by_name = {} for x in data.get("variables", []): name = x["name"] - prob.var_by_name[name] = \ - prob.add_variable(name, lowBound=None, upBound=None) + prob.var_by_name[name] = prob.add_variable(name, None, None) if "function" in obj: obj_f = parse_func(obj["function"], prob.var_by_name) if obj_f is None: