pyoframe 0.0.11__py3-none-any.whl → 0.1.1__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
@@ -3,11 +3,10 @@ Pyoframe's public API.
3
3
  Also applies the monkey patch to the DataFrame libraries.
4
4
  """
5
5
 
6
- from pyoframe.monkey_patch import patch_dataframe_libraries
7
- from pyoframe.core import sum, sum_by, Set, Constraint, Expression, Variable
8
- from pyoframe.constants import Config
6
+ from pyoframe.constants import Config, VType
7
+ from pyoframe.core import Constraint, Expression, Set, Variable, sum, sum_by
9
8
  from pyoframe.model import Model
10
- from pyoframe.constants import VType
9
+ from pyoframe.monkey_patch import patch_dataframe_libraries
11
10
 
12
11
  patch_dataframe_libraries()
13
12
 
pyoframe/_arithmetic.py CHANGED
@@ -1,19 +1,63 @@
1
+ """
2
+ Defines helper functions for doing arithmetic operations on expressions (e.g. addition).
3
+ """
4
+
1
5
  from typing import TYPE_CHECKING, List, Optional
6
+
2
7
  import polars as pl
3
8
 
4
9
  from pyoframe.constants import (
5
10
  COEF_KEY,
11
+ CONST_TERM,
12
+ KEY_TYPE,
13
+ POLARS_VERSION,
14
+ QUAD_VAR_KEY,
6
15
  RESERVED_COL_KEYS,
7
16
  VAR_KEY,
8
- UnmatchedStrategy,
9
17
  Config,
10
18
  PyoframeError,
19
+ UnmatchedStrategy,
11
20
  )
12
21
 
13
22
  if TYPE_CHECKING: # pragma: no cover
14
23
  from pyoframe.core import Expression
15
24
 
16
25
 
26
+ def _multiply_expressions(self: "Expression", other: "Expression") -> "Expression":
27
+ """
28
+ Multiply two or more expressions together.
29
+
30
+ Examples:
31
+ >>> import pyoframe as pf
32
+ >>> m = pf.Model("min")
33
+ >>> m.x1 = pf.Variable()
34
+ >>> m.x2 = pf.Variable()
35
+ >>> m.x3 = pf.Variable()
36
+ >>> result = 5 * m.x1 * m.x2
37
+ >>> result
38
+ <Expression size=1 dimensions={} terms=1 degree=2>
39
+ 5 x2 * x1
40
+ >>> result * m.x3
41
+ Traceback (most recent call last):
42
+ ...
43
+ pyoframe.constants.PyoframeError: Failed to multiply expressions:
44
+ <Expression size=1 dimensions={} terms=1 degree=2> * <Expression size=1 dimensions={} terms=1>
45
+ Due to error:
46
+ Cannot multiply a quadratic expression by a non-constant.
47
+ """
48
+ try:
49
+ return _multiply_expressions_core(self, other)
50
+ except PyoframeError as error:
51
+ raise PyoframeError(
52
+ "Failed to multiply expressions:\n"
53
+ + " * ".join(
54
+ e.to_str(include_header=True, include_data=False) for e in [self, other]
55
+ )
56
+ + "\nDue to error:\n"
57
+ + str(error)
58
+ ) from error
59
+
60
+
17
61
  def _add_expressions(*expressions: "Expression") -> "Expression":
18
62
  try:
19
63
  return _add_expressions_core(*expressions)
@@ -28,6 +72,98 @@ def _add_expressions(*expressions: "Expression") -> "Expression":
28
72
  ) from error
29
73
 
30
74
 
75
+ def _multiply_expressions_core(self: "Expression", other: "Expression") -> "Expression":
76
+ self_degree, other_degree = self.degree(), other.degree()
77
+ if self_degree + other_degree > 2:
78
+ # We know one of the two must be a quadratic since 1 + 1 is not greater than 2.
79
+ raise PyoframeError("Cannot multiply a quadratic expression by a non-constant.")
80
+ if self_degree < other_degree:
81
+ self, other = other, self
82
+ self_degree, other_degree = other_degree, self_degree
83
+ if other_degree == 1:
84
+ assert (
85
+ self_degree == 1
86
+ ), "This should always be true since the sum of degrees must be <=2."
87
+ return _quadratic_multiplication(self, other)
88
+
89
+ assert (
90
+ other_degree == 0
91
+ ), "This should always be true since other cases have already been handled."
92
+ multiplier = other.data.drop(
93
+ VAR_KEY
94
+ ) # QUAD_VAR_KEY doesn't need to be dropped since we know it doesn't exist
95
+
96
+ dims = self.dimensions_unsafe
97
+ other_dims = other.dimensions_unsafe
98
+ dims_in_common = [dim for dim in dims if dim in other_dims]
99
+
100
+ data = (
101
+ self.data.join(
102
+ multiplier,
103
+ on=dims_in_common if len(dims_in_common) > 0 else None,
104
+ how="inner" if dims_in_common else "cross",
105
+ )
106
+ .with_columns(pl.col(COEF_KEY) * pl.col(COEF_KEY + "_right"))
107
+ .drop(COEF_KEY + "_right")
108
+ )
109
+
110
+ return self._new(data)
111
+
112
+
113
+ def _quadratic_multiplication(self: "Expression", other: "Expression") -> "Expression":
114
+ """
115
+ Multiply two expressions of degree 1.
116
+
117
+ Examples:
118
+ >>> import polars as pl
119
+ >>> df = pl.DataFrame({"dim": [1, 2, 3], "value": [1, 2, 3]})
120
+ >>> m = pf.Model()
121
+ >>> m.x1 = pf.Variable()
122
+ >>> m.x2 = pf.Variable()
123
+ >>> expr1 = df * m.x1
124
+ >>> expr2 = df * m.x2 * 2 + 4
125
+ >>> expr1 * expr2
126
+ <Expression size=3 dimensions={'dim': 3} terms=6 degree=2>
127
+ [1]: 4 x1 +2 x2 * x1
128
+ [2]: 8 x1 +8 x2 * x1
129
+ [3]: 12 x1 +18 x2 * x1
130
+ >>> (expr1 * expr2) - df * m.x1 * df * m.x2 * 2
131
+ <Expression size=3 dimensions={'dim': 3} terms=3>
132
+ [1]: 4 x1
133
+ [2]: 8 x1
134
+ [3]: 12 x1
135
+ """
136
+ dims = self.dimensions_unsafe
137
+ other_dims = other.dimensions_unsafe
138
+ dims_in_common = [dim for dim in dims if dim in other_dims]
139
+
140
+ data = (
141
+ self.data.join(
142
+ other.data,
143
+ on=dims_in_common if len(dims_in_common) > 0 else None,
144
+ how="inner" if dims_in_common else "cross",
145
+ )
146
+ .with_columns(pl.col(COEF_KEY) * pl.col(COEF_KEY + "_right"))
147
+ .drop(COEF_KEY + "_right")
148
+ .rename({VAR_KEY + "_right": QUAD_VAR_KEY})
149
+ # Swap VAR_KEY and QUAD_VAR_KEY so that VAR_KEy is always the larger one
150
+ .with_columns(
151
+ pl.when(pl.col(VAR_KEY) < pl.col(QUAD_VAR_KEY))
152
+ .then(pl.col(QUAD_VAR_KEY))
153
+ .otherwise(pl.col(VAR_KEY))
154
+ .alias(VAR_KEY),
155
+ pl.when(pl.col(VAR_KEY) < pl.col(QUAD_VAR_KEY))
156
+ .then(pl.col(VAR_KEY))
157
+ .otherwise(pl.col(QUAD_VAR_KEY))
158
+ .alias(QUAD_VAR_KEY),
159
+ )
160
+ )
161
+
162
+ data = _sum_like_terms(data)
163
+
164
+ return self._new(data)
165
+
166
+
31
167
  def _add_expressions_core(*expressions: "Expression") -> "Expression":
32
168
  # Mapping of how a sum of two expressions should propogate the unmatched strategy
33
169
  propogatation_strategies = {
@@ -116,7 +252,9 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
116
252
  not Config.disable_unmatched_checks
117
253
  ), "This code should not be reached when unmatched checks are disabled."
118
254
  outer_join = get_indices(left).join(
119
- get_indices(right), how="outer", on=dims
255
+ get_indices(right),
256
+ how="full" if POLARS_VERSION.major >= 1 else "outer",
257
+ on=dims,
120
258
  )
121
259
  if outer_join.get_column(dims[0]).null_count() > 0:
122
260
  raise PyoframeError(
@@ -159,11 +297,24 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
159
297
  propogate_strat = expressions[0].unmatched_strategy
160
298
  expr_data = [expr.data for expr in expressions]
161
299
 
300
+ # Add quadratic column if it is needed and doesn't already exist
301
+ if any(QUAD_VAR_KEY in df.columns for df in expr_data):
302
+ expr_data = [
303
+ (
304
+ df.with_columns(pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE))
305
+ if QUAD_VAR_KEY not in df.columns
306
+ else df
307
+ )
308
+ for df in expr_data
309
+ ]
310
+
162
311
  # Sort columns to allow for concat
163
- expr_data = [e.select(sorted(e.columns)) for e in expr_data]
312
+ expr_data = [
313
+ e.select(dims + [c for c in e.columns if c not in dims]) for e in expr_data
314
+ ]
164
315
 
165
316
  data = pl.concat(expr_data, how="vertical_relaxed")
166
- data = data.group_by(dims + [VAR_KEY], maintain_order=True).sum()
317
+ data = _sum_like_terms(data)
167
318
 
168
319
  new_expr = expressions[0]._new(data)
169
320
  new_expr.unmatched_strategy = propogate_strat
@@ -188,6 +339,7 @@ def _add_dimension(self: "Expression", target: "Expression") -> "Expression":
188
339
  return self
189
340
 
190
341
  if not set(missing_dims) <= set(self.allowed_new_dims):
342
+ # TODO actually suggest using e.g. .add_dim("a", "b") instead of just "use .add_dim()"
191
343
  raise PyoframeError(
192
344
  f"Dataframe has missing dimensions {missing_dims}. If this is intentional, use .add_dim()\n{self.data}"
193
345
  )
@@ -210,6 +362,20 @@ def _add_dimension(self: "Expression", target: "Expression") -> "Expression":
210
362
  return self._new(result)
211
363
 
212
364
 
365
+ def _sum_like_terms(df: pl.DataFrame) -> pl.DataFrame:
366
+ """Combines terms with the same variables. Removes quadratic column if they all happen to cancel."""
367
+ dims = [c for c in df.columns if c not in RESERVED_COL_KEYS]
368
+ var_cols = [VAR_KEY] + ([QUAD_VAR_KEY] if QUAD_VAR_KEY in df.columns else [])
369
+ df = (
370
+ df.group_by(dims + var_cols, maintain_order=True)
371
+ .sum()
372
+ .filter(pl.col(COEF_KEY) != 0)
373
+ )
374
+ if QUAD_VAR_KEY in df.columns and (df.get_column(QUAD_VAR_KEY) == CONST_TERM).all():
375
+ df = df.drop(QUAD_VAR_KEY)
376
+ return df
377
+
378
+
213
379
  def _get_dimensions(df: pl.DataFrame) -> Optional[List[str]]:
214
380
  """
