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 +15 -0
- pyoframe/_arithmetic.py +228 -0
- pyoframe/constants.py +280 -0
- pyoframe/constraints.py +911 -0
- pyoframe/io.py +149 -0
- pyoframe/io_mappers.py +206 -0
- pyoframe/model.py +92 -0
- pyoframe/model_element.py +116 -0
- pyoframe/monkey_patch.py +54 -0
- pyoframe/objective.py +42 -0
- pyoframe/solvers.py +186 -0
- pyoframe/util.py +271 -0
- pyoframe/variables.py +193 -0
- pyoframe-0.0.4.dist-info/LICENSE +23 -0
- pyoframe-0.0.4.dist-info/METADATA +58 -0
- pyoframe-0.0.4.dist-info/RECORD +18 -0
- pyoframe-0.0.4.dist-info/WHEEL +5 -0
- pyoframe-0.0.4.dist-info/top_level.txt +1 -0
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"]
|
pyoframe/_arithmetic.py
ADDED
|
@@ -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")
|