pyoframe 0.0.11__py3-none-any.whl → 0.1.1__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 CHANGED
@@ -1,27 +1,56 @@
1
- from typing import Any, Iterable, List, Optional, Union
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Iterable, List, Optional, Union
3
+
4
+ import pandas as pd
5
+ import polars as pl
6
+ import pyoptinterface as poi
7
+
2
8
  from pyoframe.constants import (
3
- ObjSense,
4
- VType,
9
+ CONST_TERM,
10
+ SUPPORTED_SOLVER_TYPES,
5
11
  Config,
6
- Result,
7
- PyoframeError,
12
+ ObjSense,
8
13
  ObjSenseValue,
14
+ PyoframeError,
15
+ VType,
9
16
  )
10
- from pyoframe.io_mappers import NamedVariableMapper, IOMappers
17
+ from pyoframe.core import Constraint, Variable
11
18
  from pyoframe.model_element import ModelElement, ModelElementWithId
12
- from pyoframe.core import Constraint
13
19
  from pyoframe.objective import Objective
14
- from pyoframe.user_defined import Container, AttrContainerMixin
15
- from pyoframe.core import Variable
16
- from pyoframe.io import to_file
17
- from pyoframe.solvers import solve, Solver
18
- import polars as pl
19
- import pandas as pd
20
+ from pyoframe.util import Container, NamedVariableMapper, for_solvers, get_obj_repr
20
21
 
21
22
 
22
- class Model(AttrContainerMixin):
23
+ class Model:
23
24
  """
24
- Represents a mathematical optimization model. Add variables, constraints, and an objective to the model by setting attributes.
25
+ The object that holds all the variables, constraints, and the objective.
26
+
27
+ Parameters:
28
+ name:
29
+ The name of the model. Currently it is not used for much.
30
+ solver:
31
+ The solver to use. If `None`, `Config.default_solver` will be used.
32
+ If `Config.default_solver` has not been set (`None`), Pyoframe will try to detect whichever solver is already installed.
33
+ solver_env:
34
+ Gurobi only: a dictionary of parameters to set when creating the Gurobi environment.
35
+ use_var_names:
36
+ Whether to pass variable names to the solver. Set to `True` if you'd like outputs from e.g. `Model.write()` to be legible.
37
+ sense:
38
+ Either "min" or "max". Indicates whether it's a minmization or maximization problem.
39
+ Typically, this parameter can be omitted (`None`) as it will automatically be
40
+ set when the objective is set using `.minimize` or `.maximize`.
41
+
42
+ Example:
43
+ >>> m = pf.Model()
44
+ >>> m.X = pf.Variable()
45
+ >>> m.my_constraint = m.X <= 10
46
+ >>> m
47
+ <Model vars=1 constrs=1 objective=False>
48
+
49
+ Try setting the Gurobi license:
50
+ >>> m = pf.Model(solver="gurobi", solver_env=dict(ComputeServer="myserver", ServerPassword="mypassword"))
51
+ Traceback (most recent call last):
52
+ ...
53
+ RuntimeError: Could not resolve host: myserver (code 6, command POST http://myserver/api/v1/cluster/jobs)
25
54
  """
26
55
 
27
56
  _reserved_attributes = [
@@ -32,29 +61,89 @@ class Model(AttrContainerMixin):
32
61
  "io_mappers",
33
62
  "name",
34
63
  "solver",
35
- "solver_model",
64
+ "poi",
36
65
  "params",
37
66
  "result",
38
67
  "attr",
39
68
  "sense",
40
69
  "objective",
70
+ "_use_var_names",
71
+ "ONE",
72
+ "solver_name",
73
+ "minimize",
74
+ "maximize",
41
75
  ]
42
76
 
