pyoframe 0.0.4__py3-none-any.whl → 0.0.6__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/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, Config
11
- from pyoframe.constraints import Constraint
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
- Base62ConstMapper,
15
- Base62VarMapper,
14
+ Base36ConstMapper,
15
+ Base36VarMapper,
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
- 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)
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(m.constraints, f, "s.t."):
40
- f.writelines(constraint.to_str(var_map=var_map, const_map=const_map) + "\n")
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
- for variable in create_section(m.variables, f, "bounds"):
48
- lb = f"{variable.lb:.12g}"
49
- ub = f"{variable.ub:.12g}"
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.writelines(df)
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(m.binary_variables, f, "binary"):
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.writelines(lines + "\n")
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(m.integer_variables, f, "general"):
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.writelines(lines + "\n")
113
+ f.write(lines + "\n")
88
114
 
89
115
 
90
116
  T = TypeVar("T")
@@ -103,9 +129,9 @@ 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 = NamedMapper(Variable)
132
+ var_map = NamedVariableMapper(Variable)
107
133
  else:
108
- var_map = Base62VarMapper(Variable)
134
+ var_map = Base36VarMapper(Variable)
109
135
 
110
136
  for v in m.variables:
111
137
  var_map.add(v)
@@ -113,10 +139,20 @@ def get_var_map(m: "Model", use_var_names):
113
139
 
114
140
 
115
141
  def to_file(
116
- m: "Model", file_path: Optional[Union[str, Path]], use_var_names=False
142
+ m: "Model", file_path: Optional[Union[str, Path]] = None, use_var_names=False
117
143
  ) -> Path:
118
144
  """
119
145
  Write out a model to a lp file.
146
+
147
+ Args:
148
+ m: The model to write out.
149
+ file_path: The path to write the model to. If None, a temporary file is created. The caller is responsible for
150
+ deleting the file after use.
151
+ use_var_names: If True, variable names are used in the lp file. Otherwise, variable
152
+ indices are used.
153
+
154
+ Returns:
155
+ The path to the lp file.
120
156
  """
121
157
  if file_path is None:
