pyoframe 1.0.1__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyoframe/__init__.py CHANGED
@@ -11,6 +11,7 @@ from pyoframe._core import Constraint, Expression, Set, Variable, sum, sum_by
11
11
  from pyoframe._model import Model
12
12
  from pyoframe._monkey_patch import patch_dataframe_libraries
13
13
  from pyoframe._objective import Objective
14
+ from pyoframe._param import Param
14
15
 
15
16
  try:
16
17
  from pyoframe._version import __version__, __version_tuple__ # noqa: F401
@@ -25,6 +26,7 @@ __all__ = [
25
26
  "Expression",
26
27
  "Constraint",
27
28
  "Objective",
29
+ "Param",
28
30
  "Set",
29
31
  "Config",
30
32
  "sum",
pyoframe/_arithmetic.py CHANGED
@@ -42,7 +42,7 @@ def multiply(self: Expression, other: Expression) -> Expression:
42
42
  >>> m.x3 = pf.Variable()
43
43
  >>> result = 5 * m.x1 * m.x2
44
44
  >>> result
45
- <Expression terms=1 type=quadratic>
45
+ <Expression (quadratic) terms=1>
46
46
  5 x2 * x1
47
47
  >>> result * m.x3
48
48
  Traceback (most recent call last):
@@ -112,7 +112,7 @@ def _quadratic_multiplication(self: Expression, other: Expression) -> Expression
112
112
  >>> expr1 = df * m.x1
113
113
  >>> expr2 = df * m.x2 * 2 + 4
114
114
  >>> expr1 * expr2
115
- <Expression height=3 terms=6 type=quadratic>
115
+ <Expression (quadratic) height=3 terms=6>
116
116
  ┌─────┬───────────────────┐
117
117
  │ dim ┆ expression │
118
118
  │ (3) ┆ │
@@ -122,7 +122,7 @@ def _quadratic_multiplication(self: Expression, other: Expression) -> Expression
122
122
  │ 3 ┆ 12 x1 +18 x2 * x1 │
123
123
  └─────┴───────────────────┘
124
124
  >>> (expr1 * expr2) - df * m.x1 * df * m.x2 * 2
125
- <Expression height=3 terms=3 type=linear>
125
+ <Expression (linear) height=3 terms=3>
126
126
  ┌─────┬────────────┐
127
127
  │ dim ┆ expression │
128
128
  │ (3) ┆ │
@@ -431,14 +431,15 @@ def _broadcast(
431
431
  maintain_order="left" if Config.maintain_order else None,
432
432
  )
433
433
  if result.get_column(missing_dims[0]).null_count() > 0:
434
- target_labels = target.data.select(target._dimensions_unsafe).unique(
434
+ self_labels = self.data.select(self._dimensions_unsafe).unique(
435
435
  maintain_order=Config.maintain_order
436
436
  )
437
437
  _raise_extras_error(
438
438
  self,
439
439
  target,
440
- target_labels.join(self.data, how="anti", on=common_dims),
440
+ self_labels.join(target.data, how="anti", on=common_dims),
441
441
  swapped,
442
+ extras_on_right=False,
442
443
  )
443
444
  res = self._new(result, self.name)
444
445
  res._copy_flags(self)
pyoframe/_constants.py CHANGED
@@ -141,19 +141,18 @@ class _Config:
141
141
  Only consider enabling after you have thoroughly tested your code.
142
142
 
143
143
  Examples:
144
- >>> import polars as pl
145
- >>> population = pl.DataFrame(
144
+ >>> population = pf.Param(
146
145
  ... {
147
146
  ... "city": ["Toronto", "Vancouver", "Montreal"],
148
147
  ... "pop": [2_731_571, 631_486, 1_704_694],
149
148
  ... }
150
- ... ).to_expr()
151
- >>> population_influx = pl.DataFrame(
149
+ ... )
150
+ >>> population_influx = pf.Param(
152
151
  ... {
153
152
  ... "city": ["Toronto", "Vancouver", "Montreal"],
154
153
  ... "influx": [100_000, 50_000, None],
155
154
  ... }
156
- ... ).to_expr()
155
+ ... )
157
156
 
158
157
  Normally, an error warns users that the two expressions have conflicting labels:
159
158
  >>> population + population_influx
@@ -174,7 +173,7 @@ class _Config:
174
173
  But if `Config.disable_extras_checks = True`, the error is suppressed and the sum is considered to be `population.keep_extras() + population_influx.keep_extras()`:
175
174
  >>> pf.Config.disable_extras_checks = True
176
175
  >>> population + population_influx
177
- <Expression height=3 terms=3 type=constant>
176
+ <Expression (parameter) height=3 terms=3>
178
177
  ┌───────────┬────────────┐
179
178
  │ city ┆ expression │
180
179
  │ (3) ┆ │
@@ -228,11 +227,11 @@ class _Config:
228
227
  >>> m.X = pf.Variable()
229
228
  >>> expr = 100.752038759 * m.X
230
229
  >>> expr
231
- <Expression terms=1 type=linear>
230
+ <Expression (linear) terms=1>
232
231
  100.752 X
233
232
  >>> pf.Config.float_to_str_precision = None
234
233
  >>> expr
235
- <Expression terms=1 type=linear>
234
+ <Expression (linear) terms=1>
236
235
  100.752038759 X
237
236
  """
238
237
  return self._settings.float_to_str_precision
@@ -280,7 +279,7 @@ class _Config:
280
279
  >>> m = pf.Model()
281
280
  >>> m.X = pf.Variable(pf.Set(x=range(100)), pf.Set(y=range(100)))
282
281
  >>> m.X.sum("y")
283
- <Expression height=100 terms=10000 type=linear>
282
+ <Expression (linear) height=100 terms=10000>
284
283
  ┌───────┬───────────────────────────────┐
285
284
  │ x ┆ expression │
286
285
  │ (100) ┆ │
@@ -298,7 +297,7 @@ class _Config:
298
297
  │ 99 ┆ X[99,0] + X[99,1] + X[99,2] … │
299
298
  └───────┴───────────────────────────────┘
300
299
  >>> m.X.sum()
301
- <Expression terms=10000 type=linear>
300
+ <Expression (linear) terms=10000>
302
301
  X[0,0] + X[0,1] + X[0,2] …
303
302
  """
304
303
  return self._settings.print_max_terms
pyoframe/_core.py CHANGED
@@ -79,6 +79,8 @@ class BaseOperableBlock(BaseBlock):
79
79
  See Also:
80
80
  [`drop_extras`][pyoframe.Expression.drop_extras].
81
81
  """
82
+ if self._extras_strategy == ExtrasStrategy.KEEP:
83
+ return self
82
84
  new = self._new(self.data, name=f"{self.name}.keep_extras()")
83
85
  new._copy_flags(self)
84
86
  new._extras_strategy = ExtrasStrategy.KEEP
@@ -92,6 +94,8 @@ class BaseOperableBlock(BaseBlock):
92
94
  See Also:
93
95
  [`keep_extras`][pyoframe.Expression.keep_extras].
94
96
  """
97
+ if self._extras_strategy == ExtrasStrategy.DROP:
98
+ return self
95
99
  new = self._new(self.data, name=f"{self.name}.drop_extras()")
96
100
  new._copy_flags(self)
97
101
  new._extras_strategy = ExtrasStrategy.DROP
@@ -123,6 +127,8 @@ class BaseOperableBlock(BaseBlock):
123
127
  See Also:
124
128
  [`keep_extras`][pyoframe.Expression.keep_extras] and [`drop_extras`][pyoframe.Expression.drop_extras].
125
129
  """
130
+ if self._extras_strategy == ExtrasStrategy.UNSET:
131
+ return self
126
132
  new = self._new(self.data, name=f"{self.name}.raise_extras()")
127
133
  new._copy_flags(self)
128
134
  new._extras_strategy = ExtrasStrategy.UNSET
@@ -169,7 +175,7 @@ class BaseOperableBlock(BaseBlock):
169
175
  └───────┴─────────┴──────────────────┘
170
176
 
171
177
  >>> m.v.rename({"city": "location"})
172
- <Expression height=12 terms=12 type=linear>
178
+ <Expression (linear) height=12 terms=12>
173
179
  ┌───────┬──────────┬──────────────────┐
174
180
  │ hour ┆ location ┆ expression │
175
181
  │ (4) ┆ (3) ┆ │
@@ -228,7 +234,7 @@ class BaseOperableBlock(BaseBlock):
228
234
  ... ]
229
235
  ... )
230
236
  >>> m.v.pick(hour="06:00")
231
- <Expression height=3 terms=3 type=linear>
237
+ <Expression (linear) height=3 terms=3>
232
238
  ┌─────────┬──────────────────┐
233
239
  │ city ┆ expression │
234
240
  │ (3) ┆ │
@@ -238,7 +244,7 @@ class BaseOperableBlock(BaseBlock):
238
244
  │ Paris ┆ v[06:00,Paris] │
239
245
  └─────────┴──────────────────┘
240
246
  >>> m.v.pick(hour="06:00", city="Toronto")
241
- <Expression terms=1 type=linear>
247
+ <Expression (linear) terms=1>
242
248
  v[06:00,Toronto]
243
249
 
244
250
  See Also:
@@ -290,7 +296,7 @@ class BaseOperableBlock(BaseBlock):
290
296
  >>> m = pf.Model()
291
297
  >>> m.v = pf.Variable()
292
298
  >>> m.v**2
293
- <Expression terms=1 type=quadratic>
299
+ <Expression (quadratic) terms=1>
294
300
  v * v
295
301
  >>> m.v**3
296
302
  Traceback (most recent call last):
@@ -320,7 +326,7 @@ class BaseOperableBlock(BaseBlock):
320
326
  >>> df = pl.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]})
321
327
  >>> m.v = pf.Variable(df["dim1"])
322
328
  >>> m.v - df
323
- <Expression height=3 terms=6 type=linear>
329
+ <Expression (linear) height=3 terms=6>
324
330
  ┌──────┬────────────┐
325
331
  │ dim1 ┆ expression │
326
332
  │ (3) ┆ │
@@ -331,7 +337,7 @@ class BaseOperableBlock(BaseBlock):
331
337
  └──────┴────────────┘
332
338
  """
333
339
  if not isinstance(other, (int, float)):
334
- other = other.to_expr()
340
+ other = other.to_expr() # TODO don't rely on monkey patch
335
341
  return self.to_expr() + (-other)
336
342
 
337
343
  def __rmul__(self, other):
@@ -348,7 +354,7 @@ class BaseOperableBlock(BaseBlock):
348
354
  >>> m = pf.Model()
349
355
  >>> m.v = Variable({"dim1": [1, 2, 3]})
350
356
  >>> m.v / 2
351
- <Expression height=3 terms=3 type=linear>
357
+ <Expression (linear) height=3 terms=3>
352
358
  ┌──────┬────────────┐
353
359
  │ dim1 ┆ expression │
354
360
  │ (3) ┆ │
@@ -360,6 +366,13 @@ class BaseOperableBlock(BaseBlock):
360
366
  """
361
367
  return self.to_expr() * (1 / other)
362
368
 
369
+ def __rtruediv__(self, other):
370
+ # This just improves error messages when trying to divide by a Set or Variable.
371
+ # When dividing by an Expression, see the Expression.__rtruediv__ method.
372
+ raise PyoframeError(
373
+ f"Cannot divide by '{self.name}' because it is not a number or parameter."
374
+ )
375
+
363
376
  def __rsub__(self, other):
364
377
  """Supports right subtraction.
365
378
 
@@ -367,7 +380,7 @@ class BaseOperableBlock(BaseBlock):
367
380
  >>> m = pf.Model()
368
381
  >>> m.v = Variable({"dim1": [1, 2, 3]})
369
382
  >>> 1 - m.v
370
- <Expression height=3 terms=6 type=linear>
383
+ <Expression (linear) height=3 terms=6>
371
384
  ┌──────┬────────────┐
372
385
  │ dim1 ┆ expression │
373
386
  │ (3) ┆ │
@@ -379,6 +392,20 @@ class BaseOperableBlock(BaseBlock):
379
392
  """
380
393
  return other + (-self.to_expr())
381
394
 
395
+ def __or__(self, other: Operable) -> Expression:
396
+ if isinstance(other, (int, float)):
397
+ raise PyoframeError(
398
+ "Cannot use '|' operator with scalars. Did you mean to use '+' instead?"
399
+ )
400
+ return self.to_expr().keep_extras() + other.to_expr().keep_extras() # type: ignore
401
+
402
+ def __ror__(self, other: Operable) -> Expression:
403
+ if isinstance(other, (int, float)):
404
+ raise PyoframeError(
405
+ "Cannot use '|' operator with scalars. Did you mean to use '+' instead?"
406
+ )
407
+ return self.to_expr().keep_extras() + other.to_expr().keep_extras() # type: ignore
408
+
382
409
  def __le__(self, other):
383
410
  return Constraint(self - other, ConstraintSense.LE)
384
411
 
@@ -571,7 +598,7 @@ class Set(BaseOperableBlock):
571
598
  def __repr__(self):
572
599
  header = get_obj_repr(
573
600
  self,
574
- "unnamed" if self.name == "unnamed_set" else self.name,
601
+ "'unnamed'" if self.name == "unnamed_set" else f"'{self.name}'",
575
602
  height=self.data.height,
576
603
  )
577
604
  data = self._add_shape_to_columns(self.data)
@@ -645,7 +672,7 @@ class Expression(BaseOperableBlock):
645
672
  >>> m.Size = pf.Variable(df.index)
646
673
  >>> expr = df["cost"] * m.Time + df["cost"] * m.Size
647
674
  >>> expr
648
- <Expression height=5 terms=10 type=linear>
675
+ <Expression (linear) height=5 terms=10>
649
676
  ┌──────┬──────┬──────────────────────────────┐
650
677
  │ item ┆ time ┆ expression │
651
678
  │ (2) ┆ (3) ┆ │
@@ -691,7 +718,7 @@ class Expression(BaseOperableBlock):
691
718
 
692
719
  Examples:
693
720
  >>> pf.Expression.constant(5)
694
- <Expression terms=1 type=constant>
721
+ <Expression (parameter) terms=1>
695
722
  5
696
723
  """
697
724
  return cls(
@@ -712,7 +739,7 @@ class Expression(BaseOperableBlock):
712
739
  If no dimensions are specified, the sum is taken over all of the expression's dimensions.
713
740
 
714
741
  Examples:
715
- >>> expr = pl.DataFrame(
742
+ >>> expr = pf.Param(
716
743
  ... {
717
744
  ... "time": ["mon", "tue", "wed", "mon", "tue"],
718
745
  ... "place": [
@@ -724,9 +751,9 @@ class Expression(BaseOperableBlock):
724
751
  ... ],
725
752
  ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6],
726
753
  ... }
727
- ... ).to_expr()
754
+ ... )
728
755
  >>> expr
729
- <Expression height=5 terms=5 type=constant>
756
+ <Expression (parameter) height=5 terms=5>
730
757
  ┌──────┬───────────┬────────────┐
731
758
  │ time ┆ place ┆ expression │
732
759
  │ (3) ┆ (2) ┆ │
@@ -738,7 +765,7 @@ class Expression(BaseOperableBlock):
738
765
  │ tue ┆ Vancouver ┆ 2000000 │
739
766
  └──────┴───────────┴────────────┘
740
767
  >>> expr.sum("time")
741
- <Expression height=2 terms=2 type=constant>
768
+ <Expression (parameter) height=2 terms=2>
742
769
  ┌───────────┬────────────┐
743
770
  │ place ┆ expression │
744
771
  │ (2) ┆ │
@@ -747,7 +774,7 @@ class Expression(BaseOperableBlock):
747
774
  │ Vancouver ┆ 3000000 │
748
775
  └───────────┴────────────┘
749
776
  >>> expr.sum()
750
- <Expression terms=1 type=constant>
777
+ <Expression (parameter) terms=1>
751
778
  9000000
752
779
 
753
780
  If the given dimensions don't exist, an error will be raised:
@@ -783,7 +810,7 @@ class Expression(BaseOperableBlock):
783
810
  """Like [`Expression.sum`][pyoframe.Expression.sum], but the sum is taken over all dimensions *except* those specified in `by` (just like a `group_by().sum()` operation).
784
811
 
785
812
  Examples:
786
- >>> expr = pl.DataFrame(
813
+ >>> expr = pf.Param(
787
814
  ... {
788
815
  ... "time": ["mon", "tue", "wed", "mon", "tue"],
789
816
  ... "place": [
@@ -795,9 +822,9 @@ class Expression(BaseOperableBlock):
795
822
  ... ],
796
823
  ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6],
797
824
  ... }
798
- ... ).to_expr()
825
+ ... )
799
826
  >>> expr
800
- <Expression height=5 terms=5 type=constant>
827
+ <Expression (parameter) height=5 terms=5>
801
828
  ┌──────┬───────────┬────────────┐
802
829
  │ time ┆ place ┆ expression │
803
830
  │ (3) ┆ (2) ┆ │
@@ -810,7 +837,7 @@ class Expression(BaseOperableBlock):
810
837
  └──────┴───────────┴────────────┘
811
838
 
812
839
  >>> expr.sum_by("place")
813
- <Expression height=2 terms=2 type=constant>
840
+ <Expression (parameter) height=2 terms=2>
814
841
  ┌───────────┬────────────┐
815
842
  │ place ┆ expression │
816
843
  │ (2) ┆ │
@@ -873,13 +900,13 @@ class Expression(BaseOperableBlock):
873
900
 
874
901
  Examples:
875
902
  >>> import polars as pl
876
- >>> pop_data = pl.DataFrame(
903
+ >>> pop_data = pf.Param(
877
904
  ... {
878
905
  ... "city": ["Toronto", "Vancouver", "Boston"],
879
906
  ... "year": [2024, 2024, 2024],
880
907
  ... "population": [10, 2, 8],
881
908
  ... }
882
- ... ).to_expr()
909
+ ... )
883
910
  >>> cities_and_countries = pl.DataFrame(
884
911
  ... {
885
912
  ... "city": ["Toronto", "Vancouver", "Boston"],
@@ -887,7 +914,7 @@ class Expression(BaseOperableBlock):
887
914
  ... }
888
915
  ... )
889
916
  >>> pop_data.map(cities_and_countries)
890
- <Expression height=2 terms=2 type=constant>
917
+ <Expression (parameter) height=2 terms=2>
891
918
  ┌──────┬─────────┬────────────┐
892
919
  │ year ┆ country ┆ expression │
893
920
  │ (1) ┆ (2) ┆ │
@@ -897,7 +924,7 @@ class Expression(BaseOperableBlock):
897
924
  └──────┴─────────┴────────────┘
898
925
 
899
926
  >>> pop_data.map(cities_and_countries, drop_shared_dims=False)
900
- <Expression height=3 terms=3 type=constant>
927
+ <Expression (parameter) height=3 terms=3>
901
928
  ┌───────────┬──────┬─────────┬────────────┐
902
929
  │ city ┆ year ┆ country ┆ expression │
903
930
  │ (3) ┆ (1) ┆ (2) ┆ │
@@ -967,7 +994,7 @@ class Expression(BaseOperableBlock):
967
994
  >>> m = pf.Model()
968
995
  >>> m.quantity = pf.Variable(cost[["item", "time"]])
969
996
  >>> (m.quantity * cost).rolling_sum(over="time", window_size=2)
970
- <Expression height=5 terms=8 type=linear>
997
+ <Expression (linear) height=5 terms=8>
971
998
  ┌──────┬──────┬──────────────────────────────────┐
972
999
  │ item ┆ time ┆ expression │
973
1000
  │ (2) ┆ (3) ┆ │
@@ -1003,11 +1030,8 @@ class Expression(BaseOperableBlock):
1003
1030
  """Filters this expression to only include the dimensions within the provided set.
1004
1031
 
1005
1032
  Examples:
1006
- >>> import pandas as pd
1007
- >>> general_expr = pd.DataFrame(
1008
- ... {"dim1": [1, 2, 3], "value": [1, 2, 3]}
1009
- ... ).to_expr()
1010
- >>> filter_expr = pd.DataFrame({"dim1": [1, 3], "value": [5, 6]}).to_expr()
1033
+ >>> general_expr = pf.Param({"dim1": [1, 2, 3], "value": [1, 2, 3]})
1034
+ >>> filter_expr = pf.Param({"dim1": [1, 3], "value": [5, 6]})
1011
1035
  >>> general_expr.within(filter_expr).data
1012
1036
  shape: (2, 3)
1013
1037
  ┌──────┬─────────┬───────────────┐
@@ -1068,11 +1092,10 @@ class Expression(BaseOperableBlock):
1068
1092
  If `False`, returns the degree as an integer (0, 1, or 2).
1069
1093
 
1070
1094
  Examples:
1071
- >>> import pandas as pd
1072
1095
  >>> m = pf.Model()
1073
1096
  >>> m.v1 = pf.Variable()
1074
1097
  >>> m.v2 = pf.Variable()
1075
- >>> expr = pd.DataFrame({"dim1": [1, 2, 3], "value": [1, 2, 3]}).to_expr()
1098
+ >>> expr = pf.Param({"dim1": [1, 2, 3], "value": [1, 2, 3]})
1076
1099
  >>> expr.degree()
1077
1100
  0
1078
1101
  >>> expr *= m.v1
@@ -1090,18 +1113,17 @@ class Expression(BaseOperableBlock):
1090
1113
  elif (self.data.get_column(VAR_KEY) != CONST_TERM).any():
1091
1114
  return "linear" if return_str else 1
1092
1115
  else:
1093
- return "constant" if return_str else 0
1116
+ return "parameter" if return_str else 0
1094
1117
 
1095
1118
  def __add__(self, other):
1096
1119
  """Adds another expression or a constant to this expression.
1097
1120
 
1098
1121
  Examples:
1099
- >>> import pandas as pd
1100
1122
  >>> m = pf.Model()
1101
- >>> add = pd.DataFrame({"dim1": [1, 2, 3], "add": [10, 20, 30]}).to_expr()
1123
+ >>> add = pf.Param({"dim1": [1, 2, 3], "add": [10, 20, 30]})
1102
1124
  >>> m.v = Variable(add)
1103
1125
  >>> m.v + add
1104
- <Expression height=3 terms=6 type=linear>
1126
+ <Expression (linear) height=3 terms=6>
1105
1127
  ┌──────┬────────────┐
1106
1128
  │ dim1 ┆ expression │
1107
1129
  │ (3) ┆ │
@@ -1112,7 +1134,7 @@ class Expression(BaseOperableBlock):
1112
1134
  └──────┴────────────┘
1113
1135
 
1114
1136
  >>> m.v + add + 2
1115
- <Expression height=3 terms=6 type=linear>
1137
+ <Expression (linear) height=3 terms=6>
1116
1138
  ┌──────┬────────────┐
1117
1139
  │ dim1 ┆ expression │
1118
1140
  │ (3) ┆ │
@@ -1138,12 +1160,12 @@ class Expression(BaseOperableBlock):
1138
1160
  https://bravos-power.github.io/pyoframe/latest/learn/concepts/addition
1139
1161
  >>> m.v2 = Variable()
1140
1162
  >>> 5 + 2 * m.v2
1141
- <Expression terms=2 type=linear>
1163
+ <Expression (linear) terms=2>
1142
1164
  2 v2 +5
1143
1165
  """
1144
1166
  if isinstance(other, (int, float)):
1145
1167
  return self._add_const(other)
1146
- other = other.to_expr()
1168
+ other = other.to_expr() # TODO don't rely on monkey patch
1147
1169
  self._learn_from_other(other)
1148
1170
  return add(self, other)
1149
1171
 
@@ -1156,10 +1178,25 @@ class Expression(BaseOperableBlock):
1156
1178
  name=f"({other} * {self.name})",
1157
1179
  )
1158
1180
 
1159
- other = other.to_expr()
1181
+ other: Expression = other.to_expr() # TODO don't rely on monkey patch
1160
1182
  self._learn_from_other(other)
1161
1183
  return multiply(self, other)
1162
1184
 
1185
+ def __rtruediv__(self, other):
1186
+ """Support dividing by an expression when that expression is a constant."""
1187
+ assert isinstance(other, (int, float)), (
1188
+ f"Expected a number not a {type(other)} when dividing by an expression."
1189
+ )
1190
+ if self.degree() != 0:
1191
+ raise PyoframeError(
1192
+ f"Cannot divide by '{self.name}' because denominators cannot contain variables."
1193
+ )
1194
+
1195
+ return self._new(
1196
+ self.data.with_columns((pl.lit(other) / pl.col(COEF_KEY)).alias(COEF_KEY)),
1197
+ name=f"({other} / {self.name})",
1198
+ )
1199
+
1163
1200
  def to_expr(self) -> Expression:
1164
1201
  """Returns the expression itself."""
1165
1202
  return self
@@ -1181,13 +1218,13 @@ class Expression(BaseOperableBlock):
1181
1218
  >>> m.x1 = Variable()
1182
1219
  >>> m.x2 = Variable()
1183
1220
  >>> m.x1 + 5
1184
- <Expression terms=2 type=linear>
1221
+ <Expression (linear) terms=2>
1185
1222
  x1 +5
1186
1223
  >>> m.x1**2 + 5
1187
- <Expression terms=2 type=quadratic>
1224
+ <Expression (quadratic) terms=2>
1188
1225
  x1 * x1 +5
1189
1226
  >>> m.x1**2 + m.x2 + 5
1190
- <Expression terms=3 type=quadratic>
1227
+ <Expression (quadratic) terms=3>
1191
1228
  x1 * x1 + x2 +5
1192
1229
 
1193
1230
  It also works with dimensions
@@ -1195,7 +1232,7 @@ class Expression(BaseOperableBlock):
1195
1232
  >>> m = pf.Model()
1196
1233
  >>> m.v = Variable({"dim1": [1, 2, 3]})
1197
1234
  >>> m.v * m.v + 5
1198
- <Expression height=3 terms=6 type=quadratic>
1235
+ <Expression (quadratic) height=3 terms=6>
1199
1236
  ┌──────┬─────────────────┐
1200
1237
  │ dim1 ┆ expression │
1201
1238
  │ (3) ┆ │
@@ -1558,9 +1595,9 @@ class Expression(BaseOperableBlock):
1558
1595
  """Returns a string representation of the expression's header."""
1559
1596
  return get_obj_repr(
1560
1597
  self,
1598
+ f"({self.degree(return_str=True)})",
1561
1599
  height=len(self) if self.dimensions else None,
1562
1600
  terms=self.terms,
1563
- type=self.degree(return_str=True),
1564
1601
  )
1565
1602
 
1566
1603
  def __repr__(self) -> str:
@@ -1581,7 +1618,7 @@ class Expression(BaseOperableBlock):
1581
1618
  >>> m.v = pf.Variable({"t": [1, 2]})
1582
1619
  >>> coef = pl.DataFrame({"t": [1, 2], "coef": [0, 1]})
1583
1620
  >>> coef * (m.v + 4)
1584
- <Expression height=2 terms=3 type=linear>
1621
+ <Expression (linear) height=2 terms=3>
1585
1622
  ┌─────┬────────────┐
1586
1623
  │ t ┆ expression │
1587
1624
  │ (2) ┆ │
@@ -2189,10 +2226,10 @@ class Constraint(BaseBlock):
2189
2226
  return (
2190
2227
  get_obj_repr(
2191
2228
  self,
2192
- self.name,
2229
+ f"'{self.name}'",
2230
+ f"({self.lhs.degree(return_str=True)})",
2193
2231
  height=len(self) if self.dimensions else None,
2194
2232
  terms=len(self.lhs.data),
2195
- type=self.lhs.degree(return_str=True),
2196
2233
  )
2197
2234
  + "\n"
2198
2235
  + self.to_str()
@@ -2202,18 +2239,22 @@ class Constraint(BaseBlock):
2202
2239
  class Variable(BaseOperableBlock):
2203
2240
  """A decision variable for an optimization model.
2204
2241
 
2242
+ !!! tip
2243
+ If `lb` or `ub` are a dimensioned object (e.g. an [Expression][pyoframe.Expression]), they will automatically be [broadcasted](../../learn/concepts/addition.md#over) to match the variable's dimensions.
2244
+
2205
2245
  Parameters:
2206
2246
  *indexing_sets:
2207
2247
  If no indexing_sets are provided, a single variable with no dimensions is created.
2208
2248
  Otherwise, a variable is created for each element in the Cartesian product of the indexing_sets (see Set for details on behaviour).
2209
- lb:
2210
- The lower bound for all variables.
2211
- ub:
2212
- The upper bound for all variables.
2213
2249
  vtype:
2214
2250
  The type of the variable. Can be either a VType enum or a string. Default is VType.CONTINUOUS.
2251
+ lb:
2252
+ The lower bound for the variables.
2253
+ ub:
2254
+ The upper bound for the variables.
2215
2255
  equals:
2216
- When specified, a variable is created and a constraint is added to make the variable equal to the provided expression.
2256
+ When specified, a variable is created for every label in `equals` and a constraint is added to make the variable equal to the provided expression.
2257
+ `indexing_sets` cannot be provided when using `equals`.
2217
2258
 
2218
2259
  Examples:
2219
2260
  >>> import pandas as pd
@@ -2298,7 +2339,7 @@ class Variable(BaseOperableBlock):
2298
2339
  assert len(indexing_sets) == 0, (
2299
2340
  "Cannot specify both 'equals' and 'indexing_sets'"
2300
2341
  )
2301
- equals = equals.to_expr()
2342
+ equals = equals.to_expr() # TODO don't rely on monkey patch
2302
2343
  indexing_sets = (equals,)
2303
2344
 
2304
2345
  data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
@@ -2309,10 +2350,16 @@ class Variable(BaseOperableBlock):
2309
2350
  self._equals: Expression | None = equals
2310
2351
 
2311
2352
  if lb is not None and not isinstance(lb, (float, int)):
2353
+ lb: Expression = lb.to_expr() # TODO don't rely on monkey patch
2354
+ if not self.dimensionless:
2355
+ lb = lb.over(*self.dimensions)
2312
2356
  self._lb_expr, self.lb = lb, None
2313
2357
  else:
2314
2358
  self._lb_expr, self.lb = None, lb
2315
2359
  if ub is not None and not isinstance(ub, (float, int)):
2360
+ ub = ub.to_expr() # TODO don't rely on monkey patch
2361
+ if not self.dimensionless:
2362
+ ub = ub.over(*self.dimensions) # pyright: ignore[reportOptionalIterable]
2316
2363
  self._ub_expr, self.ub = ub, None
2317
2364
  else:
2318
2365
  self._ub_expr, self.ub = None, ub
@@ -2518,7 +2565,7 @@ class Variable(BaseOperableBlock):
2518
2565
  result = (
2519
2566
  get_obj_repr(
2520
2567
  self,
2521
- self.name,
2568
+ f"'{self.name}'",
2522
2569
  lb=self.lb,
2523
2570
  ub=self.ub,
2524
2571
  height=self.data.height if self.dimensions else None,
@@ -2581,7 +2628,7 @@ class Variable(BaseOperableBlock):
2581
2628
  https://bravos-power.github.io/pyoframe/latest/learn/concepts/addition
2582
2629
 
2583
2630
  >>> (m.bat_charge + m.bat_flow).drop_extras() == m.bat_charge.next("time")
2584
- <Constraint 'unnamed' height=6 terms=18 type=linear>
2631
+ <Constraint 'unnamed' (linear) height=6 terms=18>
2585
2632
  ┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
2586
2633
  │ time ┆ city ┆ constraint │
2587
2634
  │ (3) ┆ (2) ┆ │
@@ -2603,7 +2650,7 @@ class Variable(BaseOperableBlock):
2603
2650
  >>> (m.bat_charge + m.bat_flow) == m.bat_charge.next(
2604
2651
  ... "time", wrap_around=True
2605
2652
  ... )
2606
- <Constraint 'unnamed' height=8 terms=24 type=linear>
2653
+ <Constraint 'unnamed' (linear) height=8 terms=24>
2607
2654
  ┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
2608
2655
  │ time ┆ city ┆ constraint │
2609
2656
  │ (4) ┆ (2) ┆ │
pyoframe/_model.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import time
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
@@ -50,6 +51,10 @@ class Model:
50
51
  Either "min" or "max". Indicates whether it's a minimization or maximization problem.
51
52
  Typically, this parameter can be omitted (`None`) as it will automatically be
52
53
  set when the objective is set using `.minimize` or `.maximize`.
54
+ verbose:
55
+ If `True`, logging messages will be printed every time a Variable or Constraint is added to the model.
56
+ This is useful to discover performance bottlenecks.
57
+ Logging can be further configured via the [`logging` module](https://docs.python.org/3/howto/logging.html) by modifying the `pyoframe` logger.
53
58
 
54
59
  Examples:
55
60
  >>> m = pf.Model()
@@ -87,6 +92,8 @@ class Model:
87
92
  "solver_name",
88
93
  "minimize",
89
94
  "maximize",
95
+ "_logger",
96
+ "_last_log",
90
97
  ]
91
98
 
92
99
  def __init__(
@@ -98,6 +105,7 @@ class Model:
98
105
  solver_uses_variable_names: bool = False,
99
106
  print_uses_variable_names: bool = True,
100
107
  sense: ObjSense | ObjSenseValue | None = None,
108
+ verbose: bool = False,
101
109
  ):
102
110
  self._poi, self.solver = Model._create_poi_model(solver, solver_env)
103
111
  self.solver_name: str = self.solver.name
@@ -112,6 +120,17 @@ class Model:
112
120
  self._attr = Container(self._set_attr, self._get_attr)
113
121
  self._solver_uses_variable_names = solver_uses_variable_names
114
122
 
123
+ self._logger = None
124
+ self._last_log = None
125
+ if verbose:
126
+ import logging
127
+
128
+ self._logger = logging.getLogger("pyoframe")
129
+ self._logger.addHandler(logging.NullHandler())
130
+ self._logger.setLevel(logging.DEBUG)
131
+
132
+ self._last_log = time.time()
133
+
115
134
  @property
116
135
  def poi(self):
117
136
  """The underlying PyOptInterface model used to interact with the solver.
@@ -234,7 +253,7 @@ class Model:
234
253
  from pyoptinterface import ipopt
235
254
  except ModuleNotFoundError as e: # pragma: no cover
236
255
  raise ModuleNotFoundError(
237
- "Failed to import the Ipopt solver. Did you run `pip install pyoptinterface[ipopt]`?"
256
+ "Failed to import the Ipopt solver. Did you run `pip install pyoptinterface[nlp]`?"
238
257
  ) from e
239
258
 
240
259
  try:
@@ -326,18 +345,6 @@ class Model:
326
345
  Raises:
327
346
  ValueError: If the objective has not been defined.
328
347
 
329
- Examples:
330
- >>> m = pf.Model()
331
- >>> m.X = pf.Variable()
332
- >>> m.objective
333
- Traceback (most recent call last):
334
- ...
335
- ValueError: Objective is not defined.
336
- >>> m.maximize = m.X
337
- >>> m.objective
338
- <Objective terms=1 type=linear>
339
- X
340
-
341
348
  See Also:
342
349
  [`Model.has_objective`][pyoframe.Model.has_objective]
343
350
  """
@@ -357,9 +364,11 @@ class Model:
357
364
  value._on_add_to_model(self, "objective")
358
365
 
359
366
  @property
360
- def minimize(self) -> Objective | None:
367
+ def minimize(self) -> Objective:
361
368
  """Sets or gets the model's objective for minimization problems."""
362
- if self.sense != ObjSense.MIN:
369
+ if self._objective is None:
370
+ raise ValueError("Objective is not defined.")
371
+ if self.sense == ObjSense.MAX:
363
372
  raise ValueError("Can't get .minimize in a maximization problem.")
364
373
  return self._objective
365
374
 
@@ -372,9 +381,11 @@ class Model:
372
381
  self.objective = value
373
382
 
374
383
  @property
375
- def maximize(self) -> Objective | None:
384
+ def maximize(self) -> Objective:
376
385
  """Sets or gets the model's objective for maximization problems."""
377
- if self.sense != ObjSense.MAX:
386
+ if self._objective is None:
387
+ raise ValueError("Objective is not defined.")
388
+ if self.sense == ObjSense.MIN:
378
389
  raise ValueError("Can't get .maximize in a minimization problem.")
379
390
  return self._objective
380
391
 
@@ -400,14 +411,37 @@ class Model:
400
411
  f"Cannot create {__name} since it was already created."
401
412
  )
402
413
 
414
+ log = self._logger is not None and isinstance(
415
+ __value, (Constraint, Variable)
416
+ )
417
+
418
+ if log:
419
+ start_time = time.time()
420
+
403
421
  __value._on_add_to_model(self, __name)
404
422
 
423
+ if log:
424
+ solver_time = time.time() - start_time # type: ignore
425
+
405
426
  if isinstance(__value, Variable):
406
427
  self._variables.append(__value)
407
428
  if self._var_map is not None:
408
429
  self._var_map.add(__value)
430
+ if log:
431
+ type_name = "variable"
409
432
  elif isinstance(__value, Constraint):
410
433
  self._constraints.append(__value)
434
+ if log:
435
+ type_name = "constraint"
436
+
437
+ if log:
438
+ elapsed_time = time.time() - self._last_log # type: ignore
439
+ self._last_log += elapsed_time # type: ignore
440
+
441
+ self._logger.debug( # type: ignore
442
+ f"Added {type_name} '{__name}' to model ({elapsed_time:.1f}s elapsed, {solver_time:.1f}s for solver, n={len(__value)})" # type: ignore
443
+ )
444
+
411
445
  return super().__setattr__(__name, __value)
412
446
 
413
447
  # Defining a custom __getattribute__ prevents type checkers from complaining about attribute access
@@ -417,7 +451,7 @@ class Model:
417
451
  def __repr__(self) -> str:
418
452
  return get_obj_repr(
419
453
  self,
420
- self.name,
454
+ f"'{self.name}'" if self.name is not None else None,
421
455
  vars=len(self.variables),
422
456
  constrs=len(self.constraints),
423
457
  has_objective=self.has_objective,
@@ -532,6 +566,134 @@ class Model:
532
566
  """
533
567
  self.poi.computeIIS()
534
568
 
569
+ def variables_size_info(self, memory_unit: pl.SizeUnit = "b") -> pl.DataFrame:
570
+ """Returns a DataFrame with information about the memory usage of each variable in the model.
571
+
572
+ !!! warning "Experimental"
573
+ This method is experimental and may change or be removed in future versions. We're interested in your [feedback]([feedback](https://github.com/Bravos-Power/pyoframe/issues).
574
+
575
+ Parameters:
576
+ memory_unit:
577
+ The size of the memory unit to use for the memory usage information.
578
+ See [`polars.DataFrame.estimated_size`](https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.estimated_size.html).
579
+
580
+ Examples:
581
+ >>> m = pf.Model()
582
+ >>> m.X = pf.Variable()
583
+ >>> m.Y = pf.Variable(pf.Set(dim_x=range(100)))
584
+ >>> m.variables_size_info()
585
+ shape: (3, 5)
586
+ ┌───────┬───────────────┬────────────────────┬───────────────────────┬────────────────────────────┐
587
+ │ name ┆ num_variables ┆ num_variables_perc ┆ pyoframe_memory_usage ┆ pyoframe_memory_usage_perc │
588
+ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │
589
+ │ str ┆ i64 ┆ str ┆ i64 ┆ str │
590
+ ╞═══════╪═══════════════╪════════════════════╪═══════════════════════╪════════════════════════════╡
591
+ │ Y ┆ 100 ┆ 99.0% ┆ 1200 ┆ 99.7% │
592
+ │ X ┆ 1 ┆ 1.0% ┆ 4 ┆ 0.3% │
593
+ │ TOTAL ┆ 101 ┆ 100.0% ┆ 1204 ┆ 100.0% │
594
+ └───────┴───────────────┴────────────────────┴───────────────────────┴────────────────────────────┘
595
+ """
596
+ data = pl.DataFrame(
597
+ [
598
+ dict(name=v.name, n=len(v), mem=v.estimated_size(memory_unit))
599
+ for v in self.variables
600
+ ]
601
+ ).sort("n", descending=True)
602
+
603
+ total = data.sum().with_columns(name=pl.lit("TOTAL"))
604
+ data = pl.concat([data, total])
605
+
606
+ def format(expr: pl.Expr) -> pl.Expr:
607
+ return (100 * expr).round(1).cast(pl.String) + pl.lit("%")
608
+
609
+ data = data.with_columns(
610
+ n_per=format(pl.col("n") / total["n"].item()),
611
+ mem_per=format(pl.col("mem") / total["mem"].item()),
612
+ )
613
+
614
+ data = data.select("name", "n", "n_per", "mem", "mem_per")
615
+ data = data.rename(
616
+ {
617
+ "n": "num_variables",
618
+ "n_per": "num_variables_perc",
619
+ "mem": "pyoframe_memory_usage",
620
+ "mem_per": "pyoframe_memory_usage_perc",
621
+ }
622
+ )
623
+
624
+ return pl.DataFrame(data)
625
+
626
+ def constraints_size_info(self, memory_unit: pl.SizeUnit = "b") -> pl.DataFrame:
627
+ """Returns a DataFrame with information about the memory usage of each constraint in the model.
628
+
629
+ !!! warning "Experimental"
630
+ This method is experimental and may change or be removed in future versions. We're interested in your [feedback](https://github.com/Bravos-Power/pyoframe/issues).
631
+
632
+ Parameters:
633
+ memory_unit:
634
+ The size of the memory unit to use for the memory usage information.
635
+ See [`polars.DataFrame.estimated_size`](https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.estimated_size.html).
636
+
637
+ Examples:
638
+ >>> m = pf.Model()
639
+ >>> m.X = pf.Variable()
640
+ >>> m.Y = pf.Variable(pf.Set(dim_x=range(100)))
641
+ >>> m.c1 = m.X.over("dim_x") + m.Y <= 10
642
+ >>> m.c2 = m.X + m.Y.sum() <= 20
643
+ >>> m.constraints_size_info()
644
+ shape: (3, 7)
645
+ ┌───────┬───────────────┬──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
646
+ │ name ┆ num_constrain ┆ num_constrai ┆ num_non_zero ┆ num_non_zero ┆ pyoframe_mem ┆ pyoframe_mem │
647
+ │ --- ┆ ts ┆ nts_perc ┆ s ┆ s_perc ┆ ory_usage ┆ ory_usage_pe │
648
+ │ str ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ rc │
649
+ │ ┆ i64 ┆ str ┆ i64 ┆ str ┆ i64 ┆ --- │
650
+ │ ┆ ┆ ┆ ┆ ┆ ┆ str │
651
+ ╞═══════╪═══════════════╪══════════════╪══════════════╪══════════════╪══════════════╪══════════════╡
652
+ │ c1 ┆ 100 ┆ 99.0% ┆ 300 ┆ 74.6% ┆ 7314 ┆ 85.6% │
653
+ │ c2 ┆ 1 ┆ 1.0% ┆ 102 ┆ 25.4% ┆ 1228 ┆ 14.4% │
654
+ │ TOTAL ┆ 101 ┆ 100.0% ┆ 402 ┆ 100.0% ┆ 8542 ┆ 100.0% │
655
+ └───────┴───────────────┴──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
656
+ """
657
+ data = pl.DataFrame(
658
+ [
659
+ dict(
660
+ name=c.name,
661
+ n=len(c),
662
+ non_zeros=c.lhs.data.height,
663
+ mem=c.estimated_size(memory_unit),
664
+ )
665
+ for c in self.constraints
666
+ ]
667
+ ).sort("n", descending=True)
668
+
669
+ total = data.sum().with_columns(name=pl.lit("TOTAL"))
670
+ data = pl.concat([data, total])
671
+
672
+ def format(col: pl.Expr) -> pl.Expr:
673
+ return (100 * col).round(1).cast(pl.String) + pl.lit("%")
674
+
675
+ data = data.with_columns(
676
+ n_per=format(pl.col("n") / total["n"].item()),
677
+ non_zeros_per=format(pl.col("non_zeros") / total["non_zeros"].item()),
678
+ mem_per=format(pl.col("mem") / total["mem"].item()),
679
+ )
680
+
681
+ data = data.select(
682
+ "name", "n", "n_per", "non_zeros", "non_zeros_per", "mem", "mem_per"
683
+ )
684
+ data = data.rename(
685
+ {
686
+ "n": "num_constraints",
687
+ "n_per": "num_constraints_perc",
688
+ "non_zeros": "num_non_zeros",
689
+ "non_zeros_per": "num_non_zeros_perc",
690
+ "mem": "pyoframe_memory_usage",
691
+ "mem_per": "pyoframe_memory_usage_perc",
692
+ }
693
+ )
694
+
695
+ return pl.DataFrame(data)
696
+
535
697
  def dispose(self):
536
698
  """Disposes of the model and cleans up the solver environment.
537
699
 
@@ -48,6 +48,8 @@ class BaseBlock(ABC):
48
48
  self._data = data
49
49
  self._model: Model | None = None
50
50
  self.name: str = name # gets overwritten if object is added to model
51
+ """A user-friendly name that is displayed when printing the object or in error messages.
52
+ When an object is added to a model, this name is updated to the name used in the model."""
51
53
 
52
54
  def _on_add_to_model(self, model: Model, name: str):
53
55
  self.name = name
pyoframe/_monkey_patch.py CHANGED
@@ -5,8 +5,8 @@ from functools import wraps
5
5
  import pandas as pd
6
6
  import polars as pl
7
7
 
8
- from pyoframe._constants import COEF_KEY, CONST_TERM, VAR_KEY
9
- from pyoframe._core import BaseOperableBlock, Expression
8
+ from pyoframe._core import BaseOperableBlock
9
+ from pyoframe._param import Param
10
10
 
11
11
 
12
12
  def _patch_class(cls):
@@ -29,54 +29,10 @@ def _patch_class(cls):
29
29
  cls.__contains__ = _patch_method(cls.__contains__)
30
30
 
31
31
 
32
- def polars_df_to_expr(self: pl.DataFrame) -> Expression:
33
- """Converts a [polars](https://pola.rs/) `DataFrame` to a Pyoframe [Expression][pyoframe.Expression] by using the last column for values and the previous columns as dimensions.
34
-
35
- See [Special Functions](../../learn/concepts/special-functions.md#dataframeto_expr) for more details.
36
-
37
- Examples:
38
- >>> import polars as pl
39
- >>> df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9]})
40
- >>> df.to_expr()
41
- <Expression height=3 terms=3 type=constant>
42
- ┌─────┬─────┬────────────┐
43
- │ x ┆ y ┆ expression │
44
- │ (3) ┆ (3) ┆ │
45
- ╞═════╪═════╪════════════╡
46
- │ 1 ┆ 4 ┆ 7 │
47
- │ 2 ┆ 5 ┆ 8 │
48
- │ 3 ┆ 6 ┆ 9 │
49
- └─────┴─────┴────────────┘
50
- """
51
- name = self.columns[-1]
52
- return Expression(
53
- self.rename({name: COEF_KEY})
54
- .drop_nulls(COEF_KEY)
55
- .with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
56
- name=name,
57
- )
58
-
59
-
60
- def pandas_df_to_expr(self: pd.DataFrame) -> Expression:
61
- """Same as [`polars.DataFrame.to_expr`](./polars.DataFrame.to_expr.md), but for [pandas](https://pandas.pydata.org/) DataFrames."""
62
- return polars_df_to_expr(pl.from_pandas(self))
63
-
64
-
65
- def pandas_series_to_expr(self: pd.Series) -> Expression:
66
- """Converts a [pandas](https://pandas.pydata.org/) `Series` to a Pyoframe [Expression][pyoframe.Expression], using the index for labels.
67
-
68
- See [Special Functions](../../learn/concepts/special-functions.md#dataframeto_expr) for more details.
69
-
70
- Note that no equivalent method exists for Polars Series, as Polars does not support indexes.
71
- """
72
- return pandas_df_to_expr(self.to_frame().reset_index())
73
-
74
-
75
32
  def patch_dataframe_libraries():
76
33
  _patch_class(pd.DataFrame)
77
34
  _patch_class(pd.Series)
78
35
  _patch_class(pl.DataFrame)
79
- _patch_class(pl.Series)
80
- pl.DataFrame.to_expr = polars_df_to_expr
81
- pd.DataFrame.to_expr = pandas_df_to_expr
82
- pd.Series.to_expr = pandas_series_to_expr
36
+ pl.DataFrame.to_expr = lambda self: Param(self) # type: ignore
37
+ pd.DataFrame.to_expr = lambda self: Param(self) # type: ignore
38
+ pd.Series.to_expr = lambda self: Param(self) # type: ignore
pyoframe/_objective.py CHANGED
@@ -20,7 +20,7 @@ class Objective(Expression):
20
20
  >>> m.con = m.A + m.B <= 10
21
21
  >>> m.maximize = 2 * m.B + 4
22
22
  >>> m.maximize
23
- <Objective terms=2 type=linear>
23
+ <Objective (linear) terms=2>
24
24
  2 B +4
25
25
 
26
26
  The objective value can be retrieved with from the solver once the model is solved using `.value`.
@@ -67,7 +67,7 @@ class Objective(Expression):
67
67
  if isinstance(expr, (int, float)):
68
68
  expr = Expression.constant(expr)
69
69
  else:
70
- expr = expr.to_expr()
70
+ expr = expr.to_expr() # TODO don't rely on monkey patch
71
71
  super().__init__(expr.data, name="objective")
72
72
  self._model = expr._model
73
73
  if self.dimensions is not None:
pyoframe/_param.py ADDED
@@ -0,0 +1,99 @@
1
+ """Defines the function for creating model parameters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+ import polars as pl
9
+
10
+ from pyoframe._constants import COEF_KEY, CONST_TERM, VAR_KEY
11
+ from pyoframe._core import Expression
12
+
13
+
14
+ def Param(
15
+ data: pl.DataFrame | pd.DataFrame | pd.Series | dict | str | Path,
16
+ ) -> Expression:
17
+ """Creates a model parameter, i.e. an [Expression][pyoframe.Expression] that doesn't involve any variables.
18
+
19
+ A Parameter can be created from a DataFrame, CSV file, Parquet file, data dictionary, or a Pandas Series.
20
+
21
+ !!! info "`Param` is a function, not a class"
22
+ Technically, `Param(data)` is a function that returns an [Expression][pyoframe.Expression], not a class.
23
+ However, for consistency with other modeling frameworks, we provide it as a class-like function (i.e. an uppercase function).
24
+
25
+ !!! tip "Smart naming"
26
+ If a Param is not given a name (i.e. if it is not assigned to a model: `m.my_name = Param(...)`),
27
+ then its [name][pyoframe._model_element.BaseBlock.name] is inferred from the name of the column in `data` that contains the parameter values.
28
+ This makes debugging models with inline parameters easier.
29
+
30
+ Args:
31
+ data: The data to use for the parameter.
32
+
33
+ If `data` is a polars or pandas `DataFrame`, the last column will be treated as the values of the parameter, and all other columns as labels.
34
+
35
+ If `data` is a string or `Path`, it will be interpreted as a path to a CSV or Parquet file that will be read and used as a `DataFrame`. The file extension must be `.csv` or `.parquet`.
36
+
37
+ If `data` is a `pandas.Series`, the index(es) will be treated as columns for labels and the series values as the parameter values.
38
+
39
+ If `data` is of any other type (e.g. a dictionary), it will be used as if you had called `Param(pl.DataFrame(data))`.
40
+
41
+ Returns:
42
+ An Expression representing the parameter.
43
+
44
+ Examples:
45
+ >>> m = pf.Model()
46
+ >>> m.fixed_cost = pf.Param({"plant": ["A", "B"], "cost": [1000, 1500]})
47
+ >>> m.fixed_cost
48
+ <Expression (parameter) height=2 terms=2>
49
+ ┌───────┬────────────┐
50
+ │ plant ┆ expression │
51
+ │ (2) ┆ │
52
+ ╞═══════╪════════════╡
53
+ │ A ┆ 1000 │
54
+ │ B ┆ 1500 │
55
+ └───────┴────────────┘
56
+
57
+ Since `Param` simply returns an Expression, you can use it in building larger expressions as usual:
58
+
59
+ >>> m.variable_cost = pf.Param(
60
+ ... pl.DataFrame({"plant": ["A", "B"], "cost": [50, 60]})
61
+ ... )
62
+ >>> m.total_cost = m.fixed_cost + m.variable_cost
63
+ >>> m.total_cost
64
+ <Expression (parameter) height=2 terms=2>
65
+ ┌───────┬────────────┐
66
+ │ plant ┆ expression │
67
+ │ (2) ┆ │
68
+ ╞═══════╪════════════╡
69
+ │ A ┆ 1050 │
70
+ │ B ┆ 1560 │
71
+ └───────┴────────────┘
72
+ """
73
+ if isinstance(data, pd.Series):
74
+ data = data.to_frame().reset_index()
75
+ if isinstance(data, pd.DataFrame):
76
+ data = pl.from_pandas(data)
77
+
78
+ if isinstance(data, (str, Path)):
79
+ data = Path(data)
80
+ if data.suffix.lower() == ".csv":
81
+ data = pl.read_csv(data)
82
+ elif data.suffix.lower() in {".parquet"}:
83
+ data = pl.read_parquet(data)
84
+ else:
85
+ raise NotImplementedError(
86
+ f"Could not create parameter. Unsupported file format: {data.suffix}"
87
+ )
88
+
89
+ if not isinstance(data, pl.DataFrame):
90
+ data = pl.DataFrame(data)
91
+
92
+ value_col = data.columns[-1]
93
+
94
+ return Expression(
95
+ data.rename({value_col: COEF_KEY})
96
+ .drop_nulls(COEF_KEY)
97
+ .with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
98
+ name=value_col,
99
+ )
pyoframe/_utils.py CHANGED
@@ -42,7 +42,7 @@ def get_obj_repr(obj: object, *props: str | None, **kwargs):
42
42
 
43
43
  See usage for examples.
44
44
  """
45
- props_str = " ".join(f"'{v}'" for v in props if v is not None)
45
+ props_str = " ".join(v for v in props if v is not None)
46
46
  if props_str:
47
47
  props_str += " "
48
48
  kwargs_str = " ".join(f"{k}={v}" for k, v in kwargs.items() if v is not None)
@@ -302,7 +302,7 @@ class NamedVariableMapper:
302
302
  >>> m = pf.Model()
303
303
  >>> m.foo = pf.Variable(pl.DataFrame({"t": range(4)}))
304
304
  >>> m.foo.sum()
305
- <Expression terms=4 type=linear>
305
+ <Expression (linear) terms=4>
306
306
  foo[0] + foo[1] + foo[2] + foo[3]
307
307
  """
308
308
 
pyoframe/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.0.1'
32
- __version_tuple__ = version_tuple = (1, 0, 1)
31
+ __version__ = version = '1.2.0'
32
+ __version_tuple__ = version_tuple = (1, 2, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoframe
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: Blazing fast linear program interface
5
5
  Author-email: Bravos Power <dev@bravospower.com>
6
6
  License-Expression: MIT
@@ -20,21 +20,21 @@ Requires-Dist: pyarrow
20
20
  Requires-Dist: pandas<3
21
21
  Requires-Dist: pyoptinterface==0.5.1
22
22
  Provides-Extra: highs
23
- Requires-Dist: highsbox<=1.11.0; extra == "highs"
23
+ Requires-Dist: highsbox<=1.12.0; extra == "highs"
24
24
  Provides-Extra: ipopt
25
25
  Requires-Dist: pyoptinterface[nlp]; extra == "ipopt"
26
- Requires-Dist: llvmlite<=0.44.0; extra == "ipopt"
26
+ Requires-Dist: llvmlite<=0.46.0; extra == "ipopt"
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: ruff==0.12.11; extra == "dev"
29
29
  Requires-Dist: polars>=1.32.3; extra == "dev"
30
30
  Requires-Dist: pytest==8.4.1; extra == "dev"
31
31
  Requires-Dist: pytest-cov==6.2.1; extra == "dev"
32
- Requires-Dist: sybil[pytest]==9.2.0; extra == "dev"
32
+ Requires-Dist: sybil[pytest]==9.3.0; extra == "dev"
33
33
  Requires-Dist: pre-commit==4.3.0; extra == "dev"
34
34
  Requires-Dist: gurobipy==12.0.3; extra == "dev"
35
35
  Requires-Dist: coverage==7.10.6; extra == "dev"
36
36
  Requires-Dist: ipykernel==6.30.1; extra == "dev"
37
- Requires-Dist: highsbox<=1.11.0; extra == "dev"
37
+ Requires-Dist: highsbox<=1.12.0; extra == "dev"
38
38
  Requires-Dist: pyoptinterface[nlp]; extra == "dev"
39
39
  Requires-Dist: numpy; extra == "dev"
40
40
  Provides-Extra: docs
@@ -0,0 +1,16 @@
1
+ pyoframe/__init__.py,sha256=Ij-9priyKTHaGzVhMhtZlDKWz0ggAwGAS9DqB4O6zWU,886
2
+ pyoframe/_arithmetic.py,sha256=n41QG9qlAn5rZkYUmF9EUR3B3abOUJWlJEDZ3P5jgj4,20578
3
+ pyoframe/_constants.py,sha256=LWlry4K5w-3vVyq7CpEQ28UfM3LulbKxkO-nBlWWJzE,17847
4
+ pyoframe/_core.py,sha256=M2WbOGrCAxPdl0W2AxC9GSCD8MnRSwBHMn9hytWiQcI,119353
5
+ pyoframe/_model.py,sha256=T9FxSeF8b3xw9P_es0LwMGnDhI4LSAuUpELCgg0RSXA,31754
6
+ pyoframe/_model_element.py,sha256=VVRqh2uM8HFvRFvqQmgM93jqofa-8mPwyB-qYA0YjRU,6345
7
+ pyoframe/_monkey_patch.py,sha256=7FWMRXZIHK7mRkZfOKQ8Y724q1sPbq_EiPjlJCTfYoA,1168
8
+ pyoframe/_objective.py,sha256=HeiP4KjlXn-IplqV-MALF26yMmh41JyHXjZhhtKJIsQ,4367
9
+ pyoframe/_param.py,sha256=FUSfITPb-WZA-xwVcF9dCCHO2K_pky5GBWooImsSy6I,4147
10
+ pyoframe/_utils.py,sha256=XaPZ8j9YQ-HnAuT2NLAvDCJGVzKSjUmRxARNuGWykIM,12508
11
+ pyoframe/_version.py,sha256=-uLONazCO1SzFfcY-K6A1keL--LIVfTYccGX6ciADac,704
12
+ pyoframe-1.2.0.dist-info/licenses/LICENSE,sha256=u_Spw4ynlwTMRZeCX-uacv_hBU547pBygiA6d2ONNV4,1074
13
+ pyoframe-1.2.0.dist-info/METADATA,sha256=C2JCa-gO8TeVP-wOAS0MJDfusaH4TAwtRJ7d7xunzx4,4060
14
+ pyoframe-1.2.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
15
+ pyoframe-1.2.0.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
16
+ pyoframe-1.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,15 +0,0 @@
1
- pyoframe/__init__.py,sha256=Nlql3FYed7bXWumvUeMd3rjnoL4l8XC5orO4uxWrDAc,839
2
- pyoframe/_arithmetic.py,sha256=9V3N2Yq7Ib13UfTQnINxa9oS7786f5DnRsrAPcFlYYE,20558
3
- pyoframe/_constants.py,sha256=n80so80usutTJpeDlDXkHRE-_dpPD1LxF2plscXhbwQ,17925
4
- pyoframe/_core.py,sha256=NXH_ze1iN61vk38Fj6Ire0uqKUJRSDUlD6PVc8mTHrQ,116975
5
- pyoframe/_model.py,sha256=MtA9gleQoUAqO2dxhCFZ8GOZvyyJB_PIYBzvQ0m8enc,22466
6
- pyoframe/_model_element.py,sha256=oQ7nykJ5XEzJ6Klq3lT6ZwQvDrxY_wgZYVaN7pgyZOs,6149
7
- pyoframe/_monkey_patch.py,sha256=Y2zXN5MpqDeAWELddyaFQNam57fehSXHiza1PFaZ-QY,3128
8
- pyoframe/_objective.py,sha256=yIHoaBLsjGCKzIB6RQErV3vzE2U5DGORlhifRStB_Mc,4335
9
- pyoframe/_utils.py,sha256=5yy-5DOWCW7q3QzPv9tKquLUQJtNdpJJilEqxAq7TN8,12518
10
- pyoframe/_version.py,sha256=JvmBpae6cHui8lSCsCcZQAxzawN2NERHGsr-rIUeJMo,704
11
- pyoframe-1.0.1.dist-info/licenses/LICENSE,sha256=u_Spw4ynlwTMRZeCX-uacv_hBU547pBygiA6d2ONNV4,1074
12
- pyoframe-1.0.1.dist-info/METADATA,sha256=UtnvQdR7vJQvLm8p0RvgwdGfR7yCISswH_V4KuihBr4,4060
13
- pyoframe-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- pyoframe-1.0.1.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
15
- pyoframe-1.0.1.dist-info/RECORD,,