43
- def __init__(self, min_or_max: Union[ObjSense, ObjSenseValue], name=None, **kwargs):
44
- super().__init__(**kwargs)
77
+ def __init__(
78
+ self,
79
+ name=None,
80
+ solver: Optional[SUPPORTED_SOLVER_TYPES] = None,
81
+ solver_env: Optional[Dict[str, str]] = None,
82
+ use_var_names=False,
83
+ sense: Union[ObjSense, ObjSenseValue, None] = None,
84
+ ):
85
+ self.poi, self.solver_name = Model.create_poi_model(solver, solver_env)
45
86
  self._variables: List[Variable] = []
46
87
  self._constraints: List[Constraint] = []
47
- self.sense = ObjSense(min_or_max)
88
+ self.sense = ObjSense(sense) if sense is not None else None
48
89
  self._objective: Optional[Objective] = None
49
90
  self.var_map = (
50
91
  NamedVariableMapper(Variable) if Config.print_uses_variable_names else None
51
92
  )
52
- self.io_mappers: Optional[IOMappers] = None
53
93
  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
94
+
95
+ self.params = Container(self._set_param, self._get_param)
96
+ self.attr = Container(self._set_attr, self._get_attr)
97
+ self._use_var_names = use_var_names
98
+
99
+ @property
100
+ def use_var_names(self):
101
+ return self._use_var_names
102
+
103
+ @classmethod
104
+ def create_poi_model(
105
+ cls, solver: Optional[str], solver_env: Optional[Dict[str, str]]
106
+ ):
107
+ if solver is None:
108
+ if Config.default_solver is None:
109
+ for solver_option in ["highs", "gurobi"]:
110
+ try:
111
+ return cls.create_poi_model(solver_option, solver_env)
112
+ except RuntimeError:
113
+ pass
114
+ raise ValueError(
115
+ 'Could not automatically find a solver. Is one installed? If so, specify which one: e.g. Model(solver="gurobi")'
116
+ )
117
+ else:
118
+ solver = Config.default_solver
119
+
120
+ solver = solver.lower()
121
+ if solver == "gurobi":
122
+ from pyoptinterface import gurobi
123
+
124
+ if solver_env is None:
125
+ model = gurobi.Model()
126
+ else:
127
+ env = gurobi.Env(empty=True)
128
+ for key, value in solver_env.items():
129
+ env.set_raw_parameter(key, value)
130
+ env.start()
131
+ model = gurobi.Model(env)
132
+ elif solver == "highs":
133
+ from pyoptinterface import highs
134
+
135
+ model = highs.Model()
136
+ else:
137
+ raise ValueError(
138
+ f"Solver {solver} not recognized or supported."
139
+ ) # pragma: no cover
140
+
141
+ constant_var = model.add_variable(lb=1, ub=1, name="ONE")
142
+ if constant_var.index != CONST_TERM:
143
+ raise ValueError(
144
+ "The first variable should have index 0."
145
+ ) # pragma: no cover
146
+ return model, solver
58
147
 
59
148
  @property
60
149
  def variables(self) -> List[Variable]:
@@ -62,10 +151,26 @@ class Model(AttrContainerMixin):
62
151
 
63
152
  @property
64
153
  def binary_variables(self) -> Iterable[Variable]:
154
+ """
155
+ Examples:
156
+ >>> m = pf.Model()
157
+ >>> m.X = pf.Variable(vtype=pf.VType.BINARY)
158
+ >>> m.Y = pf.Variable()
159
+ >>> len(list(m.binary_variables))
160
+ 1
161
+ """
65
162
  return (v for v in self.variables if v.vtype == VType.BINARY)
66
163
 
67
164
  @property
68
165
  def integer_variables(self) -> Iterable[Variable]:
166
+ """
167
+ Examples:
168
+ >>> m = pf.Model()
169
+ >>> m.X = pf.Variable(vtype=pf.VType.INTEGER)
170
+ >>> m.Y = pf.Variable()
171
+ >>> len(list(m.integer_variables))
172
+ 1
173
+ """
69
174
  return (v for v in self.variables if v.vtype == VType.INTEGER)
70
175
 
71
176
  @property
@@ -78,10 +183,43 @@ class Model(AttrContainerMixin):
78
183
 
79
184
  @objective.setter
80
185
  def objective(self, value):
81
- value = Objective(value)
186
+ if self._objective is not None and (
187
+ not isinstance(value, Objective) or not value._constructive
188
+ ):
189
+ raise ValueError("An objective already exists. Use += or -= to modify it.")
190
+ if not isinstance(value, Objective):
191
+ value = Objective(value)
82
192
  self._objective = value
83
193
  value.on_add_to_model(self, "objective")
84
194
 
195
+ @property
196
+ def minimize(self):
197
+ if self.sense != ObjSense.MIN:
198
+ raise ValueError("Can't get .minimize in a maximization problem.")
199
+ return self._objective
200
+
201
+ @minimize.setter
202
+ def minimize(self, value):
203
+ if self.sense is None:
204
+ self.sense = ObjSense.MIN
205
+ if self.sense != ObjSense.MIN:
206
+ raise ValueError("Can't set .minimize in a maximization problem.")
207
+ self.objective = value
208
+
209
+ @property
210
+ def maximize(self):
211
+ if self.sense != ObjSense.MAX:
212
+ raise ValueError("Can't get .maximize in a minimization problem.")
213
+ return self._objective
214
+
215
+ @maximize.setter
216
+ def maximize(self, value):
217
+ if self.sense is None:
218
+ self.sense = ObjSense.MAX
219
+ if self.sense != ObjSense.MAX:
220
+ raise ValueError("Can't set .maximize in a minimization problem.")
221
+ self.objective = value
222
+
85
223
  def __setattr__(self, __name: str, __value: Any) -> None:
