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/io.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing all import/export functionalities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from io import TextIOWrapper
|
|
6
|
+
from tempfile import NamedTemporaryFile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, Union
|
|
9
|
+
|
|
10
|
+
from pyoframe.constants import VAR_KEY, Config
|
|
11
|
+
from pyoframe.constraints import Constraint
|
|
12
|
+
from pyoframe.variables import Variable
|
|
13
|
+
from pyoframe.io_mappers import (
|
|
14
|
+
Base62ConstMapper,
|
|
15
|
+
Base62VarMapper,
|
|
16
|
+
IOMappers,
|
|
17
|
+
Mapper,
|
|
18
|
+
NamedMapper,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
22
|
+
from pyoframe.model import Model
|
|
23
|
+
|
|
24
|
+
import polars as pl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def objective_to_file(m: "Model", f: TextIOWrapper, var_map):
|
|
28
|
+
"""
|
|
29
|
+
Write out the objective of a model to a lp file.
|
|
30
|
+
"""
|
|
31
|
+
assert m.objective is not None, "No objective set."
|
|
32
|
+
|
|
33
|
+
f.write(f"{m.objective.sense.value}\n\nobj:\n\n")
|
|
34
|
+
result = m.objective.to_str(var_map=var_map, include_prefix=False)
|
|
35
|
+
f.writelines(result)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def constraints_to_file(m: "Model", f: TextIOWrapper, var_map, const_map):
|
|
39
|
+
for constraint in create_section(m.constraints, f, "s.t."):
|
|
40
|
+
f.writelines(constraint.to_str(var_map=var_map, const_map=const_map) + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bounds_to_file(m: "Model", f, var_map):
|
|
44
|
+
"""
|
|
45
|
+
Write out variables of a model to a lp file.
|
|
46
|
+
"""
|
|
47
|
+
for variable in create_section(m.variables, f, "bounds"):
|
|
48
|
+
lb = f"{variable.lb:.12g}"
|
|
49
|
+
ub = f"{variable.ub:.12g}"
|
|
50
|
+
|
|
51
|
+
df = (
|
|
52
|
+
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
|
+
)
|
|
58
|
+
.item()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
f.writelines(df)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def binaries_to_file(m: "Model", f, var_map: Mapper):
|
|
65
|
+
"""
|
|
66
|
+
Write out binaries of a model to a lp file.
|
|
67
|
+
"""
|
|
68
|
+
for variable in create_section(m.binary_variables, f, "binary"):
|
|
69
|
+
lines = (
|
|
70
|
+
var_map.apply(variable.data, to_col=None)
|
|
71
|
+
.select(pl.col(VAR_KEY).str.concat("\n"))
|
|
72
|
+
.item()
|
|
73
|
+
)
|
|
74
|
+
f.writelines(lines + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def integers_to_file(m: "Model", f, var_map: Mapper):
|
|
78
|
+
"""
|
|
79
|
+
Write out integers of a model to a lp file.
|
|
80
|
+
"""
|
|
81
|
+
for variable in create_section(m.integer_variables, f, "general"):
|
|
82
|
+
lines = (
|
|
83
|
+
var_map.apply(variable.data, to_col=None)
|
|
84
|
+
.select(pl.col(VAR_KEY).str.concat("\n"))
|
|
85
|
+
.item()
|
|
86
|
+
)
|
|
87
|
+
f.writelines(lines + "\n")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
T = TypeVar("T")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_section(iterable: Iterable[T], f, section_header) -> Iterable[T]:
|
|
94
|
+
wrote = False
|
|
95
|
+
for item in iterable:
|
|
96
|
+
if not wrote:
|
|
97
|
+
f.write(f"\n\n{section_header}\n\n")
|
|
98
|
+
wrote = True
|
|
99
|
+
yield item
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_var_map(m: "Model", use_var_names):
|
|
103
|
+
if use_var_names:
|
|
104
|
+
if m.var_map is not None:
|
|
105
|
+
return m.var_map
|
|
106
|
+
var_map = NamedMapper(Variable)
|
|
107
|
+
else:
|
|
108
|
+
var_map = Base62VarMapper(Variable)
|
|
109
|
+
|
|
110
|
+
for v in m.variables:
|
|
111
|
+
var_map.add(v)
|
|
112
|
+
return var_map
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def to_file(
|
|
116
|
+
m: "Model", file_path: Optional[Union[str, Path]], use_var_names=False
|
|
117
|
+
) -> Path:
|
|
118
|
+
"""
|
|
119
|
+
Write out a model to a lp file.
|
|
120
|
+
"""
|
|
121
|
+
if file_path is None:
|
|
122
|
+
with NamedTemporaryFile(
|
|
123
|
+
prefix="pyoframe-problem-", suffix=".lp", mode="w", delete=False
|
|
124
|
+
) as f:
|
|
125
|
+
file_path = f.name
|
|
126
|
+
|
|
127
|
+
file_path = Path(file_path)
|
|
128
|
+
assert file_path.suffix == ".lp", f"File format `{file_path.suffix}` not supported."
|
|
129
|
+
|
|
130
|
+
if file_path.exists():
|
|
131
|
+
file_path.unlink()
|
|
132
|
+
|
|
133
|
+
const_map = (
|
|
134
|
+
NamedMapper(Constraint) if use_var_names else Base62ConstMapper(Constraint)
|
|
135
|
+
)
|
|
136
|
+
for c in m.constraints:
|
|
137
|
+
const_map.add(c)
|
|
138
|
+
var_map = get_var_map(m, use_var_names)
|
|
139
|
+
m.io_mappers = IOMappers(var_map, const_map)
|
|
140
|
+
|
|
141
|
+
with open(file_path, mode="w") as f:
|
|
142
|
+
objective_to_file(m, f, var_map)
|
|
143
|
+
constraints_to_file(m, f, var_map, const_map)
|
|
144
|
+
bounds_to_file(m, f, var_map)
|
|
145
|
+
binaries_to_file(m, f, var_map)
|
|
146
|
+
integers_to_file(m, f, var_map)
|
|
147
|
+
f.write("end\n")
|
|
148
|
+
|
|
149
|
+
return file_path
|
pyoframe/io_mappers.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines various methods for mapping a variable or constraint to its string representation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import math
|
|
7
|
+
import string
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Optional, Type, Union
|
|
11
|
+
import polars as pl
|
|
12
|
+
from pyoframe.constants import NAME_COL
|
|
13
|
+
from pyoframe.util import concat_dimensions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
17
|
+
from pyoframe.model import Variable
|
|
18
|
+
from pyoframe.constraints import Constraint
|
|
19
|
+
from pyoframe.util import IdCounterMixin
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class IOMappers:
|
|
24
|
+
var_map: "Mapper"
|
|
25
|
+
const_map: "Mapper"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Mapper(ABC):
|
|
29
|
+
def __init__(self, cls: Type["IdCounterMixin"]) -> None:
|
|
30
|
+
self._ID_COL = cls.get_id_column_name()
|
|
31
|
+
self.mapping_registry = pl.DataFrame(
|
|
32
|
+
{self._ID_COL: [], NAME_COL: []},
|
|
33
|
+
schema={self._ID_COL: pl.UInt32, NAME_COL: pl.String},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def add(self, element: Union["Variable", "Constraint"]) -> None:
|
|
37
|
+
self.mapping_registry = pl.concat(
|
|
38
|
+
[self.mapping_registry, self._element_to_map(element)]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def _element_to_map(self, element: "IdCounterMixin") -> pl.DataFrame: ...
|
|
43
|
+
|
|
44
|
+
def apply(
|
|
45
|
+
self,
|
|
46
|
+
df: pl.DataFrame,
|
|
47
|
+
to_col: Optional[str],
|
|
48
|
+
) -> pl.DataFrame:
|
|
49
|
+
result = df.join(
|
|
50
|
+
self.mapping_registry, on=self._ID_COL, how="left", validate="m:1"
|
|
51
|
+
)
|
|
52
|
+
if to_col is None:
|
|
53
|
+
result = result.drop(self._ID_COL)
|
|
54
|
+
to_col = self._ID_COL
|
|
55
|
+
return result.rename({NAME_COL: to_col})
|
|
56
|
+
|
|
57
|
+
def undo(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
58
|
+
return df.join(
|
|
59
|
+
self.mapping_registry, on=NAME_COL, how="left", validate="m:1"
|
|
60
|
+
).drop(NAME_COL)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NamedMapper(Mapper):
|
|
64
|
+
"""
|
|
65
|
+
Maps constraints or variables to a string representation using the object's name and dimensions.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
|
|
69
|
+
>>> import polars as pl
|
|
70
|
+
>>> import pyoframe as pf
|
|
71
|
+
>>> m = pf.Model()
|
|
72
|
+
>>> m.foo = pf.Variable(pl.DataFrame({"t": range(4)}))
|
|
73
|
+
>>> pf.sum(m.foo)
|
|
74
|
+
<Expression size=1 dimensions={} terms=4>
|
|
75
|
+
foo[0] + foo[1] + foo[2] + foo[3]
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def _element_to_map(self, element) -> pl.DataFrame:
|
|
79
|
+
element_name = element.name # type: ignore
|
|
80
|
+
assert (
|
|
81
|
+
element_name is not None
|
|
82
|
+
), "Element must have a name to be used in a named mapping."
|
|
83
|
+
return concat_dimensions(
|
|
84
|
+
element.ids, keep_dims=False, prefix=element_name, to_col=NAME_COL
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Base62Mapper(Mapper, ABC):
|
|
89
|
+
# Mapping between a base 62 character and its integer value
|
|
90
|
+
_CHAR_TABLE = pl.DataFrame(
|
|
91
|
+
{"char": list(string.digits + string.ascii_letters)},
|
|
92
|
+
).with_columns(pl.int_range(pl.len()).cast(pl.UInt32).alias("code"))
|
|
93
|
+
|
|
94
|
+
_BASE = _CHAR_TABLE.height # _BASE = 62
|
|
95
|
+
_ZERO = _CHAR_TABLE.filter(pl.col("code") == 0).select("char").item() # _ZERO = "0"
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def _prefix(self) -> "str": ...
|
|
100
|
+
|
|
101
|
+
def apply(
|
|
102
|
+
self,
|
|
103
|
+
df: pl.DataFrame,
|
|
104
|
+
to_col: Optional[str] = None,
|
|
105
|
+
) -> pl.DataFrame:
|
|
106
|
+
if df.height == 0:
|
|
107
|
+
return df
|
|
108
|
+
|
|
109
|
+
query = pl.concat_str(
|
|
110
|
+
pl.lit(self._prefix),
|
|
111
|
+
pl.col(self._ID_COL).map_batches(
|
|
112
|
+
Base62Mapper._to_base62,
|
|
113
|
+
return_dtype=pl.String,
|
|
114
|
+
is_elementwise=True,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if to_col is None:
|
|
119
|
+
to_col = self._ID_COL
|
|
120
|
+
|
|
121
|
+
return df.with_columns(query.alias(to_col))
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def _to_base62(cls, int_col: pl.Series) -> pl.Series:
|
|
125
|
+
"""Returns a series of dtype str with a base 62 representation of the integers in int_col.
|
|
126
|
+
The letters 0-9a-zA-Z are used as symbols for the representation.
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
|
|
130
|
+
>>> import polars as pl
|
|
131
|
+
>>> s = pl.Series([0,10,20,60,53,66], dtype=pl.UInt32)
|
|
132
|
+
>>> Base62Mapper._to_base62(s).to_list()
|
|
133
|
+
['0', 'a', 'k', 'Y', 'R', '14']
|
|
134
|
+
|
|
135
|
+
>>> s = pl.Series([0], dtype=pl.UInt32)
|
|
136
|
+
>>> Base62Mapper._to_base62(s).to_list()
|
|
137
|
+
['0']
|
|
138
|
+
"""
|
|
139
|
+
assert isinstance(
|
|
140
|
+
int_col.dtype, pl.UInt32
|
|
141
|
+
), "_to_base62() only works for UInt32 id columns"
|
|
142
|
+
|
|
143
|
+
largest_id = int_col.max()
|
|
144
|
+
if largest_id == 0:
|
|
145
|
+
max_digits = 1
|
|
146
|
+
else:
|
|
147
|
+
max_digits = math.floor(math.log(largest_id, cls._BASE)) + 1 # type: ignore
|
|
148
|
+
|
|
149
|
+
digits = []
|
|
150
|
+
|
|
151
|
+
for i in range(max_digits):
|
|
152
|
+
remainder = int_col % cls._BASE
|
|
153
|
+
|
|
154
|
+
digits.append(
|
|
155
|
+
remainder.to_frame(name="code")
|
|
156
|
+
.join(cls._CHAR_TABLE, on="code", how="left")
|
|
157
|
+
.select("char")
|
|
158
|
+
.rename({"char": f"digit{i}"})
|
|
159
|
+
)
|
|
160
|
+
int_col //= cls._BASE
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
pl.concat(reversed(digits), how="horizontal")
|
|
164
|
+
.select(pl.concat_str(pl.all()))
|
|
165
|
+
.to_series()
|
|
166
|
+
.str.strip_chars_start(cls._ZERO)
|
|
167
|
+
.replace("", cls._ZERO)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _element_to_map(self, element) -> pl.DataFrame:
|
|
171
|
+
return self.apply(element.ids.select(self._ID_COL), to_col=NAME_COL)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Base62VarMapper(Base62Mapper):
|
|
175
|
+
"""
|
|
176
|
+
Examples:
|
|
177
|
+
>>> import polars as pl
|
|
178
|
+
>>> from pyoframe import Model, Variable
|
|
179
|
+
>>> from pyoframe.constants import VAR_KEY
|
|
180
|
+
>>> m = Model()
|
|
181
|
+
>>> m.x = Variable(pl.DataFrame({"t": range(1,63)}))
|
|
182
|
+
>>> (m.x.filter(t=11)+1).to_str()
|
|
183
|
+
'[11]: 1 + x[11]'
|
|
184
|
+
>>> (m.x.filter(t=11)+1).to_str(var_map=Base62VarMapper(Variable))
|
|
185
|
+
'[11]: 1 + xb'
|
|
186
|
+
|
|
187
|
+
>>> Base62VarMapper(Variable).apply(pl.DataFrame({VAR_KEY: []}))
|
|
188
|
+
shape: (0, 1)
|
|
189
|
+
┌───────────────┐
|
|
190
|
+
│ __variable_id │
|
|
191
|
+
│ --- │
|
|
192
|
+
│ null │
|
|
193
|
+
╞═══════════════╡
|
|
194
|
+
└───────────────┘
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def _prefix(self) -> "str":
|
|
199
|
+
return "x"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Base62ConstMapper(Base62Mapper):
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def _prefix(self) -> "str":
|
|
206
|
+
return "c"
|
pyoframe/model.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import Any, Iterable, List, Optional
|
|
2
|
+
from pyoframe.constants import ObjSense, VType, Config
|
|
3
|
+
from pyoframe.constraints import SupportsMath
|
|
4
|
+
from pyoframe.io_mappers import NamedMapper, IOMappers
|
|
5
|
+
from pyoframe.model_element import ModelElement
|
|
6
|
+
from pyoframe.constraints import Constraint
|
|
7
|
+
from pyoframe.objective import Objective
|
|
8
|
+
from pyoframe.variables import Variable
|
|
9
|
+
from pyoframe.io import to_file
|
|
10
|
+
from pyoframe.solvers import solve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Model:
|
|
14
|
+
"""
|
|
15
|
+
Represents a mathematical optimization model. Add variables, constraints, and an objective to the model by setting attributes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, name=None):
|
|
19
|
+
self._variables: List[Variable] = []
|
|
20
|
+
self._constraints: List[Constraint] = []
|
|
21
|
+
self._objective: Optional[Objective] = None
|
|
22
|
+
self.var_map = (
|
|
23
|
+
NamedMapper(Variable) if Config.print_uses_variable_names else None
|
|
24
|
+
)
|
|
25
|
+
self.io_mappers: Optional[IOMappers] = None
|
|
26
|
+
self.name = name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def variables(self) -> List[Variable]:
|
|
30
|
+
return self._variables
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def binary_variables(self) -> Iterable[Variable]:
|
|
34
|
+
return (v for v in self.variables if v.vtype == VType.BINARY)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def integer_variables(self) -> Iterable[Variable]:
|
|
38
|
+
return (v for v in self.variables if v.vtype == VType.INTEGER)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def constraints(self):
|
|
42
|
+
return self._constraints
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def objective(self):
|
|
46
|
+
return self._objective
|
|
47
|
+
|
|
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
|
+
def __setattr__(self, __name: str, __value: Any) -> None:
|
|
59
|
+
if __name in ("maximize", "minimize"):
|
|
60
|
+
assert isinstance(
|
|
61
|
+
__value, SupportsMath
|
|
62
|
+
), f"Setting {__name} on the model requires an objective expression."
|
|
63
|
+
self._objective = Objective(__value, sense=__name)
|
|
64
|
+
self._objective.name = __name
|
|
65
|
+
self._objective._model = self
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if isinstance(__value, ModelElement) and not __name.startswith("_"):
|
|
69
|
+
assert not hasattr(
|
|
70
|
+
self, __name
|
|
71
|
+
), f"Cannot create {__name} since it was already created."
|
|
72
|
+
|
|
73
|
+
__value.name = __name
|
|
74
|
+
__value._model = self
|
|
75
|
+
|
|
76
|
+
if isinstance(__value, Objective):
|
|
77
|
+
assert self.objective is None, "Cannot create more than one objective."
|
|
78
|
+
self._objective = __value
|
|
79
|
+
if isinstance(__value, Variable):
|
|
80
|
+
self._variables.append(__value)
|
|
81
|
+
if self.var_map is not None:
|
|
82
|
+
self.var_map.add(__value)
|
|
83
|
+
elif isinstance(__value, Constraint):
|
|
84
|
+
self._constraints.append(__value)
|
|
85
|
+
|
|
86
|
+
return super().__setattr__(__name, __value)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"""Model '{self.name}' ({len(self.variables)} vars, {len(self.constraints)} constrs, {1 if self.objective else "no"} obj)"""
|
|
90
|
+
|
|
91
|
+
to_file = to_file
|
|
92
|
+
solve = solve
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
import polars as pl
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pyoframe.constants import COEF_KEY, RESERVED_COL_KEYS, VAR_KEY
|
|
8
|
+
from pyoframe._arithmetic import _get_dimensions
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
11
|
+
from pyoframe.model import Model
|
|
12
|
+
|
|
13
|
+
|
|
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
|
+
class ModelElement(ABC):
|
|
26
|
+
def __init__(self, data: pl.DataFrame, **kwargs) -> None:
|
|
27
|
+
# Sanity checks, no duplicate column names
|
|
28
|
+
assert len(data.columns) == len(
|
|
29
|
+
set(data.columns)
|
|
30
|
+
), "Duplicate column names found."
|
|
31
|
+
|
|
32
|
+
cols = _get_dimensions(data)
|
|
33
|
+
if cols is None:
|
|
34
|
+
cols = []
|
|
35
|
+
cols += [col for col in RESERVED_COL_KEYS if col in data.columns]
|
|
36
|
+
|
|
37
|
+
# Reorder columns to keep things consistent
|
|
38
|
+
data = data.select(cols)
|
|
39
|
+
|
|
40
|
+
# Cast to proper dtype
|
|
41
|
+
if COEF_KEY in data.columns:
|
|
42
|
+
data = data.cast({COEF_KEY: pl.Float64})
|
|
43
|
+
if VAR_KEY in data.columns:
|
|
44
|
+
data = data.cast({VAR_KEY: pl.UInt32})
|
|
45
|
+
|
|
46
|
+
self._data = data
|
|
47
|
+
self._model: Optional[Model] = None
|
|
48
|
+
self.name = None
|
|
49
|
+
super().__init__(**kwargs)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def data(self) -> pl.DataFrame:
|
|
53
|
+
return self._data
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def dimensions(self) -> Optional[List[str]]:
|
|
57
|
+
"""
|
|
58
|
+
The names of the data's dimensions.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
>>> from pyoframe.variables import Variable
|
|
62
|
+
>>> # A variable with no dimensions
|
|
63
|
+
>>> Variable().dimensions
|
|
64
|
+
|
|
65
|
+
>>> # A variable with dimensions of "hour" and "city"
|
|
66
|
+
>>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
|
|
67
|
+
['hour', 'city']
|
|
68
|
+
"""
|
|
69
|
+
return _get_dimensions(self.data)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def dimensions_unsafe(self) -> List[str]:
|
|
73
|
+
"""
|
|
74
|
+
Same as `dimensions` but returns an empty list if there are no dimensions instead of None.
|
|
75
|
+
When unsure, use `dimensions` instead since the type checker forces users to handle the None case (no dimensions).
|
|
76
|
+
"""
|
|
77
|
+
dims = self.dimensions
|
|
78
|
+
if dims is None:
|
|
79
|
+
return []
|
|
80
|
+
return dims
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def shape(self) -> Dict[str, int]:
|
|
84
|
+
"""
|
|
85
|
+
The number of indices in each dimension.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> from pyoframe.variables import Variable
|
|
89
|
+
>>> # A variable with no dimensions
|
|
90
|
+
>>> Variable().shape
|
|
91
|
+
{}
|
|
92
|
+
>>> # A variable with dimensions of "hour" and "city"
|
|
93
|
+
>>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
|
|
94
|
+
{'hour': 4, 'city': 3}
|
|
95
|
+
"""
|
|
96
|
+
dims = self.dimensions
|
|
97
|
+
if dims is None:
|
|
98
|
+
return {}
|
|
99
|
+
return {dim: self.data[dim].n_unique() for dim in dims}
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
dims = self.dimensions
|
|
103
|
+
if dims is None:
|
|
104
|
+
return 1
|
|
105
|
+
return self.data.select(dims).n_unique()
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def _new(self, data: pl.DataFrame):
|
|
109
|
+
"""
|
|
110
|
+
Used to create a new instance of the same class with the given data (for e.g. on .rename(), .with_columns(), etc.).
|
|
111
|
+
"""
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
114
|
+
rename = _pass_polars_method("rename")
|
|
115
|
+
with_columns = _pass_polars_method("with_columns")
|
|
116
|
+
filter = _pass_polars_method("filter")
|
pyoframe/monkey_patch.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import polars as pl
|
|
3
|
+
from pyoframe.constraints import SupportsMath
|
|
4
|
+
from pyoframe.constraints import Expression
|
|
5
|
+
from functools import wraps
|
|
6
|
+
|
|
7
|
+
from pyoframe.constants import COEF_KEY, CONST_TERM, VAR_KEY
|
|
8
|
+
|
|
9
|
+
# pyright: reportAttributeAccessIssue=false
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _patch_class(cls):
|
|
13
|
+
def _patch_method(func):
|
|
14
|
+
@wraps(func)
|
|
15
|
+
def wrapper(self, other):
|
|
16
|
+
if isinstance(other, SupportsMath):
|
|
17
|
+
return NotImplemented
|
|
18
|
+
return func(self, other)
|
|
19
|
+
|
|
20
|
+
return wrapper
|
|
21
|
+
|
|
22
|
+
cls.__add__ = _patch_method(cls.__add__)
|
|
23
|
+
cls.__mul__ = _patch_method(cls.__mul__)
|
|
24
|
+
cls.__sub__ = _patch_method(cls.__sub__)
|
|
25
|
+
cls.__le__ = _patch_method(cls.__le__)
|
|
26
|
+
cls.__ge__ = _patch_method(cls.__ge__)
|
|
27
|
+
cls.__contains__ = _patch_method(cls.__contains__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _dataframe_to_expr(self: pl.DataFrame) -> Expression:
|
|
31
|
+
return Expression(
|
|
32
|
+
self.rename({self.columns[-1]: COEF_KEY})
|
|
33
|
+
.drop_nulls(COEF_KEY)
|
|
34
|
+
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY))
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def patch_dataframe_libraries():
|
|
39
|
+
"""
|
|
40
|
+
Applies two patches to the DataFrame and Series classes of both pandas and polars.
|
|
41
|
+
1) Patches arithmetic operators (e.g. `__add__`) such that operations between DataFrames/Series and `Expressionable`s
|
|
42
|
+
are not supported (i.e. `return NotImplemented`). This leads Python to try the reverse operation (e.g. `__radd__`)
|
|
43
|
+
which is supported by the `Expressionable` class.
|
|
44
|
+
2) Adds a `to_expr` method to DataFrame/Series that allows them to be converted to an `Expression` object.
|
|
45
|
+
Series become dataframes and dataframes become expressions where everything but the last column are treated as dimensions.
|
|
46
|
+
"""
|
|
47
|
+
_patch_class(pd.DataFrame)
|
|
48
|
+
_patch_class(pd.Series)
|
|
49
|
+
_patch_class(pl.DataFrame)
|
|
50
|
+
_patch_class(pl.Series)
|
|
51
|
+
pl.DataFrame.to_expr = _dataframe_to_expr
|
|
52
|
+
pl.Series.to_expr = lambda self: self.to_frame().to_expr()
|
|
53
|
+
pd.DataFrame.to_expr = lambda self: pl.from_pandas(self).to_expr()
|
|
54
|
+
pd.Series.to_expr = lambda self: self.to_frame().reset_index().to_expr()
|
pyoframe/objective.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
from pyoframe.constants import ObjSense, ObjSenseValue
|
|
3
|
+
from pyoframe.constraints import SupportsMath, Expression
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Objective(Expression):
|
|
7
|
+
r"""
|
|
8
|
+
Examples:
|
|
9
|
+
>>> from pyoframe import Variable, Model, sum
|
|
10
|
+
>>> m = Model()
|
|
11
|
+
>>> m.a = Variable()
|
|
12
|
+
>>> m.b = Variable({"dim1": [1, 2, 3]})
|
|
13
|
+
>>> m.maximize = m.a + sum("dim1", m.b)
|
|
14
|
+
>>> m.maximize
|
|
15
|
+
<Objective size=1 dimensions={} terms=4>
|
|
16
|
+
maximize: a + b[1] + b[2] + b[3]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, expr: SupportsMath, sense: Union[ObjSense, ObjSenseValue]
|
|
21
|
+
) -> None:
|
|
22
|
+
self.sense = ObjSense(sense)
|
|
23
|
+
|
|
24
|
+
expr = expr.to_expr()
|
|
25
|
+
super().__init__(expr.to_expr().data)
|
|
26
|
+
self._model = expr._model
|
|
27
|
+
assert (
|
|
28
|
+
self.dimensions is None
|
|
29
|
+
), "Objective cannot have dimensions as it must be a single expression"
|
|
30
|
+
self._value: Optional[float] = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def value(self):
|
|
34
|
+
if self._value is None:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Objective value is not available before solving the model"
|
|
37
|
+
)
|
|
38
|
+
return self._value
|
|
39
|
+
|
|
40
|
+
@value.setter
|
|
41
|
+
def value(self, value):
|
|
42
|
+
self._value = value
|