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 +23 -0
- pyoframe-0.0.4/PKG-INFO +58 -0
- pyoframe-0.0.4/README.md +19 -0
- pyoframe-0.0.4/pyproject.toml +49 -0
- pyoframe-0.0.4/setup.cfg +4 -0
- pyoframe-0.0.4/src/pyoframe/__init__.py +15 -0
- pyoframe-0.0.4/src/pyoframe/_arithmetic.py +228 -0
- pyoframe-0.0.4/src/pyoframe/constants.py +280 -0
- pyoframe-0.0.4/src/pyoframe/constraints.py +911 -0
- pyoframe-0.0.4/src/pyoframe/io.py +149 -0
- pyoframe-0.0.4/src/pyoframe/io_mappers.py +206 -0
- pyoframe-0.0.4/src/pyoframe/model.py +92 -0
- pyoframe-0.0.4/src/pyoframe/model_element.py +116 -0
- pyoframe-0.0.4/src/pyoframe/monkey_patch.py +54 -0
- pyoframe-0.0.4/src/pyoframe/objective.py +42 -0
- pyoframe-0.0.4/src/pyoframe/solvers.py +186 -0
- pyoframe-0.0.4/src/pyoframe/util.py +271 -0
- pyoframe-0.0.4/src/pyoframe/variables.py +193 -0
- pyoframe-0.0.4/src/pyoframe.egg-info/PKG-INFO +58 -0
- pyoframe-0.0.4/src/pyoframe.egg-info/SOURCES.txt +25 -0
- pyoframe-0.0.4/src/pyoframe.egg-info/dependency_links.txt +1 -0
- pyoframe-0.0.4/src/pyoframe.egg-info/requires.txt +23 -0
- pyoframe-0.0.4/src/pyoframe.egg-info/top_level.txt +1 -0
- pyoframe-0.0.4/tests/test_arithmetic.py +440 -0
- pyoframe-0.0.4/tests/test_examples.py +148 -0
- pyoframe-0.0.4/tests/test_io.py +55 -0
- pyoframe-0.0.4/tests/test_operations.py +51 -0
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.
|
pyoframe-0.0.4/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://codecov.io/gh/Bravos-Power/pyoframe)
|
|
43
|
+
[](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml)
|
|
44
|
+
[](https://Bravos-Power.github.io/pyoframe/reference/)
|
|
45
|
+
[](https://opensource.org/licenses/MIT)
|
|
46
|
+
[](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+no%3Alabel)
|
|
47
|
+
[](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.
|
pyoframe-0.0.4/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Pyoframe: Fast and low-memory linear programming models
|
|
2
|
+
|
|
3
|
+
[](https://codecov.io/gh/Bravos-Power/pyoframe)
|
|
4
|
+
[](https://github.com/Bravos-Power/pyoframe/actions/workflows/ci.yml)
|
|
5
|
+
[](https://Bravos-Power.github.io/pyoframe/reference/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/Bravos-Power/pyoframe/issues?q=is%3Aopen+is%3Aissue+no%3Alabel)
|
|
8
|
+
[](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"
|
pyoframe-0.0.4/setup.cfg
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
|
|
@@ -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")
|