215
381
  Returns the dimensions of the DataFrame. Reserved columns do not count as dimensions.
pyoframe/constants.py CHANGED
@@ -1,18 +1,14 @@
1
1
  """
2
2
  File containing shared constants used across the package.
3
-
4
- Code is heavily based on the `linopy` package by Fabian Hofmann.
5
-
6
- MIT License
7
3
  """
8
4
 
9
5
  import importlib.metadata
10
6
  import typing
11
- from dataclasses import dataclass
12
7
  from enum import Enum
13
- from typing import Literal, Optional, Union
8
+ from typing import Literal, Optional
14
9
 
15
10
  import polars as pl
11
+ import pyoptinterface as poi
16
12
  from packaging import version
17
13
 
18
14
  # We want to try and support multiple major versions of polars
@@ -20,22 +16,23 @@ POLARS_VERSION = version.parse(importlib.metadata.version("polars"))
20
16
 
21
17
  COEF_KEY = "__coeff"
22
18
  VAR_KEY = "__variable_id"
19
+ QUAD_VAR_KEY = "__quadratic_variable_id"
23
20
  CONSTRAINT_KEY = "__constraint_id"
24
21
  SOLUTION_KEY = "solution"
25
22
  DUAL_KEY = "dual"
26
- RC_COL = "RC"
27
- SLACK_COL = "slack"
23
+ SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs"]
24
+ KEY_TYPE = pl.UInt32
28
25
 
