pyoframe 0.0.11__py3-none-any.whl → 0.1.1__py3-none-any.whl
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.
- pyoframe/__init__.py +3 -4
- pyoframe/_arithmetic.py +170 -4
- pyoframe/constants.py +43 -199
- pyoframe/core.py +746 -421
- pyoframe/model.py +260 -28
- pyoframe/model_element.py +43 -84
- pyoframe/monkey_patch.py +3 -3
- pyoframe/objective.py +81 -31
- pyoframe/util.py +157 -19
- {pyoframe-0.0.11.dist-info → pyoframe-0.1.1.dist-info}/LICENSE +0 -2
- {pyoframe-0.0.11.dist-info → pyoframe-0.1.1.dist-info}/METADATA +29 -27
- pyoframe-0.1.1.dist-info/RECORD +14 -0
- {pyoframe-0.0.11.dist-info → pyoframe-0.1.1.dist-info}/WHEEL +1 -1
- pyoframe/io.py +0 -252
- pyoframe/io_mappers.py +0 -238
- pyoframe/solvers.py +0 -377
- pyoframe/user_defined.py +0 -60
- pyoframe-0.0.11.dist-info/RECORD +0 -18
- {pyoframe-0.0.11.dist-info → pyoframe-0.1.1.dist-info}/top_level.txt +0 -0
pyoframe/model.py
CHANGED
|
@@ -1,27 +1,56 @@
|
|
|
1
|
-
from
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import polars as pl
|
|
6
|
+
import pyoptinterface as poi
|
|
7
|
+
|
|
2
8
|
from pyoframe.constants import (
|
|
3
|
-
|
|
4
|
-
|
|
9
|
+
CONST_TERM,
|
|
10
|
+
SUPPORTED_SOLVER_TYPES,
|
|
5
11
|
Config,
|
|
6
|
-
|
|
7
|
-
PyoframeError,
|
|
12
|
+
ObjSense,
|
|
8
13
|
ObjSenseValue,
|
|
14
|
+
PyoframeError,
|
|
15
|
+
VType,
|
|
9
16
|
)
|
|
10
|
-
from pyoframe.
|
|
17
|
+
from pyoframe.core import Constraint, Variable
|
|
11
18
|
from pyoframe.model_element import ModelElement, ModelElementWithId
|
|
12
|
-
from pyoframe.core import Constraint
|
|
13
19
|
from pyoframe.objective import Objective
|
|
14
|
-
from pyoframe.
|
|
15
|
-
from pyoframe.core import Variable
|
|
16
|
-
from pyoframe.io import to_file
|
|
17
|
-
from pyoframe.solvers import solve, Solver
|
|
18
|
-
import polars as pl
|
|
19
|
-
import pandas as pd
|
|
20
|
+
from pyoframe.util import Container, NamedVariableMapper, for_solvers, get_obj_repr
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
class Model
|
|
23
|
+
class Model:
|
|
23
24
|
"""
|
|
24
|
-
|
|
25
|
+
The object that holds all the variables, constraints, and the objective.
|
|
26
|
+
|
|
27
|
+
Parameters:
|
|
28
|
+
name:
|
|
29
|
+
The name of the model. Currently it is not used for much.
|
|
30
|
+
solver:
|
|
31
|
+
The solver to use. If `None`, `Config.default_solver` will be used.
|
|
32
|
+
If `Config.default_solver` has not been set (`None`), Pyoframe will try to detect whichever solver is already installed.
|
|
33
|
+
solver_env:
|
|
34
|
+
Gurobi only: a dictionary of parameters to set when creating the Gurobi environment.
|
|
35
|
+
use_var_names:
|
|
36
|
+
Whether to pass variable names to the solver. Set to `True` if you'd like outputs from e.g. `Model.write()` to be legible.
|
|
37
|
+
sense:
|
|
38
|
+
Either "min" or "max". Indicates whether it's a minmization or maximization problem.
|
|
39
|
+
Typically, this parameter can be omitted (`None`) as it will automatically be
|
|
40
|
+
set when the objective is set using `.minimize` or `.maximize`.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> m = pf.Model()
|
|
44
|
+
>>> m.X = pf.Variable()
|
|
45
|
+
>>> m.my_constraint = m.X <= 10
|
|
46
|
+
>>> m
|
|
47
|
+
<Model vars=1 constrs=1 objective=False>
|
|
48
|
+
|
|
49
|
+
Try setting the Gurobi license:
|
|
50
|
+
>>> m = pf.Model(solver="gurobi", solver_env=dict(ComputeServer="myserver", ServerPassword="mypassword"))
|
|
51
|
+
Traceback (most recent call last):
|
|
52
|
+
...
|
|
53
|
+
RuntimeError: Could not resolve host: myserver (code 6, command POST http://myserver/api/v1/cluster/jobs)
|
|
25
54
|
"""
|
|
26
55
|
|
|
27
56
|
_reserved_attributes = [
|
|
@@ -32,29 +61,89 @@ class Model(AttrContainerMixin):
|
|
|
32
61
|
"io_mappers",
|
|
33
62
|
"name",
|
|
34
63
|
"solver",
|
|
35
|
-
"
|
|
64
|
+
"poi",
|
|
36
65
|
"params",
|
|
37
66
|
"result",
|
|
38
67
|
"attr",
|
|
39
68
|
"sense",
|
|
40
69
|
"objective",
|
|
70
|
+
"_use_var_names",
|
|
71
|
+
"ONE",
|
|
72
|
+
"solver_name",
|
|
73
|
+
"minimize",
|
|
74
|
+
"maximize",
|
|
41
75
|
]
|
|
42
76
|
|
|
43
|
-
def __init__(
|
|
44
|
-
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
name=None,
|
|
80
|
+
solver: Optional[SUPPORTED_SOLVER_TYPES] = None,
|
|
81
|
+
solver_env: Optional[Dict[str, str]] = None,
|
|
82
|
+
use_var_names=False,
|
|
83
|
+
sense: Union[ObjSense, ObjSenseValue, None] = None,
|
|
84
|
+
):
|
|
85
|
+
self.poi, self.solver_name = Model.create_poi_model(solver, solver_env)
|
|
45
86
|
self._variables: List[Variable] = []
|
|
46
87
|
self._constraints: List[Constraint] = []
|
|
47
|
-
self.sense = ObjSense(
|
|
88
|
+
self.sense = ObjSense(sense) if sense is not None else None
|
|
48
89
|
self._objective: Optional[Objective] = None
|
|
49
90
|
self.var_map = (
|
|
50
91
|
NamedVariableMapper(Variable) if Config.print_uses_variable_names else None
|
|
51
92
|
)
|
|
52
|
-
self.io_mappers: Optional[IOMappers] = None
|
|
53
93
|
self.name = name
|
|
54
|
-
|
|
55
|
-
self.
|
|
56
|
-
self.
|
|
57
|
-
self.
|
|
94
|
+
|
|
95
|
+
self.params = Container(self._set_param, self._get_param)
|
|
96
|
+
self.attr = Container(self._set_attr, self._get_attr)
|
|
97
|
+
self._use_var_names = use_var_names
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def use_var_names(self):
|
|
101
|
+
return self._use_var_names
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def create_poi_model(
|
|
105
|
+
cls, solver: Optional[str], solver_env: Optional[Dict[str, str]]
|
|
106
|
+
):
|
|
107
|
+
if solver is None:
|
|
108
|
+
if Config.default_solver is None:
|
|
109
|
+
for solver_option in ["highs", "gurobi"]:
|
|
110
|
+
try:
|
|
111
|
+
return cls.create_poi_model(solver_option, solver_env)
|
|
112
|
+
except RuntimeError:
|
|
113
|
+
pass
|
|
114
|
+
raise ValueError(
|
|
115
|
+
'Could not automatically find a solver. Is one installed? If so, specify which one: e.g. Model(solver="gurobi")'
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
solver = Config.default_solver
|
|
119
|
+
|
|
120
|
+
solver = solver.lower()
|
|
121
|
+
if solver == "gurobi":
|
|
122
|
+
from pyoptinterface import gurobi
|
|
123
|
+
|
|
124
|
+
if solver_env is None:
|
|
125
|
+
model = gurobi.Model()
|
|
126
|
+
else:
|
|
127
|
+
env = gurobi.Env(empty=True)
|
|
128
|
+
for key, value in solver_env.items():
|
|
129
|
+
env.set_raw_parameter(key, value)
|
|
130
|
+
env.start()
|
|
131
|
+
model = gurobi.Model(env)
|
|
132
|
+
elif solver == "highs":
|
|
133
|
+
from pyoptinterface import highs
|
|
134
|
+
|
|
135
|
+
model = highs.Model()
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Solver {solver} not recognized or supported."
|
|
139
|
+
) # pragma: no cover
|
|
140
|
+
|
|
141
|
+
constant_var = model.add_variable(lb=1, ub=1, name="ONE")
|
|
142
|
+
if constant_var.index != CONST_TERM:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"The first variable should have index 0."
|
|
145
|
+
) # pragma: no cover
|
|
146
|
+
return model, solver
|
|
58
147
|
|
|
59
148
|
@property
|
|
60
149
|
def variables(self) -> List[Variable]:
|
|
@@ -62,10 +151,26 @@ class Model(AttrContainerMixin):
|
|
|
62
151
|
|
|
63
152
|
@property
|
|
64
153
|
def binary_variables(self) -> Iterable[Variable]:
|
|
154
|
+
"""
|
|
155
|
+
Examples:
|
|
156
|
+
>>> m = pf.Model()
|
|
157
|
+
>>> m.X = pf.Variable(vtype=pf.VType.BINARY)
|
|
158
|
+
>>> m.Y = pf.Variable()
|
|
159
|
+
>>> len(list(m.binary_variables))
|
|
160
|
+
1
|
|
161
|
+
"""
|
|
65
162
|
return (v for v in self.variables if v.vtype == VType.BINARY)
|
|
66
163
|
|
|
67
164
|
@property
|
|
68
165
|
def integer_variables(self) -> Iterable[Variable]:
|
|
166
|
+
"""
|
|
167
|
+
Examples:
|
|
168
|
+
>>> m = pf.Model()
|
|
169
|
+
>>> m.X = pf.Variable(vtype=pf.VType.INTEGER)
|
|
170
|
+
>>> m.Y = pf.Variable()
|
|
171
|
+
>>> len(list(m.integer_variables))
|
|
172
|
+
1
|
|
173
|
+
"""
|
|
69
174
|
return (v for v in self.variables if v.vtype == VType.INTEGER)
|
|
70
175
|
|
|
71
176
|
@property
|
|
@@ -78,10 +183,43 @@ class Model(AttrContainerMixin):
|
|
|
78
183
|
|
|
79
184
|
@objective.setter
|
|
80
185
|
def objective(self, value):
|
|
81
|
-
|
|
186
|
+
if self._objective is not None and (
|
|
187
|
+
not isinstance(value, Objective) or not value._constructive
|
|
188
|
+
):
|
|
189
|
+
raise ValueError("An objective already exists. Use += or -= to modify it.")
|
|
190
|
+
if not isinstance(value, Objective):
|
|
191
|
+
value = Objective(value)
|
|
82
192
|
self._objective = value
|
|
83
193
|
value.on_add_to_model(self, "objective")
|
|
84
194
|
|
|
195
|
+
@property
|
|
196
|
+
def minimize(self):
|
|
197
|
+
if self.sense != ObjSense.MIN:
|
|
198
|
+
raise ValueError("Can't get .minimize in a maximization problem.")
|
|
199
|
+
return self._objective
|
|
200
|
+
|
|
201
|
+
@minimize.setter
|
|
202
|
+
def minimize(self, value):
|
|
203
|
+
if self.sense is None:
|
|
204
|
+
self.sense = ObjSense.MIN
|
|
205
|
+
if self.sense != ObjSense.MIN:
|
|
206
|
+
raise ValueError("Can't set .minimize in a maximization problem.")
|
|
207
|
+
self.objective = value
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def maximize(self):
|
|
211
|
+
if self.sense != ObjSense.MAX:
|
|
212
|
+
raise ValueError("Can't get .maximize in a minimization problem.")
|
|
213
|
+
return self._objective
|
|
214
|
+
|
|
215
|
+
@maximize.setter
|
|
216
|
+
def maximize(self, value):
|
|
217
|
+
if self.sense is None:
|
|
218
|
+
self.sense = ObjSense.MAX
|
|
219
|
+
if self.sense != ObjSense.MAX:
|
|
220
|
+
raise ValueError("Can't set .maximize in a minimization problem.")
|
|
221
|
+
self.objective = value
|
|
222
|
+
|
|
85
223
|
def __setattr__(self, __name: str, __value: Any) -> None:
|
|
86
224
|
if __name not in Model._reserved_attributes and not isinstance(
|
|
87
225
|
__value, (ModelElement, pl.DataFrame, pd.DataFrame)
|
|
@@ -110,7 +248,101 @@ class Model(AttrContainerMixin):
|
|
|
110
248
|
return super().__setattr__(__name, __value)
|
|
111
249
|
|
|
112
250
|
def __repr__(self) -> str:
|
|
113
|
-
return
|
|
251
|
+
return get_obj_repr(
|
|
252
|
+
self,
|
|
253
|
+
name=self.name,
|
|
254
|
+
vars=len(self.variables),
|
|
255
|
+
constrs=len(self.constraints),
|
|
256
|
+
objective=bool(self.objective),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def write(self, file_path: Union[Path, str]):
|
|
260
|
+
file_path = Path(file_path)
|
|
261
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
self.poi.write(str(file_path))
|
|
263
|
+
|
|
264
|
+
def optimize(self):
|
|
265
|
+
self.poi.optimize()
|
|
266
|
+
|
|
267
|
+
@for_solvers("gurobi")
|
|
268
|
+
def convert_to_fixed(self) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Turns a mixed integer program into a continuous one by fixing
|
|
271
|
+
all the integer and binary variables to their solution values.
|
|
272
|
+
|
|
273
|
+
!!! warning "Gurobi only"
|
|
274
|
+
This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
>>> m = pf.Model(solver="gurobi")
|
|
278
|
+
>>> m.X = pf.Variable(vtype=pf.VType.BINARY, lb=0)
|
|
279
|
+
>>> m.Y = pf.Variable(vtype=pf.VType.INTEGER, lb=0)
|
|
280
|
+
>>> m.Z = pf.Variable(lb=0)
|
|
281
|
+
>>> m.my_constraint = m.X + m.Y + m.Z <= 10
|
|
282
|
+
>>> m.maximize = 3 * m.X + 2 * m.Y + m.Z
|
|
283
|
+
>>> m.optimize()
|
|
284
|
+
>>> m.X.solution, m.Y.solution, m.Z.solution
|
|
285
|
+
(1.0, 9.0, 0.0)
|
|
286
|
+
>>> m.my_constraint.dual
|
|
287
|
+
Traceback (most recent call last):
|
|
288
|
+
...
|
|
289
|
+
polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'Pi'
|
|
290
|
+
>>> m.convert_to_fixed()
|
|
291
|
+
>>> m.optimize()
|
|
292
|
+
>>> m.my_constraint.dual
|
|
293
|
+
1.0
|
|
294
|
+
|
|
295
|
+
Only works for Gurobi:
|
|
296
|
+
|
|
297
|
+
>>> m = pf.Model("max", solver="highs")
|
|
298
|
+
>>> m.convert_to_fixed()
|
|
299
|
+
Traceback (most recent call last):
|
|
300
|
+
...
|
|
301
|
+
NotImplementedError: Method 'convert_to_fixed' is not implemented for solver 'highs'.
|
|
302
|
+
"""
|
|
303
|
+
self.poi._converttofixed()
|
|
304
|
+
|
|
305
|
+
@for_solvers("gurobi", "copt")
|
|
306
|
+
def compute_IIS(self):
|
|
307
|
+
"""
|
|
308
|
+
Computes the Irreducible Infeasible Set (IIS) of the model.
|
|
309
|
+
|
|
310
|
+
!!! warning "Gurobi only"
|
|
311
|
+
This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
>>> m = pf.Model(solver="gurobi")
|
|
315
|
+
>>> m.X = pf.Variable(lb=0, ub=2)
|
|
316
|
+
>>> m.Y = pf.Variable(lb=0, ub=2)
|
|
317
|
+
>>> m.bad_constraint = m.X >= 3
|
|
318
|
+
>>> m.minimize = m.X + m.Y
|
|
319
|
+
>>> m.optimize()
|
|
320
|
+
>>> m.attr.TerminationStatus
|
|
321
|
+
<TerminationStatusCode.INFEASIBLE: 3>
|
|
322
|
+
>>> m.bad_constraint.attr.IIS
|
|
323
|
+
Traceback (most recent call last):
|
|
324
|
+
...
|
|
325
|
+
polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'IISConstr'
|
|
326
|
+
>>> m.compute_IIS()
|
|
327
|
+
>>> m.bad_constraint.attr.IIS
|
|
328
|
+
True
|
|
329
|
+
"""
|
|
330
|
+
self.poi.computeIIS()
|
|
331
|
+
|
|
332
|
+
def _set_param(self, name, value):
|
|
333
|
+
self.poi.set_raw_parameter(name, value)
|
|
334
|
+
|
|
335
|
+
def _get_param(self, name):
|
|
336
|
+
return self.poi.get_raw_parameter(name)
|
|
337
|
+
|
|
338
|
+
def _set_attr(self, name, value):
|
|
339
|
+
try:
|
|
340
|
+
self.poi.set_model_attribute(poi.ModelAttribute[name], value)
|
|
341
|
+
except KeyError:
|
|
342
|
+
self.poi.set_model_raw_attribute(name, value)
|
|
114
343
|
|
|
115
|
-
|
|
116
|
-
|
|
344
|
+
def _get_attr(self, name):
|
|
345
|
+
try:
|
|
346
|
+
return self.poi.get_model_attribute(poi.ModelAttribute[name])
|
|
347
|
+
except KeyError:
|
|
348
|
+
return self.poi.get_model_raw_attribute(name)
|
pyoframe/model_element.py
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from abc import ABC, abstractmethod
|
|
3
4
|
from collections import defaultdict
|
|
4
|
-
from typing import Any, Dict, List, Optional
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
6
|
+
|
|
5
7
|
import polars as pl
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
|
-
from pyoframe.constants import COEF_KEY, RESERVED_COL_KEYS, VAR_KEY
|
|
9
9
|
from pyoframe._arithmetic import _get_dimensions
|
|
10
|
-
from pyoframe.
|
|
10
|
+
from pyoframe.constants import (
|
|
11
|
+
COEF_KEY,
|
|
12
|
+
KEY_TYPE,
|
|
13
|
+
QUAD_VAR_KEY,
|
|
14
|
+
RESERVED_COL_KEYS,
|
|
15
|
+
VAR_KEY,
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
if TYPE_CHECKING: # pragma: no cover
|
|
13
19
|
from pyoframe.model import Model
|
|
@@ -32,7 +38,9 @@ class ModelElement(ABC):
|
|
|
32
38
|
if COEF_KEY in data.columns:
|
|
33
39
|
data = data.cast({COEF_KEY: pl.Float64})
|
|
34
40
|
if VAR_KEY in data.columns:
|
|
35
|
-
data = data.cast({VAR_KEY:
|
|
41
|
+
data = data.cast({VAR_KEY: KEY_TYPE})
|
|
42
|
+
if QUAD_VAR_KEY in data.columns:
|
|
43
|
+
data = data.cast({QUAD_VAR_KEY: KEY_TYPE})
|
|
36
44
|
|
|
37
45
|
self._data = data
|
|
38
46
|
self._model: Optional[Model] = None
|
|
@@ -57,12 +65,11 @@ class ModelElement(ABC):
|
|
|
57
65
|
The names of the data's dimensions.
|
|
58
66
|
|
|
59
67
|
Examples:
|
|
60
|
-
>>> from pyoframe.core import Variable
|
|
61
68
|
>>> # A variable with no dimensions
|
|
62
|
-
>>> Variable().dimensions
|
|
69
|
+
>>> pf.Variable().dimensions
|
|
63
70
|
|
|
64
71
|
>>> # A variable with dimensions of "hour" and "city"
|
|
65
|
-
>>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
|
|
72
|
+
>>> pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
|
|
66
73
|
['hour', 'city']
|
|
67
74
|
"""
|
|
68
75
|
return _get_dimensions(self.data)
|
|
@@ -84,12 +91,11 @@ class ModelElement(ABC):
|
|
|
84
91
|
The number of indices in each dimension.
|
|
85
92
|
|
|
86
93
|
Examples:
|
|
87
|
-
>>> from pyoframe.core import Variable
|
|
88
94
|
>>> # A variable with no dimensions
|
|
89
|
-
>>> Variable().shape
|
|
95
|
+
>>> pf.Variable().shape
|
|
90
96
|
{}
|
|
91
97
|
>>> # A variable with dimensions of "hour" and "city"
|
|
92
|
-
>>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
|
|
98
|
+
>>> pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
|
|
93
99
|
{'hour': 4, 'city': 3}
|
|
94
100
|
"""
|
|
95
101
|
dims = self.dimensions
|
|
@@ -135,48 +141,41 @@ class SupportPolarsMethodMixin(ABC):
|
|
|
135
141
|
@abstractmethod
|
|
136
142
|
def data(self): ...
|
|
137
143
|
|
|
144
|
+
def pick(self, **kwargs):
|
|
145
|
+
"""
|
|
146
|
+
Filters elements by the given criteria and then drops the filtered dimensions.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
>>> m = pf.Model()
|
|
150
|
+
>>> m.v = pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}])
|
|
151
|
+
>>> m.v.pick(hour="06:00")
|
|
152
|
+
<Expression size=3 dimensions={'city': 3} terms=3>
|
|
153
|
+
[Toronto]: v[06:00,Toronto]
|
|
154
|
+
[Berlin]: v[06:00,Berlin]
|
|
155
|
+
[Paris]: v[06:00,Paris]
|
|
156
|
+
>>> m.v.pick(hour="06:00", city="Toronto")
|
|
157
|
+
<Expression size=1 dimensions={} terms=1>
|
|
158
|
+
v[06:00,Toronto]
|
|
159
|
+
"""
|
|
160
|
+
return self._new(self.data.filter(**kwargs).drop(kwargs.keys()))
|
|
161
|
+
|
|
138
162
|
|
|
139
|
-
class ModelElementWithId(ModelElement
|
|
163
|
+
class ModelElementWithId(ModelElement):
|
|
140
164
|
"""
|
|
141
165
|
Provides a method that assigns a unique ID to each row in a DataFrame.
|
|
142
166
|
IDs start at 1 and go up consecutively. No zero ID is assigned since it is reserved for the constant variable term.
|
|
143
167
|
IDs are only unique for the subclass since different subclasses have different counters.
|
|
144
168
|
"""
|
|
145
169
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@classmethod
|
|
150
|
-
def reset_counters(cls):
|
|
151
|
-
"""
|
|
152
|
-
Resets all the ID counters.
|
|
153
|
-
This function is called before every unit test to reset the code state.
|
|
154
|
-
"""
|
|
155
|
-
cls._id_counters = defaultdict(lambda: 1)
|
|
156
|
-
|
|
157
|
-
def __init__(self, data: pl.DataFrame, **kwargs) -> None:
|
|
158
|
-
super().__init__(data, **kwargs)
|
|
159
|
-
self._data = self._assign_ids(self.data)
|
|
160
|
-
|
|
161
|
-
@classmethod
|
|
162
|
-
def _assign_ids(cls, df: pl.DataFrame) -> pl.DataFrame:
|
|
163
|
-
"""
|
|
164
|
-
Adds the column `to_column` to the DataFrame `df` with the next batch
|
|
165
|
-
of unique consecutive IDs.
|
|
166
|
-
"""
|
|
167
|
-
cls_name = cls.__name__
|
|
168
|
-
cur_count = cls._id_counters[cls_name]
|
|
169
|
-
id_col_name = cls.get_id_column_name()
|
|
170
|
+
@property
|
|
171
|
+
def _has_ids(self) -> bool:
|
|
172
|
+
return self.get_id_column_name() in self.data.columns
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
pl.int_range(cur_count, cur_count + pl.len()).alias(id_col_name)
|
|
174
|
+
def _assert_has_ids(self):
|
|
175
|
+
if not self._has_ids:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Cannot use '{self.__class__.__name__}' before it has beed added to a model."
|
|
176
178
|
)
|
|
177
|
-
df = df.with_columns(pl.col(id_col_name).cast(pl.UInt32))
|
|
178
|
-
cls._id_counters[cls_name] += df.height
|
|
179
|
-
return df
|
|
180
179
|
|
|
181
180
|
@classmethod
|
|
182
181
|
@abstractmethod
|
|
@@ -184,43 +183,3 @@ class ModelElementWithId(ModelElement, AttrContainerMixin):
|
|
|
184
183
|
"""
|
|
185
184
|
Returns the name of the column containing the IDs.
|
|
186
185
|
"""
|
|
187
|
-
|
|
188
|
-
@property
|
|
189
|
-
def ids(self) -> pl.DataFrame:
|
|
190
|
-
return self.data.select(self.dimensions_unsafe + [self.get_id_column_name()])
|
|
191
|
-
|
|
192
|
-
def _extend_dataframe_by_id(self, addition: pl.DataFrame):
|
|
193
|
-
cols = addition.columns
|
|
194
|
-
assert len(cols) == 2
|
|
195
|
-
id_col = self.get_id_column_name()
|
|
196
|
-
assert id_col in cols
|
|
197
|
-
cols.remove(id_col)
|
|
198
|
-
new_col = cols[0]
|
|
199
|
-
|
|
200
|
-
original = self.data
|
|
201
|
-
|
|
202
|
-
if new_col in original.columns:
|
|
203
|
-
original = original.drop(new_col)
|
|
204
|
-
self._data = original.join(addition, on=id_col, how="left", validate="1:1")
|
|
205
|
-
|
|
206
|
-
def _preprocess_attr(self, name: str, value: Any) -> Any:
|
|
207
|
-
dims = self.dimensions
|
|
208
|
-
ids = self.ids
|
|
209
|
-
id_col = self.get_id_column_name()
|
|
210
|
-
|
|
211
|
-
if isinstance(value, pl.DataFrame):
|
|
212
|
-
if value.shape == (1, 1):
|
|
213
|
-
value = value.item()
|
|
214
|
-
else:
|
|
215
|
-
assert (
|
|
216
|
-
dims is not None
|
|
217
|
-
), "Attribute must be a scalar since there are no dimensions"
|
|
218
|
-
result = value.join(ids, on=dims, validate="1:1", how="inner").drop(
|
|
219
|
-
dims
|
|
220
|
-
)
|
|
221
|
-
assert len(result.columns) == 2, "Attribute has too many columns"
|
|
222
|
-
value_col = [c for c in result.columns if c != id_col][0]
|
|
223
|
-
return result.rename({value_col: name})
|
|
224
|
-
|
|
225
|
-
assert ids.height == 1, "Attribute is a scalar but there are multiple IDs."
|
|
226
|
-
return pl.DataFrame({name: [value], id_col: ids.get_column(id_col)})
|
pyoframe/monkey_patch.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
1
3
|
import pandas as pd
|
|
2
4
|
import polars as pl
|
|
3
|
-
from pyoframe.core import SupportsMath
|
|
4
|
-
from pyoframe.core import Expression
|
|
5
|
-
from functools import wraps
|
|
6
5
|
|
|
7
6
|
from pyoframe.constants import COEF_KEY, CONST_TERM, VAR_KEY
|
|
7
|
+
from pyoframe.core import Expression, SupportsMath
|
|
8
8
|
|
|
9
9
|
# pyright: reportAttributeAccessIssue=false
|
|
10
10
|
|
pyoframe/objective.py
CHANGED
|
@@ -1,45 +1,95 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pyoptinterface as poi
|
|
4
|
+
|
|
5
|
+
from pyoframe.core import Expression, SupportsToExpr
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class Objective(Expression):
|
|
7
|
-
|
|
9
|
+
"""
|
|
8
10
|
Examples:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
>>> m
|
|
12
|
-
>>> m.
|
|
13
|
-
>>> m.
|
|
14
|
-
>>> m.
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
An `Objective` is automatically created when an `Expression` is assigned to `.minimize` or `.maximize`
|
|
12
|
+
|
|
13
|
+
>>> m = pf.Model()
|
|
14
|
+
>>> m.A, m.B = pf.Variable(lb=0), pf.Variable(lb=0)
|
|
15
|
+
>>> m.con = m.A + m.B <= 10
|
|
16
|
+
>>> m.maximize = 2 * m.B + 4
|
|
17
|
+
>>> m.maximize
|
|
18
|
+
<Objective size=1 dimensions={} terms=2>
|
|
19
|
+
objective: 2 B +4
|
|
20
|
+
|
|
21
|
+
The objective value can be retrieved with from the solver once the model is solved using `.value`.
|
|
22
|
+
|
|
23
|
+
>>> m.optimize()
|
|
24
|
+
>>> m.maximize.value
|
|
25
|
+
24.0
|
|
26
|
+
|
|
27
|
+
Objectives support `+=` and `-=` operators.
|
|
28
|
+
|
|
29
|
+
>>> m.maximize += 3 * m.A
|
|
30
|
+
>>> m.optimize()
|
|
31
|
+
>>> m.A.solution, m.B.solution
|
|
32
|
+
(10.0, 0.0)
|
|
33
|
+
>>> m.maximize -= 2 * m.A
|
|
34
|
+
>>> m.optimize()
|
|
35
|
+
>>> m.A.solution, m.B.solution
|
|
36
|
+
(0.0, 10.0)
|
|
37
|
+
|
|
38
|
+
Objectives cannot be created from dimensioned expressions since an objective must be a single expression.
|
|
39
|
+
|
|
40
|
+
>>> m = pf.Model()
|
|
41
|
+
>>> m.dimensioned_variable = pf.Variable({"city": ["Toronto", "Berlin", "Paris"]})
|
|
42
|
+
>>> m.maximize = m.dimensioned_variable
|
|
43
|
+
Traceback (most recent call last):
|
|
44
|
+
...
|
|
45
|
+
ValueError: Objective cannot be created from a dimensioned expression. Did you forget to use pf.sum()?
|
|
46
|
+
|
|
47
|
+
Objectives cannot be overwritten.
|
|
48
|
+
|
|
49
|
+
>>> m = pf.Model()
|
|
50
|
+
>>> m.A = pf.Variable(lb=0)
|
|
51
|
+
>>> m.maximize = 2 * m.A
|
|
52
|
+
>>> m.maximize = 3 * m.A
|
|
53
|
+
Traceback (most recent call last):
|
|
54
|
+
...
|
|
55
|
+
ValueError: An objective already exists. Use += or -= to modify it.
|
|
17
56
|
"""
|
|
18
57
|
|
|
19
|
-
def __init__(
|
|
20
|
-
expr =
|
|
58
|
+
def __init__(
|
|
59
|
+
self, expr: SupportsToExpr | int | float, _constructive: bool = False
|
|
60
|
+
) -> None:
|
|
61
|
+
self._constructive = _constructive
|
|
62
|
+
if isinstance(expr, (int, float)):
|
|
63
|
+
expr = Expression.constant(expr)
|
|
64
|
+
else:
|
|
65
|
+
expr = expr.to_expr()
|
|
21
66
|
super().__init__(expr.data)
|
|
22
67
|
self._model = expr._model
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
68
|
+
if self.dimensions is not None:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Objective cannot be created from a dimensioned expression. Did you forget to use pf.sum()?"
|
|
71
|
+
)
|
|
27
72
|
|
|
28
73
|
@property
|
|
29
|
-
def value(self):
|
|
30
|
-
|
|
74
|
+
def value(self) -> float:
|
|
75
|
+
"""
|
|
76
|
+
The value of the objective function (only available after solving the model).
|
|
77
|
+
|
|
78
|
+
This value is obtained by directly querying the solver.
|
|
79
|
+
"""
|
|
80
|
+
return self._model.poi.get_model_attribute(poi.ModelAttribute.ObjectiveValue)
|
|
81
|
+
|
|
82
|
+
def on_add_to_model(self, model, name):
|
|
83
|
+
super().on_add_to_model(model, name)
|
|
84
|
+
assert self._model is not None
|
|
85
|
+
if self._model.sense is None:
|
|
31
86
|
raise ValueError(
|
|
32
|
-
"
|
|
87
|
+
"Can't set an objective without specifying the sense. Did you use .objective instead of .minimize or .maximize ?"
|
|
33
88
|
)
|
|
34
|
-
|
|
89
|
+
self._model.poi.set_objective(self.to_poi(), sense=self._model.sense.to_poi())
|
|
35
90
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
self._value = value
|
|
91
|
+
def __iadd__(self, other):
|
|
92
|
+
return Objective(self + other, _constructive=True)
|
|
39
93
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
constant_terms = self.constant_terms
|
|
43
|
-
if len(constant_terms) == 0:
|
|
44
|
-
return False
|
|
45
|
-
return constant_terms.get_column(COEF_KEY).item() != 0
|
|
94
|
+
def __isub__(self, other):
|
|
95
|
+
return Objective(self - other, _constructive=True)
|