desdeo 1.1.3__py3-none-any.whl → 2.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.
Files changed (122) hide show
  1. desdeo/__init__.py +8 -8
  2. desdeo/api/README.md +73 -0
  3. desdeo/api/__init__.py +15 -0
  4. desdeo/api/app.py +40 -0
  5. desdeo/api/config.py +69 -0
  6. desdeo/api/config.toml +53 -0
  7. desdeo/api/db.py +25 -0
  8. desdeo/api/db_init.py +79 -0
  9. desdeo/api/db_models.py +164 -0
  10. desdeo/api/malaga_db_init.py +27 -0
  11. desdeo/api/models/__init__.py +66 -0
  12. desdeo/api/models/archive.py +34 -0
  13. desdeo/api/models/preference.py +90 -0
  14. desdeo/api/models/problem.py +507 -0
  15. desdeo/api/models/reference_point_method.py +18 -0
  16. desdeo/api/models/session.py +46 -0
  17. desdeo/api/models/state.py +96 -0
  18. desdeo/api/models/user.py +51 -0
  19. desdeo/api/routers/_NAUTILUS.py +245 -0
  20. desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
  21. desdeo/api/routers/_NIMBUS.py +762 -0
  22. desdeo/api/routers/__init__.py +5 -0
  23. desdeo/api/routers/problem.py +110 -0
  24. desdeo/api/routers/reference_point_method.py +117 -0
  25. desdeo/api/routers/session.py +76 -0
  26. desdeo/api/routers/test.py +16 -0
  27. desdeo/api/routers/user_authentication.py +366 -0
  28. desdeo/api/schema.py +94 -0
  29. desdeo/api/tests/__init__.py +0 -0
  30. desdeo/api/tests/conftest.py +59 -0
  31. desdeo/api/tests/test_models.py +701 -0
  32. desdeo/api/tests/test_routes.py +216 -0
  33. desdeo/api/utils/database.py +274 -0
  34. desdeo/api/utils/logger.py +29 -0
  35. desdeo/core.py +27 -0
  36. desdeo/emo/__init__.py +29 -0
  37. desdeo/emo/hooks/archivers.py +172 -0
  38. desdeo/emo/methods/EAs.py +418 -0
  39. desdeo/emo/methods/__init__.py +0 -0
  40. desdeo/emo/methods/bases.py +59 -0
  41. desdeo/emo/operators/__init__.py +1 -0
  42. desdeo/emo/operators/crossover.py +780 -0
  43. desdeo/emo/operators/evaluator.py +118 -0
  44. desdeo/emo/operators/generator.py +356 -0
  45. desdeo/emo/operators/mutation.py +1053 -0
  46. desdeo/emo/operators/selection.py +1036 -0
  47. desdeo/emo/operators/termination.py +178 -0
  48. desdeo/explanations/__init__.py +6 -0
  49. desdeo/explanations/explainer.py +100 -0
  50. desdeo/explanations/utils.py +90 -0
  51. desdeo/mcdm/__init__.py +19 -0
  52. desdeo/mcdm/nautili.py +345 -0
  53. desdeo/mcdm/nautilus.py +477 -0
  54. desdeo/mcdm/nautilus_navigator.py +655 -0
  55. desdeo/mcdm/nimbus.py +417 -0
  56. desdeo/mcdm/pareto_navigator.py +269 -0
  57. desdeo/mcdm/reference_point_method.py +116 -0
  58. desdeo/problem/__init__.py +79 -0
  59. desdeo/problem/evaluator.py +561 -0
  60. desdeo/problem/gurobipy_evaluator.py +562 -0
  61. desdeo/problem/infix_parser.py +341 -0
  62. desdeo/problem/json_parser.py +944 -0
  63. desdeo/problem/pyomo_evaluator.py +468 -0
  64. desdeo/problem/schema.py +1808 -0
  65. desdeo/problem/simulator_evaluator.py +298 -0
  66. desdeo/problem/sympy_evaluator.py +244 -0
  67. desdeo/problem/testproblems/__init__.py +73 -0
  68. desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
  69. desdeo/problem/testproblems/dtlz2_problem.py +102 -0
  70. desdeo/problem/testproblems/forest_problem.py +275 -0
  71. desdeo/problem/testproblems/knapsack_problem.py +163 -0
  72. desdeo/problem/testproblems/mcwb_problem.py +831 -0
  73. desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
  74. desdeo/problem/testproblems/momip_problem.py +172 -0
  75. desdeo/problem/testproblems/nimbus_problem.py +143 -0
  76. desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
  77. desdeo/problem/testproblems/re_problem.py +492 -0
  78. desdeo/problem/testproblems/river_pollution_problem.py +434 -0
  79. desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
  80. desdeo/problem/testproblems/simple_problem.py +351 -0
  81. desdeo/problem/testproblems/simulator_problem.py +92 -0
  82. desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
  83. desdeo/problem/testproblems/zdt_problem.py +271 -0
  84. desdeo/problem/utils.py +245 -0
  85. desdeo/tools/GenerateReferencePoints.py +181 -0
  86. desdeo/tools/__init__.py +102 -0
  87. desdeo/tools/generics.py +145 -0
  88. desdeo/tools/gurobipy_solver_interfaces.py +258 -0
  89. desdeo/tools/indicators_binary.py +11 -0
  90. desdeo/tools/indicators_unary.py +375 -0
  91. desdeo/tools/interaction_schema.py +38 -0
  92. desdeo/tools/intersection.py +54 -0
  93. desdeo/tools/iterative_pareto_representer.py +99 -0
  94. desdeo/tools/message.py +234 -0
  95. desdeo/tools/ng_solver_interfaces.py +199 -0
  96. desdeo/tools/non_dominated_sorting.py +133 -0
  97. desdeo/tools/patterns.py +281 -0
  98. desdeo/tools/proximal_solver.py +99 -0
  99. desdeo/tools/pyomo_solver_interfaces.py +464 -0
  100. desdeo/tools/reference_vectors.py +462 -0
  101. desdeo/tools/scalarization.py +3138 -0
  102. desdeo/tools/scipy_solver_interfaces.py +454 -0
  103. desdeo/tools/score_bands.py +464 -0
  104. desdeo/tools/utils.py +320 -0
  105. desdeo/utopia_stuff/__init__.py +0 -0
  106. desdeo/utopia_stuff/data/1.json +15 -0
  107. desdeo/utopia_stuff/data/2.json +13 -0
  108. desdeo/utopia_stuff/data/3.json +15 -0
  109. desdeo/utopia_stuff/data/4.json +17 -0
  110. desdeo/utopia_stuff/data/5.json +15 -0
  111. desdeo/utopia_stuff/from_json.py +40 -0
  112. desdeo/utopia_stuff/reinit_user.py +38 -0
  113. desdeo/utopia_stuff/utopia_db_init.py +212 -0
  114. desdeo/utopia_stuff/utopia_problem.py +403 -0
  115. desdeo/utopia_stuff/utopia_problem_old.py +415 -0
  116. desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
  117. desdeo-2.0.0.dist-info/LICENSE +21 -0
  118. desdeo-2.0.0.dist-info/METADATA +168 -0
  119. desdeo-2.0.0.dist-info/RECORD +120 -0
  120. {desdeo-1.1.3.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
  121. desdeo-1.1.3.dist-info/METADATA +0 -18
  122. desdeo-1.1.3.dist-info/RECORD +0 -4
@@ -0,0 +1,1808 @@
1
+ """Schema for the problem definition.
2
+
3
+ The problem definition is a JSON file that contains the following information:
4
+
5
+ - Constants
6
+ - Variables
7
+ - Objectives
8
+ - Extra functions
9
+ - Scalarization functions
10
+ - Evaluated solutions and their info
11
+
12
+ """
13
+
14
+ from collections import Counter
15
+ from collections.abc import Iterable
16
+ from enum import Enum
17
+ from itertools import product
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAliasType
20
+
21
+ import numpy as np
22
+ from pydantic import (
23
+ BaseModel,
24
+ ConfigDict,
25
+ Field,
26
+ PrivateAttr,
27
+ ValidationError,
28
+ ValidationInfo,
29
+ ValidatorFunctionWrapHandler,
30
+ WrapValidator,
31
+ field_validator,
32
+ model_validator,
33
+ )
34
+ from pydantic_core import PydanticCustomError
35
+
36
+ from desdeo.problem.infix_parser import InfixExpressionParser
37
+
38
+ if TYPE_CHECKING:
39
+ from desdeo.api.models import ProblemDB
40
+
41
+ VariableType = float | int | bool
42
+
43
+
44
+ def tensor_custom_error_validator(value: Any, handler: ValidatorFunctionWrapHandler, _info: ValidationInfo) -> Any:
45
+ """Custom error handler to simplify error messages related to recursive tensor types.
46
+
47
+ Args:
48
+ value (Any): input value to be validated.
49
+ handler (ValidatorFunctionWrapHandler): handler to check the values.
50
+ _info (ValidationInfo): info related to the validation of the value.
51
+
52
+ Raises:
53
+ PydanticCustomError: when the value is an invalid tensor type.
54
+
55
+ Returns:
56
+ Any: a valid tensor.
57
+ """
58
+ try:
59
+ return handler(value)
60
+ except ValidationError as exc:
61
+ raise PydanticCustomError("invalid tensor", "Input is not a valid tensor") from exc
62
+
63
+
64
+ Tensor = TypeAliasType(
65
+ "Tensor",
66
+ Annotated[
67
+ list["Tensor"] | list[VariableType] | VariableType | Literal["List"] | None,
68
+ WrapValidator(tensor_custom_error_validator),
69
+ ],
70
+ )
71
+
72
+
73
+ def parse_infix_to_func(cls: "Problem", v: str | list) -> list:
74
+ """Validator that checks if the 'func' field is of type str or list.
75
+
76
+ If str, then it is assumed the string represents the func in infix notation. The string
77
+ is parsed in the validator. If list, then the func is assumed to be represented in Math JSON format.
78
+
79
+ Args:
80
+ cls: the class of the pydantic model the validator is applied to.
81
+ v (str | list): The func to be validated.
82
+
83
+ Raises:
84
+ ValueError: v is neither an instance of str or a list.
85
+
86
+ Returns:
87
+ list: The func represented in Math JSON format.
88
+ """
89
+ if v is None:
90
+ return v
91
+ # Check if v is a string (infix expression), then parse it
92
+ if isinstance(v, str):
93
+ parser = InfixExpressionParser()
94
+ return parser.parse(v)
95
+ # If v is already in the correct format (a list), just return it
96
+ if isinstance(v, list):
97
+ return v
98
+
99
+ # Raise an error if v is neither a string nor a list
100
+ msg = f"The function expressions must be a string (infix expression) or a list. Got {type(v)}."
101
+ raise ValueError(msg)
102
+
103
+
104
+ def parse_scenario_key_singleton_to_list(cls: "Problem", v: str | list[str]) -> list[str] | None:
105
+ """Validator that checks the type of a scenario key.
106
+
107
+ If the type is a list, it will be returned as it is. If it is a string,
108
+ then a list with the single string is returned. Else, a ValueError is raised.
109
+
110
+ Args:
111
+ cls: the class of the pydantic model the validator is applied to.
112
+ v (str | list[str]): the scenario key, or keys, to be validated.
113
+
114
+ Raises:
115
+ ValueError: raised when `v` it neither a string or a list.
116
+
117
+ Returns:
118
+ list[str]: a list with scenario keys.
119
+ """
120
+ if v is None:
121
+ return v
122
+ if isinstance(v, str):
123
+ return [v]
124
+ if isinstance(v, list):
125
+ return v
126
+
127
+ msg = f"The scenario keys must be either a list of strings, or a single string. Got {type(v)}."
128
+ raise ValueError(msg)
129
+
130
+
131
+ def parse_list_to_mathjson(cls: "TensorVariable", v: Tensor | VariableType | None) -> list:
132
+ """Validator that makes sure a nested Python list is represented as tensor following the MathJSON convention.
133
+
134
+ Args:
135
+ cls (TensorVariable): the class of the pydantic model the validator is applied to.
136
+ v (Tensor | VariableType | None): the nested lists to be validated.
137
+
138
+ Returns:
139
+ list: a tensor following the MathJSON conventions; or a single value or None,
140
+ if v was assigned to one of these types.
141
+ """
142
+ if v is None or isinstance(v, VariableType):
143
+ return v
144
+
145
+ # Check if the input is already in MathJSON format
146
+ if isinstance(v, list) and len(v) > 0 and v[0] == "List":
147
+ return v
148
+
149
+ # recursively parse into a MathJSON representation
150
+ if isinstance(v, list) and len(v) > 0:
151
+ if v[0] == "List":
152
+ # assumed to be already in MathJson format, just return the list
153
+ return v
154
+ if isinstance(v[0], list):
155
+ # recursive case, encountered list
156
+ return ["List", *[parse_list_to_mathjson(TensorVariable, v_element) for v_element in v]]
157
+ if isinstance(v[0], VariableType | None):
158
+ # terminal case, encountered a VariableType
159
+ return ["List", *v]
160
+
161
+ # if anything else is encountered, raise an error
162
+ msg = "Encountered value that is not a valid VariableType nor a list."
163
+ raise ValueError(msg)
164
+
165
+ msg = f"The tensor must a Python list (of lists) or a single value of type VariableType. Got {type(v)}."
166
+ raise ValueError(msg)
167
+
168
+
169
+ def get_tensor_values(
170
+ values: Iterable[VariableType | Iterable[VariableType]] | VariableType | None,
171
+ ) -> Iterable[VariableType | Iterable[VariableType]] | VariableType | None:
172
+ """Return the values for a given attribute as a nested Python list or single value.
173
+
174
+ Removes the 'List' entries from the JSON format to give a Python compatible list.
175
+ If the values are a single value or None, then a single value or None is returned
176
+ instead, respectively.
177
+
178
+ Arguments:
179
+ values (Iterable[VariableType | Iterable[VariableType]] | VariableType | None):
180
+ the values that should be extracted as a Python list.
181
+
182
+ Returns:
183
+ list[VariableType] | Iterable[list[VariableType]] | VariableType| None: a list with shape `self.shape` with the
184
+ values defined for the variable. If a single values consisted of a single value or None instead, then
185
+ a single valuer or None are returned, respectively.
186
+ """
187
+ if values is None or isinstance(values, VariableType):
188
+ return values
189
+
190
+ if isinstance(values, list) and len(values) > 1:
191
+ if values[0] == "List" and isinstance(values[1], list):
192
+ # recursive case, encountered list
193
+ return [get_tensor_values(v_element) for v_element in values[1:]]
194
+ if values[0] == "List":
195
+ # terminal case, encountered a VariableType
196
+ return [*values[1:]]
197
+
198
+ # if anything else is encountered, raise an error
199
+ msg = "Encountered value that is not a valid VariableType nor a list."
200
+ raise ValueError(msg)
201
+
202
+ msg = f"Values must be a valid MathJSON vector. Got {type(values)}."
203
+ raise ValueError(msg)
204
+
205
+
206
+ class VariableTypeEnum(str, Enum):
207
+ """An enumerator for possible variable types."""
208
+
209
+ real = "real"
210
+ """A continuous variable."""
211
+ integer = "integer"
212
+ """An integer variable."""
213
+ binary = "binary"
214
+ """A binary variable."""
215
+
216
+
217
+ class VariableDomainTypeEnum(str, Enum):
218
+ """An enumerator for the possible variable type domains of a problem."""
219
+
220
+ continuous = "continuous"
221
+ """All variables are real valued."""
222
+ binary = "binary"
223
+ """All variables are binary valued."""
224
+ integer = "integer"
225
+ """All variables are integer or binary valued."""
226
+ mixed = "mixed"
227
+ """Some variables are continuos, some are integer or binary."""
228
+
229
+
230
+ class ConstraintTypeEnum(str, Enum):
231
+ """An enumerator for supported constraint expression types."""
232
+
233
+ EQ = "="
234
+ """An equality constraint."""
235
+ LTE = "<=" # less than or equal
236
+ """An inequality constraint of type 'less than or equal'."""
237
+
238
+
239
+ class ObjectiveTypeEnum(str, Enum):
240
+ """An enumerator for supported objective function types."""
241
+
242
+ analytical = "analytical"
243
+ """An objective function with an analytical formulation. E.g., it can be
244
+ expressed with mathematical expressions, such as x_1 + x_2."""
245
+ data_based = "data_based"
246
+ """A data-based objective function. It is assumed that when such an
247
+ objective is present in a `Problem`, then there is a
248
+ `DiscreteRepresentation` available with values representing the objective
249
+ function."""
250
+ simulator = "simulator"
251
+ """A simulator based objective function. It is assumed that a Path (str)
252
+ to a simulator file that connects a simulator to DESDEO is present in
253
+ the `Objective` and also in the list of simulators in the `Problem`."""
254
+ surrogate = "surrogate"
255
+ """A surrogate based objective function. It is assumed that a Path (str)
256
+ to a surrogate saved on the disk is present in the `Objective` and also in
257
+ the list of simulators in the `Problem`."""
258
+
259
+
260
+ class Constant(BaseModel):
261
+ """Model for a constant."""
262
+
263
+ model_config = ConfigDict(frozen=True, from_attributes=True)
264
+
265
+ name: str = Field(
266
+ description=(
267
+ "Descriptive name of the constant. This can be used in UI and visualizations. Example: 'maximum cost'."
268
+ ),
269
+ )
270
+ """Descriptive name of the constant. This can be used in UI and visualizations." " Example: 'maximum cost'."""
271
+ symbol: str = Field(
272
+ description=(
273
+ "Symbol to represent the constant. This will be used in the rest of the problem definition."
274
+ " It may also be used in UIs and visualizations. Example: 'c_1'."
275
+ ),
276
+ )
277
+ """ Symbol to represent the constant. This will be used in the rest of the
278
+ problem definition. It may also be used in UIs and visualizations. Example:
279
+ 'c_1'."""
280
+ value: VariableType = Field(description="The value of the constant.")
281
+ """The value of the constant."""
282
+
283
+
284
+ class TensorConstant(BaseModel):
285
+ """Model for a tensor containing constant values."""
286
+
287
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, from_attributes=True)
288
+
289
+ name: str = Field(description="Descriptive name of the tensor representing the values. E.g., 'distances'")
290
+ """Descriptive name of the tensor representing the values. E.g., 'distances'"""
291
+ symbol: str = Field(
292
+ description=(
293
+ "Symbol to represent the constant. This will be used in the rest of the problem definition."
294
+ " Notice that the elements of the tensor will be represented with the symbol followed by"
295
+ " indices. E.g., the first element of the third element of a 2-dimensional tensor,"
296
+ " is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable."
297
+ " Note that indexing starts from 1."
298
+ )
299
+ )
300
+ """
301
+ Symbol to represent the constant. This will be used in the rest of the problem definition.
302
+ Notice that the elements of the tensor will be represented with the symbol followed by
303
+ indices. E.g., the first element of the third element of a 2-dimensional tensor,
304
+ is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable.
305
+ Note that indexing starts from 1.
306
+ """
307
+ shape: list[int] = Field(
308
+ description=(
309
+ "A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
310
+ )
311
+ )
312
+ """A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns.
313
+ """
314
+ values: Tensor = Field(
315
+ description=(
316
+ "A list of lists, with the elements representing the values of each constant element in the tensor. "
317
+ "E.g., `[[5, 22, 0], [14, 5, 44]]`."
318
+ ),
319
+ )
320
+ """A list of lists, with the elements representing the initial values of each constant element in the tensor.
321
+ E.g., `[[5, 22, 0], [14, 5, 44]]`."""
322
+
323
+ _parse_list_to_mathjson = field_validator("values", mode="before")(parse_list_to_mathjson)
324
+
325
+ def get_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None, Iterable[None]]:
326
+ """Return the constant values as a Python iterable (e.g., list of list)."""
327
+ values = get_tensor_values(self.values)
328
+ if isinstance(values, VariableType | None):
329
+ return np.full(self.shape, values).tolist()
330
+
331
+ return values
332
+
333
+ def to_constants(self) -> list[Constant]:
334
+ """Flatten the tensor into a list of Constants.
335
+
336
+ Returns:
337
+ list[Constant]: a list of Constants.
338
+ """
339
+ constants = []
340
+ for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
341
+ constants.append(self[*indices])
342
+
343
+ return constants
344
+
345
+ def __getitem__(self, indices: int | tuple[int]) -> Constant:
346
+ """Implements random access for TensorConstant.
347
+
348
+ Note:
349
+ Indexing is assumed to start at 1.
350
+
351
+ Args:
352
+ indices (int | Tuple[int]): a single integer or tuple of integers.
353
+
354
+ Returns:
355
+ Constant: A new instance of Constant that has been setup with
356
+ information found at the specified indices in the TensorConstant.
357
+ """
358
+ if isinstance(indices, tuple):
359
+ # multi-dimensional indexing
360
+ name = f"{self.name} at position {[*indices]}"
361
+ symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"
362
+
363
+ value = self.get_values()
364
+
365
+ for idx in indices:
366
+ value = value[idx - 1]
367
+
368
+ else:
369
+ # single indexing
370
+ name = f"{self.name} at position [{indices}]"
371
+ symbol = f"{self.symbol}_{indices}"
372
+ value = self.get_values()[indices - 1]
373
+
374
+ return Constant(name=name, symbol=symbol, value=value)
375
+
376
+
377
+ class Variable(BaseModel):
378
+ """Model for a variable."""
379
+
380
+ model_config = ConfigDict(frozen=True, from_attributes=True)
381
+
382
+ name: str = Field(
383
+ description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
384
+ )
385
+ """Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."""
386
+ symbol: str = Field(
387
+ description=(
388
+ "Symbol to represent the variable. This will be used in the rest of the problem definition."
389
+ " It may also be used in UIs and visualizations. Example: 'v_1'."
390
+ ),
391
+ )
392
+ """ Symbol to represent the variable. This will be used in the rest of the
393
+ problem definition. It may also be used in UIs and visualizations. Example:
394
+ 'v_1'."""
395
+ variable_type: VariableTypeEnum = Field(description="Type of the variable. Can be real, integer or binary.")
396
+ """Type of the variable. Can be real, integer or binary."""
397
+ lowerbound: VariableType | None = Field(description="Lower bound of the variable.", default=None)
398
+ """Lower bound of the variable. Defaults to `None`."""
399
+ upperbound: VariableType | None = Field(description="Upper bound of the variable.", default=None)
400
+ """Upper bound of the variable. Defaults to `None`."""
401
+ initial_value: VariableType | None = Field(
402
+ description="Initial value of the variable. This is optional.", default=None
403
+ )
404
+ """Initial value of the variable. This is optional. Defaults to `None`."""
405
+
406
+
407
+ class TensorVariable(BaseModel):
408
+ """Model for a tensor, e.g., vector variable."""
409
+
410
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, from_attributes=True)
411
+
412
+ name: str = Field(
413
+ description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
414
+ )
415
+ """Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."""
416
+ symbol: str = Field(
417
+ description=(
418
+ "Symbol to represent the variable. This will be used in the rest of the problem definition."
419
+ " Notice that the elements of the tensor will be represented with the symbol followed by"
420
+ " indices. E.g., the first element of the third element of a 2-dimensional tensor,"
421
+ " is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable."
422
+ " Note that indexing starts from 1."
423
+ )
424
+ )
425
+ """
426
+ Symbol to represent the variable. This will be used in the rest of the problem definition.
427
+ Notice that the elements of the tensor will be represented with the symbol followed by
428
+ indices. E.g., the first element of the third element of a 2-dimensional tensor,
429
+ is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable.
430
+ Note that indexing starts from 1.
431
+ """
432
+ variable_type: VariableTypeEnum = Field(
433
+ description=(
434
+ "Type of the variable. Can be real, integer, or binary. "
435
+ "Note that each element of a TensorVariable is assumed to be of the same type."
436
+ )
437
+ )
438
+ """Type of the variable. Can be real, integer, or binary.
439
+ Note that each element of a TensorVariable is assumed to be of the same type."""
440
+
441
+ shape: list[int] = Field(
442
+ description=(
443
+ "A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
444
+ )
445
+ )
446
+ """A list of the dimensions of the tensor,
447
+ e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns.
448
+ """
449
+ lowerbounds: Tensor | None = Field(
450
+ description=(
451
+ "A list of lists, with the elements representing the lower bounds of each element. "
452
+ "E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, that value is assumed to be the lower "
453
+ "bound of each element. Defaults to None."
454
+ ),
455
+ default=None,
456
+ )
457
+ """A list of lists, with the elements representing the lower bounds of each
458
+ element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied,
459
+ that value is assumed to be the lower bound of each element. Defaults to
460
+ None."""
461
+ upperbounds: Tensor | VariableType | None = Field(
462
+ description=(
463
+ "A list of lists, with the elements representing the upper bounds of each "
464
+ "element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, "
465
+ "that value is assumed to be the upper bound of each element. Defaults to "
466
+ "None."
467
+ ),
468
+ default=None,
469
+ )
470
+ """A list of lists, with the elements representing the upper bounds of each
471
+ element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied,
472
+ that value is assumed to be the upper bound of each element. Defaults to
473
+ None."""
474
+ initial_values: Tensor | VariableType | None = Field(
475
+ description=(
476
+ "A list of lists, with the elements representing the initial values of "
477
+ "each element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is "
478
+ "supplied, that value is assumed to be the initial value of each element. "
479
+ "Defaults to None."
480
+ ),
481
+ default=None,
482
+ )
483
+ """A list of lists, with the elements representing the initial values of
484
+ each element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is
485
+ supplied, that value is assumed to be the initial value of each element.
486
+ Defaults to None."""
487
+
488
+ _parse_list_to_mathjson = field_validator("lowerbounds", "upperbounds", "initial_values", mode="before")(
489
+ parse_list_to_mathjson
490
+ )
491
+
492
+ def get_lowerbound_values(
493
+ self,
494
+ ) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
495
+ """Return the lowerbounds values, if any, as a Python iterable (list of list)."""
496
+ lowerbounds = get_tensor_values(self.lowerbounds)
497
+ if isinstance(lowerbounds, VariableType | None):
498
+ # single value, construct list with the correct dimensions
499
+ return np.full(self.shape, lowerbounds).tolist()
500
+
501
+ return lowerbounds
502
+
503
+ def get_upperbound_values(
504
+ self,
505
+ ) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
506
+ """Return the upperbounds values, if any, as a Python iterable (list of list)."""
507
+ upperbounds = get_tensor_values(self.upperbounds)
508
+ if isinstance(upperbounds, VariableType | None):
509
+ # single value, construct list with the correct dimensions
510
+ return np.full(self.shape, upperbounds).tolist()
511
+
512
+ return upperbounds
513
+
514
+ def get_initial_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
515
+ """Return the initial values, if any, as a Python iterable (list of list)."""
516
+ values = get_tensor_values(self.initial_values)
517
+ if isinstance(values, VariableType | None):
518
+ # single value, construct list with the correct dimensions
519
+ return np.full(self.shape, values).tolist()
520
+
521
+ return values
522
+
523
+ def to_variables(self) -> list[Variable]:
524
+ """Flatten the tensor into a list of Variables.
525
+
526
+ Returns:
527
+ list[Variable]: a list of Variables.
528
+ """
529
+ variables = []
530
+ for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
531
+ variables.append(self[*indices])
532
+
533
+ return variables
534
+
535
+ def __getitem__(self, indices: int | tuple[int]) -> Variable:
536
+ """Implements random access for TensorVariable.
537
+
538
+ Note:
539
+ Indexing is assumed to start at 1.
540
+
541
+ Args:
542
+ indices (int | Tuple[int]): a single integer or tuple of integers.
543
+
544
+ Returns:
545
+ Variable: A new instance of Variable that has been setup with
546
+ information found at the specified indices in the TensorVariable.
547
+ """
548
+ if isinstance(indices, tuple):
549
+ # multi-dimensional indexing
550
+ name = f"{self.name} at position {[*indices]}"
551
+ symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"
552
+
553
+ lowerbound = self.get_lowerbound_values()
554
+ upperbound = self.get_upperbound_values()
555
+ initial_value = self.get_initial_values()
556
+
557
+ for idx in indices:
558
+ lowerbound = lowerbound[idx - 1]
559
+ upperbound = upperbound[idx - 1]
560
+ initial_value = initial_value[idx - 1]
561
+
562
+ else:
563
+ # single indexing
564
+ name = f"{self.name} at position [{indices}]"
565
+ symbol = f"{self.symbol}_{indices}"
566
+
567
+ lowerbound = self.get_lowerbound_values()[indices - 1]
568
+ upperbound = self.get_upperbound_values()[indices - 1]
569
+ initial_value = self.get_initial_values()[indices - 1]
570
+
571
+ return Variable(
572
+ name=name,
573
+ symbol=symbol,
574
+ variable_type=self.variable_type,
575
+ lowerbound=lowerbound,
576
+ upperbound=upperbound,
577
+ initial_value=initial_value,
578
+ )
579
+
580
+
581
+ class ExtraFunction(BaseModel):
582
+ """Model for extra functions.
583
+
584
+ These functions can, e.g., be functions that are re-used in the problem formulation, or
585
+ they are needed for other computations related to the problem.
586
+ """
587
+
588
+ model_config = ConfigDict(frozen=True, from_attributes=True)
589
+
590
+ name: str = Field(
591
+ description=("Descriptive name of the function. Example: 'normalization'."),
592
+ )
593
+ """Descriptive name of the function. Example: 'normalization'."""
594
+ symbol: str = Field(
595
+ description=(
596
+ "Symbol to represent the function. This will be used in the rest of the problem definition."
597
+ " It may also be used in UIs and visualizations. Example: 'avg'."
598
+ ),
599
+ )
600
+ """ Symbol to represent the function. This will be used in the rest of the
601
+ problem definition. It may also be used in UIs and visualizations. Example:
602
+ 'avg'."""
603
+ func: list | None = Field(
604
+ description=(
605
+ "The string representing the function. This is a JSON object that can be parsed into a function."
606
+ "Must be a valid MathJSON object."
607
+ " The symbols in the function must match symbols defined for objective/variable/constant."
608
+ "Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. "
609
+ "If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."
610
+ ),
611
+ default=None,
612
+ )
613
+ """ The string representing the function. This is a JSON object that can be
614
+ parsed into a function. Must be a valid MathJSON object. The symbols in
615
+ the function must match symbols defined for objective/variable/constant.
616
+ Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'.
617
+ If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."""
618
+ simulator_path: Path | None = Field(
619
+ description=(
620
+ "Path to a python file with the connection to simulators. Must be a valid Path."
621
+ "Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions."
622
+ "If 'None', either 'func' or 'surrogates' must not be 'None'."
623
+ ),
624
+ default=None,
625
+ )
626
+ """Path to a python file with the connection to simulators. Must be a valid Path.
627
+ Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions.
628
+ If 'None', either 'func' or 'surrogates' must not be 'None'."""
629
+ surrogates: list[Path] | None = Field(
630
+ description=(
631
+ "A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based "
632
+ "or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must "
633
+ "not be 'None'."
634
+ ),
635
+ default=None,
636
+ )
637
+ """A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based
638
+ or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must
639
+ not be 'None'."""
640
+ is_linear: bool = Field(
641
+ description="Whether the function expression is linear or not. Defaults to `False`.", default=False
642
+ )
643
+ """Whether the function expression is linear or not. Defaults to `False`."""
644
+ is_convex: bool = Field(
645
+ description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
646
+ )
647
+ """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
648
+ is_twice_differentiable: bool = Field(
649
+ description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
650
+ )
651
+ """Whether the function expression is twice differentiable or not. Defaults to `False`"""
652
+ scenario_keys: list[str] | None = Field(
653
+ description="Optional. The keys of the scenario the extra functions belongs to.", default=None
654
+ )
655
+ """Optional. The keys of the scenarios the extra functions belongs to."""
656
+
657
+ _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
658
+ _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")(
659
+ parse_scenario_key_singleton_to_list
660
+ )
661
+
662
+
663
+ class ScalarizationFunction(BaseModel):
664
+ """Model for scalarization of the problem."""
665
+
666
+ model_config = ConfigDict(from_attributes=True)
667
+
668
+ name: str = Field(description=("Name of the scalarization function."))
669
+ """Name of the scalarization function."""
670
+ symbol: str | None = Field(
671
+ description=(
672
+ "Optional symbol to represent the scalarization function. This may be used in UIs and visualizations."
673
+ ),
674
+ default=None,
675
+ )
676
+ """Optional symbol to represent the scalarization function. This may be used
677
+ in UIs and visualizations. Defaults to `None`."""
678
+ func: list = Field(
679
+ description=(
680
+ "Function representation of the scalarization. This is a JSON object that can be parsed into a function."
681
+ "Must be a valid MathJSON object."
682
+ " The symbols in the function must match the symbols defined for objective/variable/constant/extra"
683
+ " function."
684
+ ),
685
+ )
686
+ """ Function representation of the scalarization. This is a JSON object that
687
+ can be parsed into a function. Must be a valid MathJSON object. The
688
+ symbols in the function must match the symbols defined for
689
+ objective/variable/constant/extra function."""
690
+ is_linear: bool = Field(
691
+ description="Whether the function expression is linear or not. Defaults to `False`.", default=False
692
+ )
693
+ """Whether the function expression is linear or not. Defaults to `False`."""
694
+ is_convex: bool = Field(
695
+ description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
696
+ default=False,
697
+ )
698
+ """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
699
+ is_twice_differentiable: bool = Field(
700
+ description="Whether the function expression is twice differentiable or not. Defaults to `False`",
701
+ default=False,
702
+ )
703
+ """Whether the function expression is twice differentiable or not. Defaults to `False`"""
704
+ scenario_keys: list[str] = Field(
705
+ description="Optional. The keys of the scenarios the scalarization function belongs to.", default=None
706
+ )
707
+ """Optional. The keys of the scenarios the scalarization function belongs to."""
708
+
709
+ _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
710
+ _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")(
711
+ parse_scenario_key_singleton_to_list
712
+ )
713
+
714
+
715
+ class Simulator(BaseModel):
716
+ """Model for simulator data."""
717
+
718
+ model_config = ConfigDict(frozen=True, from_attributes=True)
719
+
720
+ name: str = Field(
721
+ description=("Descriptive name of the simulator. This can be used in UI and visualizations."),
722
+ )
723
+ """Descriptive name of the simulator. This can be used in UI and visualizations."""
724
+ symbol: str = Field(
725
+ description=(
726
+ "Symbol to represent the simulator. This will be used in the rest of the problem definition."
727
+ " It may also be used in UIs and visualizations."
728
+ ),
729
+ )
730
+ file: Path = Field(
731
+ description=("Path to a python file with the connection to simulators."),
732
+ )
733
+ """Path to a python file with the connection to simulators."""
734
+ parameter_options: dict | None = Field(
735
+ description=(
736
+ "Parameters to the simulator that are not decision variables, but affect the results."
737
+ "Format is similar to decision variables. Can be 'None'."
738
+ ),
739
+ default=None,
740
+ )
741
+ """Parameters to the simulator that are not decision variables, but affect the results.
742
+ Format is similar to decision variables. Can be 'None'."""
743
+
744
+
745
+ class Objective(BaseModel):
746
+ """Model for an objective function."""
747
+
748
+ model_config = ConfigDict(frozen=True, from_attributes=True)
749
+
750
+ name: str = Field(
751
+ description=(
752
+ "Descriptive name of the objective function. This can be used in UI and visualizations. Example: 'time'."
753
+ ),
754
+ )
755
+ """Descriptive name of the objective function. This can be used in UI and visualizations."""
756
+ symbol: str = Field(
757
+ description=(
758
+ "Symbol to represent the objective function. This will be used in the rest of the problem definition."
759
+ " It may also be used in UIs and visualizations. Example: 'f_1'."
760
+ ),
761
+ )
762
+ """ Symbol to represent the objective function. This will be used in the
763
+ rest of the problem definition. It may also be used in UIs and
764
+ visualizations. Example: 'f_1'."""
765
+ unit: str | None = Field(
766
+ description=(
767
+ "The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds'"
768
+ " or 'millions of hectares'."
769
+ ),
770
+ default=None,
771
+ )
772
+ """The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds' or
773
+ 'millions of hectares'. Defaults to `None`."""
774
+ func: list | None = Field(
775
+ description=(
776
+ "The objective function. This is a JSON object that can be parsed into a function."
777
+ "Must be a valid MathJSON object. The symbols in the function must match the symbols defined for "
778
+ "variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or "
779
+ "'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must "
780
+ "not be 'None'."
781
+ ),
782
+ default=None,
783
+ )
784
+ """ The objective function. This is a JSON object that can be parsed into a function.
785
+ Must be a valid MathJSON object. The symbols in the function must match the symbols defined for
786
+ variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or
787
+ 'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must
788
+ not be 'None'."""
789
+ simulator_path: Path | None = Field(
790
+ description=(
791
+ "Path to a python file with the connection to simulators. Must be a valid Path."
792
+ "Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions."
793
+ "If 'None', either 'func' or 'surrogates' must not be 'None'."
794
+ ),
795
+ default=None,
796
+ )
797
+ """Path to a python file with the connection to simulators. Must be a valid Path.
798
+ Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions.
799
+ If 'None', either 'func' or 'surrogates' must not be 'None'."""
800
+ surrogates: list[Path] | None = Field(
801
+ description=(
802
+ "A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based "
803
+ "or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must "
804
+ "not be 'None'."
805
+ ),
806
+ default=None,
807
+ )
808
+ """A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based
809
+ or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must
810
+ not be 'None'."""
811
+ maximize: bool = Field(
812
+ description="Whether the objective function is to be maximized or minimized.",
813
+ default=False,
814
+ )
815
+ """Whether the objective function is to be maximized or minimized. Defaults to `False`."""
816
+ ideal: float | None = Field(description="Ideal value of the objective. This is optional.", default=None)
817
+ """Ideal value of the objective. This is optional. Defaults to `None`."""
818
+ nadir: float | None = Field(description="Nadir value of the objective. This is optional.", default=None)
819
+ """Nadir value of the objective. This is optional. Defaults to `None`."""
820
+
821
+ objective_type: ObjectiveTypeEnum = Field(
822
+ description=(
823
+ "The type of objective function. 'analytical' means the objective function value is calculated "
824
+ "based on 'func'. 'data_based' means the objective function value should be retrieved from a table. "
825
+ "In case of 'data_based' objective function, the 'func' field is ignored. Defaults to 'analytical'."
826
+ ),
827
+ default=ObjectiveTypeEnum.analytical,
828
+ )
829
+ """ The type of objective function. 'analytical' means the objective
830
+ function value is calculated based on 'func'. 'data_based' means the
831
+ objective function value should be retrieved from a table. In case of
832
+ 'data_based' objective function, the 'func' field is ignored. Defaults to
833
+ 'analytical'. Defaults to 'analytical'."""
834
+ is_linear: bool = Field(
835
+ description="Whether the function expression is linear or not. Defaults to `False`.", default=False
836
+ )
837
+ """Whether the function expression is linear or not. Defaults to `False`."""
838
+ is_convex: bool = Field(
839
+ description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
840
+ )
841
+ """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
842
+ is_twice_differentiable: bool = Field(
843
+ description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
844
+ )
845
+ """Whether the function expression is twice differentiable or not. Defaults to `False`"""
846
+ scenario_keys: list[str] | None = Field(
847
+ description="Optional. The keys of the scenarios the objective function belongs to.", default=None
848
+ )
849
+ """Optional. The keys of the scenarios the objective function belongs to."""
850
+
851
+ _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
852
+ _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")(
853
+ parse_scenario_key_singleton_to_list
854
+ )
855
+
856
+
857
+ class Constraint(BaseModel):
858
+ """Model for a constraint function."""
859
+
860
+ model_config = ConfigDict(frozen=True, from_attributes=True)
861
+
862
+ name: str = Field(
863
+ description=(
864
+ "Descriptive name of the constraint. This can be used in UI and visualizations. Example: 'maximum length'."
865
+ ),
866
+ )
867
+ """ Descriptive name of the constraint. This can be used in UI and
868
+ visualizations. Example: 'maximum length'"""
869
+ symbol: str = Field(
870
+ description=(
871
+ "Symbol to represent the constraint. This will be used in the rest of the problem definition."
872
+ " It may also be used in UIs and visualizations. Example: 'g_1'."
873
+ ),
874
+ )
875
+ """ Symbol to represent the constraint. This will be used in the rest of the
876
+ problem definition. It may also be used in UIs and visualizations. Example:
877
+ 'g_1'. """
878
+ cons_type: ConstraintTypeEnum = Field(
879
+ description=(
880
+ "The type of the constraint. Constraints are assumed to be in a standard form where the supplied 'func'"
881
+ " expression is on the left hand side of the constraint's expression, and on the right hand side a zero"
882
+ " value is assume. The comparison between the left hand side and right hand side is either and quality"
883
+ " comparison ('=') or lesser than equal comparison ('<=')."
884
+ )
885
+ )
886
+ """ The type of the constraint. Constraints are assumed to be in a standard
887
+ form where the supplied 'func' expression is on the left hand side of the
888
+ constraint's expression, and on the right hand side a zero value is assume.
889
+ The comparison between the left hand side and right hand side is either and
890
+ quality comparison ('=') or lesser than equal comparison ('<=')."""
891
+ func: list | None = Field(
892
+ description=(
893
+ "Function of the constraint. This is a JSON object that can be parsed into a function."
894
+ "Must be a valid MathJSON object."
895
+ " The symbols in the function must match objective/variable/constant symbols."
896
+ "Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. "
897
+ "If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."
898
+ ),
899
+ default=None,
900
+ )
901
+ """ Function of the constraint. This is a JSON object that can be parsed
902
+ into a function. Must be a valid MathJSON object. The symbols in the
903
+ function must match objective/variable/constant symbols.
904
+ Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'.
905
+ If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."""
906
+ simulator_path: Path | None = Field(
907
+ description=(
908
+ "Path to a python file with the connection to simulators. Must be a valid Path."
909
+ "Can be 'None' for if either 'func' or 'surrogates' is not 'None'."
910
+ "If 'None', either 'func' or 'surrogates' must not be 'None'."
911
+ ),
912
+ default=None,
913
+ )
914
+ """Path to a python file with the connection to simulators. Must be a valid Path.
915
+ Can be 'None' for if either 'func' or 'surrogates' is not 'None'.
916
+ If 'None', either 'func' or 'surrogates' must not be 'None'."""
917
+ surrogates: list[Path] | None = Field(
918
+ description=(
919
+ "A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path' "
920
+ "is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'."
921
+ ),
922
+ default=None,
923
+ )
924
+ """A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path'
925
+ is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'."""
926
+ is_linear: bool = Field(
927
+ description="Whether the constraint is linear or not. Defaults to True, e.g., a linear constraint is assumed.",
928
+ default=True,
929
+ )
930
+ """Whether the constraint is linear or not. Defaults to True, e.g., a linear
931
+ constraint is assumed. Defaults to `True`."""
932
+ is_convex: bool = Field(
933
+ description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
934
+ )
935
+ """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
936
+ is_twice_differentiable: bool = Field(
937
+ description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
938
+ )
939
+ """Whether the function expression is twice differentiable or not. Defaults to `False`"""
940
+ scenario_keys: list[str] | None = Field(
941
+ description="Optional. The keys of the scenarios the constraint belongs to.", default=None
942
+ )
943
+ """Optional. The keys of the scenarios the constraint belongs to."""
944
+
945
+ _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
946
+ _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")(
947
+ parse_scenario_key_singleton_to_list
948
+ )
949
+
950
+
951
+ class DiscreteRepresentation(BaseModel):
952
+ """Model to represent discrete objective function and decision variable pairs.
953
+
954
+ Can be used alongside an analytical representation as well.
955
+
956
+ Used with Objectives of type 'data_based' by default. Each of the decision
957
+ variable values and objective functions values are ordered in their
958
+ respective dict entries. This means that the decision variable values found
959
+ at `variable_values['x_i'][j]` correspond to the objective function values
960
+ found at `objective_values['f_i'][j]` for all `i` and some `j`.
961
+ """
962
+
963
+ model_config = ConfigDict(frozen=True, from_attributes=True)
964
+
965
+ variable_values: dict[str, list[VariableType]] = Field(
966
+ description=(
967
+ "A dictionary with decision variable values. Each dict key points to a list of all the decision "
968
+ "variable values available for the decision variable given in the key. "
969
+ "The keys must match the 'symbols' defined for the decision variables."
970
+ )
971
+ )
972
+ """ A dictionary with decision variable values. Each dict key points to a
973
+ list of all the decision variable values available for the decision variable
974
+ given in the key. The keys must match the 'symbols' defined for the
975
+ decision variables."""
976
+ objective_values: dict[str, list[float]] = Field(
977
+ description=(
978
+ "A dictionary with objective function values. Each dict key points to a list of all the objective "
979
+ "function values available for the objective function given in the key. The keys must match the 'symbols' "
980
+ "defined for the objective functions."
981
+ )
982
+ )
983
+ """ A dictionary with objective function values. Each dict key points to a
984
+ list of all the objective function values available for the objective
985
+ function given in the key. The keys must match the 'symbols' defined for the
986
+ objective functions."""
987
+ non_dominated: bool = Field(
988
+ description=(
989
+ "Indicates whether the representation consists of non-dominated points or not."
990
+ "If False, some method can employ non-dominated sorting, which might slow an interactive method down."
991
+ ),
992
+ default=False,
993
+ )
994
+ """ Indicates whether the representation consists of non-dominated points or
995
+ not. If False, some method can employ non-dominated sorting, which might
996
+ slow an interactive method down. Defaults to `False`."""
997
+
998
+
999
+ class Problem(BaseModel):
1000
+ """Model for a problem definition."""
1001
+
1002
+ model_config = ConfigDict(frozen=True)
1003
+
1004
+ _scalarization_index: int = PrivateAttr(default=1)
1005
+ # TODO: make init to communicate the _scalarization_index to a new model
1006
+
1007
+ @classmethod
1008
+ def from_problemdb(cls, db_instance: "ProblemDB") -> "Problem":
1009
+ """."""
1010
+ constants = [Constant.model_validate(const) for const in db_instance.constants] + [
1011
+ TensorConstant.model_validate(const) for const in db_instance.tensor_constants
1012
+ ]
1013
+
1014
+ return cls(
1015
+ name=db_instance.name,
1016
+ description=db_instance.description,
1017
+ is_convex=db_instance.is_convex,
1018
+ is_linear=db_instance.is_linear,
1019
+ is_twice_differentiable=db_instance.is_twice_differentiable,
1020
+ variable_domain=db_instance.variable_domain,
1021
+ scenario_keys=db_instance.scenario_keys,
1022
+ constants=constants if constants != [] else None,
1023
+ variables=[Variable.model_validate(var) for var in db_instance.variables]
1024
+ + [TensorVariable.model_validate(var) for var in db_instance.tensor_variables],
1025
+ objectives=[Objective.model_validate(obj) for obj in db_instance.objectives],
1026
+ constraints=[Constraint.model_validate(const) for const in db_instance.constraints]
1027
+ if db_instance.constraints != []
1028
+ else None,
1029
+ scalarization_funcs=[ScalarizationFunction.model_validate(scal) for scal in db_instance.scalarization_funcs]
1030
+ if db_instance.scalarization_funcs != []
1031
+ else None,
1032
+ extra_funcs=[ExtraFunction.model_validate(extra) for extra in db_instance.extra_funcs]
1033
+ if db_instance.extra_funcs != []
1034
+ else None,
1035
+ discrete_representation=DiscreteRepresentation.model_validate(db_instance.discrete_representation)
1036
+ if db_instance.discrete_representation is not None
1037
+ else None,
1038
+ simulators=[Simulator.model_validate(sim) for sim in db_instance.simulators]
1039
+ if db_instance.simulators != []
1040
+ else None,
1041
+ )
1042
+
1043
+ @model_validator(mode="after")
1044
+ def set_default_scalarization_names(self) -> "Problem":
1045
+ """Check the scalarization functions for symbols with value 'None'.
1046
+
1047
+ If found, names them systematically
1048
+ 'scal_i', where 'i' is a running index stored in an instance attribute.
1049
+ """
1050
+ if self.scalarization_funcs is None:
1051
+ return self
1052
+
1053
+ for func in self.scalarization_funcs:
1054
+ if func.symbol is None:
1055
+ func.symbol = f"scal_{self._scalarization_index}"
1056
+ self._scalarization_index += 1
1057
+
1058
+ return self
1059
+
1060
+ @model_validator(mode="after")
1061
+ def check_for_non_unique_symbols(self) -> "Problem":
1062
+ """Check that all the symbols defined in the different fields are unique."""
1063
+ symbols = self.get_all_symbols()
1064
+
1065
+ # symbol is always populated
1066
+ symbol_counts = Counter(symbols)
1067
+
1068
+ # collect duplicates, if they exist
1069
+ duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}
1070
+
1071
+ if duplicates:
1072
+ # if any duplicates are found, raise a value error and report the duplicate symbols.
1073
+ msg = "Non-unique symbols found in the Problem model."
1074
+ for symbol, count in duplicates.items():
1075
+ msg += f" Symbol '{symbol}' occurs {count} times."
1076
+
1077
+ raise ValueError(msg)
1078
+
1079
+ return self
1080
+
1081
+ def get_all_symbols(self) -> list[str]:
1082
+ """Collects and returns all the symbols symbols currently defined in the model."""
1083
+ # collect all symbols
1084
+ symbols = [variable.symbol for variable in self.variables]
1085
+ symbols += [objective.symbol for objective in self.objectives]
1086
+ if self.constants is not None:
1087
+ symbols += [constant.symbol for constant in self.constants]
1088
+ if self.constraints is not None:
1089
+ symbols += [constraint.symbol for constraint in self.constraints]
1090
+ if self.extra_funcs is not None:
1091
+ symbols += [extra.symbol for extra in self.extra_funcs]
1092
+ if self.scalarization_funcs is not None:
1093
+ symbols += [scalarization.symbol for scalarization in self.scalarization_funcs]
1094
+
1095
+ return symbols
1096
+
1097
+ def add_scalarization(self, new_scal: ScalarizationFunction) -> "Problem":
1098
+ """Adds a new scalarization function to the model.
1099
+
1100
+ If no symbol is defined, adds a name with the format 'scal_i'.
1101
+
1102
+ Does not modify the original problem model, but instead returns a copy of it with the added
1103
+ scalarization function.
1104
+
1105
+ Args:
1106
+ new_scal (ScalarizationFunction): Scalarization functions to be added to the model.
1107
+
1108
+ Raises:
1109
+ ValueError: Raised when a ScalarizationFunction is given with a symbol that already exists in the model.
1110
+
1111
+ Returns:
1112
+ Problem: a copy of the problem with the added scalarization function.
1113
+ """
1114
+ if new_scal.symbol is None:
1115
+ new_scal.symbol = f"scal_{self._scalarization_index}"
1116
+ self._scalarization_index += 1
1117
+
1118
+ if self.scalarization_funcs is None:
1119
+ return self.model_copy(update={"scalarization_funcs": [new_scal]})
1120
+ symbols = self.get_all_symbols()
1121
+ symbols.append(new_scal.symbol)
1122
+ symbol_counts = Counter(symbols)
1123
+ duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}
1124
+
1125
+ if duplicates:
1126
+ msg = "Non-unique symbols found in the Problem model."
1127
+ for symbol, count in duplicates.items():
1128
+ msg += f" Symbol '{symbol}' occurs {count} times."
1129
+
1130
+ raise ValueError(msg)
1131
+
1132
+ return self.model_copy(update={"scalarization_funcs": [*self.scalarization_funcs, new_scal]})
1133
+
1134
+ def update_ideal_and_nadir(
1135
+ self,
1136
+ new_ideal: dict[str, VariableType | None] | None = None,
1137
+ new_nadir: dict[str, VariableType | None] | None = None,
1138
+ ) -> "Problem":
1139
+ """Update the ideal and nadir values of the problem.
1140
+
1141
+ Args:
1142
+ new_ideal (dict[str, VariableType | None] | None): _description_
1143
+ new_nadir (dict[str, VariableType | None] | None): _description_
1144
+ """
1145
+ updated_objectives = []
1146
+ for objective in self.objectives:
1147
+ new_objective = objective.model_copy(
1148
+ update={
1149
+ **(
1150
+ {"ideal": new_ideal[objective.symbol]}
1151
+ if new_ideal is not None and objective.symbol in new_ideal
1152
+ else {}
1153
+ ),
1154
+ **(
1155
+ {"nadir": new_nadir[objective.symbol]}
1156
+ if new_nadir is not None and objective.symbol in new_nadir
1157
+ else {}
1158
+ ),
1159
+ }
1160
+ )
1161
+
1162
+ updated_objectives.append(new_objective)
1163
+
1164
+ return self.model_copy(update={"objectives": updated_objectives})
1165
+
1166
+ def add_constraints(self, new_constraints: list[Constraint]) -> "Problem":
1167
+ """Adds new constraints to the problem model.
1168
+
1169
+ Does not modify the original problem model, but instead returns a copy of it with
1170
+ the added constraints. The symbols of the new constraints to be added must be
1171
+ unique.
1172
+
1173
+ Args:
1174
+ new_constraints (list[Constraint]): the new `Constraint`s to be added to the model.
1175
+
1176
+ Raises:
1177
+ TypeError: when the `new_constraints` is not a list.
1178
+ ValueError: when duplicate symbols are found among the new_constraints, or
1179
+ any of the new constraints utilized an existing symbol in the problem's model.
1180
+
1181
+ Returns:
1182
+ Problem: a copy of the problem with the added constraints.
1183
+ """
1184
+ if not isinstance(new_constraints, list):
1185
+ # not a list
1186
+ msg = "The argument `new_constraints` must be a list."
1187
+ raise TypeError(msg)
1188
+
1189
+ all_symbols = self.get_all_symbols()
1190
+ new_symbols = [const.symbol for const in new_constraints]
1191
+
1192
+ if len(new_symbols) > len(set(new_symbols)):
1193
+ # duplicate symbols in the new constraint functions
1194
+ msg = "Duplicate symbols found in the new constraint functions to be added."
1195
+ raise ValueError(msg)
1196
+
1197
+ for s in new_symbols:
1198
+ if s in all_symbols:
1199
+ # symbol already exists
1200
+ msg = "A symbol was provided for a new constraint that already exists in the problem definition."
1201
+ raise ValueError(msg)
1202
+
1203
+ # proceed to add the new constraints
1204
+ return self.model_copy(
1205
+ update={
1206
+ "constraints": new_constraints if self.constraints is None else [*self.constraints, *new_constraints]
1207
+ }
1208
+ )
1209
+
1210
+ def add_variables(self, new_variables: list[Variable | TensorVariable]) -> "Problem":
1211
+ """Adds new variables to the problem model.
1212
+
1213
+ Does not modify the original problem model, but instead returns a copy of it with
1214
+ the added variables. The symbols of the new variables to be added must be
1215
+ unique.
1216
+
1217
+ Args:
1218
+ new_variables (list[Variable | TensorVariable]): the new variables to be added to the model.
1219
+
1220
+ Raises:
1221
+ TypeError: when the `new_variables` is not a list.
1222
+ ValueError: when duplicate symbols are found among the new_variables, or
1223
+ any of the new variables utilized an existing symbol in the problem's model.
1224
+
1225
+ Returns:
1226
+ Problem: a copy of the problem with the added variables.
1227
+ """
1228
+ if not isinstance(new_variables, list):
1229
+ # not a list
1230
+ msg = "The argument `new_variables` must be a list."
1231
+ raise TypeError(msg)
1232
+
1233
+ all_symbols = self.get_all_symbols()
1234
+ new_symbols = [const.symbol for const in new_variables]
1235
+
1236
+ if len(new_symbols) > len(set(new_symbols)):
1237
+ # duplicate symbols in the new variables
1238
+ msg = "Duplicate symbols found in the new variables to be added."
1239
+ raise ValueError(msg)
1240
+
1241
+ for s in new_symbols:
1242
+ if s in all_symbols:
1243
+ # symbol already exists
1244
+ msg = "A symbol was provided for a new variable that already exists in the problem definition."
1245
+ raise ValueError(msg)
1246
+
1247
+ # proceed to add the new variables, assumed existing variables are defined
1248
+ return self.model_copy(update={"variables": [*self.variables, *new_variables]})
1249
+
1250
+ def get_flattened_variables(self) -> list[Variable]:
1251
+ """Return a list of the (flattened) variables of the problem.
1252
+
1253
+ Returns a list of the variables defined for the problem so that any TensorVariables are flattened.
1254
+
1255
+ Returns:
1256
+ list[Variable]: list of (flattened) variables.
1257
+ """
1258
+ return [
1259
+ item
1260
+ for var in self.variables
1261
+ for item in (var.to_variables() if isinstance(var, TensorVariable) else [var])
1262
+ ]
1263
+
1264
+ def get_constraint(self, symbol: str) -> Constraint | None:
1265
+ """Return a copy of a `Constant` with the given symbol.
1266
+
1267
+ Args:
1268
+ symbol (str): the symbol of the constraint.
1269
+
1270
+ Returns:
1271
+ Constant | None: the copy of the constraint with the given symbol, or `None` if the constraint is not found.
1272
+ Also return `None` if no constraints have been defined for the problem.
1273
+ """
1274
+ if self.constraints is None:
1275
+ # no constraints defined
1276
+ return None
1277
+ for constraint in self.constraints:
1278
+ if constraint.symbol == symbol:
1279
+ return constraint.model_copy()
1280
+
1281
+ # did not find symbol
1282
+ return None
1283
+
1284
+ def get_variable(self, symbol: str) -> Variable | TensorVariable | None:
1285
+ """Return a copy of a `Variable` with the given symbol.
1286
+
1287
+ Args:
1288
+ symbol (str): the symbol of the variable.
1289
+
1290
+ Returns:
1291
+ Variable | TensorVariable | None: the copy of the variable with the given symbol,
1292
+ or `None` if the variable is not found.
1293
+ """
1294
+ for variable in self.variables:
1295
+ if variable.symbol == symbol:
1296
+ # variable found
1297
+ return variable.model_copy()
1298
+
1299
+ # variable not found
1300
+ return None
1301
+
1302
+ def get_objective(self, symbol: str, *, copy: bool = True) -> Objective | None:
1303
+ """Return a copy of an `Objective` with the given symbol.
1304
+
1305
+ Args:
1306
+ symbol (str): the symbol of the objective.
1307
+ copy (bool): if True, return a copy of the objective, otherwise, return a reference. Defaults to True.
1308
+
1309
+ Returns:
1310
+ Objective | None: the copy of the objective with the given symbol, or `None` if the objective is not found.
1311
+ """
1312
+ for objective in self.objectives:
1313
+ if objective.symbol == symbol:
1314
+ # objective found
1315
+ if copy:
1316
+ # return a copy of the objective
1317
+ return objective.model_copy()
1318
+
1319
+ # return a reference instead
1320
+ return objective
1321
+
1322
+ # objective not found
1323
+ return None
1324
+
1325
+ def get_scalarization(self, symbol: str) -> ScalarizationFunction | None:
1326
+ """Return a copy of a `ScalarizationFunction` with the given symbol.
1327
+
1328
+ Args:
1329
+ symbol (str): the symbol of the scalarization function.
1330
+
1331
+ Returns:
1332
+ ScalarizationFunction | None: the copy of the scalarization function with the given symbol, or `None` if the
1333
+ scalarization function is not found. Returns `None` also when no scalarization functions have been
1334
+ defined for the problem.
1335
+ """
1336
+ if self.scalarization_funcs is None:
1337
+ # no scalarization functions defined
1338
+ return None
1339
+
1340
+ for scal in self.scalarization_funcs:
1341
+ if scal.symbol == symbol:
1342
+ # scalarization function found
1343
+ return scal.model_copy()
1344
+
1345
+ # scalarization function is not found
1346
+ return None
1347
+
1348
+ def get_ideal_point(self) -> dict[str, float | None]:
1349
+ """Get the ideal point of the problem as an objective dict.
1350
+
1351
+ Returns an objective dict containing the ideal values of the
1352
+ the problem for each objective function. These values may be `None`.
1353
+
1354
+ Returns:
1355
+ dict[str, float | None] | None: an objective dict with the ideal
1356
+ point values (which may be `None`), or `None`.
1357
+ """
1358
+ return {f"{obj.symbol}": obj.ideal for obj in self.objectives}
1359
+
1360
+ def get_nadir_point(self) -> dict[str, float | None]:
1361
+ """Get the nadir point of the problem as an objective dict.
1362
+
1363
+ Returns an objective dict containing the nadir values of the
1364
+ the problem for each objective function. These values may be `None`.
1365
+
1366
+ Returns:
1367
+ dict[str, float | None] | None: an objective dict with the nadir
1368
+ point values (which may be `None`), or `None`.
1369
+ """
1370
+ return {f"{obj.symbol}": obj.nadir for obj in self.objectives}
1371
+
1372
+ @property
1373
+ def variable_domain(self) -> VariableDomainTypeEnum:
1374
+ """Check the variables defined for the problem and returns the type of their domain.
1375
+
1376
+ Checks the variable types defined for the problem and tells if the
1377
+ problem is continuous, integer, binary, or mixed-integer.
1378
+
1379
+ Returns:
1380
+ VariableDomainEnum: whether the problem is continuous, integer, binary, or mixed-integer.
1381
+ """
1382
+ variable_types = [var.variable_type for var in self.variables]
1383
+
1384
+ if all(t == VariableTypeEnum.real for t in variable_types):
1385
+ # all variables are real valued -> continuous problem
1386
+ return VariableDomainTypeEnum.continuous
1387
+
1388
+ if all(t == VariableTypeEnum.binary for t in variable_types):
1389
+ # all variables are binary valued -> binary problem
1390
+ return VariableDomainTypeEnum.binary
1391
+
1392
+ if all(t in [VariableTypeEnum.integer, VariableTypeEnum.binary] for t in variable_types):
1393
+ # all variables are integer or binary -> integer problem
1394
+ return VariableDomainTypeEnum.integer
1395
+
1396
+ # mixed problem
1397
+ return VariableDomainTypeEnum.mixed
1398
+
1399
+ @property
1400
+ def is_convex(self) -> bool:
1401
+ """Check if all the functions expressions in the problem are convex.
1402
+
1403
+ Note:
1404
+ If the field "is_convex" is explicitly set, then the provided value is returned.
1405
+
1406
+ Otherwise, this method just checks all the functions expressions present in the problem
1407
+ and return true if all of them are convex. For complicated problems, this might
1408
+ result in an incorrect results. User discretion is advised.
1409
+
1410
+ Returns:
1411
+ bool: whether the problem is convex or not.
1412
+ """
1413
+ if self.is_convex_ is not None:
1414
+ return self.is_convex_
1415
+
1416
+ is_convex_values = (
1417
+ [obj.is_convex for obj in self.objectives]
1418
+ + ([con.is_convex for con in self.constraints] if self.constraints is not None else [])
1419
+ + ([extra.is_convex for extra in self.extra_funcs] if self.extra_funcs is not None else [])
1420
+ + ([scal.is_convex for scal in self.scalarization_funcs] if self.scalarization_funcs is not None else [])
1421
+ )
1422
+
1423
+ return all(is_convex_values)
1424
+
1425
+ @property
1426
+ def is_linear(self) -> bool:
1427
+ """Check if all the functions expressions in the problem are linear.
1428
+
1429
+ Note:
1430
+ If the field "is_linear" is explicitly set, then the provided value is returned.
1431
+
1432
+ Otherwise, this method just checks all the functions expressions present in the problem
1433
+ and return true if all of them are linear. For complicated problems, this might
1434
+ result in an incorrect results. User discretion is advised.
1435
+
1436
+ Returns:
1437
+ bool: whether the problem is linear or not.
1438
+ """
1439
+ if self.is_linear_ is not None:
1440
+ return self.is_linear_
1441
+
1442
+ is_linear_values = (
1443
+ [obj.is_linear for obj in self.objectives]
1444
+ + ([con.is_linear for con in self.constraints] if self.constraints is not None else [])
1445
+ + ([extra.is_linear for extra in self.extra_funcs] if self.extra_funcs is not None else [])
1446
+ + ([scal.is_linear for scal in self.scalarization_funcs] if self.scalarization_funcs is not None else [])
1447
+ )
1448
+
1449
+ return all(is_linear_values)
1450
+
1451
+ @property
1452
+ def is_twice_differentiable(self) -> bool:
1453
+ """Check if all the functions expressions in the problem are twice differentiable.
1454
+
1455
+ Note:
1456
+ If the field "is_twice_differentiable" is explicitly set, then the provided value is returned.
1457
+
1458
+ Otherwise, this method just checks all the functions expressions present in the problem
1459
+ and return true if all of them are twice differentiable. For complicated problems, this might
1460
+ result in an incorrect results. User discretion is advised.
1461
+
1462
+ Returns:
1463
+ bool: whether the problem is twice differentiable or not.
1464
+ """
1465
+ if self.is_twice_differentiable_ is not None:
1466
+ return self.is_twice_differentiable_
1467
+
1468
+ is_diff_values = (
1469
+ [obj.is_twice_differentiable for obj in self.objectives]
1470
+ + ([con.is_twice_differentiable for con in self.constraints] if self.constraints is not None else [])
1471
+ + ([extra.is_twice_differentiable for extra in self.extra_funcs] if self.extra_funcs is not None else [])
1472
+ + (
1473
+ [scal.is_twice_differentiable for scal in self.scalarization_funcs]
1474
+ if self.scalarization_funcs is not None
1475
+ else []
1476
+ )
1477
+ )
1478
+
1479
+ return all(is_diff_values)
1480
+
1481
+ def get_scenario_problem(self, target_keys: str | list[str]) -> "Problem":
1482
+ """Returns a new Problem with fields belonging to a specified scenario.
1483
+
1484
+ The new problem will have the fields `objectives`, `constraints`, `extra_funcs`,
1485
+ and `scalarization_funcs` with only the entries that belong to the specified
1486
+ scenario. The other entries will remain unchanged.
1487
+
1488
+ Note:
1489
+ Fields with their `scenario_key` being `None` are assumed to belong to all scenarios,
1490
+ and are thus always included in each scenario.
1491
+
1492
+ Args:
1493
+ target_keys (str | list[str]): the key or keys of the scenario(s) we wish to get.
1494
+
1495
+ Raises:
1496
+ ValueError: (some of) the given `target_keys` has not been defined to be a scenario
1497
+ in the problem.
1498
+
1499
+ Returns:
1500
+ Problem: a new problem with only the field that belong to the specified scenario.
1501
+ """
1502
+ if isinstance(target_keys, str):
1503
+ # if just a single key is given, make a list out of it.abs
1504
+ target_keys = [target_keys]
1505
+
1506
+ # the any matches any keys
1507
+ if self.scenario_keys is None or not any(element in target_keys for element in self.scenario_keys):
1508
+ # invalid scenario
1509
+ msg = (
1510
+ f"The scenario '{target_keys}' has not been defined to be a valid scenario, or the problem has no "
1511
+ "scenarios defined."
1512
+ )
1513
+ raise ValueError(msg)
1514
+
1515
+ # add the fields if the field has the given target_keys in its scenario_keys, or if the
1516
+ # target_keys is None
1517
+ scenario_objectives = [
1518
+ obj
1519
+ for obj in self.objectives
1520
+ if obj.scenario_keys is None or any(element in target_keys for element in obj.scenario_keys)
1521
+ ]
1522
+ scenario_constraints = (
1523
+ [
1524
+ cons
1525
+ for cons in self.constraints
1526
+ if cons.scenario_keys is None or any(element in target_keys for element in cons.scenario_keys)
1527
+ ]
1528
+ if self.constraints is not None
1529
+ else None
1530
+ )
1531
+ scenario_extras = (
1532
+ [
1533
+ extra
1534
+ for extra in self.extra_funcs
1535
+ if extra.scenario_keys is None or any(element in target_keys for element in extra.scenario_keys)
1536
+ ]
1537
+ if self.extra_funcs is not None
1538
+ else None
1539
+ )
1540
+ scenario_scals = (
1541
+ [
1542
+ scal
1543
+ for scal in self.scalarization_funcs
1544
+ if scal.scenario_keys is None or any(element in target_keys for element in scal.scenario_keys)
1545
+ ]
1546
+ if self.scalarization_funcs is not None
1547
+ else None
1548
+ )
1549
+
1550
+ return self.model_copy(
1551
+ update={
1552
+ "objectives": scenario_objectives,
1553
+ "constraints": scenario_constraints,
1554
+ "extra_funcs": scenario_extras,
1555
+ "scalarization_funcs": scenario_scals,
1556
+ }
1557
+ )
1558
+
1559
+ def save_to_json(self, path: Path) -> None:
1560
+ """Save the Problem model in JSON format to a file.
1561
+
1562
+ Args:
1563
+ path (Path): path to the file the model should be saved to.
1564
+
1565
+ """
1566
+ json_content = self.model_dump_json(indent=4)
1567
+ path.write_text(json_content)
1568
+
1569
+ @classmethod
1570
+ def load_json(cls, path: Path) -> "Problem":
1571
+ """Load a Problem model stored in a JSON file.
1572
+
1573
+ Args:
1574
+ path (Path): path to file storing a Problem model in JSON format.
1575
+
1576
+ Returns:
1577
+ Problem: the as defined in the data.
1578
+ """
1579
+ json_data = path.read_text()
1580
+
1581
+ return cls.model_validate_json(json_data)
1582
+
1583
+ @model_validator(mode="after")
1584
+ def set_is_twice_differentiable(cls, values):
1585
+ """If "is_twice_differentiable" is explicitly provided to the model, we set it to that value."""
1586
+ if "is_twice_differentiable" in values and values["is_twice_differentiable"] is not None:
1587
+ values["is_twice_differentiable_"] = values["is_twice_differentiable"]
1588
+
1589
+ return values
1590
+
1591
+ @model_validator(mode="after")
1592
+ @classmethod
1593
+ def set_is_linear(cls, values):
1594
+ """If "is_linear" is explicitly provided to the model, we set it to that value."""
1595
+ if "is_linear" in values and values["is_linear"] is not None:
1596
+ values["is_linear_"] = values["is_linear"]
1597
+
1598
+ return values
1599
+
1600
+ @model_validator(mode="after")
1601
+ @classmethod
1602
+ def set_is_convex(cls, values):
1603
+ """If "is_convex" is explicitly provided to the model, we set it to that value."""
1604
+ if "is_convex" in values and values["is_convex"] is not None:
1605
+ values["is_convex_"] = values["is_convex"]
1606
+
1607
+ return values
1608
+
1609
+ name: str = Field(
1610
+ description="Name of the problem.",
1611
+ )
1612
+ """Name of the problem."""
1613
+ description: str = Field(description="Description of the problem.")
1614
+ """Description of the problem."""
1615
+ constants: list[Constant | TensorConstant] | None = Field(
1616
+ description="Optional list of the constants present in the problem.", default=None
1617
+ )
1618
+ """List of the constants present in the problem. Defaults to `None`."""
1619
+ variables: list[Variable | TensorVariable] = Field(description="List of variables present in the problem.")
1620
+ """List of variables present in the problem."""
1621
+ objectives: list[Objective] = Field(description="List of the objectives present in the problem.")
1622
+ """List of the objectives present in the problem."""
1623
+ constraints: list[Constraint] | None = Field(
1624
+ description="Optional list of constraints present in the problem.",
1625
+ default=None,
1626
+ )
1627
+ """Optional list of constraints present in the problem. Defaults to `None`."""
1628
+ extra_funcs: list[ExtraFunction] | None = Field(
1629
+ description="Optional list of extra functions. Use this if some function is repeated multiple times.",
1630
+ default=None,
1631
+ )
1632
+ """Optional list of extra functions. Use this if some function is repeated multiple times. Defaults to `None`."""
1633
+ scalarization_funcs: list[ScalarizationFunction] | None = Field(
1634
+ description="Optional list of scalarization functions of the problem.", default=None
1635
+ )
1636
+ """Optional list of scalarization functions of the problem. Defaults to `None`."""
1637
+ discrete_representation: DiscreteRepresentation | None = Field(
1638
+ description=(
1639
+ "Optional. Required when there are one or more 'data_based' Objectives. The corresponding values "
1640
+ "of the 'data_based' objective function will be fetched from this with the given variable values. "
1641
+ "Is also utilized for methods which require both an analytical and discrete representation of a problem."
1642
+ ),
1643
+ default=None,
1644
+ )
1645
+ """Optional. Required when there are one or more 'data_based' Objectives.
1646
+ The corresponding values of the 'data_based' objective function will be
1647
+ fetched from this with the given variable values. Is also utilized for
1648
+ methods which require both an analytical and discrete representation of a
1649
+ problem. Defaults to `None`."""
1650
+ scenario_keys: list[str] | None = Field(
1651
+ description=(
1652
+ "Optional. The scenario keys defined for the problem. Each key will point to a subset of objectives, "
1653
+ "constraints, extra functions, and scalarization functions that have the same scenario key defined to them."
1654
+ "If None, then the problem is assumed to not contain scenarios."
1655
+ ),
1656
+ default=None,
1657
+ )
1658
+ """Optional. The scenario keys defined for the problem. Each key will point
1659
+ to a subset of objectives, " "constraints, extra functions, and
1660
+ scalarization functions that have the same scenario key defined to them."
1661
+ "If None, then the problem is assumed to not contain scenarios."""
1662
+ simulators: list[Simulator] | None = Field(
1663
+ description=(
1664
+ "Optional. The simulators used by the problem. Required when there are one or more "
1665
+ "Objectives defined by simulators. The corresponding values of the 'simulator' objective "
1666
+ "function will be fetched from these simulators with the given variable values."
1667
+ ),
1668
+ default=None,
1669
+ )
1670
+ """Optional. The simulators used by the problem. Required when there are one or more
1671
+ Objectives defined by simulators. The corresponding values of the 'simulator' objective
1672
+ function will be fetched from these simulators with the given variable values.
1673
+ Defaults to `None`."""
1674
+ is_convex_: bool | None = Field(
1675
+ description=(
1676
+ "Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. "
1677
+ "If set to `None`, this property will be automatically inferred from the "
1678
+ "respective properties of other attributes."
1679
+ ),
1680
+ default=None,
1681
+ alias="is_convex",
1682
+ )
1683
+ """Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. "
1684
+ "If set to `None`, this property will be automatically inferred from the "
1685
+ "respective properties of other attributes."""
1686
+ is_linear_: bool | None = Field(
1687
+ description=(
1688
+ "Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. "
1689
+ "If set to `None`, this property will be automatically inferred from the "
1690
+ "respective properties of other attributes."
1691
+ ),
1692
+ default=None,
1693
+ alias="is_linear",
1694
+ )
1695
+ """Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. "
1696
+ "If set to `None`, this property will be automatically inferred from the "
1697
+ "respective properties of other attributes."""
1698
+ is_twice_differentiable_: bool | None = Field(
1699
+ description=(
1700
+ "Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice "
1701
+ "differentiable. If set to `None`, this property will be automatically inferred from the "
1702
+ "respective properties of other attributes."
1703
+ ),
1704
+ default=None,
1705
+ alias="is_twice_differentiable",
1706
+ )
1707
+ """Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice "
1708
+ "differentiable. If set to `None`, this property will be automatically inferred from the "
1709
+ "respective properties of other attributes."""
1710
+
1711
+
1712
+ if __name__ == "__main__":
1713
+ import erdantic as erd
1714
+
1715
+ diagram = erd.create(Problem)
1716
+ diagram.draw("problem_map.png")
1717
+
1718
+ constant_model = Constant(name="constant example", symbol="c", value=42.1)
1719
+ # print(Constant.schema_json(indent=2))
1720
+ # print(constant_model.model_dump_json(indent=2))
1721
+
1722
+ variable_model_1 = Variable(
1723
+ name="example variable",
1724
+ symbol="x_1",
1725
+ variable_type=VariableTypeEnum.real,
1726
+ lowerbound=-0.75,
1727
+ upperbound=11.3,
1728
+ initial_value=4.2,
1729
+ )
1730
+ variable_model_2 = Variable(
1731
+ name="example variable",
1732
+ symbol="x_2",
1733
+ variable_type=VariableTypeEnum.real,
1734
+ lowerbound=-0.75,
1735
+ upperbound=11.3,
1736
+ initial_value=4.2,
1737
+ )
1738
+ variable_model_3 = Variable(
1739
+ name="example variable",
1740
+ symbol="x_3",
1741
+ variable_type=VariableTypeEnum.real,
1742
+ lowerbound=-0.75,
1743
+ upperbound=11.3,
1744
+ initial_value=4.2,
1745
+ )
1746
+ # print(Variable.schema_json(indent=2))
1747
+ # print(variable_model.model_dump_json(indent=2))
1748
+
1749
+ objective_model_1 = Objective(
1750
+ name="example objective",
1751
+ symbol="f_1",
1752
+ func=["Divide", ["Add", "x_1", 3], 2],
1753
+ maximize=False,
1754
+ ideal=-3.3,
1755
+ nadir=5.2,
1756
+ )
1757
+ objective_model_2 = Objective(
1758
+ name="example objective",
1759
+ symbol="f_2",
1760
+ func=["Divide", ["Add", "x_1", 3], 2],
1761
+ maximize=False,
1762
+ ideal=-3.3,
1763
+ nadir=5.2,
1764
+ )
1765
+ objective_model_3 = Objective(
1766
+ name="example objective",
1767
+ symbol="f_3",
1768
+ func=["Divide", ["Add", "x_1", 3], 2],
1769
+ maximize=False,
1770
+ ideal=-3.3,
1771
+ nadir=5.2,
1772
+ )
1773
+ # print(Objective.schema_json(indent=2))
1774
+ # print(objective_model.model_dump_json(indent=2))
1775
+
1776
+ constraint_model = Constraint(
1777
+ name="example constraint",
1778
+ symbol="g_1",
1779
+ func=["Add", ["Add", ["Divide", "x_1", 2], "c"], -4.2],
1780
+ cons_type=ConstraintTypeEnum.LTE,
1781
+ )
1782
+ # print(Constraint.schema_json(indent=2))
1783
+ # print(constraint_model.model_dump_json(indent=2))
1784
+
1785
+ extra_func_model = ExtraFunction(name="example extra function", symbol="m", func=["Divide", "f_1", 100])
1786
+ # print(ExtraFunction.schema_json(indent=2))
1787
+ # print(extra_func_model.model_dump_json(indent=2))
1788
+
1789
+ scalarization_function_model = ScalarizationFunction(
1790
+ name="Achievement scalarizing function",
1791
+ symbol="S",
1792
+ func=["Max", ["Multiply", "w_1", ["Add", "f_1", -1.1]], ["Multiply", "w_2", ["Add", "f_2", -2.2]]],
1793
+ )
1794
+ # print(ScalarizationFunction.schema_json(indent=2))
1795
+ # print(scalarization_function_model.model_dump_json(indent=2))
1796
+
1797
+ problem_model = Problem(
1798
+ name="Example problem",
1799
+ description="This is an example of a the JSON object of the 'Problem' model.",
1800
+ constants=[constant_model],
1801
+ variables=[variable_model_1, variable_model_2, variable_model_3],
1802
+ objectives=[objective_model_1, objective_model_2, objective_model_3],
1803
+ constraints=[constraint_model],
1804
+ extra_funcs=[extra_func_model],
1805
+ scalarization_funcs=[scalarization_function_model],
1806
+ )
1807
+
1808
+ # print(problem_model.model_dump_json(indent=2))