diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index d97599a..3cc14c1 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -1,22 +1,21 @@ 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'] + defaults: + run: + shell: bash -el {0} # Essential for Conda environment activation 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 + - run: python -m pytest ./python/run_pulp.py + - 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..1aa1c0e --- /dev/null +++ b/python/environment.yml @@ -0,0 +1,8 @@ +name: mathoptformat +dependencies: + - python=3.10 + - pytest + - pip + - pip: + - pulp==3.* + - jsonschema 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..6a0983a --- /dev/null +++ b/python/run_pulp.py @@ -0,0 +1,120 @@ +# 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, None, 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