pyoframe 0.0.4__tar.gz

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-0.0.4/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright 2024 Bravos Power
4
+ Copyright 2021-2023 Fabian Hofmann
5
+ Copyright 2015-2021 PyPSA Developers
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.1
2
+ Name: pyoframe
3
+ Version: 0.0.4
4
+ Summary: Blazing fast linear program interface
5
+ Author-email: Bravos Power <dev@bravospower.com>
6
+ Project-URL: Homepage, https://bravos-power.github.io/pyoframe/
7
+ Project-URL: documentation, https://bravos-power.github.io/pyoframe/
8
+ Project-URL: repository, https://github.com/Bravos-Power/pyoframe/
9
+ Project-URL: Issues, https://github.com/Bravos-Power/pyoframe/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Natural Language :: English
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: polars==0.20.13
19
+ Requires-Dist: numpy
20
+ Requires-Dist: pyarrow
21
+ Requires-Dist: pandas
22
+ Provides-Extra: dev
23
+ Requires-Dist: black; extra == "dev"
24
+ Requires-Dist: bumpver; extra == "dev"
25
+ Requires-Dist: isort; extra == "dev"
26
+ Requires-Dist: pip-tools; extra == "dev"
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Requires-Dist: pre-commit; extra == "dev"
30
+ Requires-Dist: gurobipy; extra == "dev"
31
+ Provides-Extra: docs
32
+ Requires-Dist: mkdocs-material==9.*; extra == "docs"
33
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
34
+ Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == "docs"
35
+ Requires-Dist: mkdocs-git-committers-plugin-2; extra == "docs"
36
+ Requires-Dist: mkdocs-gen-files; extra == "docs"
37
+ Requires-Dist: mkdocs-section-index; extra == "docs"
38
+ Requires-Dist: mkdocs-literate-nav; extra == "docs"
39
+
40
+ # Pyoframe: Fast and low-memory linear programming models
41
+
42
+ [![codecov](https://codecov.io/gh/Bravos-Power/pyoframe/graph/badge.svg?token=8258XESRYQ)](https://codecov.io/gh/Bravos-Power/pyoframe)
43
+ [![Build](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml/badge.svg)](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml)
44
+ [![Docs](https://github.com/Bravos-Power/pyoframe/actions/workflows/publish_doc.yml/badge.svg)](https://Bravos-Power.github.io/pyoframe/reference/)
45
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
46
+ [![Issues Needing Triage](https://img.shields.io/github/issues-search/Bravos-Power/pyoframe?query=no%3Alabel%20is%3Aopen&label=Needs%20Triage)](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+no%3Alabel)
47
+ [![Open Bugs](https://img.shields.io/github/issues-search/Bravos-Power/pyoframe?query=label%3Abug%20is%3Aopen&label=Open%20Bugs)](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+label%3Abug)
48
+
49
+
50
+ A library to rapidly and memory-efficiently formulate large and sparse optimization models using Pandas or Polars dataframes.
51
+
52
+ ## Contribute
53
+
54
+ Contributions are welcome! See [`CONTRIBUTE.md`](./CONTRIBUTE.md).
55
+
56
+ ## Acknowledgments
57
+
58
+ Martin Staadecker first created this library while working for [Bravos Power](https://www.bravospower.com/) The library takes inspiration from Linopy and Pyomo, two prior libraries for optimization for which we are thankful.
@@ -0,0 +1,19 @@
1
+ # Pyoframe: Fast and low-memory linear programming models
2
+
3
+ [![codecov](https://codecov.io/gh/Bravos-Power/pyoframe/graph/badge.svg?token=8258XESRYQ)](https://codecov.io/gh/Bravos-Power/pyoframe)
4
+ [![Build](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml/badge.svg)](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml)
5
+ [![Docs](https://github.com/Bravos-Power/pyoframe/actions/workflows/publish_doc.yml/badge.svg)](https://Bravos-Power.github.io/pyoframe/reference/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Issues Needing Triage](https://img.shields.io/github/issues-search/Bravos-Power/pyoframe?query=no%3Alabel%20is%3Aopen&label=Needs%20Triage)](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+no%3Alabel)
8
+ [![Open Bugs](https://img.shields.io/github/issues-search/Bravos-Power/pyoframe?query=label%3Abug%20is%3Aopen&label=Open%20Bugs)](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+label%3Abug)
9
+
10
+
11
+ A library to rapidly and memory-efficiently formulate large and sparse optimization models using Pandas or Polars dataframes.
12
+
13
+ ## Contribute
14
+
15
+ Contributions are welcome! See [`CONTRIBUTE.md`](./CONTRIBUTE.md).
16
+
17
+ ## Acknowledgments
18
+
19
+ Martin Staadecker first created this library while working for [Bravos Power](https://www.bravospower.com/) The library takes inspiration from Linopy and Pyomo, two prior libraries for optimization for which we are thankful.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyoframe"
7
+ version = "0.0.4"
8
+ authors = [{ name = "Bravos Power", email = "dev@bravospower.com" }]
9
+ description = "Blazing fast linear program interface"
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Operating System :: OS Independent",
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Natural Language :: English",
18
+ ]
19
+ dependencies = ["polars==0.20.13", "numpy", "pyarrow", "pandas"]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "black",
24
+ "bumpver",
25
+ "isort",
26
+ "pip-tools",
27
+ "pytest",
28
+ "pytest-cov",
29
+ "pre-commit",
30
+ "gurobipy",
31
+ ]
32
+ docs = [
33
+ "mkdocs-material==9.*",
34
+ "mkdocstrings[python]",
35
+ "mkdocs-git-revision-date-localized-plugin",
36
+ "mkdocs-git-committers-plugin-2",
37
+ "mkdocs-gen-files",
38
+ "mkdocs-section-index",
39
+ "mkdocs-literate-nav",
40
+ ]
41
+
42
+ [tool.isort]
43
+ profile = "black"
44
+
45
+ [project.urls]
46
+ Homepage = "https://bravos-power.github.io/pyoframe/"
47
+ documentation = "https://bravos-power.github.io/pyoframe/"
48
+ repository = "https://github.com/Bravos-Power/pyoframe/"
49
+ Issues = "https://github.com/Bravos-Power/pyoframe/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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")