pyoframe 0.0.4__py3-none-any.whl → 0.0.5__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 +12 -3
- pyoframe/_arithmetic.py +2 -5
- pyoframe/constants.py +15 -12
- pyoframe/{constraints.py → core.py} +490 -74
- pyoframe/io.py +51 -25
- pyoframe/io_mappers.py +49 -18
- pyoframe/model.py +65 -42
- pyoframe/model_element.py +124 -18
- pyoframe/monkey_patch.py +2 -2
- pyoframe/objective.py +16 -13
- pyoframe/solvers.py +276 -109
- pyoframe/user_defined.py +60 -0
- pyoframe/util.py +56 -55
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.5.dist-info}/METADATA +9 -2
- pyoframe-0.0.5.dist-info/RECORD +18 -0
- pyoframe/variables.py +0 -193
- pyoframe-0.0.4.dist-info/RECORD +0 -18
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.5.dist-info}/LICENSE +0 -0
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.5.dist-info}/WHEEL +0 -0
- {pyoframe-0.0.4.dist-info → pyoframe-0.0.5.dist-info}/top_level.txt +0 -0
pyoframe/io.py
CHANGED
|
@@ -6,16 +6,17 @@ from io import TextIOWrapper
|
|
|
6
6
|
from tempfile import NamedTemporaryFile
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, Union
|
|
9
|
+
from tqdm import tqdm
|
|
9
10
|
|
|
10
|
-
from pyoframe.constants import VAR_KEY,
|
|
11
|
-
from pyoframe.
|
|
12
|
-
from pyoframe.variables import Variable
|
|
11
|
+
from pyoframe.constants import CONST_TERM, VAR_KEY, ObjSense
|
|
12
|
+
from pyoframe.core import Constraint, Variable
|
|
13
13
|
from pyoframe.io_mappers import (
|
|
14
14
|
Base62ConstMapper,
|
|
15
15
|
Base62VarMapper,
|
|
16
16
|
IOMappers,
|
|
17
17
|
Mapper,
|
|
18
18
|
NamedMapper,
|
|
19
|
+
NamedVariableMapper,
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING: # pragma: no cover
|
|
@@ -28,63 +29,88 @@ def objective_to_file(m: "Model", f: TextIOWrapper, var_map):
|
|
|
28
29
|
"""
|
|
29
30
|
Write out the objective of a model to a lp file.
|
|
30
31
|
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
if m.objective is None:
|
|
33
|
+
return
|
|
34
|
+
objective_sense = "minimize" if m.sense == ObjSense.MIN else "maximize"
|
|
35
|
+
f.write(f"{objective_sense}\n\nobj:\n\n")
|
|
36
|
+
result = m.objective.to_str(
|
|
37
|
+
var_map=var_map, include_prefix=False, include_const_variable=True
|
|
38
|
+
)
|
|
39
|
+
f.write(result)
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
def constraints_to_file(m: "Model", f: TextIOWrapper, var_map, const_map):
|
|
39
|
-
for constraint in create_section(
|
|
40
|
-
|
|
43
|
+
for constraint in create_section(
|
|
44
|
+
tqdm(m.constraints, desc="Writing constraints to file"), f, "s.t."
|
|
45
|
+
):
|
|
46
|
+
f.write(constraint.to_str(var_map=var_map, const_map=const_map) + "\n")
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
def bounds_to_file(m: "Model", f, var_map):
|
|
44
50
|
"""
|
|
45
51
|
Write out variables of a model to a lp file.
|
|
46
52
|
"""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
if (m.objective is not None and m.objective.has_constant) or len(m.variables) != 0:
|
|
54
|
+
f.write("\n\nbounds\n\n")
|
|
55
|
+
if m.objective is not None and m.objective.has_constant:
|
|
56
|
+
const_term_df = pl.DataFrame(
|
|
57
|
+
{VAR_KEY: [CONST_TERM]}, schema={VAR_KEY: pl.UInt32}
|
|
58
|
+
)
|
|
59
|
+
f.write(f"{var_map.apply(const_term_df).item()} = 1\n")
|
|
60
|
+
|
|
61
|
+
for variable in tqdm(m.variables, desc="Writing bounds to file"):
|
|
62
|
+
terms = []
|
|
63
|
+
|
|
64
|
+
if variable.lb != 0:
|
|
65
|
+
terms.append(pl.lit(f"{variable.lb:.12g} <= "))
|
|
66
|
+
|
|
67
|
+
terms.append(VAR_KEY)
|
|
68
|
+
|
|
69
|
+
if variable.ub != float("inf"):
|
|
70
|
+
terms.append(pl.lit(f" <= {variable.ub:.12g}"))
|
|
71
|
+
|
|
72
|
+
terms.append(pl.lit("\n"))
|
|
73
|
+
|
|
74
|
+
if len(terms) < 3:
|
|
75
|
+
continue
|
|
50
76
|
|
|
51
77
|
df = (
|
|
52
78
|
var_map.apply(variable.data, to_col=None)
|
|
53
|
-
.select(
|
|
54
|
-
pl.concat_str(
|
|
55
|
-
pl.lit(f"{lb} <= "), VAR_KEY, pl.lit(f" <= {ub}\n")
|
|
56
|
-
).str.concat("")
|
|
57
|
-
)
|
|
79
|
+
.select(pl.concat_str(terms).str.concat(""))
|
|
58
80
|
.item()
|
|
59
81
|
)
|
|
60
82
|
|
|
61
|
-
f.
|
|
83
|
+
f.write(df)
|
|
62
84
|
|
|
63
85
|
|
|
64
86
|
def binaries_to_file(m: "Model", f, var_map: Mapper):
|
|
65
87
|
"""
|
|
66
88
|
Write out binaries of a model to a lp file.
|
|
67
89
|
"""
|
|
68
|
-
for variable in create_section(
|
|
90
|
+
for variable in create_section(
|
|
91
|
+
tqdm(m.binary_variables, "Writing binary variables to file"), f, "binary"
|
|
92
|
+
):
|
|
69
93
|
lines = (
|
|
70
94
|
var_map.apply(variable.data, to_col=None)
|
|
71
95
|
.select(pl.col(VAR_KEY).str.concat("\n"))
|
|
72
96
|
.item()
|
|
73
97
|
)
|
|
74
|
-
f.
|
|
98
|
+
f.write(lines + "\n")
|
|
75
99
|
|
|
76
100
|
|
|
77
101
|
def integers_to_file(m: "Model", f, var_map: Mapper):
|
|
78
102
|
"""
|
|
79
103
|
Write out integers of a model to a lp file.
|
|
80
104
|
"""
|
|
81
|
-
for variable in create_section(
|
|
105
|
+
for variable in create_section(
|
|
106
|
+
tqdm(m.integer_variables, "Writing integer variables to file"), f, "general"
|
|
107
|
+
):
|
|
82
108
|
lines = (
|
|
83
109
|
var_map.apply(variable.data, to_col=None)
|
|
84
110
|
.select(pl.col(VAR_KEY).str.concat("\n"))
|
|
85
111
|
.item()
|
|
86
112
|
)
|
|
87
|
-
f.
|
|
113
|
+
f.write(lines + "\n")
|
|
88
114
|
|
|
89
115
|
|
|
90
116
|
T = TypeVar("T")
|
|
@@ -103,7 +129,7 @@ def get_var_map(m: "Model", use_var_names):
|
|
|
103
129
|
if use_var_names:
|
|
104
130
|
if m.var_map is not None:
|
|
105
131
|
return m.var_map
|
|
106
|
-
var_map =
|
|
132
|
+
var_map = NamedVariableMapper(Variable)
|
|
107
133
|
else:
|
|
108
134
|
var_map = Base62VarMapper(Variable)
|
|
109
135
|
|
|
@@ -144,6 +170,6 @@ def to_file(
|
|
|
144
170
|
bounds_to_file(m, f, var_map)
|
|
145
171
|
binaries_to_file(m, f, var_map)
|
|
146
172
|
integers_to_file(m, f, var_map)
|
|
147
|
-
f.write("
|
|
173
|
+
f.write("\nend\n")
|
|
148
174
|
|
|
149
175
|
return file_path
|
pyoframe/io_mappers.py
CHANGED
|
@@ -9,14 +9,14 @@ from abc import ABC, abstractmethod
|
|
|
9
9
|
|
|
10
10
|
from typing import TYPE_CHECKING, Optional, Type, Union
|
|
11
11
|
import polars as pl
|
|
12
|
-
from pyoframe.constants import NAME_COL
|
|
13
12
|
from pyoframe.util import concat_dimensions
|
|
13
|
+
from pyoframe.constants import CONST_TERM
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING: # pragma: no cover
|
|
17
17
|
from pyoframe.model import Variable
|
|
18
|
-
from pyoframe.
|
|
19
|
-
from pyoframe.util import
|
|
18
|
+
from pyoframe.core import Constraint
|
|
19
|
+
from pyoframe.util import CountableModelElement
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@dataclass
|
|
@@ -26,38 +26,47 @@ class IOMappers:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class Mapper(ABC):
|
|
29
|
-
|
|
29
|
+
|
|
30
|
+
NAME_COL = "__name"
|
|
31
|
+
|
|
32
|
+
def __init__(self, cls: Type["CountableModelElement"]) -> None:
|
|
30
33
|
self._ID_COL = cls.get_id_column_name()
|
|
31
34
|
self.mapping_registry = pl.DataFrame(
|
|
32
|
-
{self._ID_COL: [], NAME_COL: []},
|
|
33
|
-
schema={self._ID_COL: pl.UInt32, NAME_COL: pl.String},
|
|
35
|
+
{self._ID_COL: [], Mapper.NAME_COL: []},
|
|
36
|
+
schema={self._ID_COL: pl.UInt32, Mapper.NAME_COL: pl.String},
|
|
34
37
|
)
|
|
35
38
|
|
|
36
39
|
def add(self, element: Union["Variable", "Constraint"]) -> None:
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
self._extend_registry(self._element_to_map(element))
|
|
41
|
+
|
|
42
|
+
def _extend_registry(self, df: pl.DataFrame) -> None:
|
|
43
|
+
self.mapping_registry = pl.concat([self.mapping_registry, df])
|
|
40
44
|
|
|
41
45
|
@abstractmethod
|
|
42
|
-
def _element_to_map(self, element: "
|
|
46
|
+
def _element_to_map(self, element: "CountableModelElement") -> pl.DataFrame: ...
|
|
43
47
|
|
|
44
48
|
def apply(
|
|
45
49
|
self,
|
|
46
50
|
df: pl.DataFrame,
|
|
47
|
-
to_col: Optional[str],
|
|
51
|
+
to_col: Optional[str] = None,
|
|
48
52
|
) -> pl.DataFrame:
|
|
53
|
+
if df.height == 0:
|
|
54
|
+
return df
|
|
49
55
|
result = df.join(
|
|
50
56
|
self.mapping_registry, on=self._ID_COL, how="left", validate="m:1"
|
|
51
57
|
)
|
|
52
58
|
if to_col is None:
|
|
53
59
|
result = result.drop(self._ID_COL)
|
|
54
60
|
to_col = self._ID_COL
|
|
55
|
-
return result.rename({NAME_COL: to_col})
|
|
61
|
+
return result.rename({Mapper.NAME_COL: to_col})
|
|
56
62
|
|
|
57
63
|
def undo(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
64
|
+
if df.height == 0:
|
|
65
|
+
return df
|
|
66
|
+
df = df.rename({self._ID_COL: Mapper.NAME_COL})
|
|
58
67
|
return df.join(
|
|
59
|
-
self.mapping_registry, on=NAME_COL, how="left", validate="m:1"
|
|
60
|
-
).drop(NAME_COL)
|
|
68
|
+
self.mapping_registry, on=Mapper.NAME_COL, how="left", validate="m:1"
|
|
69
|
+
).drop(Mapper.NAME_COL)
|
|
61
70
|
|
|
62
71
|
|
|
63
72
|
class NamedMapper(Mapper):
|
|
@@ -68,7 +77,7 @@ class NamedMapper(Mapper):
|
|
|
68
77
|
|
|
69
78
|
>>> import polars as pl
|
|
70
79
|
>>> import pyoframe as pf
|
|
71
|
-
>>> m = pf.Model()
|
|
80
|
+
>>> m = pf.Model("min")
|
|
72
81
|
>>> m.foo = pf.Variable(pl.DataFrame({"t": range(4)}))
|
|
73
82
|
>>> pf.sum(m.foo)
|
|
74
83
|
<Expression size=1 dimensions={} terms=4>
|
|
@@ -81,7 +90,20 @@ class NamedMapper(Mapper):
|
|
|
81
90
|
element_name is not None
|
|
82
91
|
), "Element must have a name to be used in a named mapping."
|
|
83
92
|
return concat_dimensions(
|
|
84
|
-
element.ids, keep_dims=False, prefix=element_name, to_col=NAME_COL
|
|
93
|
+
element.ids, keep_dims=False, prefix=element_name, to_col=Mapper.NAME_COL
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NamedVariableMapper(NamedMapper):
|
|
98
|
+
CONST_TERM_NAME = "_ONE"
|
|
99
|
+
|
|
100
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
101
|
+
super().__init__(*args, **kwargs)
|
|
102
|
+
self._extend_registry(
|
|
103
|
+
pl.DataFrame(
|
|
104
|
+
{self._ID_COL: [CONST_TERM], self.NAME_COL: [self.CONST_TERM_NAME]},
|
|
105
|
+
schema={self._ID_COL: pl.UInt32, self.NAME_COL: pl.String},
|
|
106
|
+
)
|
|
85
107
|
)
|
|
86
108
|
|
|
87
109
|
|
|
@@ -168,7 +190,7 @@ class Base62Mapper(Mapper, ABC):
|
|
|
168
190
|
)
|
|
169
191
|
|
|
170
192
|
def _element_to_map(self, element) -> pl.DataFrame:
|
|
171
|
-
return self.apply(element.ids.select(self._ID_COL), to_col=NAME_COL)
|
|
193
|
+
return self.apply(element.ids.select(self._ID_COL), to_col=Mapper.NAME_COL)
|
|
172
194
|
|
|
173
195
|
|
|
174
196
|
class Base62VarMapper(Base62Mapper):
|
|
@@ -177,7 +199,7 @@ class Base62VarMapper(Base62Mapper):
|
|
|
177
199
|
>>> import polars as pl
|
|
178
200
|
>>> from pyoframe import Model, Variable
|
|
179
201
|
>>> from pyoframe.constants import VAR_KEY
|
|
180
|
-
>>> m = Model()
|
|
202
|
+
>>> m = Model("min")
|
|
181
203
|
>>> m.x = Variable(pl.DataFrame({"t": range(1,63)}))
|
|
182
204
|
>>> (m.x.filter(t=11)+1).to_str()
|
|
183
205
|
'[11]: 1 + x[11]'
|
|
@@ -194,6 +216,15 @@ class Base62VarMapper(Base62Mapper):
|
|
|
194
216
|
└───────────────┘
|
|
195
217
|
"""
|
|
196
218
|
|
|
219
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
220
|
+
super().__init__(*args, **kwargs)
|
|
221
|
+
df = pl.DataFrame(
|
|
222
|
+
{self._ID_COL: [CONST_TERM]},
|
|
223
|
+
schema={self._ID_COL: pl.UInt32},
|
|
224
|
+
)
|
|
225
|
+
df = self.apply(df, to_col=Mapper.NAME_COL)
|
|
226
|
+
self._extend_registry(df)
|
|
227
|
+
|
|
197
228
|
@property
|
|
198
229
|
def _prefix(self) -> "str":
|
|
199
230
|
return "x"
|
pyoframe/model.py
CHANGED
|
@@ -1,29 +1,59 @@
|
|
|
1
|
-
from typing import Any, Iterable, List, Optional
|
|
2
|
-
from pyoframe.constants import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
from typing import Any, Iterable, List, Optional, Union
|
|
2
|
+
from pyoframe.constants import (
|
|
3
|
+
ObjSense,
|
|
4
|
+
VType,
|
|
5
|
+
Config,
|
|
6
|
+
Result,
|
|
7
|
+
PyoframeError,
|
|
8
|
+
ObjSenseValue,
|
|
9
|
+
)
|
|
10
|
+
from pyoframe.io_mappers import NamedVariableMapper, IOMappers
|
|
11
|
+
from pyoframe.model_element import ModelElement, ModelElementWithId
|
|
12
|
+
from pyoframe.core import Constraint
|
|
7
13
|
from pyoframe.objective import Objective
|
|
8
|
-
from pyoframe.
|
|
14
|
+
from pyoframe.user_defined import Container, AttrContainerMixin
|
|
15
|
+
from pyoframe.core import Variable
|
|
9
16
|
from pyoframe.io import to_file
|
|
10
|
-
from pyoframe.solvers import solve
|
|
17
|
+
from pyoframe.solvers import solve, Solver
|
|
18
|
+
import polars as pl
|
|
19
|
+
import pandas as pd
|
|
11
20
|
|
|
12
21
|
|
|
13
|
-
class Model:
|
|
22
|
+
class Model(AttrContainerMixin):
|
|
14
23
|
"""
|
|
15
24
|
Represents a mathematical optimization model. Add variables, constraints, and an objective to the model by setting attributes.
|
|
16
25
|
"""
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
_reserved_attributes = [
|
|
28
|
+
"_variables",
|
|
29
|
+
"_constraints",
|
|
30
|
+
"_objective",
|
|
31
|
+
"var_map",
|
|
32
|
+
"io_mappers",
|
|
33
|
+
"name",
|
|
34
|
+
"solver",
|
|
35
|
+
"solver_model",
|
|
36
|
+
"params",
|
|
37
|
+
"result",
|
|
38
|
+
"attr",
|
|
39
|
+
"sense",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(self, min_or_max: Union[ObjSense, ObjSenseValue], name=None, **kwargs):
|
|
43
|
+
super().__init__(**kwargs)
|
|
19
44
|
self._variables: List[Variable] = []
|
|
20
45
|
self._constraints: List[Constraint] = []
|
|
46
|
+
self.sense = ObjSense(min_or_max)
|
|
21
47
|
self._objective: Optional[Objective] = None
|
|
22
48
|
self.var_map = (
|
|
23
|
-
|
|
49
|
+
NamedVariableMapper(Variable) if Config.print_uses_variable_names else None
|
|
24
50
|
)
|
|
25
51
|
self.io_mappers: Optional[IOMappers] = None
|
|
26
52
|
self.name = name
|
|
53
|
+
self.solver: Optional[Solver] = None
|
|
54
|
+
self.solver_model: Optional[Any] = None
|
|
55
|
+
self.params = Container()
|
|
56
|
+
self.result: Optional[Result] = None
|
|
27
57
|
|
|
28
58
|
@property
|
|
29
59
|
def variables(self) -> List[Variable]:
|
|
@@ -45,44 +75,37 @@ class Model:
|
|
|
45
75
|
def objective(self):
|
|
46
76
|
return self._objective
|
|
47
77
|
|
|
48
|
-
@property
|
|
49
|
-
def maximize(self):
|
|
50
|
-
assert self.objective is not None and self.objective.sense == ObjSense.MAX
|
|
51
|
-
return self.objective
|
|
52
|
-
|
|
53
|
-
@property
|
|
54
|
-
def minimize(self):
|
|
55
|
-
assert self.objective is not None and self.objective.sense == ObjSense.MIN
|
|
56
|
-
return self.objective
|
|
57
|
-
|
|
58
78
|
def __setattr__(self, __name: str, __value: Any) -> None:
|
|
59
|
-
if __name in (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
__value
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
if __name not in Model._reserved_attributes and not isinstance(
|
|
80
|
+
__value, (ModelElement, pl.DataFrame, pd.DataFrame)
|
|
81
|
+
):
|
|
82
|
+
raise PyoframeError(
|
|
83
|
+
f"Cannot set attribute '{__name}' on the model because it isn't of type ModelElement (e.g. Variable, Constraint, ...)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
isinstance(__value, ModelElement)
|
|
88
|
+
and __name not in Model._reserved_attributes
|
|
89
|
+
):
|
|
90
|
+
if __name == "objective":
|
|
91
|
+
__value = Objective(__value)
|
|
92
|
+
|
|
93
|
+
if isinstance(__value, ModelElementWithId):
|
|
94
|
+
assert not hasattr(
|
|
95
|
+
self, __name
|
|
96
|
+
), f"Cannot create {__name} since it was already created."
|
|
97
|
+
|
|
98
|
+
__value.on_add_to_model(self, __name)
|
|
99
|
+
|
|
79
100
|
if isinstance(__value, Variable):
|
|
80
101
|
self._variables.append(__value)
|
|
81
102
|
if self.var_map is not None:
|
|
82
103
|
self.var_map.add(__value)
|
|
83
104
|
elif isinstance(__value, Constraint):
|
|
84
105
|
self._constraints.append(__value)
|
|
85
|
-
|
|
106
|
+
elif isinstance(__value, Objective):
|
|
107
|
+
self._objective = __value
|
|
108
|
+
return
|
|
86
109
|
return super().__setattr__(__name, __value)
|
|
87
110
|
|
|
88
111
|
def __repr__(self) -> str:
|
pyoframe/model_element.py
CHANGED
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
|
-
from
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
4
5
|
import polars as pl
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
8
|
from pyoframe.constants import COEF_KEY, RESERVED_COL_KEYS, VAR_KEY
|
|
8
9
|
from pyoframe._arithmetic import _get_dimensions
|
|
10
|
+
from pyoframe.user_defined import AttrContainerMixin
|
|
9
11
|
|
|
10
12
|
if TYPE_CHECKING: # pragma: no cover
|
|
11
13
|
from pyoframe.model import Model
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
def _pass_polars_method(method_name: str):
|
|
15
|
-
"""
|
|
16
|
-
Wrapper to add a method to ModelElement that simply calls the underlying Polars method on the data attribute.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
def method(self, *args, **kwargs):
|
|
20
|
-
return self._new(getattr(self.data, method_name)(*args, **kwargs))
|
|
21
|
-
|
|
22
|
-
return method
|
|
23
|
-
|
|
24
|
-
|
|
25
16
|
class ModelElement(ABC):
|
|
26
17
|
def __init__(self, data: pl.DataFrame, **kwargs) -> None:
|
|
27
18
|
# Sanity checks, no duplicate column names
|
|
@@ -48,17 +39,25 @@ class ModelElement(ABC):
|
|
|
48
39
|
self.name = None
|
|
49
40
|
super().__init__(**kwargs)
|
|
50
41
|
|
|
42
|
+
def on_add_to_model(self, model: "Model", name: str):
|
|
43
|
+
self.name = name
|
|
44
|
+
self._model = model
|
|
45
|
+
|
|
51
46
|
@property
|
|
52
47
|
def data(self) -> pl.DataFrame:
|
|
53
48
|
return self._data
|
|
54
49
|
|
|
50
|
+
@property
|
|
51
|
+
def friendly_name(self) -> str:
|
|
52
|
+
return self.name if self.name is not None else "unnamed"
|
|
53
|
+
|
|
55
54
|
@property
|
|
56
55
|
def dimensions(self) -> Optional[List[str]]:
|
|
57
56
|
"""
|
|
58
57
|
The names of the data's dimensions.
|
|
59
58
|
|
|
60
59
|
Examples:
|
|
61
|
-
>>> from pyoframe.
|
|
60
|
+
>>> from pyoframe.core import Variable
|
|
62
61
|
>>> # A variable with no dimensions
|
|
63
62
|
>>> Variable().dimensions
|
|
64
63
|
|
|
@@ -85,7 +84,7 @@ class ModelElement(ABC):
|
|
|
85
84
|
The number of indices in each dimension.
|
|
86
85
|
|
|
87
86
|
Examples:
|
|
88
|
-
>>> from pyoframe.
|
|
87
|
+
>>> from pyoframe.core import Variable
|
|
89
88
|
>>> # A variable with no dimensions
|
|
90
89
|
>>> Variable().shape
|
|
91
90
|
{}
|
|
@@ -104,13 +103,120 @@ class ModelElement(ABC):
|
|
|
104
103
|
return 1
|
|
105
104
|
return self.data.select(dims).n_unique()
|
|
106
105
|
|
|
106
|
+
|
|
107
|
+
def _support_polars_method(method_name: str):
|
|
108
|
+
"""
|
|
109
|
+
Wrapper to add a method to ModelElement that simply calls the underlying Polars method on the data attribute.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def method(self: "SupportPolarsMethodMixin", *args, **kwargs):
|
|
113
|
+
result_from_polars = getattr(self.data, method_name)(*args, **kwargs)
|
|
114
|
+
if isinstance(result_from_polars, pl.DataFrame):
|
|
115
|
+
return self._new(result_from_polars)
|
|
116
|
+
else:
|
|
117
|
+
return result_from_polars
|
|
118
|
+
|
|
119
|
+
return method
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SupportPolarsMethodMixin:
|
|
123
|
+
rename = _support_polars_method("rename")
|
|
124
|
+
with_columns = _support_polars_method("with_columns")
|
|
125
|
+
filter = _support_polars_method("filter")
|
|
126
|
+
estimated_size = _support_polars_method("estimated_size")
|
|
127
|
+
|
|
107
128
|
@abstractmethod
|
|
108
129
|
def _new(self, data: pl.DataFrame):
|
|
109
130
|
"""
|
|
110
131
|
Used to create a new instance of the same class with the given data (for e.g. on .rename(), .with_columns(), etc.).
|
|
111
132
|
"""
|
|
112
|
-
raise NotImplementedError
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
|
|
135
|
+
class ModelElementWithId(ModelElement, AttrContainerMixin):
|
|
136
|
+
"""
|
|
137
|
+
Provides a method that assigns a unique ID to each row in a DataFrame.
|
|
138
|
+
IDs start at 1 and go up consecutively. No zero ID is assigned since it is reserved for the constant variable term.
|
|
139
|
+
IDs are only unique for the subclass since different subclasses have different counters.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
# Keys are the subclass names and values are the next unasigned ID.
|
|
143
|
+
_id_counters: Dict[str, int] = defaultdict(lambda: 1)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def reset_counters(cls):
|
|
147
|
+
"""
|
|
148
|
+
Resets all the ID counters.
|
|
149
|
+
This function is called before every unit test to reset the code state.
|
|
150
|
+
"""
|
|
151
|
+
cls._id_counters = defaultdict(lambda: 1)
|
|
152
|
+
|
|
153
|
+
def __init__(self, data: pl.DataFrame, **kwargs) -> None:
|
|
154
|
+
super().__init__(data, **kwargs)
|
|
155
|
+
self._data = self._assign_ids(self.data)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def _assign_ids(cls, df: pl.DataFrame) -> pl.DataFrame:
|
|
159
|
+
"""
|
|
160
|
+
Adds the column `to_column` to the DataFrame `df` with the next batch
|
|
161
|
+
of unique consecutive IDs.
|
|
162
|
+
"""
|
|
163
|
+
cls_name = cls.__name__
|
|
164
|
+
cur_count = cls._id_counters[cls_name]
|
|
165
|
+
id_col_name = cls.get_id_column_name()
|
|
166
|
+
|
|
167
|
+
if df.height == 0:
|
|
168
|
+
df = df.with_columns(pl.lit(cur_count).alias(id_col_name))
|
|
169
|
+
else:
|
|
170
|
+
df = df.with_columns(
|
|
171
|
+
pl.int_range(cur_count, cur_count + pl.len()).alias(id_col_name)
|
|
172
|
+
)
|
|
173
|
+
df = df.with_columns(pl.col(id_col_name).cast(pl.UInt32))
|
|
174
|
+
cls._id_counters[cls_name] += df.height
|
|
175
|
+
return df
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
@abstractmethod
|
|
179
|
+
def get_id_column_name(cls) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Returns the name of the column containing the IDs.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def ids(self) -> pl.DataFrame:
|
|
186
|
+
return self.data.select(self.dimensions_unsafe + [self.get_id_column_name()])
|
|
187
|
+
|
|
188
|
+
def _extend_dataframe_by_id(self, addition: pl.DataFrame):
|
|
189
|
+
cols = addition.columns
|
|
190
|
+
assert len(cols) == 2
|
|
191
|
+
id_col = self.get_id_column_name()
|
|
192
|
+
assert id_col in cols
|
|
193
|
+
cols.remove(id_col)
|
|
194
|
+
new_col = cols[0]
|
|
195
|
+
|
|
196
|
+
original = self.data
|
|
197
|
+
|
|
198
|
+
if new_col in original.columns:
|
|
199
|
+
original = original.drop(new_col)
|
|
200
|
+
self._data = original.join(addition, on=id_col, how="left", validate="1:1")
|
|
201
|
+
|
|
202
|
+
def _preprocess_attr(self, name: str, value: Any) -> Any:
|
|
203
|
+
dims = self.dimensions
|
|
204
|
+
ids = self.ids
|
|
205
|
+
id_col = self.get_id_column_name()
|
|
206
|
+
|
|
207
|
+
if isinstance(value, pl.DataFrame):
|
|
208
|
+
if value.shape == (1, 1):
|
|
209
|
+
value = value.item()
|
|
210
|
+
else:
|
|
211
|
+
assert (
|
|
212
|
+
dims is not None
|
|
213
|
+
), "Attribute must be a scalar since there are no dimensions"
|
|
214
|
+
result = value.join(ids, on=dims, validate="1:1", how="inner").drop(
|
|
215
|
+
dims
|
|
216
|
+
)
|
|
217
|
+
assert len(result.columns) == 2, "Attribute has too many columns"
|
|
218
|
+
value_col = [c for c in result.columns if c != id_col][0]
|
|
219
|
+
return result.rename({value_col: name})
|
|
220
|
+
|
|
221
|
+
assert ids.height == 1, "Attribute is a scalar but there are multiple IDs."
|
|
222
|
+
return pl.DataFrame({name: [value], id_col: ids.get_column(id_col)})
|
pyoframe/monkey_patch.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pandas as pd
|
|
2
2
|
import polars as pl
|
|
3
|
-
from pyoframe.
|
|
4
|
-
from pyoframe.
|
|
3
|
+
from pyoframe.core import SupportsMath
|
|
4
|
+
from pyoframe.core import Expression
|
|
5
5
|
from functools import wraps
|
|
6
6
|
|
|
7
7
|
from pyoframe.constants import COEF_KEY, CONST_TERM, VAR_KEY
|
pyoframe/objective.py
CHANGED
|
@@ -1,28 +1,24 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
|
-
from pyoframe.constants import
|
|
3
|
-
from pyoframe.
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from pyoframe.constants import COEF_KEY
|
|
3
|
+
from pyoframe.core import SupportsMath, Expression
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Objective(Expression):
|
|
7
7
|
r"""
|
|
8
8
|
Examples:
|
|
9
9
|
>>> from pyoframe import Variable, Model, sum
|
|
10
|
-
>>> m = Model()
|
|
10
|
+
>>> m = Model("max")
|
|
11
11
|
>>> m.a = Variable()
|
|
12
12
|
>>> m.b = Variable({"dim1": [1, 2, 3]})
|
|
13
|
-
>>> m.
|
|
14
|
-
>>> m.
|
|
13
|
+
>>> m.objective = m.a + sum("dim1", m.b)
|
|
14
|
+
>>> m.objective
|
|
15
15
|
<Objective size=1 dimensions={} terms=4>
|
|
16
|
-
|
|
16
|
+
objective: a + b[1] + b[2] + b[3]
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
def __init__(
|
|
20
|
-
self, expr: SupportsMath, sense: Union[ObjSense, ObjSenseValue]
|
|
21
|
-
) -> None:
|
|
22
|
-
self.sense = ObjSense(sense)
|
|
23
|
-
|
|
19
|
+
def __init__(self, expr: SupportsMath) -> None:
|
|
24
20
|
expr = expr.to_expr()
|
|
25
|
-
super().__init__(expr.
|
|
21
|
+
super().__init__(expr.data)
|
|
26
22
|
self._model = expr._model
|
|
27
23
|
assert (
|
|
28
24
|
self.dimensions is None
|
|
@@ -40,3 +36,10 @@ class Objective(Expression):
|
|
|
40
36
|
@value.setter
|
|
41
37
|
def value(self, value):
|
|
42
38
|
self._value = value
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def has_constant(self):
|
|
42
|
+
constant_terms = self.constant_terms
|
|
43
|
+
if len(constant_terms) == 0:
|
|
44
|
+
return False
|
|
45
|
+
return constant_terms.get_column(COEF_KEY).item() != 0
|