pyoframe 1.0.0a0__py3-none-any.whl → 1.1.0__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 +2 -0
- pyoframe/_arithmetic.py +179 -177
- pyoframe/_constants.py +103 -57
- pyoframe/_core.py +308 -204
- pyoframe/_model.py +49 -29
- pyoframe/_model_element.py +34 -18
- pyoframe/_monkey_patch.py +8 -50
- pyoframe/_objective.py +4 -6
- pyoframe/_param.py +99 -0
- pyoframe/_utils.py +10 -11
- pyoframe/_version.py +2 -2
- {pyoframe-1.0.0a0.dist-info → pyoframe-1.1.0.dist-info}/METADATA +13 -14
- pyoframe-1.1.0.dist-info/RECORD +16 -0
- pyoframe-1.0.0a0.dist-info/RECORD +0 -15
- {pyoframe-1.0.0a0.dist-info → pyoframe-1.1.0.dist-info}/WHEEL +0 -0
- {pyoframe-1.0.0a0.dist-info → pyoframe-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {pyoframe-1.0.0a0.dist-info → pyoframe-1.1.0.dist-info}/top_level.txt +0 -0
pyoframe/_model.py
CHANGED
|
@@ -20,8 +20,8 @@ from pyoframe._constants import (
|
|
|
20
20
|
VType,
|
|
21
21
|
_Solver,
|
|
22
22
|
)
|
|
23
|
-
from pyoframe._core import Constraint,
|
|
24
|
-
from pyoframe._model_element import
|
|
23
|
+
from pyoframe._core import Constraint, Operable, Variable
|
|
24
|
+
from pyoframe._model_element import BaseBlock
|
|
25
25
|
from pyoframe._objective import Objective
|
|
26
26
|
from pyoframe._utils import Container, NamedVariableMapper, for_solvers, get_obj_repr
|
|
27
27
|
|
|
@@ -76,7 +76,7 @@ class Model:
|
|
|
76
76
|
"_var_map",
|
|
77
77
|
"name",
|
|
78
78
|
"solver",
|
|
79
|
-
"
|
|
79
|
+
"_poi",
|
|
80
80
|
"_params",
|
|
81
81
|
"params",
|
|
82
82
|
"_attr",
|
|
@@ -99,21 +99,27 @@ class Model:
|
|
|
99
99
|
print_uses_variable_names: bool = True,
|
|
100
100
|
sense: ObjSense | ObjSenseValue | None = None,
|
|
101
101
|
):
|
|
102
|
-
self.
|
|
102
|
+
self._poi, self.solver = Model._create_poi_model(solver, solver_env)
|
|
103
103
|
self.solver_name: str = self.solver.name
|
|
104
104
|
self._variables: list[Variable] = []
|
|
105
105
|
self._constraints: list[Constraint] = []
|
|
106
106
|
self.sense: ObjSense | None = ObjSense(sense) if sense is not None else None
|
|
107
107
|
self._objective: Objective | None = None
|
|
108
|
-
self._var_map = (
|
|
109
|
-
NamedVariableMapper(Variable) if print_uses_variable_names else None
|
|
110
|
-
)
|
|
108
|
+
self._var_map = NamedVariableMapper() if print_uses_variable_names else None
|
|
111
109
|
self.name: str | None = name
|
|
112
110
|
|
|
113
111
|
self._params = Container(self._set_param, self._get_param)
|
|
114
112
|
self._attr = Container(self._set_attr, self._get_attr)
|
|
115
113
|
self._solver_uses_variable_names = solver_uses_variable_names
|
|
116
114
|
|
|
115
|
+
@property
|
|
116
|
+
def poi(self):
|
|
117
|
+
"""The underlying PyOptInterface model used to interact with the solver.
|
|
118
|
+
|
|
119
|
+
Modifying the underlying model directly is not recommended and may lead to unexpected behaviors.
|
|
120
|
+
"""
|
|
121
|
+
return self._poi
|
|
122
|
+
|
|
117
123
|
@property
|
|
118
124
|
def solver_uses_variable_names(self):
|
|
119
125
|
"""Whether to pass human-readable variable names to the solver."""
|
|
@@ -125,8 +131,9 @@ class Model:
|
|
|
125
131
|
|
|
126
132
|
Several model attributes are common across all solvers making it easy to switch between solvers (see supported attributes for
|
|
127
133
|
[Gurobi](https://metab0t.github.io/PyOptInterface/gurobi.html#supported-model-attribute),
|
|
128
|
-
[HiGHS](https://metab0t.github.io/PyOptInterface/highs.html),
|
|
129
|
-
[Ipopt](https://metab0t.github.io/PyOptInterface/ipopt.html))
|
|
134
|
+
[HiGHS](https://metab0t.github.io/PyOptInterface/highs.html),
|
|
135
|
+
[Ipopt](https://metab0t.github.io/PyOptInterface/ipopt.html)), and
|
|
136
|
+
[COPT](https://metab0t.github.io/PyOptInterface/copt.html).
|
|
130
137
|
|
|
131
138
|
We additionally support all of [Gurobi's attributes](https://docs.gurobi.com/projects/optimizer/en/current/reference/attributes.html#sec:Attributes) when using Gurobi.
|
|
132
139
|
|
|
@@ -161,7 +168,8 @@ class Model:
|
|
|
161
168
|
See the list of available parameters for
|
|
162
169
|
[Gurobi](https://docs.gurobi.com/projects/optimizer/en/current/reference/parameters.html#sec:Parameters),
|
|
163
170
|
[HiGHS](https://ergo-code.github.io/HiGHS/stable/options/definitions/),
|
|
164
|
-
|
|
171
|
+
[Ipopt](https://coin-or.github.io/Ipopt/OPTIONS.html),
|
|
172
|
+
and [COPT](https://guide.coap.online/copt/en-doc/parameter.html).
|
|
165
173
|
|
|
166
174
|
Examples:
|
|
167
175
|
For example, if you'd like to use Gurobi's barrier method, you can set the `Method` parameter:
|
|
@@ -226,7 +234,7 @@ class Model:
|
|
|
226
234
|
from pyoptinterface import ipopt
|
|
227
235
|
except ModuleNotFoundError as e: # pragma: no cover
|
|
228
236
|
raise ModuleNotFoundError(
|
|
229
|
-
"Failed to import the Ipopt solver. Did you run `pip install pyoptinterface[
|
|
237
|
+
"Failed to import the Ipopt solver. Did you run `pip install pyoptinterface[nlp]`?"
|
|
230
238
|
) from e
|
|
231
239
|
|
|
232
240
|
try:
|
|
@@ -237,6 +245,18 @@ class Model:
|
|
|
237
245
|
"Could not find the Ipopt solver. Are you sure you've properly installed it and added it to your PATH?"
|
|
238
246
|
) from e
|
|
239
247
|
raise e
|
|
248
|
+
elif solver.name == "copt":
|
|
249
|
+
from pyoptinterface import copt
|
|
250
|
+
|
|
251
|
+
if solver_env is None:
|
|
252
|
+
env = copt.Env()
|
|
253
|
+
else:
|
|
254
|
+
# COPT uses EnvConfig for configuration
|
|
255
|
+
env_config = copt.EnvConfig()
|
|
256
|
+
for key, value in solver_env.items():
|
|
257
|
+
env_config.set(key, value)
|
|
258
|
+
env = copt.Env(env_config)
|
|
259
|
+
model = copt.Model(env)
|
|
240
260
|
else:
|
|
241
261
|
raise ValueError(
|
|
242
262
|
f"Solver {solver} not recognized or supported."
|
|
@@ -315,7 +335,7 @@ class Model:
|
|
|
315
335
|
ValueError: Objective is not defined.
|
|
316
336
|
>>> m.maximize = m.X
|
|
317
337
|
>>> m.objective
|
|
318
|
-
<Objective terms=1
|
|
338
|
+
<Objective (linear) terms=1>
|
|
319
339
|
X
|
|
320
340
|
|
|
321
341
|
See Also:
|
|
@@ -326,7 +346,7 @@ class Model:
|
|
|
326
346
|
return self._objective
|
|
327
347
|
|
|
328
348
|
@objective.setter
|
|
329
|
-
def objective(self, value:
|
|
349
|
+
def objective(self, value: Operable):
|
|
330
350
|
if self.has_objective and (
|
|
331
351
|
not isinstance(value, Objective) or not value._constructive
|
|
332
352
|
):
|
|
@@ -344,7 +364,7 @@ class Model:
|
|
|
344
364
|
return self._objective
|
|
345
365
|
|
|
346
366
|
@minimize.setter
|
|
347
|
-
def minimize(self, value:
|
|
367
|
+
def minimize(self, value: Operable):
|
|
348
368
|
if self.sense is None:
|
|
349
369
|
self.sense = ObjSense.MIN
|
|
350
370
|
if self.sense != ObjSense.MIN:
|
|
@@ -359,7 +379,7 @@ class Model:
|
|
|
359
379
|
return self._objective
|
|
360
380
|
|
|
361
381
|
@maximize.setter
|
|
362
|
-
def maximize(self, value:
|
|
382
|
+
def maximize(self, value: Operable):
|
|
363
383
|
if self.sense is None:
|
|
364
384
|
self.sense = ObjSense.MAX
|
|
365
385
|
if self.sense != ObjSense.MAX:
|
|
@@ -368,17 +388,14 @@ class Model:
|
|
|
368
388
|
|
|
369
389
|
def __setattr__(self, __name: str, __value: Any) -> None:
|
|
370
390
|
if __name not in Model._reserved_attributes and not isinstance(
|
|
371
|
-
__value, (
|
|
391
|
+
__value, (BaseBlock, pl.DataFrame, pd.DataFrame)
|
|
372
392
|
):
|
|
373
393
|
raise PyoframeError(
|
|
374
|
-
f"Cannot set attribute '{__name}' on the model because it isn't of
|
|
394
|
+
f"Cannot set attribute '{__name}' on the model because it isn't a subtype of BaseBlock (e.g. Variable, Constraint, ...)"
|
|
375
395
|
)
|
|
376
396
|
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
and __name not in Model._reserved_attributes
|
|
380
|
-
):
|
|
381
|
-
if isinstance(__value, ModelElementWithId):
|
|
397
|
+
if isinstance(__value, BaseBlock) and __name not in Model._reserved_attributes:
|
|
398
|
+
if __value._get_id_column_name() is not None:
|
|
382
399
|
assert not hasattr(self, __name), (
|
|
383
400
|
f"Cannot create {__name} since it was already created."
|
|
384
401
|
)
|
|
@@ -400,7 +417,7 @@ class Model:
|
|
|
400
417
|
def __repr__(self) -> str:
|
|
401
418
|
return get_obj_repr(
|
|
402
419
|
self,
|
|
403
|
-
self.name,
|
|
420
|
+
f"'{self.name}'" if self.name is not None else None,
|
|
404
421
|
vars=len(self.variables),
|
|
405
422
|
constrs=len(self.constraints),
|
|
406
423
|
has_objective=self.has_objective,
|
|
@@ -431,7 +448,10 @@ class Model:
|
|
|
431
448
|
"""
|
|
432
449
|
if not self.solver.supports_write:
|
|
433
450
|
raise NotImplementedError(f"{self.solver.name} does not support .write()")
|
|
434
|
-
if
|
|
451
|
+
if (
|
|
452
|
+
not self.solver_uses_variable_names
|
|
453
|
+
and self.solver.accelerate_with_repeat_names
|
|
454
|
+
):
|
|
435
455
|
raise ValueError(
|
|
436
456
|
f"{self.solver.name} requires solver_uses_variable_names=True to use .write()"
|
|
437
457
|
)
|
|
@@ -488,10 +508,10 @@ class Model:
|
|
|
488
508
|
|
|
489
509
|
@for_solvers("gurobi", "copt")
|
|
490
510
|
def compute_IIS(self):
|
|
491
|
-
"""Gurobi only: Computes the Irreducible Infeasible Set (IIS) of the model.
|
|
511
|
+
"""Gurobi and COPT only: Computes the Irreducible Infeasible Set (IIS) of the model.
|
|
492
512
|
|
|
493
|
-
!!! warning "Gurobi only"
|
|
494
|
-
This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
|
|
513
|
+
!!! warning "Gurobi and COPT only"
|
|
514
|
+
This method only works with the Gurobi and COPT solver. Open an issue if you'd like to see support for other solvers.
|
|
495
515
|
|
|
496
516
|
Examples:
|
|
497
517
|
>>> m = pf.Model("gurobi")
|
|
@@ -548,7 +568,7 @@ class Model:
|
|
|
548
568
|
self.poi.set_raw_parameter(name, value)
|
|
549
569
|
except KeyError as e:
|
|
550
570
|
raise KeyError(
|
|
551
|
-
f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/learn/getting-started/solver-access/ for a list of valid parameters."
|
|
571
|
+
f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/latest/learn/getting-started/solver-access/ for a list of valid parameters."
|
|
552
572
|
) from e
|
|
553
573
|
|
|
554
574
|
def _get_param(self, name):
|
|
@@ -556,7 +576,7 @@ class Model:
|
|
|
556
576
|
return self.poi.get_raw_parameter(name)
|
|
557
577
|
except KeyError as e:
|
|
558
578
|
raise KeyError(
|
|
559
|
-
f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/learn/getting-started/solver-access/ for a list of valid parameters."
|
|
579
|
+
f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/latest/learn/getting-started/solver-access/ for a list of valid parameters."
|
|
560
580
|
) from e
|
|
561
581
|
|
|
562
582
|
def _set_attr(self, name, value):
|
pyoframe/_model_element.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from abc import ABC
|
|
5
|
+
from abc import ABC
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
import polars as pl
|
|
@@ -10,17 +10,17 @@ import polars as pl
|
|
|
10
10
|
from pyoframe._arithmetic import _get_dimensions
|
|
11
11
|
from pyoframe._constants import (
|
|
12
12
|
COEF_KEY,
|
|
13
|
-
KEY_TYPE,
|
|
14
13
|
QUAD_VAR_KEY,
|
|
15
14
|
RESERVED_COL_KEYS,
|
|
16
15
|
VAR_KEY,
|
|
16
|
+
Config,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING: # pragma: no cover
|
|
20
20
|
from pyoframe import Model
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
class
|
|
23
|
+
class BaseBlock(ABC):
|
|
24
24
|
"""The base class for elements of a Model such as [][pyoframe.Variable] and [][pyoframe.Constraint]."""
|
|
25
25
|
|
|
26
26
|
def __init__(self, data: pl.DataFrame, name="unnamed") -> None:
|
|
@@ -41,13 +41,15 @@ class ModelElement(ABC):
|
|
|
41
41
|
if COEF_KEY in data.columns:
|
|
42
42
|
data = data.cast({COEF_KEY: pl.Float64})
|
|
43
43
|
if VAR_KEY in data.columns:
|
|
44
|
-
data = data.cast({VAR_KEY:
|
|
44
|
+
data = data.cast({VAR_KEY: Config.id_dtype})
|
|
45
45
|
if QUAD_VAR_KEY in data.columns:
|
|
46
|
-
data = data.cast({QUAD_VAR_KEY:
|
|
46
|
+
data = data.cast({QUAD_VAR_KEY: Config.id_dtype})
|
|
47
47
|
|
|
48
48
|
self._data = data
|
|
49
49
|
self._model: Model | None = None
|
|
50
50
|
self.name: str = name # gets overwritten if object is added to model
|
|
51
|
+
"""A user-friendly name that is displayed when printing the object or in error messages.
|
|
52
|
+
When an object is added to a model, this name is updated to the name used in the model."""
|
|
51
53
|
|
|
52
54
|
def _on_add_to_model(self, model: Model, name: str):
|
|
53
55
|
self.name = name
|
|
@@ -77,6 +79,26 @@ class ModelElement(ABC):
|
|
|
77
79
|
"""
|
|
78
80
|
return _get_dimensions(self.data)
|
|
79
81
|
|
|
82
|
+
@property
|
|
83
|
+
def dimensionless(self) -> bool:
|
|
84
|
+
"""Whether the object has no dimensions.
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
A variable with no dimensions
|
|
88
|
+
>>> pf.Variable().dimensionless
|
|
89
|
+
True
|
|
90
|
+
|
|
91
|
+
A variable with dimensions of "hour" and "city"
|
|
92
|
+
>>> pf.Variable(
|
|
93
|
+
... [
|
|
94
|
+
... {"hour": ["00:00", "06:00", "12:00", "18:00"]},
|
|
95
|
+
... {"city": ["Toronto", "Berlin", "Paris"]},
|
|
96
|
+
... ]
|
|
97
|
+
... ).dimensionless
|
|
98
|
+
False
|
|
99
|
+
"""
|
|
100
|
+
return self.dimensions is None
|
|
101
|
+
|
|
80
102
|
@property
|
|
81
103
|
def _dimensions_unsafe(self) -> list[str]:
|
|
82
104
|
"""Same as `dimensions` but returns an empty list if there are no dimensions instead of `None`.
|
|
@@ -90,7 +112,7 @@ class ModelElement(ABC):
|
|
|
90
112
|
|
|
91
113
|
@property
|
|
92
114
|
def shape(self) -> dict[str, int]:
|
|
93
|
-
"""The number of
|
|
115
|
+
"""The number of distinct labels in each dimension.
|
|
94
116
|
|
|
95
117
|
Examples:
|
|
96
118
|
A variable with no dimensions
|
|
@@ -151,17 +173,11 @@ class ModelElement(ABC):
|
|
|
151
173
|
return 1
|
|
152
174
|
return self.data.select(dims).n_unique()
|
|
153
175
|
|
|
154
|
-
|
|
155
|
-
class ModelElementWithId(ModelElement):
|
|
156
|
-
"""Extends ModelElement with a method that assigns a unique ID to each row in a DataFrame.
|
|
157
|
-
|
|
158
|
-
IDs start at 1 and go up consecutively. No zero ID is assigned since it is reserved for the constant variable term.
|
|
159
|
-
IDs are only unique for the subclass since different subclasses have different counters.
|
|
160
|
-
"""
|
|
161
|
-
|
|
162
176
|
@property
|
|
163
177
|
def _has_ids(self) -> bool:
|
|
164
|
-
|
|
178
|
+
id_col = self._get_id_column_name()
|
|
179
|
+
assert id_col is not None, "Cannot check for IDs if no ID column is defined."
|
|
180
|
+
return id_col in self.data.columns
|
|
165
181
|
|
|
166
182
|
def _assert_has_ids(self):
|
|
167
183
|
if not self._has_ids:
|
|
@@ -170,6 +186,6 @@ class ModelElementWithId(ModelElement):
|
|
|
170
186
|
)
|
|
171
187
|
|
|
172
188
|
@classmethod
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
def _get_id_column_name(cls) -> str | None:
|
|
190
|
+
"""Subclasses should override to indicate that `data` contains an ID column."""
|
|
191
|
+
return None
|
pyoframe/_monkey_patch.py
CHANGED
|
@@ -5,15 +5,15 @@ from functools import wraps
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
import polars as pl
|
|
7
7
|
|
|
8
|
-
from pyoframe.
|
|
9
|
-
from pyoframe.
|
|
8
|
+
from pyoframe._core import BaseOperableBlock
|
|
9
|
+
from pyoframe._param import Param
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _patch_class(cls):
|
|
13
13
|
def _patch_method(func):
|
|
14
14
|
@wraps(func)
|
|
15
15
|
def wrapper(self, other):
|
|
16
|
-
if isinstance(other,
|
|
16
|
+
if isinstance(other, BaseOperableBlock):
|
|
17
17
|
return NotImplemented
|
|
18
18
|
return func(self, other)
|
|
19
19
|
|
|
@@ -24,57 +24,15 @@ def _patch_class(cls):
|
|
|
24
24
|
cls.__sub__ = _patch_method(cls.__sub__)
|
|
25
25
|
cls.__le__ = _patch_method(cls.__le__)
|
|
26
26
|
cls.__ge__ = _patch_method(cls.__ge__)
|
|
27
|
+
cls.__lt__ = _patch_method(cls.__lt__)
|
|
28
|
+
cls.__gt__ = _patch_method(cls.__gt__)
|
|
27
29
|
cls.__contains__ = _patch_method(cls.__contains__)
|
|
28
30
|
|
|
29
31
|
|
|
30
|
-
def polars_df_to_expr(self: pl.DataFrame) -> Expression:
|
|
31
|
-
"""Converts a [polars](https://pola.rs/) `DataFrame` to a Pyoframe [Expression][pyoframe.Expression] by using the last column for values and the previous columns as dimensions.
|
|
32
|
-
|
|
33
|
-
See [Special Functions](../learn/concepts/special-functions.md#dataframeto_expr) for more details.
|
|
34
|
-
|
|
35
|
-
Examples:
|
|
36
|
-
>>> import polars as pl
|
|
37
|
-
>>> df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9]})
|
|
38
|
-
>>> df.to_expr()
|
|
39
|
-
<Expression height=3 terms=3 type=constant>
|
|
40
|
-
┌─────┬─────┬────────────┐
|
|
41
|
-
│ x ┆ y ┆ expression │
|
|
42
|
-
│ (3) ┆ (3) ┆ │
|
|
43
|
-
╞═════╪═════╪════════════╡
|
|
44
|
-
│ 1 ┆ 4 ┆ 7 │
|
|
45
|
-
│ 2 ┆ 5 ┆ 8 │
|
|
46
|
-
│ 3 ┆ 6 ┆ 9 │
|
|
47
|
-
└─────┴─────┴────────────┘
|
|
48
|
-
"""
|
|
49
|
-
name = self.columns[-1]
|
|
50
|
-
return Expression(
|
|
51
|
-
self.rename({name: COEF_KEY})
|
|
52
|
-
.drop_nulls(COEF_KEY)
|
|
53
|
-
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
|
|
54
|
-
name=name,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def pandas_df_to_expr(self: pd.DataFrame) -> Expression:
|
|
59
|
-
"""Same as [`polars.DataFrame.to_expr`](./polars.DataFrame.to_expr.md), but for [pandas](https://pandas.pydata.org/) DataFrames."""
|
|
60
|
-
return polars_df_to_expr(pl.from_pandas(self))
|
|
61
|
-
|
|
62
|
-
|
|
63
32
|
def patch_dataframe_libraries():
|
|
64
|
-
"""Patches the DataFrame and Series classes of both pandas and polars.
|
|
65
|
-
|
|
66
|
-
1) Patches arithmetic operators (e.g. `__add__`) such that operations between DataFrames/Series and `Expressionable`s
|
|
67
|
-
are not supported (i.e. `return NotImplemented`). This leads Python to try the reverse operation (e.g. `__radd__`)
|
|
68
|
-
which is supported by the `Expressionable` class.
|
|
69
|
-
2) Adds a `to_expr` method to DataFrame/Series that allows them to be converted to an `Expression` object.
|
|
70
|
-
Series become DataFrames and DataFrames become expressions where everything but the last column are treated as dimensions.
|
|
71
|
-
"""
|
|
72
33
|
_patch_class(pd.DataFrame)
|
|
73
34
|
_patch_class(pd.Series)
|
|
74
35
|
_patch_class(pl.DataFrame)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
pd.
|
|
78
|
-
# TODO make a set instead!
|
|
79
|
-
pl.Series.to_expr = lambda self: self.to_frame().to_expr()
|
|
80
|
-
pd.Series.to_expr = lambda self: self.to_frame().reset_index().to_expr()
|
|
36
|
+
pl.DataFrame.to_expr = lambda self: Param(self) # type: ignore
|
|
37
|
+
pd.DataFrame.to_expr = lambda self: Param(self) # type: ignore
|
|
38
|
+
pd.Series.to_expr = lambda self: Param(self) # type: ignore
|
pyoframe/_objective.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import pyoptinterface as poi
|
|
6
6
|
|
|
7
7
|
from pyoframe._constants import ObjSense
|
|
8
|
-
from pyoframe._core import Expression,
|
|
8
|
+
from pyoframe._core import Expression, Operable
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
# TODO don't subclass Expression to avoid a bunch of unnecessary functions being available.
|
|
@@ -20,7 +20,7 @@ class Objective(Expression):
|
|
|
20
20
|
>>> m.con = m.A + m.B <= 10
|
|
21
21
|
>>> m.maximize = 2 * m.B + 4
|
|
22
22
|
>>> m.maximize
|
|
23
|
-
<Objective terms=2
|
|
23
|
+
<Objective (linear) terms=2>
|
|
24
24
|
2 B +4
|
|
25
25
|
|
|
26
26
|
The objective value can be retrieved with from the solver once the model is solved using `.value`.
|
|
@@ -62,14 +62,12 @@ class Objective(Expression):
|
|
|
62
62
|
ValueError: An objective already exists. Use += or -= to modify it.
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
|
-
def __init__(
|
|
66
|
-
self, expr: SupportsToExpr | int | float, _constructive: bool = False
|
|
67
|
-
) -> None:
|
|
65
|
+
def __init__(self, expr: Operable, _constructive: bool = False) -> None:
|
|
68
66
|
self._constructive = _constructive
|
|
69
67
|
if isinstance(expr, (int, float)):
|
|
70
68
|
expr = Expression.constant(expr)
|
|
71
69
|
else:
|
|
72
|
-
expr = expr.to_expr()
|
|
70
|
+
expr = expr.to_expr() # TODO don't rely on monkey patch
|
|
73
71
|
super().__init__(expr.data, name="objective")
|
|
74
72
|
self._model = expr._model
|
|
75
73
|
if self.dimensions is not None:
|
pyoframe/_param.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Defines the function for creating model parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from pyoframe._constants import COEF_KEY, CONST_TERM, VAR_KEY
|
|
11
|
+
from pyoframe._core import Expression
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def Param(
|
|
15
|
+
data: pl.DataFrame | pd.DataFrame | pd.Series | dict | str | Path,
|
|
16
|
+
) -> Expression:
|
|
17
|
+
"""Creates a model parameter, i.e. an [Expression][pyoframe.Expression] that doesn't involve any variables.
|
|
18
|
+
|
|
19
|
+
A Parameter can be created from a DataFrame, CSV file, Parquet file, data dictionary, or a Pandas Series.
|
|
20
|
+
|
|
21
|
+
!!! info "`Param` is a function, not a class"
|
|
22
|
+
Technically, `Param(data)` is a function that returns an [Expression][pyoframe.Expression], not a class.
|
|
23
|
+
However, for consistency with other modeling frameworks, we provide it as a class-like function (i.e. an uppercase function).
|
|
24
|
+
|
|
25
|
+
!!! tip "Smart naming"
|
|
26
|
+
If a Param is not given a name (i.e. if it is not assigned to a model: `m.my_name = Param(...)`),
|
|
27
|
+
then its [name][pyoframe._model_element.BaseBlock.name] is inferred from the name of the column in `data` that contains the parameter values.
|
|
28
|
+
This makes debugging models with inline parameters easier.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
data: The data to use for the parameter.
|
|
32
|
+
|
|
33
|
+
If `data` is a polars or pandas `DataFrame`, the last column will be treated as the values of the parameter, and all other columns as labels.
|
|
34
|
+
|
|
35
|
+
If `data` is a string or `Path`, it will be interpreted as a path to a CSV or Parquet file that will be read and used as a `DataFrame`. The file extension must be `.csv` or `.parquet`.
|
|
36
|
+
|
|
37
|
+
If `data` is a `pandas.Series`, the index(es) will be treated as columns for labels and the series values as the parameter values.
|
|
38
|
+
|
|
39
|
+
If `data` is of any other type (e.g. a dictionary), it will be used as if you had called `Param(pl.DataFrame(data))`.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
An Expression representing the parameter.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> m = pf.Model()
|
|
46
|
+
>>> m.fixed_cost = pf.Param({"plant": ["A", "B"], "cost": [1000, 1500]})
|
|
47
|
+
>>> m.fixed_cost
|
|
48
|
+
<Expression (parameter) height=2 terms=2>
|
|
49
|
+
┌───────┬────────────┐
|
|
50
|
+
│ plant ┆ expression │
|
|
51
|
+
│ (2) ┆ │
|
|
52
|
+
╞═══════╪════════════╡
|
|
53
|
+
│ A ┆ 1000 │
|
|
54
|
+
│ B ┆ 1500 │
|
|
55
|
+
└───────┴────────────┘
|
|
56
|
+
|
|
57
|
+
Since `Param` simply returns an Expression, you can use it in building larger expressions as usual:
|
|
58
|
+
|
|
59
|
+
>>> m.variable_cost = pf.Param(
|
|
60
|
+
... pl.DataFrame({"plant": ["A", "B"], "cost": [50, 60]})
|
|
61
|
+
... )
|
|
62
|
+
>>> m.total_cost = m.fixed_cost + m.variable_cost
|
|
63
|
+
>>> m.total_cost
|
|
64
|
+
<Expression (parameter) height=2 terms=2>
|
|
65
|
+
┌───────┬────────────┐
|
|
66
|
+
│ plant ┆ expression │
|
|
67
|
+
│ (2) ┆ │
|
|
68
|
+
╞═══════╪════════════╡
|
|
69
|
+
│ A ┆ 1050 │
|
|
70
|
+
│ B ┆ 1560 │
|
|
71
|
+
└───────┴────────────┘
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(data, pd.Series):
|
|
74
|
+
data = data.to_frame().reset_index()
|
|
75
|
+
if isinstance(data, pd.DataFrame):
|
|
76
|
+
data = pl.from_pandas(data)
|
|
77
|
+
|
|
78
|
+
if isinstance(data, (str, Path)):
|
|
79
|
+
data = Path(data)
|
|
80
|
+
if data.suffix.lower() == ".csv":
|
|
81
|
+
data = pl.read_csv(data)
|
|
82
|
+
elif data.suffix.lower() in {".parquet"}:
|
|
83
|
+
data = pl.read_parquet(data)
|
|
84
|
+
else:
|
|
85
|
+
raise NotImplementedError(
|
|
86
|
+
f"Could not create parameter. Unsupported file format: {data.suffix}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not isinstance(data, pl.DataFrame):
|
|
90
|
+
data = pl.DataFrame(data)
|
|
91
|
+
|
|
92
|
+
value_col = data.columns[-1]
|
|
93
|
+
|
|
94
|
+
return Expression(
|
|
95
|
+
data.rename({value_col: COEF_KEY})
|
|
96
|
+
.drop_nulls(COEF_KEY)
|
|
97
|
+
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
|
|
98
|
+
name=value_col,
|
|
99
|
+
)
|
pyoframe/_utils.py
CHANGED
|
@@ -21,9 +21,8 @@ from pyoframe._constants import (
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING: # pragma: no cover
|
|
24
|
-
from pyoframe._core import
|
|
24
|
+
from pyoframe._core import BaseOperableBlock
|
|
25
25
|
from pyoframe._model import Variable
|
|
26
|
-
from pyoframe._model_element import ModelElementWithId
|
|
27
26
|
|
|
28
27
|
if sys.version_info >= (3, 10):
|
|
29
28
|
pairwise = itertools.pairwise
|
|
@@ -43,7 +42,7 @@ def get_obj_repr(obj: object, *props: str | None, **kwargs):
|
|
|
43
42
|
|
|
44
43
|
See usage for examples.
|
|
45
44
|
"""
|
|
46
|
-
props_str = " ".join(
|
|
45
|
+
props_str = " ".join(v for v in props if v is not None)
|
|
47
46
|
if props_str:
|
|
48
47
|
props_str += " "
|
|
49
48
|
kwargs_str = " ".join(f"{k}={v}" for k, v in kwargs.items() if v is not None)
|
|
@@ -245,7 +244,7 @@ def cast_coef_to_string(
|
|
|
245
244
|
return df
|
|
246
245
|
|
|
247
246
|
|
|
248
|
-
def unwrap_single_values(func):
|
|
247
|
+
def unwrap_single_values(func) -> pl.DataFrame | Any:
|
|
249
248
|
"""Returns the DataFrame unless it is a single value in which case return the value."""
|
|
250
249
|
|
|
251
250
|
@wraps(func)
|
|
@@ -303,23 +302,23 @@ class NamedVariableMapper:
|
|
|
303
302
|
>>> m = pf.Model()
|
|
304
303
|
>>> m.foo = pf.Variable(pl.DataFrame({"t": range(4)}))
|
|
305
304
|
>>> m.foo.sum()
|
|
306
|
-
<Expression terms=4
|
|
305
|
+
<Expression (linear) terms=4>
|
|
307
306
|
foo[0] + foo[1] + foo[2] + foo[3]
|
|
308
307
|
"""
|
|
309
308
|
|
|
310
309
|
CONST_TERM_NAME = "_ONE"
|
|
311
310
|
NAME_COL = "__name"
|
|
312
311
|
|
|
313
|
-
def __init__(self
|
|
312
|
+
def __init__(self) -> None:
|
|
314
313
|
self._ID_COL = VAR_KEY
|
|
315
314
|
self.mapping_registry = pl.DataFrame(
|
|
316
315
|
{self._ID_COL: [], self.NAME_COL: []},
|
|
317
|
-
schema={self._ID_COL:
|
|
316
|
+
schema={self._ID_COL: Config.id_dtype, self.NAME_COL: pl.String},
|
|
318
317
|
)
|
|
319
318
|
self._extend_registry(
|
|
320
319
|
pl.DataFrame(
|
|
321
320
|
{self._ID_COL: [CONST_TERM], self.NAME_COL: [self.CONST_TERM_NAME]},
|
|
322
|
-
schema={self._ID_COL:
|
|
321
|
+
schema={self._ID_COL: Config.id_dtype, self.NAME_COL: pl.String},
|
|
323
322
|
)
|
|
324
323
|
)
|
|
325
324
|
|
|
@@ -375,15 +374,15 @@ def for_solvers(*solvers: str):
|
|
|
375
374
|
return decorator
|
|
376
375
|
|
|
377
376
|
|
|
378
|
-
# TODO: rename and change to return_expr once Set is split away from
|
|
379
|
-
def return_new(func: Callable[..., pl.DataFrame]) -> Callable[...,
|
|
377
|
+
# TODO: rename and change to return_expr once Set is split away from BaseOperableBlock
|
|
378
|
+
def return_new(func: Callable[..., pl.DataFrame]) -> Callable[..., BaseOperableBlock]:
|
|
380
379
|
"""Decorator that upcasts the returned DataFrame to an Expression.
|
|
381
380
|
|
|
382
381
|
Requires the first argument (self) to support self._new().
|
|
383
382
|
"""
|
|
384
383
|
|
|
385
384
|
@wraps(func)
|
|
386
|
-
def wrapper(self:
|
|
385
|
+
def wrapper(self: BaseOperableBlock, *args, **kwargs):
|
|
387
386
|
result = func(self, *args, **kwargs)
|
|
388
387
|
return self._new(result, name=f"{self.name}.{func.__name__}(…)")
|
|
389
388
|
|
pyoframe/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '1.0
|
|
32
|
-
__version_tuple__ = version_tuple = (1,
|
|
31
|
+
__version__ = version = '1.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 1, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|