26
+ # Variable ID for constant terms. This variable ID is reserved.
29
27
  CONST_TERM = 0
30
28
 
31
29
  RESERVED_COL_KEYS = (
32
30
  COEF_KEY,
33
31
  VAR_KEY,
32
+ QUAD_VAR_KEY,
34
33
  CONSTRAINT_KEY,
35
34
  SOLUTION_KEY,
36
35
  DUAL_KEY,
37
- RC_COL,
38
- SLACK_COL,
39
36
  )
40
37
 
41
38
 
@@ -52,9 +49,16 @@ class _ConfigMeta(type):
52
49
 
53
50
 
54
51
  class Config(metaclass=_ConfigMeta):
52
+ """
53
+ Configuration options that apply to the entire library.
54
+ """
55
+
56
+ default_solver: Optional[SUPPORTED_SOLVER_TYPES] = None
55
57
  disable_unmatched_checks: bool = False
56
- print_float_precision: Optional[int] = 5
58
+ float_to_str_precision: Optional[int] = 5
57
59
  print_uses_variable_names: bool = True
60
+ print_max_line_length: int = 80
61
+ print_max_lines: int = 15
58
62
  # Number of elements to show when printing a set to the console (additional elements are replaced with ...)
59
63
  print_max_set_elements: int = 50