86
224
  if __name not in Model._reserved_attributes and not isinstance(
87
225
  __value, (ModelElement, pl.DataFrame, pd.DataFrame)
@@ -110,7 +248,101 @@ class Model(AttrContainerMixin):
110
248
  return super().__setattr__(__name, __value)
111
249
 
112
250
  def __repr__(self) -> str:
113
- return f"""Model '{self.name}' ({len(self.variables)} vars, {len(self.constraints)} constrs, {1 if self.objective else "no"} obj)"""
251
+ return get_obj_repr(
252
+ self,
253
+ name=self.name,
254
+ vars=len(self.variables),
255
+ constrs=len(self.constraints),
256
+ objective=bool(self.objective),
257
+ )
258
+
259
+ def write(self, file_path: Union[Path, str]):
260
+ file_path = Path(file_path)
261
+ file_path.parent.mkdir(parents=True, exist_ok=True)
262
+ self.poi.write(str(file_path))
263
+
264
+ def optimize(self):
265
+ self.poi.optimize()
266
+
267
+ @for_solvers("gurobi")
268
+ def convert_to_fixed(self) -> None:
269
+ """
270
+ Turns a mixed integer program into a continuous one by fixing
271
+ all the integer and binary variables to their solution values.
272
+
273
+ !!! warning "Gurobi only"
274
+ This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
275
+
276
+ Example:
277
+ >>> m = pf.Model(solver="gurobi")
278
+ >>> m.X = pf.Variable(vtype=pf.VType.BINARY, lb=0)
279
+ >>> m.Y = pf.Variable(vtype=pf.VType.INTEGER, lb=0)
280
+ >>> m.Z = pf.Variable(lb=0)
281
+ >>> m.my_constraint = m.X + m.Y + m.Z <= 10
282
+ >>> m.maximize = 3 * m.X + 2 * m.Y + m.Z
283
+ >>> m.optimize()
284
+ >>> m.X.solution, m.Y.solution, m.Z.solution
285
+ (1.0, 9.0, 0.0)
286
+ >>> m.my_constraint.dual
287
+ Traceback (most recent call last):
288
+ ...
289
+ polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'Pi'
290
+ >>> m.convert_to_fixed()
291
+ >>> m.optimize()
292
+ >>> m.my_constraint.dual
293
+ 1.0
294
+
295
+ Only works for Gurobi:
296
+
297
+ >>> m = pf.Model("max", solver="highs")
298
+ >>> m.convert_to_fixed()
299
+ Traceback (most recent call last):
300
+ ...
301
+ NotImplementedError: Method 'convert_to_fixed' is not implemented for solver 'highs'.
302
+ """
303
+ self.poi._converttofixed()
304
+
305
+ @for_solvers("gurobi", "copt")
306
+ def compute_IIS(self):
307
+ """
308
+ Computes the Irreducible Infeasible Set (IIS) of the model.
309
+
310
+ !!! warning "Gurobi only"
311
+ This method only works with the Gurobi solver. Open an issue if you'd like to see support for other solvers.
312
+
313
+ Example:
314
+ >>> m = pf.Model(solver="gurobi")
315
+ >>> m.X = pf.Variable(lb=0, ub=2)
316
+ >>> m.Y = pf.Variable(lb=0, ub=2)
317
+ >>> m.bad_constraint = m.X >= 3
318
+ >>> m.minimize = m.X + m.Y
319
+ >>> m.optimize()
320
+ >>> m.attr.TerminationStatus
321
+ <TerminationStatusCode.INFEASIBLE: 3>
322
+ >>> m.bad_constraint.attr.IIS
323
+ Traceback (most recent call last):
324
+ ...
325
+ polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'IISConstr'
326
+ >>> m.compute_IIS()
327
+ >>> m.bad_constraint.attr.IIS
328
+ True
329
+ """
330
+ self.poi.computeIIS()
331
+
332
+ def _set_param(self, name, value):
333
+ self.poi.set_raw_parameter(name, value)
334
+
335
+ def _get_param(self, name):
336
+ return self.poi.get_raw_parameter(name)
337
+
338
+ def _set_attr(self, name, value):
339
+ try:
340
+ self.poi.set_model_attribute(poi.ModelAttribute[name], value)
341
+ except KeyError:
342
+ self.poi.set_model_raw_attribute(name, value)
114
343
 
115
- to_file = to_file
116
- solve = solve
344
+ def _get_attr(self, name):
345
+ try:
346
+ return self.poi.get_model_attribute(poi.ModelAttribute[name])
347
+ except KeyError:
348
+ return self.poi.get_model_raw_attribute(name)
pyoframe/model_element.py CHANGED
@@ -1,13 +1,19 @@
1
1
  from __future__ import annotations
2
+
2
3
  from abc import ABC, abstractmethod
3
4
  from collections import defaultdict
4
- from typing import Any, Dict, List, Optional
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
+
5
7
  import polars as pl
6
- from typing import TYPE_CHECKING
7
8
 
8
- from pyoframe.constants import COEF_KEY, RESERVED_COL_KEYS, VAR_KEY
9
9
  from pyoframe._arithmetic import _get_dimensions
10
- from pyoframe.user_defined import AttrContainerMixin
10
+ from pyoframe.constants import (
11
+ COEF_KEY,
12
+ KEY_TYPE,
13
+ QUAD_VAR_KEY,
14
+ RESERVED_COL_KEYS,
15
+ VAR_KEY,
16
+ )
11
17
 
12
18
  if TYPE_CHECKING: # pragma: no cover
13
19
  from pyoframe.model import Model
@@ -32,7 +38,9 @@ class ModelElement(ABC):
32
38
  if COEF_KEY in data.columns:
33
39
  data = data.cast({COEF_KEY: pl.Float64})
34
40
  if VAR_KEY in data.columns:
35
- data = data.cast({VAR_KEY: pl.UInt32})
41
+ data = data.cast({VAR_KEY: KEY_TYPE})
42
+ if QUAD_VAR_KEY in data.columns:
43
+ data = data.cast({QUAD_VAR_KEY: KEY_TYPE})
36
44
 
37
45
  self._data = data
38
46
  self._model: Optional[Model] = None
@@ -57,12 +65,11 @@ class ModelElement(ABC):
57
65
  The names of the data's dimensions.
58
66
 
59
67
  Examples:
60
- >>> from pyoframe.core import Variable
61
68
  >>> # A variable with no dimensions
62
- >>> Variable().dimensions
69
+ >>> pf.Variable().dimensions
63
70
 
64
71
  >>> # A variable with dimensions of "hour" and "city"
65
- >>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
72
+ >>> pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
66
73
  ['hour', 'city']
67
74
  """
68
75
  return _get_dimensions(self.data)
@@ -84,12 +91,11 @@ class ModelElement(ABC):
84
91
  The number of indices in each dimension.
85
92
 
86
93
  Examples:
87
- >>> from pyoframe.core import Variable
88
94
  >>> # A variable with no dimensions
89
- >>> Variable().shape
95
+ >>> pf.Variable().shape
90
96
  {}
91
97
  >>> # A variable with dimensions of "hour" and "city"
92
- >>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
98
+ >>> pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
93
99
  {'hour': 4, 'city': 3}
94
100
  """
95
101
  dims = self.dimensions
@@ -135,48 +141,41 @@ class SupportPolarsMethodMixin(ABC):
135
141
  @abstractmethod
136
142
  def data(self): ...
137
143
 
144
+ def pick(self, **kwargs):
145
+ """
146
+ Filters elements by the given criteria and then drops the filtered dimensions.
147
+
148
+ Example:
149
+ >>> m = pf.Model()
150
+ >>> m.v = pf.Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}])
151
+ >>> m.v.pick(hour="06:00")
152
+ <Expression size=3 dimensions={'city': 3} terms=3>
153
+ [Toronto]: v[06:00,Toronto]
154
+ [Berlin]: v[06:00,Berlin]
155
+ [Paris]: v[06:00,Paris]
156
+ >>> m.v.pick(hour="06:00", city="Toronto")
157
+ <Expression size=1 dimensions={} terms=1>
158
+ v[06:00,Toronto]
159
+ """
160
+ return self._new(self.data.filter(**kwargs).drop(kwargs.keys()))
161
+
138
162
 
139
- class ModelElementWithId(ModelElement, AttrContainerMixin):
163
+ class ModelElementWithId(ModelElement):
140
164
  """
141
165
  Provides a method that assigns a unique ID to each row in a DataFrame.
142
166
  IDs start at 1 and go up consecutively. No zero ID is assigned since it is reserved for the constant variable term.
143
167
  IDs are only unique for the subclass since different subclasses have different counters.
144
168
  """
145
169
 
146
- # Keys are the subclass names and values are the next unasigned ID.
147
- _id_counters: Dict[str, int] = defaultdict(lambda: 1)
148
-
149
- @classmethod
150
- def reset_counters(cls):
151
- """
152
- Resets all the ID counters.
153
- This function is called before every unit test to reset the code state.
154
- """
155
- cls._id_counters = defaultdict(lambda: 1)
156
-
157
- def __init__(self, data: pl.DataFrame, **kwargs) -> None:
158
- super().__init__(data, **kwargs)
159
- self._data = self._assign_ids(self.data)
160
-
161
- @classmethod
162
- def _assign_ids(cls, df: pl.DataFrame) -> pl.DataFrame:
163
- """
164
- Adds the column `to_column` to the DataFrame `df` with the next batch
165
- of unique consecutive IDs.
166
- """
167
- cls_name = cls.__name__
168
- cur_count = cls._id_counters[cls_name]
169
- id_col_name = cls.get_id_column_name()
170
+ @property
171
+ def _has_ids(self) -> bool:
172
+ return self.get_id_column_name() in self.data.columns
170
173
 
171
- if df.height == 0:
172
- df = df.with_columns(pl.lit(cur_count).alias(id_col_name))
173
- else:
174
- df = df.with_columns(
175
- pl.int_range(cur_count, cur_count + pl.len()).alias(id_col_name)
174
+ def _assert_has_ids(self):
175
+ if not self._has_ids:
176
+ raise ValueError(
177
+ f"Cannot use '{self.__class__.__name__}' before it has beed added to a model."
176
178
  )
177
- df = df.with_columns(pl.col(id_col_name).cast(pl.UInt32))
178
- cls._id_counters[cls_name] += df.height
179
- return df
180
179
 
181
180
  @classmethod
182
181
  @abstractmethod
@@ -184,43 +183,3 @@ class ModelElementWithId(ModelElement, AttrContainerMixin):
184
183
  """
185
184
  Returns the name of the column containing the IDs.
186
185
  """
187
-
188
- @property
189
- def ids(self) -> pl.DataFrame:
190
- return self.data.select(self.dimensions_unsafe + [self.get_id_column_name()])
191
-
192
- def _extend_dataframe_by_id(self, addition: pl.DataFrame):
193
- cols = addition.columns
194
- assert len(cols) == 2
195
- id_col = self.get_id_column_name()
196
- assert id_col in cols
197
- cols.remove(id_col)
198
- new_col = cols[0]
199
-
200
- original = self.data
201
-
202
- if new_col in original.columns:
203
- original = original.drop(new_col)
204
- self._data = original.join(addition, on=id_col, how="left", validate="1:1")
205
-
206
- def _preprocess_attr(self, name: str, value: Any) -> Any:
207
- dims = self.dimensions
208
- ids = self.ids
209
- id_col = self.get_id_column_name()
210
-
211
- if isinstance(value, pl.DataFrame):
212
- if value.shape == (1, 1):
213
- value = value.item()
214
- else:
215
- assert (
216
- dims is not None
217
- ), "Attribute must be a scalar since there are no dimensions"
218
- result = value.join(ids, on=dims, validate="1:1", how="inner").drop(
219
- dims
220
- )
221
- assert len(result.columns) == 2, "Attribute has too many columns"
222
- value_col = [c for c in result.columns if c != id_col][0]
223
- return result.rename({value_col: name})
224
-
225
- assert ids.height == 1, "Attribute is a scalar but there are multiple IDs."
226
- return pl.DataFrame({name: [value], id_col: ids.get_column(id_col)})
pyoframe/monkey_patch.py CHANGED
@@ -1,10 +1,10 @@
1
+ from functools import wraps
2
+
1
3
  import pandas as pd
2
4
  import polars as pl
3
- from pyoframe.core import SupportsMath
4
- from pyoframe.core import Expression
5
- from functools import wraps
6
5
 
7
6
  from pyoframe.constants import COEF_KEY, CONST_TERM, VAR_KEY
7
+ from pyoframe.core import Expression, SupportsMath
8
8
 
9
9
  # pyright: reportAttributeAccessIssue=false
10
10
 
pyoframe/objective.py CHANGED
@@ -1,45 +1,95 @@
1
- from typing import Optional
2
- from pyoframe.constants import COEF_KEY
3
- from pyoframe.core import SupportsMath, Expression
1
+ from __future__ import annotations
2
+
3
+ import pyoptinterface as poi
4
+
5
+ from pyoframe.core import Expression, SupportsToExpr
4
6
 
5
7
 
6
8
  class Objective(Expression):
7
- r"""
9
+ """
8
10
  Examples:
9
- >>> from pyoframe import Variable, Model, sum
10
- >>> m = Model("max")
11
- >>> m.a = Variable()
12
- >>> m.b = Variable({"dim1": [1, 2, 3]})
13
- >>> m.objective = m.a + sum("dim1", m.b)
14
- >>> m.objective
15
- <Objective size=1 dimensions={} terms=4>
16
- objective: a + b[1] + b[2] + b[3]
11
+ An `Objective` is automatically created when an `Expression` is assigned to `.minimize` or `.maximize`
12
+
13
+ >>> m = pf.Model()
14
+ >>> m.A, m.B = pf.Variable(lb=0), pf.Variable(lb=0)
15
+ >>> m.con = m.A + m.B <= 10
16
+ >>> m.maximize = 2 * m.B + 4
17
+ >>> m.maximize
18
+ <Objective size=1 dimensions={} terms=2>
19
+ objective: 2 B +4
20
+
21
+ The objective value can be retrieved with from the solver once the model is solved using `.value`.
22
+
23
+ >>> m.optimize()
24
+ >>> m.maximize.value
25
+ 24.0
26
+
27
+ Objectives support `+=` and `-=` operators.
28
+
29
+ >>> m.maximize += 3 * m.A
30
+ >>> m.optimize()
31
+ >>> m.A.solution, m.B.solution
32
+ (10.0, 0.0)
33
+ >>> m.maximize -= 2 * m.A
34
+ >>> m.optimize()
35
+ >>> m.A.solution, m.B.solution
36
+ (0.0, 10.0)
37
+
38
+ Objectives cannot be created from dimensioned expressions since an objective must be a single expression.
39
+
40
+ >>> m = pf.Model()
41
+ >>> m.dimensioned_variable = pf.Variable({"city": ["Toronto", "Berlin", "Paris"]})
42
+ >>> m.maximize = m.dimensioned_variable
43
+ Traceback (most recent call last):
44
+ ...
45
+ ValueError: Objective cannot be created from a dimensioned expression. Did you forget to use pf.sum()?
46
+
47
+ Objectives cannot be overwritten.
48
+
49
+ >>> m = pf.Model()
50
+ >>> m.A = pf.Variable(lb=0)
51
+ >>> m.maximize = 2 * m.A
52
+ >>> m.maximize = 3 * m.A
53
+ Traceback (most recent call last):
54
+ ...
55
+ ValueError: An objective already exists. Use += or -= to modify it.
17
56
  """
18
57
 
19
- def __init__(self, expr: SupportsMath) -> None:
20
- expr = expr.to_expr()
58
+ def __init__(
59
+ self, expr: SupportsToExpr | int | float, _constructive: bool = False
60
+ ) -> None:
61
+ self._constructive = _constructive
62
+ if isinstance(expr, (int, float)):
63
+ expr = Expression.constant(expr)
64
+ else:
65
+ expr = expr.to_expr()
21
66
  super().__init__(expr.data)
22
67
  self._model = expr._model
23
- assert (
24
- self.dimensions is None
25
- ), "Objective cannot have dimensions as it must be a single expression"
26
- self._value: Optional[float] = None
68
+ if self.dimensions is not None:
69
+ raise ValueError(
70
+ "Objective cannot be created from a dimensioned expression. Did you forget to use pf.sum()?"
71
+ )
27
72
 
28
73
  @property
29
- def value(self):
30
- if self._value is None:
74
+ def value(self) -> float:
75
+ """
76
+ The value of the objective function (only available after solving the model).
77
+
78
+ This value is obtained by directly querying the solver.
79
+ """
80
+ return self._model.poi.get_model_attribute(poi.ModelAttribute.ObjectiveValue)
81
+
82
+ def on_add_to_model(self, model, name):
83
+ super().on_add_to_model(model, name)
84
+ assert self._model is not None
85
+ if self._model.sense is None:
31
86
  raise ValueError(
32
- "Objective value is not available before solving the model"
87
+ "Can't set an objective without specifying the sense. Did you use .objective instead of .minimize or .maximize ?"
33
88
  )
34
- return self._value
89
+ self._model.poi.set_objective(self.to_poi(), sense=self._model.sense.to_poi())
35
90
 
36
- @value.setter
37
- def value(self, value):
38
- self._value = value
91
+ def __iadd__(self, other):
92
+ return Objective(self + other, _constructive=True)
39
93
 
40
- @property
41
- def has_constant(self):
42
- constant_terms = self.constant_terms
43
- if len(constant_terms) == 0:
44
- return False
45
- return constant_terms.get_column(COEF_KEY).item() != 0
94
+ def __isub__(self, other):
95
+ return Objective(self - other, _constructive=True)