rtc-tools 2.7.3__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 (50) hide show
  1. rtc_tools-2.7.3.dist-info/METADATA +53 -0
  2. rtc_tools-2.7.3.dist-info/RECORD +50 -0
  3. rtc_tools-2.7.3.dist-info/WHEEL +5 -0
  4. rtc_tools-2.7.3.dist-info/entry_points.txt +3 -0
  5. rtc_tools-2.7.3.dist-info/licenses/COPYING.LESSER +165 -0
  6. rtc_tools-2.7.3.dist-info/top_level.txt +1 -0
  7. rtctools/__init__.py +5 -0
  8. rtctools/_internal/__init__.py +0 -0
  9. rtctools/_internal/alias_tools.py +188 -0
  10. rtctools/_internal/caching.py +25 -0
  11. rtctools/_internal/casadi_helpers.py +99 -0
  12. rtctools/_internal/debug_check_helpers.py +41 -0
  13. rtctools/_version.py +21 -0
  14. rtctools/data/__init__.py +4 -0
  15. rtctools/data/csv.py +150 -0
  16. rtctools/data/interpolation/__init__.py +3 -0
  17. rtctools/data/interpolation/bspline.py +31 -0
  18. rtctools/data/interpolation/bspline1d.py +169 -0
  19. rtctools/data/interpolation/bspline2d.py +54 -0
  20. rtctools/data/netcdf.py +467 -0
  21. rtctools/data/pi.py +1236 -0
  22. rtctools/data/rtc.py +228 -0
  23. rtctools/data/storage.py +343 -0
  24. rtctools/optimization/__init__.py +0 -0
  25. rtctools/optimization/collocated_integrated_optimization_problem.py +3208 -0
  26. rtctools/optimization/control_tree_mixin.py +221 -0
  27. rtctools/optimization/csv_lookup_table_mixin.py +462 -0
  28. rtctools/optimization/csv_mixin.py +300 -0
  29. rtctools/optimization/goal_programming_mixin.py +769 -0
  30. rtctools/optimization/goal_programming_mixin_base.py +1094 -0
  31. rtctools/optimization/homotopy_mixin.py +165 -0
  32. rtctools/optimization/initial_state_estimation_mixin.py +89 -0
  33. rtctools/optimization/io_mixin.py +320 -0
  34. rtctools/optimization/linearization_mixin.py +33 -0
  35. rtctools/optimization/linearized_order_goal_programming_mixin.py +235 -0
  36. rtctools/optimization/min_abs_goal_programming_mixin.py +385 -0
  37. rtctools/optimization/modelica_mixin.py +482 -0
  38. rtctools/optimization/netcdf_mixin.py +177 -0
  39. rtctools/optimization/optimization_problem.py +1302 -0
  40. rtctools/optimization/pi_mixin.py +292 -0
  41. rtctools/optimization/planning_mixin.py +19 -0
  42. rtctools/optimization/single_pass_goal_programming_mixin.py +676 -0
  43. rtctools/optimization/timeseries.py +56 -0
  44. rtctools/rtctoolsapp.py +131 -0
  45. rtctools/simulation/__init__.py +0 -0
  46. rtctools/simulation/csv_mixin.py +171 -0
  47. rtctools/simulation/io_mixin.py +195 -0
  48. rtctools/simulation/pi_mixin.py +255 -0
  49. rtctools/simulation/simulation_problem.py +1293 -0
  50. rtctools/util.py +241 -0
