pyoframe 0.2.1__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/_model.py ADDED
@@ -0,0 +1,578 @@
1
+ """Defines the `Model` class for Pyoframe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import pandas as pd
9
+ import polars as pl
10
+ import pyoptinterface as poi
11
+
12
+ from pyoframe._constants import (
13
+ CONST_TERM,
14
+ SUPPORTED_SOLVER_TYPES,
15
+ SUPPORTED_SOLVERS,
16
+ Config,
17
+ ObjSense,
18
+ ObjSenseValue,
19
+ PyoframeError,
20
+ VType,
21
+ _Solver,
22
+ )
23
+ from pyoframe._core import Constraint, SupportsToExpr, Variable
24
+ from pyoframe._model_element import ModelElement, ModelElementWithId
25
+ from pyoframe._objective import Objective
26
+ from pyoframe._utils import Container, NamedVariableMapper, for_solvers, get_obj_repr
27
+
28
+ if TYPE_CHECKING: # pragma: no cover
29
+ from collections.abc import Generator
30
+
31
+
32
+ class Model:
33
+ """The founding block of any Pyoframe optimization model onto which variables, constraints, and an objective can be added.
34
+
35
+ Parameters:
36
+ solver:
37
+ The solver to use. If `None`, Pyoframe will try to use whichever solver is installed
38
+ (unless [Config.default_solver][pyoframe._Config.default_solver] was changed from its default value of `auto`).
39
+ solver_env:
40
+ Gurobi only: a dictionary of parameters to set when creating the Gurobi environment.
41
+ name:
42
+ The name of the model. Currently it is not used for much.
43
+ solver_uses_variable_names:
44
+ If `True`, the solver will use your custom variable names in its outputs (e.g. during [`Model.write()`][pyoframe.Model.write]).
45
+ This can be useful for debugging `.lp`, `.sol`, and `.ilp` files, but may worsen performance.
46
+ print_uses_variable_names:
47
+ If `True`, pyoframe will use your custom variables names when printing elements of the model to the console.
48
+ This is useful for debugging, but may slightly worsen performance.
49
+ sense:
50
+ Either "min" or "max". Indicates whether it's a minimization or maximization problem.
51
+ Typically, this parameter can be omitted (`None`) as it will automatically be
52
+ set when the objective is set using `.minimize` or `.maximize`.
53
+
54
+ Examples:
55
+ >>> m = pf.Model()
56
+ >>> m.X = pf.Variable()
57
+ >>> m.my_constraint = m.X <= 10
58
+ >>> m
59
+ <Model vars=1 constrs=1 has_objective=False solver=gurobi>
60
+
61
+ Use `solver_env` to, for example, connect to a Gurobi Compute Server:
62
+ >>> m = pf.Model(
63
+ ... "gurobi",
64
+ ... solver_env=dict(ComputeServer="myserver", ServerPassword="mypassword"),
65
+ ... )
66
+ Traceback (most recent call last):
67
+ ...
68
+ RuntimeError: Could not resolve host: myserver (code 6, command POST http://myserver/api/v1/cluster/jobs)
69
+ """
70
+
71
+ _reserved_attributes = [
72
+ "_variables",
73
+ "_constraints",
74
+ "_objective",
75
+ "objective",
76
+ "_var_map",
77
+ "name",
78
+ "solver",
79
+ "poi",
80
+ "_params",
81
+ "params",
82
+ "_attr",
83
+ "attr",
84
+ "sense",
85
+ "_solver_uses_variable_names",
86
+ "ONE",
87
+ "solver_name",
88
+ "minimize",
89
+ "maximize",
90
+ ]
91
+
92
+ def __init__(
93
+ self,
94
+ solver: SUPPORTED_SOLVER_TYPES | _Solver | None = None,
95
+ solver_env: dict[str, str] | None = None,
96
+ *,
97
+ name: str | None = None,
98
+ solver_uses_variable_names: bool = False,
99
+ print_uses_variable_names: bool = True,
100
+ sense: ObjSense | ObjSenseValue | None = None,
101
+ ):
102
+ self.poi, self.solver = Model._create_poi_model(solver, solver_env)
103
+ self.solver_name: str = self.solver.name
104
+ self._variables: list[Variable] = []
105
+ self._constraints: list[Constraint] = []
106
+ self.sense: ObjSense | None = ObjSense(sense) if sense is not None else None
107
+ self._objective: Objective | None = None
108
+ self._var_map = (
109
+ NamedVariableMapper(Variable) if print_uses_variable_names else None
110
+ )
111
+ self.name: str | None = name
112
+
113
+ self._params = Container(self._set_param, self._get_param)
114
+ self._attr = Container(self._set_attr, self._get_attr)
115
+ self._solver_uses_variable_names = solver_uses_variable_names
116
+
117
+ @property
118
+ def solver_uses_variable_names(self):
119
+ """Whether to pass human-readable variable names to the solver."""
120
+ return self._solver_uses_variable_names
121
+
122
+ @property
123
+ def attr(self) -> Container:
124
+ """An object that allows reading and writing model attributes.
125
+
126
+ Several model attributes are common across all solvers making it easy to switch between solvers (see supported attributes for
127
+ [Gurobi](https://metab0t.github.io/PyOptInterface/gurobi.html#supported-model-attribute),
128
+ [HiGHS](https://metab0t.github.io/PyOptInterface/highs.html), and
129
+ [Ipopt](https://metab0t.github.io/PyOptInterface/ipopt.html)).
130
+
131
+ We additionally support all of [Gurobi's attributes](https://docs.gurobi.com/projects/optimizer/en/current/reference/attributes.html#sec:Attributes) when using Gurobi.
132
+
133
+ Examples:
134
+ >>> m = pf.Model()
135
+ >>> m.v = pf.Variable(lb=1, ub=1, vtype="integer")
136
+ >>> m.attr.Silent = True # Prevent solver output from being printed
137
+ >>> m.optimize()
138
+ >>> m.attr.TerminationStatus
139
+ <TerminationStatusCode.OPTIMAL: 2>
140
+
141
+ Some attributes, like `NumVars`, are solver-specific.
142
+ >>> m = pf.Model("gurobi")
143
+ >>> m.attr.NumConstrs
144
+ 0
145
+ >>> m = pf.Model("highs")
146
+ >>> m.attr.NumConstrs
147
+ Traceback (most recent call last):
148
+ ...
149
+ KeyError: 'NumConstrs'
150
+
151
+ See Also:
152
+ [Variable.attr][pyoframe.Variable.attr] for setting variable attributes and
153
+ [Constraint.attr][pyoframe.Constraint.attr] for setting constraint attributes.
154
+ """
155
+ return self._attr
156
+
157
+ @property
158
+ def params(self) -> Container:
159
+ """An object that allows reading and writing solver-specific parameters.
160
+
161
+ See the list of available parameters for
162
+ [Gurobi](https://docs.gurobi.com/projects/optimizer/en/current/reference/parameters.html#sec:Parameters),
163
+ [HiGHS](https://ergo-code.github.io/HiGHS/stable/options/definitions/),
164
+ and [Ipopt](https://coin-or.github.io/Ipopt/OPTIONS.html).
165
+
166
+ Examples:
167
+ For example, if you'd like to use Gurobi's barrier method, you can set the `Method` parameter:
168
+ >>> m = pf.Model("gurobi")
169
+ >>> m.params.Method = 2
170
+ """
171
+ return self._params
172
+
173
+ @classmethod
174
+ def _create_poi_model(
175
+ cls, solver: str | _Solver | None, solver_env: dict[str, str] | None
176
+ ):
177
+ if solver is None:
178
+ if Config.default_solver == "raise":
179
+ raise ValueError(
180
+ "No solver specified during model construction and automatic solver detection is disabled."
181
+ )
182
+ elif Config.default_solver == "auto":
183
+ for solver_option in SUPPORTED_SOLVERS:
184
+ try:
185
+ return cls._create_poi_model(solver_option, solver_env)
186
+ except RuntimeError:
187
+ pass
188
+ raise RuntimeError(
189
+ 'Could not automatically find a solver. Is one installed? If so, specify which one: e.g. Model("gurobi")'
190
+ )
191
+ elif isinstance(Config.default_solver, (_Solver, str)):
192
+ solver = Config.default_solver
193
+ else:
194
+ raise ValueError(
195
+ f"Config.default_solver has an invalid value: {Config.default_solver}."
196
+ )
197
+
198
+ if isinstance(solver, str):
199
+ solver = solver.lower()
200
+ for s in SUPPORTED_SOLVERS:
201
+ if s.name == solver:
202
+ solver = s
203
+ break
204
+ else:
205
+ raise ValueError(
206
+ f"Unsupported solver: '{solver}'. Supported solvers are: {', '.join(s.name for s in SUPPORTED_SOLVERS)}."
207
+ )
208
+
209
+ if solver.name == "gurobi":
210
+ from pyoptinterface import gurobi
211
+
212
+ if solver_env is None:
213
+ env = gurobi.Env()
214
+ else:
215
+ env = gurobi.Env(empty=True)
216
+ for key, value in solver_env.items():
217
+ env.set_raw_parameter(key, value)
218
+ env.start()
219
+ model = gurobi.Model(env)
220
+ elif solver.name == "highs":
221
+ from pyoptinterface import highs
222
+
223
+ model = highs.Model()
224
+ elif solver.name == "ipopt":
225
+ try:
226
+ from pyoptinterface import ipopt
227
+ except ModuleNotFoundError as e: # pragma: no cover
228
+ raise ModuleNotFoundError(
229
+ "Failed to import the Ipopt solver. Did you run `pip install pyoptinterface[ipopt]`?"
230
+ ) from e
231
+
232
+ try:
233
+ model = ipopt.Model()
234
+ except RuntimeError as e: # pragma: no cover
235
+ if "IPOPT library is not loaded" in str(e):
236
+ raise RuntimeError(
237
+ "Could not find the Ipopt solver. Are you sure you've properly installed it and added it to your PATH?"
238
+ ) from e
239
+ raise e
240
+ else:
241
+ raise ValueError(
242
+ f"Solver {solver} not recognized or supported."
243
+ ) # pragma: no cover
244
+
245
+ constant_var = model.add_variable(lb=1, ub=1, name="ONE")
246
+ assert constant_var.index == CONST_TERM, (
247
+ "The first variable should have index 0."
248
+ )
249
+ return model, solver
250
+
251
+ @property
252
+ def variables(self) -> list[Variable]:
253
+ """Returns a list of the model's variables."""
254
+ return self._variables
255
+
256
+ @property
257
+ def binary_variables(self) -> Generator[Variable]:
258
+ """Returns the model's binary variables.
259
+
260
+ Examples:
261
+ >>> m = pf.Model()
262
+ >>> m.X = pf.Variable(vtype=pf.VType.BINARY)
263
+ >>> m.Y = pf.Variable()
264
+ >>> len(list(m.binary_variables))
265
+ 1
266
+ """
267
+ return (v for v in self.variables if v.vtype == VType.BINARY)
268
+
269
+ @property
270
+ def integer_variables(self) -> Generator[Variable]:
271
+ """Returns the model's integer variables.
272
+
273
+ Examples:
274
+ >>> m = pf.Model()
275
+ >>> m.X = pf.Variable(vtype=pf.VType.INTEGER)
276
+ >>> m.Y = pf.Variable()
277
+ >>> len(list(m.integer_variables))
278
+ 1
279
+ """
280
+ return (v for v in self.variables if v.vtype == VType.INTEGER)
281
+
282
+ @property
283
+ def constraints(self) -> list[Constraint]:
284
+ """Returns the model's constraints."""
285
+ return self._constraints
286
+
287
+ @property
288
+ def has_objective(self) -> bool:
289
+ """Returns whether the model's objective has been defined.
290
+
291
+ Examples:
292
+ >>> m = pf.Model()
293
+ >>> m.has_objective
294
+ False
295
+ >>> m.X = pf.Variable()
296
+ >>> m.maximize = m.X
297
+ >>> m.has_objective
298
+ True
299
+ """
300
+ return self._objective is not None
301
+
302
+ @property
303
+ def objective(self) -> Objective:
304
+ """Returns the model's objective.
305
+
306
+ Raises:
307
+ ValueError: If the objective has not been defined.
308
+
309
+ Examples:
310
+ >>> m = pf.Model()
311
+ >>> m.X = pf.Variable()
312
+ >>> m.objective
313
+ Traceback (most recent call last):
314
+ ...
315
+ ValueError: Objective is not defined.
316
+ >>> m.maximize = m.X
317
+ >>> m.objective
318
+ <Objective terms=1 type=linear>
319
+ X
320
+
321
+ See Also:
322
+ [`Model.has_objective`][pyoframe.Model.has_objective]
323
+ """
324
+ if self._objective is None:
325
+ raise ValueError("Objective is not defined.")
326
+ return self._objective
327
+
328
+ @objective.setter
329
+ def objective(self, value: SupportsToExpr | float | int):
330
+ if self.has_objective and (
331
+ not isinstance(value, Objective) or not value._constructive
332
+ ):
333
+ raise ValueError("An objective already exists. Use += or -= to modify it.")
334
+ if not isinstance(value, Objective):
335
+ value = Objective(value)
336
+ self._objective = value
337
+ value._on_add_to_model(self, "objective")
338
+
339
+ @property
340
+ def minimize(self) -> Objective | None:
341
+ """Sets or gets the model's objective for minimization problems."""
342
+ if self.sense != ObjSense.MIN:
343
+ raise ValueError("Can't get .minimize in a maximization problem.")
344
+ return self._objective
345
+
346
+ @minimize.setter
347
+ def minimize(self, value: SupportsToExpr | float | int):
348
+ if self.sense is None:
349
+ self.sense = ObjSense.MIN
350
+ if self.sense != ObjSense.MIN:
351
+ raise ValueError("Can't set .minimize in a maximization problem.")
352
+ self.objective = value
353
+
354
+ @property
355
+ def maximize(self) -> Objective | None:
356
+ """Sets or gets the model's objective for maximization problems."""
357
+ if self.sense != ObjSense.MAX:
358
+ raise ValueError("Can't get .maximize in a minimization problem.")
359
+ return self._objective
360
+
361
+ @maximize.setter
362
+ def maximize(self, value: SupportsToExpr | float | int):
363
+ if self.sense is None:
364
+ self.sense = ObjSense.MAX
365
+ if self.sense != ObjSense.MAX:
366
+ raise ValueError("Can't set .maximize in a minimization problem.")
367
+ self.objective = value
368
+
369
+ def __setattr__(self, __name: str, __value: Any) -> None:
370
+ if __name not in Model._reserved_attributes and not isinstance(
371
+ __value, (ModelElement, pl.DataFrame, pd.DataFrame)
372
+ ):
373
+ raise PyoframeError(
374
+ f"Cannot set attribute '{__name}' on the model because it isn't of type ModelElement (e.g. Variable, Constraint, ...)"
375
+ )
376
+
377
+ if (
378
+ isinstance(__value, ModelElement)
379
+ and __name not in Model._reserved_attributes
380
+ ):
381
+ if isinstance(__value, ModelElementWithId):
382
+ assert not hasattr(self, __name), (
383
+ f"Cannot create {__name} since it was already created."
384
+ )
385
+
386
+ __value._on_add_to_model(self, __name)
387
+
388
+ if isinstance(__value, Variable):
389
+ self._variables.append(__value)
390
+ if self._var_map is not None:
391
+ self._var_map.add(__value)
392
+ elif isinstance(__value, Constraint):
393
+ self._constraints.append(__value)
394
+ return super().__setattr__(__name, __value)
395
+
396
+ # Defining a custom __getattribute__ prevents type checkers from complaining about attribute access
397
+ def __getattribute__(self, name: str) -> Any:
398
+ return super().__getattribute__(name)
399
+
400
+ def __repr__(self) -> str:
401
+ return get_obj_repr(
402
+ self,
403
+ self.name,
404
+ vars=len(self.variables),
405
+ constrs=len(self.constraints),
406
+ has_objective=self.has_objective,
407
+ solver=self.solver_name,
408
+ )
409
+
410
+ def write(self, file_path: Path | str, pretty: bool = False):
411
+ """Outputs the model or the solution to a file (e.g. a `.lp`, `.sol`, `.mps`, or `.ilp` file).
412
+
413
+ These files can be useful for manually debugging a model.
414
+ Consult your solver documentation to learn more.
415
+
416
+ When creating your model, set [`solver_uses_variable_names`][pyoframe.Model]
417
+ to make the outputed file human-readable.
418
+
419
+ ```python
420
+ m = pf.Model(solver_uses_variable_names=True)
421
+ ```
422
+
423
+ For Gurobi, `solver_uses_variable_names=True` is mandatory when using
424
+ .write(). This may become mandatory for other solvers too without notice.
425
+
426
+ Parameters:
427
+ file_path:
428
+ The path to the file to write to.
429
+ pretty:
430
+ Only used when writing .sol files in HiGHS. If `True`, will use HiGH's pretty print columnar style which contains more information.
431
+ """
432
+ if not self.solver.supports_write:
433
+ raise NotImplementedError(f"{self.solver.name} does not support .write()")
434
+ if not self.solver_uses_variable_names and self.solver.block_auto_names:
435
+ raise ValueError(
436
+ f"{self.solver.name} requires solver_uses_variable_names=True to use .write()"
437
+ )
438
+
439
+ file_path = Path(file_path)
440
+ file_path.parent.mkdir(parents=True, exist_ok=True)
441
+
442
+ kwargs = {}
443
+ if self.solver.name == "highs":
444
+ if self.solver_uses_variable_names:
445
+ self.params.write_solution_style = 1
446
+ kwargs["pretty"] = pretty
447
+ self.poi.write(str(file_path), **kwargs)
448
+
449
+ def optimize(self):
450
+ """Optimizes the model using your selected solver (e.g. Gurobi, HiGHS)."""
451
+ self.poi.optimize()
452
+
453
+ @for_solvers("gurobi")
454
+ def convert_to_fixed(self) -> None:
455
+ """Gurobi only: Converts a mixed integer program into a continuous one by fixing all the non-continuous variables to their solution values.
456
+
457
+ !!! warning "Gurobi only"
458
+ This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
459
+
460
+ Examples:
461
+ >>> m = pf.Model("gurobi")
462
+ >>> m.X = pf.Variable(vtype=pf.VType.BINARY, lb=0)
463
+ >>> m.Y = pf.Variable(vtype=pf.VType.INTEGER, lb=0)
464
+ >>> m.Z = pf.Variable(lb=0)
465
+ >>> m.my_constraint = m.X + m.Y + m.Z <= 10
466
+ >>> m.maximize = 3 * m.X + 2 * m.Y + m.Z
467
+ >>> m.optimize()
468
+ >>> m.X.solution, m.Y.solution, m.Z.solution
469
+ (1, 9, 0.0)
470
+ >>> m.my_constraint.dual
471
+ Traceback (most recent call last):
472
+ ...
473
+ RuntimeError: Unable to retrieve attribute 'Pi'
474
+ >>> m.convert_to_fixed()
475
+ >>> m.optimize()
476
+ >>> m.my_constraint.dual
477
+ 1.0
478
+
479
+ Only works for Gurobi:
480
+
481
+ >>> m = pf.Model("highs")
482
+ >>> m.convert_to_fixed()
483
+ Traceback (most recent call last):
484
+ ...
485
+ NotImplementedError: Method 'convert_to_fixed' is not implemented for solver 'highs'.
486
+ """
487
+ self.poi._converttofixed()
488
+
489
+ @for_solvers("gurobi", "copt")
490
+ def compute_IIS(self):
491
+ """Gurobi only: Computes the Irreducible Infeasible Set (IIS) of the model.
492
+
493
+ !!! warning "Gurobi only"
494
+ This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
495
+
496
+ Examples:
497
+ >>> m = pf.Model("gurobi")
498
+ >>> m.X = pf.Variable(lb=0, ub=2)
499
+ >>> m.Y = pf.Variable(lb=0, ub=2)
500
+ >>> m.bad_constraint = m.X >= 3
501
+ >>> m.minimize = m.X + m.Y
502
+ >>> m.optimize()
503
+ >>> m.attr.TerminationStatus
504
+ <TerminationStatusCode.INFEASIBLE: 3>
505
+ >>> m.bad_constraint.attr.IIS
506
+ Traceback (most recent call last):
507
+ ...
508
+ RuntimeError: Unable to retrieve attribute 'IISConstr'
509
+ >>> m.compute_IIS()
510
+ >>> m.bad_constraint.attr.IIS
511
+ True
512
+ """
513
+ self.poi.computeIIS()
514
+
515
+ def dispose(self):
516
+ """Disposes of the model and cleans up the solver environment.
517
+
518
+ When using Gurobi compute server, this cleanup will
519
+ ensure your run is not marked as 'ABORTED'.
520
+
521
+ Note that once the model is disposed, it cannot be used anymore.
522
+
523
+ Examples:
524
+ >>> m = pf.Model()
525
+ >>> m.X = pf.Variable(ub=1)
526
+ >>> m.maximize = m.X
527
+ >>> m.optimize()
528
+ >>> m.X.solution
529
+ 1.0
530
+ >>> m.dispose()
531
+ """
532
+ env = None
533
+ if hasattr(self.poi, "_env"):
534
+ env = self.poi._env
535
+ self.poi.close()
536
+ if env is not None:
537
+ env.close()
538
+
539
+ def __del__(self):
540
+ # This ensures that the model is closed *before* the environment is. This avoids the Gurobi warning:
541
+ # Warning: environment still referenced so free is deferred (Continue to use WLS)
542
+ # I include the hasattr check to avoid errors in case __init__ failed and poi was never set.
543
+ if hasattr(self, "poi"):
544
+ self.poi.close()
545
+
546
+ def _set_param(self, name, value):
547
+ try:
548
+ self.poi.set_raw_parameter(name, value)
549
+ except KeyError as e:
550
+ raise KeyError(
551
+ f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/learn/getting-started/solver-access/ for a list of valid parameters."
552
+ ) from e
553
+
554
+ def _get_param(self, name):
555
+ try:
556
+ return self.poi.get_raw_parameter(name)
557
+ except KeyError as e:
558
+ raise KeyError(
559
+ f"Unknown parameter: '{name}'. See https://bravos-power.github.io/pyoframe/learn/getting-started/solver-access/ for a list of valid parameters."
560
+ ) from e
561
+
562
+ def _set_attr(self, name, value):
563
+ try:
564
+ self.poi.set_model_attribute(poi.ModelAttribute[name], value)
565
+ except KeyError as e:
566
+ if self.solver.name == "gurobi":
567
+ self.poi.set_model_raw_attribute(name, value)
568
+ else:
569
+ raise e
570
+
571
+ def _get_attr(self, name):
572
+ try:
573
+ return self.poi.get_model_attribute(poi.ModelAttribute[name])
574
+ except KeyError as e:
575
+ if self.solver.name == "gurobi":
576
+ return self.poi.get_model_raw_attribute(name)
577
+ else:
578
+ raise e