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/core.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
from typing import (
|
|
5
6
|
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Dict,
|
|
6
9
|
Iterable,
|
|
7
10
|
List,
|
|
8
11
|
Mapping,
|
|
@@ -13,20 +16,25 @@ from typing import (
|
|
|
13
16
|
overload,
|
|
14
17
|
)
|
|
15
18
|
|
|
19
|
+
import numpy as np
|
|
16
20
|
import pandas as pd
|
|
17
21
|
import polars as pl
|
|
18
|
-
|
|
22
|
+
import pyoptinterface as poi
|
|
19
23
|
|
|
20
|
-
from pyoframe._arithmetic import
|
|
24
|
+
from pyoframe._arithmetic import (
|
|
25
|
+
_add_expressions,
|
|
26
|
+
_get_dimensions,
|
|
27
|
+
_multiply_expressions,
|
|
28
|
+
)
|
|
21
29
|
from pyoframe.constants import (
|
|
22
30
|
COEF_KEY,
|
|
23
31
|
CONST_TERM,
|
|
24
32
|
CONSTRAINT_KEY,
|
|
25
33
|
DUAL_KEY,
|
|
34
|
+
KEY_TYPE,
|
|
26
35
|
POLARS_VERSION,
|
|
27
|
-
|
|
36
|
+
QUAD_VAR_KEY,
|
|
28
37
|
RESERVED_COL_KEYS,
|
|
29
|
-
SLACK_COL,
|
|
30
38
|
SOLUTION_KEY,
|
|
31
39
|
VAR_KEY,
|
|
32
40
|
Config,
|
|
@@ -43,6 +51,7 @@ from pyoframe.model_element import (
|
|
|
43
51
|
SupportPolarsMethodMixin,
|
|
44
52
|
)
|
|
45
53
|
from pyoframe.util import (
|
|
54
|
+
Container,
|
|
46
55
|
FuncArgs,
|
|
47
56
|
cast_coef_to_string,
|
|
48
57
|
concat_dimensions,
|
|
@@ -55,8 +64,6 @@ from pyoframe.util import (
|
|
|
55
64
|
if TYPE_CHECKING: # pragma: no cover
|
|
56
65
|
from pyoframe.model import Model
|
|
57
66
|
|
|
58
|
-
VAR_TYPE = pl.UInt32
|
|
59
|
-
|
|
60
67
|
|
|
61
68
|
def _forward_to_expression(func_name: str):
|
|
62
69
|
def wrapper(self: "SupportsMath", *args, **kwargs) -> "Expression":
|
|
@@ -98,6 +105,25 @@ class SupportsMath(ABC, SupportsToExpr):
|
|
|
98
105
|
sum = _forward_to_expression("sum")
|
|
99
106
|
map = _forward_to_expression("map")
|
|
100
107
|
|
|
108
|
+
def __pow__(self, power: int):
|
|
109
|
+
"""
|
|
110
|
+
Support squaring expressions:
|
|
111
|
+
>>> m = pf.Model()
|
|
112
|
+
>>> m.v = pf.Variable()
|
|
113
|
+
>>> m.v ** 2
|
|
114
|
+
<Expression size=1 dimensions={} terms=1 degree=2>
|
|
115
|
+
v * v
|
|
116
|
+
>>> m.v ** 3
|
|
117
|
+
Traceback (most recent call last):
|
|
118
|
+
...
|
|
119
|
+
ValueError: Raising an expressions to **3 is not supported. Expressions can only be squared (**2).
|
|
120
|
+
"""
|
|
121
|
+
if power == 2:
|
|
122
|
+
return self * self
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Raising an expressions to **{power} is not supported. Expressions can only be squared (**2)."
|
|
125
|
+
)
|
|
126
|
+
|
|
101
127
|
def __neg__(self):
|
|
102
128
|
res = self.to_expr() * -1
|
|
103
129
|
# Negating a constant term should keep the unmatched strategy
|
|
@@ -107,14 +133,14 @@ class SupportsMath(ABC, SupportsToExpr):
|
|
|
107
133
|
def __sub__(self, other):
|
|
108
134
|
"""
|
|
109
135
|
>>> import polars as pl
|
|
110
|
-
>>>
|
|
136
|
+
>>> m = pf.Model()
|
|
111
137
|
>>> df = pl.DataFrame({"dim1": [1,2,3], "value": [1,2,3]})
|
|
112
|
-
>>>
|
|
113
|
-
>>>
|
|
138
|
+
>>> m.v = pf.Variable(df["dim1"])
|
|
139
|
+
>>> m.v - df
|
|
114
140
|
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
115
|
-
[1]:
|
|
116
|
-
[2]:
|
|
117
|
-
[3]:
|
|
141
|
+
[1]: v[1] -1
|
|
142
|
+
[2]: v[2] -2
|
|
143
|
+
[3]: v[3] -3
|
|
118
144
|
"""
|
|
119
145
|
if not isinstance(other, (int, float)):
|
|
120
146
|
other = other.to_expr()
|
|
@@ -126,33 +152,69 @@ class SupportsMath(ABC, SupportsToExpr):
|
|
|
126
152
|
def __radd__(self, other):
|
|
127
153
|
return self.to_expr() + other
|
|
128
154
|
|
|
155
|
+
def __truediv__(self, other):
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
Support division.
|
|
160
|
+
>>> m = pf.Model()
|
|
161
|
+
>>> m.v = Variable({"dim1": [1,2,3]})
|
|
162
|
+
>>> m.v / 2
|
|
163
|
+
<Expression size=3 dimensions={'dim1': 3} terms=3>
|
|
164
|
+
[1]: 0.5 v[1]
|
|
165
|
+
[2]: 0.5 v[2]
|
|
166
|
+
[3]: 0.5 v[3]
|
|
167
|
+
"""
|
|
168
|
+
return self.to_expr() * (1 / other)
|
|
169
|
+
|
|
170
|
+
def __rsub__(self, other):
|
|
171
|
+
"""
|
|
172
|
+
Support right subtraction.
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
>>> m = pf.Model()
|
|
176
|
+
>>> m.v = Variable({"dim1": [1,2,3]})
|
|
177
|
+
>>> 1 - m.v
|
|
178
|
+
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
179
|
+
[1]: 1 - v[1]
|
|
180
|
+
[2]: 1 - v[2]
|
|
181
|
+
[3]: 1 - v[3]
|
|
182
|
+
"""
|
|
183
|
+
return other + (-self.to_expr())
|
|
184
|
+
|
|
129
185
|
def __le__(self, other):
|
|
130
186
|
"""Equality constraint.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
>>> m = pf.Model()
|
|
190
|
+
>>> m.v = pf.Variable()
|
|
191
|
+
>>> m.v <= 1
|
|
192
|
+
<Constraint sense='<=' size=1 dimensions={} terms=2>
|
|
193
|
+
v <= 1
|
|
136
194
|
"""
|
|
137
195
|
return Constraint(self - other, ConstraintSense.LE)
|
|
138
196
|
|
|
139
197
|
def __ge__(self, other):
|
|
140
198
|
"""Equality constraint.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
>>> m = pf.Model()
|
|
202
|
+
>>> m.v = pf.Variable()
|
|
203
|
+
>>> m.v >= 1
|
|
204
|
+
<Constraint sense='>=' size=1 dimensions={} terms=2>
|
|
205
|
+
v >= 1
|
|
146
206
|
"""
|
|
147
207
|
return Constraint(self - other, ConstraintSense.GE)
|
|
148
208
|
|
|
149
|
-
def __eq__(self, value: object):
|
|
209
|
+
def __eq__(self, value: object): # type: ignore
|
|
150
210
|
"""Equality constraint.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
>>> m = pf.Model()
|
|
214
|
+
>>> m.v = pf.Variable()
|
|
215
|
+
>>> m.v == 1
|
|
216
|
+
<Constraint sense='=' size=1 dimensions={} terms=2>
|
|
217
|
+
v = 1
|
|
156
218
|
"""
|
|
157
219
|
return Constraint(self - value, ConstraintSense.EQ)
|
|
158
220
|
|
|
@@ -169,6 +231,15 @@ SetTypes = Union[
|
|
|
169
231
|
|
|
170
232
|
|
|
171
233
|
class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
234
|
+
"""
|
|
235
|
+
A set which can then be used to index variables.
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
>>> pf.Set(x=range(2), y=range(3))
|
|
239
|
+
<Set size=6 dimensions={'x': 2, 'y': 3}>
|
|
240
|
+
[(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]
|
|
241
|
+
"""
|
|
242
|
+
|
|
172
243
|
def __init__(self, *data: SetTypes | Iterable[SetTypes], **named_data):
|
|
173
244
|
data_list = list(data)
|
|
174
245
|
for name, set in named_data.items():
|
|
@@ -190,28 +261,29 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
190
261
|
*over: SetTypes | Iterable[SetTypes],
|
|
191
262
|
) -> pl.DataFrame:
|
|
192
263
|
"""
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
264
|
+
Examples:
|
|
265
|
+
>>> import pandas as pd
|
|
266
|
+
>>> dim1 = pd.Index([1, 2, 3], name="dim1")
|
|
267
|
+
>>> dim2 = pd.Index(["a", "b"], name="dim1")
|
|
268
|
+
>>> Set._parse_acceptable_sets([dim1, dim2])
|
|
269
|
+
Traceback (most recent call last):
|
|
270
|
+
...
|
|
271
|
+
AssertionError: All coordinates must have unique column names.
|
|
272
|
+
>>> dim2.name = "dim2"
|
|
273
|
+
>>> Set._parse_acceptable_sets([dim1, dim2])
|
|
274
|
+
shape: (6, 2)
|
|
275
|
+
┌──────┬──────┐
|
|
276
|
+
│ dim1 ┆ dim2 │
|
|
277
|
+
│ --- ┆ --- │
|
|
278
|
+
│ i64 ┆ str │
|
|
279
|
+
╞══════╪══════╡
|
|
280
|
+
│ 1 ┆ a │
|
|
281
|
+
│ 1 ┆ b │
|
|
282
|
+
│ 2 ┆ a │
|
|
283
|
+
│ 2 ┆ b │
|
|
284
|
+
│ 3 ┆ a │
|
|
285
|
+
│ 3 ┆ b │
|
|
286
|
+
└──────┴──────┘
|
|
215
287
|
"""
|
|
216
288
|
assert len(over) > 0, "At least one set must be provided."
|
|
217
289
|
over_iter: Iterable[SetTypes] = parse_inputs_as_iterable(*over)
|
|
@@ -248,7 +320,7 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
248
320
|
return self._new(
|
|
249
321
|
pl.concat([self.data, other.data]).unique(maintain_order=True)
|
|
250
322
|
)
|
|
251
|
-
except pl.ShapeError as e:
|
|
323
|
+
except pl.exceptions.ShapeError as e:
|
|
252
324
|
if "unable to vstack, column names don't match" in str(e):
|
|
253
325
|
raise PyoframeError(
|
|
254
326
|
f"Failed to add sets '{self.friendly_name}' and '{other.friendly_name}' because dimensions do not match ({self.dimensions} != {other.dimensions}) "
|
|
@@ -295,6 +367,10 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
295
367
|
df = set.to_frame()
|
|
296
368
|
elif isinstance(set, Set):
|
|
297
369
|
df = set.data
|
|
370
|
+
elif isinstance(set, range):
|
|
371
|
+
raise ValueError(
|
|
372
|
+
"Cannot convert a range to a set without a dimension name. Try Set(dim_name=range(...))"
|
|
373
|
+
)
|
|
298
374
|
else:
|
|
299
375
|
raise ValueError(f"Cannot convert type {type(set)} to a polars DataFrame")
|
|
300
376
|
|
|
@@ -313,24 +389,26 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
313
389
|
|
|
314
390
|
|
|
315
391
|
class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
316
|
-
"""A linear expression."""
|
|
392
|
+
"""A linear or quadratic expression."""
|
|
317
393
|
|
|
318
394
|
def __init__(self, data: pl.DataFrame):
|
|
319
395
|
"""
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
396
|
+
A linear expression.
|
|
397
|
+
|
|
398
|
+
Examples:
|
|
399
|
+
>>> import pandas as pd
|
|
400
|
+
>>> df = pd.DataFrame({"item" : [1, 1, 1, 2, 2], "time": ["mon", "tue", "wed", "mon", "tue"], "cost": [1, 2, 3, 4, 5]}).set_index(["item", "time"])
|
|
401
|
+
>>> m = pf.Model()
|
|
402
|
+
>>> m.Time = pf.Variable(df.index)
|
|
403
|
+
>>> m.Size = pf.Variable(df.index)
|
|
404
|
+
>>> expr = df["cost"] * m.Time + df["cost"] * m.Size
|
|
405
|
+
>>> expr
|
|
406
|
+
<Expression size=5 dimensions={'item': 2, 'time': 3} terms=10>
|
|
407
|
+
[1,mon]: Time[1,mon] + Size[1,mon]
|
|
408
|
+
[1,tue]: 2 Time[1,tue] +2 Size[1,tue]
|
|
409
|
+
[1,wed]: 3 Time[1,wed] +3 Size[1,wed]
|
|
410
|
+
[2,mon]: 4 Time[2,mon] +4 Size[2,mon]
|
|
411
|
+
[2,tue]: 5 Time[2,tue] +5 Size[2,tue]
|
|
334
412
|
"""
|
|
335
413
|
# Sanity checks, VAR_KEY and COEF_KEY must be present
|
|
336
414
|
assert VAR_KEY in data.columns, "Missing variable column."
|
|
@@ -362,14 +440,32 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
362
440
|
# )
|
|
363
441
|
# )
|
|
364
442
|
|
|
443
|
+
@classmethod
|
|
444
|
+
def constant(cls, constant: int | float) -> "Expression":
|
|
445
|
+
"""
|
|
446
|
+
Examples:
|
|
447
|
+
>>> pf.Expression.constant(5)
|
|
448
|
+
<Expression size=1 dimensions={} terms=1>
|
|
449
|
+
5
|
|
450
|
+
"""
|
|
451
|
+
return cls(
|
|
452
|
+
pl.DataFrame(
|
|
453
|
+
{
|
|
454
|
+
COEF_KEY: [constant],
|
|
455
|
+
VAR_KEY: [CONST_TERM],
|
|
456
|
+
},
|
|
457
|
+
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
365
461
|
def sum(self, over: Union[str, Iterable[str]]):
|
|
366
462
|
"""
|
|
367
463
|
Examples:
|
|
368
464
|
>>> import pandas as pd
|
|
369
|
-
>>>
|
|
465
|
+
>>> m = pf.Model()
|
|
370
466
|
>>> df = pd.DataFrame({"item" : [1, 1, 1, 2, 2], "time": ["mon", "tue", "wed", "mon", "tue"], "cost": [1, 2, 3, 4, 5]}).set_index(["item", "time"])
|
|
371
|
-
>>> quantity = Variable(df.reset_index()[["item"]].drop_duplicates())
|
|
372
|
-
>>> expr = (quantity * df["cost"]).sum("time")
|
|
467
|
+
>>> m.quantity = Variable(df.reset_index()[["item"]].drop_duplicates())
|
|
468
|
+
>>> expr = (m.quantity * df["cost"]).sum("time")
|
|
373
469
|
>>> expr.data
|
|
374
470
|
shape: (2, 3)
|
|
375
471
|
┌──────┬─────────┬───────────────┐
|
|
@@ -393,11 +489,18 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
393
489
|
|
|
394
490
|
return self._new(
|
|
395
491
|
self.data.drop(over)
|
|
396
|
-
.group_by(remaining_dims +
|
|
492
|
+
.group_by(remaining_dims + self._variable_columns, maintain_order=True)
|
|
397
493
|
.sum()
|
|
398
494
|
)
|
|
399
495
|
|
|
400
|
-
|
|
496
|
+
@property
|
|
497
|
+
def _variable_columns(self) -> List[str]:
|
|
498
|
+
if self.is_quadratic:
|
|
499
|
+
return [VAR_KEY, QUAD_VAR_KEY]
|
|
500
|
+
else:
|
|
501
|
+
return [VAR_KEY]
|
|
502
|
+
|
|
503
|
+
def map(self, mapping_set: SetTypes, drop_shared_dims: bool = True) -> Expression:
|
|
401
504
|
"""
|
|
402
505
|
Replaces the dimensions that are shared with mapping_set with the other dimensions found in mapping_set.
|
|
403
506
|
|
|
@@ -405,33 +508,29 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
405
508
|
is indexed by city to data indexed by country (see example).
|
|
406
509
|
|
|
407
510
|
Parameters:
|
|
408
|
-
mapping_set
|
|
511
|
+
mapping_set:
|
|
409
512
|
The set to map the expression to. This can be a DataFrame, Index, or another Set.
|
|
410
|
-
drop_shared_dims
|
|
513
|
+
drop_shared_dims:
|
|
411
514
|
If True, the dimensions shared between the expression and the mapping set are dropped from the resulting expression and
|
|
412
515
|
repeated rows are summed.
|
|
413
516
|
If False, the shared dimensions are kept in the resulting expression.
|
|
414
517
|
|
|
415
518
|
Returns:
|
|
416
|
-
Expression
|
|
417
|
-
A new Expression containing the result of the mapping operation.
|
|
519
|
+
A new Expression containing the result of the mapping operation.
|
|
418
520
|
|
|
419
521
|
Examples:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
[Toronto,2024,Canada]: 10
|
|
433
|
-
[Vancouver,2024,Canada]: 2
|
|
434
|
-
[Boston,2024,USA]: 8
|
|
522
|
+
>>> import polars as pl
|
|
523
|
+
>>> pop_data = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "year": [2024, 2024, 2024], "population": [10, 2, 8]}).to_expr()
|
|
524
|
+
>>> cities_and_countries = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "country": ["Canada", "Canada", "USA"]})
|
|
525
|
+
>>> pop_data.map(cities_and_countries)
|
|
526
|
+
<Expression size=2 dimensions={'year': 1, 'country': 2} terms=2>
|
|
527
|
+
[2024,Canada]: 12
|
|
528
|
+
[2024,USA]: 8
|
|
529
|
+
>>> pop_data.map(cities_and_countries, drop_shared_dims=False)
|
|
530
|
+
<Expression size=3 dimensions={'city': 3, 'year': 1, 'country': 2} terms=3>
|
|
531
|
+
[Toronto,2024,Canada]: 10
|
|
532
|
+
[Vancouver,2024,Canada]: 2
|
|
533
|
+
[Boston,2024,USA]: 8
|
|
435
534
|
"""
|
|
436
535
|
mapping_set = Set(mapping_set)
|
|
437
536
|
|
|
@@ -458,7 +557,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
458
557
|
|
|
459
558
|
return mapped_expression
|
|
460
559
|
|
|
461
|
-
def rolling_sum(self, over: str, window_size: int):
|
|
560
|
+
def rolling_sum(self, over: str, window_size: int) -> Expression:
|
|
462
561
|
"""
|
|
463
562
|
Calculates the rolling sum of the Expression over a specified window size for a given dimension.
|
|
464
563
|
|
|
@@ -467,25 +566,23 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
467
566
|
|
|
468
567
|
|
|
469
568
|
Parameters:
|
|
470
|
-
over :
|
|
569
|
+
over :
|
|
471
570
|
The name of the dimension (column) over which the rolling sum is calculated.
|
|
472
571
|
This dimension must exist within the Expression's dimensions.
|
|
473
|
-
window_size :
|
|
572
|
+
window_size :
|
|
474
573
|
The size of the moving window in terms of number of records.
|
|
475
574
|
The rolling sum is calculated over this many consecutive elements.
|
|
476
575
|
|
|
477
576
|
Returns:
|
|
478
|
-
Expression
|
|
479
|
-
A new Expression instance containing the result of the rolling sum operation.
|
|
577
|
+
A new Expression instance containing the result of the rolling sum operation.
|
|
480
578
|
This new Expression retains all dimensions (columns) of the original data,
|
|
481
579
|
with the rolling sum applied over the specified dimension.
|
|
482
580
|
|
|
483
581
|
Examples:
|
|
484
582
|
>>> import polars as pl
|
|
485
|
-
>>> from pyoframe import Variable, Model
|
|
486
583
|
>>> cost = pl.DataFrame({"item" : [1, 1, 1, 2, 2], "time": [1, 2, 3, 1, 2], "cost": [1, 2, 3, 4, 5]})
|
|
487
|
-
>>> m = Model(
|
|
488
|
-
>>> m.quantity = Variable(cost[["item", "time"]])
|
|
584
|
+
>>> m = pf.Model()
|
|
585
|
+
>>> m.quantity = pf.Variable(cost[["item", "time"]])
|
|
489
586
|
>>> (m.quantity * cost).rolling_sum(over="time", window_size=2)
|
|
490
587
|
<Expression size=5 dimensions={'item': 2, 'time': 3} terms=8>
|
|
491
588
|
[1,1]: quantity[1,1]
|
|
@@ -507,7 +604,9 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
507
604
|
[
|
|
508
605
|
df.with_columns(pl.col(over).max())
|
|
509
606
|
for _, df in self.data.rolling(
|
|
510
|
-
index_column=over,
|
|
607
|
+
index_column=over,
|
|
608
|
+
period=f"{window_size}i",
|
|
609
|
+
group_by=remaining_dims,
|
|
511
610
|
)
|
|
512
611
|
]
|
|
513
612
|
)
|
|
@@ -515,20 +614,20 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
515
614
|
|
|
516
615
|
def within(self, set: "SetTypes") -> Expression:
|
|
517
616
|
"""
|
|
518
|
-
Examples
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
617
|
+
Examples:
|
|
618
|
+
>>> import pandas as pd
|
|
619
|
+
>>> general_expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}).to_expr()
|
|
620
|
+
>>> filter_expr = pd.DataFrame({"dim1": [1, 3], "value": [5, 6]}).to_expr()
|
|
621
|
+
>>> general_expr.within(filter_expr).data
|
|
622
|
+
shape: (2, 3)
|
|
623
|
+
┌──────┬─────────┬───────────────┐
|
|
624
|
+
│ dim1 ┆ __coeff ┆ __variable_id │
|
|
625
|
+
│ --- ┆ --- ┆ --- │
|
|
626
|
+
│ i64 ┆ f64 ┆ u32 │
|
|
627
|
+
╞══════╪═════════╪═══════════════╡
|
|
628
|
+
│ 1 ┆ 1.0 ┆ 0 │
|
|
629
|
+
│ 3 ┆ 3.0 ┆ 0 │
|
|
630
|
+
└──────┴─────────┴───────────────┘
|
|
532
631
|
"""
|
|
533
632
|
df: pl.DataFrame = Set(set).data
|
|
534
633
|
set_dims = _get_dimensions(df)
|
|
@@ -543,24 +642,69 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
543
642
|
by_dims = df.select(dims_in_common).unique(maintain_order=True)
|
|
544
643
|
return self._new(self.data.join(by_dims, on=dims_in_common))
|
|
545
644
|
|
|
645
|
+
@property
|
|
646
|
+
def is_quadratic(self) -> bool:
|
|
647
|
+
"""
|
|
648
|
+
Returns True if the expression is quadratic, False otherwise.
|
|
649
|
+
|
|
650
|
+
Computes in O(1) since expressions are quadratic if and
|
|
651
|
+
only if self.data contain the QUAD_VAR_KEY column.
|
|
652
|
+
|
|
653
|
+
Examples:
|
|
654
|
+
>>> import pandas as pd
|
|
655
|
+
>>> m = pf.Model()
|
|
656
|
+
>>> m.v = Variable()
|
|
657
|
+
>>> expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}) * m.v
|
|
658
|
+
>>> expr *= m.v
|
|
659
|
+
>>> expr.is_quadratic
|
|
660
|
+
True
|
|
661
|
+
"""
|
|
662
|
+
return QUAD_VAR_KEY in self.data.columns
|
|
663
|
+
|
|
664
|
+
def degree(self) -> int:
|
|
665
|
+
"""
|
|
666
|
+
Returns the degree of the expression (0=constant, 1=linear, 2=quadratic).
|
|
667
|
+
|
|
668
|
+
Examples:
|
|
669
|
+
>>> import pandas as pd
|
|
670
|
+
>>> m = pf.Model()
|
|
671
|
+
>>> m.v1 = pf.Variable()
|
|
672
|
+
>>> m.v2 = pf.Variable()
|
|
673
|
+
>>> expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}).to_expr()
|
|
674
|
+
>>> expr.degree()
|
|
675
|
+
0
|
|
676
|
+
>>> expr *= m.v1
|
|
677
|
+
>>> expr.degree()
|
|
678
|
+
1
|
|
679
|
+
>>> expr += (m.v2 ** 2).add_dim("dim1")
|
|
680
|
+
>>> expr.degree()
|
|
681
|
+
2
|
|
682
|
+
"""
|
|
683
|
+
if self.is_quadratic:
|
|
684
|
+
return 2
|
|
685
|
+
elif (self.data.get_column(VAR_KEY) != CONST_TERM).any():
|
|
686
|
+
return 1
|
|
687
|
+
else:
|
|
688
|
+
return 0
|
|
689
|
+
|
|
546
690
|
def __add__(self, other):
|
|
547
691
|
"""
|
|
548
692
|
Examples:
|
|
549
693
|
>>> import pandas as pd
|
|
550
|
-
>>>
|
|
694
|
+
>>> m = pf.Model()
|
|
551
695
|
>>> add = pd.DataFrame({"dim1": [1,2,3], "add": [10, 20, 30]}).to_expr()
|
|
552
|
-
>>>
|
|
553
|
-
>>>
|
|
696
|
+
>>> m.v = Variable(add)
|
|
697
|
+
>>> m.v + add
|
|
554
698
|
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
555
|
-
[1]:
|
|
556
|
-
[2]:
|
|
557
|
-
[3]:
|
|
558
|
-
>>>
|
|
699
|
+
[1]: v[1] +10
|
|
700
|
+
[2]: v[2] +20
|
|
701
|
+
[3]: v[3] +30
|
|
702
|
+
>>> m.v + add + 2
|
|
559
703
|
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
560
|
-
[1]:
|
|
561
|
-
[2]:
|
|
562
|
-
[3]:
|
|
563
|
-
>>>
|
|
704
|
+
[1]: v[1] +12
|
|
705
|
+
[2]: v[2] +22
|
|
706
|
+
[3]: v[3] +32
|
|
707
|
+
>>> m.v + pd.DataFrame({"dim1": [1,2], "add": [10, 20]})
|
|
564
708
|
Traceback (most recent call last):
|
|
565
709
|
...
|
|
566
710
|
pyoframe.constants.PyoframeError: Failed to add expressions:
|
|
@@ -575,9 +719,10 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
575
719
|
╞══════╪════════════╡
|
|
576
720
|
│ 3 ┆ null │
|
|
577
721
|
└──────┴────────────┘
|
|
578
|
-
>>>
|
|
722
|
+
>>> m.v2 = Variable()
|
|
723
|
+
>>> 5 + 2 * m.v2
|
|
579
724
|
<Expression size=1 dimensions={} terms=2>
|
|
580
|
-
2
|
|
725
|
+
2 v2 +5
|
|
581
726
|
"""
|
|
582
727
|
if isinstance(other, str):
|
|
583
728
|
raise ValueError(
|
|
@@ -597,31 +742,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
597
742
|
|
|
598
743
|
other = other.to_expr()
|
|
599
744
|
self._learn_from_other(other)
|
|
600
|
-
|
|
601
|
-
if (other.data.get_column(VAR_KEY) != CONST_TERM).any():
|
|
602
|
-
self, other = other, self
|
|
603
|
-
|
|
604
|
-
if (other.data.get_column(VAR_KEY) != CONST_TERM).any():
|
|
605
|
-
raise ValueError(
|
|
606
|
-
"Multiplication of two expressions with variables is non-linear and not supported."
|
|
607
|
-
)
|
|
608
|
-
multiplier = other.data.drop(VAR_KEY)
|
|
609
|
-
|
|
610
|
-
dims = self.dimensions_unsafe
|
|
611
|
-
other_dims = other.dimensions_unsafe
|
|
612
|
-
dims_in_common = [dim for dim in dims if dim in other_dims]
|
|
613
|
-
|
|
614
|
-
data = (
|
|
615
|
-
self.data.join(
|
|
616
|
-
multiplier,
|
|
617
|
-
on=dims_in_common if len(dims_in_common) > 0 else None,
|
|
618
|
-
how="inner" if dims_in_common else "cross",
|
|
619
|
-
)
|
|
620
|
-
.with_columns(pl.col(COEF_KEY) * pl.col(COEF_KEY + "_right"))
|
|
621
|
-
.drop(COEF_KEY + "_right")
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
return self._new(data)
|
|
745
|
+
return _multiply_expressions(self, other)
|
|
625
746
|
|
|
626
747
|
def to_expr(self) -> Expression:
|
|
627
748
|
return self
|
|
@@ -638,28 +759,66 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
638
759
|
return e
|
|
639
760
|
|
|
640
761
|
def _add_const(self, const: int | float) -> Expression:
|
|
762
|
+
"""
|
|
763
|
+
Examples:
|
|
764
|
+
>>> m = pf.Model()
|
|
765
|
+
>>> m.x1 = Variable()
|
|
766
|
+
>>> m.x2 = Variable()
|
|
767
|
+
>>> m.x1 + 5
|
|
768
|
+
<Expression size=1 dimensions={} terms=2>
|
|
769
|
+
x1 +5
|
|
770
|
+
>>> m.x1 ** 2 + 5
|
|
771
|
+
<Expression size=1 dimensions={} terms=2 degree=2>
|
|
772
|
+
x1 * x1 +5
|
|
773
|
+
>>> m.x1 ** 2 + m.x2 + 5
|
|
774
|
+
<Expression size=1 dimensions={} terms=3 degree=2>
|
|
775
|
+
x1 * x1 + x2 +5
|
|
776
|
+
|
|
777
|
+
It also works with dimensions
|
|
778
|
+
|
|
779
|
+
>>> m = pf.Model()
|
|
780
|
+
>>> m.v = Variable({"dim1": [1, 2, 3]})
|
|
781
|
+
>>> m.v * m.v + 5
|
|
782
|
+
<Expression size=3 dimensions={'dim1': 3} terms=6 degree=2>
|
|
783
|
+
[1]: 5 + v[1] * v[1]
|
|
784
|
+
[2]: 5 + v[2] * v[2]
|
|
785
|
+
[3]: 5 + v[3] * v[3]
|
|
786
|
+
"""
|
|
641
787
|
dim = self.dimensions
|
|
642
788
|
data = self.data
|
|
643
789
|
# Fill in missing constant terms
|
|
644
790
|
if not dim:
|
|
645
791
|
if CONST_TERM not in data[VAR_KEY]:
|
|
792
|
+
const_df = pl.DataFrame(
|
|
793
|
+
{COEF_KEY: [0.0], VAR_KEY: [CONST_TERM]},
|
|
794
|
+
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
795
|
+
)
|
|
796
|
+
if self.is_quadratic:
|
|
797
|
+
const_df = const_df.with_columns(
|
|
798
|
+
pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
|
|
799
|
+
)
|
|
646
800
|
data = pl.concat(
|
|
647
|
-
[
|
|
648
|
-
data,
|
|
649
|
-
pl.DataFrame(
|
|
650
|
-
{COEF_KEY: [0.0], VAR_KEY: [CONST_TERM]},
|
|
651
|
-
schema={COEF_KEY: pl.Float64, VAR_KEY: VAR_TYPE},
|
|
652
|
-
),
|
|
653
|
-
],
|
|
801
|
+
[data, const_df],
|
|
654
802
|
how="vertical_relaxed",
|
|
655
803
|
)
|
|
656
804
|
else:
|
|
657
805
|
keys = (
|
|
658
806
|
data.select(dim)
|
|
659
807
|
.unique(maintain_order=True)
|
|
660
|
-
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY).cast(
|
|
808
|
+
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY).cast(KEY_TYPE))
|
|
661
809
|
)
|
|
662
|
-
|
|
810
|
+
if self.is_quadratic:
|
|
811
|
+
keys = keys.with_columns(
|
|
812
|
+
pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
|
|
813
|
+
)
|
|
814
|
+
if POLARS_VERSION.major >= 1:
|
|
815
|
+
data = data.join(
|
|
816
|
+
keys, on=dim + self._variable_columns, how="full", coalesce=True
|
|
817
|
+
)
|
|
818
|
+
else:
|
|
819
|
+
data = data.join(
|
|
820
|
+
keys, on=dim + self._variable_columns, how="outer_coalesce"
|
|
821
|
+
)
|
|
663
822
|
data = data.with_columns(pl.col(COEF_KEY).fill_null(0.0))
|
|
664
823
|
|
|
665
824
|
data = data.with_columns(
|
|
@@ -674,17 +833,20 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
674
833
|
def constant_terms(self):
|
|
675
834
|
dims = self.dimensions
|
|
676
835
|
constant_terms = self.data.filter(pl.col(VAR_KEY) == CONST_TERM).drop(VAR_KEY)
|
|
836
|
+
if self.is_quadratic:
|
|
837
|
+
constant_terms = constant_terms.drop(QUAD_VAR_KEY)
|
|
677
838
|
if dims is not None:
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
on=dims,
|
|
681
|
-
|
|
682
|
-
|
|
839
|
+
dims_df = self.data.select(dims).unique(maintain_order=True)
|
|
840
|
+
if POLARS_VERSION.major >= 1:
|
|
841
|
+
df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
|
|
842
|
+
else:
|
|
843
|
+
df = constant_terms.join(dims_df, on=dims, how="outer_coalesce")
|
|
844
|
+
return df.with_columns(pl.col(COEF_KEY).fill_null(0.0))
|
|
683
845
|
else:
|
|
684
846
|
if len(constant_terms) == 0:
|
|
685
847
|
return pl.DataFrame(
|
|
686
848
|
{COEF_KEY: [0.0], VAR_KEY: [CONST_TERM]},
|
|
687
|
-
schema={COEF_KEY: pl.Float64, VAR_KEY:
|
|
849
|
+
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
688
850
|
)
|
|
689
851
|
return constant_terms
|
|
690
852
|
|
|
@@ -692,23 +854,20 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
692
854
|
def variable_terms(self):
|
|
693
855
|
return self.data.filter(pl.col(VAR_KEY) != CONST_TERM)
|
|
694
856
|
|
|
695
|
-
@property
|
|
696
857
|
@unwrap_single_values
|
|
697
|
-
def
|
|
858
|
+
def evaluate(self) -> pl.DataFrame:
|
|
698
859
|
"""
|
|
699
860
|
The value of the expression. Only available after the model has been solved.
|
|
700
861
|
|
|
701
862
|
Examples:
|
|
702
|
-
>>>
|
|
703
|
-
>>> m = pf.Model("max")
|
|
863
|
+
>>> m = pf.Model()
|
|
704
864
|
>>> m.X = pf.Variable({"dim1": [1, 2, 3]}, ub=10)
|
|
705
865
|
>>> m.expr_1 = 2 * m.X + 1
|
|
706
866
|
>>> m.expr_2 = pf.sum(m.expr_1)
|
|
707
|
-
>>> m.
|
|
708
|
-
>>>
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
>>> m.expr_1.value
|
|
867
|
+
>>> m.maximize = m.expr_2 - 3
|
|
868
|
+
>>> m.attr.Silent = True
|
|
869
|
+
>>> m.optimize()
|
|
870
|
+
>>> m.expr_1.evaluate()
|
|
712
871
|
shape: (3, 2)
|
|
713
872
|
┌──────┬──────────┐
|
|
714
873
|
│ dim1 ┆ solution │
|
|
@@ -719,66 +878,76 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
719
878
|
│ 2 ┆ 21.0 │
|
|
720
879
|
│ 3 ┆ 21.0 │
|
|
721
880
|
└──────┴──────────┘
|
|
722
|
-
>>> m.expr_2.
|
|
881
|
+
>>> m.expr_2.evaluate()
|
|
723
882
|
63.0
|
|
724
883
|
"""
|
|
725
884
|
assert (
|
|
726
885
|
self._model is not None
|
|
727
886
|
), "Expression must be added to the model to use .value"
|
|
728
|
-
if self._model.result is None or self._model.result.solution is None:
|
|
729
|
-
raise ValueError(
|
|
730
|
-
"Can't obtain value of expression since the model has not been solved."
|
|
731
|
-
)
|
|
732
887
|
|
|
733
|
-
df =
|
|
734
|
-
|
|
735
|
-
|
|
888
|
+
df = self.data
|
|
889
|
+
sm = self._model.poi
|
|
890
|
+
attr = poi.VariableAttribute.Value
|
|
891
|
+
for var_col in self._variable_columns:
|
|
892
|
+
df = df.with_columns(
|
|
736
893
|
(
|
|
737
|
-
pl.
|
|
738
|
-
.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
894
|
+
pl.col(COEF_KEY)
|
|
895
|
+
* pl.col(var_col).map_elements(
|
|
896
|
+
lambda v_id: (
|
|
897
|
+
sm.get_variable_attribute(poi.VariableIndex(v_id), attr)
|
|
898
|
+
if v_id != CONST_TERM
|
|
899
|
+
else 1
|
|
900
|
+
),
|
|
901
|
+
return_dtype=pl.Float64,
|
|
902
|
+
)
|
|
903
|
+
).alias(COEF_KEY)
|
|
904
|
+
).drop(var_col)
|
|
905
|
+
|
|
906
|
+
df = df.rename({COEF_KEY: SOLUTION_KEY})
|
|
746
907
|
|
|
747
908
|
dims = self.dimensions
|
|
748
909
|
if dims is not None:
|
|
749
910
|
df = df.group_by(dims, maintain_order=True)
|
|
750
911
|
return df.sum()
|
|
751
912
|
|
|
752
|
-
def
|
|
753
|
-
self
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
913
|
+
def to_poi(self) -> poi.ScalarAffineFunction:
|
|
914
|
+
if self.dimensions is not None:
|
|
915
|
+
raise ValueError(
|
|
916
|
+
"Only non-dimensioned expressions can be converted to PyOptInterface."
|
|
917
|
+
) # pragma: no cover
|
|
918
|
+
|
|
919
|
+
return poi.ScalarAffineFunction(
|
|
920
|
+
coefficients=self.data.get_column(COEF_KEY).to_numpy(),
|
|
921
|
+
variables=self.data.get_column(VAR_KEY).to_numpy(),
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
def to_str_table(self, include_const_term=True):
|
|
761
925
|
data = self.data if include_const_term else self.variable_terms
|
|
762
|
-
data = cast_coef_to_string(data
|
|
926
|
+
data = cast_coef_to_string(data)
|
|
763
927
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
data = data.drop(VAR_KEY).rename({"str_var": VAR_KEY})
|
|
775
|
-
else:
|
|
928
|
+
for var_col in self._variable_columns:
|
|
929
|
+
temp_var_column = f"{var_col}_temp"
|
|
930
|
+
if self._model is not None and self._model.var_map is not None:
|
|
931
|
+
data = self._model.var_map.apply(
|
|
932
|
+
data, to_col=temp_var_column, id_col=var_col
|
|
933
|
+
)
|
|
934
|
+
else:
|
|
935
|
+
data = data.with_columns(
|
|
936
|
+
pl.concat_str(pl.lit("x"), var_col).alias(temp_var_column)
|
|
937
|
+
)
|
|
776
938
|
data = data.with_columns(
|
|
777
|
-
pl.when(pl.col(
|
|
939
|
+
pl.when(pl.col(var_col) == CONST_TERM)
|
|
778
940
|
.then(pl.lit(""))
|
|
779
|
-
.otherwise(
|
|
941
|
+
.otherwise(temp_var_column)
|
|
942
|
+
.alias(var_col)
|
|
943
|
+
).drop(temp_var_column)
|
|
944
|
+
if self.is_quadratic:
|
|
945
|
+
data = data.with_columns(
|
|
946
|
+
pl.when(pl.col(QUAD_VAR_KEY) == "")
|
|
947
|
+
.then(pl.col(VAR_KEY))
|
|
948
|
+
.otherwise(pl.concat_str(VAR_KEY, pl.lit(" * "), pl.col(QUAD_VAR_KEY)))
|
|
780
949
|
.alias(VAR_KEY)
|
|
781
|
-
).drop(
|
|
950
|
+
).drop(QUAD_VAR_KEY)
|
|
782
951
|
|
|
783
952
|
dimensions = self.dimensions
|
|
784
953
|
|
|
@@ -791,7 +960,6 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
791
960
|
)
|
|
792
961
|
).drop(COEF_KEY, VAR_KEY)
|
|
793
962
|
|
|
794
|
-
# Combine terms into one string
|
|
795
963
|
if dimensions is not None:
|
|
796
964
|
data = data.group_by(dimensions, maintain_order=True).agg(
|
|
797
965
|
pl.col("expr").str.concat(delimiter=" ")
|
|
@@ -803,15 +971,15 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
803
971
|
data = data.with_columns(pl.col("expr").str.strip_chars(characters=" +"))
|
|
804
972
|
|
|
805
973
|
# TODO add vertical ... if too many rows, in the middle of the table
|
|
806
|
-
if
|
|
807
|
-
data = data.head(
|
|
974
|
+
if Config.print_max_lines:
|
|
975
|
+
data = data.head(Config.print_max_lines)
|
|
808
976
|
|
|
809
|
-
if
|
|
977
|
+
if Config.print_max_line_length:
|
|
810
978
|
data = data.with_columns(
|
|
811
|
-
pl.when(pl.col("expr").str.len_chars() >
|
|
979
|
+
pl.when(pl.col("expr").str.len_chars() > Config.print_max_line_length)
|
|
812
980
|
.then(
|
|
813
981
|
pl.concat_str(
|
|
814
|
-
pl.col("expr").str.slice(0,
|
|
982
|
+
pl.col("expr").str.slice(0, Config.print_max_line_length),
|
|
815
983
|
pl.lit("..."),
|
|
816
984
|
)
|
|
817
985
|
)
|
|
@@ -820,54 +988,45 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
|
820
988
|
return data
|
|
821
989
|
|
|
822
990
|
def to_str_create_prefix(self, data):
|
|
823
|
-
if self.name is
|
|
824
|
-
|
|
825
|
-
|
|
991
|
+
if self.name is None and self.dimensions is None:
|
|
992
|
+
return data
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
concat_dimensions(data, prefix=self.name, ignore_columns=["expr"])
|
|
996
|
+
.with_columns(
|
|
826
997
|
pl.concat_str("concated_dim", pl.lit(": "), "expr").alias("expr")
|
|
827
|
-
)
|
|
828
|
-
|
|
998
|
+
)
|
|
999
|
+
.drop("concated_dim")
|
|
1000
|
+
)
|
|
829
1001
|
|
|
830
1002
|
def to_str(
|
|
831
1003
|
self,
|
|
832
|
-
max_line_len=None,
|
|
833
|
-
max_rows=None,
|
|
834
1004
|
include_const_term=True,
|
|
835
|
-
include_const_variable=False,
|
|
836
|
-
var_map=None,
|
|
837
|
-
include_prefix=True,
|
|
838
1005
|
include_header=False,
|
|
839
1006
|
include_data=True,
|
|
840
|
-
float_precision=None,
|
|
841
1007
|
):
|
|
842
1008
|
result = ""
|
|
843
1009
|
if include_header:
|
|
844
1010
|
result += get_obj_repr(
|
|
845
|
-
self,
|
|
1011
|
+
self,
|
|
1012
|
+
size=len(self),
|
|
1013
|
+
dimensions=self.shape,
|
|
1014
|
+
terms=len(self.data),
|
|
1015
|
+
degree=2 if self.degree() == 2 else None,
|
|
846
1016
|
)
|
|
847
1017
|
if include_header and include_data:
|
|
848
1018
|
result += "\n"
|
|
849
1019
|
if include_data:
|
|
850
1020
|
str_table = self.to_str_table(
|
|
851
|
-
max_line_len=max_line_len,
|
|
852
|
-
max_rows=max_rows,
|
|
853
1021
|
include_const_term=include_const_term,
|
|
854
|
-
include_const_variable=include_const_variable,
|
|
855
|
-
var_map=var_map,
|
|
856
|
-
float_precision=float_precision,
|
|
857
1022
|
)
|
|
858
|
-
|
|
859
|
-
str_table = self.to_str_create_prefix(str_table)
|
|
1023
|
+
str_table = self.to_str_create_prefix(str_table)
|
|
860
1024
|
result += str_table.select(pl.col("expr").str.concat(delimiter="\n")).item()
|
|
861
1025
|
|
|
862
1026
|
return result
|
|
863
1027
|
|
|
864
1028
|
def __repr__(self) -> str:
|
|
865
|
-
return self.to_str(
|
|
866
|
-
max_line_len=80,
|
|
867
|
-
max_rows=15,
|
|
868
|
-
include_header=True,
|
|
869
|
-
float_precision=Config.print_float_precision,
|
|
870
|
-
)
|
|
1029
|
+
return self.to_str(include_header=True)
|
|
871
1030
|
|
|
872
1031
|
def __str__(self) -> str:
|
|
873
1032
|
return self.to_str()
|
|
@@ -918,80 +1077,157 @@ class Constraint(ModelElementWithId):
|
|
|
918
1077
|
"""Initialize a constraint.
|
|
919
1078
|
|
|
920
1079
|
Parameters:
|
|
921
|
-
lhs:
|
|
1080
|
+
lhs:
|
|
922
1081
|
The left hand side of the constraint.
|
|
923
|
-
sense:
|
|
1082
|
+
sense:
|
|
924
1083
|
The sense of the constraint.
|
|
925
1084
|
"""
|
|
926
1085
|
self.lhs = lhs
|
|
927
1086
|
self._model = lhs._model
|
|
928
1087
|
self.sense = sense
|
|
929
1088
|
self.to_relax: Optional[FuncArgs] = None
|
|
1089
|
+
self.attr = Container(self._set_attribute, self._get_attribute)
|
|
930
1090
|
|
|
931
1091
|
dims = self.lhs.dimensions
|
|
932
1092
|
data = pl.DataFrame() if dims is None else self.lhs.data.select(dims).unique()
|
|
933
1093
|
|
|
934
1094
|
super().__init__(data)
|
|
935
1095
|
|
|
1096
|
+
def _set_attribute(self, name, value):
|
|
1097
|
+
self._assert_has_ids()
|
|
1098
|
+
col_name = name
|
|
1099
|
+
try:
|
|
1100
|
+
name = poi.ConstraintAttribute[name]
|
|
1101
|
+
setter = self._model.poi.set_constraint_attribute
|
|
1102
|
+
except KeyError:
|
|
1103
|
+
setter = self._model.poi.set_constraint_raw_attribute
|
|
1104
|
+
|
|
1105
|
+
if self.dimensions is None:
|
|
1106
|
+
for key in self.data.get_column(CONSTRAINT_KEY):
|
|
1107
|
+
setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
|
|
1108
|
+
else:
|
|
1109
|
+
for key, value in (
|
|
1110
|
+
self.data.join(value, on=self.dimensions, how="inner")
|
|
1111
|
+
.select(pl.col(CONSTRAINT_KEY), pl.col(col_name))
|
|
1112
|
+
.iter_rows()
|
|
1113
|
+
):
|
|
1114
|
+
setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
|
|
1115
|
+
|
|
1116
|
+
@unwrap_single_values
|
|
1117
|
+
def _get_attribute(self, name):
|
|
1118
|
+
self._assert_has_ids()
|
|
1119
|
+
col_name = name
|
|
1120
|
+
try:
|
|
1121
|
+
name = poi.ConstraintAttribute[name]
|
|
1122
|
+
getter = self._model.poi.get_constraint_attribute
|
|
1123
|
+
except KeyError:
|
|
1124
|
+
getter = self._model.poi.get_constraint_raw_attribute
|
|
1125
|
+
|
|
1126
|
+
with (
|
|
1127
|
+
warnings.catch_warnings()
|
|
1128
|
+
): # map_elements without return_dtype= gives a warning
|
|
1129
|
+
warnings.filterwarnings(
|
|
1130
|
+
action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
|
|
1131
|
+
)
|
|
1132
|
+
return self.data.with_columns(
|
|
1133
|
+
pl.col(CONSTRAINT_KEY)
|
|
1134
|
+
.map_elements(
|
|
1135
|
+
lambda v_id: getter(
|
|
1136
|
+
poi.ConstraintIndex(poi.ConstraintType.Linear, v_id), name
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
.alias(col_name)
|
|
1140
|
+
).select(self.dimensions_unsafe + [col_name])
|
|
1141
|
+
|
|
936
1142
|
def on_add_to_model(self, model: "Model", name: str):
|
|
937
1143
|
super().on_add_to_model(model, name)
|
|
938
1144
|
if self.to_relax is not None:
|
|
939
1145
|
self.relax(*self.to_relax.args, **self.to_relax.kwargs)
|
|
1146
|
+
self._assign_ids()
|
|
940
1147
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
The first call to this property will load the slack values from the solver (lazy loading).
|
|
948
|
-
"""
|
|
949
|
-
if SLACK_COL not in self.data.columns:
|
|
950
|
-
assert (
|
|
951
|
-
self._model is not None
|
|
952
|
-
), "Constraint must be added to a model to get the slack."
|
|
953
|
-
if self._model.solver is None:
|
|
954
|
-
raise ValueError("The model has not been solved yet.")
|
|
955
|
-
self._model.solver.load_slack()
|
|
956
|
-
return self.data.select(self.dimensions_unsafe + [SLACK_COL])
|
|
1148
|
+
def _assign_ids(self):
|
|
1149
|
+
assert self._model is not None
|
|
1150
|
+
|
|
1151
|
+
is_quadratic = self.lhs.is_quadratic
|
|
1152
|
+
use_var_names = self._model.use_var_names
|
|
1153
|
+
kwargs: Dict[str, Any] = dict(sense=self.sense.to_poi(), rhs=0)
|
|
957
1154
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1155
|
+
key_cols = [COEF_KEY] + self.lhs._variable_columns
|
|
1156
|
+
key_cols_polars = [pl.col(c) for c in key_cols]
|
|
1157
|
+
|
|
1158
|
+
add_constraint = (
|
|
1159
|
+
self._model.poi.add_quadratic_constraint
|
|
1160
|
+
if is_quadratic
|
|
1161
|
+
else self._model.poi.add_linear_constraint
|
|
1162
|
+
)
|
|
1163
|
+
ScalarFunction = (
|
|
1164
|
+
poi.ScalarQuadraticFunction if is_quadratic else poi.ScalarAffineFunction
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
if self.dimensions is None:
|
|
1168
|
+
if self._model.use_var_names:
|
|
1169
|
+
kwargs["name"] = self.name
|
|
1170
|
+
df = self.data.with_columns(
|
|
1171
|
+
pl.lit(
|
|
1172
|
+
add_constraint(
|
|
1173
|
+
ScalarFunction(
|
|
1174
|
+
*[self.lhs.data.get_column(c).to_numpy() for c in key_cols]
|
|
1175
|
+
),
|
|
1176
|
+
**kwargs,
|
|
1177
|
+
).index
|
|
1178
|
+
)
|
|
1179
|
+
.alias(CONSTRAINT_KEY)
|
|
1180
|
+
.cast(KEY_TYPE)
|
|
1181
|
+
)
|
|
1182
|
+
else:
|
|
1183
|
+
df = self.lhs.data.group_by(self.dimensions, maintain_order=True).agg(
|
|
1184
|
+
*key_cols_polars
|
|
1185
|
+
)
|
|
1186
|
+
if use_var_names:
|
|
1187
|
+
df = (
|
|
1188
|
+
concat_dimensions(df, prefix=self.name)
|
|
1189
|
+
.with_columns(
|
|
1190
|
+
pl.struct(*key_cols_polars, pl.col("concated_dim"))
|
|
1191
|
+
.map_elements(
|
|
1192
|
+
lambda x: add_constraint(
|
|
1193
|
+
ScalarFunction(*[np.array(x[c]) for c in key_cols]),
|
|
1194
|
+
name=x["concated_dim"],
|
|
1195
|
+
**kwargs,
|
|
1196
|
+
).index,
|
|
1197
|
+
return_dtype=KEY_TYPE,
|
|
1198
|
+
)
|
|
1199
|
+
.alias(CONSTRAINT_KEY)
|
|
1200
|
+
)
|
|
1201
|
+
.drop("concated_dim")
|
|
1202
|
+
)
|
|
1203
|
+
else:
|
|
1204
|
+
df = df.with_columns(
|
|
1205
|
+
pl.struct(*key_cols_polars)
|
|
1206
|
+
.map_elements(
|
|
1207
|
+
lambda x: add_constraint(
|
|
1208
|
+
ScalarFunction(*[np.array(x[c]) for c in key_cols]),
|
|
1209
|
+
**kwargs,
|
|
1210
|
+
).index,
|
|
1211
|
+
return_dtype=KEY_TYPE,
|
|
1212
|
+
)
|
|
1213
|
+
.alias(CONSTRAINT_KEY)
|
|
1214
|
+
)
|
|
1215
|
+
df = df.drop(key_cols)
|
|
1216
|
+
|
|
1217
|
+
self._data = df
|
|
961
1218
|
|
|
962
1219
|
@property
|
|
963
1220
|
@unwrap_single_values
|
|
964
1221
|
def dual(self) -> Union[pl.DataFrame, float]:
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
@dual.setter
|
|
970
|
-
def dual(self, value):
|
|
971
|
-
self._extend_dataframe_by_id(value)
|
|
1222
|
+
dual = self.attr.Dual
|
|
1223
|
+
if isinstance(dual, pl.DataFrame):
|
|
1224
|
+
dual = dual.rename({"Dual": DUAL_KEY})
|
|
1225
|
+
return dual
|
|
972
1226
|
|
|
973
1227
|
@classmethod
|
|
974
1228
|
def get_id_column_name(cls):
|
|
975
1229
|
return CONSTRAINT_KEY
|
|
976
1230
|
|
|
977
|
-
def to_str_create_prefix(self, data, const_map=None):
|
|
978
|
-
if const_map is None:
|
|
979
|
-
return self.lhs.to_str_create_prefix(data)
|
|
980
|
-
|
|
981
|
-
data_map = const_map.apply(self.ids, to_col=None)
|
|
982
|
-
|
|
983
|
-
if self.dimensions is None:
|
|
984
|
-
assert data.height == 1
|
|
985
|
-
prefix = data_map.select(pl.col(CONSTRAINT_KEY)).item()
|
|
986
|
-
return data.select(
|
|
987
|
-
pl.concat_str(pl.lit(f"{prefix}: "), "expr").alias("expr")
|
|
988
|
-
)
|
|
989
|
-
|
|
990
|
-
data = data.join(data_map, on=self.dimensions)
|
|
991
|
-
return data.with_columns(
|
|
992
|
-
pl.concat_str(CONSTRAINT_KEY, pl.lit(": "), "expr").alias("expr")
|
|
993
|
-
).drop(CONSTRAINT_KEY)
|
|
994
|
-
|
|
995
1231
|
def filter(self, *args, **kwargs) -> pl.DataFrame:
|
|
996
1232
|
return self.lhs.data.filter(*args, **kwargs)
|
|
997
1233
|
|
|
@@ -1002,25 +1238,64 @@ class Constraint(ModelElementWithId):
|
|
|
1002
1238
|
Relaxes the constraint by adding a variable to the constraint that can be non-zero at a cost.
|
|
1003
1239
|
|
|
1004
1240
|
Parameters:
|
|
1005
|
-
cost:
|
|
1241
|
+
cost:
|
|
1006
1242
|
The cost of relaxing the constraint. Costs should be positives as they will automatically
|
|
1007
1243
|
become negative for maximization problems.
|
|
1008
|
-
max:
|
|
1244
|
+
max:
|
|
1009
1245
|
The maximum value of the relaxation variable.
|
|
1010
1246
|
|
|
1011
1247
|
Returns:
|
|
1012
1248
|
The same constraint
|
|
1013
1249
|
|
|
1014
1250
|
Examples:
|
|
1015
|
-
>>>
|
|
1016
|
-
>>> m = pf.
|
|
1251
|
+
>>> m = pf.Model()
|
|
1252
|
+
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1253
|
+
>>> m.hours_day = pf.Variable(lb=0)
|
|
1254
|
+
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1255
|
+
>>> m.maximize = m.hours_day
|
|
1256
|
+
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1257
|
+
>>> m.optimize()
|
|
1258
|
+
>>> m.hours_day.solution
|
|
1259
|
+
16.0
|
|
1260
|
+
>>> m.maximize += 2 * m.hours_day
|
|
1261
|
+
>>> m.optimize()
|
|
1262
|
+
>>> m.hours_day.solution
|
|
1263
|
+
19.0
|
|
1264
|
+
|
|
1265
|
+
Note: .relax() can only be called after the sense of the model has been defined.
|
|
1266
|
+
|
|
1267
|
+
>>> m = pf.Model()
|
|
1268
|
+
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1269
|
+
>>> m.hours_day = pf.Variable(lb=0)
|
|
1270
|
+
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1271
|
+
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1272
|
+
Traceback (most recent call last):
|
|
1273
|
+
...
|
|
1274
|
+
ValueError: Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...).
|
|
1275
|
+
|
|
1276
|
+
One way to solve this is by setting the sense directly on the model. See how this works fine:
|
|
1277
|
+
|
|
1278
|
+
>>> m = pf.Model(sense="max")
|
|
1279
|
+
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1280
|
+
>>> m.hours_day = pf.Variable(lb=0)
|
|
1281
|
+
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1282
|
+
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1283
|
+
|
|
1284
|
+
And now an example with dimensions:
|
|
1285
|
+
|
|
1017
1286
|
>>> homework_due_tomorrow = pl.DataFrame({"project": ["A", "B", "C"], "cost_per_hour_underdelivered": [10, 20, 30], "hours_to_finish": [9, 9, 9], "max_underdelivered": [1, 9, 9]})
|
|
1018
1287
|
>>> m.hours_spent = pf.Variable(homework_due_tomorrow[["project"]], lb=0)
|
|
1019
|
-
>>> m.must_finish_project = m.hours_spent >= homework_due_tomorrow[["project", "hours_to_finish"]]
|
|
1288
|
+
>>> m.must_finish_project = (m.hours_spent >= homework_due_tomorrow[["project", "hours_to_finish"]]).relax(homework_due_tomorrow[["project", "cost_per_hour_underdelivered"]], max=homework_due_tomorrow[["project", "max_underdelivered"]])
|
|
1020
1289
|
>>> m.only_one_day = sum("project", m.hours_spent) <= 24
|
|
1021
|
-
>>>
|
|
1022
|
-
>>>
|
|
1023
|
-
|
|
1290
|
+
>>> # Relaxing a constraint after it has already been assigned will give an error
|
|
1291
|
+
>>> m.only_one_day.relax(1)
|
|
1292
|
+
Traceback (most recent call last):
|
|
1293
|
+
...
|
|
1294
|
+
ValueError: .relax() must be called before the Constraint is added to the model
|
|
1295
|
+
>>> m.attr.Silent = True
|
|
1296
|
+
>>> m.optimize()
|
|
1297
|
+
>>> m.maximize.value
|
|
1298
|
+
-50.0
|
|
1024
1299
|
>>> m.hours_spent.solution
|
|
1025
1300
|
shape: (3, 2)
|
|
1026
1301
|
┌─────────┬──────────┐
|
|
@@ -1032,30 +1307,12 @@ class Constraint(ModelElementWithId):
|
|
|
1032
1307
|
│ B ┆ 7.0 │
|
|
1033
1308
|
│ C ┆ 9.0 │
|
|
1034
1309
|
└─────────┴──────────┘
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
>>> # It can also be done all in one go!
|
|
1038
|
-
>>> m = pf.Model("max")
|
|
1039
|
-
>>> homework_due_tomorrow = pl.DataFrame({"project": ["A", "B", "C"], "cost_per_hour_underdelivered": [10, 20, 30], "hours_to_finish": [9, 9, 9], "max_underdelivered": [1, 9, 9]})
|
|
1040
|
-
>>> m.hours_spent = pf.Variable(homework_due_tomorrow[["project"]], lb=0)
|
|
1041
|
-
>>> m.must_finish_project = (m.hours_spent >= homework_due_tomorrow[["project", "hours_to_finish"]]).relax(5)
|
|
1042
|
-
>>> m.only_one_day = (sum("project", m.hours_spent) <= 24).relax(1)
|
|
1043
|
-
>>> _ = m.solve(log_to_console=False) # doctest: +ELLIPSIS
|
|
1044
|
-
\rWriting ...
|
|
1045
|
-
>>> m.objective.value
|
|
1046
|
-
-3.0
|
|
1047
|
-
>>> m.hours_spent.solution
|
|
1048
|
-
shape: (3, 2)
|
|
1049
|
-
┌─────────┬──────────┐
|
|
1050
|
-
│ project ┆ solution │
|
|
1051
|
-
│ --- ┆ --- │
|
|
1052
|
-
│ str ┆ f64 │
|
|
1053
|
-
╞═════════╪══════════╡
|
|
1054
|
-
│ A ┆ 9.0 │
|
|
1055
|
-
│ B ┆ 9.0 │
|
|
1056
|
-
│ C ┆ 9.0 │
|
|
1057
|
-
└─────────┴──────────┘
|
|
1058
1310
|
"""
|
|
1311
|
+
if self._has_ids:
|
|
1312
|
+
raise ValueError(
|
|
1313
|
+
".relax() must be called before the Constraint is added to the model"
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1059
1316
|
m = self._model
|
|
1060
1317
|
if m is None or self.name is None:
|
|
1061
1318
|
self.to_relax = FuncArgs(args=[cost, max])
|
|
@@ -1066,6 +1323,7 @@ class Constraint(ModelElementWithId):
|
|
|
1066
1323
|
m, var_name
|
|
1067
1324
|
), "Conflicting names, relaxation variable already exists on the model."
|
|
1068
1325
|
var = Variable(self, lb=0, ub=max)
|
|
1326
|
+
setattr(m, var_name, var)
|
|
1069
1327
|
|
|
1070
1328
|
if self.sense == ConstraintSense.LE:
|
|
1071
1329
|
self.lhs -= var
|
|
@@ -1077,11 +1335,14 @@ class Constraint(ModelElementWithId):
|
|
|
1077
1335
|
"Relaxation for equalities has not yet been implemented. Submit a pull request!"
|
|
1078
1336
|
)
|
|
1079
1337
|
|
|
1080
|
-
setattr(m, var_name, var)
|
|
1081
1338
|
penalty = var * cost
|
|
1082
1339
|
if self.dimensions:
|
|
1083
1340
|
penalty = sum(self.dimensions, penalty)
|
|
1084
|
-
if m.sense
|
|
1341
|
+
if m.sense is None:
|
|
1342
|
+
raise ValueError(
|
|
1343
|
+
"Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...)."
|
|
1344
|
+
)
|
|
1345
|
+
elif m.sense == ObjSense.MAX:
|
|
1085
1346
|
penalty *= -1
|
|
1086
1347
|
if m.objective is None:
|
|
1087
1348
|
m.objective = penalty
|
|
@@ -1090,24 +1351,12 @@ class Constraint(ModelElementWithId):
|
|
|
1090
1351
|
|
|
1091
1352
|
return self
|
|
1092
1353
|
|
|
1093
|
-
def to_str(
|
|
1094
|
-
self,
|
|
1095
|
-
max_line_len=None,
|
|
1096
|
-
max_rows=None,
|
|
1097
|
-
var_map=None,
|
|
1098
|
-
float_precision=None,
|
|
1099
|
-
const_map=None,
|
|
1100
|
-
) -> str:
|
|
1354
|
+
def to_str(self) -> str:
|
|
1101
1355
|
dims = self.dimensions
|
|
1102
|
-
str_table = self.lhs.to_str_table(
|
|
1103
|
-
|
|
1104
|
-
max_rows=max_rows,
|
|
1105
|
-
include_const_term=False,
|
|
1106
|
-
var_map=var_map,
|
|
1107
|
-
)
|
|
1108
|
-
str_table = self.to_str_create_prefix(str_table, const_map=const_map)
|
|
1356
|
+
str_table = self.lhs.to_str_table(include_const_term=False)
|
|
1357
|
+
str_table = self.lhs.to_str_create_prefix(str_table)
|
|
1109
1358
|
rhs = self.lhs.constant_terms.with_columns(pl.col(COEF_KEY) * -1)
|
|
1110
|
-
rhs = cast_coef_to_string(rhs, drop_ones=False
|
|
1359
|
+
rhs = cast_coef_to_string(rhs, drop_ones=False)
|
|
1111
1360
|
# Remove leading +
|
|
1112
1361
|
rhs = rhs.with_columns(pl.col(COEF_KEY).str.strip_chars(characters=" +"))
|
|
1113
1362
|
rhs = rhs.rename({COEF_KEY: "rhs"})
|
|
@@ -1132,7 +1381,7 @@ class Constraint(ModelElementWithId):
|
|
|
1132
1381
|
terms=len(self.lhs.data),
|
|
1133
1382
|
)
|
|
1134
1383
|
+ "\n"
|
|
1135
|
-
+ self.to_str(
|
|
1384
|
+
+ self.to_str()
|
|
1136
1385
|
)
|
|
1137
1386
|
|
|
1138
1387
|
|
|
@@ -1141,39 +1390,53 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
|
1141
1390
|
Represents one or many decision variable in an optimization model.
|
|
1142
1391
|
|
|
1143
1392
|
Parameters:
|
|
1144
|
-
*indexing_sets:
|
|
1393
|
+
*indexing_sets:
|
|
1145
1394
|
If no indexing_sets are provided, a single variable with no dimensions is created.
|
|
1146
1395
|
Otherwise, a variable is created for each element in the Cartesian product of the indexing_sets (see Set for details on behaviour).
|
|
1147
|
-
lb:
|
|
1396
|
+
lb:
|
|
1148
1397
|
The lower bound for all variables.
|
|
1149
|
-
ub:
|
|
1398
|
+
ub:
|
|
1150
1399
|
The upper bound for all variables.
|
|
1151
|
-
vtype:
|
|
1400
|
+
vtype:
|
|
1152
1401
|
The type of the variable. Can be either a VType enum or a string. Default is VType.CONTINUOUS.
|
|
1153
|
-
equals:
|
|
1402
|
+
equals:
|
|
1154
1403
|
When specified, a variable is created and a constraint is added to make the variable equal to the provided expression.
|
|
1155
1404
|
|
|
1156
1405
|
Examples:
|
|
1157
1406
|
>>> import pandas as pd
|
|
1158
|
-
>>>
|
|
1407
|
+
>>> m = pf.Model()
|
|
1159
1408
|
>>> df = pd.DataFrame({"dim1": [1, 1, 2, 2, 3, 3], "dim2": ["a", "b", "a", "b", "a", "b"]})
|
|
1160
|
-
>>> Variable(df)
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1409
|
+
>>> v = Variable(df)
|
|
1410
|
+
>>> v
|
|
1411
|
+
<Variable size=6 dimensions={'dim1': 3, 'dim2': 2} added_to_model=False>
|
|
1412
|
+
|
|
1413
|
+
Variables cannot be used until they're added to the model.
|
|
1414
|
+
|
|
1415
|
+
>>> m.constraint = v <= 3
|
|
1416
|
+
Traceback (most recent call last):
|
|
1417
|
+
...
|
|
1418
|
+
ValueError: Cannot use 'Variable' before it has beed added to a model.
|
|
1419
|
+
>>> m.v = v
|
|
1420
|
+
>>> m.constraint = m.v <= 3
|
|
1421
|
+
|
|
1422
|
+
>>> m.v
|
|
1423
|
+
<Variable name=v size=6 dimensions={'dim1': 3, 'dim2': 2}>
|
|
1424
|
+
[1,a]: v[1,a]
|
|
1425
|
+
[1,b]: v[1,b]
|
|
1426
|
+
[2,a]: v[2,a]
|
|
1427
|
+
[2,b]: v[2,b]
|
|
1428
|
+
[3,a]: v[3,a]
|
|
1429
|
+
[3,b]: v[3,b]
|
|
1430
|
+
>>> m.v2 = Variable(df[["dim1"]])
|
|
1169
1431
|
Traceback (most recent call last):
|
|
1170
1432
|
...
|
|
1171
1433
|
ValueError: Duplicate rows found in input data.
|
|
1172
|
-
>>> Variable(df[["dim1"]].drop_duplicates())
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
[
|
|
1176
|
-
[
|
|
1434
|
+
>>> m.v3 = Variable(df[["dim1"]].drop_duplicates())
|
|
1435
|
+
>>> m.v3
|
|
1436
|
+
<Variable name=v3 size=3 dimensions={'dim1': 3}>
|
|
1437
|
+
[1]: v3[1]
|
|
1438
|
+
[2]: v3[2]
|
|
1439
|
+
[3]: v3[3]
|
|
1177
1440
|
"""
|
|
1178
1441
|
|
|
1179
1442
|
# TODO: Breaking change, remove support for Iterable[AcceptableSets]
|
|
@@ -1185,10 +1448,6 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
|
1185
1448
|
vtype: VType | VTypeValue = VType.CONTINUOUS,
|
|
1186
1449
|
equals: Optional[SupportsMath] = None,
|
|
1187
1450
|
):
|
|
1188
|
-
if lb is None:
|
|
1189
|
-
lb = float("-inf")
|
|
1190
|
-
if ub is None:
|
|
1191
|
-
ub = float("inf")
|
|
1192
1451
|
if equals is not None:
|
|
1193
1452
|
assert (
|
|
1194
1453
|
len(indexing_sets) == 0
|
|
@@ -1199,28 +1458,106 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
|
1199
1458
|
super().__init__(data)
|
|
1200
1459
|
|
|
1201
1460
|
self.vtype: VType = VType(vtype)
|
|
1461
|
+
self.attr = Container(self._set_attribute, self._get_attribute)
|
|
1202
1462
|
self._equals = equals
|
|
1203
1463
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1464
|
+
if lb is not None and not isinstance(lb, (float, int)):
|
|
1465
|
+
self._lb_expr, self.lb = lb, None
|
|
1466
|
+
else:
|
|
1467
|
+
self._lb_expr, self.lb = None, lb
|
|
1468
|
+
if ub is not None and not isinstance(ub, (float, int)):
|
|
1469
|
+
self._ub_expr, self.ub = ub, None
|
|
1470
|
+
else:
|
|
1471
|
+
self._ub_expr, self.ub = None, ub
|
|
1472
|
+
|
|
1473
|
+
def _set_attribute(self, name, value):
|
|
1474
|
+
self._assert_has_ids()
|
|
1475
|
+
col_name = name
|
|
1476
|
+
try:
|
|
1477
|
+
name = poi.VariableAttribute[name]
|
|
1478
|
+
setter = self._model.poi.set_variable_attribute
|
|
1479
|
+
except KeyError:
|
|
1480
|
+
setter = self._model.poi.set_variable_raw_attribute
|
|
1207
1481
|
|
|
1208
|
-
if
|
|
1209
|
-
|
|
1482
|
+
if self.dimensions is None:
|
|
1483
|
+
for key in self.data.get_column(VAR_KEY):
|
|
1484
|
+
setter(poi.VariableIndex(key), name, value)
|
|
1210
1485
|
else:
|
|
1211
|
-
|
|
1486
|
+
for key, v in (
|
|
1487
|
+
self.data.join(value, on=self.dimensions, how="inner")
|
|
1488
|
+
.select(pl.col(VAR_KEY), pl.col(col_name))
|
|
1489
|
+
.iter_rows()
|
|
1490
|
+
):
|
|
1491
|
+
setter(poi.VariableIndex(key), name, v)
|
|
1212
1492
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1493
|
+
@unwrap_single_values
|
|
1494
|
+
def _get_attribute(self, name):
|
|
1495
|
+
self._assert_has_ids()
|
|
1496
|
+
col_name = name
|
|
1497
|
+
try:
|
|
1498
|
+
name = poi.VariableAttribute[name]
|
|
1499
|
+
getter = self._model.poi.get_variable_attribute
|
|
1500
|
+
except KeyError:
|
|
1501
|
+
getter = self._model.poi.get_variable_raw_attribute
|
|
1502
|
+
|
|
1503
|
+
with (
|
|
1504
|
+
warnings.catch_warnings()
|
|
1505
|
+
): # map_elements without return_dtype= gives a warning
|
|
1506
|
+
warnings.filterwarnings(
|
|
1507
|
+
action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
|
|
1508
|
+
)
|
|
1509
|
+
return self.data.with_columns(
|
|
1510
|
+
pl.col(VAR_KEY)
|
|
1511
|
+
.map_elements(lambda v_id: getter(poi.VariableIndex(v_id), name))
|
|
1512
|
+
.alias(col_name)
|
|
1513
|
+
).select(self.dimensions_unsafe + [col_name])
|
|
1514
|
+
|
|
1515
|
+
def _assign_ids(self):
|
|
1516
|
+
kwargs = dict(domain=self.vtype.to_poi())
|
|
1517
|
+
if self.lb is not None:
|
|
1518
|
+
kwargs["lb"] = self.lb
|
|
1519
|
+
if self.ub is not None:
|
|
1520
|
+
kwargs["ub"] = self.ub
|
|
1521
|
+
|
|
1522
|
+
if self.dimensions is not None and self._model.use_var_names:
|
|
1523
|
+
df = (
|
|
1524
|
+
concat_dimensions(self.data, prefix=self.name)
|
|
1525
|
+
.with_columns(
|
|
1526
|
+
pl.col("concated_dim")
|
|
1527
|
+
.map_elements(
|
|
1528
|
+
lambda name: self._model.poi.add_variable(
|
|
1529
|
+
name=name, **kwargs
|
|
1530
|
+
).index,
|
|
1531
|
+
return_dtype=KEY_TYPE,
|
|
1532
|
+
)
|
|
1533
|
+
.alias(VAR_KEY)
|
|
1534
|
+
)
|
|
1535
|
+
.drop("concated_dim")
|
|
1536
|
+
)
|
|
1215
1537
|
else:
|
|
1216
|
-
|
|
1538
|
+
if self._model.use_var_names:
|
|
1539
|
+
kwargs["name"] = self.name
|
|
1540
|
+
|
|
1541
|
+
df = self.data.with_columns(
|
|
1542
|
+
pl.lit(0).alias(VAR_KEY).cast(KEY_TYPE)
|
|
1543
|
+
).with_columns(
|
|
1544
|
+
pl.col(VAR_KEY).map_elements(
|
|
1545
|
+
lambda _: self._model.poi.add_variable(**kwargs).index,
|
|
1546
|
+
return_dtype=KEY_TYPE,
|
|
1547
|
+
)
|
|
1548
|
+
)
|
|
1217
1549
|
|
|
1218
|
-
|
|
1550
|
+
self._data = df
|
|
1551
|
+
|
|
1552
|
+
def on_add_to_model(self, model, name):
|
|
1219
1553
|
super().on_add_to_model(model, name)
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1554
|
+
self._assign_ids()
|
|
1555
|
+
if self._lb_expr is not None:
|
|
1556
|
+
setattr(model, f"{name}_lb", self._lb_expr <= self)
|
|
1557
|
+
|
|
1558
|
+
if self._ub_expr is not None:
|
|
1559
|
+
setattr(model, f"{name}_ub", self <= self._ub_expr)
|
|
1560
|
+
|
|
1224
1561
|
if self._equals is not None:
|
|
1225
1562
|
setattr(model, f"{name}_equals", self == self._equals)
|
|
1226
1563
|
|
|
@@ -1231,52 +1568,41 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
|
1231
1568
|
@property
|
|
1232
1569
|
@unwrap_single_values
|
|
1233
1570
|
def solution(self):
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
return
|
|
1238
|
-
|
|
1239
|
-
@property
|
|
1240
|
-
@unwrap_single_values
|
|
1241
|
-
def RC(self):
|
|
1242
|
-
"""
|
|
1243
|
-
The reduced cost of the variable.
|
|
1244
|
-
Will raise an error if the model has not already been solved.
|
|
1245
|
-
The first call to this property will load the reduced costs from the solver (lazy loading).
|
|
1246
|
-
"""
|
|
1247
|
-
if RC_COL not in self.data.columns:
|
|
1248
|
-
assert (
|
|
1249
|
-
self._model is not None
|
|
1250
|
-
), "Variable must be added to a model to get the reduced cost."
|
|
1251
|
-
if self._model.solver is None:
|
|
1252
|
-
raise ValueError("The model has not been solved yet.")
|
|
1253
|
-
self._model.solver.load_rc()
|
|
1254
|
-
return self.data.select(self.dimensions_unsafe + [RC_COL])
|
|
1255
|
-
|
|
1256
|
-
@RC.setter
|
|
1257
|
-
def RC(self, value):
|
|
1258
|
-
self._extend_dataframe_by_id(value)
|
|
1259
|
-
|
|
1260
|
-
@solution.setter
|
|
1261
|
-
def solution(self, value):
|
|
1262
|
-
self._extend_dataframe_by_id(value)
|
|
1571
|
+
solution = self.attr.Value
|
|
1572
|
+
if isinstance(solution, pl.DataFrame):
|
|
1573
|
+
solution = solution.rename({"Value": SOLUTION_KEY})
|
|
1574
|
+
return solution
|
|
1263
1575
|
|
|
1264
1576
|
def __repr__(self):
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1577
|
+
if self._has_ids:
|
|
1578
|
+
return (
|
|
1579
|
+
get_obj_repr(
|
|
1580
|
+
self,
|
|
1581
|
+
("name", "lb", "ub"),
|
|
1582
|
+
size=self.data.height,
|
|
1583
|
+
dimensions=self.shape,
|
|
1584
|
+
)
|
|
1585
|
+
+ "\n"
|
|
1586
|
+
+ self.to_expr().to_str()
|
|
1587
|
+
)
|
|
1588
|
+
else:
|
|
1589
|
+
return get_obj_repr(
|
|
1590
|
+
self,
|
|
1591
|
+
("name", "lb", "ub"),
|
|
1592
|
+
size=self.data.height,
|
|
1593
|
+
dimensions=self.shape,
|
|
1594
|
+
added_to_model=False,
|
|
1268
1595
|
)
|
|
1269
|
-
+ "\n"
|
|
1270
|
-
+ self.to_expr().to_str(max_line_len=80, max_rows=10)
|
|
1271
|
-
)
|
|
1272
1596
|
|
|
1273
1597
|
def to_expr(self) -> Expression:
|
|
1598
|
+
self._assert_has_ids()
|
|
1274
1599
|
if POLARS_VERSION.major < 1:
|
|
1275
1600
|
return self._new(self.data.drop(SOLUTION_KEY))
|
|
1276
1601
|
else:
|
|
1277
1602
|
return self._new(self.data.drop(SOLUTION_KEY, strict=False))
|
|
1278
1603
|
|
|
1279
1604
|
def _new(self, data: pl.DataFrame):
|
|
1605
|
+
self._assert_has_ids()
|
|
1280
1606
|
e = Expression(data.with_columns(pl.lit(1.0).alias(COEF_KEY)))
|
|
1281
1607
|
e._model = self._model
|
|
1282
1608
|
# We propogate the unmatched strategy intentionally. Without this a .keep_unmatched() on a variable would always be lost.
|
|
@@ -1296,12 +1622,11 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
|
1296
1622
|
|
|
1297
1623
|
Examples:
|
|
1298
1624
|
>>> import pandas as pd
|
|
1299
|
-
>>> from pyoframe import Variable, Model
|
|
1300
1625
|
>>> time_dim = pd.DataFrame({"time": ["00:00", "06:00", "12:00", "18:00"]})
|
|
1301
1626
|
>>> space_dim = pd.DataFrame({"city": ["Toronto", "Berlin"]})
|
|
1302
|
-
>>> m = Model(
|
|
1303
|
-
>>> m.bat_charge = Variable(time_dim, space_dim)
|
|
1304
|
-
>>> m.bat_flow = Variable(time_dim, space_dim)
|
|
1627
|
+
>>> m = pf.Model()
|
|
1628
|
+
>>> m.bat_charge = pf.Variable(time_dim, space_dim)
|
|
1629
|
+
>>> m.bat_flow = pf.Variable(time_dim, space_dim)
|
|
1305
1630
|
>>> # Fails because the dimensions are not the same
|
|
1306
1631
|
>>> m.bat_charge + m.bat_flow == m.bat_charge.next("time")
|
|
1307
1632
|
Traceback (most recent call last):
|