pyoframe 0.0.4__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 ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Pyoframe's public API.
3
+ Also applies the monkey patch to the DataFrame libraries.
4
+ """
5
+
6
+ from pyoframe.monkey_patch import patch_dataframe_libraries
7
+ from pyoframe.constraints import sum, sum_by, Set, Constraint
8
+ from pyoframe.constants import Config
9
+ from pyoframe.variables import Variable
10
+ from pyoframe.model import Model
11
+ from pyoframe.constants import VType
12
+
13
+ patch_dataframe_libraries()
14
+
15
+ __all__ = ["sum", "sum_by", "Variable", "Model", "Set", "VType", "Config", "Constraint"]
@@ -0,0 +1,228 @@
1
+ from typing import TYPE_CHECKING, List, Optional
2
+ import polars as pl
3
+
4
+ from pyoframe.constants import (
5
+ COEF_KEY,
6
+ RESERVED_COL_KEYS,
7
+ VAR_KEY,
8
+ UnmatchedStrategy,
9
+ Config,
10
+ )
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from pyoframe.constraints import Expression
14
+
15
+
16
+ class PyoframeError(Exception):
17
+ pass
18
+
19
+
20
+ def _add_expressions(*expressions: "Expression") -> "Expression":
21
+ try:
22
+ return _add_expressions_core(*expressions)
23
+ except PyoframeError as error:
24
+ raise PyoframeError(
25
+ "Failed to add expressions:\n"
26
+ + " + ".join(
27
+ e.to_str(include_header=True, include_data=False) for e in expressions
28
+ )
29
+ + "\nDue to error:\n"
30
+ + str(error)
31
+ ) from error
32
+
33
+
34
+ def _add_expressions_core(*expressions: "Expression") -> "Expression":
35
+ # Mapping of how a sum of two expressions should propogate the unmatched strategy
36
+ propogatation_strategies = {
37
+ (UnmatchedStrategy.DROP, UnmatchedStrategy.DROP): UnmatchedStrategy.DROP,
38
+ (
39
+ UnmatchedStrategy.UNSET,
40
+ UnmatchedStrategy.UNSET,
41
+ ): UnmatchedStrategy.UNSET,
42
+ (UnmatchedStrategy.KEEP, UnmatchedStrategy.KEEP): UnmatchedStrategy.KEEP,
43
+ (UnmatchedStrategy.DROP, UnmatchedStrategy.KEEP): UnmatchedStrategy.UNSET,
44
+ (UnmatchedStrategy.DROP, UnmatchedStrategy.UNSET): UnmatchedStrategy.DROP,
45
+ (UnmatchedStrategy.KEEP, UnmatchedStrategy.UNSET): UnmatchedStrategy.KEEP,
46
+ }
47
+
48
+ assert len(expressions) > 1, "Need at least two expressions to add together."
49
+
50
+ dims = expressions[0].dimensions
51
+
52
+ if dims is None:
53
+ requires_join = False
54
+ dims = []
55
+ elif Config.disable_unmatched_checks:
56
+ requires_join = any(
57
+ expr.unmatched_strategy
58
+ not in (UnmatchedStrategy.KEEP, UnmatchedStrategy.UNSET)
59
+ for expr in expressions
60
+ )
61
+ else:
62
+ requires_join = any(
63
+ expr.unmatched_strategy != UnmatchedStrategy.KEEP for expr in expressions
64
+ )
65
+
66
+ has_dim_conflict = any(
67
+ sorted(dims) != sorted(expr.dimensions_unsafe) for expr in expressions[1:]
68
+ )
69
+
70
+ # If we cannot use .concat compute the sum in a pairwise manner
71
+ if len(expressions) > 2 and (has_dim_conflict or requires_join):
72
+ result = expressions[0]
73
+ for expr in expressions[1:]:
74
+ result = _add_expressions_core(result, expr)
75
+ return result
76
+
77
+ if has_dim_conflict:
78
+ assert len(expressions) == 2
79
+ expressions = (
80
+ _add_dimension(expressions[0], expressions[1]),
81
+ _add_dimension(expressions[1], expressions[0]),
82
+ )
83
+ assert sorted(expressions[0].dimensions_unsafe) == sorted(
84
+ expressions[1].dimensions_unsafe
85
+ )
86
+
87
+ dims = expressions[0].dimensions_unsafe
88
+ # Check no dims conflict
89
+ assert all(
90
+ sorted(dims) == sorted(expr.dimensions_unsafe) for expr in expressions[1:]
91
+ )
92
+ if requires_join:
93
+ assert len(expressions) == 2
94
+ assert dims != []
95
+ left, right = expressions[0], expressions[1]
96
+
97
+ # Order so that drop always comes before keep, and keep always comes before default
98
+ if (left.unmatched_strategy, right.unmatched_strategy) in (
99
+ (UnmatchedStrategy.UNSET, UnmatchedStrategy.DROP),
100
+ (UnmatchedStrategy.UNSET, UnmatchedStrategy.KEEP),
101
+ (UnmatchedStrategy.KEEP, UnmatchedStrategy.DROP),
102
+ ):
103
+ left, right = right, left
104
+
105
+ def get_indices(expr):
106
+ return expr.data.select(dims).unique(maintain_order=True)
107
+
108
+ left_data, right_data = left.data, right.data
109
+
110
+ strat = (left.unmatched_strategy, right.unmatched_strategy)
111
+
112
+ propogate_strat = propogatation_strategies[strat]
113
+
114
+ if strat == (UnmatchedStrategy.DROP, UnmatchedStrategy.DROP):
115
+ left_data = left.data.join(get_indices(right), how="inner", on=dims)
116
+ right_data = right.data.join(get_indices(left), how="inner", on=dims)
117
+ elif strat == (UnmatchedStrategy.UNSET, UnmatchedStrategy.UNSET):
118
+ assert (
119
+ not Config.disable_unmatched_checks
120
+ ), "This code should not be reached when unmatched checks are disabled."
121
+ outer_join = get_indices(left).join(
122
+ get_indices(right), how="outer", on=dims
123
+ )
124
+ if outer_join.get_column(dims[0]).null_count() > 0:
125
+ raise PyoframeError(
126
+ "Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()\n"
127
+ + str(outer_join.filter(outer_join.get_column(dims[0]).is_null()))
128
+ )
129
+ if outer_join.get_column(dims[0] + "_right").null_count() > 0:
130
+ raise PyoframeError(
131
+ "Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()\n"
132
+ + str(
133
+ outer_join.filter(
134
+ outer_join.get_column(dims[0] + "_right").is_null()
135
+ )
136
+ )
137
+ )
138
+ elif strat == (UnmatchedStrategy.DROP, UnmatchedStrategy.KEEP):
139
+ left_data = get_indices(right).join(left.data, how="left", on=dims)
140
+ elif strat == (UnmatchedStrategy.DROP, UnmatchedStrategy.UNSET):
141
+ left_data = get_indices(right).join(left.data, how="left", on=dims)
142
+ if left_data.get_column(COEF_KEY).null_count() > 0:
143
+ raise PyoframeError(
144
+ "Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()\n"
145
+ + str(left_data.filter(left_data.get_column(COEF_KEY).is_null()))
146
+ )
147
+ elif strat == (UnmatchedStrategy.KEEP, UnmatchedStrategy.UNSET):
148
+ assert (
149
+ not Config.disable_unmatched_checks
150
+ ), "This code should not be reached when unmatched checks are disabled."
151
+ unmatched = right.data.join(get_indices(left), how="anti", on=dims)
152
+ if len(unmatched) > 0:
153
+ raise PyoframeError(
154
+ "Dataframe has unmatched values. If this is intentional, use .drop_unmatched() or .keep_unmatched()\n"
155
+ + str(unmatched)
156
+ )
157
+ else: # pragma: no cover
158
+ assert False, "This code should've never been reached!"
159
+
160
+ expr_data = [left_data, right_data]
161
+ else:
162
+ propogate_strat = expressions[0].unmatched_strategy
163
+ expr_data = [expr.data for expr in expressions]
164
+
165
+ # Sort columns to allow for concat
166
+ expr_data = [e.select(sorted(e.columns)) for e in expr_data]
167
+
168
+ data = pl.concat(expr_data, how="vertical_relaxed")
169
+ data = data.group_by(dims + [VAR_KEY], maintain_order=True).sum()
170
+
171
+ new_expr = expressions[0]._new(data)
172
+ new_expr.unmatched_strategy = propogate_strat
173
+
174
+ return new_expr
175
+
176
+
177
+ def _add_dimension(self: "Expression", target: "Expression") -> "Expression":
178
+ target_dims = target.dimensions
179
+ if target_dims is None:
180
+ return self
181
+ dims = self.dimensions
182
+ if dims is None:
183
+ dims_in_common = []
184
+ missing_dims = target_dims
185
+ else:
186
+ dims_in_common = [dim for dim in dims if dim in target_dims]
187
+ missing_dims = [dim for dim in target_dims if dim not in dims]
188
+
189
+ # We're already at the size of our target
190
+ if not missing_dims:
191
+ return self
192
+
193
+ if not set(missing_dims) <= set(self.allowed_new_dims):
194
+ raise PyoframeError(
195
+ f"Dataframe has missing dimensions {missing_dims}. If this is intentional, use .add_dim()\n{self.data}"
196
+ )
197
+
198
+ target_data = target.data.select(target_dims).unique(maintain_order=True)
199
+
200
+ if not dims_in_common:
201
+ return self._new(self.data.join(target_data, how="cross"))
202
+
203
+ # If drop, we just do an inner join to get into the shape of the other
204
+ if self.unmatched_strategy == UnmatchedStrategy.DROP:
205
+ return self._new(self.data.join(target_data, on=dims_in_common, how="inner"))
206
+
207
+ result = self.data.join(target_data, on=dims_in_common, how="left")
208
+ right_has_missing = result.get_column(missing_dims[0]).null_count() > 0
209
+ if right_has_missing:
210
+ raise PyoframeError(
211
+ f"Cannot add dimension {missing_dims} since it contains unmatched values. If this is intentional, consider using .drop_unmatched()"
212
+ )
213
+ return self._new(result)
214
+
215
+
216
+ def _get_dimensions(df: pl.DataFrame) -> Optional[List[str]]:
217
+ """
218
+ Returns the dimensions of the DataFrame. Reserved columns do not count as dimensions.
219
+ If there are no dimensions, returns None to force caller to handle this special case.
220
+
221
+ Examples:
222
+ >>> import polars as pl
223
+ >>> _get_dimensions(pl.DataFrame({"x": [1, 2, 3], "y": [1, 2, 3]}))
224
+ ['x', 'y']
225
+ >>> _get_dimensions(pl.DataFrame({"__variable_id": [1, 2, 3]}))
226
+ """
227
+ result = [col for col in df.columns if col not in RESERVED_COL_KEYS]
228
+ return result if result else None
pyoframe/constants.py ADDED
@@ -0,0 +1,280 @@
1
+ """
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
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+ import typing
12
+ from typing import Any, Literal, Optional, Union
13
+ import polars as pl
14
+
15
+
16
+ COEF_KEY = "__coeff"
17
+ VAR_KEY = "__variable_id"
18
+ CONSTRAINT_KEY = "__constraint_id"
19
+ SOLUTION_KEY = "solution"
20
+ DUAL_KEY = "dual"
21
+ NAME_COL = "__name"
22
+
23
+ CONST_TERM = 0
24
+
25
+ RESERVED_COL_KEYS = (
26
+ COEF_KEY,
27
+ VAR_KEY,
28
+ CONSTRAINT_KEY,
29
+ SOLUTION_KEY,
30
+ DUAL_KEY,
31
+ NAME_COL,
32
+ )
33
+
34
+
35
+ class _ConfigMeta(type):
36
+ """Metaclass for Config that stores the default values of all configuration options."""
37
+
38
+ def __init__(cls, name, bases, dct):
39
+ super().__init__(name, bases, dct)
40
+ cls._defaults = {
41
+ k: v
42
+ for k, v in dct.items()
43
+ if not k.startswith("_") and type(v) != classmethod
44
+ }
45
+
46
+
47
+ class Config(metaclass=_ConfigMeta):
48
+ disable_unmatched_checks: bool = False
49
+ print_float_precision: Optional[int] = 5
50
+ print_uses_variable_names: bool = True
51
+
52
+ @classmethod
53
+ def reset_defaults(cls):
54
+ """
55
+ Resets all configuration options to their default values.
56
+ """
57
+ for key, value in cls._defaults.items():
58
+ setattr(cls, key, value)
59
+
60
+
61
+ class ConstraintSense(Enum):
62
+ LE = "<="
63
+ GE = ">="
64
+ EQ = "="
65
+
66
+
67
+ class ObjSense(Enum):
68
+ MIN = "minimize"
69
+ MAX = "maximize"
70
+
71
+
72
+ class VType(Enum):
73
+ CONTINUOUS = "continuous"
74
+ BINARY = "binary"
75
+ INTEGER = "integer"
76
+
77
+
78
+ class UnmatchedStrategy(Enum):
79
+ UNSET = "not_set"
80
+ DROP = "drop"
81
+ KEEP = "keep"
82
+
83
+
84
+ # This is a hack to get the Literal type for VType
85
+ # See: https://stackoverflow.com/questions/67292470/type-hinting-enum-member-value-in-python
86
+ ObjSenseValue = Literal["minimize", "maximize"]
87
+ VTypeValue = Literal["continuous", "binary", "integer"]
88
+ for enum, type in [(ObjSense, ObjSenseValue), (VType, VTypeValue)]:
89
+ assert set(typing.get_args(type)) == {vtype.value for vtype in enum}
90
+
91
+
92
+ class ModelStatus(Enum):
93
+ """
94
+ Model status.
95
+
96
+ The set of possible model status is a superset of the solver status
97
+ set.
98
+ """
99
+
100
+ ok = "ok"
101
+ warning = "warning"
102
+ error = "error"
103
+ aborted = "aborted"
104
+ unknown = "unknown"
105
+ initialized = "initialized"
106
+
107
+
108
+ class SolverStatus(Enum):
109
+ """
110
+ Solver status.
111
+ """
112
+
113
+ ok = "ok"
114
+ warning = "warning"
115
+ error = "error"
116
+ aborted = "aborted"
117
+ unknown = "unknown"
118
+
119
+ @classmethod
120
+ def process(cls, status: str) -> "SolverStatus":
121
+ try:
122
+ return cls(status)
123
+ except ValueError:
124
+ return cls("unknown")
125
+
126
+ @classmethod
127
+ def from_termination_condition(
128
+ cls, termination_condition: "TerminationCondition"
129
+ ) -> "SolverStatus":
130
+ for status in STATUS_TO_TERMINATION_CONDITION_MAP:
131
+ if termination_condition in STATUS_TO_TERMINATION_CONDITION_MAP[status]:
132
+ return status
133
+ return cls("unknown")
134
+
135
+
136
+ class TerminationCondition(Enum):
137
+ """
138
+ Termination condition of the solver.
139
+ """
140
+
141
+ # UNKNOWN
142
+ unknown = "unknown"
143
+
144
+ # OK
145
+ optimal = "optimal"
146
+ time_limit = "time_limit"
147
+ iteration_limit = "iteration_limit"
148
+ terminated_by_limit = "terminated_by_limit"
149
+ suboptimal = "suboptimal"
150
+
151
+ # WARNING
152
+ unbounded = "unbounded"
153
+ infeasible = "infeasible"
154
+ infeasible_or_unbounded = "infeasible_or_unbounded"
155
+ other = "other"
156
+
157
+ # ERROR
158
+ internal_solver_error = "internal_solver_error"
159
+ error = "error"
160
+
161
+ # ABORTED
162
+ user_interrupt = "user_interrupt"
163
+ resource_interrupt = "resource_interrupt"
164
+ licensing_problems = "licensing_problems"
165
+
166
+ @classmethod
167
+ def process(
168
+ cls, termination_condition: Union[str, "TerminationCondition"]
169
+ ) -> "TerminationCondition":
170
+ try:
171
+ return cls(termination_condition)
172
+ except ValueError:
173
+ return cls("unknown")
174
+
175
+
176
+ STATUS_TO_TERMINATION_CONDITION_MAP = {
177
+ SolverStatus.ok: [
178
+ TerminationCondition.optimal,
179
+ TerminationCondition.iteration_limit,
180
+ TerminationCondition.time_limit,
181
+ TerminationCondition.terminated_by_limit,
182
+ TerminationCondition.suboptimal,
183
+ ],
184
+ SolverStatus.warning: [
185
+ TerminationCondition.unbounded,
186
+ TerminationCondition.infeasible,
187
+ TerminationCondition.infeasible_or_unbounded,
188
+ TerminationCondition.other,
189
+ ],
190
+ SolverStatus.error: [
191
+ TerminationCondition.internal_solver_error,
192
+ TerminationCondition.error,
193
+ ],
194
+ SolverStatus.aborted: [
195
+ TerminationCondition.user_interrupt,
196
+ TerminationCondition.resource_interrupt,
197
+ TerminationCondition.licensing_problems,
198
+ ],
199
+ SolverStatus.unknown: [TerminationCondition.unknown],
200
+ }
201
+
202
+
203
+ @dataclass
204
+ class Status:
205
+ """
206
+ Status and termination condition of the solver.
207
+ """
208
+
209
+ status: SolverStatus
210
+ termination_condition: TerminationCondition
211
+
212
+ @classmethod
213
+ def process(cls, status: str, termination_condition: str) -> "Status":
214
+ return cls(
215
+ status=SolverStatus.process(status),
216
+ termination_condition=TerminationCondition.process(termination_condition),
217
+ )
218
+
219
+ @classmethod
220
+ def from_termination_condition(
221
+ cls, termination_condition: Union["TerminationCondition", str]
222
+ ) -> "Status":
223
+ termination_condition = TerminationCondition.process(termination_condition)
224
+ solver_status = SolverStatus.from_termination_condition(termination_condition)
225
+ return cls(solver_status, termination_condition)
226
+
227
+ @property
228
+ def is_ok(self) -> bool:
229
+ return self.status == SolverStatus.ok
230
+
231
+
232
+ @dataclass
233
+ class Solution:
234
+ """
235
+ Solution returned by the solver.
236
+ """
237
+
238
+ primal: pl.DataFrame
239
+ dual: Optional[pl.DataFrame]
240
+ objective: float
241
+
242
+
243
+ @dataclass
244
+ class Result:
245
+ """
246
+ Result of the optimization.
247
+ """
248
+
249
+ status: Status
250
+ solution: Optional[Solution] = None
251
+ solver_model: Optional[Any] = None
252
+
253
+ def __repr__(self) -> str:
254
+ solver_model_string = (
255
+ "not available" if self.solver_model is None else "available"
256
+ )
257
+
258
+ res = (
259
+ f"Status: {self.status.status.value}\n"
260
+ f"Termination condition: {self.status.termination_condition.value}\n"
261
+ )
262
+ if self.solution is not None:
263
+ res += (
264
+ f"Solution: {len(self.solution.primal)} primals, {len(self.solution.dual) if self.solution.dual is not None else 0} duals\n"
265
+ f"Objective: {self.solution.objective:.2e}\n"
266
+ )
267
+ res += f"Solver model: {solver_model_string}\n"
268
+
269
+ return res
270
+
271
+ def info(self):
272
+ status = self.status
273
+
274
+ if status.is_ok:
275
+ if status.termination_condition == TerminationCondition.suboptimal:
276
+ print(f"Optimization solution is sub-optimal: \n{self}\n")
277
+ else:
278
+ print(f" Optimization successful: \n{self}\n")
279
+ else:
280
+ print(f"Optimization failed: \n{self}\n")