pyoframe 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

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