122
158
  with NamedTemporaryFile(
@@ -131,7 +167,7 @@ def to_file(
131
167
  file_path.unlink()
132
168
 
133
169
  const_map = (
134
- NamedMapper(Constraint) if use_var_names else Base62ConstMapper(Constraint)
170
+ NamedMapper(Constraint) if use_var_names else Base36ConstMapper(Constraint)
135
171
  )
136
172
  for c in m.constraints:
137
173
  const_map.add(c)
@@ -144,6 +180,6 @@ def to_file(
144
180
  bounds_to_file(m, f, var_map)
145
181
  binaries_to_file(m, f, var_map)
146
182
  integers_to_file(m, f, var_map)
147
- f.write("end\n")
183
+ f.write("\nend\n")
148
184
 
149
185
  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.constraints import Constraint
19
- from pyoframe.util import IdCounterMixin
18
+ from pyoframe.core import Constraint
19
+ from pyoframe.model_element import ModelElementWithId
20
20
 
21
21
 
22
22
  @dataclass
@@ -26,38 +26,47 @@ class IOMappers:
26
26
 
27
27
 
28
28
  class Mapper(ABC):
29
- def __init__(self, cls: Type["IdCounterMixin"]) -> None:
29
+
30
+ NAME_COL = "__name"
31
+
32
+ def __init__(self, cls: Type["ModelElementWithId"]) -> 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.mapping_registry = pl.concat(
38
- [self.mapping_registry, self._element_to_map(element)]
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: "IdCounterMixin") -> pl.DataFrame: ...
46
+ def _element_to_map(self, element: "ModelElementWithId") -> 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,17 +90,31 @@ 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
 
88
- class Base62Mapper(Mapper, ABC):
89
- # Mapping between a base 62 character and its integer value
110
+ class Base36Mapper(Mapper, ABC):
111
+ # Mapping between a base 36 character and its integer value
112
+ # Note: we must use only lowercase since Gurobi auto-converts variables that aren't in constraints to lowercase (kind of annoying)
90
113
  _CHAR_TABLE = pl.DataFrame(
91
- {"char": list(string.digits + string.ascii_letters)},
114
+ {"char": list(string.digits + string.ascii_lowercase)},
92
115
  ).with_columns(pl.int_range(pl.len()).cast(pl.UInt32).alias("code"))
93
116
 
94
- _BASE = _CHAR_TABLE.height # _BASE = 62
117
+ _BASE = _CHAR_TABLE.height # _BASE = 36
95
118
  _ZERO = _CHAR_TABLE.filter(pl.col("code") == 0).select("char").item() # _ZERO = "0"
96
119
 
97
120
  @property
@@ -109,7 +132,7 @@ class Base62Mapper(Mapper, ABC):
109
132
  query = pl.concat_str(
110
133
  pl.lit(self._prefix),
111
134
  pl.col(self._ID_COL).map_batches(
112
- Base62Mapper._to_base62,
135
+ Base36Mapper._to_base36,
113
136
  return_dtype=pl.String,
114
137
  is_elementwise=True,
115
138
  ),
@@ -121,24 +144,24 @@ class Base62Mapper(Mapper, ABC):
121
144
  return df.with_columns(query.alias(to_col))
122
145
 
123
146
  @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.
147
+ def _to_base36(cls, int_col: pl.Series) -> pl.Series:
148
+ """Returns a series of dtype str with a base 36 representation of the integers in int_col.
149
+ The letters 0-9A-Z are used as symbols for the representation.
127
150
 
128
151
  Examples:
129
152
 
130
153
  >>> import polars as pl
131
154
  >>> 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']
155
+ >>> Base36Mapper._to_base36(s).to_list()
156
+ ['0', 'a', 'k', '1o', '1h', '1u']
134
157
 
135
158
  >>> s = pl.Series([0], dtype=pl.UInt32)
136
- >>> Base62Mapper._to_base62(s).to_list()
159
+ >>> Base36Mapper._to_base36(s).to_list()
137
160
  ['0']
138
161
  """
139
162
  assert isinstance(
140
163
  int_col.dtype, pl.UInt32
141
- ), "_to_base62() only works for UInt32 id columns"
164
+ ), "_to_base36() only works for UInt32 id columns"
142
165
 
143
166
  largest_id = int_col.max()
144
167
  if largest_id == 0:
@@ -168,23 +191,23 @@ class Base62Mapper(Mapper, ABC):
168
191
  )
169
192
 
170
193
  def _element_to_map(self, element) -> pl.DataFrame:
171
- return self.apply(element.ids.select(self._ID_COL), to_col=NAME_COL)
194
+ return self.apply(element.ids.select(self._ID_COL), to_col=Mapper.NAME_COL)
172
195
 
173
196
 
174
- class Base62VarMapper(Base62Mapper):
197
+ class Base36VarMapper(Base36Mapper):
175
198
  """
176
199
  Examples:
177
200
  >>> import polars as pl
178
201
  >>> from pyoframe import Model, Variable
179
202
  >>> from pyoframe.constants import VAR_KEY
180
- >>> m = Model()
203
+ >>> m = Model("min")
181
204
  >>> m.x = Variable(pl.DataFrame({"t": range(1,63)}))
182
205
  >>> (m.x.filter(t=11)+1).to_str()
183
206
  '[11]: 1 + x[11]'
184
- >>> (m.x.filter(t=11)+1).to_str(var_map=Base62VarMapper(Variable))
207
+ >>> (m.x.filter(t=11)+1).to_str(var_map=Base36VarMapper(Variable))
185
208
  '[11]: 1 + xb'
186
209
 
187
- >>> Base62VarMapper(Variable).apply(pl.DataFrame({VAR_KEY: []}))
210
+ >>> Base36VarMapper(Variable).apply(pl.DataFrame({VAR_KEY: []}))
188
211
  shape: (0, 1)
189
212
  ┌───────────────┐
190
213
  │ __variable_id │
@@ -194,12 +217,21 @@ class Base62VarMapper(Base62Mapper):
194
217
  └───────────────┘
195
218
  """
196
219
 
220
+ def __init__(self, *args, **kwargs) -> None:
221
+ super().__init__(*args, **kwargs)
222
+ df = pl.DataFrame(
223
+ {self._ID_COL: [CONST_TERM]},
224
+ schema={self._ID_COL: pl.UInt32},
225
+ )
226
+ df = self.apply(df, to_col=Mapper.NAME_COL)
227
+ self._extend_registry(df)
228
+
197
229
  @property
198
230
  def _prefix(self) -> "str":
199
231
  return "x"
200
232
 
201
233
 
202
- class Base62ConstMapper(Base62Mapper):
234
+ class Base36ConstMapper(Base36Mapper):
203
235
 
204
236
  @property
205
237
  def _prefix(self) -> "str":
pyoframe/model.py CHANGED
@@ -1,29 +1,60 @@
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
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.variables import Variable
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
- def __init__(self, name=None):
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
+ "objective",
41
+ ]
42
+
43
+ def __init__(self, min_or_max: Union[ObjSense, ObjSenseValue], name=None, **kwargs):
44
+ super().__init__(**kwargs)
19
45
  self._variables: List[Variable] = []
20
46
  self._constraints: List[Constraint] = []
47
+ self.sense = ObjSense(min_or_max)
21
48
  self._objective: Optional[Objective] = None
22
49
  self.var_map = (
23
- NamedMapper(Variable) if Config.print_uses_variable_names else None
50
+ NamedVariableMapper(Variable) if Config.print_uses_variable_names else None
24
51
  )
25
52
  self.io_mappers: Optional[IOMappers] = None
26
53
  self.name = name
54
+ self.solver: Optional[Solver] = None
55
+ self.solver_model: Optional[Any] = None
56
+ self.params = Container()
57
+ self.result: Optional[Result] = None
27
58
 
28
59
  @property
29
60
  def variables(self) -> List[Variable]:
@@ -45,44 +76,37 @@ class Model:
45
76
  def objective(self):
46
77
  return self._objective
47
78
 
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
79
+ @objective.setter
80
+ def objective(self, value):
81
+ value = Objective(value)
82
+ self._objective = value
83
+ value.on_add_to_model(self, "objective")
57
84
 
58
85
  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
86
+ if __name not in Model._reserved_attributes and not isinstance(
87
+ __value, (ModelElement, pl.DataFrame, pd.DataFrame)
88
+ ):
89
+ raise PyoframeError(
90
+ f"Cannot set attribute '{__name}' on the model because it isn't of type ModelElement (e.g. Variable, Constraint, ...)"
91
+ )
92
+
93
+ if (
94
+ isinstance(__value, ModelElement)
95
+ and __name not in Model._reserved_attributes
96
+ ):
97
+ if isinstance(__value, ModelElementWithId):
98
+ assert not hasattr(
99
+ self, __name
100
+ ), f"Cannot create {__name} since it was already created."
101
+
102
+ __value.on_add_to_model(self, __name)
103
+
79
104
  if isinstance(__value, Variable):
80
105
  self._variables.append(__value)
81
106
  if self.var_map is not None:
82
107
  self.var_map.add(__value)
83
108
  elif isinstance(__value, Constraint):
84
109
  self._constraints.append(__value)
85
-
86
110
  return super().__setattr__(__name, __value)
87
111
 
88
112
  def __repr__(self) -> str: