pyoframe 0.0.4__py3-none-any.whl → 0.0.6__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 +12 -3
- pyoframe/_arithmetic.py +3 -6
- pyoframe/constants.py +20 -14
- pyoframe/{constraints.py → core.py} +504 -74
- pyoframe/io.py +66 -30
- pyoframe/io_mappers.py +66 -34
- pyoframe/model.py +65 -41
- pyoframe/model_element.py +128 -18
- pyoframe/monkey_patch.py +2 -2
- pyoframe/objective.py +16 -13
- pyoframe/solvers.py +300 -109
- pyoframe/user_defined.py +60 -0
- pyoframe/util.py +56 -55
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.6.dist-info}/METADATA +9 -2
- pyoframe-0.0.6.dist-info/RECORD +18 -0
- pyoframe/variables.py +0 -193
- pyoframe-0.0.4.dist-info/RECORD +0 -18
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.6.dist-info}/LICENSE +0 -0
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.6.dist-info}/WHEEL +0 -0
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.6.dist-info}/top_level.txt +0 -0
|
@@ -8,6 +8,7 @@ from typing import (
|
|
|
8
8
|
overload,
|
|
9
9
|
Union,
|
|
10
10
|
Optional,
|
|
11
|
+
TYPE_CHECKING,
|
|
11
12
|
)
|
|
12
13
|
from abc import ABC, abstractmethod
|
|
13
14
|
|
|
@@ -21,20 +22,36 @@ from pyoframe.constants import (
|
|
|
21
22
|
CONSTRAINT_KEY,
|
|
22
23
|
DUAL_KEY,
|
|
23
24
|
RESERVED_COL_KEYS,
|
|
25
|
+
SLACK_COL,
|
|
24
26
|
VAR_KEY,
|
|
27
|
+
SOLUTION_KEY,
|
|
28
|
+
RC_COL,
|
|
29
|
+
VType,
|
|
30
|
+
VTypeValue,
|
|
25
31
|
Config,
|
|
26
32
|
ConstraintSense,
|
|
27
33
|
UnmatchedStrategy,
|
|
34
|
+
PyoframeError,
|
|
35
|
+
ObjSense,
|
|
28
36
|
)
|
|
29
37
|
from pyoframe.util import (
|
|
30
|
-
IdCounterMixin,
|
|
31
38
|
cast_coef_to_string,
|
|
32
39
|
concat_dimensions,
|
|
33
40
|
get_obj_repr,
|
|
34
41
|
parse_inputs_as_iterable,
|
|
42
|
+
unwrap_single_values,
|
|
43
|
+
dataframe_to_tupled_list,
|
|
44
|
+
FuncArgs,
|
|
35
45
|
)
|
|
36
46
|
|
|
37
|
-
from pyoframe.model_element import
|
|
47
|
+
from pyoframe.model_element import (
|
|
48
|
+
ModelElement,
|
|
49
|
+
ModelElementWithId,
|
|
50
|
+
SupportPolarsMethodMixin,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
54
|
+
from pyoframe.model import Model
|
|
38
55
|
|
|
39
56
|
VAR_TYPE = pl.UInt32
|
|
40
57
|
|
|
@@ -54,9 +71,10 @@ class SupportsToExpr(Protocol):
|
|
|
54
71
|
class SupportsMath(ABC, SupportsToExpr):
|
|
55
72
|
"""Any object that can be converted into an expression."""
|
|
56
73
|
|
|
57
|
-
def __init__(self):
|
|
74
|
+
def __init__(self, **kwargs):
|
|
58
75
|
self.unmatched_strategy = UnmatchedStrategy.UNSET
|
|
59
76
|
self.allowed_new_dims: List[str] = []
|
|
77
|
+
super().__init__(**kwargs)
|
|
60
78
|
|
|
61
79
|
def keep_unmatched(self):
|
|
62
80
|
self.unmatched_strategy = UnmatchedStrategy.KEEP
|
|
@@ -71,8 +89,7 @@ class SupportsMath(ABC, SupportsToExpr):
|
|
|
71
89
|
return self
|
|
72
90
|
|
|
73
91
|
@abstractmethod
|
|
74
|
-
def to_expr(self) -> "Expression":
|
|
75
|
-
raise NotImplementedError
|
|
92
|
+
def to_expr(self) -> "Expression": ...
|
|
76
93
|
|
|
77
94
|
__add__ = _forward_to_expression("__add__")
|
|
78
95
|
__mul__ = _forward_to_expression("__mul__")
|
|
@@ -145,16 +162,17 @@ SetTypes = Union[
|
|
|
145
162
|
SupportsMath,
|
|
146
163
|
Mapping[str, Sequence[object]],
|
|
147
164
|
"Set",
|
|
165
|
+
"Constraint",
|
|
148
166
|
]
|
|
149
167
|
|
|
150
168
|
|
|
151
|
-
class Set(ModelElement, SupportsMath):
|
|
169
|
+
class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
152
170
|
def __init__(self, *data: SetTypes | Iterable[SetTypes], **named_data):
|
|
153
171
|
data_list = list(data)
|
|
154
172
|
for name, set in named_data.items():
|
|
155
173
|
data_list.append({name: set})
|
|
156
174
|
df = self._parse_acceptable_sets(*data_list)
|
|
157
|
-
if df.is_duplicated().any():
|
|
175
|
+
if not df.is_empty() and df.is_duplicated().any():
|
|
158
176
|
raise ValueError("Duplicate rows found in input data.")
|
|
159
177
|
super().__init__(df)
|
|
160
178
|
|
|
@@ -224,20 +242,34 @@ class Set(ModelElement, SupportsMath):
|
|
|
224
242
|
|
|
225
243
|
def __add__(self, other):
|
|
226
244
|
if isinstance(other, Set):
|
|
227
|
-
|
|
245
|
+
try:
|
|
246
|
+
return self._new(
|
|
247
|
+
pl.concat([self.data, other.data]).unique(maintain_order=True)
|
|
248
|
+
)
|
|
249
|
+
except pl.ShapeError as e:
|
|
250
|
+
if "unable to vstack, column names don't match" in str(e):
|
|
251
|
+
raise PyoframeError(
|
|
252
|
+
f"Failed to add sets '{self.friendly_name}' and '{other.friendly_name}' because dimensions do not match ({self.dimensions} != {other.dimensions}) "
|
|
253
|
+
) from e
|
|
254
|
+
raise e
|
|
255
|
+
|
|
228
256
|
return super().__add__(other)
|
|
229
257
|
|
|
230
258
|
def __repr__(self):
|
|
231
259
|
return (
|
|
232
260
|
get_obj_repr(self, ("name",), size=self.data.height, dimensions=self.shape)
|
|
233
261
|
+ "\n"
|
|
234
|
-
+
|
|
262
|
+
+ dataframe_to_tupled_list(
|
|
263
|
+
self.data, num_max_elements=Config.print_max_set_elements
|
|
264
|
+
)
|
|
235
265
|
)
|
|
236
266
|
|
|
237
267
|
@staticmethod
|
|
238
268
|
def _set_to_polars(set: "SetTypes") -> pl.DataFrame:
|
|
239
269
|
if isinstance(set, dict):
|
|
240
270
|
df = pl.DataFrame(set)
|
|
271
|
+
elif isinstance(set, Constraint):
|
|
272
|
+
df = set.data.select(set.dimensions_unsafe)
|
|
241
273
|
elif isinstance(set, SupportsMath):
|
|
242
274
|
df = set.to_expr().data.drop(RESERVED_COL_KEYS).unique(maintain_order=True)
|
|
243
275
|
elif isinstance(set, pd.Index):
|
|
@@ -267,7 +299,7 @@ class Set(ModelElement, SupportsMath):
|
|
|
267
299
|
return df
|
|
268
300
|
|
|
269
301
|
|
|
270
|
-
class Expression(ModelElement, SupportsMath):
|
|
302
|
+
class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
|
|
271
303
|
"""A linear expression."""
|
|
272
304
|
|
|
273
305
|
def __init__(self, data: pl.DataFrame):
|
|
@@ -275,7 +307,7 @@ class Expression(ModelElement, SupportsMath):
|
|
|
275
307
|
>>> import pandas as pd
|
|
276
308
|
>>> from pyoframe import Variable, Model
|
|
277
309
|
>>> 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"])
|
|
278
|
-
>>> m = Model()
|
|
310
|
+
>>> m = Model("min")
|
|
279
311
|
>>> m.Time = Variable(df.index)
|
|
280
312
|
>>> m.Size = Variable(df.index)
|
|
281
313
|
>>> expr = df["cost"] * m.Time + df["cost"] * m.Size
|
|
@@ -292,14 +324,31 @@ class Expression(ModelElement, SupportsMath):
|
|
|
292
324
|
assert COEF_KEY in data.columns, "Missing coefficient column."
|
|
293
325
|
|
|
294
326
|
# Sanity check no duplicates indices
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
327
|
+
if Config.enable_is_duplicated_expression_safety_check:
|
|
328
|
+
duplicated_mask = data.drop(COEF_KEY).is_duplicated()
|
|
329
|
+
# In theory this should never happen unless there's a bug in the library
|
|
330
|
+
if duplicated_mask.any(): # pragma: no cover
|
|
331
|
+
duplicated_data = data.filter(duplicated_mask)
|
|
332
|
+
raise ValueError(
|
|
333
|
+
f"Cannot create an expression with duplicate indices:\n{duplicated_data}."
|
|
334
|
+
)
|
|
300
335
|
|
|
301
336
|
super().__init__(data)
|
|
302
337
|
|
|
338
|
+
# Might add this in later
|
|
339
|
+
# @classmethod
|
|
340
|
+
# def empty(cls, dimensions=[], type=None):
|
|
341
|
+
# data = {COEF_KEY: [], VAR_KEY: []}
|
|
342
|
+
# data.update({d: [] for d in dimensions})
|
|
343
|
+
# schema = {COEF_KEY: pl.Float64, VAR_KEY: pl.UInt32}
|
|
344
|
+
# if type is not None:
|
|
345
|
+
# schema.update({d: t for d, t in zip(dimensions, type)})
|
|
346
|
+
# return Expression(
|
|
347
|
+
# pl.DataFrame(data).with_columns(
|
|
348
|
+
# *[pl.col(c).cast(t) for c, t in schema.items()]
|
|
349
|
+
# )
|
|
350
|
+
# )
|
|
351
|
+
|
|
303
352
|
def sum(self, over: Union[str, Iterable[str]]):
|
|
304
353
|
"""
|
|
305
354
|
Examples:
|
|
@@ -358,18 +407,18 @@ class Expression(ModelElement, SupportsMath):
|
|
|
358
407
|
|
|
359
408
|
>>> import polars as pl
|
|
360
409
|
>>> from pyoframe import Variable, Model
|
|
361
|
-
>>> pop_data = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "population": [10, 2, 8]}).to_expr()
|
|
410
|
+
>>> pop_data = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "year": [2024, 2024, 2024], "population": [10, 2, 8]}).to_expr()
|
|
362
411
|
>>> cities_and_countries = pl.DataFrame({"city": ["Toronto", "Vancouver", "Boston"], "country": ["Canada", "Canada", "USA"]})
|
|
363
412
|
>>> pop_data.map(cities_and_countries)
|
|
364
|
-
<Expression size=2 dimensions={'country': 2} terms=2>
|
|
365
|
-
[Canada]: 12
|
|
366
|
-
[USA]: 8
|
|
413
|
+
<Expression size=2 dimensions={'year': 1, 'country': 2} terms=2>
|
|
414
|
+
[2024,Canada]: 12
|
|
415
|
+
[2024,USA]: 8
|
|
367
416
|
|
|
368
417
|
>>> pop_data.map(cities_and_countries, drop_shared_dims=False)
|
|
369
|
-
<Expression size=3 dimensions={'city': 3, 'country': 2} terms=3>
|
|
370
|
-
[Toronto,Canada]: 10
|
|
371
|
-
[Vancouver,Canada]: 2
|
|
372
|
-
[Boston,USA]: 8
|
|
418
|
+
<Expression size=3 dimensions={'city': 3, 'year': 1, 'country': 2} terms=3>
|
|
419
|
+
[Toronto,2024,Canada]: 10
|
|
420
|
+
[Vancouver,2024,Canada]: 2
|
|
421
|
+
[Boston,2024,USA]: 8
|
|
373
422
|
"""
|
|
374
423
|
mapping_set = Set(mapping_set)
|
|
375
424
|
|
|
@@ -422,7 +471,7 @@ class Expression(ModelElement, SupportsMath):
|
|
|
422
471
|
>>> import polars as pl
|
|
423
472
|
>>> from pyoframe import Variable, Model
|
|
424
473
|
>>> cost = pl.DataFrame({"item" : [1, 1, 1, 2, 2], "time": [1, 2, 3, 1, 2], "cost": [1, 2, 3, 4, 5]})
|
|
425
|
-
>>> m = Model()
|
|
474
|
+
>>> m = Model("min")
|
|
426
475
|
>>> m.quantity = Variable(cost[["item", "time"]])
|
|
427
476
|
>>> (m.quantity * cost).rolling_sum(over="time", window_size=2)
|
|
428
477
|
<Expression size=5 dimensions={'item': 2, 'time': 3} terms=8>
|
|
@@ -501,7 +550,7 @@ class Expression(ModelElement, SupportsMath):
|
|
|
501
550
|
>>> var + pd.DataFrame({"dim1": [1,2], "add": [10, 20]})
|
|
502
551
|
Traceback (most recent call last):
|
|
503
552
|
...
|
|
504
|
-
pyoframe.
|
|
553
|
+
pyoframe.constants.PyoframeError: Failed to add expressions:
|
|
505
554
|
<Expression size=3 dimensions={'dim1': 3} terms=3> + <Expression size=2 dimensions={'dim1': 2} terms=2>
|
|
506
555
|
Due to error:
|
|
507
556
|
Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()
|
|
@@ -630,11 +679,67 @@ class Expression(ModelElement, SupportsMath):
|
|
|
630
679
|
def variable_terms(self):
|
|
631
680
|
return self.data.filter(pl.col(VAR_KEY) != CONST_TERM)
|
|
632
681
|
|
|
682
|
+
@property
|
|
683
|
+
@unwrap_single_values
|
|
684
|
+
def value(self) -> pl.DataFrame:
|
|
685
|
+
"""
|
|
686
|
+
The value of the expression. Only available after the model has been solved.
|
|
687
|
+
|
|
688
|
+
Examples:
|
|
689
|
+
>>> import pyoframe as pf
|
|
690
|
+
>>> m = pf.Model("max")
|
|
691
|
+
>>> m.X = pf.Variable({"dim1": [1, 2, 3]}, ub=10)
|
|
692
|
+
>>> m.expr_1 = 2 * m.X + 1
|
|
693
|
+
>>> m.expr_2 = pf.sum(m.expr_1)
|
|
694
|
+
>>> m.objective = m.expr_2 - 3
|
|
695
|
+
>>> result = m.solve(log_to_console=False)
|
|
696
|
+
>>> m.expr_1.value
|
|
697
|
+
shape: (3, 2)
|
|
698
|
+
┌──────┬──────────┐
|
|
699
|
+
│ dim1 ┆ solution │
|
|
700
|
+
│ --- ┆ --- │
|
|
701
|
+
│ i64 ┆ f64 │
|
|
702
|
+
╞══════╪══════════╡
|
|
703
|
+
│ 1 ┆ 21.0 │
|
|
704
|
+
│ 2 ┆ 21.0 │
|
|
705
|
+
│ 3 ┆ 21.0 │
|
|
706
|
+
└──────┴──────────┘
|
|
707
|
+
>>> m.expr_2.value
|
|
708
|
+
63.0
|
|
709
|
+
"""
|
|
710
|
+
assert (
|
|
711
|
+
self._model is not None
|
|
712
|
+
), "Expression must be added to the model to use .value"
|
|
713
|
+
if self._model.result is None or self._model.result.solution is None:
|
|
714
|
+
raise ValueError(
|
|
715
|
+
"Can't obtain value of expression since the model has not been solved."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
df = (
|
|
719
|
+
self.data.join(self._model.result.solution.primal, on=VAR_KEY, how="left")
|
|
720
|
+
.with_columns(
|
|
721
|
+
(
|
|
722
|
+
pl.when(pl.col(VAR_KEY) == CONST_TERM)
|
|
723
|
+
.then(1)
|
|
724
|
+
.otherwise(pl.col(SOLUTION_KEY))
|
|
725
|
+
* pl.col(COEF_KEY)
|
|
726
|
+
).alias(SOLUTION_KEY)
|
|
727
|
+
)
|
|
728
|
+
.drop(VAR_KEY)
|
|
729
|
+
.drop(COEF_KEY)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
dims = self.dimensions
|
|
733
|
+
if dims is not None:
|
|
734
|
+
df = df.group_by(dims, maintain_order=True)
|
|
735
|
+
return df.sum()
|
|
736
|
+
|
|
633
737
|
def to_str_table(
|
|
634
738
|
self,
|
|
635
739
|
max_line_len=None,
|
|
636
740
|
max_rows=None,
|
|
637
741
|
include_const_term=True,
|
|
742
|
+
include_const_variable=False,
|
|
638
743
|
var_map=None,
|
|
639
744
|
float_precision=None,
|
|
640
745
|
):
|
|
@@ -650,12 +755,15 @@ class Expression(ModelElement, SupportsMath):
|
|
|
650
755
|
data = data.with_columns(
|
|
651
756
|
pl.concat_str(pl.lit("x"), VAR_KEY).alias("str_var")
|
|
652
757
|
)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
.
|
|
657
|
-
|
|
658
|
-
|
|
758
|
+
if include_const_variable:
|
|
759
|
+
data = data.drop(VAR_KEY).rename({"str_var": VAR_KEY})
|
|
760
|
+
else:
|
|
761
|
+
data = data.with_columns(
|
|
762
|
+
pl.when(pl.col(VAR_KEY) == CONST_TERM)
|
|
763
|
+
.then(pl.lit(""))
|
|
764
|
+
.otherwise("str_var")
|
|
765
|
+
.alias(VAR_KEY)
|
|
766
|
+
).drop("str_var")
|
|
659
767
|
|
|
660
768
|
dimensions = self.dimensions
|
|
661
769
|
|
|
@@ -709,6 +817,7 @@ class Expression(ModelElement, SupportsMath):
|
|
|
709
817
|
max_line_len=None,
|
|
710
818
|
max_rows=None,
|
|
711
819
|
include_const_term=True,
|
|
820
|
+
include_const_variable=False,
|
|
712
821
|
var_map=None,
|
|
713
822
|
include_prefix=True,
|
|
714
823
|
include_header=False,
|
|
@@ -727,6 +836,7 @@ class Expression(ModelElement, SupportsMath):
|
|
|
727
836
|
max_line_len=max_line_len,
|
|
728
837
|
max_rows=max_rows,
|
|
729
838
|
include_const_term=include_const_term,
|
|
839
|
+
include_const_variable=include_const_variable,
|
|
730
840
|
var_map=var_map,
|
|
731
841
|
float_precision=float_precision,
|
|
732
842
|
)
|
|
@@ -749,11 +859,11 @@ class Expression(ModelElement, SupportsMath):
|
|
|
749
859
|
|
|
750
860
|
|
|
751
861
|
@overload
|
|
752
|
-
def sum(over: Union[str, Sequence[str]], expr: SupportsToExpr): ...
|
|
862
|
+
def sum(over: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression": ...
|
|
753
863
|
|
|
754
864
|
|
|
755
865
|
@overload
|
|
756
|
-
def sum(over: SupportsToExpr): ...
|
|
866
|
+
def sum(over: SupportsToExpr) -> "Expression": ...
|
|
757
867
|
|
|
758
868
|
|
|
759
869
|
def sum(
|
|
@@ -786,10 +896,10 @@ def sum_by(by: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression":
|
|
|
786
896
|
return sum(over=remaining_dims, expr=expr)
|
|
787
897
|
|
|
788
898
|
|
|
789
|
-
class Constraint(
|
|
899
|
+
class Constraint(ModelElementWithId):
|
|
790
900
|
"""A linear programming constraint."""
|
|
791
901
|
|
|
792
|
-
def __init__(self, lhs: Expression
|
|
902
|
+
def __init__(self, lhs: Expression, sense: ConstraintSense):
|
|
793
903
|
"""Initialize a constraint.
|
|
794
904
|
|
|
795
905
|
Parameters:
|
|
@@ -798,53 +908,60 @@ class Constraint(Expression, IdCounterMixin):
|
|
|
798
908
|
sense: Sense
|
|
799
909
|
The sense of the constraint.
|
|
800
910
|
"""
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
else:
|
|
804
|
-
data = lhs
|
|
805
|
-
super().__init__(data)
|
|
806
|
-
if isinstance(lhs, Expression):
|
|
807
|
-
self._model = lhs._model
|
|
911
|
+
self.lhs = lhs
|
|
912
|
+
self._model = lhs._model
|
|
808
913
|
self.sense = sense
|
|
914
|
+
self.to_relax: Optional[FuncArgs] = None
|
|
809
915
|
|
|
810
|
-
dims = self.dimensions
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
)
|
|
814
|
-
|
|
916
|
+
dims = self.lhs.dimensions
|
|
917
|
+
data = pl.DataFrame() if dims is None else self.lhs.data.select(dims).unique()
|
|
918
|
+
|
|
919
|
+
super().__init__(data)
|
|
920
|
+
|
|
921
|
+
def on_add_to_model(self, model: "Model", name: str):
|
|
922
|
+
super().on_add_to_model(model, name)
|
|
923
|
+
if self.to_relax is not None:
|
|
924
|
+
self.relax(*self.to_relax.args, **self.to_relax.kwargs)
|
|
925
|
+
|
|
926
|
+
@property
|
|
927
|
+
@unwrap_single_values
|
|
928
|
+
def slack(self):
|
|
929
|
+
"""
|
|
930
|
+
The slack of the constraint.
|
|
931
|
+
Will raise an error if the model has not already been solved.
|
|
932
|
+
The first call to this property will load the slack values from the solver (lazy loading).
|
|
933
|
+
"""
|
|
934
|
+
if SLACK_COL not in self.data.columns:
|
|
935
|
+
assert (
|
|
936
|
+
self._model is not None
|
|
937
|
+
), "Constraint must be added to a model to get the slack."
|
|
938
|
+
if self._model.solver is None:
|
|
939
|
+
raise ValueError("The model has not been solved yet.")
|
|
940
|
+
self._model.solver.load_slack()
|
|
941
|
+
return self.data.select(self.dimensions_unsafe + [SLACK_COL])
|
|
942
|
+
|
|
943
|
+
@slack.setter
|
|
944
|
+
def slack(self, value):
|
|
945
|
+
self._extend_dataframe_by_id(value)
|
|
815
946
|
|
|
816
947
|
@property
|
|
948
|
+
@unwrap_single_values
|
|
817
949
|
def dual(self) -> Union[pl.DataFrame, float]:
|
|
818
|
-
if DUAL_KEY not in self.
|
|
950
|
+
if DUAL_KEY not in self.data.columns:
|
|
819
951
|
raise ValueError(f"No dual values founds for constraint '{self.name}'")
|
|
820
|
-
|
|
821
|
-
if result.shape == (1, 1):
|
|
822
|
-
return result.item()
|
|
823
|
-
return result
|
|
952
|
+
return self.data.select(self.dimensions_unsafe + [DUAL_KEY])
|
|
824
953
|
|
|
825
954
|
@dual.setter
|
|
826
955
|
def dual(self, value):
|
|
827
|
-
|
|
828
|
-
df = self.data_per_constraint
|
|
829
|
-
if DUAL_KEY in df.columns:
|
|
830
|
-
df = df.drop(DUAL_KEY)
|
|
831
|
-
self.data_per_constraint = df.join(
|
|
832
|
-
value, on=CONSTRAINT_KEY, how="left", validate="1:1"
|
|
833
|
-
)
|
|
956
|
+
self._extend_dataframe_by_id(value)
|
|
834
957
|
|
|
835
958
|
@classmethod
|
|
836
959
|
def get_id_column_name(cls):
|
|
837
960
|
return CONSTRAINT_KEY
|
|
838
961
|
|
|
839
|
-
@property
|
|
840
|
-
def ids(self) -> pl.DataFrame:
|
|
841
|
-
return self.data_per_constraint.select(
|
|
842
|
-
self.dimensions_unsafe + [CONSTRAINT_KEY]
|
|
843
|
-
)
|
|
844
|
-
|
|
845
962
|
def to_str_create_prefix(self, data, const_map=None):
|
|
846
963
|
if const_map is None:
|
|
847
|
-
return
|
|
964
|
+
return self.lhs.to_str_create_prefix(data)
|
|
848
965
|
|
|
849
966
|
data_map = const_map.apply(self.ids, to_col=None)
|
|
850
967
|
|
|
@@ -860,6 +977,107 @@ class Constraint(Expression, IdCounterMixin):
|
|
|
860
977
|
pl.concat_str(CONSTRAINT_KEY, pl.lit(": "), "expr").alias("expr")
|
|
861
978
|
).drop(CONSTRAINT_KEY)
|
|
862
979
|
|
|
980
|
+
def filter(self, *args, **kwargs) -> pl.DataFrame:
|
|
981
|
+
return self.lhs.data.filter(*args, **kwargs)
|
|
982
|
+
|
|
983
|
+
def relax(
|
|
984
|
+
self, cost: SupportsToExpr, max: Optional[SupportsToExpr] = None
|
|
985
|
+
) -> Constraint:
|
|
986
|
+
"""
|
|
987
|
+
Relaxes the constraint by adding a variable to the constraint that can be non-zero at a cost.
|
|
988
|
+
|
|
989
|
+
Parameters:
|
|
990
|
+
cost: SupportsToExpr
|
|
991
|
+
The cost of relaxing the constraint. Costs should be positives as they will automatically
|
|
992
|
+
become negative for maximization problems.
|
|
993
|
+
max: SupportsToExpr, default None
|
|
994
|
+
The maximum value of the relaxation variable.
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
The same constraint
|
|
998
|
+
|
|
999
|
+
Examples:
|
|
1000
|
+
>>> import pyoframe as pf
|
|
1001
|
+
>>> m = pf.Model("max")
|
|
1002
|
+
>>> 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]})
|
|
1003
|
+
>>> m.hours_spent = pf.Variable(homework_due_tomorrow[["project"]], lb=0)
|
|
1004
|
+
>>> m.must_finish_project = m.hours_spent >= homework_due_tomorrow[["project", "hours_to_finish"]]
|
|
1005
|
+
>>> m.only_one_day = sum("project", m.hours_spent) <= 24
|
|
1006
|
+
>>> m.solve(log_to_console=False)
|
|
1007
|
+
Status: warning
|
|
1008
|
+
Termination condition: infeasible
|
|
1009
|
+
<BLANKLINE>
|
|
1010
|
+
|
|
1011
|
+
>>> _ = m.must_finish_project.relax(homework_due_tomorrow[["project", "cost_per_hour_underdelivered"]], max=homework_due_tomorrow[["project", "max_underdelivered"]])
|
|
1012
|
+
>>> result = m.solve(log_to_console=False)
|
|
1013
|
+
>>> m.hours_spent.solution
|
|
1014
|
+
shape: (3, 2)
|
|
1015
|
+
┌─────────┬──────────┐
|
|
1016
|
+
│ project ┆ solution │
|
|
1017
|
+
│ --- ┆ --- │
|
|
1018
|
+
│ str ┆ f64 │
|
|
1019
|
+
╞═════════╪══════════╡
|
|
1020
|
+
│ A ┆ 8.0 │
|
|
1021
|
+
│ B ┆ 7.0 │
|
|
1022
|
+
│ C ┆ 9.0 │
|
|
1023
|
+
└─────────┴──────────┘
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
>>> # It can also be done all in one go!
|
|
1027
|
+
>>> m = pf.Model("max")
|
|
1028
|
+
>>> 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]})
|
|
1029
|
+
>>> m.hours_spent = pf.Variable(homework_due_tomorrow[["project"]], lb=0)
|
|
1030
|
+
>>> m.must_finish_project = (m.hours_spent >= homework_due_tomorrow[["project", "hours_to_finish"]]).relax(5)
|
|
1031
|
+
>>> m.only_one_day = (sum("project", m.hours_spent) <= 24).relax(1)
|
|
1032
|
+
>>> _ = m.solve(log_to_console=False)
|
|
1033
|
+
>>> m.objective.value
|
|
1034
|
+
-3.0
|
|
1035
|
+
>>> m.hours_spent.solution
|
|
1036
|
+
shape: (3, 2)
|
|
1037
|
+
┌─────────┬──────────┐
|
|
1038
|
+
│ project ┆ solution │
|
|
1039
|
+
│ --- ┆ --- │
|
|
1040
|
+
│ str ┆ f64 │
|
|
1041
|
+
╞═════════╪══════════╡
|
|
1042
|
+
│ A ┆ 9.0 │
|
|
1043
|
+
│ B ┆ 9.0 │
|
|
1044
|
+
│ C ┆ 9.0 │
|
|
1045
|
+
└─────────┴──────────┘
|
|
1046
|
+
"""
|
|
1047
|
+
m = self._model
|
|
1048
|
+
if m is None or self.name is None:
|
|
1049
|
+
self.to_relax = FuncArgs(args=[cost, max])
|
|
1050
|
+
return self
|
|
1051
|
+
|
|
1052
|
+
var_name = f"{self.name}_relaxation"
|
|
1053
|
+
assert not hasattr(
|
|
1054
|
+
m, var_name
|
|
1055
|
+
), "Conflicting names, relaxation variable already exists on the model."
|
|
1056
|
+
var = Variable(self, lb=0, ub=max)
|
|
1057
|
+
|
|
1058
|
+
if self.sense == ConstraintSense.LE:
|
|
1059
|
+
self.lhs -= var
|
|
1060
|
+
elif self.sense == ConstraintSense.GE:
|
|
1061
|
+
self.lhs += var
|
|
1062
|
+
else: # pragma: no cover
|
|
1063
|
+
# TODO
|
|
1064
|
+
raise NotImplementedError(
|
|
1065
|
+
"Relaxation for equalities has not yet been implemented. Submit a pull request!"
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
setattr(m, var_name, var)
|
|
1069
|
+
penalty = var * cost
|
|
1070
|
+
if self.dimensions:
|
|
1071
|
+
penalty = sum(self.dimensions, penalty)
|
|
1072
|
+
if m.sense == ObjSense.MAX:
|
|
1073
|
+
penalty *= -1
|
|
1074
|
+
if m.objective is None:
|
|
1075
|
+
m.objective = penalty
|
|
1076
|
+
else:
|
|
1077
|
+
m.objective += penalty
|
|
1078
|
+
|
|
1079
|
+
return self
|
|
1080
|
+
|
|
863
1081
|
def to_str(
|
|
864
1082
|
self,
|
|
865
1083
|
max_line_len=None,
|
|
@@ -867,16 +1085,16 @@ class Constraint(Expression, IdCounterMixin):
|
|
|
867
1085
|
var_map=None,
|
|
868
1086
|
float_precision=None,
|
|
869
1087
|
const_map=None,
|
|
870
|
-
):
|
|
1088
|
+
) -> str:
|
|
871
1089
|
dims = self.dimensions
|
|
872
|
-
str_table = self.to_str_table(
|
|
1090
|
+
str_table = self.lhs.to_str_table(
|
|
873
1091
|
max_line_len=max_line_len,
|
|
874
1092
|
max_rows=max_rows,
|
|
875
1093
|
include_const_term=False,
|
|
876
1094
|
var_map=var_map,
|
|
877
1095
|
)
|
|
878
1096
|
str_table = self.to_str_create_prefix(str_table, const_map=const_map)
|
|
879
|
-
rhs = self.constant_terms.with_columns(pl.col(COEF_KEY) * -1)
|
|
1097
|
+
rhs = self.lhs.constant_terms.with_columns(pl.col(COEF_KEY) * -1)
|
|
880
1098
|
rhs = cast_coef_to_string(rhs, drop_ones=False, float_precision=float_precision)
|
|
881
1099
|
# Remove leading +
|
|
882
1100
|
rhs = rhs.with_columns(pl.col(COEF_KEY).str.strip_chars(characters=" +"))
|
|
@@ -899,13 +1117,225 @@ class Constraint(Expression, IdCounterMixin):
|
|
|
899
1117
|
sense=f"'{self.sense.value}'",
|
|
900
1118
|
size=len(self),
|
|
901
1119
|
dimensions=self.shape,
|
|
902
|
-
terms=len(self.data),
|
|
1120
|
+
terms=len(self.lhs.data),
|
|
903
1121
|
)
|
|
904
1122
|
+ "\n"
|
|
905
1123
|
+ self.to_str(max_line_len=80, max_rows=15)
|
|
906
1124
|
)
|
|
907
1125
|
|
|
1126
|
+
|
|
1127
|
+
class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
|
|
1128
|
+
"""
|
|
1129
|
+
Represents one or many decision variable in an optimization model.
|
|
1130
|
+
|
|
1131
|
+
Parameters:
|
|
1132
|
+
*indexing_sets: SetTypes (typically a DataFrame or Set)
|
|
1133
|
+
If no indexing_sets are provided, a single variable with no dimensions is created.
|
|
1134
|
+
Otherwise, a variable is created for each element in the Cartesian product of the indexing_sets (see Set for details on behaviour).
|
|
1135
|
+
lb: float
|
|
1136
|
+
The lower bound for all variables.
|
|
1137
|
+
ub: float
|
|
1138
|
+
The upper bound for all variables.
|
|
1139
|
+
vtype: VType | VTypeValue
|
|
1140
|
+
The type of the variable. Can be either a VType enum or a string. Default is VType.CONTINUOUS.
|
|
1141
|
+
equals: SupportsToExpr
|
|
1142
|
+
When specified, a variable is created and a constraint is added to make the variable equal to the provided expression.
|
|
1143
|
+
|
|
1144
|
+
Examples:
|
|
1145
|
+
>>> import pandas as pd
|
|
1146
|
+
>>> from pyoframe import Variable
|
|
1147
|
+
>>> df = pd.DataFrame({"dim1": [1, 1, 2, 2, 3, 3], "dim2": ["a", "b", "a", "b", "a", "b"]})
|
|
1148
|
+
>>> Variable(df)
|
|
1149
|
+
<Variable lb=-inf ub=inf size=6 dimensions={'dim1': 3, 'dim2': 2}>
|
|
1150
|
+
[1,a]: x1
|
|
1151
|
+
[1,b]: x2
|
|
1152
|
+
[2,a]: x3
|
|
1153
|
+
[2,b]: x4
|
|
1154
|
+
[3,a]: x5
|
|
1155
|
+
[3,b]: x6
|
|
1156
|
+
>>> Variable(df[["dim1"]])
|
|
1157
|
+
Traceback (most recent call last):
|
|
1158
|
+
...
|
|
1159
|
+
ValueError: Duplicate rows found in input data.
|
|
1160
|
+
>>> Variable(df[["dim1"]].drop_duplicates())
|
|
1161
|
+
<Variable lb=-inf ub=inf size=3 dimensions={'dim1': 3}>
|
|
1162
|
+
[1]: x7
|
|
1163
|
+
[2]: x8
|
|
1164
|
+
[3]: x9
|
|
1165
|
+
"""
|
|
1166
|
+
|
|
1167
|
+
# TODO: Breaking change, remove support for Iterable[AcceptableSets]
|
|
1168
|
+
def __init__(
|
|
1169
|
+
self,
|
|
1170
|
+
*indexing_sets: SetTypes | Iterable[SetTypes],
|
|
1171
|
+
lb: float | int | SupportsToExpr | None = None,
|
|
1172
|
+
ub: float | int | SupportsToExpr | None = None,
|
|
1173
|
+
vtype: VType | VTypeValue = VType.CONTINUOUS,
|
|
1174
|
+
equals: Optional[SupportsMath] = None,
|
|
1175
|
+
):
|
|
1176
|
+
if lb is None:
|
|
1177
|
+
lb = float("-inf")
|
|
1178
|
+
if ub is None:
|
|
1179
|
+
ub = float("inf")
|
|
1180
|
+
if equals is not None:
|
|
1181
|
+
assert (
|
|
1182
|
+
len(indexing_sets) == 0
|
|
1183
|
+
), "Cannot specify both 'equals' and 'indexing_sets'"
|
|
1184
|
+
indexing_sets = (equals,)
|
|
1185
|
+
|
|
1186
|
+
data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
|
|
1187
|
+
super().__init__(data)
|
|
1188
|
+
|
|
1189
|
+
self.vtype: VType = VType(vtype)
|
|
1190
|
+
self._equals = equals
|
|
1191
|
+
|
|
1192
|
+
# Tightening the bounds is not strictly necessary, but it adds clarity
|
|
1193
|
+
if self.vtype == VType.BINARY:
|
|
1194
|
+
lb, ub = 0, 1
|
|
1195
|
+
|
|
1196
|
+
if isinstance(lb, (float, int)):
|
|
1197
|
+
self.lb, self.lb_constraint = lb, None
|
|
1198
|
+
else:
|
|
1199
|
+
self.lb, self.lb_constraint = float("-inf"), lb <= self
|
|
1200
|
+
|
|
1201
|
+
if isinstance(ub, (float, int)):
|
|
1202
|
+
self.ub, self.ub_constraint = ub, None
|
|
1203
|
+
else:
|
|
1204
|
+
self.ub, self.ub_constraint = float("inf"), self <= ub
|
|
1205
|
+
|
|
1206
|
+
def on_add_to_model(self, model: "Model", name: str):
|
|
1207
|
+
super().on_add_to_model(model, name)
|
|
1208
|
+
if self.lb_constraint is not None:
|
|
1209
|
+
setattr(model, f"{name}_lb", self.lb_constraint)
|
|
1210
|
+
if self.ub_constraint is not None:
|
|
1211
|
+
setattr(model, f"{name}_ub", self.ub_constraint)
|
|
1212
|
+
if self._equals is not None:
|
|
1213
|
+
setattr(model, f"{name}_equals", self == self._equals)
|
|
1214
|
+
|
|
1215
|
+
@classmethod
|
|
1216
|
+
def get_id_column_name(cls):
|
|
1217
|
+
return VAR_KEY
|
|
1218
|
+
|
|
1219
|
+
@property
|
|
1220
|
+
@unwrap_single_values
|
|
1221
|
+
def solution(self):
|
|
1222
|
+
if SOLUTION_KEY not in self.data.columns:
|
|
1223
|
+
raise ValueError(f"No solution solution found for Variable '{self.name}'.")
|
|
1224
|
+
|
|
1225
|
+
return self.data.select(self.dimensions_unsafe + [SOLUTION_KEY])
|
|
1226
|
+
|
|
1227
|
+
@property
|
|
1228
|
+
@unwrap_single_values
|
|
1229
|
+
def RC(self):
|
|
1230
|
+
"""
|
|
1231
|
+
The reduced cost of the variable.
|
|
1232
|
+
Will raise an error if the model has not already been solved.
|
|
1233
|
+
The first call to this property will load the reduced costs from the solver (lazy loading).
|
|
1234
|
+
"""
|
|
1235
|
+
if RC_COL not in self.data.columns:
|
|
1236
|
+
assert (
|
|
1237
|
+
self._model is not None
|
|
1238
|
+
), "Variable must be added to a model to get the reduced cost."
|
|
1239
|
+
if self._model.solver is None:
|
|
1240
|
+
raise ValueError("The model has not been solved yet.")
|
|
1241
|
+
self._model.solver.load_rc()
|
|
1242
|
+
return self.data.select(self.dimensions_unsafe + [RC_COL])
|
|
1243
|
+
|
|
1244
|
+
@RC.setter
|
|
1245
|
+
def RC(self, value):
|
|
1246
|
+
self._extend_dataframe_by_id(value)
|
|
1247
|
+
|
|
1248
|
+
@solution.setter
|
|
1249
|
+
def solution(self, value):
|
|
1250
|
+
self._extend_dataframe_by_id(value)
|
|
1251
|
+
|
|
1252
|
+
def __repr__(self):
|
|
1253
|
+
return (
|
|
1254
|
+
get_obj_repr(
|
|
1255
|
+
self, ("name", "lb", "ub"), size=self.data.height, dimensions=self.shape
|
|
1256
|
+
)
|
|
1257
|
+
+ "\n"
|
|
1258
|
+
+ self.to_expr().to_str(max_line_len=80, max_rows=10)
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
def to_expr(self) -> Expression:
|
|
1262
|
+
return self._new(self.data.drop(SOLUTION_KEY))
|
|
1263
|
+
|
|
908
1264
|
def _new(self, data: pl.DataFrame):
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1265
|
+
e = Expression(data.with_columns(pl.lit(1.0).alias(COEF_KEY)))
|
|
1266
|
+
e._model = self._model
|
|
1267
|
+
# We propogate the unmatched strategy intentionally. Without this a .keep_unmatched() on a variable would always be lost.
|
|
1268
|
+
e.unmatched_strategy = self.unmatched_strategy
|
|
1269
|
+
e.allowed_new_dims = self.allowed_new_dims
|
|
1270
|
+
return e
|
|
1271
|
+
|
|
1272
|
+
def next(self, dim: str, wrap_around: bool = False) -> Expression:
|
|
1273
|
+
"""
|
|
1274
|
+
Creates an expression where the variable at each index is the next variable in the specified dimension.
|
|
1275
|
+
|
|
1276
|
+
Parameters:
|
|
1277
|
+
dim:
|
|
1278
|
+
The dimension over which to shift the variable.
|
|
1279
|
+
wrap_around:
|
|
1280
|
+
If True, the last index in the dimension is connected to the first index.
|
|
1281
|
+
|
|
1282
|
+
Examples:
|
|
1283
|
+
>>> import pandas as pd
|
|
1284
|
+
>>> from pyoframe import Variable, Model
|
|
1285
|
+
>>> time_dim = pd.DataFrame({"time": ["00:00", "06:00", "12:00", "18:00"]})
|
|
1286
|
+
>>> space_dim = pd.DataFrame({"city": ["Toronto", "Berlin"]})
|
|
1287
|
+
>>> m = Model("min")
|
|
1288
|
+
>>> m.bat_charge = Variable(time_dim, space_dim)
|
|
1289
|
+
>>> m.bat_flow = Variable(time_dim, space_dim)
|
|
1290
|
+
>>> # Fails because the dimensions are not the same
|
|
1291
|
+
>>> m.bat_charge + m.bat_flow == m.bat_charge.next("time")
|
|
1292
|
+
Traceback (most recent call last):
|
|
1293
|
+
...
|
|
1294
|
+
pyoframe.constants.PyoframeError: Failed to add expressions:
|
|
1295
|
+
<Expression size=8 dimensions={'time': 4, 'city': 2} terms=16> + <Expression size=6 dimensions={'city': 2, 'time': 3} terms=6>
|
|
1296
|
+
Due to error:
|
|
1297
|
+
Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()
|
|
1298
|
+
shape: (2, 4)
|
|
1299
|
+
┌───────┬─────────┬────────────┬────────────┐
|
|
1300
|
+
│ time ┆ city ┆ time_right ┆ city_right │
|
|
1301
|
+
│ --- ┆ --- ┆ --- ┆ --- │
|
|
1302
|
+
│ str ┆ str ┆ str ┆ str │
|
|
1303
|
+
╞═══════╪═════════╪════════════╪════════════╡
|
|
1304
|
+
│ 18:00 ┆ Toronto ┆ null ┆ null │
|
|
1305
|
+
│ 18:00 ┆ Berlin ┆ null ┆ null │
|
|
1306
|
+
└───────┴─────────┴────────────┴────────────┘
|
|
1307
|
+
|
|
1308
|
+
>>> (m.bat_charge + m.bat_flow).drop_unmatched() == m.bat_charge.next("time")
|
|
1309
|
+
<Constraint sense='=' size=6 dimensions={'time': 3, 'city': 2} terms=18>
|
|
1310
|
+
[00:00,Berlin]: bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin] = 0
|
|
1311
|
+
[00:00,Toronto]: bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto] - bat_charge[06:00,Toronto] = 0
|
|
1312
|
+
[06:00,Berlin]: bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin] = 0
|
|
1313
|
+
[06:00,Toronto]: bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto] - bat_charge[12:00,Toronto] = 0
|
|
1314
|
+
[12:00,Berlin]: bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin] = 0
|
|
1315
|
+
[12:00,Toronto]: bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto] - bat_charge[18:00,Toronto] = 0
|
|
1316
|
+
|
|
1317
|
+
>>> (m.bat_charge + m.bat_flow) == m.bat_charge.next("time", wrap_around=True)
|
|
1318
|
+
<Constraint sense='=' size=8 dimensions={'time': 4, 'city': 2} terms=24>
|
|
1319
|
+
[00:00,Berlin]: bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin] = 0
|
|
1320
|
+
[00:00,Toronto]: bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto] - bat_charge[06:00,Toronto] = 0
|
|
1321
|
+
[06:00,Berlin]: bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin] = 0
|
|
1322
|
+
[06:00,Toronto]: bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto] - bat_charge[12:00,Toronto] = 0
|
|
1323
|
+
[12:00,Berlin]: bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin] = 0
|
|
1324
|
+
[12:00,Toronto]: bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto] - bat_charge[18:00,Toronto] = 0
|
|
1325
|
+
[18:00,Berlin]: bat_charge[18:00,Berlin] + bat_flow[18:00,Berlin] - bat_charge[00:00,Berlin] = 0
|
|
1326
|
+
[18:00,Toronto]: bat_charge[18:00,Toronto] + bat_flow[18:00,Toronto] - bat_charge[00:00,Toronto] = 0
|
|
1327
|
+
"""
|
|
1328
|
+
|
|
1329
|
+
wrapped = self.data.select(dim).unique(maintain_order=True).sort(by=dim)
|
|
1330
|
+
wrapped = wrapped.with_columns(pl.col(dim).shift(-1).alias("__next"))
|
|
1331
|
+
if wrap_around:
|
|
1332
|
+
wrapped = wrapped.with_columns(pl.col("__next").fill_null(pl.first(dim)))
|
|
1333
|
+
else:
|
|
1334
|
+
wrapped = wrapped.drop_nulls(dim)
|
|
1335
|
+
|
|
1336
|
+
expr = self.to_expr()
|
|
1337
|
+
data = expr.data.rename({dim: "__prev"})
|
|
1338
|
+
data = data.join(
|
|
1339
|
+
wrapped, left_on="__prev", right_on="__next", how="inner"
|
|
1340
|
+
).drop(["__prev", "__next"])
|
|
1341
|
+
return expr._new(data)
|