desdeo 1.2__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.2.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
  121. desdeo-1.2.dist-info/METADATA +0 -16
  122. desdeo-1.2.dist-info/RECORD +0 -4
@@ -0,0 +1,944 @@
1
+ """Defines a parser to parse multiobjective optimziation problems defined in a JSON format."""
2
+
3
+ from collections.abc import Callable
4
+ from enum import Enum
5
+ from functools import reduce
6
+
7
+ import gurobipy as gp
8
+ import numpy as np
9
+ import polars as pl
10
+ import pyomo.environ as pyomo
11
+ import sympy as sp
12
+ from pyomo.core.expr.numeric_expr import MaxExpression as _PyomoMax
13
+ from pyomo.core.expr.numeric_expr import MinExpression as _PyomoMin
14
+
15
+ # Mathematical objects in gurobipy can take many types
16
+ gpexpression = gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr
17
+
18
+
19
+ class FormatEnum(str, Enum):
20
+ """Enumerates the supported formats the JSON format may be parsed to."""
21
+
22
+ polars = "polars"
23
+ pyomo = "pyomo"
24
+ sympy = "sympy"
25
+ gurobipy = "gurobipy"
26
+
27
+
28
+ class ParserError(Exception):
29
+ """Raised when an error related to the MathParser class in encountered."""
30
+
31
+
32
+ class MathParser:
33
+ """A class to instantiate MathJSON parsers.
34
+
35
+ Currently only parses MathJSON to polars expressions. Pyomo WIP.
36
+ """
37
+
38
+ def __init__(self, to_format: FormatEnum = "polars"):
39
+ """Create a parser instance for parsing MathJSON notation into polars expressions.
40
+
41
+ Args:
42
+ to_format (FormatEnum, optional): to which format a JSON representation should be parsed to.
43
+ Defaults to "polars".
44
+ """
45
+ # Define operator names. Change these when the name is altered in the JSON format.
46
+ # Basic arithmetic operators
47
+ self.NEGATE: str = "Negate"
48
+ self.ADD: str = "Add"
49
+ self.SUB: str = "Subtract"
50
+ self.MUL: str = "Multiply"
51
+ self.DIV: str = "Divide"
52
+
53
+ # Vector and matrix operations
54
+ self.MATMUL: str = "MatMul"
55
+ self.SUM: str = "Sum"
56
+ self.RANDOM_ACCESS = "At"
57
+
58
+ # Exponentation and logarithms
59
+ self.EXP: str = "Exp"
60
+ self.LN: str = "Ln"
61
+ self.LB: str = "Lb"
62
+ self.LG: str = "Lg"
63
+ self.LOP: str = "LogOnePlus"
64
+ self.SQRT: str = "Sqrt"
65
+ self.SQUARE: str = "Square"
66
+ self.POW: str = "Power"
67
+
68
+ # Rounding operators
69
+ self.ABS: str = "Abs"
70
+ self.CEIL: str = "Ceil"
71
+ self.FLOOR: str = "Floor"
72
+
73
+ # Trigonometric operations
74
+ self.ARCCOS: str = "Arccos"
75
+ self.ARCCOSH: str = "Arccosh"
76
+ self.ARCSIN: str = "Arcsin"
77
+ self.ARCSINH: str = "Arcsinh"
78
+ self.ARCTAN: str = "Arctan"
79
+ self.ARCTANH: str = "Arctanh"
80
+ self.COS: str = "Cos"
81
+ self.COSH: str = "Cosh"
82
+ self.SIN: str = "Sin"
83
+ self.SINH: str = "Sinh"
84
+ self.TAN: str = "Tan"
85
+ self.TANH: str = "Tanh"
86
+
87
+ # Comparison operators
88
+ self.EQUAL: str = "Equal"
89
+ self.GREATER: str = "Greater"
90
+ self.GE: str = "GreaterEqual"
91
+ self.LESS: str = "Less"
92
+ self.LE: str = "LessEqual"
93
+ self.NE: str = "NotEqual"
94
+
95
+ # Other operators
96
+ self.MAX: str = "Max"
97
+ self.MIN: str = "Min"
98
+ self.RATIONAL: str = "Rational"
99
+
100
+ self.literals = int | float
101
+
102
+ def to_expr(x: self.literals | pl.Expr):
103
+ """Helper function to convert literals to polars expressions."""
104
+ return pl.lit(x) if isinstance(x, self.literals) else x
105
+
106
+ def to_sympy_expr(x):
107
+ return sp.sympify(x, evaluate=False) if isinstance(x, self.literals) else x
108
+
109
+ def gp_error():
110
+ msg = "The gurobipy model format only supports linear and quadratic expressions."
111
+ ParserError(msg)
112
+
113
+ def _polars_reduce(ufunc, exprs):
114
+ def _reduce_function(acc, x, ufunc=ufunc):
115
+ acc_numpy = acc.to_numpy()
116
+ x_numpy = x.to_numpy()
117
+
118
+ if acc_numpy.shape == x_numpy.shape:
119
+ return pl.Series(values=ufunc(acc_numpy, x_numpy))
120
+
121
+ expanded_shape = acc_numpy.shape + (1,) * (x_numpy.ndim - acc_numpy.ndim)
122
+
123
+ return pl.Series(values=ufunc(acc_numpy.reshape(expanded_shape), x_numpy))
124
+
125
+ return pl.reduce(function=_reduce_function, exprs=exprs)
126
+
127
+ def _polars_reduce_unary(expr, ufunc):
128
+ def _reduce_function(acc, _, ufunc=ufunc):
129
+ return pl.Series(values=ufunc(acc.to_numpy()))
130
+
131
+ return pl.reduce(function=_reduce_function, exprs=[expr, None])
132
+
133
+ def _polars_reduce_matmul(*exprs):
134
+ def _reduce_function(acc, x):
135
+ acc = acc.to_numpy()
136
+ x = x.to_numpy()
137
+
138
+ if len(acc.shape) == 2 and len(x.shape) == 2:
139
+ # Row vectors, just return the dot product, polars does not handle
140
+ # "column" vectors anyway
141
+ return pl.Series(values=np.einsum("ij,ij->i", acc, x, optimize=True))
142
+
143
+ # actual matrix product required
144
+ return pl.Series(values=np.matmul(acc, x))
145
+
146
+ return pl.reduce(function=_reduce_function, exprs=exprs)
147
+
148
+ def _polars_summation(expr):
149
+ """Polars matrix summation."""
150
+
151
+ def _reduce_function(acc, _):
152
+ acc_numpy = acc.to_numpy()
153
+ return pl.Series(values=np.sum(acc_numpy, axis=tuple(range(1, acc_numpy.ndim))))
154
+
155
+ return pl.reduce(function=_reduce_function, exprs=[expr, None])
156
+
157
+ def _polars_random_access(expr, *indices):
158
+ """Polars tensor random access."""
159
+ for index in indices:
160
+ expr = expr.arr.get(index - 1) # 1 indexing assumed in JSON format
161
+
162
+ return expr
163
+
164
+ def _polars_generic_apply(a, b):
165
+ pass
166
+
167
+ polars_env = {
168
+ # Define the operations for the different operators.
169
+ # Basic arithmetic operations
170
+ self.NEGATE: lambda x: _polars_reduce_unary(x, np.negative),
171
+ self.ADD: lambda *args: _polars_reduce(np.add, args),
172
+ self.SUB: lambda *args: _polars_reduce(np.subtract, args),
173
+ self.MUL: lambda *args: _polars_reduce(np.multiply, args),
174
+ self.DIV: lambda *args: _polars_reduce(np.divide, args),
175
+ # Vector and matrix operations
176
+ self.MATMUL: _polars_reduce_matmul,
177
+ self.SUM: lambda x: _polars_summation(x),
178
+ self.RANDOM_ACCESS: _polars_random_access,
179
+ # Exponentiation and logarithms
180
+ self.EXP: lambda x: _polars_reduce_unary(x, np.exp),
181
+ self.LN: lambda x: _polars_reduce_unary(x, np.log),
182
+ self.LB: lambda x: _polars_reduce_unary(x, np.log2),
183
+ self.LG: lambda x: _polars_reduce_unary(x, np.log10),
184
+ self.LOP: lambda x: _polars_reduce_unary(x, np.log1p),
185
+ self.SQRT: lambda x: _polars_reduce_unary(x, np.sqrt),
186
+ self.SQUARE: lambda x: _polars_reduce_unary(x, lambda y: np.power(y, 2)),
187
+ self.POW: lambda *args: _polars_reduce(np.power, args),
188
+ # Trigonometric operations
189
+ self.ARCCOS: lambda x: _polars_reduce_unary(x, np.arccos),
190
+ self.ARCCOSH: lambda x: _polars_reduce_unary(x, np.arccosh),
191
+ self.ARCSIN: lambda x: _polars_reduce_unary(x, np.arcsin),
192
+ self.ARCSINH: lambda x: _polars_reduce_unary(x, np.arcsinh),
193
+ self.ARCTAN: lambda x: _polars_reduce_unary(x, np.arctan),
194
+ self.ARCTANH: lambda x: _polars_reduce_unary(x, np.arctanh),
195
+ self.COS: lambda x: _polars_reduce_unary(x, np.cos),
196
+ self.COSH: lambda x: _polars_reduce_unary(x, np.cosh),
197
+ self.SIN: lambda x: _polars_reduce_unary(x, np.sin),
198
+ self.SINH: lambda x: _polars_reduce_unary(x, np.sinh),
199
+ self.TAN: lambda x: _polars_reduce_unary(x, np.tan),
200
+ self.TANH: lambda x: _polars_reduce_unary(x, np.tanh),
201
+ # Rounding operations
202
+ self.ABS: lambda x: _polars_reduce_unary(x, np.abs),
203
+ self.CEIL: lambda x: _polars_reduce_unary(x, np.ceil),
204
+ self.FLOOR: lambda x: _polars_reduce_unary(x, np.floor),
205
+ # Other operations
206
+ self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst), # Not supported
207
+ self.MAX: lambda *args: reduce(lambda x, y: pl.max_horizontal(to_expr(x), to_expr(y)), args),
208
+ self.MIN: lambda *args: reduce(lambda x, y: pl.min_horizontal(to_expr(x), to_expr(y)), args),
209
+ }
210
+
211
+ def _pyomo_negate(x):
212
+ """Negates the given operand."""
213
+
214
+ def _expr_negate_rule(x):
215
+ def _inner(_, *indices):
216
+ return -x[indices]
217
+
218
+ return _inner
219
+
220
+ def _negate(x):
221
+ # check if operand in indexed
222
+ if hasattr(x, "index_set") and x.is_indexed():
223
+ # indexed, return new pyomo expression
224
+ expr = pyomo.Expression(x.index_set(), rule=_expr_negate_rule(x))
225
+ expr.construct()
226
+
227
+ return expr
228
+
229
+ # not indexed, just regular negate
230
+ return -x
231
+
232
+ return _negate(x)
233
+
234
+ def _pyomo_pow(base, exp):
235
+ """Implements a power operator compatible with Pyomo expressions."""
236
+
237
+ def _expr_pow_rule(base, exp):
238
+ def _inner(_, *indices):
239
+ return base[indices] ** exp
240
+
241
+ return _inner
242
+
243
+ def _pow(base, exp=exp):
244
+ # check if operand in indexed
245
+ if hasattr(base, "index_set") and base.is_indexed():
246
+ # indexed, return new pyomo expression
247
+ expr = pyomo.Expression(base.index_set(), rule=_expr_pow_rule(base, exp))
248
+ expr.construct()
249
+
250
+ return expr
251
+
252
+ # not indexed, just regular power
253
+ return base**exp
254
+
255
+ return _pow(base, exp)
256
+
257
+ def _pyomo_unary(x, op):
258
+ """Implements unary operators to work with indexed expressions."""
259
+
260
+ def _expr_rule(x, op):
261
+ def _inner(_, *indices, op=op):
262
+ return op(x[indices])
263
+
264
+ return _inner
265
+
266
+ def _op(x, op):
267
+ # check if operand in indexed
268
+ if hasattr(x, "index_set") and x.is_indexed():
269
+ # indexed, return new pyomo expression
270
+ expr = pyomo.Expression(x.index_set(), rule=_expr_rule(x, op))
271
+ expr.construct()
272
+
273
+ return expr
274
+
275
+ # not indexed, just regular power
276
+ return op(x)
277
+
278
+ return _op(x, op)
279
+
280
+ def _pyomo_addition(*args, subtraction=False):
281
+ """Add (subtract) scalars or tensors to (from) each other."""
282
+
283
+ def _expr_matrix_addition_rule(x, y, subtraction=subtraction):
284
+ def _inner(_, *args):
285
+ return x[*args] + y[*args] if not subtraction else x[*args] - y[*args]
286
+
287
+ return _inner
288
+
289
+ def _expr_elementwise_with_single_indexed(indexed, not_indexed, subtraction=subtraction):
290
+ def _inner(_, *indices):
291
+ return (indexed[indices] + not_indexed) if not subtraction else (indexed[indices] - not_indexed)
292
+
293
+ return _inner
294
+
295
+ def _add(x, y, subtraction=subtraction):
296
+ # if both are indexed, try matrix addition
297
+ if (hasattr(x, "index_set") and x.is_indexed()) and (hasattr(y, "index_set") and y.is_indexed()):
298
+ # try matrix addition
299
+ # check that the dimensions of x and y matches
300
+ if x.index_set().set_tuple != y.index_set().set_tuple:
301
+ msg = (
302
+ f"The dimensions of x {x.index_set().set_tuple} must match that"
303
+ f" of y {y.index_set().set_tuple} for matrix addition."
304
+ )
305
+ raise ParserError(msg)
306
+
307
+ expr = pyomo.Expression(
308
+ x.index_set(), rule=_expr_matrix_addition_rule(x, y, subtraction=subtraction)
309
+ )
310
+ expr.construct()
311
+
312
+ return expr
313
+
314
+ # if neither is indexed, do normal addition
315
+ if not (hasattr(x, "index_set") and x.is_indexed()) and not (
316
+ hasattr(y, "index_set") and y.is_indexed()
317
+ ):
318
+ if not subtraction:
319
+ # try regular addition
320
+ return x + y
321
+ # try regular subtraction
322
+ return x - y
323
+
324
+ # x is indexed, y is not
325
+ if (hasattr(x, "index_set")) and x.is_indexed():
326
+ expr = pyomo.Expression(
327
+ x.index_set(), rule=_expr_elementwise_with_single_indexed(x, y, subtraction=subtraction)
328
+ )
329
+
330
+ expr.construct()
331
+ return expr
332
+
333
+ # if y is indexed, x is not
334
+ if (hasattr(y, "index_set")) and y.is_indexed():
335
+ expr = pyomo.Expression(
336
+ y.index_set(), rule=_expr_elementwise_with_single_indexed(y, x, subtraction=subtraction)
337
+ )
338
+
339
+ expr.construct()
340
+ return expr
341
+
342
+ # if only one of the operands is indexed, then addition is not supported. Throw error.
343
+ msg = "For addition, both operands must be either scalars or matrices with matching dimensions."
344
+ raise ParserError(msg)
345
+
346
+ return reduce(_add, args)
347
+
348
+ def _pyomo_subtraction(*args):
349
+ return _pyomo_addition(*args, subtraction=True)
350
+
351
+ def _pyomo_multiply(*args, division=False):
352
+ """Multiply tensor with a scalar."""
353
+
354
+ def _expr_multiply_with_scalar_rule(scalar_value, to_multiply, division=division):
355
+ def _inner(_, *indices):
356
+ return to_multiply[indices] * scalar_value if not division else to_multiply[indices] / scalar_value
357
+
358
+ return _inner
359
+
360
+ def _expr_matrix_multiply_rule(x, y, division=division):
361
+ def _inner(_, *args):
362
+ return x[*args] * y[*args] if not division else x[*args] / y[*args]
363
+
364
+ return _inner
365
+
366
+ def _multiply(x, y, division=division):
367
+ if not hasattr(x, "is_indexed") and not hasattr(y, "is_indexed"):
368
+ # x and y are scalars
369
+ return x * y if not division else x / y
370
+
371
+ # check if x or y is scalar
372
+ if (
373
+ hasattr(x, "is_indexed")
374
+ and x.is_indexed()
375
+ and x.dim() > 0
376
+ and (not hasattr(y, "is_indexed") or not y.is_indexed() or y.is_indexed() and y.dim() == 0)
377
+ ):
378
+ # x is a tensor, y is scalar
379
+ expr = pyomo.Expression(
380
+ x.index_set(), rule=_expr_multiply_with_scalar_rule(y, x, division=division)
381
+ )
382
+ expr.construct()
383
+ return expr
384
+ if (
385
+ hasattr(y, "is_indexed")
386
+ and y.is_indexed()
387
+ and y.dim() > 0
388
+ and (not hasattr(x, "is_indexed") or not x.is_indexed() or x.is_indexed() and x.dim() == 0)
389
+ ):
390
+ # y is a tensor, x is scalar
391
+ expr = pyomo.Expression(
392
+ y.index_set(), rule=_expr_multiply_with_scalar_rule(x, y, division=division)
393
+ )
394
+ expr.construct()
395
+ return expr
396
+
397
+ # check if both are indexed
398
+ if hasattr(x, "index_set") and hasattr(y, "index_set"):
399
+ # both are indexed, neither is a scalar, check dims and sized, if match,
400
+ # multiply together element-wise
401
+ if x.index_set() != y.index_set():
402
+ msg = (
403
+ f"The dimensions of x {x.index_set().set_tuple} must match that"
404
+ f" of y {y.index_set().set_tuple} for element-wise matrix multiplication."
405
+ )
406
+ raise ParserError(msg)
407
+
408
+ expr = pyomo.Expression(x.index_set(), rule=_expr_matrix_multiply_rule(x, y, division=division))
409
+ expr.construct()
410
+
411
+ return expr
412
+
413
+ # both are scalars
414
+ return x * y if not division else x / y
415
+
416
+ return reduce(_multiply, args)
417
+
418
+ def _pyomo_division(*args):
419
+ return _pyomo_multiply(*args, division=True)
420
+
421
+ def _pyomo_matrix_multiplication(*args):
422
+ """Multiply two matrices together."""
423
+
424
+ def _expr_matmul_rule(mat_a, mat_b, j_indices):
425
+ def _inner(_, i_index, k_index):
426
+ return sum(mat_a[i_index, j] * mat_b[j, k_index] for j in j_indices)
427
+
428
+ return _inner
429
+
430
+ def _matmul(mat_a, mat_b):
431
+ if not (hasattr(mat_a, "is_indexed") and mat_a.is_indexed()) or not (
432
+ hasattr(mat_b, "is_indexed") and mat_b.is_indexed()
433
+ ):
434
+ # either mat_a or mat_b is not tensor
435
+ msg = "Either mat_a or mat_b, or both, is not indexed. Cannot perform matrix multiplication."
436
+ raise ParserError(msg)
437
+
438
+ # check for regular vectors, then do dot product
439
+ if mat_a.dim() == 1 and mat_b.dim() == 1:
440
+ if (len_a := len(mat_a.index_set())) != (len_b := len(mat_b.index_set())):
441
+ msg = (
442
+ "For dot product, the sizes of the vectors must match."
443
+ f" Sizes mat_a = {len_a} and mat_b = {len_b}."
444
+ )
445
+ raise ParserError(msg)
446
+
447
+ return pyomo.sum_product(mat_a, mat_b, index=mat_a.index_set())
448
+
449
+ # assuming mat_a has dimensions i,j; and mat_b j,k;
450
+ # then the j dimension is squeezed and the i and k dimensions are kept.
451
+
452
+ # check that we are dealing with matrices
453
+ min_dimension = 2
454
+ if (
455
+ not hasattr(mat_a.index_set(), "set_tuple") or len(mat_a.index_set().set_tuple) < min_dimension
456
+ ) or (not hasattr(mat_b.index_set(), "set_tuple") and len(mat_b.index_set().set_tuple) < min_dimension):
457
+ msg = "Both mat_a and mat_b must have at least two dimensions."
458
+ raise ParserError(msg)
459
+
460
+ # check that the outer dimensions (the one to be squeezed) matches
461
+ if len(mat_a.index_set().set_tuple[-1]) != len(mat_b.index_set().set_tuple[0]):
462
+ msg = (
463
+ f"The last dimension size of mat_a ({mat_a.index_set().set_tuple[-1]}) must "
464
+ f"match the first dimension of mat_b ({mat_b.index_set().set_tuple[0]})"
465
+ )
466
+ raise ParserError(msg)
467
+
468
+ expr = pyomo.Expression(
469
+ mat_a.index_set().set_tuple[0],
470
+ mat_b.index_set().set_tuple[1],
471
+ rule=_expr_matmul_rule(mat_a, mat_b, mat_a.index_set().set_tuple[1]),
472
+ )
473
+ expr.construct()
474
+
475
+ return expr
476
+
477
+ return reduce(_matmul, args)
478
+
479
+ def _pyomo_summation(summand):
480
+ """Sum an indexed Pyomo object."""
481
+ return pyomo.sum_product(summand, index=summand.index_set())
482
+
483
+ def _pyomo_random_access(indexed, *indices):
484
+ return indexed[*indices]
485
+
486
+ pyomo_env = {
487
+ # Define the operations for the different operators.
488
+ # Basic arithmetic operations
489
+ self.NEGATE: _pyomo_negate,
490
+ self.ADD: _pyomo_addition,
491
+ self.SUB: _pyomo_subtraction,
492
+ self.MUL: _pyomo_multiply,
493
+ self.DIV: _pyomo_division,
494
+ # Vector and matrix operations
495
+ self.MATMUL: _pyomo_matrix_multiplication,
496
+ self.SUM: _pyomo_summation,
497
+ self.RANDOM_ACCESS: _pyomo_random_access,
498
+ # Exponentiation and logarithms
499
+ self.EXP: lambda x: _pyomo_unary(x, pyomo.exp),
500
+ self.LN: lambda x: _pyomo_unary(x, pyomo.log),
501
+ self.LB: lambda x: _pyomo_unary(
502
+ x, lambda x: pyomo.log(x) / pyomo.log(2)
503
+ ), # change of base, pyomo has no log2
504
+ self.LG: lambda x: _pyomo_unary(x, pyomo.log10),
505
+ self.LOP: lambda x: _pyomo_unary(x, lambda x: pyomo.log(x + 1)),
506
+ self.SQRT: lambda x: _pyomo_unary(x, pyomo.sqrt),
507
+ self.SQUARE: lambda x: _pyomo_pow(x, 2),
508
+ self.POW: lambda x, y: _pyomo_pow(x, y),
509
+ # Trigonometric operations
510
+ self.ARCCOS: lambda x: _pyomo_unary(x, pyomo.acos),
511
+ self.ARCCOSH: lambda x: _pyomo_unary(x, pyomo.acosh),
512
+ self.ARCSIN: lambda x: _pyomo_unary(x, pyomo.asin),
513
+ self.ARCSINH: lambda x: _pyomo_unary(x, pyomo.asinh),
514
+ self.ARCTAN: lambda x: _pyomo_unary(x, pyomo.atan),
515
+ self.ARCTANH: lambda x: _pyomo_unary(x, pyomo.atanh),
516
+ self.COS: lambda x: _pyomo_unary(x, pyomo.cos),
517
+ self.COSH: lambda x: _pyomo_unary(x, pyomo.cosh),
518
+ self.SIN: lambda x: _pyomo_unary(x, pyomo.sin),
519
+ self.SINH: lambda x: _pyomo_unary(x, pyomo.sinh),
520
+ self.TAN: lambda x: _pyomo_unary(x, pyomo.tan),
521
+ self.TANH: lambda x: _pyomo_unary(x, pyomo.tanh),
522
+ # Rounding operations
523
+ self.ABS: lambda x: _pyomo_unary(x, abs),
524
+ self.CEIL: lambda x: _pyomo_unary(x, pyomo.ceil),
525
+ self.FLOOR: lambda x: _pyomo_unary(x, pyomo.floor),
526
+ # Other operations
527
+ self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst), # not supported
528
+ # probably a better idea to reformulate expressions with a max when utilized with pyomo
529
+ # self.MAX: lambda *args: reduce(lambda x, y: _PyomoMax((x, y)), args),
530
+ self.MAX: lambda *args: _PyomoMax(args),
531
+ self.MIN: lambda *args: _PyomoMin(args),
532
+ }
533
+
534
+ def _sympy_matmul(*args):
535
+ """Sympy matrix multiplication."""
536
+ msg = (
537
+ "Matrix multiplication '@' has not been implemented for the Sympy parser yet."
538
+ " Feel free to contribute!"
539
+ )
540
+ raise NotImplementedError(msg)
541
+
542
+ def _sympy_summation(summand):
543
+ """Sympy matrix summation."""
544
+ msg = (
545
+ "Matrix summation 'Sum' has not been implemented for the Sympy parser yet." " Feel free to contribute!"
546
+ )
547
+ raise NotImplementedError(msg)
548
+
549
+ def _sympy_random_access(*args):
550
+ msg = (
551
+ "Tensor random access with 'At' has not been implemented for the Sympy parser yet. "
552
+ "Feel free to contribute!"
553
+ )
554
+ raise NotImplementedError(msg)
555
+
556
+ sympy_env = {
557
+ # Basic arithmetic operations
558
+ self.NEGATE: lambda x: -to_sympy_expr(x),
559
+ self.ADD: lambda *args: reduce(lambda x, y: to_sympy_expr(x) + to_sympy_expr(y), args),
560
+ self.SUB: lambda *args: reduce(lambda x, y: to_sympy_expr(x) - to_sympy_expr(y), args),
561
+ self.MUL: lambda *args: reduce(lambda x, y: to_sympy_expr(x) * to_sympy_expr(y), args),
562
+ self.DIV: lambda *args: reduce(lambda x, y: to_sympy_expr(x) / to_sympy_expr(y), args),
563
+ # Vector and matrix operations
564
+ self.MATMUL: _sympy_matmul,
565
+ self.SUM: _sympy_summation,
566
+ self.RANDOM_ACCESS: _sympy_random_access,
567
+ # Exponentiation and logarithms
568
+ self.EXP: lambda x: sp.exp(to_sympy_expr(x)),
569
+ self.LN: lambda x: sp.log(to_sympy_expr(x)),
570
+ self.LB: lambda x: sp.log(to_sympy_expr(x), 2),
571
+ self.LG: lambda x: sp.log(to_sympy_expr(x), 10),
572
+ self.LOP: lambda x: sp.log(1 + to_sympy_expr(x)),
573
+ self.SQRT: lambda x: sp.sqrt(to_sympy_expr(x)),
574
+ self.SQUARE: lambda x: to_sympy_expr(x) ** 2,
575
+ self.POW: lambda x, y: to_sympy_expr(x) ** to_sympy_expr(y),
576
+ # Trigonometric operations
577
+ self.SIN: lambda x: sp.sin(to_sympy_expr(x)),
578
+ self.COS: lambda x: sp.cos(to_sympy_expr(x)),
579
+ self.TAN: lambda x: sp.tan(to_sympy_expr(x)),
580
+ self.ARCSIN: lambda x: sp.asin(to_sympy_expr(x)),
581
+ self.ARCCOS: lambda x: sp.acos(to_sympy_expr(x)),
582
+ self.ARCTAN: lambda x: sp.atan(to_sympy_expr(x)),
583
+ # Hyperbolic functions
584
+ self.SINH: lambda x: sp.sinh(to_sympy_expr(x)),
585
+ self.COSH: lambda x: sp.cosh(to_sympy_expr(x)),
586
+ self.TANH: lambda x: sp.tanh(to_sympy_expr(x)),
587
+ self.ARCSINH: lambda x: sp.asinh(to_sympy_expr(x)),
588
+ self.ARCCOSH: lambda x: sp.acosh(to_sympy_expr(x)),
589
+ self.ARCTANH: lambda x: sp.atanh(to_sympy_expr(x)),
590
+ # Other
591
+ self.ABS: lambda x: sp.Abs(to_sympy_expr(x)),
592
+ self.CEIL: lambda x: sp.ceiling(to_sympy_expr(x)),
593
+ self.FLOOR: lambda x: sp.floor(to_sympy_expr(x)),
594
+ # Note: Max and Min in sympy take any number of arguments
595
+ self.MAX: lambda *args: sp.Max(*args),
596
+ self.MIN: lambda *args: sp.Min(*args),
597
+ # Rational numbers, for now assuming two-element list for numerator and denominator
598
+ self.RATIONAL: lambda x, y: sp.Rational(x, y),
599
+ }
600
+
601
+ def _gurobipy_matmul(*args):
602
+ """Gurobipy matrix multiplication."""
603
+
604
+ def _matmul(a, b):
605
+ if isinstance(a, list):
606
+ a = np.array(a)
607
+ if isinstance(b, list):
608
+ b = np.array(b)
609
+ if len(np.shape(a @ b)) == 1:
610
+ return a @ b
611
+ return (a @ b).sum()
612
+
613
+ return reduce(_matmul, args)
614
+ msg = (
615
+ "Matrix multiplication '@' has not been implemented for the Gurobipy parser yet."
616
+ " Feel free to contribute!"
617
+ )
618
+ raise NotImplementedError(msg)
619
+
620
+ def _gurobipy_summation(summand):
621
+ """Gurobipy matrix summation."""
622
+
623
+ def _sum(summand):
624
+ if isinstance(summand, list):
625
+ summand = np.array(summand)
626
+ return summand.sum()
627
+
628
+ return _sum(summand)
629
+ msg = (
630
+ "Matrix summation 'Sum' has not been implemented for the Gurobipy parser yet."
631
+ " Feel free to contribute!"
632
+ )
633
+ raise NotImplementedError(msg)
634
+
635
+ def _gurobipy_random_access(*args):
636
+ msg = (
637
+ "Tensor random access with 'At' has not been implemented for the Gurobipy parser yet. "
638
+ "Feel free to contribute!"
639
+ )
640
+ raise NotImplementedError(msg)
641
+
642
+ gurobipy_env = {
643
+ # Define the operations for the different operators.
644
+ # Basic arithmetic operations
645
+ self.NEGATE: lambda x: -x,
646
+ self.ADD: lambda *args: reduce(lambda x, y: x + y, args),
647
+ self.SUB: lambda *args: reduce(lambda x, y: x - y, args),
648
+ self.MUL: lambda *args: reduce(lambda x, y: x * y, args),
649
+ self.DIV: lambda *args: reduce(lambda x, y: x / y, args),
650
+ # Vector and matrix operations
651
+ self.MATMUL: _gurobipy_matmul,
652
+ self.SUM: _gurobipy_summation,
653
+ self.RANDOM_ACCESS: _gurobipy_random_access,
654
+ # Exponentiation and logarithms
655
+ # it would be possible to implement some of these with the special functions that
656
+ # gurobi has to offer, but they would only work under specific circumstances
657
+ self.EXP: lambda x: gp_error(),
658
+ self.LN: lambda x: gp_error(),
659
+ self.LB: lambda x: gp_error(),
660
+ self.LG: lambda x: gp_error(),
661
+ self.LOP: lambda x: gp_error(),
662
+ self.SQRT: lambda x: gp_error(),
663
+ self.SQUARE: lambda x: x**2,
664
+ self.POW: lambda x, y: x**y, # this will likely cause an error at some point for most y
665
+ # Trigonometric operations
666
+ # it would be possible to implement some of these with the special functions that
667
+ # gurobi has to offer, but they would only work under specific circumstances
668
+ self.ARCCOS: lambda x: gp_error(),
669
+ self.ARCCOSH: lambda x: gp_error(),
670
+ self.ARCSIN: lambda x: gp_error(),
671
+ self.ARCSINH: lambda x: gp_error(),
672
+ self.ARCTAN: lambda x: gp_error(),
673
+ self.ARCTANH: lambda x: gp_error(),
674
+ self.COS: lambda x: gp_error(),
675
+ self.COSH: lambda x: gp_error(),
676
+ self.SIN: lambda x: gp_error(),
677
+ self.SINH: lambda x: gp_error(),
678
+ self.TAN: lambda x: gp_error(),
679
+ self.TANH: lambda x: gp_error(),
680
+ # Rounding operations
681
+ self.ABS: lambda x: gp.abs_(x),
682
+ self.CEIL: lambda x: gp_error(),
683
+ self.FLOOR: lambda x: gp_error(),
684
+ # Other operations
685
+ self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst), # not supported
686
+ self.MAX: lambda *args: gp.max_(args), ## OBS! max and min are unsupported, but left here for reasons
687
+ self.MIN: lambda *args: gp.min_(args),
688
+ }
689
+
690
+ match to_format:
691
+ case FormatEnum.polars:
692
+ self.env = polars_env
693
+ self.parse = self._parse_to_polars
694
+ case FormatEnum.pyomo:
695
+ self.env = pyomo_env
696
+ self.parse = self._parse_to_pyomo
697
+ case FormatEnum.sympy:
698
+ self.env = sympy_env
699
+ self.parse = self._parse_to_sympy
700
+ case FormatEnum.gurobipy:
701
+ self.env = gurobipy_env
702
+ self.parse = self._parse_to_gurobipy
703
+ case _:
704
+ msg = f"Given target format {to_format} not supported. Must be one of {FormatEnum}."
705
+ raise ParserError(msg)
706
+
707
+ def _parse_to_polars(self, expr: list | str | int | float) -> pl.Expr:
708
+ """Recursively parses JSON math expressions and returns a polars expression.
709
+
710
+ Arguments:
711
+ expr (list): A list with a Polish notation expression that describes a, e.g.,
712
+ ["Multiply", ["Sqrt", 2], "x2"]
713
+
714
+ Raises:
715
+ ParserError: when a unsupported operator type is encountered.
716
+
717
+ Returns:
718
+ pl.Expr: A polars expression that may be evaluated further.
719
+
720
+ """
721
+ if isinstance(expr, pl.Expr):
722
+ # Terminal case: polars expression
723
+ return expr
724
+ if isinstance(expr, str):
725
+ # Terminal case: str expression (represents a column name)
726
+ return pl.col(expr)
727
+ if isinstance(expr, self.literals):
728
+ # Terminal case: numeric literal
729
+ return pl.lit(expr)
730
+
731
+ if isinstance(expr, list):
732
+ if len(expr) == 1 and isinstance(expr[0], str | self.literals):
733
+ # Terminal case, single symbol expression or literal
734
+ if isinstance(expr[0], str):
735
+ return pl.col(expr)
736
+ # just a literal
737
+ return pl.lit(expr[0])
738
+
739
+ # Extract the operation name
740
+ if isinstance(expr[0], str) and expr[0] in self.env:
741
+ op_name = expr[0]
742
+ # Parse the operands
743
+ operands = [self.parse(e) for e in expr[1:]]
744
+
745
+ if isinstance(operands, list) and len(operands) == 1:
746
+ # if the operands have redundant brackets, remove them
747
+ operands = operands[0]
748
+
749
+ if isinstance(operands, list):
750
+ return self.env[op_name](*operands)
751
+
752
+ return self.env[op_name](operands)
753
+
754
+ # else, assume the list contents are parseable expressions
755
+ return [self.parse(e) for e in expr]
756
+
757
+ msg = f"Encountered unsupported type '{type(expr)}' during parsing."
758
+ raise ParserError(msg)
759
+
760
+ def _parse_to_pyomo(
761
+ self, expr: list | str | int | float | pyomo.Expression, model: pyomo.Model
762
+ ) -> pyomo.Expression:
763
+ """Parses the MathJSON format recursively into a Pyomo expression.
764
+
765
+ Args:
766
+ expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
767
+ ["Multiply", ["Sqrt", 2], "x2"]
768
+ model (pyomo.Model): a pyomo model with the symbols defined appearing in the expression.
769
+ E.g., "x2" -> model.x2 must be defined.
770
+
771
+ Raises:
772
+ ParserError: when a unsupported operator type is encountered.
773
+
774
+ Returns:
775
+ pyomo.Expression: returns a pyomo expression equivalent to the original expressions.
776
+ """
777
+ if isinstance(expr, pyomo.Expression):
778
+ # Terminal case: pyomo expression
779
+ return expr
780
+ if isinstance(expr, str):
781
+ # Terminal case: str expression, represent a variable or expression
782
+ return getattr(model, expr)
783
+ if isinstance(expr, self.literals):
784
+ # Terminal case: numeric literal
785
+ return expr
786
+
787
+ if isinstance(expr, list):
788
+ if len(expr) == 1 and isinstance(expr[0], str | self.literals):
789
+ # Terminal case, single symbol expression or literal
790
+ if isinstance(expr[0], str):
791
+ return getattr(model, expr[0])
792
+ # just a literal
793
+ return pyomo.Expression(expr=expr[0])
794
+
795
+ # Extract the operation name
796
+ if isinstance(expr[0], str) and expr[0] in self.env:
797
+ op_name = expr[0]
798
+ # Parse the operands
799
+ operands = [self._parse_to_pyomo(e, model) for e in expr[1:]]
800
+
801
+ if isinstance(operands, list) and len(operands) == 1:
802
+ # if the operands have redundant brackets, remove them
803
+ operands = operands[0]
804
+
805
+ if isinstance(operands, list):
806
+ return self.env[op_name](*operands)
807
+
808
+ return self.env[op_name](operands)
809
+
810
+ # else, assume the list contents are parseable expressions
811
+ return [self._parse_to_pyomo(e, model) for e in expr]
812
+
813
+ msg = f"Encountered unsupported type '{type(expr)}' during parsing."
814
+ raise ParserError(msg)
815
+
816
+ def _parse_to_sympy(self, expr: list | str | int | float | sp.Basic) -> sp.Basic:
817
+ """Parse the MathJSON format recursively into a sympy expression.
818
+
819
+ Args:
820
+ expr (list | str | int | float | sp.Basic): base call should be a list in Polish
821
+ notation representing a mathematical expression. Recursion calls can be of various
822
+ types.
823
+
824
+ Raises:
825
+ ParserError: when a unsupported operator type is encountered.
826
+
827
+ Returns:
828
+ sp.Basic: a sympy expression that represents the original mathematical
829
+ expression in the supplied MathJSON format.
830
+ """
831
+ if isinstance(expr, sp.Basic):
832
+ # Terminal case: sympy expression
833
+ return expr
834
+ if isinstance(expr, str):
835
+ # Terminal case: represents a variable
836
+ return sp.sympify(expr, evaluate=False)
837
+ if isinstance(expr, self.literals):
838
+ # Terminal case: numeric literal
839
+ return sp.sympify(expr, evaluate=False)
840
+
841
+ if isinstance(expr, list):
842
+ if len(expr) == 1 and isinstance(expr[0], str | self.literals):
843
+ # Terminal case, single symbol expression or literal
844
+ return sp.sympify(expr[0], evaluate=False)
845
+
846
+ # Extract the operation name
847
+ if isinstance(expr[0], str) and expr[0] in self.env:
848
+ op_name = expr[0]
849
+ # Parse the operands
850
+ operands = [self.parse(e) for e in expr[1:]]
851
+
852
+ if isinstance(operands, list) and len(operands) == 1:
853
+ # if the operands have redundant brackets, remove them
854
+ operands = operands[0]
855
+
856
+ if isinstance(operands, list):
857
+ return self.env[op_name](*operands)
858
+
859
+ return self.env[op_name](operands)
860
+
861
+ # else, assume the list contents are parseable expressions
862
+ return [self.parse(e) for e in expr]
863
+
864
+ msg = f"Encountered unsupported type '{type(expr)}' during parsing."
865
+ raise ParserError(msg)
866
+
867
+ def _parse_to_gurobipy(
868
+ self, expr: list | str | int | float, callback: Callable[[str], gpexpression | int | float]
869
+ ) -> gpexpression | int | float:
870
+ """Parses the MathJSON format recursively into a gurobipy expression.
871
+
872
+ Gurobi only fundamentally supports linear and quadratic expressions, and this parser
873
+ does not check that the inputs are valid. If you try to input something else, you will
874
+ likely encounter an error at some point.
875
+
876
+ Args:
877
+ expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
878
+ ["Multiply", ["Sqrt", 2], "x2"]
879
+ callback (Callable): A function that can return a gurobipy expression associated with the
880
+ correct model when called with symbol str.
881
+
882
+ Returns:
883
+ Returns a gurobipy expression (that can belong into one of multiple types) equivalent to the original
884
+ expressions.
885
+ All possible output types should be supported as parts of gurobipy constraints. gurobipy.GenExpr at
886
+ least isn't supported as an objective function.
887
+ """
888
+ if isinstance(expr, gpexpression):
889
+ # Terminal case: gurobipy expression
890
+ return expr
891
+ if isinstance(expr, str):
892
+ # Terminal case: str expression, represent a variable or expression
893
+ return callback(expr)
894
+ if isinstance(expr, self.literals):
895
+ # Terminal case: numeric literal
896
+ return expr
897
+
898
+ if isinstance(expr, list):
899
+ # Extract the operation name
900
+ if isinstance(expr[0], str) and expr[0] in self.env:
901
+ op_name = expr[0]
902
+ # Parse the operands
903
+ operands = [self._parse_to_gurobipy(e, callback) for e in expr[1:]]
904
+
905
+ while isinstance(operands, list) and len(operands) == 1:
906
+ # if the operands have redundant brackets, remove them
907
+ operands = operands[0]
908
+
909
+ if isinstance(operands, list):
910
+ return self.env[op_name](*operands)
911
+
912
+ return self.env[op_name](operands)
913
+
914
+ # else, assume the list contents are parseable expressions
915
+ return [self._parse_to_gurobipy(e, callback) for e in expr]
916
+
917
+ msg = f"Encountered unsupported type '{type(expr)}' during parsing."
918
+ raise ParserError(msg)
919
+
920
+
921
+ def replace_str(lst: list | str, target: str, sub: list | str | float | int) -> list:
922
+ """Replace a target in list with a substitution recursively.
923
+
924
+ Arguments:
925
+ lst (list or str): The list where the substitution is to be made.
926
+ target (str): The target of the substitution.
927
+ sub (list or str): The content to substitute the target.
928
+
929
+ Return:
930
+ list or str: The list or str with the substitution.
931
+
932
+ Example:
933
+ replace_str("["Max", "g_i", ["Add","g_i","f_i"]]]", "_i", "_1") --->
934
+ ["Max", "g_1", ["Add","g_1","f_1"]]]
935
+ """
936
+ if isinstance(lst, list):
937
+ return [replace_str(item, target, sub) for item in lst]
938
+ if isinstance(lst, str):
939
+ if target == lst:
940
+ if isinstance(sub, str):
941
+ return lst.replace(target, sub)
942
+ return sub
943
+ return lst
944
+ return lst