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