pyoframe 0.0.4__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/io.py ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ Module containing all import/export functionalities.
3
+ """
4
+
5
+ from io import TextIOWrapper
6
+ from tempfile import NamedTemporaryFile
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, Union
9
+
10
+ from pyoframe.constants import VAR_KEY, Config
11
+ from pyoframe.constraints import Constraint
12
+ from pyoframe.variables import Variable
13
+ from pyoframe.io_mappers import (
14
+ Base62ConstMapper,
15
+ Base62VarMapper,
16
+ IOMappers,
17
+ Mapper,
18
+ NamedMapper,
19
+ )
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from pyoframe.model import Model
23
+
24
+ import polars as pl
25
+
26
+
27
+ def objective_to_file(m: "Model", f: TextIOWrapper, var_map):
28
+ """
29
+ Write out the objective of a model to a lp file.
30
+ """
31
+ assert m.objective is not None, "No objective set."
32
+
33
+ f.write(f"{m.objective.sense.value}\n\nobj:\n\n")
34
+ result = m.objective.to_str(var_map=var_map, include_prefix=False)
35
+ f.writelines(result)
36
+
37
+
38
+ def constraints_to_file(m: "Model", f: TextIOWrapper, var_map, const_map):
39
+ for constraint in create_section(m.constraints, f, "s.t."):
40
+ f.writelines(constraint.to_str(var_map=var_map, const_map=const_map) + "\n")
41
+
42
+
43
+ def bounds_to_file(m: "Model", f, var_map):
44
+ """
45
+ Write out variables of a model to a lp file.
46
+ """
47
+ for variable in create_section(m.variables, f, "bounds"):
48
+ lb = f"{variable.lb:.12g}"
49
+ ub = f"{variable.ub:.12g}"
50
+
51
+ df = (
52
+ var_map.apply(variable.data, to_col=None)
53
+ .select(
54
+ pl.concat_str(
55
+ pl.lit(f"{lb} <= "), VAR_KEY, pl.lit(f" <= {ub}\n")
56
+ ).str.concat("")
57
+ )
58
+ .item()
59
+ )
60
+
61
+ f.writelines(df)
62
+
63
+
64
+ def binaries_to_file(m: "Model", f, var_map: Mapper):
65
+ """
66
+ Write out binaries of a model to a lp file.
67
+ """
68
+ for variable in create_section(m.binary_variables, f, "binary"):
69
+ lines = (
70
+ var_map.apply(variable.data, to_col=None)
71
+ .select(pl.col(VAR_KEY).str.concat("\n"))
72
+ .item()
73
+ )
74
+ f.writelines(lines + "\n")
75
+
76
+
77
+ def integers_to_file(m: "Model", f, var_map: Mapper):
78
+ """
79
+ Write out integers of a model to a lp file.
80
+ """
81
+ for variable in create_section(m.integer_variables, f, "general"):
82
+ lines = (
83
+ var_map.apply(variable.data, to_col=None)
84
+ .select(pl.col(VAR_KEY).str.concat("\n"))
85
+ .item()
86
+ )
87
+ f.writelines(lines + "\n")
88
+
89
+
90
+ T = TypeVar("T")
91
+
92
+
93
+ def create_section(iterable: Iterable[T], f, section_header) -> Iterable[T]:
94
+ wrote = False
95
+ for item in iterable:
96
+ if not wrote:
97
+ f.write(f"\n\n{section_header}\n\n")
98
+ wrote = True
99
+ yield item
100
+
101
+
102
+ def get_var_map(m: "Model", use_var_names):
103
+ if use_var_names:
104
+ if m.var_map is not None:
105
+ return m.var_map
106
+ var_map = NamedMapper(Variable)
107
+ else:
108
+ var_map = Base62VarMapper(Variable)
109
+
110
+ for v in m.variables:
111
+ var_map.add(v)
112
+ return var_map
113
+
114
+
115
+ def to_file(
116
+ m: "Model", file_path: Optional[Union[str, Path]], use_var_names=False
117
+ ) -> Path:
118
+ """
119
+ Write out a model to a lp file.
120
+ """
121
+ if file_path is None:
122
+ with NamedTemporaryFile(
123
+ prefix="pyoframe-problem-", suffix=".lp", mode="w", delete=False
124
+ ) as f:
125
+ file_path = f.name
126
+
127
+ file_path = Path(file_path)
128
+ assert file_path.suffix == ".lp", f"File format `{file_path.suffix}` not supported."
129
+
130
+ if file_path.exists():
131
+ file_path.unlink()
132
+
133
+ const_map = (
134
+ NamedMapper(Constraint) if use_var_names else Base62ConstMapper(Constraint)
135
+ )
136
+ for c in m.constraints:
137
+ const_map.add(c)
138
+ var_map = get_var_map(m, use_var_names)
139
+ m.io_mappers = IOMappers(var_map, const_map)
140
+
141
+ with open(file_path, mode="w") as f:
142
+ objective_to_file(m, f, var_map)
143
+ constraints_to_file(m, f, var_map, const_map)
144
+ bounds_to_file(m, f, var_map)
145
+ binaries_to_file(m, f, var_map)
146
+ integers_to_file(m, f, var_map)
147
+ f.write("end\n")
148
+
149
+ return file_path
pyoframe/io_mappers.py ADDED
@@ -0,0 +1,206 @@
1
+ """
2
+ Defines various methods for mapping a variable or constraint to its string representation.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+ import string
8
+ from abc import ABC, abstractmethod
9
+
10
+ from typing import TYPE_CHECKING, Optional, Type, Union
11
+ import polars as pl
12
+ from pyoframe.constants import NAME_COL
13
+ from pyoframe.util import concat_dimensions
14
+
15
+
16
+ if TYPE_CHECKING: # pragma: no cover
17
+ from pyoframe.model import Variable
18
+ from pyoframe.constraints import Constraint
19
+ from pyoframe.util import IdCounterMixin
20
+
21
+
22
+ @dataclass
23
+ class IOMappers:
24
+ var_map: "Mapper"
25
+ const_map: "Mapper"
26
+
27
+
28
+ class Mapper(ABC):
29
+ def __init__(self, cls: Type["IdCounterMixin"]) -> None:
30
+ self._ID_COL = cls.get_id_column_name()
31
+ self.mapping_registry = pl.DataFrame(
32
+ {self._ID_COL: [], NAME_COL: []},
33
+ schema={self._ID_COL: pl.UInt32, NAME_COL: pl.String},
34
+ )
35
+
36
+ def add(self, element: Union["Variable", "Constraint"]) -> None:
37
+ self.mapping_registry = pl.concat(
38
+ [self.mapping_registry, self._element_to_map(element)]
39
+ )
40
+
41
+ @abstractmethod
42
+ def _element_to_map(self, element: "IdCounterMixin") -> pl.DataFrame: ...
43
+
44
+ def apply(
45
+ self,
46
+ df: pl.DataFrame,
47
+ to_col: Optional[str],
48
+ ) -> pl.DataFrame:
49
+ result = df.join(
50
+ self.mapping_registry, on=self._ID_COL, how="left", validate="m:1"
51
+ )
52
+ if to_col is None:
53
+ result = result.drop(self._ID_COL)
54
+ to_col = self._ID_COL
55
+ return result.rename({NAME_COL: to_col})
56
+
57
+ def undo(self, df: pl.DataFrame) -> pl.DataFrame:
58
+ return df.join(
59
+ self.mapping_registry, on=NAME_COL, how="left", validate="m:1"
60
+ ).drop(NAME_COL)
61
+
62
+
63
+ class NamedMapper(Mapper):
64
+ """
65
+ Maps constraints or variables to a string representation using the object's name and dimensions.
66
+
67
+ Examples:
68
+
69
+ >>> import polars as pl
70
+ >>> import pyoframe as pf
71
+ >>> m = pf.Model()
72
+ >>> m.foo = pf.Variable(pl.DataFrame({"t": range(4)}))
73
+ >>> pf.sum(m.foo)
74
+ <Expression size=1 dimensions={} terms=4>
75
+ foo[0] + foo[1] + foo[2] + foo[3]
76
+ """
77
+
78
+ def _element_to_map(self, element) -> pl.DataFrame:
79
+ element_name = element.name # type: ignore
80
+ assert (
81
+ element_name is not None
82
+ ), "Element must have a name to be used in a named mapping."
83
+ return concat_dimensions(
84
+ element.ids, keep_dims=False, prefix=element_name, to_col=NAME_COL
85
+ )
86
+
87
+
88
+ class Base62Mapper(Mapper, ABC):
89
+ # Mapping between a base 62 character and its integer value
90
+ _CHAR_TABLE = pl.DataFrame(
91
+ {"char": list(string.digits + string.ascii_letters)},
92
+ ).with_columns(pl.int_range(pl.len()).cast(pl.UInt32).alias("code"))
93
+
94
+ _BASE = _CHAR_TABLE.height # _BASE = 62
95
+ _ZERO = _CHAR_TABLE.filter(pl.col("code") == 0).select("char").item() # _ZERO = "0"
96
+
97
+ @property
98
+ @abstractmethod
99
+ def _prefix(self) -> "str": ...
100
+
101
+ def apply(
102
+ self,
103
+ df: pl.DataFrame,
104
+ to_col: Optional[str] = None,
105
+ ) -> pl.DataFrame:
106
+ if df.height == 0:
107
+ return df
108
+
109
+ query = pl.concat_str(
110
+ pl.lit(self._prefix),
111
+ pl.col(self._ID_COL).map_batches(
112
+ Base62Mapper._to_base62,
113
+ return_dtype=pl.String,
114
+ is_elementwise=True,
115
+ ),
116
+ )
117
+
118
+ if to_col is None:
119
+ to_col = self._ID_COL
120
+
121
+ return df.with_columns(query.alias(to_col))
122
+
123
+ @classmethod
124
+ def _to_base62(cls, int_col: pl.Series) -> pl.Series:
125
+ """Returns a series of dtype str with a base 62 representation of the integers in int_col.
126
+ The letters 0-9a-zA-Z are used as symbols for the representation.
127
+
128
+ Examples:
129
+
130
+ >>> import polars as pl
131
+ >>> s = pl.Series([0,10,20,60,53,66], dtype=pl.UInt32)
132
+ >>> Base62Mapper._to_base62(s).to_list()
133
+ ['0', 'a', 'k', 'Y', 'R', '14']
134
+
135
+ >>> s = pl.Series([0], dtype=pl.UInt32)
136
+ >>> Base62Mapper._to_base62(s).to_list()
137
+ ['0']
138
+ """
139
+ assert isinstance(
140
+ int_col.dtype, pl.UInt32
141
+ ), "_to_base62() only works for UInt32 id columns"
142
+
143
+ largest_id = int_col.max()
144
+ if largest_id == 0:
145
+ max_digits = 1
146
+ else:
147
+ max_digits = math.floor(math.log(largest_id, cls._BASE)) + 1 # type: ignore
148
+
149
+ digits = []
150
+
151
+ for i in range(max_digits):
152
+ remainder = int_col % cls._BASE
153
+
154
+ digits.append(
155
+ remainder.to_frame(name="code")
156
+ .join(cls._CHAR_TABLE, on="code", how="left")
157
+ .select("char")
158
+ .rename({"char": f"digit{i}"})
159
+ )
160
+ int_col //= cls._BASE
161
+
162
+ return (
163
+ pl.concat(reversed(digits), how="horizontal")
164
+ .select(pl.concat_str(pl.all()))
165
+ .to_series()
166
+ .str.strip_chars_start(cls._ZERO)
167
+ .replace("", cls._ZERO)
168
+ )
169
+
170
+ def _element_to_map(self, element) -> pl.DataFrame:
171
+ return self.apply(element.ids.select(self._ID_COL), to_col=NAME_COL)
172
+
173
+
174
+ class Base62VarMapper(Base62Mapper):
175
+ """
176
+ Examples:
177
+ >>> import polars as pl
178
+ >>> from pyoframe import Model, Variable
179
+ >>> from pyoframe.constants import VAR_KEY
180
+ >>> m = Model()
181
+ >>> m.x = Variable(pl.DataFrame({"t": range(1,63)}))
182
+ >>> (m.x.filter(t=11)+1).to_str()
183
+ '[11]: 1 + x[11]'
184
+ >>> (m.x.filter(t=11)+1).to_str(var_map=Base62VarMapper(Variable))
185
+ '[11]: 1 + xb'
186
+
187
+ >>> Base62VarMapper(Variable).apply(pl.DataFrame({VAR_KEY: []}))
188
+ shape: (0, 1)
189
+ ┌───────────────┐
190
+ │ __variable_id │
191
+ │ --- │
192
+ │ null │
193
+ ╞═══════════════╡
194
+ └───────────────┘
195
+ """
196
+
197
+ @property
198
+ def _prefix(self) -> "str":
199
+ return "x"
200
+
201
+
202
+ class Base62ConstMapper(Base62Mapper):
203
+
204
+ @property
205
+ def _prefix(self) -> "str":
206
+ return "c"
pyoframe/model.py ADDED
@@ -0,0 +1,92 @@
1
+ from typing import Any, Iterable, List, Optional
2
+ from pyoframe.constants import ObjSense, VType, Config
3
+ from pyoframe.constraints import SupportsMath
4
+ from pyoframe.io_mappers import NamedMapper, IOMappers
5
+ from pyoframe.model_element import ModelElement
6
+ from pyoframe.constraints import Constraint
7
+ from pyoframe.objective import Objective
8
+ from pyoframe.variables import Variable
9
+ from pyoframe.io import to_file
10
+ from pyoframe.solvers import solve
11
+
12
+
13
+ class Model:
14
+ """
15
+ Represents a mathematical optimization model. Add variables, constraints, and an objective to the model by setting attributes.
16
+ """
17
+
18
+ def __init__(self, name=None):
19
+ self._variables: List[Variable] = []
20
+ self._constraints: List[Constraint] = []
21
+ self._objective: Optional[Objective] = None
22
+ self.var_map = (
23
+ NamedMapper(Variable) if Config.print_uses_variable_names else None
24
+ )
25
+ self.io_mappers: Optional[IOMappers] = None
26
+ self.name = name
27
+
28
+ @property
29
+ def variables(self) -> List[Variable]:
30
+ return self._variables
31
+
32
+ @property
33
+ def binary_variables(self) -> Iterable[Variable]:
34
+ return (v for v in self.variables if v.vtype == VType.BINARY)
35
+
36
+ @property
37
+ def integer_variables(self) -> Iterable[Variable]:
38
+ return (v for v in self.variables if v.vtype == VType.INTEGER)
39
+
40
+ @property
41
+ def constraints(self):
42
+ return self._constraints
43
+
44
+ @property
45
+ def objective(self):
46
+ return self._objective
47
+
48
+ @property
49
+ def maximize(self):
50
+ assert self.objective is not None and self.objective.sense == ObjSense.MAX
51
+ return self.objective
52
+
53
+ @property
54
+ def minimize(self):
55
+ assert self.objective is not None and self.objective.sense == ObjSense.MIN
56
+ return self.objective
57
+
58
+ def __setattr__(self, __name: str, __value: Any) -> None:
59
+ if __name in ("maximize", "minimize"):
60
+ assert isinstance(
61
+ __value, SupportsMath
62
+ ), f"Setting {__name} on the model requires an objective expression."
63
+ self._objective = Objective(__value, sense=__name)
64
+ self._objective.name = __name
65
+ self._objective._model = self
66
+ return
67
+
68
+ if isinstance(__value, ModelElement) and not __name.startswith("_"):
69
+ assert not hasattr(
70
+ self, __name
71
+ ), f"Cannot create {__name} since it was already created."
72
+
73
+ __value.name = __name
74
+ __value._model = self
75
+
76
+ if isinstance(__value, Objective):
77
+ assert self.objective is None, "Cannot create more than one objective."
78
+ self._objective = __value
79
+ if isinstance(__value, Variable):
80
+ self._variables.append(__value)
81
+ if self.var_map is not None:
82
+ self.var_map.add(__value)
83
+ elif isinstance(__value, Constraint):
84
+ self._constraints.append(__value)
85
+
86
+ return super().__setattr__(__name, __value)
87
+
88
+ def __repr__(self) -> str:
89
+ return f"""Model '{self.name}' ({len(self.variables)} vars, {len(self.constraints)} constrs, {1 if self.objective else "no"} obj)"""
90
+
91
+ to_file = to_file
92
+ solve = solve
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from typing import Dict, List, Optional
4
+ import polars as pl
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pyoframe.constants import COEF_KEY, RESERVED_COL_KEYS, VAR_KEY
8
+ from pyoframe._arithmetic import _get_dimensions
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ from pyoframe.model import Model
12
+
13
+
14
+ def _pass_polars_method(method_name: str):
15
+ """
16
+ Wrapper to add a method to ModelElement that simply calls the underlying Polars method on the data attribute.
17
+ """
18
+
19
+ def method(self, *args, **kwargs):
20
+ return self._new(getattr(self.data, method_name)(*args, **kwargs))
21
+
22
+ return method
23
+
24
+
25
+ class ModelElement(ABC):
26
+ def __init__(self, data: pl.DataFrame, **kwargs) -> None:
27
+ # Sanity checks, no duplicate column names
28
+ assert len(data.columns) == len(
29
+ set(data.columns)
30
+ ), "Duplicate column names found."
31
+
32
+ cols = _get_dimensions(data)
33
+ if cols is None:
34
+ cols = []
35
+ cols += [col for col in RESERVED_COL_KEYS if col in data.columns]
36
+
37
+ # Reorder columns to keep things consistent
38
+ data = data.select(cols)
39
+
40
+ # Cast to proper dtype
41
+ if COEF_KEY in data.columns:
42
+ data = data.cast({COEF_KEY: pl.Float64})
43
+ if VAR_KEY in data.columns:
44
+ data = data.cast({VAR_KEY: pl.UInt32})
45
+
46
+ self._data = data
47
+ self._model: Optional[Model] = None
48
+ self.name = None
49
+ super().__init__(**kwargs)
50
+
51
+ @property
52
+ def data(self) -> pl.DataFrame:
53
+ return self._data
54
+
55
+ @property
56
+ def dimensions(self) -> Optional[List[str]]:
57
+ """
58
+ The names of the data's dimensions.
59
+
60
+ Examples:
61
+ >>> from pyoframe.variables import Variable
62
+ >>> # A variable with no dimensions
63
+ >>> Variable().dimensions
64
+
65
+ >>> # A variable with dimensions of "hour" and "city"
66
+ >>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).dimensions
67
+ ['hour', 'city']
68
+ """
69
+ return _get_dimensions(self.data)
70
+
71
+ @property
72
+ def dimensions_unsafe(self) -> List[str]:
73
+ """
74
+ Same as `dimensions` but returns an empty list if there are no dimensions instead of None.
75
+ When unsure, use `dimensions` instead since the type checker forces users to handle the None case (no dimensions).
76
+ """
77
+ dims = self.dimensions
78
+ if dims is None:
79
+ return []
80
+ return dims
81
+
82
+ @property
83
+ def shape(self) -> Dict[str, int]:
84
+ """
85
+ The number of indices in each dimension.
86
+
87
+ Examples:
88
+ >>> from pyoframe.variables import Variable
89
+ >>> # A variable with no dimensions
90
+ >>> Variable().shape
91
+ {}
92
+ >>> # A variable with dimensions of "hour" and "city"
93
+ >>> Variable([{"hour": ["00:00", "06:00", "12:00", "18:00"]}, {"city": ["Toronto", "Berlin", "Paris"]}]).shape
94
+ {'hour': 4, 'city': 3}
95
+ """
96
+ dims = self.dimensions
97
+ if dims is None:
98
+ return {}
99
+ return {dim: self.data[dim].n_unique() for dim in dims}
100
+
101
+ def __len__(self) -> int:
102
+ dims = self.dimensions
103
+ if dims is None:
104
+ return 1
105
+ return self.data.select(dims).n_unique()
106
+
107
+ @abstractmethod
108
+ def _new(self, data: pl.DataFrame):
109
+ """
110
+ Used to create a new instance of the same class with the given data (for e.g. on .rename(), .with_columns(), etc.).
111
+ """
112
+ raise NotImplementedError
113
+
114
+ rename = _pass_polars_method("rename")
115
+ with_columns = _pass_polars_method("with_columns")
116
+ filter = _pass_polars_method("filter")
@@ -0,0 +1,54 @@
1
+ import pandas as pd
2
+ import polars as pl
3
+ from pyoframe.constraints import SupportsMath
4
+ from pyoframe.constraints import Expression
5
+ from functools import wraps
6
+
7
+ from pyoframe.constants import COEF_KEY, CONST_TERM, VAR_KEY
8
+
9
+ # pyright: reportAttributeAccessIssue=false
10
+
11
+
12
+ def _patch_class(cls):
13
+ def _patch_method(func):
14
+ @wraps(func)
15
+ def wrapper(self, other):
16
+ if isinstance(other, SupportsMath):
17
+ return NotImplemented
18
+ return func(self, other)
19
+
20
+ return wrapper
21
+
22
+ cls.__add__ = _patch_method(cls.__add__)
23
+ cls.__mul__ = _patch_method(cls.__mul__)
24
+ cls.__sub__ = _patch_method(cls.__sub__)
25
+ cls.__le__ = _patch_method(cls.__le__)
26
+ cls.__ge__ = _patch_method(cls.__ge__)
27
+ cls.__contains__ = _patch_method(cls.__contains__)
28
+
29
+
30
+ def _dataframe_to_expr(self: pl.DataFrame) -> Expression:
31
+ return Expression(
32
+ self.rename({self.columns[-1]: COEF_KEY})
33
+ .drop_nulls(COEF_KEY)
34
+ .with_columns(pl.lit(CONST_TERM).alias(VAR_KEY))
35
+ )
36
+
37
+
38
+ def patch_dataframe_libraries():
39
+ """
40
+ Applies two patches to the DataFrame and Series classes of both pandas and polars.
41
+ 1) Patches arithmetic operators (e.g. `__add__`) such that operations between DataFrames/Series and `Expressionable`s
42
+ are not supported (i.e. `return NotImplemented`). This leads Python to try the reverse operation (e.g. `__radd__`)
43
+ which is supported by the `Expressionable` class.
44
+ 2) Adds a `to_expr` method to DataFrame/Series that allows them to be converted to an `Expression` object.
45
+ Series become dataframes and dataframes become expressions where everything but the last column are treated as dimensions.
46
+ """
47
+ _patch_class(pd.DataFrame)
48
+ _patch_class(pd.Series)
49
+ _patch_class(pl.DataFrame)
50
+ _patch_class(pl.Series)
51
+ pl.DataFrame.to_expr = _dataframe_to_expr
52
+ pl.Series.to_expr = lambda self: self.to_frame().to_expr()
53
+ pd.DataFrame.to_expr = lambda self: pl.from_pandas(self).to_expr()
54
+ pd.Series.to_expr = lambda self: self.to_frame().reset_index().to_expr()
pyoframe/objective.py ADDED
@@ -0,0 +1,42 @@
1
+ from typing import Optional, Union
2
+ from pyoframe.constants import ObjSense, ObjSenseValue
3
+ from pyoframe.constraints import SupportsMath, Expression
4
+
5
+
6
+ class Objective(Expression):
7
+ r"""
8
+ Examples:
9
+ >>> from pyoframe import Variable, Model, sum
10
+ >>> m = Model()
11
+ >>> m.a = Variable()
12
+ >>> m.b = Variable({"dim1": [1, 2, 3]})
13
+ >>> m.maximize = m.a + sum("dim1", m.b)
14
+ >>> m.maximize
15
+ <Objective size=1 dimensions={} terms=4>
16
+ maximize: a + b[1] + b[2] + b[3]
17
+ """
18
+
19
+ def __init__(
20
+ self, expr: SupportsMath, sense: Union[ObjSense, ObjSenseValue]
21
+ ) -> None:
22
+ self.sense = ObjSense(sense)
23
+
24
+ expr = expr.to_expr()
25
+ super().__init__(expr.to_expr().data)
26
+ self._model = expr._model
27
+ assert (
28
+ self.dimensions is None
29
+ ), "Objective cannot have dimensions as it must be a single expression"
30
+ self._value: Optional[float] = None
31
+
32
+ @property
33
+ def value(self):
34
+ if self._value is None:
35
+ raise ValueError(
36
+ "Objective value is not available before solving the model"
37
+ )
38
+ return self._value
39
+
40
+ @value.setter
41
+ def value(self, value):
42
+ self._value = value