pyoframe 0.2.0__py3-none-any.whl → 1.0.0a0__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/_constants.py ADDED
@@ -0,0 +1,416 @@
1
+ """Contains shared constants which are used across the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Literal
9
+
10
+ import polars as pl
11
+ import pyoptinterface as poi
12
+
13
+ COEF_KEY = "__coeff"
14
+ VAR_KEY = "__variable_id"
15
+ QUAD_VAR_KEY = "__quadratic_variable_id"
16
+ CONSTRAINT_KEY = "__constraint_id"
17
+ SOLUTION_KEY = "solution"
18
+ DUAL_KEY = "dual"
19
+
20
+ # TODO: move as configuration since this could be too small... also add a test to make sure errors occur on overflow.
21
+ KEY_TYPE = pl.UInt32
22
+
23
+
24
+ @dataclass
25
+ class _Solver:
26
+ name: SUPPORTED_SOLVER_TYPES
27
+ supports_integer_variables: bool = True
28
+ supports_quadratics: bool = True
29
+ supports_duals: bool = True
30
+ supports_objective_sense: bool = True
31
+ supports_write: bool = True
32
+ block_auto_names: bool = False
33
+ """
34
+ When True, Pyoframe blocks automatic variable and constraint name
35
+ generation to improve performance by setting all the variable names to 'V'
36
+ and all the constraint names to 'C'. This should only be True for solvers
37
+ that support conflicting variable and constraint names. Benchmarking
38
+ should be performed to verify that this improves performance before turning
39
+ this on for other solvers.
40
+ """
41
+
42
+ def __repr__(self):
43
+ return self.name
44
+
45
+
46
+ SUPPORTED_SOLVERS = [
47
+ _Solver("gurobi", block_auto_names=True),
48
+ _Solver("highs", supports_quadratics=False, supports_duals=False),
49
+ _Solver(
50
+ "ipopt",
51
+ supports_integer_variables=False,
52
+ supports_objective_sense=False,
53
+ supports_write=False,
54
+ ),
55
+ ]
56
+
57
+
58
+ # Variable ID for constant terms. This variable ID is reserved.
59
+ CONST_TERM = 0
60
+
61
+ RESERVED_COL_KEYS = (
62
+ COEF_KEY,
63
+ VAR_KEY,
64
+ QUAD_VAR_KEY,
65
+ CONSTRAINT_KEY,
66
+ SOLUTION_KEY,
67
+ DUAL_KEY,
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class ConfigDefaults:
73
+ default_solver: SUPPORTED_SOLVER_TYPES | _Solver | Literal["raise", "auto"] = "auto"
74
+ disable_unmatched_checks: bool = False
75
+ enable_is_duplicated_expression_safety_check: bool = False
76
+ integer_tolerance: float = 1e-8
77
+ float_to_str_precision: int | None = 5
78
+ print_polars_config: pl.Config = field(
79
+ default_factory=lambda: pl.Config(
80
+ tbl_hide_column_data_types=True,
81
+ tbl_hide_dataframe_shape=True,
82
+ fmt_str_lengths=100, # Set to a large value to avoid truncation (within reason)
83
+ apply_on_context_enter=True,
84
+ )
85
+ )
86
+ print_max_terms: int = 5
87
+ maintain_order: bool = True
88
+
89
+
90
+ class _Config:
91
+ """General settings for Pyoframe (for advanced users).
92
+
93
+ Accessible via `pf.Config` (see examples below).
94
+ """
95
+
96
+ def __init__(self):
97
+ self._settings = ConfigDefaults()
98
+
99
+ @property
100
+ def default_solver(
101
+ self,
102
+ ) -> SUPPORTED_SOLVER_TYPES | _Solver | Literal["raise", "auto"]:
103
+ """The solver to use when [Model][pyoframe.Model] is instantiated without specifying a solver.
104
+
105
+ If `auto`, Pyoframe will try to use whichever solver is installed.
106
+ If `raise`, an exception will be raised when [Model][pyoframe.Model] is instantiated without specifying a solver.
107
+
108
+ We recommend that users specify their solver when instantiating [Model][pyoframe.Model] rather than relying on this option.
109
+ """
110
+ return self._settings.default_solver
111
+
112
+ @default_solver.setter
113
+ def default_solver(self, value):
114
+ self._settings.default_solver = value
115
+
116
+ @property
117
+ def disable_unmatched_checks(self) -> bool:
118
+ """When `True`, improves performance by skipping unmatched checks (not recommended).
119
+
120
+ When `True`, unmatched checks are disabled which effectively means that all expressions
121
+ are treated as if they contained [`.keep_unmatched()`][pyoframe.Expression.keep_unmatched]
122
+ (unless [`.drop_unmatched()`][pyoframe.Expression.drop_unmatched] was applied).
123
+
124
+ !!! warning
125
+ This might improve performance, but it will suppress the "unmatched" errors that alert developers to unexpected
126
+ behaviors (see [here](../learn/concepts/special-functions.md#drop_unmatched-and-keep_unmatched)).
127
+ Only consider enabling after you have thoroughly tested your code.
128
+
129
+ Examples:
130
+ >>> import polars as pl
131
+ >>> population = pl.DataFrame(
132
+ ... {
133
+ ... "city": ["Toronto", "Vancouver", "Montreal"],
134
+ ... "pop": [2_731_571, 631_486, 1_704_694],
135
+ ... }
136
+ ... ).to_expr()
137
+ >>> population_influx = pl.DataFrame(
138
+ ... {
139
+ ... "city": ["Toronto", "Vancouver", "Montreal"],
140
+ ... "influx": [100_000, 50_000, None],
141
+ ... }
142
+ ... ).to_expr()
143
+
144
+ Normally, an error warns users that the two expressions have conflicting indices:
145
+ >>> population + population_influx
146
+ Traceback (most recent call last):
147
+ ...
148
+ pyoframe._constants.PyoframeError: Cannot add the two expressions below because of unmatched values.
149
+ Expression 1: pop
150
+ Expression 2: influx
151
+ Unmatched values:
152
+ shape: (1, 2)
153
+ ┌──────────┬────────────┐
154
+ │ city ┆ city_right │
155
+ │ --- ┆ --- │
156
+ │ str ┆ str │
157
+ ╞══════════╪════════════╡
158
+ │ Montreal ┆ null │
159
+ └──────────┴────────────┘
160
+ If this is intentional, use .drop_unmatched() or .keep_unmatched().
161
+
162
+ But if `Config.disable_unmatched_checks = True`, the error is suppressed and the sum is considered to be `population.keep_unmatched() + population_influx.keep_unmatched()`:
163
+ >>> pf.Config.disable_unmatched_checks = True
164
+ >>> population + population_influx
165
+ <Expression height=3 terms=3 type=constant>
166
+ ┌───────────┬────────────┐
167
+ │ city ┆ expression │
168
+ │ (3) ┆ │
169
+ ╞═══════════╪════════════╡
170
+ │ Toronto ┆ 2831571 │
171
+ │ Vancouver ┆ 681486 │
172
+ │ Montreal ┆ 1704694 │
173
+ └───────────┴────────────┘
174
+ """
175
+ return self._settings.disable_unmatched_checks
176
+
177
+ @disable_unmatched_checks.setter
178
+ def disable_unmatched_checks(self, value: bool):
179
+ self._settings.disable_unmatched_checks = value
180
+
181
+ @property
182
+ def enable_is_duplicated_expression_safety_check(self) -> bool:
183
+ """Setting for internal testing purposes only.
184
+
185
+ When `True`, pyoframe checks that there are no bugs leading to duplicated terms in expressions.
186
+ """
187
+ return self._settings.enable_is_duplicated_expression_safety_check
188
+
189
+ @enable_is_duplicated_expression_safety_check.setter
190
+ def enable_is_duplicated_expression_safety_check(self, value: bool):
191
+ self._settings.enable_is_duplicated_expression_safety_check = value
192
+
193
+ @property
194
+ def integer_tolerance(self) -> float:
195
+ """Tolerance for checking if a floating point value is an integer.
196
+
197
+ !!! info
198
+ For convenience, Pyoframe returns the solution of integer and binary variables as integers not floating point values.
199
+ To do so, Pyoframe must convert the solver-provided floating point values to integers. To avoid unexpected rounding errors,
200
+ Pyoframe uses this tolerance to check that the floating point result is an integer as expected. Overly tight tolerances can trigger
201
+ unexpected errors. Setting the tolerance to zero disables the check.
202
+ """
203
+ return self._settings.integer_tolerance
204
+
205
+ @integer_tolerance.setter
206
+ def integer_tolerance(self, value: float):
207
+ self._settings.integer_tolerance = value
208
+
209
+ @property
210
+ def float_to_str_precision(self) -> int | None:
211
+ """Number of decimal places to use when displaying mathematical expressions.
212
+
213
+ Examples:
214
+ >>> pf.Config.float_to_str_precision = 3
215
+ >>> m = pf.Model()
216
+ >>> m.X = pf.Variable()
217
+ >>> expr = 100.752038759 * m.X
218
+ >>> expr
219
+ <Expression terms=1 type=linear>
220
+ 100.752 X
221
+ >>> pf.Config.float_to_str_precision = None
222
+ >>> expr
223
+ <Expression terms=1 type=linear>
224
+ 100.752038759 X
225
+ """
226
+ return self._settings.float_to_str_precision
227
+
228
+ @float_to_str_precision.setter
229
+ def float_to_str_precision(self, value: int | None):
230
+ self._settings.float_to_str_precision = value
231
+
232
+ @property
233
+ def print_polars_config(self) -> pl.Config:
234
+ """[`polars.Config`](https://docs.pola.rs/api/python/stable/reference/config.html) object to use when printing dimensioned Pyoframe objects.
235
+
236
+ Examples:
237
+ For example, to limit the number of rows printed in a table, use `set_tbl_rows`:
238
+ >>> pf.Config.print_polars_config.set_tbl_rows(5)
239
+ <class 'polars.config.Config'>
240
+ >>> m = pf.Model()
241
+ >>> m.X = pf.Variable(pf.Set(x=range(100)))
242
+ >>> m.X
243
+ <Variable 'X' height=100>
244
+ ┌───────┬──────────┐
245
+ │ x ┆ variable │
246
+ │ (100) ┆ │
247
+ ╞═══════╪══════════╡
248
+ │ 0 ┆ X[0] │
249
+ │ 1 ┆ X[1] │
250
+ │ 2 ┆ X[2] │
251
+ │ … ┆ … │
252
+ │ 98 ┆ X[98] │
253
+ │ 99 ┆ X[99] │
254
+ └───────┴──────────┘
255
+ """
256
+ return self._settings.print_polars_config
257
+
258
+ @print_polars_config.setter
259
+ def print_polars_config(self, value: pl.Config):
260
+ self._settings.print_polars_config = value
261
+
262
+ @property
263
+ def print_max_terms(self) -> int:
264
+ """Maximum number of terms to print in an expression before truncating it.
265
+
266
+ Examples:
267
+ >>> pf.Config.print_max_terms = 3
268
+ >>> m = pf.Model()
269
+ >>> m.X = pf.Variable(pf.Set(x=range(100)), pf.Set(y=range(100)))
270
+ >>> m.X.sum("y")
271
+ <Expression height=100 terms=10000 type=linear>
272
+ ┌───────┬───────────────────────────────┐
273
+ │ x ┆ expression │
274
+ │ (100) ┆ │
275
+ ╞═══════╪═══════════════════════════════╡
276
+ │ 0 ┆ X[0,0] + X[0,1] + X[0,2] … │
277
+ │ 1 ┆ X[1,0] + X[1,1] + X[1,2] … │
278
+ │ 2 ┆ X[2,0] + X[2,1] + X[2,2] … │
279
+ │ 3 ┆ X[3,0] + X[3,1] + X[3,2] … │
280
+ │ 4 ┆ X[4,0] + X[4,1] + X[4,2] … │
281
+ │ … ┆ … │
282
+ │ 95 ┆ X[95,0] + X[95,1] + X[95,2] … │
283
+ │ 96 ┆ X[96,0] + X[96,1] + X[96,2] … │
284
+ │ 97 ┆ X[97,0] + X[97,1] + X[97,2] … │
285
+ │ 98 ┆ X[98,0] + X[98,1] + X[98,2] … │
286
+ │ 99 ┆ X[99,0] + X[99,1] + X[99,2] … │
287
+ └───────┴───────────────────────────────┘
288
+ >>> m.X.sum()
289
+ <Expression terms=10000 type=linear>
290
+ X[0,0] + X[0,1] + X[0,2] …
291
+ """
292
+ return self._settings.print_max_terms
293
+
294
+ @print_max_terms.setter
295
+ def print_max_terms(self, value: int):
296
+ self._settings.print_max_terms = value
297
+
298
+ @property
299
+ def maintain_order(self) -> bool:
300
+ """Whether the order of variables, constraints, and mathematical terms is to be identical across runs.
301
+
302
+ If `False`, performance is improved, but your results may vary every so slightly across runs
303
+ since numerical errors can accumulate differently when the order of operations changes.
304
+ """
305
+ return self._settings.maintain_order
306
+
307
+ @maintain_order.setter
308
+ def maintain_order(self, value: bool):
309
+ self._settings.maintain_order = value
310
+
311
+ def reset_defaults(self):
312
+ """Resets all configuration options to their default values.
313
+
314
+ Examples:
315
+ >>> pf.Config.disable_unmatched_checks
316
+ False
317
+ >>> pf.Config.disable_unmatched_checks = True
318
+ >>> pf.Config.disable_unmatched_checks
319
+ True
320
+ >>> pf.Config.reset_defaults()
321
+ >>> pf.Config.disable_unmatched_checks
322
+ False
323
+ """
324
+ self._settings = ConfigDefaults()
325
+
326
+
327
+ Config = _Config()
328
+
329
+
330
+ class ConstraintSense(Enum):
331
+ LE = "<="
332
+ GE = ">="
333
+ EQ = "="
334
+
335
+ def _to_poi(self):
336
+ """Converts the constraint sense to its pyoptinterface equivalent."""
337
+ if self == ConstraintSense.LE:
338
+ return poi.ConstraintSense.LessEqual
339
+ elif self == ConstraintSense.EQ:
340
+ return poi.ConstraintSense.Equal
341
+ elif self == ConstraintSense.GE:
342
+ return poi.ConstraintSense.GreaterEqual
343
+ else:
344
+ raise ValueError(f"Invalid constraint type: {self}") # pragma: no cover
345
+
346
+
347
+ class ObjSense(Enum):
348
+ MIN = "min"
349
+ MAX = "max"
350
+
351
+ def _to_poi(self):
352
+ """Converts the objective sense to its pyoptinterface equivalent."""
353
+ if self == ObjSense.MIN:
354
+ return poi.ObjectiveSense.Minimize
355
+ elif self == ObjSense.MAX:
356
+ return poi.ObjectiveSense.Maximize
357
+ else:
358
+ raise ValueError(f"Invalid objective sense: {self}") # pragma: no cover
359
+
360
+
361
+ class VType(Enum):
362
+ """An [Enum](https://realpython.com/python-enum/) that can be used to specify the variable type.
363
+
364
+ Examples:
365
+ >>> m = pf.Model()
366
+ >>> m.X = pf.Variable(vtype=VType.BINARY)
367
+
368
+ The enum's string values can also be used directly although this is prone to typos:
369
+
370
+ >>> m.Y = pf.Variable(vtype="binary")
371
+ """
372
+
373
+ CONTINUOUS = "continuous"
374
+ """Variables that can be any real value."""
375
+ BINARY = "binary"
376
+ """Variables that must be either 0 or 1."""
377
+ INTEGER = "integer"
378
+ """Variables that must be integer values."""
379
+
380
+ def _to_poi(self):
381
+ """Convert the Variable type to its pyoptinterface equivalent."""
382
+ if self == VType.CONTINUOUS:
383
+ return poi.VariableDomain.Continuous
384
+ elif self == VType.BINARY:
385
+ return poi.VariableDomain.Binary
386
+ elif self == VType.INTEGER:
387
+ return poi.VariableDomain.Integer
388
+ else:
389
+ raise ValueError(f"Invalid variable type: {self}") # pragma: no cover
390
+
391
+
392
+ class UnmatchedStrategy(Enum):
393
+ """An enum to specify how to handle unmatched values in expressions."""
394
+
395
+ UNSET = "not_set"
396
+ DROP = "drop"
397
+ KEEP = "keep"
398
+
399
+
400
+ # This is a hack to get the Literal type for VType
401
+ # See: https://stackoverflow.com/questions/67292470/type-hinting-enum-member-value-in-python
402
+ ObjSenseValue = Literal["min", "max"]
403
+ VTypeValue = Literal["continuous", "binary", "integer"]
404
+ for enum, type in [(ObjSense, ObjSenseValue), (VType, VTypeValue)]:
405
+ assert set(typing.get_args(type)) == {vtype.value for vtype in enum}
406
+
407
+ SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs", "ipopt"]
408
+ assert set(typing.get_args(SUPPORTED_SOLVER_TYPES)) == {
409
+ s.name for s in SUPPORTED_SOLVERS
410
+ }
411
+
412
+
413
+ class PyoframeError(Exception):
414
+ """Class for all Pyoframe-specific errors, typically errors arising from improper arithmetic operations."""
415
+
416
+ pass