60
64
  enable_is_duplicated_expression_safety_check: bool = False
@@ -73,17 +77,45 @@ class ConstraintSense(Enum):
73
77
  GE = ">="
74
78
  EQ = "="
75
79
 
80
+ def to_poi(self):
81
+ if self == ConstraintSense.LE:
82
+ return poi.ConstraintSense.LessEqual
83
+ elif self == ConstraintSense.EQ:
84
+ return poi.ConstraintSense.Equal
85
+ elif self == ConstraintSense.GE:
86
+ return poi.ConstraintSense.GreaterEqual
87
+ else:
88
+ raise ValueError(f"Invalid constraint type: {self}") # pragma: no cover
89
+
76
90
 
77
91
  class ObjSense(Enum):
78
92
  MIN = "min"
79
93
  MAX = "max"
80
94
 
95
+ def to_poi(self):
96
+ if self == ObjSense.MIN:
97
+ return poi.ObjectiveSense.Minimize
98
+ elif self == ObjSense.MAX:
99
+ return poi.ObjectiveSense.Maximize
100
+ else:
101
+ raise ValueError(f"Invalid objective sense: {self}") # pragma: no cover
102
+
81
103
 
82
104
  class VType(Enum):
83
105
  CONTINUOUS = "continuous"
84
106
  BINARY = "binary"
85
107
  INTEGER = "integer"
86
108
 
109
+ def to_poi(self):
110
+ if self == VType.CONTINUOUS:
111
+ return poi.VariableDomain.Continuous
112
+ elif self == VType.BINARY:
113
+ return poi.VariableDomain.Binary
114
+ elif self == VType.INTEGER:
115
+ return poi.VariableDomain.Integer
116
+ else:
117
+ raise ValueError(f"Invalid variable type: {self}") # pragma: no cover
118
+
87
119
 
88
120
  class UnmatchedStrategy(Enum):
89
121
  UNSET = "not_set"
@@ -99,193 +131,5 @@ for enum, type in [(ObjSense, ObjSenseValue), (VType, VTypeValue)]:
99
131
  assert set(typing.get_args(type)) == {vtype.value for vtype in enum}
100
132
 
101
133
 
