pyoframe 0.0.11__py3-none-any.whl → 0.1.0__py3-none-any.whl

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