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/__init__.py +21 -14
- pyoframe/_arithmetic.py +265 -159
- pyoframe/_constants.py +416 -0
- pyoframe/_core.py +2575 -0
- pyoframe/_model.py +578 -0
- pyoframe/_model_element.py +175 -0
- pyoframe/_monkey_patch.py +80 -0
- pyoframe/{objective.py → _objective.py} +49 -14
- pyoframe/{util.py → _utils.py} +106 -126
- pyoframe/_version.py +16 -3
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/METADATA +32 -25
- pyoframe-1.0.0a0.dist-info/RECORD +15 -0
- pyoframe/constants.py +0 -140
- pyoframe/core.py +0 -1794
- pyoframe/model.py +0 -408
- pyoframe/model_element.py +0 -184
- pyoframe/monkey_patch.py +0 -54
- pyoframe-0.2.0.dist-info/RECORD +0 -15
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/WHEEL +0 -0
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/licenses/LICENSE +0 -0
- {pyoframe-0.2.0.dist-info → pyoframe-1.0.0a0.dist-info}/top_level.txt +0 -0
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
|