pyoframe 0.2.0__py3-none-any.whl → 1.0.0a0__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 +21 -14
- pyoframe/_arithmetic.py +265 -159
- pyoframe/_constants.py +416 -0
- pyoframe/_core.py +2575 -0
- pyoframe/_model.py +578 -0
- pyoframe/_model_element.py +175 -0
- pyoframe/_monkey_patch.py +80 -0
- pyoframe/{objective.py → _objective.py} +49 -14
- pyoframe/{util.py → _utils.py} +106 -126
- pyoframe/_version.py +16 -3
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/METADATA +32 -25
- pyoframe-1.0.0a0.dist-info/RECORD +15 -0
- pyoframe/constants.py +0 -140
- pyoframe/core.py +0 -1794
- pyoframe/model.py +0 -408
- pyoframe/model_element.py +0 -184
- pyoframe/monkey_patch.py +0 -54
- pyoframe-0.2.0.dist-info/RECORD +0 -15
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/WHEEL +0 -0
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/licenses/LICENSE +0 -0
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/top_level.txt +0 -0
pyoframe/core.py
DELETED
|
@@ -1,1794 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import warnings
|
|
4
|
-
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import (
|
|
6
|
-
TYPE_CHECKING,
|
|
7
|
-
Any,
|
|
8
|
-
Dict,
|
|
9
|
-
Iterable,
|
|
10
|
-
List,
|
|
11
|
-
Mapping,
|
|
12
|
-
Optional,
|
|
13
|
-
Protocol,
|
|
14
|
-
Sequence,
|
|
15
|
-
Union,
|
|
16
|
-
overload,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
import numpy as np
|
|
20
|
-
import pandas as pd
|
|
21
|
-
import polars as pl
|
|
22
|
-
import pyoptinterface as poi
|
|
23
|
-
|
|
24
|
-
from pyoframe._arithmetic import (
|
|
25
|
-
_add_expressions,
|
|
26
|
-
_get_dimensions,
|
|
27
|
-
_multiply_expressions,
|
|
28
|
-
_simplify_expr_df,
|
|
29
|
-
)
|
|
30
|
-
from pyoframe.constants import (
|
|
31
|
-
COEF_KEY,
|
|
32
|
-
CONST_TERM,
|
|
33
|
-
CONSTRAINT_KEY,
|
|
34
|
-
DUAL_KEY,
|
|
35
|
-
KEY_TYPE,
|
|
36
|
-
QUAD_VAR_KEY,
|
|
37
|
-
RESERVED_COL_KEYS,
|
|
38
|
-
SOLUTION_KEY,
|
|
39
|
-
VAR_KEY,
|
|
40
|
-
Config,
|
|
41
|
-
ConstraintSense,
|
|
42
|
-
ObjSense,
|
|
43
|
-
PyoframeError,
|
|
44
|
-
UnmatchedStrategy,
|
|
45
|
-
VType,
|
|
46
|
-
VTypeValue,
|
|
47
|
-
)
|
|
48
|
-
from pyoframe.model_element import (
|
|
49
|
-
ModelElement,
|
|
50
|
-
ModelElementWithId,
|
|
51
|
-
SupportPolarsMethodMixin,
|
|
52
|
-
)
|
|
53
|
-
from pyoframe.util import (
|
|
54
|
-
Container,
|
|
55
|
-
FuncArgs,
|
|
56
|
-
cast_coef_to_string,
|
|
57
|
-
concat_dimensions,
|
|
58
|
-
dataframe_to_tupled_list,
|
|
59
|
-
get_obj_repr,
|
|
60
|
-
parse_inputs_as_iterable,
|
|
61
|
-
unwrap_single_values,
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
if TYPE_CHECKING: # pragma: no cover
|
|
65
|
-
from pyoframe.model import Model
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _forward_to_expression(func_name: str):
|
|
69
|
-
def wrapper(self: "SupportsMath", *args, **kwargs) -> "Expression":
|
|
70
|
-
expr = self.to_expr()
|
|
71
|
-
return getattr(expr, func_name)(*args, **kwargs)
|
|
72
|
-
|
|
73
|
-
return wrapper
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class SupportsToExpr(Protocol):
|
|
77
|
-
def to_expr(self) -> "Expression": ...
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class SupportsMath(ABC, SupportsToExpr):
|
|
81
|
-
"""Any object that can be converted into an expression."""
|
|
82
|
-
|
|
83
|
-
def __init__(self, **kwargs):
|
|
84
|
-
self.unmatched_strategy = UnmatchedStrategy.UNSET
|
|
85
|
-
self.allowed_new_dims: List[str] = []
|
|
86
|
-
super().__init__(**kwargs)
|
|
87
|
-
|
|
88
|
-
def keep_unmatched(self):
|
|
89
|
-
self.unmatched_strategy = UnmatchedStrategy.KEEP
|
|
90
|
-
return self
|
|
91
|
-
|
|
92
|
-
def drop_unmatched(self):
|
|
93
|
-
self.unmatched_strategy = UnmatchedStrategy.DROP
|
|
94
|
-
return self
|
|
95
|
-
|
|
96
|
-
def add_dim(self, *dims: str):
|
|
97
|
-
self.allowed_new_dims.extend(dims)
|
|
98
|
-
return self
|
|
99
|
-
|
|
100
|
-
@abstractmethod
|
|
101
|
-
def to_expr(self) -> "Expression": ...
|
|
102
|
-
|
|
103
|
-
__add__ = _forward_to_expression("__add__")
|
|
104
|
-
__mul__ = _forward_to_expression("__mul__")
|
|
105
|
-
sum = _forward_to_expression("sum")
|
|
106
|
-
map = _forward_to_expression("map")
|
|
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
|
-
|
|
127
|
-
def __neg__(self):
|
|
128
|
-
res = self.to_expr() * -1
|
|
129
|
-
# Negating a constant term should keep the unmatched strategy
|
|
130
|
-
res.unmatched_strategy = self.unmatched_strategy
|
|
131
|
-
return res
|
|
132
|
-
|
|
133
|
-
def __sub__(self, other):
|
|
134
|
-
"""
|
|
135
|
-
>>> import polars as pl
|
|
136
|
-
>>> m = pf.Model()
|
|
137
|
-
>>> df = pl.DataFrame({"dim1": [1,2,3], "value": [1,2,3]})
|
|
138
|
-
>>> m.v = pf.Variable(df["dim1"])
|
|
139
|
-
>>> m.v - df
|
|
140
|
-
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
141
|
-
[1]: v[1] -1
|
|
142
|
-
[2]: v[2] -2
|
|
143
|
-
[3]: v[3] -3
|
|
144
|
-
"""
|
|
145
|
-
if not isinstance(other, (int, float)):
|
|
146
|
-
other = other.to_expr()
|
|
147
|
-
return self.to_expr() + (-other)
|
|
148
|
-
|
|
149
|
-
def __rmul__(self, other):
|
|
150
|
-
return self.to_expr() * other
|
|
151
|
-
|
|
152
|
-
def __radd__(self, other):
|
|
153
|
-
return self.to_expr() + other
|
|
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
|
-
|
|
185
|
-
def __le__(self, other):
|
|
186
|
-
"""Equality constraint.
|
|
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
|
|
194
|
-
"""
|
|
195
|
-
return Constraint(self - other, ConstraintSense.LE)
|
|
196
|
-
|
|
197
|
-
def __ge__(self, other):
|
|
198
|
-
"""Equality constraint.
|
|
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
|
|
206
|
-
"""
|
|
207
|
-
return Constraint(self - other, ConstraintSense.GE)
|
|
208
|
-
|
|
209
|
-
def __eq__(self, value: object): # type: ignore
|
|
210
|
-
"""Equality constraint.
|
|
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
|
|
218
|
-
"""
|
|
219
|
-
return Constraint(self - value, ConstraintSense.EQ)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
SetTypes = Union[
|
|
223
|
-
pl.DataFrame,
|
|
224
|
-
pd.Index,
|
|
225
|
-
pd.DataFrame,
|
|
226
|
-
SupportsMath,
|
|
227
|
-
Mapping[str, Sequence[object]],
|
|
228
|
-
"Set",
|
|
229
|
-
"Constraint",
|
|
230
|
-
]
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
243
|
-
def __init__(self, *data: SetTypes | Iterable[SetTypes], **named_data):
|
|
244
|
-
data_list = list(data)
|
|
245
|
-
for name, set in named_data.items():
|
|
246
|
-
data_list.append({name: set})
|
|
247
|
-
df = self._parse_acceptable_sets(*data_list)
|
|
248
|
-
if not df.is_empty() and df.is_duplicated().any():
|
|
249
|
-
raise ValueError("Duplicate rows found in input data.")
|
|
250
|
-
super().__init__(df)
|
|
251
|
-
|
|
252
|
-
def _new(self, data: pl.DataFrame):
|
|
253
|
-
s = Set(data)
|
|
254
|
-
s._model = self._model
|
|
255
|
-
# Copy over the unmatched strategy on operations like .rename(), .with_columns(), etc.
|
|
256
|
-
s.unmatched_strategy = self.unmatched_strategy
|
|
257
|
-
return s
|
|
258
|
-
|
|
259
|
-
@staticmethod
|
|
260
|
-
def _parse_acceptable_sets(
|
|
261
|
-
*over: SetTypes | Iterable[SetTypes],
|
|
262
|
-
) -> pl.DataFrame:
|
|
263
|
-
"""
|
|
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
|
-
└──────┴──────┘
|
|
287
|
-
"""
|
|
288
|
-
assert len(over) > 0, "At least one set must be provided."
|
|
289
|
-
over_iter: Iterable[SetTypes] = parse_inputs_as_iterable(*over)
|
|
290
|
-
|
|
291
|
-
over_frames: List[pl.DataFrame] = [Set._set_to_polars(set) for set in over_iter]
|
|
292
|
-
|
|
293
|
-
over_merged = over_frames[0]
|
|
294
|
-
|
|
295
|
-
for df in over_frames[1:]:
|
|
296
|
-
assert set(over_merged.columns) & set(df.columns) == set(), (
|
|
297
|
-
"All coordinates must have unique column names."
|
|
298
|
-
)
|
|
299
|
-
over_merged = over_merged.join(df, how="cross")
|
|
300
|
-
return over_merged
|
|
301
|
-
|
|
302
|
-
def to_expr(self) -> Expression:
|
|
303
|
-
return Expression(
|
|
304
|
-
self.data.with_columns(
|
|
305
|
-
pl.lit(1).alias(COEF_KEY), pl.lit(CONST_TERM).alias(VAR_KEY)
|
|
306
|
-
)
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
def __mul__(self, other):
|
|
310
|
-
if isinstance(other, Set):
|
|
311
|
-
assert set(self.data.columns) & set(other.data.columns) == set(), (
|
|
312
|
-
"Cannot multiply two sets with columns in common."
|
|
313
|
-
)
|
|
314
|
-
return Set(self.data, other.data)
|
|
315
|
-
return super().__mul__(other)
|
|
316
|
-
|
|
317
|
-
def __add__(self, other):
|
|
318
|
-
if isinstance(other, Set):
|
|
319
|
-
try:
|
|
320
|
-
return self._new(
|
|
321
|
-
pl.concat([self.data, other.data]).unique(maintain_order=True)
|
|
322
|
-
)
|
|
323
|
-
except pl.exceptions.ShapeError as e:
|
|
324
|
-
if "unable to vstack, column names don't match" in str(e):
|
|
325
|
-
raise PyoframeError(
|
|
326
|
-
f"Failed to add sets '{self.friendly_name}' and '{other.friendly_name}' because dimensions do not match ({self.dimensions} != {other.dimensions}) "
|
|
327
|
-
) from e
|
|
328
|
-
raise e
|
|
329
|
-
|
|
330
|
-
return super().__add__(other)
|
|
331
|
-
|
|
332
|
-
def __repr__(self):
|
|
333
|
-
return (
|
|
334
|
-
get_obj_repr(self, ("name",), size=self.data.height, dimensions=self.shape)
|
|
335
|
-
+ "\n"
|
|
336
|
-
+ dataframe_to_tupled_list(
|
|
337
|
-
self.data, num_max_elements=Config.print_max_set_elements
|
|
338
|
-
)
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
@staticmethod
|
|
342
|
-
def _set_to_polars(set: "SetTypes") -> pl.DataFrame:
|
|
343
|
-
if isinstance(set, dict):
|
|
344
|
-
df = pl.DataFrame(set)
|
|
345
|
-
elif isinstance(set, Constraint):
|
|
346
|
-
df = set.data.select(set.dimensions_unsafe)
|
|
347
|
-
elif isinstance(set, SupportsMath):
|
|
348
|
-
df = (
|
|
349
|
-
set.to_expr()
|
|
350
|
-
.data.drop(RESERVED_COL_KEYS, strict=False)
|
|
351
|
-
.unique(maintain_order=True)
|
|
352
|
-
)
|
|
353
|
-
elif isinstance(set, pd.Index):
|
|
354
|
-
df = pl.from_pandas(pd.DataFrame(index=set).reset_index())
|
|
355
|
-
elif isinstance(set, pd.DataFrame):
|
|
356
|
-
df = pl.from_pandas(set)
|
|
357
|
-
elif isinstance(set, pl.DataFrame):
|
|
358
|
-
df = set
|
|
359
|
-
elif isinstance(set, pl.Series):
|
|
360
|
-
df = set.to_frame()
|
|
361
|
-
elif isinstance(set, Set):
|
|
362
|
-
df = set.data
|
|
363
|
-
elif isinstance(set, range):
|
|
364
|
-
raise ValueError(
|
|
365
|
-
"Cannot convert a range to a set without a dimension name. Try Set(dim_name=range(...))"
|
|
366
|
-
)
|
|
367
|
-
else:
|
|
368
|
-
raise ValueError(f"Cannot convert type {type(set)} to a polars DataFrame")
|
|
369
|
-
|
|
370
|
-
if "index" in df.columns:
|
|
371
|
-
raise ValueError(
|
|
372
|
-
"Please specify a custom dimension name rather than using 'index' to avoid confusion."
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
for reserved_key in RESERVED_COL_KEYS:
|
|
376
|
-
if reserved_key in df.columns:
|
|
377
|
-
raise ValueError(
|
|
378
|
-
f"Cannot use reserved column names {reserved_key} as dimensions."
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
return df
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
385
|
-
"""A linear or quadratic expression."""
|
|
386
|
-
|
|
387
|
-
def __init__(self, data: pl.DataFrame):
|
|
388
|
-
"""
|
|
389
|
-
A linear expression.
|
|
390
|
-
|
|
391
|
-
Examples:
|
|
392
|
-
>>> import pandas as pd
|
|
393
|
-
>>> 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"])
|
|
394
|
-
>>> m = pf.Model()
|
|
395
|
-
>>> m.Time = pf.Variable(df.index)
|
|
396
|
-
>>> m.Size = pf.Variable(df.index)
|
|
397
|
-
>>> expr = df["cost"] * m.Time + df["cost"] * m.Size
|
|
398
|
-
>>> expr
|
|
399
|
-
<Expression size=5 dimensions={'item': 2, 'time': 3} terms=10>
|
|
400
|
-
[1,mon]: Time[1,mon] + Size[1,mon]
|
|
401
|
-
[1,tue]: 2 Time[1,tue] +2 Size[1,tue]
|
|
402
|
-
[1,wed]: 3 Time[1,wed] +3 Size[1,wed]
|
|
403
|
-
[2,mon]: 4 Time[2,mon] +4 Size[2,mon]
|
|
404
|
-
[2,tue]: 5 Time[2,tue] +5 Size[2,tue]
|
|
405
|
-
"""
|
|
406
|
-
# Sanity checks, VAR_KEY and COEF_KEY must be present
|
|
407
|
-
assert VAR_KEY in data.columns, "Missing variable column."
|
|
408
|
-
assert COEF_KEY in data.columns, "Missing coefficient column."
|
|
409
|
-
|
|
410
|
-
# Sanity check no duplicates indices
|
|
411
|
-
if Config.enable_is_duplicated_expression_safety_check:
|
|
412
|
-
duplicated_mask = data.drop(COEF_KEY).is_duplicated()
|
|
413
|
-
# In theory this should never happen unless there's a bug in the library
|
|
414
|
-
if duplicated_mask.any(): # pragma: no cover
|
|
415
|
-
duplicated_data = data.filter(duplicated_mask)
|
|
416
|
-
raise ValueError(
|
|
417
|
-
f"Cannot create an expression with duplicate indices:\n{duplicated_data}."
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
data = _simplify_expr_df(data)
|
|
421
|
-
|
|
422
|
-
super().__init__(data)
|
|
423
|
-
|
|
424
|
-
@classmethod
|
|
425
|
-
def constant(cls, constant: int | float) -> "Expression":
|
|
426
|
-
"""
|
|
427
|
-
Examples:
|
|
428
|
-
>>> pf.Expression.constant(5)
|
|
429
|
-
<Expression size=1 dimensions={} terms=1>
|
|
430
|
-
5
|
|
431
|
-
"""
|
|
432
|
-
return cls(
|
|
433
|
-
pl.DataFrame(
|
|
434
|
-
{
|
|
435
|
-
COEF_KEY: [constant],
|
|
436
|
-
VAR_KEY: [CONST_TERM],
|
|
437
|
-
},
|
|
438
|
-
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
439
|
-
)
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
def sum(self, over: Union[str, Iterable[str]]):
|
|
443
|
-
"""
|
|
444
|
-
Examples:
|
|
445
|
-
>>> import pandas as pd
|
|
446
|
-
>>> m = pf.Model()
|
|
447
|
-
>>> 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"])
|
|
448
|
-
>>> m.quantity = Variable(df.reset_index()[["item"]].drop_duplicates())
|
|
449
|
-
>>> expr = (m.quantity * df["cost"]).sum("time")
|
|
450
|
-
>>> expr.data
|
|
451
|
-
shape: (2, 3)
|
|
452
|
-
┌──────┬─────────┬───────────────┐
|
|
453
|
-
│ item ┆ __coeff ┆ __variable_id │
|
|
454
|
-
│ --- ┆ --- ┆ --- │
|
|
455
|
-
│ i64 ┆ f64 ┆ u32 │
|
|
456
|
-
╞══════╪═════════╪═══════════════╡
|
|
457
|
-
│ 1 ┆ 6.0 ┆ 1 │
|
|
458
|
-
│ 2 ┆ 9.0 ┆ 2 │
|
|
459
|
-
└──────┴─────────┴───────────────┘
|
|
460
|
-
"""
|
|
461
|
-
if isinstance(over, str):
|
|
462
|
-
over = [over]
|
|
463
|
-
dims = self.dimensions
|
|
464
|
-
if not dims:
|
|
465
|
-
raise ValueError(
|
|
466
|
-
f"Cannot sum over dimensions {over} since the current expression has no dimensions."
|
|
467
|
-
)
|
|
468
|
-
assert set(over) <= set(dims), f"Cannot sum over {over} as it is not in {dims}"
|
|
469
|
-
remaining_dims = [dim for dim in dims if dim not in over]
|
|
470
|
-
|
|
471
|
-
return self._new(
|
|
472
|
-
self.data.drop(over)
|
|
473
|
-
.group_by(remaining_dims + self._variable_columns, maintain_order=True)
|
|
474
|
-
.sum()
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
@property
|
|
478
|
-
def _variable_columns(self) -> List[str]:
|
|
479
|
-
if self.is_quadratic:
|
|
480
|
-
return [VAR_KEY, QUAD_VAR_KEY]
|
|
481
|
-
else:
|
|
482
|
-
return [VAR_KEY]
|
|
483
|
-
|
|
484
|
-
def map(self, mapping_set: SetTypes, drop_shared_dims: bool = True) -> Expression:
|
|
485
|
-
"""
|
|
486
|
-
Replaces the dimensions that are shared with mapping_set with the other dimensions found in mapping_set.
|
|
487
|
-
|
|
488
|
-
This is particularly useful to go from one type of dimensions to another. For example, to convert data that
|
|
489
|
-
is indexed by city to data indexed by country (see example).
|
|
490
|
-
|
|
491
|
-
Parameters:
|
|
492
|
-
mapping_set:
|
|
493
|
-
The set to map the expression to. This can be a DataFrame, Index, or another Set.
|
|
494
|
-
drop_shared_dims:
|
|
495
|
-
If True, the dimensions shared between the expression and the mapping set are dropped from the resulting expression and
|
|
496
|
-
repeated rows are summed.
|
|
497
|
-
If False, the shared dimensions are kept in the resulting expression.
|
|
498
|
-
|
|
499
|
-
Returns:
|
|
500
|
-
A new Expression containing the result of the mapping operation.
|
|
501
|
-
|
|
502
|
-
Examples:
|
|
503
|
-
>>> import polars as pl
|
|
504
|
-
>>> pop_data = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "year": [2024, 2024, 2024], "population": [10, 2, 8]}).to_expr()
|
|
505
|
-
>>> cities_and_countries = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "country": ["Canada", "Canada", "USA"]})
|
|
506
|
-
>>> pop_data.map(cities_and_countries)
|
|
507
|
-
<Expression size=2 dimensions={'year': 1, 'country': 2} terms=2>
|
|
508
|
-
[2024,Canada]: 12
|
|
509
|
-
[2024,USA]: 8
|
|
510
|
-
>>> pop_data.map(cities_and_countries, drop_shared_dims=False)
|
|
511
|
-
<Expression size=3 dimensions={'city': 3, 'year': 1, 'country': 2} terms=3>
|
|
512
|
-
[Toronto,2024,Canada]: 10
|
|
513
|
-
[Vancouver,2024,Canada]: 2
|
|
514
|
-
[Boston,2024,USA]: 8
|
|
515
|
-
"""
|
|
516
|
-
mapping_set = Set(mapping_set)
|
|
517
|
-
|
|
518
|
-
dims = self.dimensions
|
|
519
|
-
if dims is None:
|
|
520
|
-
raise ValueError("Cannot use .map() on an expression with no dimensions.")
|
|
521
|
-
|
|
522
|
-
mapping_dims = mapping_set.dimensions
|
|
523
|
-
if mapping_dims is None:
|
|
524
|
-
raise ValueError(
|
|
525
|
-
"Cannot use .map() with a mapping set containing no dimensions."
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
shared_dims = [dim for dim in dims if dim in mapping_dims]
|
|
529
|
-
if not shared_dims:
|
|
530
|
-
raise ValueError(
|
|
531
|
-
f"Cannot apply .map() as there are no shared dimensions between the expression (dims={self.dimensions}) and the mapping set (dims={mapping_set.dimensions})."
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
mapped_expression = self * mapping_set
|
|
535
|
-
|
|
536
|
-
if drop_shared_dims:
|
|
537
|
-
return sum(shared_dims, mapped_expression)
|
|
538
|
-
|
|
539
|
-
return mapped_expression
|
|
540
|
-
|
|
541
|
-
def rolling_sum(self, over: str, window_size: int) -> Expression:
|
|
542
|
-
"""
|
|
543
|
-
Calculates the rolling sum of the Expression over a specified window size for a given dimension.
|
|
544
|
-
|
|
545
|
-
This method applies a rolling sum operation over the dimension specified by `over`,
|
|
546
|
-
using a window defined by `window_size`.
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
Parameters:
|
|
550
|
-
over :
|
|
551
|
-
The name of the dimension (column) over which the rolling sum is calculated.
|
|
552
|
-
This dimension must exist within the Expression's dimensions.
|
|
553
|
-
window_size :
|
|
554
|
-
The size of the moving window in terms of number of records.
|
|
555
|
-
The rolling sum is calculated over this many consecutive elements.
|
|
556
|
-
|
|
557
|
-
Returns:
|
|
558
|
-
A new Expression instance containing the result of the rolling sum operation.
|
|
559
|
-
This new Expression retains all dimensions (columns) of the original data,
|
|
560
|
-
with the rolling sum applied over the specified dimension.
|
|
561
|
-
|
|
562
|
-
Examples:
|
|
563
|
-
>>> import polars as pl
|
|
564
|
-
>>> cost = pl.DataFrame({"item" : [1, 1, 1, 2, 2], "time": [1, 2, 3, 1, 2], "cost": [1, 2, 3, 4, 5]})
|
|
565
|
-
>>> m = pf.Model()
|
|
566
|
-
>>> m.quantity = pf.Variable(cost[["item", "time"]])
|
|
567
|
-
>>> (m.quantity * cost).rolling_sum(over="time", window_size=2)
|
|
568
|
-
<Expression size=5 dimensions={'item': 2, 'time': 3} terms=8>
|
|
569
|
-
[1,1]: quantity[1,1]
|
|
570
|
-
[1,2]: quantity[1,1] +2 quantity[1,2]
|
|
571
|
-
[1,3]: 2 quantity[1,2] +3 quantity[1,3]
|
|
572
|
-
[2,1]: 4 quantity[2,1]
|
|
573
|
-
[2,2]: 4 quantity[2,1] +5 quantity[2,2]
|
|
574
|
-
"""
|
|
575
|
-
dims = self.dimensions
|
|
576
|
-
if dims is None:
|
|
577
|
-
raise ValueError(
|
|
578
|
-
"Cannot use rolling_sum() with an expression with no dimensions."
|
|
579
|
-
)
|
|
580
|
-
assert over in dims, f"Cannot sum over {over} as it is not in {dims}"
|
|
581
|
-
remaining_dims = [dim for dim in dims if dim not in over]
|
|
582
|
-
|
|
583
|
-
return self._new(
|
|
584
|
-
pl.concat(
|
|
585
|
-
[
|
|
586
|
-
df.with_columns(pl.col(over).max())
|
|
587
|
-
for _, df in self.data.rolling(
|
|
588
|
-
index_column=over,
|
|
589
|
-
period=f"{window_size}i",
|
|
590
|
-
group_by=remaining_dims,
|
|
591
|
-
)
|
|
592
|
-
]
|
|
593
|
-
)
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
def within(self, set: "SetTypes") -> Expression:
|
|
597
|
-
"""
|
|
598
|
-
Examples:
|
|
599
|
-
>>> import pandas as pd
|
|
600
|
-
>>> general_expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}).to_expr()
|
|
601
|
-
>>> filter_expr = pd.DataFrame({"dim1": [1, 3], "value": [5, 6]}).to_expr()
|
|
602
|
-
>>> general_expr.within(filter_expr).data
|
|
603
|
-
shape: (2, 3)
|
|
604
|
-
┌──────┬─────────┬───────────────┐
|
|
605
|
-
│ dim1 ┆ __coeff ┆ __variable_id │
|
|
606
|
-
│ --- ┆ --- ┆ --- │
|
|
607
|
-
│ i64 ┆ f64 ┆ u32 │
|
|
608
|
-
╞══════╪═════════╪═══════════════╡
|
|
609
|
-
│ 1 ┆ 1.0 ┆ 0 │
|
|
610
|
-
│ 3 ┆ 3.0 ┆ 0 │
|
|
611
|
-
└──────┴─────────┴───────────────┘
|
|
612
|
-
"""
|
|
613
|
-
df: pl.DataFrame = Set(set).data
|
|
614
|
-
set_dims = _get_dimensions(df)
|
|
615
|
-
assert set_dims is not None, (
|
|
616
|
-
"Cannot use .within() with a set with no dimensions."
|
|
617
|
-
)
|
|
618
|
-
dims = self.dimensions
|
|
619
|
-
assert dims is not None, (
|
|
620
|
-
"Cannot use .within() with an expression with no dimensions."
|
|
621
|
-
)
|
|
622
|
-
dims_in_common = [dim for dim in dims if dim in set_dims]
|
|
623
|
-
by_dims = df.select(dims_in_common).unique(maintain_order=True)
|
|
624
|
-
return self._new(self.data.join(by_dims, on=dims_in_common))
|
|
625
|
-
|
|
626
|
-
@property
|
|
627
|
-
def is_quadratic(self) -> bool:
|
|
628
|
-
"""
|
|
629
|
-
Returns True if the expression is quadratic, False otherwise.
|
|
630
|
-
|
|
631
|
-
Computes in O(1) since expressions are quadratic if and
|
|
632
|
-
only if self.data contain the QUAD_VAR_KEY column.
|
|
633
|
-
|
|
634
|
-
Examples:
|
|
635
|
-
>>> import pandas as pd
|
|
636
|
-
>>> m = pf.Model()
|
|
637
|
-
>>> m.v = Variable()
|
|
638
|
-
>>> expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}) * m.v
|
|
639
|
-
>>> expr *= m.v
|
|
640
|
-
>>> expr.is_quadratic
|
|
641
|
-
True
|
|
642
|
-
"""
|
|
643
|
-
return QUAD_VAR_KEY in self.data.columns
|
|
644
|
-
|
|
645
|
-
def degree(self) -> int:
|
|
646
|
-
"""
|
|
647
|
-
Returns the degree of the expression (0=constant, 1=linear, 2=quadratic).
|
|
648
|
-
|
|
649
|
-
Examples:
|
|
650
|
-
>>> import pandas as pd
|
|
651
|
-
>>> m = pf.Model()
|
|
652
|
-
>>> m.v1 = pf.Variable()
|
|
653
|
-
>>> m.v2 = pf.Variable()
|
|
654
|
-
>>> expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}).to_expr()
|
|
655
|
-
>>> expr.degree()
|
|
656
|
-
0
|
|
657
|
-
>>> expr *= m.v1
|
|
658
|
-
>>> expr.degree()
|
|
659
|
-
1
|
|
660
|
-
>>> expr += (m.v2 ** 2).add_dim("dim1")
|
|
661
|
-
>>> expr.degree()
|
|
662
|
-
2
|
|
663
|
-
"""
|
|
664
|
-
if self.is_quadratic:
|
|
665
|
-
return 2
|
|
666
|
-
elif (self.data.get_column(VAR_KEY) != CONST_TERM).any():
|
|
667
|
-
return 1
|
|
668
|
-
else:
|
|
669
|
-
return 0
|
|
670
|
-
|
|
671
|
-
def __add__(self, other):
|
|
672
|
-
"""
|
|
673
|
-
Examples:
|
|
674
|
-
>>> import pandas as pd
|
|
675
|
-
>>> m = pf.Model()
|
|
676
|
-
>>> add = pd.DataFrame({"dim1": [1,2,3], "add": [10, 20, 30]}).to_expr()
|
|
677
|
-
>>> m.v = Variable(add)
|
|
678
|
-
>>> m.v + add
|
|
679
|
-
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
680
|
-
[1]: v[1] +10
|
|
681
|
-
[2]: v[2] +20
|
|
682
|
-
[3]: v[3] +30
|
|
683
|
-
>>> m.v + add + 2
|
|
684
|
-
<Expression size=3 dimensions={'dim1': 3} terms=6>
|
|
685
|
-
[1]: v[1] +12
|
|
686
|
-
[2]: v[2] +22
|
|
687
|
-
[3]: v[3] +32
|
|
688
|
-
>>> m.v + pd.DataFrame({"dim1": [1,2], "add": [10, 20]})
|
|
689
|
-
Traceback (most recent call last):
|
|
690
|
-
...
|
|
691
|
-
pyoframe.constants.PyoframeError: Failed to add expressions:
|
|
692
|
-
<Expression size=3 dimensions={'dim1': 3} terms=3> + <Expression size=2 dimensions={'dim1': 2} terms=2>
|
|
693
|
-
Due to error:
|
|
694
|
-
Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()
|
|
695
|
-
shape: (1, 2)
|
|
696
|
-
┌──────┬────────────┐
|
|
697
|
-
│ dim1 ┆ dim1_right │
|
|
698
|
-
│ --- ┆ --- │
|
|
699
|
-
│ i64 ┆ i64 │
|
|
700
|
-
╞══════╪════════════╡
|
|
701
|
-
│ 3 ┆ null │
|
|
702
|
-
└──────┴────────────┘
|
|
703
|
-
>>> m.v2 = Variable()
|
|
704
|
-
>>> 5 + 2 * m.v2
|
|
705
|
-
<Expression size=1 dimensions={} terms=2>
|
|
706
|
-
2 v2 +5
|
|
707
|
-
"""
|
|
708
|
-
if isinstance(other, str):
|
|
709
|
-
raise ValueError(
|
|
710
|
-
"Cannot add a string to an expression. Perhaps you meant to use pf.sum() instead of sum()?"
|
|
711
|
-
)
|
|
712
|
-
if isinstance(other, (int, float)):
|
|
713
|
-
return self._add_const(other)
|
|
714
|
-
other = other.to_expr()
|
|
715
|
-
self._learn_from_other(other)
|
|
716
|
-
return _add_expressions(self, other)
|
|
717
|
-
|
|
718
|
-
def __mul__(
|
|
719
|
-
self: "Expression", other: int | float | SupportsToExpr
|
|
720
|
-
) -> "Expression":
|
|
721
|
-
if isinstance(other, (int, float)):
|
|
722
|
-
return self.with_columns(pl.col(COEF_KEY) * other)
|
|
723
|
-
|
|
724
|
-
other = other.to_expr()
|
|
725
|
-
self._learn_from_other(other)
|
|
726
|
-
return _multiply_expressions(self, other)
|
|
727
|
-
|
|
728
|
-
def to_expr(self) -> Expression:
|
|
729
|
-
return self
|
|
730
|
-
|
|
731
|
-
def _learn_from_other(self, other: Expression):
|
|
732
|
-
if self._model is None and other._model is not None:
|
|
733
|
-
self._model = other._model
|
|
734
|
-
|
|
735
|
-
def _new(self, data: pl.DataFrame) -> Expression:
|
|
736
|
-
e = Expression(data)
|
|
737
|
-
e._model = self._model
|
|
738
|
-
# Note: We intentionally don't propogate the unmatched strategy to the new expression
|
|
739
|
-
e.allowed_new_dims = self.allowed_new_dims
|
|
740
|
-
return e
|
|
741
|
-
|
|
742
|
-
def _add_const(self, const: int | float) -> Expression:
|
|
743
|
-
"""
|
|
744
|
-
Examples:
|
|
745
|
-
>>> m = pf.Model()
|
|
746
|
-
>>> m.x1 = Variable()
|
|
747
|
-
>>> m.x2 = Variable()
|
|
748
|
-
>>> m.x1 + 5
|
|
749
|
-
<Expression size=1 dimensions={} terms=2>
|
|
750
|
-
x1 +5
|
|
751
|
-
>>> m.x1 ** 2 + 5
|
|
752
|
-
<Expression size=1 dimensions={} terms=2 degree=2>
|
|
753
|
-
x1 * x1 +5
|
|
754
|
-
>>> m.x1 ** 2 + m.x2 + 5
|
|
755
|
-
<Expression size=1 dimensions={} terms=3 degree=2>
|
|
756
|
-
x1 * x1 + x2 +5
|
|
757
|
-
|
|
758
|
-
It also works with dimensions
|
|
759
|
-
|
|
760
|
-
>>> m = pf.Model()
|
|
761
|
-
>>> m.v = Variable({"dim1": [1, 2, 3]})
|
|
762
|
-
>>> m.v * m.v + 5
|
|
763
|
-
<Expression size=3 dimensions={'dim1': 3} terms=6 degree=2>
|
|
764
|
-
[1]: 5 + v[1] * v[1]
|
|
765
|
-
[2]: 5 + v[2] * v[2]
|
|
766
|
-
[3]: 5 + v[3] * v[3]
|
|
767
|
-
"""
|
|
768
|
-
dim = self.dimensions
|
|
769
|
-
data = self.data
|
|
770
|
-
# Fill in missing constant terms
|
|
771
|
-
if not dim:
|
|
772
|
-
if CONST_TERM not in data[VAR_KEY]:
|
|
773
|
-
const_df = pl.DataFrame(
|
|
774
|
-
{COEF_KEY: [0.0], VAR_KEY: [CONST_TERM]},
|
|
775
|
-
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
776
|
-
)
|
|
777
|
-
if self.is_quadratic:
|
|
778
|
-
const_df = const_df.with_columns(
|
|
779
|
-
pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
|
|
780
|
-
)
|
|
781
|
-
data = pl.concat(
|
|
782
|
-
[data, const_df],
|
|
783
|
-
how="vertical_relaxed",
|
|
784
|
-
)
|
|
785
|
-
else:
|
|
786
|
-
keys = (
|
|
787
|
-
data.select(dim)
|
|
788
|
-
.unique(maintain_order=True)
|
|
789
|
-
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY).cast(KEY_TYPE))
|
|
790
|
-
)
|
|
791
|
-
if self.is_quadratic:
|
|
792
|
-
keys = keys.with_columns(
|
|
793
|
-
pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
|
|
794
|
-
)
|
|
795
|
-
data = data.join(
|
|
796
|
-
keys, on=dim + self._variable_columns, how="full", coalesce=True
|
|
797
|
-
).with_columns(pl.col(COEF_KEY).fill_null(0.0))
|
|
798
|
-
|
|
799
|
-
data = data.with_columns(
|
|
800
|
-
pl.when(pl.col(VAR_KEY) == CONST_TERM)
|
|
801
|
-
.then(pl.col(COEF_KEY) + const)
|
|
802
|
-
.otherwise(pl.col(COEF_KEY))
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
return self._new(data)
|
|
806
|
-
|
|
807
|
-
@property
|
|
808
|
-
def constant_terms(self):
|
|
809
|
-
dims = self.dimensions
|
|
810
|
-
constant_terms = self.data.filter(pl.col(VAR_KEY) == CONST_TERM).drop(VAR_KEY)
|
|
811
|
-
if self.is_quadratic:
|
|
812
|
-
constant_terms = constant_terms.drop(QUAD_VAR_KEY)
|
|
813
|
-
if dims is not None:
|
|
814
|
-
dims_df = self.data.select(dims).unique(maintain_order=True)
|
|
815
|
-
df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
|
|
816
|
-
return df.with_columns(pl.col(COEF_KEY).fill_null(0.0))
|
|
817
|
-
else:
|
|
818
|
-
if len(constant_terms) == 0:
|
|
819
|
-
return pl.DataFrame(
|
|
820
|
-
{COEF_KEY: [0.0], VAR_KEY: [CONST_TERM]},
|
|
821
|
-
schema={COEF_KEY: pl.Float64, VAR_KEY: KEY_TYPE},
|
|
822
|
-
)
|
|
823
|
-
return constant_terms
|
|
824
|
-
|
|
825
|
-
@property
|
|
826
|
-
def variable_terms(self):
|
|
827
|
-
return self.data.filter(pl.col(VAR_KEY) != CONST_TERM)
|
|
828
|
-
|
|
829
|
-
@unwrap_single_values
|
|
830
|
-
def evaluate(self) -> pl.DataFrame:
|
|
831
|
-
"""
|
|
832
|
-
The value of the expression. Only available after the model has been solved.
|
|
833
|
-
|
|
834
|
-
Examples:
|
|
835
|
-
>>> m = pf.Model()
|
|
836
|
-
>>> m.X = pf.Variable({"dim1": [1, 2, 3]}, ub=10)
|
|
837
|
-
>>> m.expr_1 = 2 * m.X + 1
|
|
838
|
-
>>> m.expr_2 = pf.sum(m.expr_1)
|
|
839
|
-
>>> m.maximize = m.expr_2 - 3
|
|
840
|
-
>>> m.attr.Silent = True
|
|
841
|
-
>>> m.optimize()
|
|
842
|
-
>>> m.expr_1.evaluate()
|
|
843
|
-
shape: (3, 2)
|
|
844
|
-
┌──────┬──────────┐
|
|
845
|
-
│ dim1 ┆ solution │
|
|
846
|
-
│ --- ┆ --- │
|
|
847
|
-
│ i64 ┆ f64 │
|
|
848
|
-
╞══════╪══════════╡
|
|
849
|
-
│ 1 ┆ 21.0 │
|
|
850
|
-
│ 2 ┆ 21.0 │
|
|
851
|
-
│ 3 ┆ 21.0 │
|
|
852
|
-
└──────┴──────────┘
|
|
853
|
-
>>> m.expr_2.evaluate()
|
|
854
|
-
63.0
|
|
855
|
-
"""
|
|
856
|
-
assert self._model is not None, (
|
|
857
|
-
"Expression must be added to the model to use .value"
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
df = self.data
|
|
861
|
-
sm = self._model.poi
|
|
862
|
-
attr = poi.VariableAttribute.Value
|
|
863
|
-
for var_col in self._variable_columns:
|
|
864
|
-
df = df.with_columns(
|
|
865
|
-
(
|
|
866
|
-
pl.col(COEF_KEY)
|
|
867
|
-
* pl.col(var_col).map_elements(
|
|
868
|
-
lambda v_id: (
|
|
869
|
-
sm.get_variable_attribute(poi.VariableIndex(v_id), attr)
|
|
870
|
-
if v_id != CONST_TERM
|
|
871
|
-
else 1
|
|
872
|
-
),
|
|
873
|
-
return_dtype=pl.Float64,
|
|
874
|
-
)
|
|
875
|
-
).alias(COEF_KEY)
|
|
876
|
-
).drop(var_col)
|
|
877
|
-
|
|
878
|
-
df = df.rename({COEF_KEY: SOLUTION_KEY})
|
|
879
|
-
|
|
880
|
-
dims = self.dimensions
|
|
881
|
-
if dims is not None:
|
|
882
|
-
df = df.group_by(dims, maintain_order=True)
|
|
883
|
-
return df.sum()
|
|
884
|
-
|
|
885
|
-
def to_poi(self) -> poi.ScalarAffineFunction | poi.ScalarQuadraticFunction:
|
|
886
|
-
if self.dimensions is not None:
|
|
887
|
-
raise ValueError(
|
|
888
|
-
"Only non-dimensioned expressions can be converted to PyOptInterface."
|
|
889
|
-
) # pragma: no cover
|
|
890
|
-
|
|
891
|
-
if self.is_quadratic:
|
|
892
|
-
return poi.ScalarQuadraticFunction(
|
|
893
|
-
coefficients=self.data.get_column(COEF_KEY).to_numpy(),
|
|
894
|
-
var1s=self.data.get_column(VAR_KEY).to_numpy(),
|
|
895
|
-
var2s=self.data.get_column(QUAD_VAR_KEY).to_numpy(),
|
|
896
|
-
)
|
|
897
|
-
else:
|
|
898
|
-
return poi.ScalarAffineFunction(
|
|
899
|
-
coefficients=self.data.get_column(COEF_KEY).to_numpy(),
|
|
900
|
-
variables=self.data.get_column(VAR_KEY).to_numpy(),
|
|
901
|
-
)
|
|
902
|
-
|
|
903
|
-
def to_str_table(self, include_const_term=True):
|
|
904
|
-
data = self.data if include_const_term else self.variable_terms
|
|
905
|
-
data = cast_coef_to_string(data)
|
|
906
|
-
|
|
907
|
-
for var_col in self._variable_columns:
|
|
908
|
-
temp_var_column = f"{var_col}_temp"
|
|
909
|
-
if self._model is not None and self._model.var_map is not None:
|
|
910
|
-
data = self._model.var_map.apply(
|
|
911
|
-
data, to_col=temp_var_column, id_col=var_col
|
|
912
|
-
)
|
|
913
|
-
else:
|
|
914
|
-
data = data.with_columns(
|
|
915
|
-
pl.concat_str(pl.lit("x"), var_col).alias(temp_var_column)
|
|
916
|
-
)
|
|
917
|
-
data = data.with_columns(
|
|
918
|
-
pl.when(pl.col(var_col) == CONST_TERM)
|
|
919
|
-
.then(pl.lit(""))
|
|
920
|
-
.otherwise(temp_var_column)
|
|
921
|
-
.alias(var_col)
|
|
922
|
-
).drop(temp_var_column)
|
|
923
|
-
if self.is_quadratic:
|
|
924
|
-
data = data.with_columns(
|
|
925
|
-
pl.when(pl.col(QUAD_VAR_KEY) == "")
|
|
926
|
-
.then(pl.col(VAR_KEY))
|
|
927
|
-
.otherwise(pl.concat_str(VAR_KEY, pl.lit(" * "), pl.col(QUAD_VAR_KEY)))
|
|
928
|
-
.alias(VAR_KEY)
|
|
929
|
-
).drop(QUAD_VAR_KEY)
|
|
930
|
-
|
|
931
|
-
dimensions = self.dimensions
|
|
932
|
-
|
|
933
|
-
# Create a string for each term
|
|
934
|
-
data = data.with_columns(
|
|
935
|
-
expr=pl.concat_str(
|
|
936
|
-
COEF_KEY,
|
|
937
|
-
pl.lit(" "),
|
|
938
|
-
VAR_KEY,
|
|
939
|
-
)
|
|
940
|
-
).drop(COEF_KEY, VAR_KEY)
|
|
941
|
-
|
|
942
|
-
if dimensions is not None:
|
|
943
|
-
data = data.group_by(dimensions, maintain_order=True).agg(
|
|
944
|
-
pl.col("expr").str.join(delimiter=" ")
|
|
945
|
-
)
|
|
946
|
-
else:
|
|
947
|
-
data = data.select(pl.col("expr").str.join(delimiter=" "))
|
|
948
|
-
|
|
949
|
-
# Remove leading +
|
|
950
|
-
data = data.with_columns(pl.col("expr").str.strip_chars(characters=" +"))
|
|
951
|
-
|
|
952
|
-
# TODO add vertical ... if too many rows, in the middle of the table
|
|
953
|
-
if Config.print_max_lines:
|
|
954
|
-
data = data.head(Config.print_max_lines)
|
|
955
|
-
|
|
956
|
-
if Config.print_max_line_length:
|
|
957
|
-
data = data.with_columns(
|
|
958
|
-
pl.when(pl.col("expr").str.len_chars() > Config.print_max_line_length)
|
|
959
|
-
.then(
|
|
960
|
-
pl.concat_str(
|
|
961
|
-
pl.col("expr").str.slice(0, Config.print_max_line_length),
|
|
962
|
-
pl.lit("..."),
|
|
963
|
-
)
|
|
964
|
-
)
|
|
965
|
-
.otherwise(pl.col("expr"))
|
|
966
|
-
)
|
|
967
|
-
return data
|
|
968
|
-
|
|
969
|
-
def to_str_create_prefix(self, data):
|
|
970
|
-
if self.name is None and self.dimensions is None:
|
|
971
|
-
return data
|
|
972
|
-
|
|
973
|
-
return (
|
|
974
|
-
concat_dimensions(data, prefix=self.name, ignore_columns=["expr"])
|
|
975
|
-
.with_columns(
|
|
976
|
-
pl.concat_str("concated_dim", pl.lit(": "), "expr").alias("expr")
|
|
977
|
-
)
|
|
978
|
-
.drop("concated_dim")
|
|
979
|
-
)
|
|
980
|
-
|
|
981
|
-
def to_str(
|
|
982
|
-
self,
|
|
983
|
-
include_const_term=True,
|
|
984
|
-
include_header=False,
|
|
985
|
-
include_data=True,
|
|
986
|
-
):
|
|
987
|
-
result = ""
|
|
988
|
-
if include_header:
|
|
989
|
-
result += get_obj_repr(
|
|
990
|
-
self,
|
|
991
|
-
size=len(self),
|
|
992
|
-
dimensions=self.shape,
|
|
993
|
-
terms=self.terms,
|
|
994
|
-
degree=2 if self.degree() == 2 else None,
|
|
995
|
-
)
|
|
996
|
-
if include_header and include_data:
|
|
997
|
-
result += "\n"
|
|
998
|
-
if include_data:
|
|
999
|
-
str_table = self.to_str_table(
|
|
1000
|
-
include_const_term=include_const_term,
|
|
1001
|
-
)
|
|
1002
|
-
str_table = self.to_str_create_prefix(str_table)
|
|
1003
|
-
result += str_table.select(pl.col("expr").str.join(delimiter="\n")).item()
|
|
1004
|
-
|
|
1005
|
-
return result
|
|
1006
|
-
|
|
1007
|
-
def __repr__(self) -> str:
|
|
1008
|
-
return self.to_str(include_header=True)
|
|
1009
|
-
|
|
1010
|
-
def __str__(self) -> str:
|
|
1011
|
-
return self.to_str()
|
|
1012
|
-
|
|
1013
|
-
@property
|
|
1014
|
-
def terms(self) -> int:
|
|
1015
|
-
"""
|
|
1016
|
-
Number of terms across all subexpressions.
|
|
1017
|
-
|
|
1018
|
-
Expressions equal to zero count as one term.
|
|
1019
|
-
|
|
1020
|
-
Examples:
|
|
1021
|
-
>>> import polars as pl
|
|
1022
|
-
>>> m = pf.Model()
|
|
1023
|
-
>>> m.v = pf.Variable({"t": [1, 2]})
|
|
1024
|
-
>>> coef = pl.DataFrame({"t": [1, 2], "coef": [0, 1]})
|
|
1025
|
-
>>> coef*(m.v+4)
|
|
1026
|
-
<Expression size=2 dimensions={'t': 2} terms=3>
|
|
1027
|
-
[1]: 0
|
|
1028
|
-
[2]: 4 + v[2]
|
|
1029
|
-
>>> (coef*(m.v+4)).terms
|
|
1030
|
-
3
|
|
1031
|
-
"""
|
|
1032
|
-
return len(self.data)
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
@overload
|
|
1036
|
-
def sum(over: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression": ...
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
@overload
|
|
1040
|
-
def sum(over: SupportsToExpr) -> "Expression": ...
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
def sum(
|
|
1044
|
-
over: Union[str, Sequence[str], SupportsToExpr],
|
|
1045
|
-
expr: Optional[SupportsToExpr] = None,
|
|
1046
|
-
) -> "Expression":
|
|
1047
|
-
"""
|
|
1048
|
-
Sum an expression over specified dimensions.
|
|
1049
|
-
If no dimensions are specified, the sum is taken over all of the expression's dimensions.
|
|
1050
|
-
|
|
1051
|
-
Examples:
|
|
1052
|
-
>>> expr = pl.DataFrame({
|
|
1053
|
-
... "time": ["mon", "tue", "wed", "mon", "tue"],
|
|
1054
|
-
... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
|
|
1055
|
-
... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
|
|
1056
|
-
... }).to_expr()
|
|
1057
|
-
>>> expr
|
|
1058
|
-
<Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
|
|
1059
|
-
[mon,Toronto]: 1000000
|
|
1060
|
-
[tue,Toronto]: 3000000
|
|
1061
|
-
[wed,Toronto]: 2000000
|
|
1062
|
-
[mon,Vancouver]: 1000000
|
|
1063
|
-
[tue,Vancouver]: 2000000
|
|
1064
|
-
>>> pf.sum("time", expr)
|
|
1065
|
-
<Expression size=2 dimensions={'place': 2} terms=2>
|
|
1066
|
-
[Toronto]: 6000000
|
|
1067
|
-
[Vancouver]: 3000000
|
|
1068
|
-
>>> pf.sum(expr)
|
|
1069
|
-
<Expression size=1 dimensions={} terms=1>
|
|
1070
|
-
9000000
|
|
1071
|
-
"""
|
|
1072
|
-
if expr is None:
|
|
1073
|
-
assert isinstance(over, SupportsMath)
|
|
1074
|
-
over = over.to_expr()
|
|
1075
|
-
all_dims = over.dimensions
|
|
1076
|
-
if all_dims is None:
|
|
1077
|
-
raise ValueError(
|
|
1078
|
-
"Cannot sum over dimensions with an expression with no dimensions."
|
|
1079
|
-
)
|
|
1080
|
-
return over.sum(all_dims)
|
|
1081
|
-
else:
|
|
1082
|
-
assert isinstance(over, (str, Sequence))
|
|
1083
|
-
return expr.to_expr().sum(over)
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
def sum_by(by: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression":
|
|
1087
|
-
"""
|
|
1088
|
-
Like `pf.sum()`, but the sum is taken over all dimensions except those specified in `by` (just like a groupby operation).
|
|
1089
|
-
|
|
1090
|
-
Examples:
|
|
1091
|
-
>>> expr = pl.DataFrame({
|
|
1092
|
-
... "time": ["mon", "tue", "wed", "mon", "tue"],
|
|
1093
|
-
... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
|
|
1094
|
-
... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
|
|
1095
|
-
... }).to_expr()
|
|
1096
|
-
>>> expr
|
|
1097
|
-
<Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
|
|
1098
|
-
[mon,Toronto]: 1000000
|
|
1099
|
-
[tue,Toronto]: 3000000
|
|
1100
|
-
[wed,Toronto]: 2000000
|
|
1101
|
-
[mon,Vancouver]: 1000000
|
|
1102
|
-
[tue,Vancouver]: 2000000
|
|
1103
|
-
>>> pf.sum_by("place", expr)
|
|
1104
|
-
<Expression size=2 dimensions={'place': 2} terms=2>
|
|
1105
|
-
[Toronto]: 6000000
|
|
1106
|
-
[Vancouver]: 3000000
|
|
1107
|
-
"""
|
|
1108
|
-
if isinstance(by, str):
|
|
1109
|
-
by = [by]
|
|
1110
|
-
expr = expr.to_expr()
|
|
1111
|
-
dimensions = expr.dimensions
|
|
1112
|
-
assert dimensions is not None, (
|
|
1113
|
-
"Cannot sum by dimensions with an expression with no dimensions."
|
|
1114
|
-
)
|
|
1115
|
-
remaining_dims = [dim for dim in dimensions if dim not in by]
|
|
1116
|
-
return sum(over=remaining_dims, expr=expr)
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
class Constraint(ModelElementWithId):
|
|
1120
|
-
"""A linear programming constraint."""
|
|
1121
|
-
|
|
1122
|
-
def __init__(self, lhs: Expression, sense: ConstraintSense):
|
|
1123
|
-
"""Initialize a constraint.
|
|
1124
|
-
|
|
1125
|
-
Parameters:
|
|
1126
|
-
lhs:
|
|
1127
|
-
The left hand side of the constraint.
|
|
1128
|
-
sense:
|
|
1129
|
-
The sense of the constraint.
|
|
1130
|
-
"""
|
|
1131
|
-
self.lhs = lhs
|
|
1132
|
-
self._model = lhs._model
|
|
1133
|
-
self.sense = sense
|
|
1134
|
-
self.to_relax: Optional[FuncArgs] = None
|
|
1135
|
-
self.attr = Container(self._set_attribute, self._get_attribute)
|
|
1136
|
-
|
|
1137
|
-
dims = self.lhs.dimensions
|
|
1138
|
-
data = pl.DataFrame() if dims is None else self.lhs.data.select(dims).unique()
|
|
1139
|
-
|
|
1140
|
-
super().__init__(data)
|
|
1141
|
-
|
|
1142
|
-
def _set_attribute(self, name, value):
|
|
1143
|
-
self._assert_has_ids()
|
|
1144
|
-
col_name = name
|
|
1145
|
-
try:
|
|
1146
|
-
name = poi.ConstraintAttribute[name]
|
|
1147
|
-
setter = self._model.poi.set_constraint_attribute
|
|
1148
|
-
except KeyError:
|
|
1149
|
-
setter = self._model.poi.set_constraint_raw_attribute
|
|
1150
|
-
|
|
1151
|
-
if self.dimensions is None:
|
|
1152
|
-
for key in self.data.get_column(CONSTRAINT_KEY):
|
|
1153
|
-
setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
|
|
1154
|
-
else:
|
|
1155
|
-
for key, value in (
|
|
1156
|
-
self.data.join(value, on=self.dimensions, how="inner")
|
|
1157
|
-
.select(pl.col(CONSTRAINT_KEY), pl.col(col_name))
|
|
1158
|
-
.iter_rows()
|
|
1159
|
-
):
|
|
1160
|
-
setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
|
|
1161
|
-
|
|
1162
|
-
@unwrap_single_values
|
|
1163
|
-
def _get_attribute(self, name):
|
|
1164
|
-
self._assert_has_ids()
|
|
1165
|
-
col_name = name
|
|
1166
|
-
try:
|
|
1167
|
-
name = poi.ConstraintAttribute[name]
|
|
1168
|
-
getter = self._model.poi.get_constraint_attribute
|
|
1169
|
-
except KeyError:
|
|
1170
|
-
getter = self._model.poi.get_constraint_raw_attribute
|
|
1171
|
-
|
|
1172
|
-
with (
|
|
1173
|
-
warnings.catch_warnings()
|
|
1174
|
-
): # map_elements without return_dtype= gives a warning
|
|
1175
|
-
warnings.filterwarnings(
|
|
1176
|
-
action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
|
|
1177
|
-
)
|
|
1178
|
-
return self.data.with_columns(
|
|
1179
|
-
pl.col(CONSTRAINT_KEY)
|
|
1180
|
-
.map_elements(
|
|
1181
|
-
lambda v_id: getter(
|
|
1182
|
-
poi.ConstraintIndex(poi.ConstraintType.Linear, v_id), name
|
|
1183
|
-
)
|
|
1184
|
-
)
|
|
1185
|
-
.alias(col_name)
|
|
1186
|
-
).select(self.dimensions_unsafe + [col_name])
|
|
1187
|
-
|
|
1188
|
-
def on_add_to_model(self, model: "Model", name: str):
|
|
1189
|
-
super().on_add_to_model(model, name)
|
|
1190
|
-
if self.to_relax is not None:
|
|
1191
|
-
self.relax(*self.to_relax.args, **self.to_relax.kwargs)
|
|
1192
|
-
self._assign_ids()
|
|
1193
|
-
|
|
1194
|
-
def _assign_ids(self):
|
|
1195
|
-
assert self._model is not None
|
|
1196
|
-
|
|
1197
|
-
is_quadratic = self.lhs.is_quadratic
|
|
1198
|
-
use_var_names = self._model.use_var_names
|
|
1199
|
-
kwargs: Dict[str, Any] = dict(sense=self.sense.to_poi(), rhs=0)
|
|
1200
|
-
|
|
1201
|
-
key_cols = [COEF_KEY] + self.lhs._variable_columns
|
|
1202
|
-
key_cols_polars = [pl.col(c) for c in key_cols]
|
|
1203
|
-
|
|
1204
|
-
add_constraint = (
|
|
1205
|
-
self._model.poi.add_quadratic_constraint
|
|
1206
|
-
if is_quadratic
|
|
1207
|
-
else self._model.poi.add_linear_constraint
|
|
1208
|
-
)
|
|
1209
|
-
ScalarFunction = (
|
|
1210
|
-
poi.ScalarQuadraticFunction if is_quadratic else poi.ScalarAffineFunction
|
|
1211
|
-
)
|
|
1212
|
-
|
|
1213
|
-
if self.dimensions is None:
|
|
1214
|
-
if self._model.use_var_names:
|
|
1215
|
-
kwargs["name"] = self.name
|
|
1216
|
-
df = self.data.with_columns(
|
|
1217
|
-
pl.lit(
|
|
1218
|
-
add_constraint(
|
|
1219
|
-
ScalarFunction(
|
|
1220
|
-
*[self.lhs.data.get_column(c).to_numpy() for c in key_cols]
|
|
1221
|
-
),
|
|
1222
|
-
**kwargs,
|
|
1223
|
-
).index
|
|
1224
|
-
)
|
|
1225
|
-
.alias(CONSTRAINT_KEY)
|
|
1226
|
-
.cast(KEY_TYPE)
|
|
1227
|
-
)
|
|
1228
|
-
else:
|
|
1229
|
-
df = self.lhs.data.group_by(self.dimensions, maintain_order=True).agg(
|
|
1230
|
-
*key_cols_polars
|
|
1231
|
-
)
|
|
1232
|
-
if use_var_names:
|
|
1233
|
-
df = (
|
|
1234
|
-
concat_dimensions(df, prefix=self.name)
|
|
1235
|
-
.with_columns(
|
|
1236
|
-
pl.struct(*key_cols_polars, pl.col("concated_dim"))
|
|
1237
|
-
.map_elements(
|
|
1238
|
-
lambda x: add_constraint(
|
|
1239
|
-
ScalarFunction(*[np.array(x[c]) for c in key_cols]),
|
|
1240
|
-
name=x["concated_dim"],
|
|
1241
|
-
**kwargs,
|
|
1242
|
-
).index,
|
|
1243
|
-
return_dtype=KEY_TYPE,
|
|
1244
|
-
)
|
|
1245
|
-
.alias(CONSTRAINT_KEY)
|
|
1246
|
-
)
|
|
1247
|
-
.drop("concated_dim")
|
|
1248
|
-
)
|
|
1249
|
-
else:
|
|
1250
|
-
df = df.with_columns(
|
|
1251
|
-
pl.struct(*key_cols_polars)
|
|
1252
|
-
.map_elements(
|
|
1253
|
-
lambda x: add_constraint(
|
|
1254
|
-
ScalarFunction(*[np.array(x[c]) for c in key_cols]),
|
|
1255
|
-
**kwargs,
|
|
1256
|
-
).index,
|
|
1257
|
-
return_dtype=KEY_TYPE,
|
|
1258
|
-
)
|
|
1259
|
-
.alias(CONSTRAINT_KEY)
|
|
1260
|
-
)
|
|
1261
|
-
df = df.drop(key_cols)
|
|
1262
|
-
|
|
1263
|
-
self._data = df
|
|
1264
|
-
|
|
1265
|
-
@property
|
|
1266
|
-
@unwrap_single_values
|
|
1267
|
-
def dual(self) -> Union[pl.DataFrame, float]:
|
|
1268
|
-
dual = self.attr.Dual
|
|
1269
|
-
if isinstance(dual, pl.DataFrame):
|
|
1270
|
-
dual = dual.rename({"Dual": DUAL_KEY})
|
|
1271
|
-
return dual
|
|
1272
|
-
|
|
1273
|
-
@classmethod
|
|
1274
|
-
def get_id_column_name(cls):
|
|
1275
|
-
return CONSTRAINT_KEY
|
|
1276
|
-
|
|
1277
|
-
def filter(self, *args, **kwargs) -> pl.DataFrame:
|
|
1278
|
-
return self.lhs.data.filter(*args, **kwargs)
|
|
1279
|
-
|
|
1280
|
-
def relax(
|
|
1281
|
-
self, cost: SupportsToExpr, max: Optional[SupportsToExpr] = None
|
|
1282
|
-
) -> Constraint:
|
|
1283
|
-
"""
|
|
1284
|
-
Relaxes the constraint by adding a variable to the constraint that can be non-zero at a cost.
|
|
1285
|
-
|
|
1286
|
-
Parameters:
|
|
1287
|
-
cost:
|
|
1288
|
-
The cost of relaxing the constraint. Costs should be positives as they will automatically
|
|
1289
|
-
become negative for maximization problems.
|
|
1290
|
-
max:
|
|
1291
|
-
The maximum value of the relaxation variable.
|
|
1292
|
-
|
|
1293
|
-
Returns:
|
|
1294
|
-
The same constraint
|
|
1295
|
-
|
|
1296
|
-
Examples:
|
|
1297
|
-
>>> m = pf.Model()
|
|
1298
|
-
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1299
|
-
>>> m.hours_day = pf.Variable(lb=0)
|
|
1300
|
-
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1301
|
-
>>> m.maximize = m.hours_day
|
|
1302
|
-
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1303
|
-
>>> m.optimize()
|
|
1304
|
-
>>> m.hours_day.solution
|
|
1305
|
-
16.0
|
|
1306
|
-
>>> m.maximize += 2 * m.hours_day
|
|
1307
|
-
>>> m.optimize()
|
|
1308
|
-
>>> m.hours_day.solution
|
|
1309
|
-
19.0
|
|
1310
|
-
|
|
1311
|
-
Note: .relax() can only be called after the sense of the model has been defined.
|
|
1312
|
-
|
|
1313
|
-
>>> m = pf.Model()
|
|
1314
|
-
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1315
|
-
>>> m.hours_day = pf.Variable(lb=0)
|
|
1316
|
-
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1317
|
-
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1318
|
-
Traceback (most recent call last):
|
|
1319
|
-
...
|
|
1320
|
-
ValueError: Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...).
|
|
1321
|
-
|
|
1322
|
-
One way to solve this is by setting the sense directly on the model. See how this works fine:
|
|
1323
|
-
|
|
1324
|
-
>>> m = pf.Model(sense="max")
|
|
1325
|
-
>>> m.hours_sleep = pf.Variable(lb=0)
|
|
1326
|
-
>>> m.hours_day = pf.Variable(lb=0)
|
|
1327
|
-
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
|
|
1328
|
-
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
|
|
1329
|
-
|
|
1330
|
-
And now an example with dimensions:
|
|
1331
|
-
|
|
1332
|
-
>>> 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]})
|
|
1333
|
-
>>> m.hours_spent = pf.Variable(homework_due_tomorrow[["project"]], lb=0)
|
|
1334
|
-
>>> 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"]])
|
|
1335
|
-
>>> m.only_one_day = sum("project", m.hours_spent) <= 24
|
|
1336
|
-
>>> # Relaxing a constraint after it has already been assigned will give an error
|
|
1337
|
-
>>> m.only_one_day.relax(1)
|
|
1338
|
-
Traceback (most recent call last):
|
|
1339
|
-
...
|
|
1340
|
-
ValueError: .relax() must be called before the Constraint is added to the model
|
|
1341
|
-
>>> m.attr.Silent = True
|
|
1342
|
-
>>> m.optimize()
|
|
1343
|
-
>>> m.maximize.value
|
|
1344
|
-
-50.0
|
|
1345
|
-
>>> m.hours_spent.solution
|
|
1346
|
-
shape: (3, 2)
|
|
1347
|
-
┌─────────┬──────────┐
|
|
1348
|
-
│ project ┆ solution │
|
|
1349
|
-
│ --- ┆ --- │
|
|
1350
|
-
│ str ┆ f64 │
|
|
1351
|
-
╞═════════╪══════════╡
|
|
1352
|
-
│ A ┆ 8.0 │
|
|
1353
|
-
│ B ┆ 7.0 │
|
|
1354
|
-
│ C ┆ 9.0 │
|
|
1355
|
-
└─────────┴──────────┘
|
|
1356
|
-
"""
|
|
1357
|
-
if self._has_ids:
|
|
1358
|
-
raise ValueError(
|
|
1359
|
-
".relax() must be called before the Constraint is added to the model"
|
|
1360
|
-
)
|
|
1361
|
-
|
|
1362
|
-
m = self._model
|
|
1363
|
-
if m is None or self.name is None:
|
|
1364
|
-
self.to_relax = FuncArgs(args=[cost, max])
|
|
1365
|
-
return self
|
|
1366
|
-
|
|
1367
|
-
var_name = f"{self.name}_relaxation"
|
|
1368
|
-
assert not hasattr(m, var_name), (
|
|
1369
|
-
"Conflicting names, relaxation variable already exists on the model."
|
|
1370
|
-
)
|
|
1371
|
-
var = Variable(self, lb=0, ub=max)
|
|
1372
|
-
setattr(m, var_name, var)
|
|
1373
|
-
|
|
1374
|
-
if self.sense == ConstraintSense.LE:
|
|
1375
|
-
self.lhs -= var
|
|
1376
|
-
elif self.sense == ConstraintSense.GE:
|
|
1377
|
-
self.lhs += var
|
|
1378
|
-
else: # pragma: no cover
|
|
1379
|
-
# TODO
|
|
1380
|
-
raise NotImplementedError(
|
|
1381
|
-
"Relaxation for equalities has not yet been implemented. Submit a pull request!"
|
|
1382
|
-
)
|
|
1383
|
-
|
|
1384
|
-
penalty = var * cost
|
|
1385
|
-
if self.dimensions:
|
|
1386
|
-
penalty = sum(self.dimensions, penalty)
|
|
1387
|
-
if m.sense is None:
|
|
1388
|
-
raise ValueError(
|
|
1389
|
-
"Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...)."
|
|
1390
|
-
)
|
|
1391
|
-
elif m.sense == ObjSense.MAX:
|
|
1392
|
-
penalty *= -1
|
|
1393
|
-
if m.objective is None:
|
|
1394
|
-
m.objective = penalty
|
|
1395
|
-
else:
|
|
1396
|
-
m.objective += penalty
|
|
1397
|
-
|
|
1398
|
-
return self
|
|
1399
|
-
|
|
1400
|
-
def to_str(self) -> str:
|
|
1401
|
-
dims = self.dimensions
|
|
1402
|
-
str_table = self.lhs.to_str_table(include_const_term=False)
|
|
1403
|
-
str_table = self.lhs.to_str_create_prefix(str_table)
|
|
1404
|
-
rhs = self.lhs.constant_terms.with_columns(pl.col(COEF_KEY) * -1)
|
|
1405
|
-
rhs = cast_coef_to_string(rhs, drop_ones=False)
|
|
1406
|
-
# Remove leading +
|
|
1407
|
-
rhs = rhs.with_columns(pl.col(COEF_KEY).str.strip_chars(characters=" +"))
|
|
1408
|
-
rhs = rhs.rename({COEF_KEY: "rhs"})
|
|
1409
|
-
constr_str = pl.concat(
|
|
1410
|
-
[str_table, rhs], how=("align" if dims else "horizontal")
|
|
1411
|
-
)
|
|
1412
|
-
constr_str = constr_str.select(
|
|
1413
|
-
pl.concat_str("expr", pl.lit(f" {self.sense.value} "), "rhs").str.join(
|
|
1414
|
-
delimiter="\n"
|
|
1415
|
-
)
|
|
1416
|
-
).item()
|
|
1417
|
-
return constr_str
|
|
1418
|
-
|
|
1419
|
-
def __repr__(self) -> str:
|
|
1420
|
-
return (
|
|
1421
|
-
get_obj_repr(
|
|
1422
|
-
self,
|
|
1423
|
-
("name",),
|
|
1424
|
-
sense=f"'{self.sense.value}'",
|
|
1425
|
-
size=len(self),
|
|
1426
|
-
dimensions=self.shape,
|
|
1427
|
-
terms=len(self.lhs.data),
|
|
1428
|
-
)
|
|
1429
|
-
+ "\n"
|
|
1430
|
-
+ self.to_str()
|
|
1431
|
-
)
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
1435
|
-
"""
|
|
1436
|
-
Represents one or many decision variable in an optimization model.
|
|
1437
|
-
|
|
1438
|
-
Parameters:
|
|
1439
|
-
*indexing_sets:
|
|
1440
|
-
If no indexing_sets are provided, a single variable with no dimensions is created.
|
|
1441
|
-
Otherwise, a variable is created for each element in the Cartesian product of the indexing_sets (see Set for details on behaviour).
|
|
1442
|
-
lb:
|
|
1443
|
-
The lower bound for all variables.
|
|
1444
|
-
ub:
|
|
1445
|
-
The upper bound for all variables.
|
|
1446
|
-
vtype:
|
|
1447
|
-
The type of the variable. Can be either a VType enum or a string. Default is VType.CONTINUOUS.
|
|
1448
|
-
equals:
|
|
1449
|
-
When specified, a variable is created and a constraint is added to make the variable equal to the provided expression.
|
|
1450
|
-
|
|
1451
|
-
Examples:
|
|
1452
|
-
>>> import pandas as pd
|
|
1453
|
-
>>> m = pf.Model()
|
|
1454
|
-
>>> df = pd.DataFrame({"dim1": [1, 1, 2, 2, 3, 3], "dim2": ["a", "b", "a", "b", "a", "b"]})
|
|
1455
|
-
>>> v = Variable(df)
|
|
1456
|
-
>>> v
|
|
1457
|
-
<Variable size=6 dimensions={'dim1': 3, 'dim2': 2} added_to_model=False>
|
|
1458
|
-
|
|
1459
|
-
Variables cannot be used until they're added to the model.
|
|
1460
|
-
|
|
1461
|
-
>>> m.constraint = v <= 3
|
|
1462
|
-
Traceback (most recent call last):
|
|
1463
|
-
...
|
|
1464
|
-
ValueError: Cannot use 'Variable' before it has beed added to a model.
|
|
1465
|
-
>>> m.v = v
|
|
1466
|
-
>>> m.constraint = m.v <= 3
|
|
1467
|
-
|
|
1468
|
-
>>> m.v
|
|
1469
|
-
<Variable name=v size=6 dimensions={'dim1': 3, 'dim2': 2}>
|
|
1470
|
-
[1,a]: v[1,a]
|
|
1471
|
-
[1,b]: v[1,b]
|
|
1472
|
-
[2,a]: v[2,a]
|
|
1473
|
-
[2,b]: v[2,b]
|
|
1474
|
-
[3,a]: v[3,a]
|
|
1475
|
-
[3,b]: v[3,b]
|
|
1476
|
-
>>> m.v2 = Variable(df[["dim1"]])
|
|
1477
|
-
Traceback (most recent call last):
|
|
1478
|
-
...
|
|
1479
|
-
ValueError: Duplicate rows found in input data.
|
|
1480
|
-
>>> m.v3 = Variable(df[["dim1"]].drop_duplicates())
|
|
1481
|
-
>>> m.v3
|
|
1482
|
-
<Variable name=v3 size=3 dimensions={'dim1': 3}>
|
|
1483
|
-
[1]: v3[1]
|
|
1484
|
-
[2]: v3[2]
|
|
1485
|
-
[3]: v3[3]
|
|
1486
|
-
"""
|
|
1487
|
-
|
|
1488
|
-
# TODO: Breaking change, remove support for Iterable[AcceptableSets]
|
|
1489
|
-
def __init__(
|
|
1490
|
-
self,
|
|
1491
|
-
*indexing_sets: SetTypes | Iterable[SetTypes],
|
|
1492
|
-
lb: float | int | SupportsToExpr | None = None,
|
|
1493
|
-
ub: float | int | SupportsToExpr | None = None,
|
|
1494
|
-
vtype: VType | VTypeValue = VType.CONTINUOUS,
|
|
1495
|
-
equals: Optional[SupportsMath] = None,
|
|
1496
|
-
):
|
|
1497
|
-
if equals is not None:
|
|
1498
|
-
assert len(indexing_sets) == 0, (
|
|
1499
|
-
"Cannot specify both 'equals' and 'indexing_sets'"
|
|
1500
|
-
)
|
|
1501
|
-
indexing_sets = (equals,)
|
|
1502
|
-
|
|
1503
|
-
data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
|
|
1504
|
-
super().__init__(data)
|
|
1505
|
-
|
|
1506
|
-
self.vtype: VType = VType(vtype)
|
|
1507
|
-
self.attr = Container(self._set_attribute, self._get_attribute)
|
|
1508
|
-
self._equals = equals
|
|
1509
|
-
|
|
1510
|
-
if lb is not None and not isinstance(lb, (float, int)):
|
|
1511
|
-
self._lb_expr, self.lb = lb, None
|
|
1512
|
-
else:
|
|
1513
|
-
self._lb_expr, self.lb = None, lb
|
|
1514
|
-
if ub is not None and not isinstance(ub, (float, int)):
|
|
1515
|
-
self._ub_expr, self.ub = ub, None
|
|
1516
|
-
else:
|
|
1517
|
-
self._ub_expr, self.ub = None, ub
|
|
1518
|
-
|
|
1519
|
-
def _set_attribute(self, name, value):
|
|
1520
|
-
self._assert_has_ids()
|
|
1521
|
-
col_name = name
|
|
1522
|
-
try:
|
|
1523
|
-
name = poi.VariableAttribute[name]
|
|
1524
|
-
setter = self._model.poi.set_variable_attribute
|
|
1525
|
-
except KeyError:
|
|
1526
|
-
setter = self._model.poi.set_variable_raw_attribute
|
|
1527
|
-
|
|
1528
|
-
if self.dimensions is None:
|
|
1529
|
-
for key in self.data.get_column(VAR_KEY):
|
|
1530
|
-
setter(poi.VariableIndex(key), name, value)
|
|
1531
|
-
else:
|
|
1532
|
-
for key, v in (
|
|
1533
|
-
self.data.join(value, on=self.dimensions, how="inner")
|
|
1534
|
-
.select(pl.col(VAR_KEY), pl.col(col_name))
|
|
1535
|
-
.iter_rows()
|
|
1536
|
-
):
|
|
1537
|
-
setter(poi.VariableIndex(key), name, v)
|
|
1538
|
-
|
|
1539
|
-
@unwrap_single_values
|
|
1540
|
-
def _get_attribute(self, name):
|
|
1541
|
-
self._assert_has_ids()
|
|
1542
|
-
col_name = name
|
|
1543
|
-
try:
|
|
1544
|
-
name = poi.VariableAttribute[name]
|
|
1545
|
-
getter = self._model.poi.get_variable_attribute
|
|
1546
|
-
except KeyError:
|
|
1547
|
-
getter = self._model.poi.get_variable_raw_attribute
|
|
1548
|
-
|
|
1549
|
-
with (
|
|
1550
|
-
warnings.catch_warnings()
|
|
1551
|
-
): # map_elements without return_dtype= gives a warning
|
|
1552
|
-
warnings.filterwarnings(
|
|
1553
|
-
action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
|
|
1554
|
-
)
|
|
1555
|
-
return self.data.with_columns(
|
|
1556
|
-
pl.col(VAR_KEY)
|
|
1557
|
-
.map_elements(lambda v_id: getter(poi.VariableIndex(v_id), name))
|
|
1558
|
-
.alias(col_name)
|
|
1559
|
-
).select(self.dimensions_unsafe + [col_name])
|
|
1560
|
-
|
|
1561
|
-
def _assign_ids(self):
|
|
1562
|
-
kwargs = dict(domain=self.vtype.to_poi())
|
|
1563
|
-
if self.lb is not None:
|
|
1564
|
-
kwargs["lb"] = self.lb
|
|
1565
|
-
if self.ub is not None:
|
|
1566
|
-
kwargs["ub"] = self.ub
|
|
1567
|
-
|
|
1568
|
-
if self.dimensions is not None and self._model.use_var_names:
|
|
1569
|
-
df = (
|
|
1570
|
-
concat_dimensions(self.data, prefix=self.name)
|
|
1571
|
-
.with_columns(
|
|
1572
|
-
pl.col("concated_dim")
|
|
1573
|
-
.map_elements(
|
|
1574
|
-
lambda name: self._model.poi.add_variable(
|
|
1575
|
-
name=name, **kwargs
|
|
1576
|
-
).index,
|
|
1577
|
-
return_dtype=KEY_TYPE,
|
|
1578
|
-
)
|
|
1579
|
-
.alias(VAR_KEY)
|
|
1580
|
-
)
|
|
1581
|
-
.drop("concated_dim")
|
|
1582
|
-
)
|
|
1583
|
-
else:
|
|
1584
|
-
if self._model.use_var_names:
|
|
1585
|
-
kwargs["name"] = self.name
|
|
1586
|
-
|
|
1587
|
-
df = self.data.with_columns(
|
|
1588
|
-
pl.lit(0).alias(VAR_KEY).cast(KEY_TYPE)
|
|
1589
|
-
).with_columns(
|
|
1590
|
-
pl.col(VAR_KEY).map_elements(
|
|
1591
|
-
lambda _: self._model.poi.add_variable(**kwargs).index,
|
|
1592
|
-
return_dtype=KEY_TYPE,
|
|
1593
|
-
)
|
|
1594
|
-
)
|
|
1595
|
-
|
|
1596
|
-
self._data = df
|
|
1597
|
-
|
|
1598
|
-
def on_add_to_model(self, model, name):
|
|
1599
|
-
super().on_add_to_model(model, name)
|
|
1600
|
-
self._assign_ids()
|
|
1601
|
-
if self._lb_expr is not None:
|
|
1602
|
-
setattr(model, f"{name}_lb", self._lb_expr <= self)
|
|
1603
|
-
|
|
1604
|
-
if self._ub_expr is not None:
|
|
1605
|
-
setattr(model, f"{name}_ub", self <= self._ub_expr)
|
|
1606
|
-
|
|
1607
|
-
if self._equals is not None:
|
|
1608
|
-
setattr(model, f"{name}_equals", self == self._equals)
|
|
1609
|
-
|
|
1610
|
-
@classmethod
|
|
1611
|
-
def get_id_column_name(cls):
|
|
1612
|
-
return VAR_KEY
|
|
1613
|
-
|
|
1614
|
-
@property
|
|
1615
|
-
@unwrap_single_values
|
|
1616
|
-
def solution(self):
|
|
1617
|
-
"""
|
|
1618
|
-
Retrieve a variable's optimal value after the model has been solved.
|
|
1619
|
-
Returned as a DataFrame if the variable has dimensions, otherwise as a single value.
|
|
1620
|
-
Binary and integer variables are returned as integers.
|
|
1621
|
-
|
|
1622
|
-
Examples:
|
|
1623
|
-
>>> m = pf.Model()
|
|
1624
|
-
>>> m.var_continuous = pf.Variable({"dim1": [1, 2, 3]}, lb=5, ub=5)
|
|
1625
|
-
>>> m.var_integer = pf.Variable({"dim1": [1, 2, 3]}, lb=4.5, ub=5.5, vtype=VType.INTEGER)
|
|
1626
|
-
>>> m.var_dimensionless = pf.Variable(lb=4.5, ub=5.5, vtype=VType.INTEGER)
|
|
1627
|
-
>>> m.var_continuous.solution
|
|
1628
|
-
Traceback (most recent call last):
|
|
1629
|
-
...
|
|
1630
|
-
RuntimeError: Failed to retrieve solution for variable. Are you sure the model has been solved?
|
|
1631
|
-
>>> m.optimize()
|
|
1632
|
-
>>> m.var_continuous.solution
|
|
1633
|
-
shape: (3, 2)
|
|
1634
|
-
┌──────┬──────────┐
|
|
1635
|
-
│ dim1 ┆ solution │
|
|
1636
|
-
│ --- ┆ --- │
|
|
1637
|
-
│ i64 ┆ f64 │
|
|
1638
|
-
╞══════╪══════════╡
|
|
1639
|
-
│ 1 ┆ 5.0 │
|
|
1640
|
-
│ 2 ┆ 5.0 │
|
|
1641
|
-
│ 3 ┆ 5.0 │
|
|
1642
|
-
└──────┴──────────┘
|
|
1643
|
-
>>> m.var_integer.solution
|
|
1644
|
-
shape: (3, 2)
|
|
1645
|
-
┌──────┬──────────┐
|
|
1646
|
-
│ dim1 ┆ solution │
|
|
1647
|
-
│ --- ┆ --- │
|
|
1648
|
-
│ i64 ┆ i64 │
|
|
1649
|
-
╞══════╪══════════╡
|
|
1650
|
-
│ 1 ┆ 5 │
|
|
1651
|
-
│ 2 ┆ 5 │
|
|
1652
|
-
│ 3 ┆ 5 │
|
|
1653
|
-
└──────┴──────────┘
|
|
1654
|
-
>>> m.var_dimensionless.solution
|
|
1655
|
-
5
|
|
1656
|
-
"""
|
|
1657
|
-
try:
|
|
1658
|
-
solution = self.attr.Value
|
|
1659
|
-
except RuntimeError as e:
|
|
1660
|
-
raise RuntimeError(
|
|
1661
|
-
"Failed to retrieve solution for variable. Are you sure the model has been solved?"
|
|
1662
|
-
) from e
|
|
1663
|
-
if isinstance(solution, pl.DataFrame):
|
|
1664
|
-
solution = solution.rename({"Value": SOLUTION_KEY})
|
|
1665
|
-
|
|
1666
|
-
if self.vtype in [VType.BINARY, VType.INTEGER]:
|
|
1667
|
-
if isinstance(solution, pl.DataFrame):
|
|
1668
|
-
solution = solution.with_columns(
|
|
1669
|
-
pl.col("solution").alias("solution_float"),
|
|
1670
|
-
pl.col("solution").round().cast(pl.Int64),
|
|
1671
|
-
)
|
|
1672
|
-
if Config.integer_tolerance != 0:
|
|
1673
|
-
df = solution.filter(
|
|
1674
|
-
(pl.col("solution_float") - pl.col("solution")).abs()
|
|
1675
|
-
> Config.integer_tolerance
|
|
1676
|
-
)
|
|
1677
|
-
assert df.is_empty(), (
|
|
1678
|
-
f"Variable {self.name} has a non-integer value: {df}\nThis should not happen."
|
|
1679
|
-
)
|
|
1680
|
-
solution = solution.drop("solution_float")
|
|
1681
|
-
else:
|
|
1682
|
-
solution_float = solution
|
|
1683
|
-
solution = int(round(solution))
|
|
1684
|
-
if Config.integer_tolerance != 0:
|
|
1685
|
-
assert abs(solution - solution_float) < Config.integer_tolerance, (
|
|
1686
|
-
f"Value of variable {self.name} is not an integer: {solution}. This should not happen."
|
|
1687
|
-
)
|
|
1688
|
-
|
|
1689
|
-
return solution
|
|
1690
|
-
|
|
1691
|
-
def __repr__(self):
|
|
1692
|
-
if self._has_ids:
|
|
1693
|
-
return (
|
|
1694
|
-
get_obj_repr(
|
|
1695
|
-
self,
|
|
1696
|
-
("name", "lb", "ub"),
|
|
1697
|
-
size=self.data.height,
|
|
1698
|
-
dimensions=self.shape,
|
|
1699
|
-
)
|
|
1700
|
-
+ "\n"
|
|
1701
|
-
+ self.to_expr().to_str()
|
|
1702
|
-
)
|
|
1703
|
-
else:
|
|
1704
|
-
return get_obj_repr(
|
|
1705
|
-
self,
|
|
1706
|
-
("name", "lb", "ub"),
|
|
1707
|
-
size=self.data.height,
|
|
1708
|
-
dimensions=self.shape,
|
|
1709
|
-
added_to_model=False,
|
|
1710
|
-
)
|
|
1711
|
-
|
|
1712
|
-
def to_expr(self) -> Expression:
|
|
1713
|
-
self._assert_has_ids()
|
|
1714
|
-
return self._new(self.data.drop(SOLUTION_KEY, strict=False))
|
|
1715
|
-
|
|
1716
|
-
def _new(self, data: pl.DataFrame):
|
|
1717
|
-
self._assert_has_ids()
|
|
1718
|
-
e = Expression(data.with_columns(pl.lit(1.0).alias(COEF_KEY)))
|
|
1719
|
-
e._model = self._model
|
|
1720
|
-
# We propogate the unmatched strategy intentionally. Without this a .keep_unmatched() on a variable would always be lost.
|
|
1721
|
-
e.unmatched_strategy = self.unmatched_strategy
|
|
1722
|
-
e.allowed_new_dims = self.allowed_new_dims
|
|
1723
|
-
return e
|
|
1724
|
-
|
|
1725
|
-
def next(self, dim: str, wrap_around: bool = False) -> Expression:
|
|
1726
|
-
"""
|
|
1727
|
-
Creates an expression where the variable at each index is the next variable in the specified dimension.
|
|
1728
|
-
|
|
1729
|
-
Parameters:
|
|
1730
|
-
dim:
|
|
1731
|
-
The dimension over which to shift the variable.
|
|
1732
|
-
wrap_around:
|
|
1733
|
-
If True, the last index in the dimension is connected to the first index.
|
|
1734
|
-
|
|
1735
|
-
Examples:
|
|
1736
|
-
>>> import pandas as pd
|
|
1737
|
-
>>> time_dim = pd.DataFrame({"time": ["00:00", "06:00", "12:00", "18:00"]})
|
|
1738
|
-
>>> space_dim = pd.DataFrame({"city": ["Toronto", "Berlin"]})
|
|
1739
|
-
>>> m = pf.Model()
|
|
1740
|
-
>>> m.bat_charge = pf.Variable(time_dim, space_dim)
|
|
1741
|
-
>>> m.bat_flow = pf.Variable(time_dim, space_dim)
|
|
1742
|
-
>>> # Fails because the dimensions are not the same
|
|
1743
|
-
>>> m.bat_charge + m.bat_flow == m.bat_charge.next("time")
|
|
1744
|
-
Traceback (most recent call last):
|
|
1745
|
-
...
|
|
1746
|
-
pyoframe.constants.PyoframeError: Failed to add expressions:
|
|
1747
|
-
<Expression size=8 dimensions={'time': 4, 'city': 2} terms=16> + <Expression size=6 dimensions={'city': 2, 'time': 3} terms=6>
|
|
1748
|
-
Due to error:
|
|
1749
|
-
Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()
|
|
1750
|
-
shape: (2, 4)
|
|
1751
|
-
┌───────┬─────────┬────────────┬────────────┐
|
|
1752
|
-
│ time ┆ city ┆ time_right ┆ city_right │
|
|
1753
|
-
│ --- ┆ --- ┆ --- ┆ --- │
|
|
1754
|
-
│ str ┆ str ┆ str ┆ str │
|
|
1755
|
-
╞═══════╪═════════╪════════════╪════════════╡
|
|
1756
|
-
│ 18:00 ┆ Toronto ┆ null ┆ null │
|
|
1757
|
-
│ 18:00 ┆ Berlin ┆ null ┆ null │
|
|
1758
|
-
└───────┴─────────┴────────────┴────────────┘
|
|
1759
|
-
|
|
1760
|
-
>>> (m.bat_charge + m.bat_flow).drop_unmatched() == m.bat_charge.next("time")
|
|
1761
|
-
<Constraint sense='=' size=6 dimensions={'time': 3, 'city': 2} terms=18>
|
|
1762
|
-
[00:00,Berlin]: bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin] = 0
|
|
1763
|
-
[00:00,Toronto]: bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto] - bat_charge[06:00,Toronto] = 0
|
|
1764
|
-
[06:00,Berlin]: bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin] = 0
|
|
1765
|
-
[06:00,Toronto]: bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto] - bat_charge[12:00,Toronto] = 0
|
|
1766
|
-
[12:00,Berlin]: bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin] = 0
|
|
1767
|
-
[12:00,Toronto]: bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto] - bat_charge[18:00,Toronto] = 0
|
|
1768
|
-
|
|
1769
|
-
>>> (m.bat_charge + m.bat_flow) == m.bat_charge.next("time", wrap_around=True)
|
|
1770
|
-
<Constraint sense='=' size=8 dimensions={'time': 4, 'city': 2} terms=24>
|
|
1771
|
-
[00:00,Berlin]: bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin] = 0
|
|
1772
|
-
[00:00,Toronto]: bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto] - bat_charge[06:00,Toronto] = 0
|
|
1773
|
-
[06:00,Berlin]: bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin] = 0
|
|
1774
|
-
[06:00,Toronto]: bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto] - bat_charge[12:00,Toronto] = 0
|
|
1775
|
-
[12:00,Berlin]: bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin] = 0
|
|
1776
|
-
[12:00,Toronto]: bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto] - bat_charge[18:00,Toronto] = 0
|
|
1777
|
-
[18:00,Berlin]: bat_charge[18:00,Berlin] + bat_flow[18:00,Berlin] - bat_charge[00:00,Berlin] = 0
|
|
1778
|
-
[18:00,Toronto]: bat_charge[18:00,Toronto] + bat_flow[18:00,Toronto] - bat_charge[00:00,Toronto] = 0
|
|
1779
|
-
"""
|
|
1780
|
-
|
|
1781
|
-
wrapped = self.data.select(dim).unique(maintain_order=True).sort(by=dim)
|
|
1782
|
-
wrapped = wrapped.with_columns(pl.col(dim).shift(-1).alias("__next"))
|
|
1783
|
-
if wrap_around:
|
|
1784
|
-
wrapped = wrapped.with_columns(pl.col("__next").fill_null(pl.first(dim)))
|
|
1785
|
-
else:
|
|
1786
|
-
wrapped = wrapped.drop_nulls(dim)
|
|
1787
|
-
|
|
1788
|
-
expr = self.to_expr()
|
|
1789
|
-
data = expr.data.rename({dim: "__prev"})
|
|
1790
|
-
|
|
1791
|
-
data = data.join(
|
|
1792
|
-
wrapped, left_on="__prev", right_on="__next", how="inner"
|
|
1793
|
-
).drop(["__prev", "__next"], strict=False)
|
|
1794
|
-
return expr._new(data)
|