ommx-python-mip-adapter 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/PKG-INFO +2 -2
  2. ommx_python_mip_adapter-0.5.0/ommx_python_mip_adapter/__init__.py +15 -0
  3. ommx_python_mip_adapter-0.5.0/ommx_python_mip_adapter/ommx_to_python_mip.py +328 -0
  4. ommx_python_mip_adapter-0.5.0/ommx_python_mip_adapter/python_mip_to_ommx.py +200 -0
  5. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter.egg-info/PKG-INFO +2 -2
  6. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter.egg-info/SOURCES.txt +2 -1
  7. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter.egg-info/requires.txt +1 -1
  8. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/pyproject.toml +2 -2
  9. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/tests/test_instance_to_model.py +2 -2
  10. ommx_python_mip_adapter-0.4.0/ommx_python_mip_adapter/__init__.py +0 -11
  11. ommx_python_mip_adapter-0.4.0/ommx_python_mip_adapter/adapter.py +0 -362
  12. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/README.md +0 -0
  13. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter/exception.py +0 -0
  14. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter.egg-info/dependency_links.txt +0 -0
  15. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/ommx_python_mip_adapter.egg-info/top_level.txt +0 -0
  16. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/setup.cfg +0 -0
  17. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/tests/test_integration.py +0 -0
  18. {ommx_python_mip_adapter-0.4.0 → ommx_python_mip_adapter-0.5.0}/tests/test_model_to_instance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ommx_python_mip_adapter
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: An adapter for the Python-MIP from/to OMMX.
5
5
  Author-email: "Jij Inc." <info@j-ij.com>
6
6
  Project-URL: Repository, https://github.com/Jij-Inc/ommx-python-mip-adapter
@@ -15,7 +15,7 @@ Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: ommx<0.5.0,>=0.4.0
18
+ Requires-Dist: ommx<0.6.0,>=0.5.0
19
19
  Requires-Dist: mip
20
20
  Provides-Extra: dev
21
21
  Requires-Dist: markdown-code-runner; extra == "dev"