@@ -0,0 +1,3208 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import logging
5
+ import warnings
6
+ from abc import ABCMeta, abstractmethod
7
+
8
+ import casadi as ca
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from rtctools._internal.alias_tools import AliasDict
13
+ from rtctools._internal.casadi_helpers import (
14
+ interpolate,
15
+ is_affine,
16
+ nullvertcat,
17
+ reduce_matvec,
18
+ substitute_in_external,
19
+ )
20
+ from rtctools._internal.debug_check_helpers import DebugLevel, debug_check
21
+
22
+ from .optimization_problem import BT, OptimizationProblem
23
+ from .timeseries import Timeseries
24
+
25
+ logger = logging.getLogger("rtctools")
26
+
27
+
28
+ class CollocatedIntegratedOptimizationProblem(OptimizationProblem, metaclass=ABCMeta):
29
+ """
30
+ Discretizes your model using a mixed collocation/integration scheme.
31
+
32
+ Collocation means that the discretized model equations are included as constraints
33
+ between state variables in the optimization problem.
34
+
35
+ Integration means that the model equations are solved from one time step to the next
36
+ in a sequential fashion, using a rootfinding algorithm at each and every step. The
37
+ results of the integration procedure feature as inputs to the objective functions
38
+ as well as to any constraints that do not originate from the DAE model.
39
+
40
+ .. note::
41
+
42
+ To ensure that your optimization problem only has globally optimal solutions,
43
+ any model equations that are collocated must be linear. By default, all
44
+ model equations are collocated, and linearity of the model equations is
45
+ verified. Working with non-linear models is possible, but discouraged.
46
+
47
+ :cvar check_collocation_linearity:
48
+ If ``True``, check whether collocation constraints are linear. Default is ``True``.
49
+ :cvar inline_delay_expressions:
50
+ If ``True``, delay expressions are inlined into constraints and objectives instead of
51
+ being added as separate equality constraints. Default is ``False``.
52
+ """
53
+
54
+ #: Check whether the collocation constraints are linear
55
+ check_collocation_linearity = True
56
+
57
+ #: Inline delay expressions instead of adding them as separate equality constraints
58
+ inline_delay_expressions = False
59
+
60
+ #: Whether or not the collocation constraints are linear (affine)
61
+ linear_collocation = None
62
+
63
+ def __init__(self, **kwargs):
64
+ # Variables that will be optimized
65
+ self.dae_variables["free_variables"] = (
66
+ self.dae_variables["states"]
67
+ + self.dae_variables["algebraics"]
68
+ + self.dae_variables["control_inputs"]
69
+ )
70
+
71
+ # Cache names of states
72
+ self.__differentiated_states = [
73
+ variable.name() for variable in self.dae_variables["states"]
74
+ ]
75
+ self.__differentiated_states_map = {
76
+ v: i for i, v in enumerate(self.__differentiated_states)
77
+ }
78
+
79
+ self.__algebraic_states = [variable.name() for variable in self.dae_variables["algebraics"]]
80
+ self.__algebraic_states_map = {v: i for i, v in enumerate(self.__algebraic_states)}
81
+
82
+ self.__controls = [variable.name() for variable in self.dae_variables["control_inputs"]]
83
+ self.__controls_map = {v: i for i, v in enumerate(self.__controls)}
84
+
85
+ self.__derivative_names = [
86
+ variable.name() for variable in self.dae_variables["derivatives"]
87
+ ]
88
+
89
+ self.__initial_derivative_names = [
90
+ "initial_" + variable for variable in self.__derivative_names
91
+ ]
92
+
93
+ self.__initial_derivative_nominals = {}
94
+
95
+ # DAE cache
96
+ self.__integrator_step_function = None
97
+ self.__dae_residual_function_collocated = None
98
+ self.__initial_residual_with_params_fun_map = None
99
+
100
+ # Create dictionary of variables so that we have O(1) state lookup available
101
+ self.__variables = AliasDict(self.alias_relation)
102
+ for var in itertools.chain(
103
+ self.dae_variables["states"],
104
+ self.dae_variables["algebraics"],
105
+ self.dae_variables["control_inputs"],
106
+ self.dae_variables["constant_inputs"],
107
+ self.dae_variables["parameters"],
108
+ self.dae_variables["time"],
109
+ ):
110
+ self.__variables[var.name()] = var
111
+
112
+ # Call super
113
+ super().__init__(**kwargs)
114
+
115
+ @abstractmethod
116
+ def times(self, variable=None):
117
+ """
118
+ List of time stamps for variable (to optimize for).
119
+
120
+ :param variable: Variable name.
121
+
122
+ :returns: A list of time stamps for the given variable.
123
+ """
124
+ pass
125
+
126
+ def interpolation_method(self, variable=None):
127
+ """
128
+ Interpolation method for variable.
129
+
130
+ :param variable: Variable name.
131
+
132
+ :returns: Interpolation method for the given variable.
133
+ """
134
+ return self.INTERPOLATION_LINEAR
135
+
136
+ @property
137
+ def integrate_states(self):
138
+ """
139
+ TRUE if all states are to be integrated rather than collocated.
140
+ """
141
+ return False
142
+
143
+ @property
144
+ def theta(self):
145
+ r"""
146
+ RTC-Tools discretizes differential equations of the form
147
+
148
+ .. math::
149
+
150
+ \dot{x} = f(x, u)
151
+
152
+ using the :math:`\theta`-method
153
+
154
+ .. math::
155
+
156
+ x_{i+1} = x_i + \Delta t \left[\theta f(x_{i+1}, u_{i+1})
157
+ + (1 - \theta) f(x_i, u_i)\right]
158
+
159
+ The default is :math:`\theta = 1`, resulting in the implicit or backward Euler method. Note
160
+ that in this case, the control input at the initial time step is not used.
161
+
162
+ Set :math:`\theta = 0` to use the explicit or forward Euler method. Note that in this
163
+ case, the control input at the final time step is not used.
164
+
165
+ .. warning:: This is an experimental feature for :math:`0 < \theta < 1`.
166
+
167
+ .. deprecated:: 2.4
168
+ Support for semi-explicit collocation (theta < 1) will be removed in a future release.
169
+ """
170
+
171
+ # Default to implicit Euler collocation, which is cheaper to evaluate
172
+ # than the trapezoidal method, while being A-stable.
173
+ #
174
+ # N.B. Setting theta to 0 will cause problems with algebraic equations,
175
+ # unless a consistent initialization is supplied for the algebraics.
176
+ # N.B. Setting theta to any value strictly between 0 and 1 will cause
177
+ # algebraic equations to be solved in an average sense. This may
178
+ # induce unexpected oscillations.
179
+ # TODO Fix these issue by performing index reduction and splitting DAE into ODE and
180
+ # algebraic parts. Theta then only applies to the ODE part.
181
+ return 1.0
182
+
183
+ def map_options(self) -> dict[str, str | int]:
184
+ """
185
+ Returns a dictionary of CasADi ``map()`` options.
186
+
187
+ +---------------+-----------+---------------+
188
+ | Option | Type | Default value |
189
+ +===============+===========+===============+
190
+ | ``mode`` | ``str` | ``openmp`` |
191
+ +---------------+-----------+---------------+
192
+ | ``n_threads`` | ``int`` | ``None`` |
193
+ +---------------+-----------+---------------+
194
+
195
+ The ``mode`` option controls the mode of the ``map()`` call. Valid values include
196
+ ``openmp``, ``thread``, and ``unroll``. See the CasADi and documentation for detailed
197
+ documentation on these modes.
198
+
199
+ The ``n_threads`` option controls the number of threads used when in ``thread`` mode.
200
+
201
+ .. note::
202
+
203
+ Not every CasADi build has support for OpenMP enabled. For such builds, the `thread`
204
+ mode offers an alternative parallelization mode.
205
+
206
+ .. note::
207
+
208
+ The use of ``expand=True`` in ``solver_options()`` may negate the parallelization
209
+ benefits obtained using ``map()``.
210
+
211
+ :returns: A dictionary of options for the `map()` call used to evaluate constraints on
212
+ every time stamp.
213
+ """
214
+ return {"mode": "openmp"}
215
+
216
+ def transcribe(self):
217
+ # DAE residual
218
+ dae_residual = self.dae_residual
219
+
220
+ # Initial residual
221
+ initial_residual = self.initial_residual
222
+
223
+ logger.info(
224
+ f"Transcribing problem with a DAE of {dae_residual.size1()} equations, "
225
+ f"{len(self.times())} collocation points, "
226
+ f"and {len(self.dae_variables['free_variables'])} free variables"
227
+ )
228
+
229
+ # Reset dictionary of variables
230
+ for var in itertools.chain(self.path_variables, self.extra_variables):
231
+ self.__variables[var.name()] = var
232
+
233
+ # Split the constant inputs into those used in the DAE, and additional
234
+ # ones used for just the objective and/or constraints
235
+ dae_constant_inputs_names = [x.name() for x in self.dae_variables["constant_inputs"]]
236
+ extra_constant_inputs_name_and_size = []
237
+ for ensemble_member in range(self.ensemble_size):
238
+ extra_constant_inputs_name_and_size.extend(
239
+ [
240
+ (x, v.values.shape[1] if v.values.ndim > 1 else 1)
241
+ for x, v in self.constant_inputs(ensemble_member).items()
242
+ if x not in dae_constant_inputs_names
243
+ ]
244
+ )
245
+
246
+ self.__extra_constant_inputs = []
247
+ for var_name, size in extra_constant_inputs_name_and_size:
248
+ var = ca.MX.sym(var_name, size)
249
+ self.__variables[var_name] = var
250
+ self.__extra_constant_inputs.append(var)
251
+
252
+ # Cache extra and path variable names, and variable sizes
253
+ self.__path_variable_names = [variable.name() for variable in self.path_variables]
254
+ self.__extra_variable_names = [variable.name() for variable in self.extra_variables]
255
+
256
+ # Cache the variable sizes, as a repeated call to .name() and .size1()
257
+ # is expensive due to SWIG call overhead.
258
+ self.__variable_sizes = {}
259
+
260
+ for variable in itertools.chain(
261
+ self.differentiated_states,
262
+ self.algebraic_states,
263
+ self.controls,
264
+ self.__initial_derivative_names,
265
+ ):
266
+ self.__variable_sizes[variable] = 1
267
+
268
+ for mx_symbol, variable in zip(self.path_variables, self.__path_variable_names):
269
+ self.__variable_sizes[variable] = mx_symbol.size1()
270
+
271
+ for mx_symbol, variable in zip(self.extra_variables, self.__extra_variable_names):
272
+ self.__variable_sizes[variable] = mx_symbol.size1()
273
+
274
+ # Calculate nominals for the initial derivatives. We assume that the
275
+ # history has (roughly) identical time steps for the entire ensemble.
276
+ self.__initial_derivative_nominals = {}
277
+ history_0 = self.history(0)
278
+ for variable, initial_der_name in zip(
279
+ self.__differentiated_states, self.__initial_derivative_names
280
+ ):
281
+ times = self.times(variable)
282
+ default_time_step_size = 0
283
+ if len(times) > 1:
284
+ default_time_step_size = times[1] - times[0]
285
+ try:
286
+ h = history_0[variable]
287
+ if h.times[0] == times[0] or len(h.values) == 1:
288
+ dt = default_time_step_size
289
+ else:
290
+ assert h.times[-1] == times[0]
291
+ dt = h.times[-1] - h.times[-2]
292
+ except KeyError:
293
+ dt = default_time_step_size
294
+
295
+ if dt > 0:
296
+ self.__initial_derivative_nominals[initial_der_name] = (
297
+ self.variable_nominal(variable) / dt
298
+ )
299
+ else:
300
+ self.__initial_derivative_nominals[initial_der_name] = self.variable_nominal(
301
+ variable
302
+ )
303
+
304
+ # Check that the removed (because broken) integrated_states option is not used
305
+ try:
306
+ _ = self.integrated_states
307
+ except AttributeError:
308
+ # We expect there to be an error as users should use self.integrate_states
309
+ pass
310
+ else:
311
+ raise Exception(
312
+ "The integrated_states property is no longer supported. "
313
+ "Use integrate_states instead."
314
+ )
315
+
316
+ # Variables that are integrated states are not yet allowed to have size > 1
317
+ if self.integrate_states:
318
+ self.__integrated_states = [*self.differentiated_states, *self.algebraic_states]
319
+
320
+ for variable in self.__integrated_states:
321
+ if self.__variable_sizes.get(variable, 1) > 1:
322
+ raise NotImplementedError(
323
+ f"Vector symbol not supported for integrated state '{variable}'"
324
+ )
325
+ else:
326
+ self.__integrated_states = []
327
+
328
+ # The same holds for controls
329
+ for variable in self.controls:
330
+ if self.__variable_sizes.get(variable, 1) > 1:
331
+ raise NotImplementedError(
332
+ f"Vector symbol not supported for control state '{variable}'"
333
+ )
334
+
335
+ # Collocation times
336
+ collocation_times = self.times()
337
+ n_collocation_times = len(collocation_times)
338
+
339
+ # Dynamic parameters
340
+ dynamic_parameters = self.dynamic_parameters()
341
+ dynamic_parameter_names = set()
342
+
343
+ # Parameter symbols
344
+ symbolic_parameters = ca.vertcat(*self.dae_variables["parameters"])
345
+
346
+ def _interpolate_constant_inputs(variables, raw_constant_inputs):
347
+ constant_inputs_interpolated = {}
348
+ for variable in variables:
349
+ variable = variable.name()
350
+ try:
351
+ constant_input = raw_constant_inputs[variable]
352
+ except KeyError:
353
+ raise Exception(f"No values found for constant input {variable}")
354
+ else:
355
+ values = constant_input.values
356
+ interpolation_method = self.interpolation_method(variable)
357
+ constant_inputs_interpolated[variable] = self.interpolate(
358
+ collocation_times,
359
+ constant_input.times,
360
+ values,
361
+ 0.0,
362
+ 0.0,
363
+ interpolation_method,
364
+ )
365
+
366
+ return constant_inputs_interpolated
367
+
368
+ # Create a store of all ensemble-member-specific data for all ensemble members
369
+ # N.B. Don't use n * [{}], as it creates n refs to the same dict.
370
+ ensemble_store = [{} for i in range(self.ensemble_size)]
371
+ for ensemble_member in range(self.ensemble_size):
372
+ ensemble_data = ensemble_store[ensemble_member]
373
+
374
+ # Store parameters
375
+ parameters = self.parameters(ensemble_member)
376
+ parameter_values = [None] * len(self.dae_variables["parameters"])
377
+ for i, symbol in enumerate(self.dae_variables["parameters"]):
378
+ variable = symbol.name()
379
+ try:
380
+ parameter_values[i] = parameters[variable]
381
+ except KeyError:
382
+ raise Exception(f"No value specified for parameter {variable}")
383
+
384
+ if len(dynamic_parameters) > 0:
385
+ jac_1 = ca.jacobian(symbolic_parameters, ca.vertcat(*dynamic_parameters))
386
+ jac_2 = ca.jacobian(ca.vertcat(*parameter_values), ca.vertcat(*dynamic_parameters))
387
+ for i, symbol in enumerate(self.dae_variables["parameters"]):
388
+ if jac_1[i, :].nnz() > 0 or jac_2[i, :].nnz() > 0:
389
+ dynamic_parameter_names.add(symbol.name())
390
+
391
+ if np.any(
392
+ [isinstance(value, ca.MX) and not value.is_constant() for value in parameter_values]
393
+ ):
394
+ parameter_values = nullvertcat(*parameter_values)
395
+ [parameter_values] = substitute_in_external(
396
+ [parameter_values],
397
+ self.dae_variables["parameters"],
398
+ ca.vertsplit(parameter_values),
399
+ resolve_numerically=False,
400
+ )
401
+ else:
402
+ parameter_values = nullvertcat(*parameter_values)
403
+
404
+ if ensemble_member == 0:
405
+ # Store parameter values of member 0, as variable bounds may depend on these.
406
+ self.__parameter_values_ensemble_member_0 = parameter_values
407
+ ensemble_data["parameters"] = parameter_values
408
+
409
+ # Store constant inputs
410
+ raw_constant_inputs = self.constant_inputs(ensemble_member)
411
+
412
+ ensemble_data["constant_inputs"] = _interpolate_constant_inputs(
413
+ self.dae_variables["constant_inputs"], raw_constant_inputs
414
+ )
415
+ ensemble_data["extra_constant_inputs"] = _interpolate_constant_inputs(
416
+ self.__extra_constant_inputs, raw_constant_inputs
417
+ )
418
+
419
+ # Handle all extra constant input data uniformly as 2D arrays
420
+ for k, v in ensemble_data["extra_constant_inputs"].items():
421
+ if v.ndim == 1:
422
+ ensemble_data["extra_constant_inputs"][k] = v[:, None]
423
+
424
+ if self.ensemble_specific_bounds:
425
+ bounds = [self.bounds(ensemble_member=i) for i in range(self.ensemble_size)]
426
+ else:
427
+ bounds = self.bounds()
428
+
429
+ # Initialize control discretization
430
+ (
431
+ control_size,
432
+ discrete_control,
433
+ lbx_control,
434
+ ubx_control,
435
+ x0_control,
436
+ indices_control,
437
+ ) = self.discretize_controls(bounds)
438
+
439
+ # Initialize state discretization
440
+ (
441
+ state_size,
442
+ discrete_state,
443
+ lbx_state,
444
+ ubx_state,
445
+ x0_state,
446
+ indices_state,
447
+ ) = self.discretize_states(bounds)
448
+
449
+ # Merge state vector offset dictionary
450
+ self.__indices = indices_control
451
+ for ensemble_member in range(self.ensemble_size):
452
+ for key, value in indices_state[ensemble_member].items():
453
+ if isinstance(value, slice):
454
+ value = slice(value.start + control_size, value.stop + control_size)
455
+ else:
456
+ value += control_size
457
+ self.__indices[ensemble_member][key] = value
458
+
459
+ # Initialize vector of optimization symbols
460
+ X = ca.MX.sym("X", control_size + state_size)
461
+ self.__solver_input = X
462
+
463
+ # Later on, we will be slicing MX/SX objects a few times for vectorized operations (to
464
+ # reduce the overhead induced for each CasADi call). When slicing MX/SX objects, we want
465
+ # to do that with a list of Python ints. Slicing with something else (e.g. a list of
466
+ # np.int32, or a numpy array) is significantly slower.
467
+ x_inds = list(range(X.size1()))
468
+ self.__indices_as_lists = [{} for ensemble_member in range(self.ensemble_size)]
469
+
470
+ for ensemble_member in range(self.ensemble_size):
471
+ for k, v in self.__indices[ensemble_member].items():
472
+ if isinstance(v, slice):
473
+ self.__indices_as_lists[ensemble_member][k] = x_inds[v]
474
+ elif isinstance(v, int):
475
+ self.__indices_as_lists[ensemble_member][k] = [v]
476
+ else:
477
+ self.__indices_as_lists[ensemble_member][k] = [int(i) for i in v]
478
+
479
+ # Initialize bound and seed vectors
480
+ discrete = np.zeros(X.size1(), dtype=bool)
481
+
482
+ lbx = -np.inf * np.ones(X.size1())
483
+ ubx = np.inf * np.ones(X.size1())
484
+
485
+ x0 = np.zeros(X.size1())
486
+
487
+ discrete[: len(discrete_control)] = discrete_control
488
+ discrete[len(discrete_control) :] = discrete_state
489
+ lbx[: len(lbx_control)] = lbx_control
490
+ lbx[len(lbx_control) :] = lbx_state
491
+ ubx[: len(ubx_control)] = ubx_control
492
+ ubx[len(lbx_control) :] = ubx_state
493
+ x0[: len(x0_control)] = x0_control
494
+ x0[len(x0_control) :] = x0_state
495
+
496
+ # Provide a state for self.state_at() and self.der() to work with.
497
+ self.__control_size = control_size
498
+ self.__state_size = state_size
499
+ self.__symbol_cache = {}
500
+
501
+ # Free variables for the collocated optimization problem
502
+ if self.integrate_states:
503
+ integrated_variables = self.dae_variables["states"] + self.dae_variables["algebraics"]
504
+ collocated_variables = []
505
+ else:
506
+ integrated_variables = []
507
+ collocated_variables = self.dae_variables["states"] + self.dae_variables["algebraics"]
508
+ collocated_variables += self.dae_variables["control_inputs"]
509
+
510
+ if logger.getEffectiveLevel() == logging.DEBUG:
511
+ logger.debug(f"Integrating variables {repr(integrated_variables)}")
512
+ logger.debug(f"Collocating variables {repr(collocated_variables)}")
513
+
514
+ integrated_variable_names = [v.name() for v in integrated_variables]
515
+ integrated_variable_nominals = np.array(
516
+ [self.variable_nominal(v) for v in integrated_variable_names]
517
+ )
518
+
519
+ collocated_variable_names = [v.name() for v in collocated_variables]
520
+ collocated_variable_nominals = np.array(
521
+ [self.variable_nominal(v) for v in collocated_variable_names]
522
+ )
523
+
524
+ # Split derivatives into "integrated" and "collocated" lists.
525
+
526
+ if self.integrate_states:
527
+ integrated_derivatives = self.dae_variables["derivatives"][:]
528
+ collocated_derivatives = []
529
+ else:
530
+ integrated_derivatives = []
531
+ collocated_derivatives = self.dae_variables["derivatives"][:]
532
+ self.__algebraic_and_control_derivatives = []
533
+ for var in self.dae_variables["algebraics"]:
534
+ sym = ca.MX.sym(f"der({var.name()})")
535
+ self.__algebraic_and_control_derivatives.append(sym)
536
+ if self.integrate_states:
537
+ integrated_derivatives.append(sym)
538
+ else:
539
+ collocated_derivatives.append(sym)
540
+ for var in self.dae_variables["control_inputs"]:
541
+ sym = ca.MX.sym(f"der({var.name()})")
542
+ self.__algebraic_and_control_derivatives.append(sym)
543
+ collocated_derivatives.append(sym)
544
+
545
+ # Path objective
546
+ path_objective = self.path_objective(0)
547
+
548
+ # Path constraints
549
+ path_constraints = self.path_constraints(0)
550
+ path_constraint_expressions = ca.vertcat(
551
+ *[f_constraint for (f_constraint, lb, ub) in path_constraints]
552
+ )
553
+
554
+ # Delayed feedback
555
+ delayed_feedback_expressions, delayed_feedback_states, delayed_feedback_durations = (
556
+ [],
557
+ [],
558
+ [],
559
+ )
560
+ delayed_feedback = self.delayed_feedback()
561
+ if delayed_feedback:
562
+ delayed_feedback_expressions, delayed_feedback_states, delayed_feedback_durations = zip(
563
+ *delayed_feedback
564
+ )
565
+ # Make sure the original data cannot be used anymore, because it will
566
+ # become incorrect/stale with the inlining of constant parameters.
567
+ del delayed_feedback
568
+
569
+ # Initial time
570
+ t0 = self.initial_time
571
+
572
+ # Establish integrator theta
573
+ theta = self.theta
574
+ if theta < 1:
575
+ warnings.warn(
576
+ (
577
+ "Explicit collocation/integration is deprecated "
578
+ "and will be removed in a future version."
579
+ ),
580
+ FutureWarning,
581
+ stacklevel=1,
582
+ )
583
+
584
+ # Set CasADi function options
585
+ options = self.solver_options()
586
+ function_options = {"max_num_dir": options["optimized_num_dir"]}
587
+
588
+ # Update the store of all ensemble-member-specific data for all ensemble members
589
+ # with initial states, derivatives, and path variables.
590
+ # Use vectorized approach to avoid SWIG call overhead for each CasADi call.
591
+ n = len(integrated_variables) + len(collocated_variables)
592
+ for ensemble_member in range(self.ensemble_size):
593
+ ensemble_data = ensemble_store[ensemble_member]
594
+
595
+ initial_state_indices = [None] * n
596
+
597
+ # Derivatives take a bit more effort to vectorize, as we can have
598
+ # both constant values and elements in the state vector
599
+ initial_derivatives = ca.MX.zeros((n, 1))
600
+ init_der_variable = []
601
+ init_der_variable_indices = []
602
+ init_der_variable_nominals = []
603
+ init_der_constant = []
604
+ init_der_constant_values = []
605
+
606
+ history = self.history(ensemble_member)
607
+
608
+ for j, variable in enumerate(integrated_variable_names + collocated_variable_names):
609
+ initial_state_indices[j] = self.__indices_as_lists[ensemble_member][variable][0]
610
+
611
+ try:
612
+ i = self.__differentiated_states_map[variable]
613
+
614
+ initial_der_name = self.__initial_derivative_names[i]
615
+ init_der_variable_nominals.append(self.variable_nominal(initial_der_name))
616
+ init_der_variable_indices.append(
617
+ self.__indices[ensemble_member][initial_der_name]
618
+ )
619
+ init_der_variable.append(j)
620
+
621
+ except KeyError:
622
+ # We do interpolation here instead of relying on der_at. This is faster because:
623
+ # 1. We can reuse the history variable.
624
+ # 2. We know that "variable" is a canonical state
625
+ # 3. We know that we are only dealing with history (numeric values, not
626
+ # symbolics)
627
+ try:
628
+ h = history[variable]
629
+ if h.times[0] == t0 or len(h.values) == 1:
630
+ init_der = 0.0
631
+ else:
632
+ assert h.times[-1] == t0
633
+ init_der = (h.values[-1] - h.values[-2]) / (h.times[-1] - h.times[-2])
634
+ except KeyError:
635
+ init_der = 0.0
636
+
637
+ init_der_constant_values.append(init_der)
638
+ init_der_constant.append(j)
639
+
640
+ initial_derivatives[init_der_variable] = X[init_der_variable_indices] * np.array(
641
+ init_der_variable_nominals
642
+ )
643
+ if len(init_der_constant_values) > 0:
644
+ initial_derivatives[init_der_constant] = init_der_constant_values
645
+
646
+ ensemble_data["initial_state"] = X[initial_state_indices] * np.concatenate(
647
+ (integrated_variable_nominals, collocated_variable_nominals)
648
+ )
649
+ ensemble_data["initial_derivatives"] = initial_derivatives
650
+
651
+ # Store initial path variables
652
+ initial_path_variable_inds = []
653
+
654
+ path_variables_size = sum(self.__variable_sizes[v] for v in self.__path_variable_names)
655
+ path_variables_nominals = np.ones(path_variables_size)
656
+
657
+ offset = 0
658
+ for variable in self.__path_variable_names:
659
+ step = len(self.times(variable))
660
+ initial_path_variable_inds.extend(
661
+ self.__indices_as_lists[ensemble_member][variable][0::step]
662
+ )
663
+
664
+ variable_size = self.__variable_sizes[variable]
665
+ path_variables_nominals[offset : offset + variable_size] = self.variable_nominal(
666
+ variable
667
+ )
668
+ offset += variable_size
669
+
670
+ ensemble_data["initial_path_variables"] = (
671
+ X[initial_path_variable_inds] * path_variables_nominals
672
+ )
673
+
674
+ # Replace parameters which are constant across the entire ensemble
675
+ constant_parameters = []
676
+ constant_parameter_values = []
677
+
678
+ ensemble_parameters = []
679
+ ensemble_parameter_values = [[] for i in range(self.ensemble_size)]
680
+
681
+ for i, parameter in enumerate(self.dae_variables["parameters"]):
682
+ values = [
683
+ ensemble_store[ensemble_member]["parameters"][i]
684
+ for ensemble_member in range(self.ensemble_size)
685
+ ]
686
+ if (
687
+ len(values) == 1 or all(v == values[0] for v in values)
688
+ ) and parameter.name() not in dynamic_parameter_names:
689
+ constant_parameters.append(parameter)
690
+ constant_parameter_values.append(values[0])
691
+ else:
692
+ ensemble_parameters.append(parameter)
693
+ for ensemble_member in range(self.ensemble_size):
694
+ ensemble_parameter_values[ensemble_member].append(values[ensemble_member])
695
+
696
+ symbolic_parameters = ca.vertcat(*ensemble_parameters)
697
+
698
+ # Inline constant parameter values
699
+ if constant_parameters:
700
+ delayed_feedback_expressions = ca.substitute(
701
+ delayed_feedback_expressions, constant_parameters, constant_parameter_values
702
+ )
703
+
704
+ delayed_feedback_durations = ca.substitute(
705
+ delayed_feedback_durations, constant_parameters, constant_parameter_values
706
+ )
707
+
708
+ path_objective, path_constraint_expressions = ca.substitute(
709
+ [path_objective, path_constraint_expressions],
710
+ constant_parameters,
711
+ constant_parameter_values,
712
+ )
713
+
714
+ # Collect extra variable symbols
715
+ symbolic_extra_variables = ca.vertcat(*self.extra_variables)
716
+
717
+ # Aggregate ensemble data
718
+ ensemble_aggregate = {}
719
+ ensemble_aggregate["parameters"] = ca.horzcat(
720
+ *[nullvertcat(*p) for p in ensemble_parameter_values]
721
+ )
722
+ ensemble_aggregate["initial_constant_inputs"] = ca.horzcat(
723
+ *[
724
+ nullvertcat(
725
+ *[
726
+ float(d["constant_inputs"][variable.name()][0])
727
+ for variable in self.dae_variables["constant_inputs"]
728
+ ]
729
+ )
730
+ for d in ensemble_store
731
+ ]
732
+ )
733
+ ensemble_aggregate["initial_extra_constant_inputs"] = ca.horzcat(
734
+ *[
735
+ nullvertcat(
736
+ *[
737
+ d["extra_constant_inputs"][variable.name()][0, :]
738
+ for variable in self.__extra_constant_inputs
739
+ ]
740
+ )
741
+ for d in ensemble_store
742
+ ]
743
+ )
744
+ ensemble_aggregate["initial_state"] = ca.horzcat(
745
+ *[d["initial_state"] for d in ensemble_store]
746
+ )
747
+ ensemble_aggregate["initial_state"] = reduce_matvec(
748
+ ensemble_aggregate["initial_state"], self.solver_input
749
+ )
750
+ ensemble_aggregate["initial_derivatives"] = ca.horzcat(
751
+ *[d["initial_derivatives"] for d in ensemble_store]
752
+ )
753
+ ensemble_aggregate["initial_derivatives"] = reduce_matvec(
754
+ ensemble_aggregate["initial_derivatives"], self.solver_input
755
+ )
756
+ ensemble_aggregate["initial_path_variables"] = ca.horzcat(
757
+ *[d["initial_path_variables"] for d in ensemble_store]
758
+ )
759
+ ensemble_aggregate["initial_path_variables"] = reduce_matvec(
760
+ ensemble_aggregate["initial_path_variables"], self.solver_input
761
+ )
762
+
763
+ if (self.__dae_residual_function_collocated is None) and (
764
+ self.__integrator_step_function is None
765
+ ):
766
+ # Insert lookup tables. No support yet for different lookup tables per ensemble member.
767
+ lookup_tables = self.lookup_tables(0)
768
+
769
+ for sym in self.dae_variables["lookup_tables"]:
770
+ sym_name = sym.name()
771
+
772
+ try:
773
+ lookup_table = lookup_tables[sym_name]
774
+ except KeyError:
775
+ raise Exception(f"Unable to find lookup table function for {sym_name}")
776
+ else:
777
+ input_syms = [
778
+ self.variable(input_sym.name()) for input_sym in lookup_table.inputs
779
+ ]
780
+
781
+ value = lookup_table.function(*input_syms)
782
+ [dae_residual] = ca.substitute([dae_residual], [sym], [value])
783
+
784
+ if len(self.dae_variables["lookup_tables"]) > 0 and self.ensemble_size > 1:
785
+ logger.warning("Using lookup tables of ensemble member #0 for all members.")
786
+
787
+ # Insert constant parameter values
788
+ dae_residual, initial_residual = ca.substitute(
789
+ [dae_residual, initial_residual], constant_parameters, constant_parameter_values
790
+ )
791
+
792
+ # Allocate DAE to an integrated or to a collocated part
793
+ if self.integrate_states:
794
+ dae_residual_integrated = dae_residual
795
+ dae_residual_collocated = ca.MX()
796
+ else:
797
+ dae_residual_integrated = ca.MX()
798
+ dae_residual_collocated = dae_residual
799
+
800
+ # Check linearity of collocated part
801
+ if self.check_collocation_linearity and dae_residual_collocated.size1() > 0:
802
+ # Check linearity of collocation constraints, which is a necessary condition for the
803
+ # optimization problem to be convex
804
+ self.linear_collocation = True
805
+
806
+ # Aside from decision variables, the DAE expression also contains parameters
807
+ # and constant inputs. We need to inline them before we do the affinity check.
808
+ # Note that this not an exhaustive check, as other values for the
809
+ # parameters/constant inputs may result in a non-affine DAE (or vice-versa).
810
+ np.random.seed(42)
811
+ fixed_vars = ca.vertcat(
812
+ *self.dae_variables["time"],
813
+ *self.dae_variables["constant_inputs"],
814
+ ca.MX(symbolic_parameters),
815
+ )
816
+ fixed_var_values = np.random.rand(fixed_vars.size1())
817
+
818
+ if not is_affine(
819
+ ca.substitute(dae_residual_collocated, fixed_vars, fixed_var_values),
820
+ ca.vertcat(
821
+ *collocated_variables
822
+ + integrated_variables
823
+ + collocated_derivatives
824
+ + integrated_derivatives
825
+ ),
826
+ ):
827
+ self.linear_collocation = False
828
+
829
+ logger.warning(
830
+ "The DAE residual contains equations that are not affine. "
831
+ "There is therefore no guarantee that the optimization problem is convex. "
832
+ "This will, in general, result in the existence of multiple local optima "
833
+ "and trouble finding a feasible initial solution."
834
+ )
835
+
836
+ # Transcribe DAE using theta method collocation
837
+ if self.integrate_states:
838
+ I = ca.MX.sym("I", len(integrated_variables)) # noqa: E741
839
+ I0 = ca.MX.sym("I0", len(integrated_variables))
840
+ C0 = [ca.MX.sym(f"C0[{i}]") for i in range(len(collocated_variables))]
841
+ CI0 = [
842
+ ca.MX.sym(f"CI0[{i}]")
843
+ for i in range(len(self.dae_variables["constant_inputs"]))
844
+ ]
845
+ dt_sym = ca.MX.sym("dt")
846
+
847
+ integrated_finite_differences = (I - I0) / dt_sym
848
+
849
+ [dae_residual_integrated_0] = ca.substitute(
850
+ [dae_residual_integrated],
851
+ (
852
+ integrated_variables
853
+ + collocated_variables
854
+ + integrated_derivatives
855
+ + self.dae_variables["constant_inputs"]
856
+ + self.dae_variables["time"]
857
+ ),
858
+ (
859
+ [I0[i] for i in range(len(integrated_variables))]
860
+ + [C0[i] for i in range(len(collocated_variables))]
861
+ + [
862
+ integrated_finite_differences[i]
863
+ for i in range(len(integrated_derivatives))
864
+ ]
865
+ + [CI0[i] for i in range(len(self.dae_variables["constant_inputs"]))]
866
+ + [self.dae_variables["time"][0] - dt_sym]
867
+ ),
868
+ )
869
+ [dae_residual_integrated_1] = ca.substitute(
870
+ [dae_residual_integrated],
871
+ (integrated_variables + integrated_derivatives),
872
+ (
873
+ [I[i] for i in range(len(integrated_variables))]
874
+ + [
875
+ integrated_finite_differences[i]
876
+ for i in range(len(integrated_derivatives))
877
+ ]
878
+ ),
879
+ )
880
+
881
+ if theta == 0:
882
+ dae_residual_integrated = dae_residual_integrated_0
883
+ elif theta == 1:
884
+ dae_residual_integrated = dae_residual_integrated_1
885
+ else:
886
+ dae_residual_integrated = (
887
+ 1 - theta
888
+ ) * dae_residual_integrated_0 + theta * dae_residual_integrated_1
889
+
890
+ dae_residual_function_integrated = ca.Function(
891
+ "dae_residual_function_integrated",
892
+ [
893
+ I,
894
+ I0,
895
+ symbolic_parameters,
896
+ ca.vertcat(
897
+ *(
898
+ [C0[i] for i in range(len(collocated_variables))]
899
+ + [
900
+ CI0[i]
901
+ for i in range(len(self.dae_variables["constant_inputs"]))
902
+ ]
903
+ + [dt_sym]
904
+ + collocated_variables
905
+ + collocated_derivatives
906
+ + self.dae_variables["constant_inputs"]
907
+ + self.dae_variables["time"]
908
+ )
909
+ ),
910
+ ],
911
+ [dae_residual_integrated],
912
+ function_options,
913
+ )
914
+
915
+ # Expand the residual function if possible.
916
+ try:
917
+ dae_residual_function_integrated = dae_residual_function_integrated.expand()
918
+ except RuntimeError as e:
919
+ if "'eval_sx' not defined for" in str(e):
920
+ pass
921
+ else:
922
+ raise
923
+
924
+ options = self.integrator_options()
925
+ self.__integrator_step_function = ca.rootfinder(
926
+ "integrator_step_function",
927
+ "fast_newton",
928
+ dae_residual_function_integrated,
929
+ options,
930
+ )
931
+
932
+ # Initialize a Function for the DAE residual (collocated part)
933
+ elif len(collocated_variables) > 0:
934
+ self.__dae_residual_function_collocated = ca.Function(
935
+ "dae_residual_function_collocated",
936
+ [
937
+ symbolic_parameters,
938
+ ca.vertcat(
939
+ *(
940
+ collocated_variables
941
+ + collocated_derivatives
942
+ + self.dae_variables["constant_inputs"]
943
+ + self.dae_variables["time"]
944
+ )
945
+ ),
946
+ ],
947
+ [dae_residual_collocated],
948
+ function_options,
949
+ )
950
+ # Expand the residual function if possible.
951
+ try:
952
+ self.__dae_residual_function_collocated = (
953
+ self.__dae_residual_function_collocated.expand()
954
+ )
955
+ except RuntimeError as e:
956
+ if "'eval_sx' not defined for" in str(e):
957
+ pass
958
+ else:
959
+ raise
960
+
961
+ if self.integrate_states:
962
+ integrator_step_function = self.__integrator_step_function
963
+ dae_residual_collocated_size = 0
964
+ elif len(collocated_variables) > 0:
965
+ dae_residual_function_collocated = self.__dae_residual_function_collocated
966
+ dae_residual_collocated_size = dae_residual_function_collocated.mx_out(0).size1()
967
+ else:
968
+ dae_residual_collocated_size = 0
969
+
970
+ # Note that this list is stored, such that it can be reused in the
971
+ # map_path_expression() method.
972
+ self.__func_orig_inputs = [
973
+ symbolic_parameters,
974
+ ca.vertcat(
975
+ *integrated_variables,
976
+ *collocated_variables,
977
+ *integrated_derivatives,
978
+ *collocated_derivatives,
979
+ *self.dae_variables["constant_inputs"],
980
+ *self.dae_variables["time"],
981
+ *self.path_variables,
982
+ *self.__extra_constant_inputs,
983
+ ),
984
+ symbolic_extra_variables,
985
+ ]
986
+
987
+ # Initialize a Function for the path objective
988
+ # Note that we assume that the path objective expression is the same for all ensemble
989
+ # members
990
+ path_objective_function = ca.Function(
991
+ "path_objective", self.__func_orig_inputs, [path_objective], function_options
992
+ )
993
+ path_objective_function = path_objective_function.expand()
994
+
995
+ # Initialize a Function for the path constraints
996
+ # Note that we assume that the path constraint expression is the same for all ensemble
997
+ # members
998
+ path_constraints_function = ca.Function(
999
+ "path_constraints",
1000
+ self.__func_orig_inputs,
1001
+ [path_constraint_expressions],
1002
+ function_options,
1003
+ )
1004
+ path_constraints_function = path_constraints_function.expand()
1005
+
1006
+ # Initialize a Function for the delayed feedback
1007
+ delayed_feedback_function = ca.Function(
1008
+ "delayed_feedback",
1009
+ self.__func_orig_inputs,
1010
+ delayed_feedback_expressions,
1011
+ function_options,
1012
+ )
1013
+ delayed_feedback_function = delayed_feedback_function.expand()
1014
+
1015
+ # Set up accumulation over time (integration, and generation of
1016
+ # collocation constraints)
1017
+ if self.integrate_states:
1018
+ accumulated_X = ca.MX.sym("accumulated_X", len(integrated_variables))
1019
+ else:
1020
+ accumulated_X = ca.MX.sym("accumulated_X", 0)
1021
+
1022
+ path_variables_size = sum(x.size1() for x in self.path_variables)
1023
+ extra_constant_inputs_size = sum(x.size1() for x in self.__extra_constant_inputs)
1024
+
1025
+ accumulated_U = ca.MX.sym(
1026
+ "accumulated_U",
1027
+ (
1028
+ 2 * (len(collocated_variables) + len(self.dae_variables["constant_inputs"]) + 1)
1029
+ + path_variables_size
1030
+ + extra_constant_inputs_size
1031
+ ),
1032
+ )
1033
+
1034
+ integrated_states_0 = accumulated_X[0 : len(integrated_variables)]
1035
+ integrated_states_1 = ca.MX.sym("integrated_states_1", len(integrated_variables))
1036
+ collocated_states_0 = accumulated_U[0 : len(collocated_variables)]
1037
+ collocated_states_1 = accumulated_U[
1038
+ len(collocated_variables) : 2 * len(collocated_variables)
1039
+ ]
1040
+ constant_inputs_0 = accumulated_U[
1041
+ 2 * len(collocated_variables) : 2 * len(collocated_variables)
1042
+ + len(self.dae_variables["constant_inputs"])
1043
+ ]
1044
+ constant_inputs_1 = accumulated_U[
1045
+ 2 * len(collocated_variables) + len(self.dae_variables["constant_inputs"]) : 2
1046
+ * len(collocated_variables)
1047
+ + 2 * len(self.dae_variables["constant_inputs"])
1048
+ ]
1049
+
1050
+ offset = 2 * (len(collocated_variables) + len(self.dae_variables["constant_inputs"]))
1051
+ collocation_time_0 = accumulated_U[offset + 0]
1052
+ collocation_time_1 = accumulated_U[offset + 1]
1053
+ path_variables_1 = accumulated_U[offset + 2 : offset + 2 + len(self.path_variables)]
1054
+ extra_constant_inputs_1 = accumulated_U[offset + 2 + len(self.path_variables) :]
1055
+
1056
+ # Approximate derivatives using backwards finite differences
1057
+ dt = collocation_time_1 - collocation_time_0
1058
+ integrated_finite_differences = ca.MX() # Overwritten later if integrate_states is True
1059
+ collocated_finite_differences = (collocated_states_1 - collocated_states_0) / dt
1060
+
1061
+ # We use ca.vertcat to compose the list into an MX. This is, in
1062
+ # CasADi 2.4, faster.
1063
+ accumulated_Y = []
1064
+
1065
+ # Integrate integrated states
1066
+ if self.integrate_states:
1067
+ # Perform step by computing implicit function
1068
+ # CasADi shares subexpressions that are bundled into the same Function.
1069
+ # The first argument is the guess for the new value of
1070
+ # integrated_states.
1071
+ [integrated_states_1] = integrator_step_function.call(
1072
+ [
1073
+ integrated_states_0,
1074
+ integrated_states_0,
1075
+ symbolic_parameters,
1076
+ ca.vertcat(
1077
+ collocated_states_0,
1078
+ constant_inputs_0,
1079
+ dt,
1080
+ collocated_states_1,
1081
+ collocated_finite_differences,
1082
+ constant_inputs_1,
1083
+ collocation_time_1 - t0,
1084
+ ),
1085
+ ],
1086
+ False,
1087
+ True,
1088
+ )
1089
+ accumulated_Y.append(integrated_states_1)
1090
+
1091
+ # Recompute finite differences with computed new state.
1092
+ # We don't use substititute() for this, as it becomes expensive over long
1093
+ # integration horizons.
1094
+ integrated_finite_differences = (integrated_states_1 - integrated_states_0) / dt
1095
+
1096
+ # Call DAE residual at collocation point
1097
+ # Time stamp following paragraph 3.6.7 of the Modelica
1098
+ # specifications, version 3.3.
1099
+ elif len(collocated_variables) > 0:
1100
+ if theta < 1:
1101
+ # Obtain state vector
1102
+ [dae_residual_0] = dae_residual_function_collocated.call(
1103
+ [
1104
+ symbolic_parameters,
1105
+ ca.vertcat(
1106
+ collocated_states_0,
1107
+ collocated_finite_differences,
1108
+ constant_inputs_0,
1109
+ collocation_time_0 - t0,
1110
+ ),
1111
+ ],
1112
+ False,
1113
+ True,
1114
+ )
1115
+ if theta > 0:
1116
+ # Obtain state vector
1117
+ [dae_residual_1] = dae_residual_function_collocated.call(
1118
+ [
1119
+ symbolic_parameters,
1120
+ ca.vertcat(
1121
+ collocated_states_1,
1122
+ collocated_finite_differences,
1123
+ constant_inputs_1,
1124
+ collocation_time_1 - t0,
1125
+ ),
1126
+ ],
1127
+ False,
1128
+ True,
1129
+ )
1130
+ if theta == 0:
1131
+ accumulated_Y.append(dae_residual_0)
1132
+ elif theta == 1:
1133
+ accumulated_Y.append(dae_residual_1)
1134
+ else:
1135
+ accumulated_Y.append((1 - theta) * dae_residual_0 + theta * dae_residual_1)
1136
+
1137
+ self.__func_inputs_implicit = [
1138
+ symbolic_parameters,
1139
+ ca.vertcat(
1140
+ integrated_states_1,
1141
+ collocated_states_1,
1142
+ integrated_finite_differences,
1143
+ collocated_finite_differences,
1144
+ constant_inputs_1,
1145
+ collocation_time_1 - t0,
1146
+ path_variables_1,
1147
+ extra_constant_inputs_1,
1148
+ ),
1149
+ symbolic_extra_variables,
1150
+ ]
1151
+
1152
+ accumulated_Y.extend(path_objective_function.call(self.__func_inputs_implicit, False, True))
1153
+
1154
+ accumulated_Y.extend(
1155
+ path_constraints_function.call(self.__func_inputs_implicit, False, True)
1156
+ )
1157
+
1158
+ accumulated_Y.extend(
1159
+ delayed_feedback_function.call(self.__func_inputs_implicit, False, True)
1160
+ )
1161
+
1162
+ # Save the accumulated inputs such that can be used later in map_path_expression()
1163
+ self.__func_accumulated_inputs = (
1164
+ accumulated_X,
1165
+ accumulated_U,
1166
+ ca.veccat(symbolic_parameters, symbolic_extra_variables),
1167
+ )
1168
+
1169
+ # Use map/mapaccum to capture integration and collocation constraint generation over the
1170
+ # entire time horizon with one symbolic operation. This saves a lot of memory.
1171
+ if n_collocation_times > 1:
1172
+ if self.integrate_states:
1173
+ accumulated = ca.Function(
1174
+ "accumulated",
1175
+ self.__func_accumulated_inputs,
1176
+ [accumulated_Y[0], ca.vertcat(*accumulated_Y[1:])],
1177
+ function_options,
1178
+ )
1179
+ accumulation = accumulated.mapaccum("accumulation", n_collocation_times - 1)
1180
+ else:
1181
+ # Fully collocated problem. Use map(), so that we can use
1182
+ # parallelization along the time axis.
1183
+ accumulated = ca.Function(
1184
+ "accumulated",
1185
+ self.__func_accumulated_inputs,
1186
+ [ca.vertcat(*accumulated_Y)],
1187
+ function_options,
1188
+ )
1189
+ options = self.map_options()
1190
+ if options["mode"] == "thread":
1191
+ accumulation = accumulated.map(
1192
+ n_collocation_times - 1, options["mode"], options["n_threads"]
1193
+ )
1194
+ else:
1195
+ accumulation = accumulated.map(n_collocation_times - 1, options["mode"])
1196
+ else:
1197
+ accumulation = None
1198
+
1199
+ # Start collecting constraints
1200
+ f = []
1201
+ g = []
1202
+ lbg = []
1203
+ ubg = []
1204
+
1205
+ # Add constraints for initial conditions
1206
+ if self.__initial_residual_with_params_fun_map is None:
1207
+ initial_residual_with_params_fun = ca.Function(
1208
+ "initial_residual_total",
1209
+ [
1210
+ symbolic_parameters,
1211
+ ca.vertcat(
1212
+ *(
1213
+ self.dae_variables["states"]
1214
+ + self.dae_variables["algebraics"]
1215
+ + self.dae_variables["control_inputs"]
1216
+ + integrated_derivatives
1217
+ + collocated_derivatives
1218
+ + self.dae_variables["constant_inputs"]
1219
+ + self.dae_variables["time"]
1220
+ )
1221
+ ),
1222
+ ],
1223
+ [ca.veccat(dae_residual, initial_residual)],
1224
+ function_options,
1225
+ )
1226
+ self.__initial_residual_with_params_fun_map = initial_residual_with_params_fun.map(
1227
+ self.ensemble_size
1228
+ )
1229
+ initial_residual_with_params_fun_map = self.__initial_residual_with_params_fun_map
1230
+ [res] = initial_residual_with_params_fun_map.call(
1231
+ [
1232
+ ensemble_aggregate["parameters"],
1233
+ ca.vertcat(
1234
+ *[
1235
+ ensemble_aggregate["initial_state"],
1236
+ ensemble_aggregate["initial_derivatives"],
1237
+ ensemble_aggregate["initial_constant_inputs"],
1238
+ ca.repmat([0.0], 1, self.ensemble_size),
1239
+ ]
1240
+ ),
1241
+ ],
1242
+ False,
1243
+ True,
1244
+ )
1245
+
1246
+ res = ca.vec(res)
1247
+ g.append(res)
1248
+ zeros = [0.0] * res.size1()
1249
+ lbg.extend(zeros)
1250
+ ubg.extend(zeros)
1251
+
1252
+ # The initial values and the interpolated mapped arguments are saved
1253
+ # such that can be reused in map_path_expression().
1254
+ self.__func_initial_inputs = []
1255
+ self.__func_map_args = []
1256
+
1257
+ # Integrators are saved for result extraction later on
1258
+ self.__integrators = []
1259
+
1260
+ # If inlining delay expressions, prepare for the single call to ca.substitute()
1261
+ # at the end
1262
+ if self.inline_delay_expressions:
1263
+ delayed_feedback_variable_replacement = ca.MX.zeros(X.numel())
1264
+ delayed_feedback_variable_replacement[:] = X
1265
+
1266
+ # Process the objectives and constraints for each ensemble member separately.
1267
+ # Note that we don't use map here for the moment, so as to allow each ensemble member to
1268
+ # define its own constraints and objectives. Path constraints are applied for all ensemble
1269
+ # members simultaneously at the moment. We can get rid of map again, and allow every
1270
+ # ensemble member to specify its own path constraints as well, once CasADi has some kind
1271
+ # of loop detection.
1272
+ for ensemble_member in range(self.ensemble_size):
1273
+ logger.info(f"Transcribing ensemble member {ensemble_member + 1}/{self.ensemble_size}")
1274
+
1275
+ initial_state = ensemble_aggregate["initial_state"][:, ensemble_member]
1276
+ initial_derivatives = ensemble_aggregate["initial_derivatives"][:, ensemble_member]
1277
+ initial_path_variables = ensemble_aggregate["initial_path_variables"][
1278
+ :, ensemble_member
1279
+ ]
1280
+ initial_constant_inputs = ensemble_aggregate["initial_constant_inputs"][
1281
+ :, ensemble_member
1282
+ ]
1283
+ initial_extra_constant_inputs = ensemble_aggregate["initial_extra_constant_inputs"][
1284
+ :, ensemble_member
1285
+ ]
1286
+ parameters = ensemble_aggregate["parameters"][:, ensemble_member]
1287
+ extra_variables = ca.vertcat(
1288
+ *[self.extra_variable(var.name(), ensemble_member) for var in self.extra_variables]
1289
+ )
1290
+
1291
+ constant_inputs = ensemble_store[ensemble_member]["constant_inputs"]
1292
+ extra_constant_inputs = ensemble_store[ensemble_member]["extra_constant_inputs"]
1293
+
1294
+ # Initial conditions specified in history timeseries
1295
+ history = self.history(ensemble_member)
1296
+ for variable in itertools.chain(
1297
+ self.differentiated_states, self.algebraic_states, self.controls
1298
+ ):
1299
+ try:
1300
+ history_timeseries = history[variable]
1301
+ except KeyError:
1302
+ pass
1303
+ else:
1304
+ interpolation_method = self.interpolation_method(variable)
1305
+ val = self.interpolate(
1306
+ t0,
1307
+ history_timeseries.times,
1308
+ history_timeseries.values,
1309
+ np.nan,
1310
+ np.nan,
1311
+ interpolation_method,
1312
+ )
1313
+ val /= self.variable_nominal(variable)
1314
+
1315
+ if not np.isnan(val):
1316
+ idx = self.__indices_as_lists[ensemble_member][variable][0]
1317
+
1318
+ if val < lbx[idx] or val > ubx[idx]:
1319
+ logger.warning(
1320
+ f"Initial value {val} for variable '{variable}' outside bounds."
1321
+ )
1322
+
1323
+ lbx[idx] = ubx[idx] = val
1324
+
1325
+ initial_derivative_constraints = []
1326
+
1327
+ for i, variable in enumerate(self.differentiated_states):
1328
+ try:
1329
+ history_timeseries = history[variable]
1330
+ except KeyError:
1331
+ pass
1332
+ else:
1333
+ if len(history_timeseries.times) <= 1 or np.isnan(
1334
+ history_timeseries.values[-2]
1335
+ ):
1336
+ continue
1337
+
1338
+ assert history_timeseries.times[-1] == t0
1339
+
1340
+ if np.isnan(history_timeseries.values[-1]):
1341
+ t0_val = self.state_vector(variable, ensemble_member=ensemble_member)[0]
1342
+ t0_val *= self.variable_nominal(variable)
1343
+
1344
+ val = (t0_val - history_timeseries.values[-2]) / (
1345
+ t0 - history_timeseries.times[-2]
1346
+ )
1347
+ sym = initial_derivatives[i]
1348
+ initial_derivative_constraints.append(sym - val)
1349
+ else:
1350
+ interpolation_method = self.interpolation_method(variable)
1351
+
1352
+ t0_val = self.interpolate(
1353
+ t0,
1354
+ history_timeseries.times,
1355
+ history_timeseries.values,
1356
+ np.nan,
1357
+ np.nan,
1358
+ interpolation_method,
1359
+ )
1360
+ initial_der_name = self.__initial_derivative_names[i]
1361
+
1362
+ val = (t0_val - history_timeseries.values[-2]) / (
1363
+ t0 - history_timeseries.times[-2]
1364
+ )
1365
+ val /= self.variable_nominal(initial_der_name)
1366
+
1367
+ idx = self.__indices[ensemble_member][initial_der_name]
1368
+ lbx[idx] = ubx[idx] = val
1369
+
1370
+ if len(initial_derivative_constraints) > 0:
1371
+ g.append(ca.vertcat(*initial_derivative_constraints))
1372
+ lbg.append(np.zeros(len(initial_derivative_constraints)))
1373
+ ubg.append(np.zeros(len(initial_derivative_constraints)))
1374
+
1375
+ # Initial conditions for integrator
1376
+ accumulation_X0 = []
1377
+ if self.integrate_states:
1378
+ for variable in integrated_variable_names:
1379
+ value = self.state_vector(variable, ensemble_member=ensemble_member)[0]
1380
+ nominal = self.variable_nominal(variable)
1381
+ if nominal != 1:
1382
+ value *= nominal
1383
+ accumulation_X0.append(value)
1384
+ accumulation_X0 = ca.vertcat(*accumulation_X0)
1385
+
1386
+ # Input for map
1387
+ logger.info("Interpolating states")
1388
+
1389
+ accumulation_U = [None] * (
1390
+ 1
1391
+ + 2 * len(self.dae_variables["constant_inputs"])
1392
+ + 3
1393
+ + len(self.__extra_constant_inputs)
1394
+ )
1395
+
1396
+ # Most variables have collocation times equal to the global
1397
+ # collocation times. Use a vectorized approach to process them.
1398
+ interpolated_states_explicit = []
1399
+ interpolated_states_implicit = []
1400
+
1401
+ place_holder = [-1] * n_collocation_times
1402
+ for variable in collocated_variable_names:
1403
+ var_inds = self.__indices_as_lists[ensemble_member][variable]
1404
+
1405
+ # If the variable times != collocation times, what we do here is just a placeholder
1406
+ if len(var_inds) != n_collocation_times:
1407
+ var_inds = var_inds.copy()
1408
+ var_inds.extend(place_holder)
1409
+ var_inds = var_inds[:n_collocation_times]
1410
+
1411
+ interpolated_states_explicit.extend(var_inds[:-1])
1412
+ interpolated_states_implicit.extend(var_inds[1:])
1413
+
1414
+ repeated_nominals = np.tile(
1415
+ np.repeat(collocated_variable_nominals, n_collocation_times - 1), 2
1416
+ )
1417
+ interpolated_states = (
1418
+ ca.vertcat(X[interpolated_states_explicit], X[interpolated_states_implicit])
1419
+ * repeated_nominals
1420
+ )
1421
+ interpolated_states = interpolated_states.reshape(
1422
+ (n_collocation_times - 1, len(collocated_variables) * 2)
1423
+ )
1424
+
1425
+ # Handle variables that have different collocation times.
1426
+ for j, variable in enumerate(collocated_variable_names):
1427
+ times = self.times(variable)
1428
+ if n_collocation_times == len(times):
1429
+ # Already handled
1430
+ continue
1431
+
1432
+ interpolation_method = self.interpolation_method(variable)
1433
+ values = self.state_vector(variable, ensemble_member=ensemble_member)
1434
+ interpolated = interpolate(
1435
+ times, values, collocation_times, False, interpolation_method
1436
+ )
1437
+
1438
+ nominal = self.variable_nominal(variable)
1439
+ if nominal != 1:
1440
+ interpolated *= nominal
1441
+
1442
+ interpolated_states[:, j] = interpolated[:-1]
1443
+ interpolated_states[:, len(collocated_variables) + j] = interpolated[1:]
1444
+
1445
+ # We do not cache the Jacobians, as the structure may change from ensemble member to
1446
+ # member, and from goal programming/homotopy run to run.
1447
+ # We could, of course, pick the states apart into controls and states, and generate
1448
+ # Jacobians for each set separately and for each ensemble member separately, but in
1449
+ # this case the increased complexity may well offset the performance gained by
1450
+ # caching.
1451
+ interpolated_states = reduce_matvec(interpolated_states, self.solver_input)
1452
+
1453
+ accumulation_U[0] = interpolated_states
1454
+
1455
+ for j, variable in enumerate(self.dae_variables["constant_inputs"]):
1456
+ variable = variable.name()
1457
+ constant_input = constant_inputs[variable]
1458
+ accumulation_U[1 + j] = ca.MX(constant_input[0 : n_collocation_times - 1])
1459
+ accumulation_U[1 + len(self.dae_variables["constant_inputs"]) + j] = ca.MX(
1460
+ constant_input[1:n_collocation_times]
1461
+ )
1462
+
1463
+ accumulation_U[1 + 2 * len(self.dae_variables["constant_inputs"])] = ca.MX(
1464
+ collocation_times[0 : n_collocation_times - 1]
1465
+ )
1466
+ accumulation_U[1 + 2 * len(self.dae_variables["constant_inputs"]) + 1] = ca.MX(
1467
+ collocation_times[1:n_collocation_times]
1468
+ )
1469
+
1470
+ path_variables = [None] * len(self.path_variables)
1471
+ for j, variable in enumerate(self.__path_variable_names):
1472
+ variable_size = self.__variable_sizes[variable]
1473
+ values = self.state_vector(variable, ensemble_member=ensemble_member)
1474
+
1475
+ nominal = self.variable_nominal(variable)
1476
+ if isinstance(nominal, np.ndarray):
1477
+ nominal = (
1478
+ np.broadcast_to(nominal, (n_collocation_times, variable_size))
1479
+ .transpose()
1480
+ .ravel()
1481
+ )
1482
+ values *= nominal
1483
+ elif nominal != 1:
1484
+ values *= nominal
1485
+
1486
+ path_variables[j] = values.reshape((n_collocation_times, variable_size))[1:, :]
1487
+
1488
+ path_variables = reduce_matvec(ca.horzcat(*path_variables), self.solver_input)
1489
+
1490
+ accumulation_U[1 + 2 * len(self.dae_variables["constant_inputs"]) + 2] = path_variables
1491
+
1492
+ for j, variable in enumerate(self.__extra_constant_inputs):
1493
+ variable = variable.name()
1494
+ constant_input = extra_constant_inputs[variable]
1495
+ accumulation_U[1 + 2 * len(self.dae_variables["constant_inputs"]) + 3 + j] = ca.MX(
1496
+ constant_input[1:n_collocation_times, :]
1497
+ )
1498
+
1499
+ # Construct matrix using O(states) CasADi operations
1500
+ # This is faster than using blockcat, presumably because of the
1501
+ # row-wise scaling operations.
1502
+ logger.info("Aggregating and de-scaling variables")
1503
+
1504
+ accumulation_U = [var for var in accumulation_U if var.numel() > 0]
1505
+ accumulation_U = ca.transpose(ca.horzcat(*accumulation_U))
1506
+
1507
+ # Map to all time steps
1508
+ logger.info("Mapping")
1509
+
1510
+ # Save these inputs such that can be used later in map_path_expression()
1511
+ self.__func_initial_inputs.append(
1512
+ [
1513
+ parameters,
1514
+ ca.vertcat(
1515
+ initial_state,
1516
+ initial_derivatives,
1517
+ initial_constant_inputs,
1518
+ 0.0,
1519
+ initial_path_variables,
1520
+ initial_extra_constant_inputs,
1521
+ ),
1522
+ extra_variables,
1523
+ ]
1524
+ )
1525
+
1526
+ if accumulation is not None:
1527
+ integrators_and_collocation_and_path_constraints = accumulation(
1528
+ accumulation_X0,
1529
+ accumulation_U,
1530
+ ca.repmat(ca.vertcat(parameters, extra_variables), 1, n_collocation_times - 1),
1531
+ )
1532
+ else:
1533
+ integrators_and_collocation_and_path_constraints = None
1534
+
1535
+ if accumulation is not None and self.integrate_states:
1536
+ integrators = integrators_and_collocation_and_path_constraints[0]
1537
+ integrators_and_collocation_and_path_constraints = (
1538
+ integrators_and_collocation_and_path_constraints[1]
1539
+ )
1540
+ if (
1541
+ accumulation is not None
1542
+ and integrators_and_collocation_and_path_constraints.numel() > 0
1543
+ ):
1544
+ collocation_constraints = ca.vec(
1545
+ integrators_and_collocation_and_path_constraints[
1546
+ :dae_residual_collocated_size, 0 : n_collocation_times - 1
1547
+ ]
1548
+ )
1549
+ discretized_path_objective = ca.vec(
1550
+ integrators_and_collocation_and_path_constraints[
1551
+ dae_residual_collocated_size : dae_residual_collocated_size
1552
+ + path_objective.size1(),
1553
+ 0 : n_collocation_times - 1,
1554
+ ]
1555
+ )
1556
+ discretized_path_constraints = ca.vec(
1557
+ integrators_and_collocation_and_path_constraints[
1558
+ dae_residual_collocated_size
1559
+ + path_objective.size1() : dae_residual_collocated_size
1560
+ + path_objective.size1()
1561
+ + path_constraint_expressions.size1(),
1562
+ 0 : n_collocation_times - 1,
1563
+ ]
1564
+ )
1565
+ discretized_delayed_feedback = integrators_and_collocation_and_path_constraints[
1566
+ dae_residual_collocated_size
1567
+ + path_objective.size1()
1568
+ + path_constraint_expressions.size1() :,
1569
+ 0 : n_collocation_times - 1,
1570
+ ]
1571
+ else:
1572
+ collocation_constraints = ca.MX()
1573
+ discretized_path_objective = ca.MX()
1574
+ discretized_path_constraints = ca.MX()
1575
+ discretized_delayed_feedback = ca.MX()
1576
+
1577
+ logger.info("Composing NLP segment")
1578
+
1579
+ # Store integrators for result extraction
1580
+ if self.integrate_states:
1581
+ # Store integrators for result extraction
1582
+ self.__integrators.append(
1583
+ {
1584
+ variable: integrators[i, :]
1585
+ for i, variable in enumerate(integrated_variable_names)
1586
+ }
1587
+ )
1588
+ else:
1589
+ # Add collocation constraints
1590
+ g.append(collocation_constraints)
1591
+ zeros = np.zeros(collocation_constraints.size1())
1592
+ lbg.extend(zeros)
1593
+ ubg.extend(zeros)
1594
+
1595
+ # Prepare arguments for map_path_expression() calls to ca.map()
1596
+ if len(integrated_variables) + len(collocated_variables) > 0:
1597
+ if self.integrate_states:
1598
+ # Inputs
1599
+ states_and_algebraics_and_controls = ca.vertcat(
1600
+ *[
1601
+ self.variable_nominal(variable)
1602
+ * self.__integrators[ensemble_member][variable]
1603
+ for variable in integrated_variable_names
1604
+ ],
1605
+ interpolated_states[
1606
+ :,
1607
+ len(collocated_variables) :,
1608
+ ].T,
1609
+ )
1610
+ states_and_algebraics_and_controls_derivatives = (
1611
+ (
1612
+ states_and_algebraics_and_controls
1613
+ - ca.horzcat(
1614
+ ensemble_store[ensemble_member]["initial_state"],
1615
+ states_and_algebraics_and_controls[:, :-1],
1616
+ )
1617
+ ).T
1618
+ / (collocation_times[1:] - collocation_times[:-1])
1619
+ ).T
1620
+ else:
1621
+ states_and_algebraics_and_controls = interpolated_states[
1622
+ :, len(collocated_variables) :
1623
+ ].T
1624
+ states_and_algebraics_and_controls_derivatives = (
1625
+ (
1626
+ interpolated_states[:, len(collocated_variables) :]
1627
+ - interpolated_states[:, : len(collocated_variables)]
1628
+ )
1629
+ / (collocation_times[1:] - collocation_times[:-1])
1630
+ ).T
1631
+ else:
1632
+ states_and_algebraics_and_controls = ca.MX()
1633
+ states_and_algebraics_and_controls_derivatives = ca.MX()
1634
+
1635
+ self.__func_map_args.append(
1636
+ [
1637
+ ca.repmat(
1638
+ ca.vertcat(*ensemble_parameter_values[ensemble_member]),
1639
+ 1,
1640
+ n_collocation_times - 1,
1641
+ ),
1642
+ ca.vertcat(
1643
+ states_and_algebraics_and_controls,
1644
+ states_and_algebraics_and_controls_derivatives,
1645
+ *[
1646
+ ca.MX(constant_inputs[variable][1:]).T
1647
+ for variable in dae_constant_inputs_names
1648
+ ],
1649
+ ca.MX(collocation_times[1:]).T,
1650
+ path_variables.T if path_variables.numel() > 0 else ca.MX(),
1651
+ *[
1652
+ ca.MX(extra_constant_inputs[variable][1:]).T
1653
+ for (variable, _) in extra_constant_inputs_name_and_size
1654
+ ],
1655
+ ),
1656
+ ca.repmat(extra_variables, 1, n_collocation_times - 1),
1657
+ ]
1658
+ )
1659
+
1660
+ # Delayed feedback
1661
+ # Make an array of all unique times in history series
1662
+ history_times = np.unique(
1663
+ np.hstack(
1664
+ (np.array([]), *[history_series.times for history_series in history.values()])
1665
+ )
1666
+ )
1667
+ # By convention, the last timestep in history series is the initial time. We drop this
1668
+ # index
1669
+ history_times = history_times[:-1]
1670
+
1671
+ # Find the historical values of states, extrapolating backward if necessary
1672
+ history_values = np.empty(
1673
+ (history_times.shape[0], len(integrated_variables) + len(collocated_variables))
1674
+ )
1675
+ if history_times.shape[0] > 0:
1676
+ for j, var in enumerate(integrated_variables + collocated_variables):
1677
+ var_name = var.name()
1678
+ try:
1679
+ history_series = history[var_name]
1680
+ except KeyError:
1681
+ history_values[:, j] = np.nan
1682
+ else:
1683
+ interpolation_method = self.interpolation_method(var_name)
1684
+ history_values[:, j] = self.interpolate(
1685
+ history_times,
1686
+ history_series.times,
1687
+ history_series.values,
1688
+ np.nan,
1689
+ np.nan,
1690
+ interpolation_method,
1691
+ )
1692
+
1693
+ # Calculate the historical derivatives of historical values
1694
+ history_derivatives = ca.repmat(np.nan, 1, history_values.shape[1])
1695
+ if history_times.shape[0] > 1:
1696
+ history_derivatives = ca.vertcat(
1697
+ history_derivatives,
1698
+ np.diff(history_values, axis=0) / np.diff(history_times)[:, None],
1699
+ )
1700
+
1701
+ # Find the historical values of constant inputs, extrapolating backward if necessary
1702
+ constant_input_values = np.empty(
1703
+ (history_times.shape[0], len(self.dae_variables["constant_inputs"]))
1704
+ )
1705
+ if history_times.shape[0] > 0:
1706
+ for j, var in enumerate(self.dae_variables["constant_inputs"]):
1707
+ var_name = var.name()
1708
+ try:
1709
+ constant_input_series = raw_constant_inputs[var_name]
1710
+ except KeyError:
1711
+ constant_input_values[:, j] = np.nan
1712
+ else:
1713
+ interpolation_method = self.interpolation_method(var_name)
1714
+ constant_input_values[:, j] = self.interpolate(
1715
+ history_times,
1716
+ constant_input_series.times,
1717
+ constant_input_series.values,
1718
+ np.nan,
1719
+ np.nan,
1720
+ interpolation_method,
1721
+ )
1722
+
1723
+ if len(delayed_feedback_expressions) > 0:
1724
+ delayed_feedback_history = np.zeros(
1725
+ (history_times.shape[0], len(delayed_feedback_expressions))
1726
+ )
1727
+ for i, time in enumerate(history_times):
1728
+ history_delayed_feedback_res = delayed_feedback_function.call(
1729
+ [
1730
+ parameters,
1731
+ ca.veccat(
1732
+ ca.transpose(history_values[i, :]),
1733
+ ca.transpose(history_derivatives[i, :]),
1734
+ ca.transpose(constant_input_values[i, :]),
1735
+ time,
1736
+ ca.repmat(np.nan, len(self.path_variables)),
1737
+ ca.repmat(np.nan, len(self.__extra_constant_inputs)),
1738
+ ),
1739
+ ca.repmat(np.nan, len(self.extra_variables)),
1740
+ ]
1741
+ )
1742
+ delayed_feedback_history[i, :] = [
1743
+ float(val) for val in history_delayed_feedback_res
1744
+ ]
1745
+
1746
+ initial_delayed_feedback = delayed_feedback_function.call(
1747
+ self.__func_initial_inputs[ensemble_member], False, True
1748
+ )
1749
+
1750
+ path_variables_nominal = np.ones(path_variables_size)
1751
+ offset = 0
1752
+ for variable in self.__path_variable_names:
1753
+ variable_size = self.__variable_sizes[variable]
1754
+ path_variables_nominal[offset : offset + variable_size] = self.variable_nominal(
1755
+ variable
1756
+ )
1757
+ offset += variable_size
1758
+
1759
+ nominal_delayed_feedback = delayed_feedback_function.call(
1760
+ [
1761
+ parameters,
1762
+ ca.vertcat(
1763
+ [
1764
+ self.variable_nominal(var.name())
1765
+ for var in integrated_variables + collocated_variables
1766
+ ],
1767
+ np.zeros((initial_derivatives.size1(), 1)),
1768
+ initial_constant_inputs,
1769
+ 0.0,
1770
+ path_variables_nominal,
1771
+ initial_extra_constant_inputs,
1772
+ ),
1773
+ extra_variables,
1774
+ ]
1775
+ )
1776
+
1777
+ if delayed_feedback_expressions:
1778
+ # Resolve delay values
1779
+ # First, substitute parameters for values all at once. Make
1780
+ # sure substitute() gets called with the right signature. This
1781
+ # means we need at least one element that is of type MX.
1782
+ delayed_feedback_durations = list(delayed_feedback_durations)
1783
+ delayed_feedback_durations[0] = ca.MX(delayed_feedback_durations[0])
1784
+
1785
+ substituted_delay_durations = ca.substitute(
1786
+ delayed_feedback_durations,
1787
+ [ca.vertcat(symbolic_parameters)],
1788
+ [ca.vertcat(parameters)],
1789
+ )
1790
+
1791
+ # Use mapped function to evaluate delay in terms of constant inputs
1792
+ mapped_delay_function = ca.Function(
1793
+ "delay_values",
1794
+ self.dae_variables["time"] + self.dae_variables["constant_inputs"],
1795
+ substituted_delay_durations,
1796
+ ).map(len(collocation_times))
1797
+
1798
+ # Call mapped delay function with inputs as arrays
1799
+ evaluated_delay_durations = mapped_delay_function.call(
1800
+ [collocation_times]
1801
+ + [constant_inputs[v.name()] for v in self.dae_variables["constant_inputs"]]
1802
+ )
1803
+
1804
+ for i in range(len(delayed_feedback_expressions)):
1805
+ in_variable_name = delayed_feedback_states[i]
1806
+ expression = delayed_feedback_expressions[i]
1807
+ delay = evaluated_delay_durations[i]
1808
+
1809
+ # Resolve aliases
1810
+ in_canonical, in_sign = self.alias_relation.canonical_signed(in_variable_name)
1811
+ in_times = self.times(in_canonical)
1812
+ in_nominal = self.variable_nominal(in_canonical)
1813
+ in_values = in_nominal * self.state_vector(
1814
+ in_canonical, ensemble_member=ensemble_member
1815
+ )
1816
+ if in_sign < 0:
1817
+ in_values *= in_sign
1818
+
1819
+ # Cast delay from DM to np.array
1820
+ delay = delay.toarray().flatten()
1821
+
1822
+ assert np.all(np.isfinite(delay)), (
1823
+ "Delay duration must be resolvable to real values at transcribe()"
1824
+ )
1825
+
1826
+ out_times = np.concatenate([history_times, collocation_times])
1827
+ out_values = ca.veccat(
1828
+ delayed_feedback_history[:, i],
1829
+ initial_delayed_feedback[i],
1830
+ ca.transpose(discretized_delayed_feedback[i, :]),
1831
+ )
1832
+
1833
+ # Check whether enough history has been specified, and that no
1834
+ # needed history values are missing
1835
+ hist_earliest = np.min(collocation_times - delay)
1836
+ hist_start_ind = np.searchsorted(out_times, hist_earliest)
1837
+ if out_times[hist_start_ind] != hist_earliest:
1838
+ # We need an earlier value to interpolate with
1839
+ hist_start_ind -= 1
1840
+
1841
+ if hist_start_ind < 0 or np.any(
1842
+ np.isnan(delayed_feedback_history[hist_start_ind:, i])
1843
+ ):
1844
+ logger.warning(
1845
+ f"Incomplete history for delayed expression {expression}. "
1846
+ "Extrapolating t0 value backwards in time."
1847
+ )
1848
+ out_times = out_times[len(history_times) :]
1849
+ out_values = out_values[len(history_times) :]
1850
+
1851
+ # Set up delay constraints
1852
+ if len(collocation_times) != len(in_times):
1853
+ interpolation_method = self.interpolation_method(in_canonical)
1854
+ x_in = interpolate(
1855
+ in_times, in_values, collocation_times, False, interpolation_method
1856
+ )
1857
+ else:
1858
+ x_in = in_values
1859
+ interpolation_method = self.interpolation_method(in_canonical)
1860
+ x_out_delayed = interpolate(
1861
+ out_times,
1862
+ out_values,
1863
+ collocation_times - delay,
1864
+ False,
1865
+ interpolation_method,
1866
+ )
1867
+
1868
+ nominal = nominal_delayed_feedback[i]
1869
+
1870
+ if self.inline_delay_expressions:
1871
+ # Get the indices for the delayed feedback variable in the optimization
1872
+ # vector
1873
+ indices = self.__indices[ensemble_member][in_canonical]
1874
+
1875
+ # Insert replacement values into appropriate slot
1876
+ delayed_feedback_variable_replacement[indices] = (
1877
+ x_out_delayed / in_nominal / in_sign
1878
+ )
1879
+
1880
+ # Equate delayed feedback variable to zero so that the numerical solver can
1881
+ # remove it from the problem.
1882
+ #
1883
+ # Note: The alternative approach would be to shrink self.solver_input=X,
1884
+ # self.__indices, and any other variables that directly or indirectly
1885
+ # depend on the size and order of 'X'. This would be a complex operation,
1886
+ # which would however be redundant for solvers that (like Ipopt) already
1887
+ # automatically detect and remove variables where lbx==ubx.
1888
+ lbx[indices] = 0.0
1889
+ ubx[indices] = 0.0
1890
+ else:
1891
+ # Default behavior: add delay expressions as equality constraints
1892
+ g.append((x_in - x_out_delayed) / nominal)
1893
+ zeros = np.zeros(n_collocation_times)
1894
+ lbg.extend(zeros)
1895
+ ubg.extend(zeros)
1896
+
1897
+ # Objective
1898
+ f_member = self.objective(ensemble_member)
1899
+ if f_member.size1() == 0:
1900
+ f_member = 0
1901
+ if path_objective.size1() > 0:
1902
+ initial_path_objective = path_objective_function.call(
1903
+ self.__func_initial_inputs[ensemble_member], False, True
1904
+ )
1905
+ f_member += initial_path_objective[0] + ca.sum1(discretized_path_objective)
1906
+ f.append(self.ensemble_member_probability(ensemble_member) * f_member)
1907
+
1908
+ if logger.getEffectiveLevel() == logging.DEBUG:
1909
+ logger.debug(f"Adding objective {f_member}")
1910
+
1911
+ # Constraints
1912
+ constraints = self.constraints(ensemble_member)
1913
+ if constraints is None:
1914
+ raise Exception(
1915
+ "The `constraints` method returned None, but should always return a list."
1916
+ )
1917
+
1918
+ if logger.getEffectiveLevel() == logging.DEBUG:
1919
+ for constraint in constraints:
1920
+ logger.debug("Adding constraint {}, {}, {}".format(*constraint))
1921
+
1922
+ if constraints:
1923
+ g_constraint, lbg_constraint, ubg_constraint = list(zip(*constraints))
1924
+
1925
+ lbg_constraint = list(lbg_constraint)
1926
+ ubg_constraint = list(ubg_constraint)
1927
+
1928
+ # Broadcast lbg/ubg if it's a vector constraint
1929
+ for i, (g_i, lbg_i, ubg_i) in enumerate(
1930
+ zip(g_constraint, lbg_constraint, ubg_constraint)
1931
+ ):
1932
+ s = g_i.size1()
1933
+ if s > 1:
1934
+ if not isinstance(lbg_i, np.ndarray) or lbg_i.shape[0] == 1:
1935
+ lbg_constraint[i] = np.full(s, lbg_i)
1936
+ elif lbg_i.shape[0] != g_i.shape[0]:
1937
+ raise Exception(
1938
+ "Shape mismatch between constraint "
1939
+ f"#{i} ({g_i.shape[0]},) and its lower bound ({lbg_i.shape[0]},)"
1940
+ )
1941
+
1942
+ if not isinstance(ubg_i, np.ndarray) or ubg_i.shape[0] == 1:
1943
+ ubg_constraint[i] = np.full(s, ubg_i)
1944
+ elif ubg_i.shape[0] != g_i.shape[0]:
1945
+ raise Exception(
1946
+ "Shape mismatch between constraint "
1947
+ f"#{i} ({g_i.shape[0]},) and its upper bound ({ubg_i.shape[0]},)"
1948
+ )
1949
+
1950
+ g.extend(g_constraint)
1951
+ lbg.extend(lbg_constraint)
1952
+ ubg.extend(ubg_constraint)
1953
+
1954
+ # Path constraints
1955
+ # We need to call self.path_constraints() again here,
1956
+ # as the bounds may change from ensemble member to member.
1957
+ if ensemble_member > 0:
1958
+ path_constraints = self.path_constraints(ensemble_member)
1959
+
1960
+ if len(path_constraints) > 0:
1961
+ # We need to evaluate the path constraints at t0, as the initial time is not
1962
+ # included in the accumulation.
1963
+ [initial_path_constraints] = path_constraints_function.call(
1964
+ self.__func_initial_inputs[ensemble_member], False, True
1965
+ )
1966
+ g.append(initial_path_constraints)
1967
+ g.append(discretized_path_constraints)
1968
+
1969
+ lbg_path_constraints = np.empty(
1970
+ (path_constraint_expressions.size1(), n_collocation_times)
1971
+ )
1972
+ ubg_path_constraints = np.empty(
1973
+ (path_constraint_expressions.size1(), n_collocation_times)
1974
+ )
1975
+
1976
+ j = 0
1977
+ for path_constraint in path_constraints:
1978
+ if logger.getEffectiveLevel() == logging.DEBUG:
1979
+ logger.debug("Adding path constraint {}, {}, {}".format(*path_constraint))
1980
+
1981
+ s = path_constraint[0].size1()
1982
+
1983
+ lb = path_constraint[1]
1984
+ if isinstance(lb, ca.MX) and not lb.is_constant():
1985
+ [lb] = ca.substitute(
1986
+ [lb], symbolic_parameters, self.__parameter_values_ensemble_member_0
1987
+ )
1988
+ elif isinstance(lb, Timeseries):
1989
+ lb = self.interpolate(
1990
+ collocation_times, lb.times, lb.values, -np.inf, -np.inf
1991
+ ).transpose()
1992
+ elif isinstance(lb, np.ndarray):
1993
+ lb = np.broadcast_to(lb, (n_collocation_times, s)).transpose()
1994
+
1995
+ ub = path_constraint[2]
1996
+ if isinstance(ub, ca.MX) and not ub.is_constant():
1997
+ [ub] = ca.substitute(
1998
+ [ub], symbolic_parameters, self.__parameter_values_ensemble_member_0
1999
+ )
2000
+ elif isinstance(ub, Timeseries):
2001
+ ub = self.interpolate(
2002
+ collocation_times, ub.times, ub.values, np.inf, np.inf
2003
+ ).transpose()
2004
+ elif isinstance(ub, np.ndarray):
2005
+ ub = np.broadcast_to(ub, (n_collocation_times, s)).transpose()
2006
+
2007
+ lbg_path_constraints[j : j + s, :] = lb
2008
+ ubg_path_constraints[j : j + s, :] = ub
2009
+
2010
+ j += s
2011
+
2012
+ lbg.extend(lbg_path_constraints.transpose().ravel())
2013
+ ubg.extend(ubg_path_constraints.transpose().ravel())
2014
+
2015
+ # If inlining delay expressions, carry out the single call to ca.substitute()
2016
+ # A single call is more efficient as it constructs a single ca.Function internally,
2017
+ # rather than one for each element of the list. Also note that we do this _outside_
2018
+ # the ensemble member loop for speed reasons, as each substitute() roughly takes the
2019
+ # same amount of time regardless how many expressions we are replacing.
2020
+ if self.inline_delay_expressions:
2021
+ g = [ca.substitute(ca.vertcat(*g), X, delayed_feedback_variable_replacement)]
2022
+
2023
+ # NLP function
2024
+ logger.info("Creating NLP dictionary")
2025
+
2026
+ nlp = {"x": X, "f": ca.sum1(ca.vertcat(*f)), "g": ca.vertcat(*g)}
2027
+
2028
+ # Done
2029
+ logger.info("Done transcribing problem")
2030
+
2031
+ # Debug check coefficients
2032
+ self.__debug_check_transcribe_linear_coefficients(discrete, lbx, ubx, lbg, ubg, x0, nlp)
2033
+
2034
+ return discrete, lbx, ubx, lbg, ubg, x0, nlp
2035
+
2036
+ def clear_transcription_cache(self):
2037
+ """
2038
+ Clears the DAE ``Function``s that were cached by ``transcribe``.
2039
+ """
2040
+ self.__dae_residual_function_collocated = None
2041
+ self.__integrator_step_function = None
2042
+ self.__initial_residual_with_params_fun_map = None
2043
+
2044
+ def extract_results(self, ensemble_member=0):
2045
+ logger.info("Extracting results")
2046
+
2047
+ # Gather results in a dictionary
2048
+ control_results = self.extract_controls(ensemble_member)
2049
+ state_results = self.extract_states(ensemble_member)
2050
+
2051
+ # Merge dictionaries
2052
+ results = AliasDict(self.alias_relation)
2053
+ results.update(control_results)
2054
+ results.update(state_results)
2055
+
2056
+ logger.info("Done extracting results")
2057
+
2058
+ # Return results dictionary
2059
+ return results
2060
+
2061
+ @property
2062
+ def solver_input(self):
2063
+ return self.__solver_input
2064
+
2065
+ def solver_options(self):
2066
+ options = super().solver_options()
2067
+
2068
+ solver = options["solver"]
2069
+ assert solver in ["bonmin", "ipopt"]
2070
+
2071
+ # Set the option in both cases, to avoid one inadvertently remaining in the cache.
2072
+ options[solver]["jac_c_constant"] = "yes" if self.linear_collocation else "no"
2073
+ return options
2074
+
2075
+ def integrator_options(self):
2076
+ """
2077
+ Configures the implicit function used for time step integration.
2078
+
2079
+ :returns: A dictionary of CasADi :class:`rootfinder` options. See the CasADi documentation
2080
+ for details.
2081
+ """
2082
+ return {}
2083
+
2084
+ @property
2085
+ def controls(self):
2086
+ return self.__controls
2087
+
2088
+ def _collint_get_lbx_ubx(
2089
+ self,
2090
+ bounds: dict[str, BT] | list[dict[str, BT]],
2091
+ count: int,
2092
+ indices: list[dict[str, slice | int]],
2093
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
2094
+ lbx = np.full(count, -np.inf, dtype=np.float64)
2095
+ ubx = np.full(count, np.inf, dtype=np.float64)
2096
+
2097
+ # Variables that are not collocated, and only have a single entry in the state vector
2098
+ scalar_variables_set = set(self.__extra_variable_names) | set(self.__integrated_states)
2099
+
2100
+ variable_sizes = self.__variable_sizes
2101
+
2102
+ # Bounds, defaulting to +/- inf, if not set
2103
+ for ensemble_member in range(self.ensemble_size):
2104
+ if self.ensemble_specific_bounds:
2105
+ bounds_member = bounds[ensemble_member]
2106
+ else:
2107
+ bounds_member = bounds
2108
+
2109
+ for variable, inds in indices[ensemble_member].items():
2110
+ variable_size = variable_sizes[variable]
2111
+
2112
+ if variable in scalar_variables_set:
2113
+ times = self.initial_time
2114
+ n_times = 1
2115
+ else:
2116
+ times = self.times(variable)
2117
+ n_times = len(times)
2118
+
2119
+ try:
2120
+ bound = bounds_member[variable]
2121
+ except KeyError:
2122
+ pass
2123
+ else:
2124
+ nominal = self.variable_nominal(variable)
2125
+ interpolation_method = self.interpolation_method(variable)
2126
+ if isinstance(nominal, np.ndarray):
2127
+ nominal = (
2128
+ np.broadcast_to(nominal, (n_times, variable_size)).transpose().ravel()
2129
+ )
2130
+
2131
+ if bound[0] is not None:
2132
+ if isinstance(bound[0], Timeseries):
2133
+ lower_bound = self.interpolate(
2134
+ times,
2135
+ bound[0].times,
2136
+ bound[0].values,
2137
+ -np.inf,
2138
+ -np.inf,
2139
+ interpolation_method,
2140
+ ).ravel()
2141
+ elif isinstance(bound[0], np.ndarray):
2142
+ lower_bound = (
2143
+ np.broadcast_to(bound[0], (n_times, variable_size))
2144
+ .transpose()
2145
+ .ravel()
2146
+ )
2147
+ else:
2148
+ lower_bound = bound[0]
2149
+ lbx[inds] = np.maximum(lbx[inds], lower_bound / nominal)
2150
+
2151
+ if bound[1] is not None:
2152
+ if isinstance(bound[1], Timeseries):
2153
+ upper_bound = self.interpolate(
2154
+ times,
2155
+ bound[1].times,
2156
+ bound[1].values,
2157
+ +np.inf,
2158
+ +np.inf,
2159
+ interpolation_method,
2160
+ ).ravel()
2161
+ elif isinstance(bound[1], np.ndarray):
2162
+ upper_bound = (
2163
+ np.broadcast_to(bound[1], (n_times, variable_size))
2164
+ .transpose()
2165
+ .ravel()
2166
+ )
2167
+ else:
2168
+ upper_bound = bound[1]
2169
+ ubx[inds] = np.minimum(ubx[inds], upper_bound / nominal)
2170
+
2171
+ # Warn for NaNs
2172
+ if np.any(np.isnan(lbx[inds])):
2173
+ logger.error(f"Lower bound on variable {variable} contains NaN")
2174
+ if np.any(np.isnan(ubx[inds])):
2175
+ logger.error(f"Upper bound on variable {variable} contains NaN")
2176
+
2177
+ # Check that the lower bounds are not higher than the upper
2178
+ # bounds. To avoid spam, we just log the first offending one per
2179
+ # variable, not _all_ time steps.
2180
+ if np.any(lbx[inds] > ubx[inds]):
2181
+ error_inds = np.where(lbx[inds] > ubx[inds])[0].tolist()
2182
+ logger.error(
2183
+ f"Lower bound {lbx[inds][error_inds[0]] * nominal} is higher than "
2184
+ f"upper bound {ubx[inds][error_inds[0]] * nominal} for variable {variable}"
2185
+ )
2186
+
2187
+ return lbx, ubx
2188
+
2189
+ def _collint_get_x0(self, count, indices):
2190
+ x0 = np.zeros(count, dtype=np.float64)
2191
+
2192
+ # Variables that are not collocated, and only have a single entry in the state vector
2193
+ scalar_variables_set = set(self.__extra_variable_names) | set(self.__integrated_states)
2194
+
2195
+ variable_sizes = self.__variable_sizes
2196
+
2197
+ for ensemble_member in range(self.ensemble_size):
2198
+ seed = self.seed(ensemble_member)
2199
+ for variable, inds in indices[ensemble_member].items():
2200
+ variable_size = variable_sizes[variable]
2201
+
2202
+ if variable in scalar_variables_set:
2203
+ times = self.initial_time
2204
+ n_times = 1
2205
+ else:
2206
+ times = self.times(variable)
2207
+ n_times = len(times)
2208
+
2209
+ try:
2210
+ seed_k = seed[variable]
2211
+ nominal = self.variable_nominal(variable)
2212
+ interpolation_method = self.interpolation_method(variable)
2213
+ if isinstance(nominal, np.ndarray):
2214
+ nominal = (
2215
+ np.broadcast_to(nominal, (n_times, variable_size)).transpose().ravel()
2216
+ )
2217
+
2218
+ if isinstance(seed_k, Timeseries):
2219
+ seed_k = (
2220
+ self.interpolate(
2221
+ times, seed_k.times, seed_k.values, 0, 0, interpolation_method
2222
+ )
2223
+ .transpose()
2224
+ .ravel()
2225
+ )
2226
+ if isinstance(inds, int) and isinstance(seed_k, np.ndarray):
2227
+ seed_k = seed_k.item()
2228
+ x0[inds] = seed_k
2229
+
2230
+ x0[inds] /= nominal
2231
+ except KeyError:
2232
+ pass
2233
+ return x0
2234
+
2235
+ def _collint_get_discrete(self, count, indices):
2236
+ discrete = np.zeros(count, dtype=bool)
2237
+
2238
+ for ensemble_member in range(self.ensemble_size):
2239
+ for variable, inds in indices[ensemble_member].items():
2240
+ discrete[inds] = self.variable_is_discrete(variable)
2241
+
2242
+ return discrete
2243
+
2244
+ def discretize_control(self, variable, ensemble_member, times, offset):
2245
+ # Default implementation: One single set of control inputs for all
2246
+ # ensembles
2247
+ try:
2248
+ return self.__discretize_control_cache[variable]
2249
+ except KeyError:
2250
+ control_indices = slice(offset, offset + len(times))
2251
+ self.__discretize_control_cache[variable] = control_indices
2252
+ return control_indices
2253
+
2254
+ def discretize_controls(self, bounds):
2255
+ self.__discretize_control_cache = {}
2256
+
2257
+ indices = [{} for ensemble_member in range(self.ensemble_size)]
2258
+
2259
+ count = 0
2260
+ for variable in self.controls:
2261
+ times = self.times(variable)
2262
+
2263
+ for ensemble_member in range(self.ensemble_size):
2264
+ control_indices = self.discretize_control(variable, ensemble_member, times, count)
2265
+ indices[ensemble_member][variable] = control_indices
2266
+ control_indices_stop = (
2267
+ control_indices.stop
2268
+ if isinstance(control_indices, slice)
2269
+ else (int(np.max(control_indices)) + 1)
2270
+ ) # indices need not be ordered
2271
+ count = max(count, control_indices_stop)
2272
+
2273
+ discrete = self._collint_get_discrete(count, indices)
2274
+ lbx, ubx = self._collint_get_lbx_ubx(bounds, count, indices)
2275
+ x0 = self._collint_get_x0(count, indices)
2276
+
2277
+ # Return number of control variables
2278
+ return count, discrete, lbx, ubx, x0, indices
2279
+
2280
+ def extract_controls(self, ensemble_member=0):
2281
+ X = self.solver_output.copy()
2282
+
2283
+ indices = self.__indices[ensemble_member]
2284
+
2285
+ results = {}
2286
+ for variable in self.controls:
2287
+ inds = indices[variable]
2288
+ results[variable] = self.variable_nominal(variable) * X[inds]
2289
+
2290
+ return results
2291
+
2292
+ def control_at(self, variable, t, ensemble_member=0, scaled=False, extrapolate=True):
2293
+ canonical, sign = self.alias_relation.canonical_signed(variable)
2294
+
2295
+ if canonical not in self.__controls_map:
2296
+ raise KeyError(variable)
2297
+
2298
+ return self.state_at(variable, t, ensemble_member, scaled, extrapolate)
2299
+
2300
+ @property
2301
+ def differentiated_states(self):
2302
+ return self.__differentiated_states
2303
+
2304
+ @property
2305
+ def algebraic_states(self):
2306
+ return self.__algebraic_states
2307
+
2308
+ def discretize_states(self, bounds):
2309
+ # Default implementation: States for all ensemble members
2310
+ variable_sizes = self.__variable_sizes
2311
+
2312
+ # Space for collocated states
2313
+ ensemble_member_size = 0
2314
+ if self.integrate_states:
2315
+ n_model_states = len(self.differentiated_states) + len(self.algebraic_states)
2316
+ if len(self.__integrated_states) != n_model_states:
2317
+ error_msg = (
2318
+ "CollocatedIntegratedOptimizationProblem: "
2319
+ "integrated_states should specify all model states, or none at all"
2320
+ )
2321
+ logger.error(error_msg)
2322
+ raise Exception(error_msg)
2323
+
2324
+ # Count initial states only
2325
+ ensemble_member_size += n_model_states
2326
+ else:
2327
+ # Count discretised states over optimization horizon
2328
+ for variable in itertools.chain(self.differentiated_states, self.algebraic_states):
2329
+ ensemble_member_size += variable_sizes[variable] * len(self.times(variable))
2330
+ # Count any additional path variables (which cannot be integrated)
2331
+ for variable in self.__path_variable_names:
2332
+ ensemble_member_size += variable_sizes[variable] * len(self.times(variable))
2333
+
2334
+ # Space for extra variables
2335
+ for variable in self.__extra_variable_names:
2336
+ ensemble_member_size += variable_sizes[variable]
2337
+
2338
+ # Space for initial states and derivatives
2339
+ ensemble_member_size += len(self.dae_variables["derivatives"])
2340
+
2341
+ # Total space requirement
2342
+ count = self.ensemble_size * ensemble_member_size
2343
+
2344
+ # Allocate arrays
2345
+ indices = [{} for ensemble_member in range(self.ensemble_size)]
2346
+
2347
+ for ensemble_member in range(self.ensemble_size):
2348
+ offset = ensemble_member * ensemble_member_size
2349
+ for variable in itertools.chain(self.differentiated_states, self.algebraic_states):
2350
+ variable_size = variable_sizes[variable]
2351
+
2352
+ if self.integrate_states:
2353
+ assert variable_size == 1
2354
+ indices[ensemble_member][variable] = offset
2355
+
2356
+ offset += 1
2357
+ else:
2358
+ times = self.times(variable)
2359
+ n_times = len(times)
2360
+
2361
+ indices[ensemble_member][variable] = slice(
2362
+ offset, offset + n_times * variable_size
2363
+ )
2364
+
2365
+ offset += n_times * variable_size
2366
+
2367
+ for variable in self.__path_variable_names:
2368
+ variable_size = variable_sizes[variable]
2369
+
2370
+ times = self.times(variable)
2371
+ n_times = len(times)
2372
+
2373
+ indices[ensemble_member][variable] = slice(offset, offset + n_times * variable_size)
2374
+
2375
+ offset += n_times * variable_size
2376
+
2377
+ for extra_variable in self.__extra_variable_names:
2378
+ variable_size = variable_sizes[extra_variable]
2379
+
2380
+ indices[ensemble_member][extra_variable] = slice(offset, offset + variable_size)
2381
+
2382
+ offset += variable_size
2383
+
2384
+ for initial_der_name in self.__initial_derivative_names:
2385
+ indices[ensemble_member][initial_der_name] = offset
2386
+
2387
+ offset += 1
2388
+
2389
+ discrete = self._collint_get_discrete(count, indices)
2390
+ lbx, ubx = self._collint_get_lbx_ubx(bounds, count, indices)
2391
+ x0 = self._collint_get_x0(count, indices)
2392
+
2393
+ # Return number of state variables
2394
+ return count, discrete, lbx, ubx, x0, indices
2395
+
2396
+ def extract_states(self, ensemble_member=0):
2397
+ # Solver output
2398
+ X = self.solver_output.copy()
2399
+
2400
+ indices = self.__indices[ensemble_member]
2401
+
2402
+ # Extract control inputs
2403
+ results = {}
2404
+
2405
+ # Perform integration, in order to extract integrated variables
2406
+ # We bundle all integrations into a single Function, so that subexpressions
2407
+ # are evaluated only once.
2408
+ if self.integrate_states:
2409
+ # Use integrators to facilitate common subexpression
2410
+ # elimination.
2411
+ f = ca.Function(
2412
+ "f",
2413
+ [self.solver_input],
2414
+ [
2415
+ ca.vertcat(
2416
+ *[
2417
+ self.__integrators[ensemble_member][variable]
2418
+ for variable in self.__integrated_states
2419
+ ]
2420
+ )
2421
+ ],
2422
+ )
2423
+ integrators_output = f(X)
2424
+ j = 0
2425
+ for variable in self.__integrated_states:
2426
+ inds = indices[variable]
2427
+ initial_value = X[inds]
2428
+ n = self.__integrators[ensemble_member][variable].size1()
2429
+ results[variable] = self.variable_nominal(variable) * np.concatenate(
2430
+ [[initial_value], np.array(integrators_output[j : j + n, :]).ravel()]
2431
+ )
2432
+ j += n
2433
+
2434
+ # Extract initial derivatives
2435
+ for initial_der_name in self.__initial_derivative_names:
2436
+ inds = indices[initial_der_name]
2437
+
2438
+ try:
2439
+ nominal = self.variable_nominal(initial_der_name)
2440
+ results[initial_der_name] = nominal * X[inds].ravel()
2441
+ except KeyError:
2442
+ pass
2443
+
2444
+ # Extract all other variables
2445
+ variable_sizes = self.__variable_sizes
2446
+
2447
+ for variable in itertools.chain(
2448
+ self.differentiated_states,
2449
+ self.algebraic_states,
2450
+ self.__path_variable_names,
2451
+ self.__extra_variable_names,
2452
+ ):
2453
+ if variable in results:
2454
+ continue
2455
+
2456
+ inds = indices[variable]
2457
+ variable_size = variable_sizes[variable]
2458
+
2459
+ if variable_size > 1:
2460
+ results[variable] = X[inds].reshape((variable_size, -1)).transpose()
2461
+ else:
2462
+ results[variable] = X[inds]
2463
+
2464
+ results[variable] = self.variable_nominal(variable) * results[variable]
2465
+
2466
+ # Extract constant input aliases
2467
+ constant_inputs = self.constant_inputs(ensemble_member)
2468
+ for variable in self.dae_variables["constant_inputs"]:
2469
+ variable = variable.name()
2470
+ try:
2471
+ constant_input = constant_inputs[variable]
2472
+ except KeyError:
2473
+ pass
2474
+ else:
2475
+ results[variable] = np.interp(
2476
+ self.times(variable), constant_input.times, constant_input.values
2477
+ )
2478
+
2479
+ return results
2480
+
2481
+ def state_vector(self, variable, ensemble_member=0):
2482
+ indices = self.__indices[ensemble_member][variable]
2483
+ return self.solver_input[indices]
2484
+
2485
+ def state_at(self, variable, t, ensemble_member=0, scaled=False, extrapolate=True):
2486
+ if isinstance(variable, ca.MX):
2487
+ variable = variable.name()
2488
+
2489
+ if self.__variable_sizes.get(variable, 1) > 1:
2490
+ raise NotImplementedError("state_at() not supported for vector states")
2491
+
2492
+ name = "{}[{},{}]{}".format(
2493
+ variable, ensemble_member, t - self.initial_time, "S" if scaled else ""
2494
+ )
2495
+ if extrapolate:
2496
+ name += "E"
2497
+ try:
2498
+ return self.__symbol_cache[name]
2499
+ except KeyError:
2500
+ # Look up transcribe_problem() state.
2501
+ t0 = self.initial_time
2502
+ X = self.solver_input
2503
+
2504
+ # Fetch appropriate symbol, or value.
2505
+ canonical, sign = self.alias_relation.canonical_signed(variable)
2506
+ found = False
2507
+
2508
+ # Check if it is in the state vector
2509
+ try:
2510
+ inds = self.__indices[ensemble_member][canonical]
2511
+ except KeyError:
2512
+ pass
2513
+ else:
2514
+ times = self.times(canonical)
2515
+
2516
+ if self.integrate_states:
2517
+ nominal = 1
2518
+ if t == self.initial_time:
2519
+ sym = sign * X[inds]
2520
+ found = True
2521
+ else:
2522
+ variable_values = ca.horzcat(
2523
+ sign * X[inds], self.__integrators[ensemble_member][canonical]
2524
+ ).T
2525
+ else:
2526
+ nominal = self.variable_nominal(canonical)
2527
+ variable_values = X[inds]
2528
+
2529
+ if not found:
2530
+ f_left, f_right = np.nan, np.nan
2531
+ if t < t0:
2532
+ history = self.history(ensemble_member)
2533
+ try:
2534
+ history_timeseries = history[canonical]
2535
+ except KeyError:
2536
+ if extrapolate:
2537
+ sym = variable_values[0]
2538
+ else:
2539
+ sym = np.nan
2540
+ else:
2541
+ if extrapolate:
2542
+ f_left = history_timeseries.values[0]
2543
+ f_right = history_timeseries.values[-1]
2544
+ interpolation_method = self.interpolation_method(canonical)
2545
+ sym = self.interpolate(
2546
+ t,
2547
+ history_timeseries.times,
2548
+ history_timeseries.values,
2549
+ f_left,
2550
+ f_right,
2551
+ interpolation_method,
2552
+ )
2553
+ if scaled and nominal != 1:
2554
+ sym /= nominal
2555
+ else:
2556
+ if not extrapolate and (t < times[0] or t > times[-1]):
2557
+ raise Exception(
2558
+ f"Cannot interpolate for {canonical}: "
2559
+ f"Point {t} outside of range [{times[0]}, {times[-1]}]"
2560
+ )
2561
+
2562
+ interpolation_method = self.interpolation_method(canonical)
2563
+ sym = interpolate(times, variable_values, [t], False, interpolation_method)
2564
+ if not scaled and nominal != 1:
2565
+ sym *= nominal
2566
+ if sign < 0:
2567
+ sym *= -1
2568
+ found = True
2569
+
2570
+ if not found:
2571
+ constant_inputs = self.constant_inputs(ensemble_member)
2572
+ try:
2573
+ constant_input = constant_inputs[variable]
2574
+ found = True
2575
+ except KeyError:
2576
+ pass
2577
+ else:
2578
+ times = self.times(variable)
2579
+ f_left, f_right = np.nan, np.nan
2580
+ if extrapolate:
2581
+ f_left = constant_input.values[0]
2582
+ f_right = constant_input.values[-1]
2583
+ interpolation_method = self.interpolation_method(variable)
2584
+ sym = self.interpolate(
2585
+ t,
2586
+ constant_input.times,
2587
+ constant_input.values,
2588
+ f_left,
2589
+ f_right,
2590
+ interpolation_method,
2591
+ )
2592
+ if not found:
2593
+ parameters = self.parameters(ensemble_member)
2594
+ try:
2595
+ sym = parameters[variable]
2596
+ found = True
2597
+ except KeyError:
2598
+ pass
2599
+ if not found:
2600
+ raise KeyError(variable)
2601
+
2602
+ # Cache symbol.
2603
+ self.__symbol_cache[name] = sym
2604
+
2605
+ return sym
2606
+
2607
+ def variable(self, variable):
2608
+ return self.__variables[variable]
2609
+
2610
+ def variable_nominal(self, variable):
2611
+ try:
2612
+ return self.__initial_derivative_nominals[variable]
2613
+ except KeyError:
2614
+ return super().variable_nominal(variable)
2615
+
2616
+ def extra_variable(self, extra_variable, ensemble_member=0):
2617
+ indices = self.__indices[ensemble_member][extra_variable]
2618
+ return self.solver_input[indices] * self.variable_nominal(extra_variable)
2619
+
2620
+ def __states_times_in(self, variable, t0=None, tf=None, ensemble_member=0):
2621
+ # Time stamps for this variable
2622
+ times = self.times(variable)
2623
+
2624
+ # Set default values
2625
+ if t0 is None:
2626
+ t0 = times[0]
2627
+ if tf is None:
2628
+ tf = times[-1]
2629
+
2630
+ # Find canonical variable
2631
+ canonical, sign = self.alias_relation.canonical_signed(variable)
2632
+ nominal = self.variable_nominal(canonical)
2633
+ state = self.state_vector(canonical, ensemble_member)
2634
+ if self.integrate_states and canonical in self.__integrators[ensemble_member]:
2635
+ state = ca.vertcat(state, ca.transpose(self.__integrators[ensemble_member][canonical]))
2636
+ state *= nominal
2637
+ if sign < 0:
2638
+ state *= -1
2639
+
2640
+ # Compute combined points
2641
+ if t0 < times[0]:
2642
+ history = self.history(ensemble_member)
2643
+ try:
2644
+ history_timeseries = history[canonical]
2645
+ except KeyError:
2646
+ raise Exception(
2647
+ f"No history found for variable {variable}, "
2648
+ "but a historical value was requested"
2649
+ )
2650
+ else:
2651
+ history_times = history_timeseries.times[:-1]
2652
+ history = history_timeseries.values[:-1]
2653
+ if sign < 0:
2654
+ history *= -1
2655
+ else:
2656
+ history_times = np.empty(0)
2657
+ history = np.empty(0)
2658
+
2659
+ # Collect time stamps and states, "knots".
2660
+ (indices,) = np.where(np.logical_and(times >= t0, times <= tf))
2661
+ (history_indices,) = np.where(np.logical_and(history_times >= t0, history_times <= tf))
2662
+ if (t0 not in times[indices]) and (t0 not in history_times[history_indices]):
2663
+ x0 = self.state_at(variable, t0, ensemble_member)
2664
+ else:
2665
+ t0 = x0 = ca.MX()
2666
+ if (tf not in times[indices]) and (tf not in history_times[history_indices]):
2667
+ xf = self.state_at(variable, tf, ensemble_member)
2668
+ else:
2669
+ tf = xf = ca.MX()
2670
+ t = ca.vertcat(t0, history_times[history_indices], times[indices], tf)
2671
+ x = ca.vertcat(x0, history[history_indices], state[indices], xf)
2672
+
2673
+ return x, t
2674
+
2675
+ def states_in(
2676
+ self,
2677
+ variable: str,
2678
+ t0: float | None = None,
2679
+ tf: float | None = None,
2680
+ ensemble_member: int = 0,
2681
+ *,
2682
+ return_times: bool = False,
2683
+ ) -> ca.MX | tuple[ca.DM, ca.MX]:
2684
+ x, t = self.__states_times_in(variable, t0, tf, ensemble_member)
2685
+
2686
+ if return_times:
2687
+ return x, t
2688
+ else:
2689
+ return x
2690
+
2691
+ def integral(self, variable, t0=None, tf=None, ensemble_member=0):
2692
+ x, t = self.__states_times_in(variable, t0, tf, ensemble_member)
2693
+
2694
+ if x.size1() > 1:
2695
+ # Integrate knots using trapezoid rule
2696
+ x_avg = 0.5 * (x[: x.size1() - 1] + x[1:])
2697
+ dt = t[1:] - t[: x.size1() - 1]
2698
+ return ca.sum1(x_avg * dt)
2699
+ else:
2700
+ return ca.MX(0)
2701
+
2702
+ def der(self, variable):
2703
+ # Look up the derivative variable for the given non-derivative variable
2704
+ canonical, sign = self.alias_relation.canonical_signed(variable)
2705
+ try:
2706
+ i = self.__differentiated_states_map[canonical]
2707
+ return sign * self.dae_variables["derivatives"][i]
2708
+ except KeyError:
2709
+ try:
2710
+ i = self.__algebraic_states_map[canonical]
2711
+ except KeyError:
2712
+ i = len(self.algebraic_states) + self.__controls_map[canonical]
2713
+ return sign * self.__algebraic_and_control_derivatives[i]
2714
+
2715
+ def der_at(self, variable, t, ensemble_member=0):
2716
+ # Special case t being t0 for differentiated states
2717
+ if t == self.initial_time:
2718
+ # We have a special symbol for t0 derivatives
2719
+ X = self.solver_input
2720
+
2721
+ canonical, sign = self.alias_relation.canonical_signed(variable)
2722
+ try:
2723
+ i = self.__differentiated_states_map[canonical]
2724
+ except KeyError:
2725
+ # Fall through, in case 'variable' is not a differentiated state.
2726
+ pass
2727
+ else:
2728
+ initial_der_name = self.__initial_derivative_names[i]
2729
+ nominal = self.variable_nominal(initial_der_name)
2730
+ idx = self.__indices[ensemble_member][initial_der_name]
2731
+
2732
+ return nominal * sign * X[idx]
2733
+
2734
+ # Time stamps for this variable
2735
+ times = self.times(variable)
2736
+
2737
+ if t <= self.initial_time:
2738
+ # Derivative requested for t0 or earlier. We need the history.
2739
+ history = self.history(ensemble_member)
2740
+ try:
2741
+ htimes = history[variable].times[:-1]
2742
+ history_and_times = np.hstack((htimes, times))
2743
+ except KeyError:
2744
+ history_and_times = times
2745
+ else:
2746
+ history_and_times = times
2747
+
2748
+ # Special case t being the initial available point. In this case, we have
2749
+ # no derivative information available.
2750
+ if t == history_and_times[0]:
2751
+ return 0.0
2752
+
2753
+ # Handle t being an interior point, or t0 for a non-differentiated
2754
+ # state
2755
+ for i in range(len(history_and_times)):
2756
+ # Use finite differences when between collocation points, and
2757
+ # backward finite differences when on one.
2758
+ if t > history_and_times[i] and t <= history_and_times[i + 1]:
2759
+ dx = self.state_at(
2760
+ variable, history_and_times[i + 1], ensemble_member=ensemble_member
2761
+ ) - self.state_at(variable, history_and_times[i], ensemble_member=ensemble_member)
2762
+ dt = history_and_times[i + 1] - history_and_times[i]
2763
+ return dx / dt
2764
+
2765
+ # t does not belong to any collocation point interval
2766
+ raise IndexError
2767
+
2768
+ def map_path_expression(self, expr, ensemble_member):
2769
+ f = ca.Function("f", self.__func_orig_inputs, [expr]).expand()
2770
+ initial_values = f(*self.__func_initial_inputs[ensemble_member])
2771
+
2772
+ # Map
2773
+ number_of_timeslots = len(self.times())
2774
+ if number_of_timeslots > 1:
2775
+ fmap = f.map(number_of_timeslots - 1)
2776
+ values = fmap(*self.__func_map_args[ensemble_member])
2777
+
2778
+ all_values = ca.horzcat(initial_values, values)
2779
+ else:
2780
+ all_values = initial_values
2781
+
2782
+ return ca.transpose(all_values)
2783
+
2784
+ def solver_success(self, *args, **kwargs):
2785
+ self.__debug_check_state_output_scaling()
2786
+
2787
+ return super().solver_success(*args, **kwargs)
2788
+
2789
+ def _debug_get_named_nlp(self, nlp):
2790
+ x = nlp["x"]
2791
+ f = nlp["f"]
2792
+ g = nlp["g"]
2793
+
2794
+ expand_f_g = ca.Function("f_g", [x], [f, g]).expand()
2795
+ x_sx = ca.SX.sym("X", *x.shape)
2796
+ f_sx, g_sx = expand_f_g(x_sx)
2797
+
2798
+ x, f, g = x_sx, f_sx, g_sx
2799
+
2800
+ # Build a vector of symbols with the descriptive names for useful
2801
+ # logging of constraints. Some decision variables may be shared
2802
+ # between ensemble members, so first we build a complete mapping of
2803
+ # state_index -> (canonical name, ensemble members, time step index)
2804
+ state_index_map = {}
2805
+ for ensemble_member in range(self.ensemble_size):
2806
+ indices = self.__indices_as_lists[ensemble_member]
2807
+ for k, v in indices.items():
2808
+ for t_i, i in enumerate(v):
2809
+ if i in state_index_map:
2810
+ # Shared state vector entry between ensemble members
2811
+ assert k == state_index_map[i][0]
2812
+ assert t_i == state_index_map[i][2]
2813
+
2814
+ state_index_map[i][1].append(ensemble_member)
2815
+ else:
2816
+ state_index_map[i] = [k, [ensemble_member], t_i]
2817
+
2818
+ assert len(state_index_map) == x.size1()
2819
+
2820
+ # Build descriptive decision variables for each state vector entry
2821
+ var_names = []
2822
+ for i in range(len(state_index_map)):
2823
+ var_name, ensemble_members, t_i = state_index_map[i]
2824
+
2825
+ if len(ensemble_members) == 1:
2826
+ ensemble_members = ensemble_members[0]
2827
+ else:
2828
+ ensemble_members = "[{}]".format(",".join(str(x) for x in sorted(ensemble_members)))
2829
+
2830
+ var_names.append(f"{var_name}__e{ensemble_members}__t{t_i}")
2831
+
2832
+ # Create named versions of the constraints
2833
+ named_x = ca.vertcat(*(ca.SX.sym(v) for v in var_names))
2834
+ named_g = ca.vertsplit(ca.Function("tmp", [x], [g])(named_x))
2835
+ named_f = ca.vertsplit(ca.Function("tmp", [x], [f])(named_x))[0]
2836
+
2837
+ return var_names, named_x, named_f, named_g
2838
+
2839
+ @debug_check(DebugLevel.VERYHIGH)
2840
+ def __debug_check_transcribe_linear_coefficients(
2841
+ self,
2842
+ discrete,
2843
+ lbx,
2844
+ ubx,
2845
+ lbg,
2846
+ ubg,
2847
+ x0,
2848
+ nlp,
2849
+ tol_rhs=1e6,
2850
+ tol_zero=1e-12,
2851
+ tol_up=1e2,
2852
+ tol_down=1e-2,
2853
+ tol_range=1e3,
2854
+ evaluate_at_x0=False,
2855
+ ):
2856
+ nlp = nlp.copy()
2857
+
2858
+ expand_f_g = ca.Function("f_g", [nlp["x"]], [nlp["f"], nlp["g"]]).expand()
2859
+ X_sx = ca.SX.sym("X", *nlp["x"].shape)
2860
+ f_sx, g_sx = expand_f_g(X_sx)
2861
+
2862
+ nlp["x"] = X_sx
2863
+ nlp["f"] = f_sx
2864
+ nlp["g"] = g_sx
2865
+
2866
+ lbg = np.array(ca.veccat(*lbg)).ravel()
2867
+ ubg = np.array(ca.veccat(*ubg)).ravel()
2868
+
2869
+ var_names, named_x, named_f, named_g = self._debug_get_named_nlp(nlp)
2870
+
2871
+ def constr_to_str(i):
2872
+ c_str = str(named_g[i])
2873
+
2874
+ lb, ub = lbg[i], ubg[i]
2875
+
2876
+ if np.isfinite(lb) and np.isfinite(ub) and lb == ub:
2877
+ c_str = f"{c_str} = {lb}"
2878
+ elif np.isfinite(lb) and np.isfinite(ub):
2879
+ c_str = f"{lb} <= {c_str} <= {ub}"
2880
+ elif np.isfinite(lb):
2881
+ c_str = f"{c_str} >= {lb}"
2882
+ elif np.isfinite(ub):
2883
+ c_str = f"{c_str} <= {ub}"
2884
+
2885
+ return c_str
2886
+
2887
+ # Checking for right hand side of constraints
2888
+ logger.info(f"Sanity check of lbg and ubg, checking for small values (<{tol_zero})")
2889
+
2890
+ lbg_abs_no_zero = np.abs(lbg.copy())
2891
+ lbg_abs_no_zero[lbg_abs_no_zero == 0.0] = +np.inf
2892
+ ind = np.argmin(lbg_abs_no_zero)
2893
+ if np.any(np.isfinite(lbg_abs_no_zero)):
2894
+ logger.info(f"Smallest (absolute) lbg coefficient {lbg_abs_no_zero[ind]}")
2895
+ logger.info(f"E.g., {constr_to_str(ind)}")
2896
+ lbg_inds = lbg_abs_no_zero < tol_zero
2897
+ if np.any(lbg_inds):
2898
+ logger.info(f"Too small of a (absolute) lbg found: {min(lbg[lbg_inds])}")
2899
+
2900
+ ubg_abs_no_zero = np.abs(ubg.copy())
2901
+ ubg_abs_no_zero[ubg_abs_no_zero == 0.0] = +np.inf
2902
+ ind = np.argmin(ubg_abs_no_zero)
2903
+ if np.any(np.isfinite(ubg_abs_no_zero)):
2904
+ logger.info(f"Smallest (absolute) ubg coefficient {ubg_abs_no_zero[ind]}")
2905
+ logger.info(f"E.g., {constr_to_str(ind)}")
2906
+ ubg_inds = ubg_abs_no_zero < tol_zero
2907
+ if np.any(ubg_inds):
2908
+ logger.info(f"Too small of a (absolute) ubg found: {min(ubg[ubg_inds])}")
2909
+
2910
+ logger.info(f"Sanity check of lbg and ubg, checking for large values (>{tol_rhs})")
2911
+
2912
+ lbg_abs_no_inf = np.abs(lbg.copy())
2913
+ lbg_abs_no_inf[~np.isfinite(lbg_abs_no_inf)] = -np.inf
2914
+ ind = np.argmax(lbg_abs_no_inf)
2915
+ if np.any(np.isfinite(lbg_abs_no_inf)):
2916
+ logger.info(f"Largest (absolute) lbg coefficient {lbg_abs_no_inf[ind]}")
2917
+ logger.info(f"E.g., {constr_to_str(ind)}")
2918
+
2919
+ lbg_inds = lbg_abs_no_inf > tol_rhs
2920
+ if np.any(lbg_inds):
2921
+ raise Exception(f"Too large of a (absolute) lbg found: {max(lbg[lbg_inds])}")
2922
+
2923
+ ubg_abs_no_inf = np.abs(ubg.copy())
2924
+ ubg_abs_no_inf[~np.isfinite(ubg)] = -np.inf
2925
+ ind = np.argmax(ubg_abs_no_inf)
2926
+ if np.any(np.isfinite(ubg_abs_no_inf)):
2927
+ logger.info(f"Largest (absolute) ubg coefficient {ubg_abs_no_inf[ind]}")
2928
+ logger.info(f"E.g., {constr_to_str(ind)}")
2929
+
2930
+ ubg_inds = ubg_abs_no_inf > tol_rhs
2931
+ if np.any(ubg_inds):
2932
+ raise Exception(f"Too large of a (absolute) ubg found: {max(ubg[ubg_inds])}")
2933
+
2934
+ eval_point = x0 if evaluate_at_x0 else 1.0
2935
+ eval_point_str = "x0" if evaluate_at_x0 else "1.0"
2936
+
2937
+ # Check coefficient matrix
2938
+ logger.info(
2939
+ "Sanity check on objective and constraints Jacobian matrix/constant coefficients values"
2940
+ )
2941
+
2942
+ in_var = nlp["x"]
2943
+ out = []
2944
+ for o in [nlp["f"], nlp["g"]]:
2945
+ Af = ca.Function("Af", [in_var], [ca.jacobian(o, in_var)])
2946
+ bf = ca.Function("bf", [in_var], [o])
2947
+
2948
+ A = Af(eval_point)
2949
+ A = ca.sparsify(A)
2950
+
2951
+ b = bf(0)
2952
+ b = ca.sparsify(b)
2953
+
2954
+ out.append((A.tocsc().tocoo(), b.tocsc().tocoo()))
2955
+
2956
+ # Objective
2957
+ A_obj, b_obj = out[0]
2958
+ logger.info(
2959
+ f"Statistics of objective: max & min of abs(jac(f, {eval_point_str}))) "
2960
+ f"f({eval_point_str}), constants"
2961
+ )
2962
+ max_obj_A = max(np.abs(A_obj.data), default=None)
2963
+ min_obj_A = min(np.abs(A_obj.data[A_obj.data != 0.0]), default=None)
2964
+ obj_x0 = np.array(ca.Function("tmp", [in_var], [nlp["f"]])(eval_point)).ravel()[0]
2965
+ obj_b = b_obj.data[0] if len(b_obj.data) > 0 else 0.0
2966
+
2967
+ logger.info(f"{max_obj_A} & {min_obj_A}, {obj_x0}, {obj_b}")
2968
+
2969
+ if abs(obj_b) > tol_up:
2970
+ logger.info(f"Constant '{obj_b}' in objective exceeds upper tolerance of '{tol_up}'")
2971
+ if abs(obj_b) > tol_up:
2972
+ logger.info(f"Objective value at x0 '{obj_x0}' exceeds upper tolerance of '{tol_up}'")
2973
+
2974
+ # Constraints
2975
+ A_constr, b_constr = out[1]
2976
+ logger.info(
2977
+ "Statistics of constraints: max & min of abs(jac(g, x0))), max & min of abs(g(x0))"
2978
+ )
2979
+ max_constr_A = max(np.abs(A_constr.data), default=None)
2980
+ min_constr_A = min(np.abs(A_constr.data[A_constr.data != 0.0]), default=None)
2981
+ max_constr_b = max(np.abs(b_constr.data), default=None)
2982
+ min_constr_b = min(np.abs(b_constr.data[b_constr.data != 0.0]), default=None)
2983
+ logger.info(f"{max_constr_A} & {min_constr_A}, {max_constr_b} & {min_constr_b}")
2984
+
2985
+ # Filter out exactly zero, as those entries do not show up in the
2986
+ # matrix. Shut up SonarCloud warning about this exact-to-zero
2987
+ # comparison.
2988
+ maxs = [
2989
+ x
2990
+ for x in [max_constr_A, max_constr_b, max_obj_A, obj_b]
2991
+ if x is not None and x != 0.0 # NOSONAR
2992
+ ]
2993
+ mins = [
2994
+ x
2995
+ for x in [min_constr_A, min_constr_b, min_obj_A, obj_b]
2996
+ if x is not None and x != 0.0 # NOSONAR
2997
+ ]
2998
+ if (maxs and max(maxs) > tol_up) or (mins and min(mins) < tol_down):
2999
+ logger.info("Jacobian matrix /constants coefficients values outside typical range!")
3000
+
3001
+ # Check on individual constraints. (Only check values of constraint's Jacobian.)
3002
+ A_constr_csr = A_constr.tocsr()
3003
+
3004
+ exceedences = []
3005
+
3006
+ for i in range(A_constr_csr.shape[0]):
3007
+ r = A_constr_csr.getrow(i)
3008
+ data = r.data
3009
+
3010
+ try:
3011
+ max_r = max(np.abs(data))
3012
+ min_r = min(np.abs(data))
3013
+ except ValueError:
3014
+ # Emtpy constraint?
3015
+ continue
3016
+
3017
+ assert min_r != 0.0
3018
+
3019
+ if max_r > tol_up or min_r < tol_down or max_r / min_r > tol_range:
3020
+ c_str = constr_to_str(i)
3021
+ exceedences.append((i, max_r, min_r, c_str))
3022
+
3023
+ if exceedences:
3024
+ logger.info(
3025
+ "Exceedence in jacobian of constraints evaluated at x0"
3026
+ f" (max > {tol_up:g}, min < {tol_down:g}, or max / min > {tol_range:g}):"
3027
+ )
3028
+
3029
+ exceedences = sorted(exceedences, key=lambda x: x[1] / x[2], reverse=True)
3030
+
3031
+ for i, (r, max_r, min_r, c) in enumerate(exceedences):
3032
+ logger.info(f"row {r} (max: {max_r}, min: {min_r}, range: {max_r / min_r}): {c}")
3033
+
3034
+ if i >= 9:
3035
+ logger.info(
3036
+ f"Too many warnings of same type ({len(exceedences) - 10} others remain)."
3037
+ )
3038
+ break
3039
+
3040
+ # Columns
3041
+ A_constr_csc = A_constr.tocsc()
3042
+
3043
+ coeffs = []
3044
+
3045
+ max_range_found = 1.0
3046
+
3047
+ logger.info(
3048
+ "Checking for range exceedence for each variable (i.e., check Jacobian matrix columns)"
3049
+ )
3050
+ exceedences = []
3051
+
3052
+ for c in range(A_constr_csc.shape[1]):
3053
+ cur_col = A_constr_csc.getcol(c)
3054
+ cur_coeffs = cur_col.data
3055
+
3056
+ if len(cur_coeffs) == 0:
3057
+ coeffs.append(None)
3058
+ continue
3059
+
3060
+ abs_coeffs = np.abs(cur_coeffs)
3061
+
3062
+ max_r_i = np.argmax(abs_coeffs)
3063
+ min_r_i = np.argmin(abs_coeffs)
3064
+
3065
+ max_r = abs_coeffs[max_r_i]
3066
+ min_r = abs_coeffs[min_r_i]
3067
+
3068
+ assert min_r != 0.0
3069
+
3070
+ max_range_found = max(max_r / min_r, max_range_found)
3071
+
3072
+ if max_r / min_r > tol_range:
3073
+ inds = cur_col.indices
3074
+
3075
+ c_min = inds[min_r_i]
3076
+ c_max = inds[max_r_i]
3077
+
3078
+ r = A_constr_csr.getrow(c_min)
3079
+ c_min_str = constr_to_str(c_min)
3080
+ r = A_constr_csr.getrow(c_max)
3081
+ c_max_str = constr_to_str(c_max)
3082
+
3083
+ exceedences.append((c, max_r / min_r, min_r, max_r, c_min_str, c_max_str))
3084
+
3085
+ coeffs.append((min_r, max_r))
3086
+
3087
+ exceedences = sorted(exceedences, key=lambda x: x[1], reverse=True)
3088
+
3089
+ logger.info(f"Max range found: {max_range_found}")
3090
+ if exceedences:
3091
+ logger.info(f"Exceedence in range per column (max / min > {tol_range:g}):")
3092
+
3093
+ for i, (c, exc, min_, max_, c_min_str, c_max_str) in enumerate(exceedences):
3094
+ logger.info(f"col {c} ({var_names[c]}): range {exc}, min {min_}, max {max_}")
3095
+ logger.info(c_min_str)
3096
+ logger.info(c_max_str)
3097
+
3098
+ if i >= 9:
3099
+ logger.info(
3100
+ f"Too many warnings of same type ({len(exceedences) - 10} others remain)."
3101
+ )
3102
+ break
3103
+
3104
+ logger.info("Checking for range exceedence for variables in the objective function")
3105
+ max_range_found = 1.0
3106
+
3107
+ exceedences = []
3108
+ for c, d in zip(A_obj.col, A_obj.data):
3109
+ cofc = coeffs[c]
3110
+
3111
+ if cofc is None:
3112
+ # Variable does not appear in constraints
3113
+ continue
3114
+
3115
+ min_r, max_r = cofc
3116
+
3117
+ obj_coeff = abs(d)
3118
+
3119
+ max_range = max(obj_coeff / min_r, max_r / obj_coeff)
3120
+
3121
+ max_range_found = max(max_range, max_range_found)
3122
+
3123
+ if max_range > tol_range:
3124
+ exceedences.append((c, max_range, obj_coeff, min_r, max_r))
3125
+
3126
+ logger.info(f"Max range found: {max_range_found}")
3127
+ if exceedences:
3128
+ logger.info(f"Exceedence in range of objective variable (range > {tol_range:g}):")
3129
+
3130
+ for i, (c, max_range, obj_coeff, min_r, max_r) in enumerate(exceedences):
3131
+ logger.info(
3132
+ f"col {c} ({var_names[c]}): range: {max_range}, obj: {obj_coeff}, "
3133
+ f"min constr: {min_r}, max constr {max_r}"
3134
+ )
3135
+
3136
+ if i >= 9:
3137
+ logger.info(
3138
+ f"Too many warnings of same type ({len(exceedences) - 10} others remain)."
3139
+ )
3140
+ break
3141
+
3142
+ @debug_check(DebugLevel.VERYHIGH)
3143
+ def __debug_check_state_output_scaling(self, tol_up=1e4, tol_down=1e-2, ignore_all_zero=True):
3144
+ """
3145
+ Check the scaling using the resulting/optimized solver output.
3146
+
3147
+ Exceedences of the (absolute) state vector of `tol_up` are rather
3148
+ unambiguously bad. If a certain variable has _any_ violation, we
3149
+ report it.
3150
+
3151
+ Exceedences on `tol_down` are more difficult as maybe the scaling is
3152
+ correct, but the answer just happened to be (almost) zero. We only
3153
+ report if _all_ values are in violation (and even then can't really be
3154
+ certain).
3155
+ """
3156
+
3157
+ abs_output = np.abs(self.solver_output)
3158
+
3159
+ inds_up = np.flatnonzero(abs_output >= tol_up)
3160
+ inds_down = np.flatnonzero(abs_output <= tol_down)
3161
+
3162
+ indices = self.__indices_as_lists
3163
+
3164
+ variable_to_all_indices = {k: set(v) for k, v in indices[0].items()}
3165
+ for ensemble_indices in indices[1:]:
3166
+ for k, v in ensemble_indices.items():
3167
+ variable_to_all_indices[k] |= set(v)
3168
+
3169
+ if len(inds_up) > 0:
3170
+ exceedences = []
3171
+
3172
+ for k, v in variable_to_all_indices.items():
3173
+ inds = v.intersection(inds_up)
3174
+ if inds:
3175
+ exceedences.append((k, max(abs_output[list(inds)])))
3176
+
3177
+ exceedences = sorted(exceedences, key=lambda x: x[1], reverse=True)
3178
+
3179
+ if exceedences:
3180
+ logger.info(
3181
+ "Variables with at least one (absolute) state vector entry/entries "
3182
+ f"larger than {tol_up}"
3183
+ )
3184
+
3185
+ for k, v in exceedences:
3186
+ logger.info(f"{k}: abs max = {v}")
3187
+
3188
+ if len(inds_down) > 0:
3189
+ exceedences = []
3190
+
3191
+ for k, v in variable_to_all_indices.items():
3192
+ if v.issubset(inds_down):
3193
+ exceedences.append((k, max(abs_output[list(v)])))
3194
+
3195
+ exceedences = sorted(exceedences, key=lambda x: x[1], reverse=True)
3196
+
3197
+ if next((v for k, v in exceedences if not ignore_all_zero or v > 0.0), None):
3198
+ ignore_all_zero_string = " (but not all zero)" if ignore_all_zero else ""
3199
+ logger.info(
3200
+ "Variables with all (absolute) state vector entry/entries "
3201
+ f"smaller than {tol_down}{ignore_all_zero_string}"
3202
+ )
3203
+
3204
+ for k, v in exceedences:
3205
+ if ignore_all_zero and v == 0.0:
3206
+ continue
3207
+
3208
+ logger.info(f"{k}: abs max = {v}")