102
- class ModelStatus(Enum):
103
- """
104
- Model status.
105
-
106
- The set of possible model status is a superset of the solver status
107
- set.
108
- """
109
-
110
- ok = "ok"
111
- warning = "warning"
112
- error = "error"
113
- aborted = "aborted"
114
- unknown = "unknown"
115
- initialized = "initialized"
116
-
117
-
118
- class SolverStatus(Enum):
119
- """
120
- Solver status.
121
- """
122
-
123
- ok = "ok"
124
- warning = "warning"
125
- error = "error"
126
- aborted = "aborted"
127
- unknown = "unknown"
128
-
129
- @classmethod
130
- def process(cls, status: str) -> "SolverStatus":
131
- try:
132
- return cls(status)
133
- except ValueError:
134
- return cls("unknown")
135
-
136
- @classmethod
137
- def from_termination_condition(
138
- cls, termination_condition: "TerminationCondition"
139
- ) -> "SolverStatus":
140
- for (
141
- status,
142
- termination_conditions,
143
- ) in STATUS_TO_TERMINATION_CONDITION_MAP.items():
144
- if termination_condition in termination_conditions:
145
- return status
146
- return cls("unknown")
147
-
148
-
149
- class TerminationCondition(Enum):
150
- """
151
- Termination condition of the solver.
152
- """
153
-
154
- # UNKNOWN
155
- unknown = "unknown"
156
-
157
- # OK
158
- optimal = "optimal"
159
- time_limit = "time_limit"
160
- iteration_limit = "iteration_limit"
161
- terminated_by_limit = "terminated_by_limit"
162
- suboptimal = "suboptimal"
163
-
164
- # WARNING
165
- unbounded = "unbounded"
166
- infeasible = "infeasible"
167
- infeasible_or_unbounded = "infeasible_or_unbounded"
168
- other = "other"
169
-
170
- # ERROR
171
- internal_solver_error = "internal_solver_error"
172
- error = "error"
173
-
174
- # ABORTED
175
- user_interrupt = "user_interrupt"
176
- resource_interrupt = "resource_interrupt"
177
- licensing_problems = "licensing_problems"
178
-
179
- @classmethod
180
- def process(
181
- cls, termination_condition: Union[str, "TerminationCondition"]
182
- ) -> "TerminationCondition":
183
- try:
184
- return cls(termination_condition)
185
- except ValueError:
186
- return cls("unknown")
187
-
188
-
189
- STATUS_TO_TERMINATION_CONDITION_MAP = {
190
- SolverStatus.ok: [
191
- TerminationCondition.optimal,
192
- TerminationCondition.iteration_limit,
193
- TerminationCondition.time_limit,
194
- TerminationCondition.terminated_by_limit,
195
- TerminationCondition.suboptimal,
196
- ],
197
- SolverStatus.warning: [
198
- TerminationCondition.unbounded,
199
- TerminationCondition.infeasible,
200
- TerminationCondition.infeasible_or_unbounded,
201
- TerminationCondition.other,
202
- ],
203
- SolverStatus.error: [
204
- TerminationCondition.internal_solver_error,
205
- TerminationCondition.error,
206
- ],
207
- SolverStatus.aborted: [
208
- TerminationCondition.user_interrupt,
209
- TerminationCondition.resource_interrupt,
210
- TerminationCondition.licensing_problems,
211
- ],
212
- SolverStatus.unknown: [TerminationCondition.unknown],
213
- }
214
-
215
-
216
- @dataclass
217
- class Status:
218
- """
219
- Status and termination condition of the solver.
220
- """
221
-
222
- status: SolverStatus
223
- termination_condition: TerminationCondition
224
-
225
- @classmethod
226
- def process(cls, status: str, termination_condition: str) -> "Status":
227
- return cls(
228
- status=SolverStatus.process(status),
229
- termination_condition=TerminationCondition.process(termination_condition),
230
- )
231
-
232
- @classmethod
233
- def from_termination_condition(
234
- cls, termination_condition: Union["TerminationCondition", str]
235
- ) -> "Status":
236
- termination_condition = TerminationCondition.process(termination_condition)
237
- solver_status = SolverStatus.from_termination_condition(termination_condition)
238
- return cls(solver_status, termination_condition)
239
-
240
- @property
241
- def is_ok(self) -> bool:
242
- return self.status == SolverStatus.ok
243
-
244
-
245
- @dataclass
246
- class Solution:
247
- """
248
- Solution returned by the solver.
249
- """
250
-
251
- primal: pl.DataFrame
252
- dual: Optional[pl.DataFrame]
253
- objective: float
254
-
255
-
256
- @dataclass
257
- class Result:
258
- """
259
- Result of the optimization.
260
- """
261
-
262
- status: Status
263
- solution: Optional[Solution] = None
264
-
265
- def __repr__(self) -> str:
266
- res = (
267
- f"Status: {self.status.status.value}\n"
268
- f"Termination condition: {self.status.termination_condition.value}\n"
269
- )
270
- if self.solution is not None:
271
- res += (
272
- f"Solution: {len(self.solution.primal)} primals, {len(self.solution.dual) if self.solution.dual is not None else 0} duals\n"
273
- f"Objective: {self.solution.objective:.2e}\n"
274
- )
275
-
276
- return res
277
-
278
- def info(self):
279
- status = self.status
280
-
281
- if status.is_ok:
282
- if status.termination_condition == TerminationCondition.suboptimal:
283
- print(f"Optimization solution is sub-optimal: \n{self}\n")
284
- else:
285
- print(f" Optimization successful: \n{self}\n")
286
- else:
287
- print(f"Optimization failed: \n{self}\n")
288
-
289
-
290
134
  class PyoframeError(Exception):
291
135
  pass