@@ -0,0 +1,15 @@
1
+ from .ommx_to_python_mip import PythonMIPBuilder, instance_to_model, solve
2
+ from .python_mip_to_ommx import (
3
+ OMMXInstanceBuilder,
4
+ model_to_instance,
5
+ model_to_solution,
6
+ )
7
+
8
+ __all__ = [
9
+ "instance_to_model",
10
+ "model_to_instance",
11
+ "model_to_solution",
12
+ "PythonMIPBuilder",
13
+ "OMMXInstanceBuilder",
14
+ "solve",
15
+ ]
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, final
4
+ from dataclasses import dataclass
5
+
6
+ import mip
7
+
8
+ from ommx.v1.function_pb2 import Function
9
+ from ommx.v1.solution_pb2 import Optimality, Result, Infeasible, Unbounded, Relaxation
10
+ from ommx.v1 import Instance, DecisionVariable, Constraint
11
+
12
+ from .exception import OMMXPythonMIPAdapterError
13
+ from .python_mip_to_ommx import model_to_solution
14
+
15
+
16
+ @dataclass
17
+ class PythonMIPBuilder:
18
+ """
19
+ Build Python-MIP Model from ommx.v1.Instance.
20
+ """
21
+
22
+ instance: Instance
23
+ model: mip.Model
24
+
25
+ def __init__(
26
+ self,
27
+ instance: Instance,
28
+ *,
29
+ solver_name: str = mip.CBC,
30
+ solver: Optional[mip.Solver] = None,
31
+ verbose: bool = False,
32
+ ):
33
+ if instance.raw.sense == Instance.MAXIMIZE:
34
+ sense = mip.MAXIMIZE
35
+ elif instance.raw.sense == Instance.MINIMIZE:
36
+ sense = mip.MINIMIZE
37
+ else:
38
+ raise OMMXPythonMIPAdapterError(
39
+ f"Not supported sense: {instance.raw.sense}"
40
+ )
41
+ self.instance = instance
42
+ self.model = mip.Model(
43
+ sense=sense,
44
+ solver_name=solver_name,
45
+ solver=solver,
46
+ )
47
+ if verbose:
48
+ self.model.verbose = 1
49
+ else:
50
+ self.model.verbose = 0
51
+
52
+ def set_decision_variables(self):
53
+ for var in self.instance.raw.decision_variables:
54
+ if var.kind == DecisionVariable.BINARY:
55
+ self.model.add_var(
56
+ name=str(var.id),
57
+ var_type=mip.BINARY,
58
+ )
59
+ elif var.kind == DecisionVariable.INTEGER:
60
+ self.model.add_var(
61
+ name=str(var.id),
62
+ var_type=mip.INTEGER,
63
+ lb=var.bound.lower, # type: ignore
64
+ ub=var.bound.upper, # type: ignore
65
+ )
66
+ elif var.kind == DecisionVariable.CONTINUOUS:
67
+ self.model.add_var(
68
+ name=str(var.id),
69
+ var_type=mip.CONTINUOUS,
70
+ lb=var.bound.lower, # type: ignore
71
+ ub=var.bound.upper, # type: ignore
72
+ )
73
+ else:
74
+ raise OMMXPythonMIPAdapterError(
75
+ f"Not supported decision variable kind: "
76
+ f"id: {var.id}, kind: {var.kind}"
77
+ )
78
+
79
+ def as_lin_expr(
80
+ self,
81
+ f: Function,
82
+ ) -> mip.LinExpr:
83
+ """
84
+ Translate ommx.v1.Function to `mip.LinExpr` or `float`.
85
+ """
86
+ if f.HasField("constant"):
87
+ return mip.LinExpr(const=f.constant) # type: ignore
88
+ elif f.HasField("linear"):
89
+ ommx_linear = f.linear
90
+ return (
91
+ mip.xsum(
92
+ term.coefficient * self.model.vars[str(term.id)] # type: ignore
93
+ for term in ommx_linear.terms
94
+ )
95
+ + ommx_linear.constant
96
+ ) # type: ignore
97
+ raise OMMXPythonMIPAdapterError(
98
+ "The function must be either `constant` or `linear`."
99
+ )
100
+
101
+ def set_objective(self):
102
+ self.model.objective = self.as_lin_expr(self.instance.raw.objective) # type: ignore
103
+
104
+ def set_constraints(self):
105
+ for constraint in self.instance.raw.constraints:
106
+ lin_expr = self.as_lin_expr(constraint.function)
107
+ if constraint.equality == Constraint.EQUAL_TO_ZERO:
108
+ constr_expr = lin_expr == 0
109
+ elif constraint.equality == Constraint.LESS_THAN_OR_EQUAL_TO_ZERO:
110
+ constr_expr = lin_expr <= 0 # type: ignore
111
+ else:
112
+ raise OMMXPythonMIPAdapterError(
113
+ f"Not supported constraint equality: "
114
+ f"id: {constraint.id}, equality: {constraint.equality}"
115
+ )
116
+ self.model.add_constr(constr_expr, name=str(constraint.id))
117
+
118
+ @final
119
+ def build(self) -> mip.Model:
120
+ self.set_decision_variables()
121
+ self.set_objective()
122
+ self.set_constraints()
123
+ return self.model
124
+
125
+
126
+ def instance_to_model(
127
+ instance: Instance,
128
+ *,
129
+ solver_name: str = mip.CBC,
130
+ solver: Optional[mip.Solver] = None,
131
+ verbose: bool = False,
132
+ ) -> mip.Model:
133
+ """
134
+ The function to convert ommx.v1.Instance to Python-MIP Model.
135
+
136
+ Examples
137
+ =========
138
+
139
+ .. doctest::
140
+
141
+ The following example of solving an unconstrained linear optimization problem with x1 as the objective function.
142
+
143
+ >>> import ommx_python_mip_adapter as adapter
144
+ >>> from ommx.v1 import Instance, DecisionVariable
145
+
146
+ >>> x1 = DecisionVariable.integer(1, lower=0, upper=5)
147
+ >>> ommx_instance = Instance.from_components(
148
+ ... decision_variables=[x1],
149
+ ... objective=x1,
150
+ ... constraints=[],
151
+ ... sense=Instance.MINIMIZE,
152
+ ... )
153
+ >>> model = adapter.instance_to_model(ommx_instance)
154
+ >>> model.optimize()
155
+ <OptimizationStatus.OPTIMAL: 0>
156
+
157
+ >>> ommx_solutions = adapter.model_to_solution(model, ommx_instance)
158
+ >>> ommx_solutions.entries
159
+ {1: 0.0}
160
+ """
161
+ builder = PythonMIPBuilder(
162
+ instance,
163
+ solver_name=solver_name,
164
+ solver=solver,
165
+ verbose=verbose,
166
+ )
167
+ return builder.build()
168
+
169
+
170
+ def solve(
171
+ instance: Instance,
172
+ *,
173
+ relax: bool = False,
174
+ solver_name: str = mip.CBC,
175
+ solver: Optional[mip.Solver] = None,
176
+ verbose: bool = False,
177
+ ) -> Result:
178
+ """
179
+ Solve the given ommx.v1.Instance by Python-MIP, and return ommx.v1.Solution.
180
+
181
+ :param instance: The ommx.v1.Instance to solve.
182
+ :param relax: If True, relax all integer variables to continuous one by calling `Model.relax() <https://docs.python-mip.com/en/latest/classes.html#mip.Model.relax>`_ of Python-MIP.
183
+
184
+ Examples
185
+ =========
186
+
187
+ KnapSack Problem
188
+
189
+ .. doctest::
190
+
191
+ >>> from ommx.v1 import Instance, DecisionVariable
192
+ >>> from ommx.v1.solution_pb2 import Optimality
193
+ >>> from ommx_python_mip_adapter import solve
194
+
195
+ >>> p = [10, 13, 18, 31, 7, 15]
196
+ >>> w = [11, 15, 20, 35, 10, 33]
197
+ >>> x = [DecisionVariable.binary(i) for i in range(6)]
198
+ >>> instance = Instance.from_components(
199
+ ... decision_variables=x,
200
+ ... objective=sum(p[i] * x[i] for i in range(6)),
201
+ ... constraints=[sum(w[i] * x[i] for i in range(6)) <= 47],
202
+ ... sense=Instance.MAXIMIZE,
203
+ ... )
204
+
205
+ Solve it
206
+
207
+ >>> result = solve(instance)
208
+ >>> solution = result.solution
209
+
210
+ Check output
211
+
212
+ >>> sorted([(id, value) for id, value in solution.state.entries.items()])
213
+ [(0, 1.0), (1, 0.0), (2, 0.0), (3, 1.0), (4, 0.0), (5, 0.0)]
214
+ >>> solution.feasible
215
+ True
216
+ >>> assert solution.optimality == Optimality.OPTIMALITY_OPTIMAL
217
+
218
+ p[0] + p[3] = 41
219
+ w[0] + w[3] = 46 <= 47
220
+
221
+ >>> solution.objective
222
+ 41.0
223
+ >>> solution.evaluated_constraints[0].evaluated_value
224
+ -1.0
225
+
226
+ Infeasible Problem
227
+
228
+ .. doctest::
229
+
230
+ >>> from ommx.v1 import Instance, DecisionVariable
231
+ >>> from ommx_python_mip_adapter import solve
232
+
233
+ >>> x = DecisionVariable.integer(0, upper=3, lower=0)
234
+ >>> instance = Instance.from_components(
235
+ ... decision_variables=[x],
236
+ ... objective=x,
237
+ ... constraints=[x >= 4],
238
+ ... sense=Instance.MAXIMIZE,
239
+ ... )
240
+
241
+ >>> result = solve(instance)
242
+ >>> assert result.HasField("infeasible") is True
243
+ >>> assert result.HasField("unbounded") is False
244
+ >>> assert result.HasField("solution") is False
245
+
246
+ Unbounded Problem
247
+
248
+ .. doctest::
249
+
250
+ >>> from ommx.v1 import Instance, DecisionVariable
251
+ >>> from ommx_python_mip_adapter import solve
252
+
253
+ >>> x = DecisionVariable.integer(0, lower=0)
254
+ >>> instance = Instance.from_components(
255
+ ... decision_variables=[x],
256
+ ... objective=x,
257
+ ... constraints=[],
258
+ ... sense=Instance.MAXIMIZE,
259
+ ... )
260
+
261
+ >>> result = solve(instance)
262
+ >>> assert result.HasField("unbounded") is True
263
+ >>> assert result.HasField("infeasible") is False
264
+ >>> assert result.HasField("solution") is False
265
+
266
+ Dual variable
267
+
268
+ .. doctest::
269
+
270
+ >>> from ommx.v1 import Instance, DecisionVariable
271
+ >>> from ommx_python_mip_adapter import solve
272
+
273
+ >>> x = DecisionVariable.continuous(0, lower=0, upper=1)
274
+ >>> y = DecisionVariable.continuous(1, lower=0, upper=1)
275
+ >>> instance = Instance.from_components(
276
+ ... decision_variables=[x, y],
277
+ ... objective=x + y,
278
+ ... constraints=[x + y <= 1],
279
+ ... sense=Instance.MAXIMIZE,
280
+ ... )
281
+
282
+ >>> solution = solve(instance).solution
283
+ >>> solution.evaluated_constraints[0].dual_variable
284
+ 1.0
285
+
286
+ """
287
+ model = instance_to_model(
288
+ instance, solver_name=solver_name, solver=solver, verbose=verbose
289
+ )
290
+ if relax:
291
+ model.relax()
292
+ model.optimize()
293
+
294
+ if model.status == mip.OptimizationStatus.INFEASIBLE:
295
+ return Result(infeasible=Infeasible())
296
+
297
+ if model.status == mip.OptimizationStatus.UNBOUNDED:
298
+ return Result(unbounded=Unbounded())
299
+
300
+ if model.status not in [
301
+ mip.OptimizationStatus.OPTIMAL,
302
+ mip.OptimizationStatus.FEASIBLE,
303
+ ]:
304
+ return Result(error=f"Unknown status: {model.status}")
305
+
306
+ state = model_to_solution(model, instance)
307
+ solution = instance.evaluate(state)
308
+
309
+ assert solution.raw.feasible
310
+
311
+ if model.status == mip.OptimizationStatus.OPTIMAL:
312
+ solution.raw.optimality = Optimality.OPTIMALITY_OPTIMAL
313
+
314
+ if relax:
315
+ solution.raw.relaxation = Relaxation.RELAXATION_LP_RELAXED
316
+
317
+ dual_variables = {}
318
+ for constraint in model.constrs:
319
+ pi = constraint.pi
320
+ if pi is not None:
321
+ id = int(constraint.name)
322
+ dual_variables[id] = pi
323
+ for constraint in solution.raw.evaluated_constraints:
324
+ id = constraint.id
325
+ if id in dual_variables:
326
+ constraint.dual_variable = dual_variables[id]
327
+
328
+ return Result(solution=solution.raw)
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import final
4
+ import mip
5
+
6
+ from mip.exceptions import ParameterNotAvailable
7
+ from ommx.v1.constraint_pb2 import Constraint, Equality
8
+ from ommx.v1.function_pb2 import Function
9
+ from ommx.v1.linear_pb2 import Linear
10
+ from ommx.v1.solution_pb2 import State
11
+ from ommx.v1 import Instance, DecisionVariable
12
+
13
+ from .exception import OMMXPythonMIPAdapterError
14
+
15
+
16
+ @dataclass
17
+ class OMMXInstanceBuilder:
18
+ """
19
+ Build ommx.v1.Instance from Python-MIP Model.
20
+ """
21
+
22
+ model: mip.Model
23
+
24
+ def decision_variables(self) -> list[DecisionVariable]:
25
+ """
26
+ Gather decision variables from Python-MIP Model as ommx.v1.DecisionVariable.
27
+ """
28
+ decision_variables = []
29
+ for var in self.model.vars:
30
+ if var.var_type == mip.BINARY:
31
+ kind = DecisionVariable.BINARY
32
+ elif var.var_type == mip.INTEGER:
33
+ kind = DecisionVariable.INTEGER
34
+ elif var.var_type == mip.CONTINUOUS:
35
+ kind = DecisionVariable.CONTINUOUS
36
+ else:
37
+ raise OMMXPythonMIPAdapterError(
38
+ f"Not supported variable type. "
39
+ f"idx: {var.idx} name: {var.name}, type: {var.var_type}"
40
+ )
41
+ decision_variables.append(
42
+ DecisionVariable.of_type(
43
+ kind, var.idx, lower=var.lb, upper=var.ub, name=var.name
44
+ )
45
+ )
46
+ return decision_variables
47
+
48
+ def as_ommx_function(self, lin_expr: mip.LinExpr) -> Function:
49
+ terms = [
50
+ Linear.Term(id=var.idx, coefficient=coefficient) # type: ignore
51
+ for var, coefficient in lin_expr.expr.items()
52
+ ]
53
+ constant: float = lin_expr.const # type: ignore
54
+
55
+ # If the terms are empty, the function is a constant.
56
+ if len(terms) == 0:
57
+ return Function(constant=constant)
58
+ else:
59
+ return Function(linear=Linear(terms=terms, constant=constant))
60
+
61
+ def objective(self) -> Function:
62
+ # In Python-MIP, it is allowed not to set the objective function.
63
+ # If it isn't set, the model behaves as if the objective function is set to 0.
64
+ # However, an error occurs when accessing `.objective`.
65
+ # So if an error occurs, treat the objective function as 0.
66
+ try:
67
+ objective = self.model.objective
68
+ except ParameterNotAvailable:
69
+ return Function(constant=0)
70
+
71
+ return self.as_ommx_function(objective)
72
+
73
+ def constraints(self) -> list[Constraint]:
74
+ constraints = []
75
+
76
+ for constr in self.model.constrs:
77
+ id = constr.idx
78
+ lin_expr = constr.expr
79
+ name = constr.name
80
+
81
+ if lin_expr.sense == "=":
82
+ constraint = Constraint(
83
+ id=id,
84
+ equality=Equality.EQUALITY_EQUAL_TO_ZERO,
85
+ function=self.as_ommx_function(lin_expr),
86
+ name=name,
87
+ )
88
+ elif lin_expr.sense == "<":
89
+ constraint = Constraint(
90
+ id=id,
91
+ equality=Equality.EQUALITY_LESS_THAN_OR_EQUAL_TO_ZERO,
92
+ function=self.as_ommx_function(lin_expr),
93
+ name=name,
94
+ )
95
+ elif lin_expr.sense == ">":
96
+ # `ommx.v1.Constraint` does not support `GREATER_THAN_OR_EQUAL_TO_ZERO`.
97
+ # So multiply the linear expression by -1.
98
+ constraint = Constraint(
99
+ id=id,
100
+ equality=Equality.EQUALITY_LESS_THAN_OR_EQUAL_TO_ZERO,
101
+ function=self.as_ommx_function(-lin_expr),
102
+ name=name,
103
+ )
104
+ else:
105
+ raise OMMXPythonMIPAdapterError(
106
+ f"Not supported constraint sense: "
107
+ f"name: {constr.name}, sense: {lin_expr.sense}"
108
+ )
109
+
110
+ constraints.append(constraint)
111
+
112
+ return constraints
113
+
114
+ def sense(self):
115
+ if self.model.sense == mip.MAXIMIZE:
116
+ return Instance.MAXIMIZE
117
+ elif self.model.sense == mip.MINIMIZE:
118
+ return Instance.MINIMIZE
119
+ raise OMMXPythonMIPAdapterError(f"Not supported sense: {self.model.sense}")
120
+
121
+ @final
122
+ def build(self) -> Instance:
123
+ return Instance.from_components(
124
+ decision_variables=self.decision_variables(),
125
+ objective=self.objective(),
126
+ constraints=self.constraints(),
127
+ sense=self.sense(),
128
+ )
129
+
130
+
131
+ def model_to_instance(model: mip.Model) -> Instance:
132
+ """
133
+ The function to convert Python-MIP Model to ommx.v1.Instance.
134
+
135
+ Examples
136
+ =========
137
+
138
+ .. doctest::
139
+ >>> import mip
140
+ >>> import ommx_python_mip_adapter as adapter
141
+
142
+ >>> model = mip.Model()
143
+ >>> x1=model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
144
+ >>> x2=model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
145
+
146
+ >>> model.objective = - x1 - 2 * x2
147
+ >>> constr = model.add_constr(x1 + x2 - 6 <= 0)
148
+
149
+ >>> ommx_instance = adapter.model_to_instance(model)
150
+ """
151
+ builder = OMMXInstanceBuilder(model)
152
+ return builder.build()
153
+
154
+
155
+ def model_to_solution(
156
+ model: mip.Model,
157
+ instance: Instance,
158
+ ) -> State:
159
+ """
160
+ The function to create ommx.v1.State from optimized Python-MIP Model.
161
+
162
+ Examples
163
+ =========
164
+
165
+ .. doctest::
166
+
167
+ The following example of solving an unconstrained linear optimization problem with x1 as the objective function.
168
+
169
+ >>> import ommx_python_mip_adapter as adapter
170
+ >>> from ommx.v1 import Instance, DecisionVariable
171
+
172
+ >>> x1 = DecisionVariable.integer(1, lower=0, upper=5)
173
+ >>> ommx_instance = Instance.from_components(
174
+ ... decision_variables=[x1],
175
+ ... objective=x1,
176
+ ... constraints=[],
177
+ ... sense=Instance.MINIMIZE,
178
+ ... )
179
+ >>> model = adapter.instance_to_model(ommx_instance)
180
+ >>> model.optimize()
181
+ <OptimizationStatus.OPTIMAL: 0>
182
+
183
+ >>> ommx_solutions = adapter.model_to_solution(model, ommx_instance)
184
+ >>> ommx_solutions.entries
185
+ {1: 0.0}
186
+ """
187
+ if not (
188
+ model.status == mip.OptimizationStatus.OPTIMAL
189
+ or model.status == mip.OptimizationStatus.FEASIBLE
190
+ ):
191
+ raise OMMXPythonMIPAdapterError(
192
+ "`model.status` must be `OPTIMAL` or `FEASIBLE`."
193
+ )
194
+
195
+ return State(
196
+ entries={
197
+ var.id: model.var_by_name(str(var.id)).x # type: ignore
198
+ for var in instance.raw.decision_variables
199
+ }
200
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ommx_python_mip_adapter
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: An adapter for the Python-MIP from/to OMMX.
5
5
  Author-email: "Jij Inc." <info@j-ij.com>
6
6
  Project-URL: Repository, https://github.com/Jij-Inc/ommx-python-mip-adapter
@@ -15,7 +15,7 @@ Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: ommx<0.5.0,>=0.4.0
18
+ Requires-Dist: ommx<0.6.0,>=0.5.0
19
19
  Requires-Dist: mip
20
20
  Provides-Extra: dev
21
21
  Requires-Dist: markdown-code-runner; extra == "dev"
@@ -1,8 +1,9 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  ommx_python_mip_adapter/__init__.py
4
- ommx_python_mip_adapter/adapter.py
5
4
  ommx_python_mip_adapter/exception.py
5
+ ommx_python_mip_adapter/ommx_to_python_mip.py
6
+ ommx_python_mip_adapter/python_mip_to_ommx.py
6
7
  ommx_python_mip_adapter.egg-info/PKG-INFO
7
8
  ommx_python_mip_adapter.egg-info/SOURCES.txt
8
9
  ommx_python_mip_adapter.egg-info/dependency_links.txt
@@ -1,4 +1,4 @@
1
- ommx<0.5.0,>=0.4.0
1
+ ommx<0.6.0,>=0.5.0
2
2
  mip
3
3
 
4
4
  [dev]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ommx_python_mip_adapter"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
 
9
9
  description = "An adapter for the Python-MIP from/to OMMX."
10
10
  authors = [
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "License :: OSI Approved :: MIT License",
25
25
  ]
26
26
  dependencies = [
27
- "ommx >= 0.4.0, < 0.5.0",
27
+ "ommx >= 0.5.0, < 0.6.0",
28
28
  "mip",
29
29
  ]
30
30
 
@@ -41,7 +41,7 @@ def test_error_nonlinear_objective():
41
41
 
42
42
  with pytest.raises(OMMXPythonMIPAdapterError) as e:
43
43
  adapter.instance_to_model(ommx_instance)
44
- assert "The objective function must be" in str(e.value)
44
+ assert "The function must be either `constant` or `linear`." in str(e.value)
45
45
 
46
46
 
47
47
  def test_error_nonlinear_constraint():
@@ -65,7 +65,7 @@ def test_error_nonlinear_constraint():
65
65
 
66
66
  with pytest.raises(OMMXPythonMIPAdapterError) as e:
67
67
  adapter.instance_to_model(ommx_instance)
68
- assert "Only linear constraints are supported" in str(e.value)
68
+ assert "The function must be either `constant` or `linear`." in str(e.value)
69
69
 
70
70
 
71
71
  def test_error_not_supported_constraint_equality():
@@ -1,11 +0,0 @@
1
- from ommx_python_mip_adapter.adapter import (
2
- instance_to_model,
3
- model_to_instance,
4
- model_to_solution,
5
- )
6
-
7
- __all__ = [
8
- "instance_to_model",
9
- "model_to_instance",
10
- "model_to_solution",
11
- ]
@@ -1,362 +0,0 @@
1
- import typing as tp
2
-
3
- import mip
4
-
5
- from mip.exceptions import ParameterNotAvailable
6
- from ommx.v1.constraint_pb2 import Constraint, Equality
7
- from ommx.v1.function_pb2 import Function
8
- from ommx.v1.linear_pb2 import Linear
9
- from ommx.v1.solution_pb2 import State
10
- from ommx.v1 import Instance, DecisionVariable
11
-
12
- from ommx_python_mip_adapter.exception import OMMXPythonMIPAdapterError
13
-
14
-
15
- class PythonMIPBuilder:
16
- def __init__(
17
- self,
18
- instance: Instance,
19
- *,
20
- sense: str = mip.MINIMIZE,
21
- solver_name: str = mip.CBC,
22
- solver: tp.Optional[mip.Solver] = None,
23
- ):
24
- self._ommx_instance = instance.raw
25
- self._model = mip.Model(
26
- sense=sense,
27
- solver_name=solver_name,
28
- solver=solver,
29
- )
30
-
31
- def _set_decision_variables(self):
32
- for var in self._ommx_instance.decision_variables:
33
- if var.kind == DecisionVariable.BINARY:
34
- self._model.add_var(
35
- name=str(var.id),
36
- var_type=mip.BINARY,
37
- )
38
- elif var.kind == DecisionVariable.INTEGER:
39
- self._model.add_var(
40
- name=str(var.id),
41
- var_type=mip.INTEGER,
42
- lb=var.bound.lower, # type: ignore
43
- ub=var.bound.upper, # type: ignore
44
- )
45
- elif var.kind == DecisionVariable.CONTINUOUS:
46
- self._model.add_var(
47
- name=str(var.id),
48
- var_type=mip.CONTINUOUS,
49
- lb=var.bound.lower, # type: ignore
50
- ub=var.bound.upper, # type: ignore
51
- )
52
- else:
53
- raise OMMXPythonMIPAdapterError(
54
- f"Not supported decision variable kind: "
55
- f"id: {var.id}, kind: {var.kind}"
56
- )
57
-
58
- def _make_linear_expr(
59
- self,
60
- ommx_function: Function,
61
- ) -> mip.LinExpr:
62
- ommx_linear = ommx_function.linear
63
-
64
- return (
65
- mip.xsum(
66
- term.coefficient * self._model.vars[str(term.id)] # type: ignore
67
- for term in ommx_linear.terms
68
- )
69
- + ommx_linear.constant
70
- ) # type: ignore
71
-
72
- def _set_objective_function(self):
73
- ommx_objective = self._ommx_instance.objective
74
-
75
- if ommx_objective.HasField("constant"):
76
- self._model.objective = ommx_objective.constant # type: ignore
77
- elif ommx_objective.HasField("linear"):
78
- self._model.objective = self._make_linear_expr(ommx_objective)
79
- else:
80
- raise OMMXPythonMIPAdapterError(
81
- "The objective function must be either `constant` or `linear`."
82
- )
83
-
84
- def _set_constraints(self):
85
- ommx_constraints = self._ommx_instance.constraints
86
-
87
- for constraint in ommx_constraints:
88
- if not constraint.function.HasField("linear"):
89
- raise OMMXPythonMIPAdapterError(
90
- f"Only linear constraints are supported: "
91
- f"id: {constraint.id}, "
92
- f"type: {constraint.function.WhichOneof('function')}"
93
- )
94
-
95
- lin_expr = self._make_linear_expr(constraint.function)
96
-
97
- if constraint.equality == Equality.EQUALITY_EQUAL_TO_ZERO:
98
- constr_expr = lin_expr == 0
99
- elif constraint.equality == Equality.EQUALITY_LESS_THAN_OR_EQUAL_TO_ZERO:
100
- constr_expr = lin_expr <= 0 # type: ignore
101
- else:
102
- raise OMMXPythonMIPAdapterError(
103
- f"Not supported constraint equality: "
104
- f"id: {constraint.id}, equality: {constraint.equality}"
105
- )
106
-
107
- self._model.add_constr(constr_expr, name=str(constraint.id))
108
-
109
- def build(self) -> mip.Model:
110
- self._set_decision_variables()
111
- self._set_objective_function()
112
- self._set_constraints()
113
-
114
- return self._model
115
-
116
-
117
- class OMMXInstanceBuilder:
118
- def __init__(
119
- self,
120
- model: mip.Model,
121
- ):
122
- self._model = model
123
-
124
- def _decision_variables(self) -> tp.List[DecisionVariable]:
125
- decision_variables = []
126
-
127
- for var in self._model.vars:
128
- if var.var_type == mip.BINARY:
129
- kind = DecisionVariable.BINARY
130
- elif var.var_type == mip.INTEGER:
131
- kind = DecisionVariable.INTEGER
132
- elif var.var_type == mip.CONTINUOUS:
133
- kind = DecisionVariable.CONTINUOUS
134
- else:
135
- raise OMMXPythonMIPAdapterError(
136
- f"Not supported variable type. "
137
- f"idx: {var.idx} name: {var.name}, type: {var.var_type}"
138
- )
139
-
140
- decision_variables.append(
141
- DecisionVariable.of_type(
142
- kind, var.idx, lower=var.lb, upper=var.ub, name=var.name
143
- )
144
- )
145
-
146
- return decision_variables
147
-
148
- def _make_function_from_lin_expr(
149
- self,
150
- lin_expr: mip.LinExpr,
151
- ) -> Function:
152
- terms = [
153
- Linear.Term(id=var.idx, coefficient=coeff) # type: ignore
154
- for var, coeff in lin_expr.expr.items()
155
- ]
156
- constant: float = lin_expr.const # type: ignore
157
-
158
- # If the terms are empty, the function is a constant.
159
- if len(terms) == 0:
160
- return Function(constant=constant)
161
- else:
162
- return Function(linear=Linear(terms=terms, constant=constant))
163
-
164
- def _objective(self) -> Function:
165
- # In Python-MIP, it is allowed not to set the objective function.
166
- # If it isn't set, the model behaves as if the objective function is set to 0.
167
- # However, an error occurs when accessing `.objective`.
168
- # So if an error occurs, treat the objective function as 0.
169
- try:
170
- objective = self._model.objective
171
- except ParameterNotAvailable:
172
- return Function(constant=0)
173
-
174
- return self._make_function_from_lin_expr(objective)
175
-
176
- def _constraints(self) -> tp.List[Constraint]:
177
- constraints = []
178
-
179
- for constr in self._model.constrs:
180
- id = constr.idx
181
- lin_expr = constr.expr
182
- name = constr.name
183
-
184
- if lin_expr.sense == "=":
185
- constraint = Constraint(
186
- id=id,
187
- equality=Equality.EQUALITY_EQUAL_TO_ZERO,
188
- function=self._make_function_from_lin_expr(lin_expr),
189
- name=name,
190
- )
191
- elif lin_expr.sense == "<":
192
- constraint = Constraint(
193
- id=id,
194
- equality=Equality.EQUALITY_LESS_THAN_OR_EQUAL_TO_ZERO,
195
- function=self._make_function_from_lin_expr(lin_expr),
196
- name=name,
197
- )
198
- elif lin_expr.sense == ">":
199
- # `ommx.v1.Constraint` does not support `GREATER_THAN_OR_EQUAL_TO_ZERO`.
200
- # So multiply the linear expression by -1.
201
- constraint = Constraint(
202
- id=id,
203
- equality=Equality.EQUALITY_LESS_THAN_OR_EQUAL_TO_ZERO,
204
- function=self._make_function_from_lin_expr(-lin_expr),
205
- name=name,
206
- )
207
- else:
208
- raise OMMXPythonMIPAdapterError(
209
- f"Not supported constraint sense: "
210
- f"name: {constr.name}, sense: {lin_expr.sense}"
211
- )
212
-
213
- constraints.append(constraint)
214
-
215
- return constraints
216
-
217
- def _sense(self):
218
- if self._model.sense == mip.MAXIMIZE:
219
- return Instance.MAXIMIZE
220
- else:
221
- return Instance.MINIMIZE
222
-
223
- def build(self) -> Instance:
224
- return Instance.from_components(
225
- decision_variables=self._decision_variables(),
226
- objective=self._objective(),
227
- constraints=self._constraints(),
228
- sense=self._sense(),
229
- )
230
-
231
-
232
- def instance_to_model(
233
- instance: Instance,
234
- *,
235
- sense: str = mip.MINIMIZE,
236
- solver_name: str = mip.CBC,
237
- solver: tp.Optional[mip.Solver] = None,
238
- ) -> mip.Model:
239
- """
240
- The function to convert ommx.v1.Instance to Python-MIP Model.
241
-
242
- Args:
243
- ommx_instance_bytes (bytes): Serialized ommx.v1.Instance.
244
- sense (str): mip.MINIMIZE or mip.MAXIMIZE.
245
- solver_name (str): mip.CBC or mip.GUROBI. Searches for which solver is available if not informed.
246
- solver (mip.Solver): if this argument is provided, solver_name will be ignored.
247
-
248
- Returns:
249
- mip.Model: Python-MIP Model converted from ommx.v1.Instance.
250
-
251
- Raises:
252
- OMMXPythonMIPAdapterError: If converting is not possible.
253
-
254
- Examples:
255
- The following example of solving an unconstrained linear optimization problem with x1 as the objective function.
256
-
257
- >>> import ommx_python_mip_adapter as adapter
258
- >>> from ommx.v1 import Instance, DecisionVariable
259
-
260
- >>> x1 = DecisionVariable.integer(1, lower=0, upper=5)
261
- >>> ommx_instance = Instance.from_components(
262
- ... decision_variables=[x1],
263
- ... objective=x1,
264
- ... constraints=[],
265
- ... sense=Instance.MINIMIZE,
266
- ... )
267
- >>> model = adapter.instance_to_model(ommx_instance)
268
- >>> model.optimize()
269
- <OptimizationStatus.OPTIMAL: 0>
270
-
271
- >>> ommx_solutions = adapter.model_to_solution(model, ommx_instance)
272
- >>> ommx_solutions.entries
273
- {1: 0.0}
274
- """
275
- builder = PythonMIPBuilder(
276
- instance,
277
- sense=sense,
278
- solver_name=solver_name,
279
- solver=solver,
280
- )
281
- return builder.build()
282
-
283
-
284
- def model_to_instance(model: mip.Model) -> Instance:
285
- """
286
- The function to convert Python-MIP Model to ommx.v1.Instance.
287
-
288
- Args:
289
- model (mip.Model): Python-MIP Model.
290
-
291
- Returns:
292
- bytes: Serialized ommx.v1.Instance.
293
-
294
- Raises:
295
- OMMXPythonMIPAdapterError: If converting is not possible.
296
-
297
- Examples:
298
- >>> import mip
299
- >>> import ommx_python_mip_adapter as adapter
300
- >>> model = mip.Model()
301
- >>> x1=model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
302
- >>> x2=model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
303
- >>> model.objective = - x1 - 2 * x2
304
- >>> constr = model.add_constr(x1 + x2 - 6 <= 0)
305
- >>> ommx_instance = adapter.model_to_instance(model)
306
- """
307
- builder = OMMXInstanceBuilder(model)
308
- return builder.build()
309
-
310
-
311
- def model_to_solution(
312
- model: mip.Model,
313
- instance: Instance,
314
- ) -> State:
315
- """
316
- The function to create ommx.v1.SolutionList from optimized Python-MIP Model.
317
-
318
- Args:
319
- model (mip.Model): Optimized Python-MIP Model.
320
- ommx_instance_bytes (bytes): Serialized ommx.v1.Instance.
321
-
322
- Returns:
323
- bytes: Serialized ommx.v1.SolutionList
324
-
325
- Raises:
326
- OMMXPythonMIPAdapterError: When ommx.v1.SolutionList cannot be created.
327
-
328
- Examples:
329
- The following example of solving an unconstrained linear optimization problem with x1 as the objective function.
330
-
331
- >>> import ommx_python_mip_adapter as adapter
332
- >>> from ommx.v1 import Instance, DecisionVariable
333
-
334
- >>> x1 = DecisionVariable.integer(1, lower=0, upper=5)
335
- >>> ommx_instance = Instance.from_components(
336
- ... decision_variables=[x1],
337
- ... objective=x1,
338
- ... constraints=[],
339
- ... sense=Instance.MINIMIZE,
340
- ... )
341
- >>> model = adapter.instance_to_model(ommx_instance)
342
- >>> model.optimize()
343
- <OptimizationStatus.OPTIMAL: 0>
344
-
345
- >>> ommx_solutions = adapter.model_to_solution(model, ommx_instance)
346
- >>> ommx_solutions.entries
347
- {1: 0.0}
348
- """
349
- if not (
350
- model.status == mip.OptimizationStatus.OPTIMAL
351
- or model.status == mip.OptimizationStatus.FEASIBLE
352
- ):
353
- raise OMMXPythonMIPAdapterError(
354
- "`model.status` must be `OPTIMAL` or `FEASIBLE`."
355
- )
356
-
357
- return State(
358
- entries={
359
- var.id: model.var_by_name(str(var.id)).x # type: ignore
360
- for var in instance.raw.decision_variables
361
- }
362
- )