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,1302 @@
1
+ import logging
2
+ from abc import ABCMeta, abstractmethod, abstractproperty
3
+ from typing import Any, Dict, Iterator, List, Tuple, Union
4
+
5
+ import casadi as ca
6
+ import numpy as np
7
+
8
+ from rtctools._internal.alias_tools import AliasDict
9
+ from rtctools._internal.debug_check_helpers import DebugLevel, debug_check
10
+ from rtctools.data.storage import DataStoreAccessor
11
+
12
+ from .timeseries import Timeseries
13
+
14
+ logger = logging.getLogger("rtctools")
15
+
16
+
17
+ # Typical type for a bound on a variable
18
+ BT = Union[float, np.ndarray, Timeseries]
19
+
20
+
21
+ class LookupTable:
22
+ """
23
+ Base class for LookupTables.
24
+ """
25
+
26
+ @property
27
+ def inputs(self) -> List[ca.MX]:
28
+ """
29
+ List of lookup table input variables.
30
+ """
31
+ raise NotImplementedError
32
+
33
+ @property
34
+ def function(self) -> ca.Function:
35
+ """
36
+ Lookup table CasADi :class:`Function`.
37
+ """
38
+ raise NotImplementedError
39
+
40
+
41
+ class OptimizationProblem(DataStoreAccessor, metaclass=ABCMeta):
42
+ """
43
+ Base class for all optimization problems.
44
+ """
45
+
46
+ _debug_check_level = DebugLevel.MEDIUM
47
+ _debug_check_options = {}
48
+
49
+ #: Enable ensemble-specific bounds functionality
50
+ ensemble_specific_bounds = False
51
+
52
+ def __init__(self, **kwargs):
53
+ # Call parent class first for default behaviour.
54
+ super().__init__(**kwargs)
55
+
56
+ self.__mixed_integer = False
57
+
58
+ def optimize(
59
+ self,
60
+ preprocessing: bool = True,
61
+ postprocessing: bool = True,
62
+ log_solver_failure_as_error: bool = True,
63
+ ) -> bool:
64
+ """
65
+ Perform one initialize-transcribe-solve-finalize cycle.
66
+
67
+ :param preprocessing: True to enable a call to ``pre`` preceding the optimization.
68
+ :param postprocessing: True to enable a call to ``post`` following the optimization.
69
+
70
+ :returns: True on success.
71
+ """
72
+
73
+ # Deprecations / removals
74
+ if hasattr(self, "initial_state"):
75
+ raise RuntimeError(
76
+ "Support for `initial_state()` has been removed. Please use `history()` instead."
77
+ )
78
+
79
+ logger.info("Entering optimize()")
80
+
81
+ # Do any preprocessing, which may include changing parameter values on
82
+ # the model
83
+ if preprocessing:
84
+ self.pre()
85
+
86
+ # Check if control inputs are bounded
87
+ self.__check_bounds_control_input()
88
+ else:
89
+ logger.debug("Skipping Preprocessing in OptimizationProblem.optimize()")
90
+
91
+ # Transcribe problem
92
+ discrete, lbx, ubx, lbg, ubg, x0, nlp = self.transcribe()
93
+
94
+ # Create an NLP solver
95
+ logger.debug("Collecting solver options")
96
+
97
+ self.__mixed_integer = np.any(discrete)
98
+ options = {}
99
+ options.update(self.solver_options()) # Create a copy
100
+
101
+ logger.debug("Creating solver")
102
+
103
+ if options.pop("expand", False):
104
+ # NOTE: CasADi only supports the "expand" option for nlpsol. To
105
+ # also be able to expand with e.g. qpsol, we do the expansion
106
+ # ourselves here.
107
+ logger.debug("Expanding objective and constraints to SX")
108
+
109
+ expand_f_g = ca.Function("f_g", [nlp["x"]], [nlp["f"], nlp["g"]]).expand()
110
+ X_sx = ca.SX.sym("X", *nlp["x"].shape)
111
+ f_sx, g_sx = expand_f_g(X_sx)
112
+
113
+ nlp["f"] = f_sx
114
+ nlp["g"] = g_sx
115
+ nlp["x"] = X_sx
116
+
117
+ # Debug check for non-linearity in constraints
118
+ self.__debug_check_linearity_constraints(nlp)
119
+
120
+ # Debug check for linear independence of the constraints
121
+ self.__debug_check_linear_independence(lbx, ubx, lbg, ubg, nlp)
122
+
123
+ # Solver option
124
+ my_solver = options["solver"]
125
+ del options["solver"]
126
+
127
+ # Already consumed
128
+ del options["optimized_num_dir"]
129
+
130
+ # Iteration callback
131
+ iteration_callback = options.pop("iteration_callback", None)
132
+
133
+ # CasADi solver to use
134
+ casadi_solver = options.pop("casadi_solver")
135
+ if isinstance(casadi_solver, str):
136
+ casadi_solver = getattr(ca, casadi_solver)
137
+
138
+ nlpsol_options = {**options}
139
+
140
+ if self.__mixed_integer:
141
+ nlpsol_options["discrete"] = discrete
142
+ if iteration_callback:
143
+ nlpsol_options["iteration_callback"] = iteration_callback
144
+
145
+ # Remove ipopt and bonmin defaults if they are not used
146
+ if my_solver != "ipopt":
147
+ nlpsol_options.pop("ipopt", None)
148
+ if my_solver != "bonmin":
149
+ nlpsol_options.pop("bonmin", None)
150
+
151
+ solver = casadi_solver("nlp", my_solver, nlp, nlpsol_options)
152
+
153
+ # Solve NLP
154
+ logger.info("Calling solver")
155
+
156
+ results = solver(x0=x0, lbx=lbx, ubx=ubx, lbg=ca.veccat(*lbg), ubg=ca.veccat(*ubg))
157
+
158
+ # Extract relevant stats
159
+ self.__objective_value = float(results["f"])
160
+ self.__solver_output = np.array(results["x"]).ravel()
161
+ self.__transcribed_problem = {
162
+ "lbx": lbx,
163
+ "ubx": ubx,
164
+ "lbg": lbg,
165
+ "ubg": ubg,
166
+ "x0": x0,
167
+ "nlp": nlp,
168
+ }
169
+ self.__lam_g = results.get("lam_g")
170
+ self.__lam_x = results.get("lam_x")
171
+
172
+ self.__solver_stats = solver.stats()
173
+
174
+ success, log_level = self.solver_success(self.__solver_stats, log_solver_failure_as_error)
175
+
176
+ return_status = self.__solver_stats["return_status"]
177
+ if "secondary_return_status" in self.__solver_stats:
178
+ return_status = "{}: {}".format(
179
+ return_status, self.__solver_stats["secondary_return_status"]
180
+ )
181
+ wall_clock_time = "elapsed time not read"
182
+ if "t_wall_total" in self.__solver_stats:
183
+ wall_clock_time = "{} seconds".format(self.__solver_stats["t_wall_total"])
184
+ elif "t_wall_solver" in self.__solver_stats:
185
+ wall_clock_time = "{} seconds".format(self.__solver_stats["t_wall_solver"])
186
+
187
+ if success:
188
+ logger.log(
189
+ log_level,
190
+ "Solver succeeded with status {} ({}).".format(return_status, wall_clock_time),
191
+ )
192
+ else:
193
+ try:
194
+ ii = [y[0] for y in self.loop_over_error].index(self.priority)
195
+ loop_error_indicator = self.loop_over_error[ii][1]
196
+ try:
197
+ loop_error = self.loop_over_error[ii][2]
198
+ if loop_error_indicator and loop_error in return_status:
199
+ log_level = logging.INFO
200
+ except IndexError:
201
+ if loop_error_indicator:
202
+ log_level = logging.INFO
203
+ logger.log(
204
+ log_level,
205
+ "Solver failed with status {} ({}).".format(return_status, wall_clock_time),
206
+ )
207
+ except (AttributeError, ValueError):
208
+ logger.log(
209
+ log_level,
210
+ "Solver failed with status {} ({}).".format(return_status, wall_clock_time),
211
+ )
212
+
213
+ # Do any postprocessing
214
+ if postprocessing:
215
+ self.post()
216
+ else:
217
+ logger.debug("Skipping Postprocessing in OptimizationProblem.optimize()")
218
+
219
+ # Done
220
+ logger.info("Done with optimize()")
221
+
222
+ return success
223
+
224
+ def __check_bounds_control_input(self) -> None:
225
+ # Checks if at the control inputs have bounds, log warning when a control input is not
226
+ # bounded.
227
+ bounds = self.bounds()
228
+
229
+ for variable in self.dae_variables["control_inputs"]:
230
+ variable = variable.name()
231
+ if variable not in bounds:
232
+ logger.warning(
233
+ "OptimizationProblem: control input {} has no bounds.".format(variable)
234
+ )
235
+
236
+ @abstractmethod
237
+ def transcribe(
238
+ self,
239
+ ) -> Tuple[
240
+ np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, Dict[str, ca.MX]
241
+ ]:
242
+ """
243
+ Transcribe the continuous optimization problem to a discretized, solver-ready
244
+ optimization problem.
245
+ """
246
+ pass
247
+
248
+ def solver_options(self) -> Dict[str, Union[str, int, float, bool, str]]:
249
+ """
250
+ Returns a dictionary of CasADi optimization problem solver options.
251
+
252
+ The default solver for continuous problems is `Ipopt
253
+ <https://projects.coin-or.org/Ipopt/>`_.
254
+
255
+ The default solver for mixed integer problems is `Bonmin
256
+ <http://projects.coin-or.org/Bonmin/>`_.
257
+
258
+ :returns: A dictionary of solver options. See the CasADi and
259
+ respective solver documentation for details.
260
+ """
261
+ options = {"error_on_fail": False, "optimized_num_dir": 3, "casadi_solver": ca.nlpsol}
262
+
263
+ if self.__mixed_integer:
264
+ options["solver"] = "bonmin"
265
+
266
+ bonmin_options = options["bonmin"] = {}
267
+ bonmin_options["algorithm"] = "B-BB"
268
+ bonmin_options["nlp_solver"] = "Ipopt"
269
+ bonmin_options["nlp_log_level"] = 2
270
+ bonmin_options["linear_solver"] = "mumps"
271
+ else:
272
+ options["solver"] = "ipopt"
273
+
274
+ ipopt_options = options["ipopt"] = {}
275
+ ipopt_options["linear_solver"] = "mumps"
276
+ return options
277
+
278
+ def solver_success(
279
+ self, solver_stats: Dict[str, Union[str, bool]], log_solver_failure_as_error: bool
280
+ ) -> Tuple[bool, int]:
281
+ """
282
+ Translates the returned solver statistics into a boolean and log level
283
+ to indicate whether the solve was succesful, and how to log it.
284
+
285
+ :param solver_stats: Dictionary containing information about the
286
+ solver status. See explanation below.
287
+ :param log_solver_failure_as_error: Indicates whether a solve failure
288
+ Should be logged as an error or info message.
289
+
290
+ ``solver_stats`` typically consist of three fields:
291
+
292
+ * return_status: ``str``
293
+ * secondary_return_status: ``str``
294
+ * success: ``bool``
295
+
296
+ By default we rely on CasADi's interpretation of the return_status
297
+ (and secondary status) to the success variable, with an exception for
298
+ IPOPT (see below).
299
+
300
+ The logging level is typically ``logging.INFO`` for success, and
301
+ ``logging.ERROR`` for failure. Only for IPOPT an exception is made for
302
+ `Not_Enough_Degrees_Of_Freedom`, which returns ``logging.WARNING`` instead.
303
+ For example, this can happen when too many goals are specified, and
304
+ lower priority goals cannot improve further on the current result.
305
+
306
+ :returns: A tuple indicating whether or not the solver has succeeded, and what level to log
307
+ it with.
308
+ """
309
+ success = solver_stats["success"]
310
+ log_level = logging.INFO if success else logging.ERROR
311
+
312
+ if self.solver_options()["solver"].lower() in ["bonmin", "ipopt"] and solver_stats[
313
+ "return_status"
314
+ ] in ["Not_Enough_Degrees_Of_Freedom"]:
315
+ log_level = logging.WARNING
316
+
317
+ if log_level == logging.ERROR and not log_solver_failure_as_error:
318
+ log_level = logging.INFO
319
+
320
+ if self.solver_options()["solver"].lower() == "knitro":
321
+ list_feas_flags = [
322
+ "KN_RC_OPTIMAL_OR_SATISFACTORY",
323
+ "KN_RC_ITER_LIMIT_FEAS",
324
+ "KN_RC_NEAR_OPT",
325
+ "KN_RC_FEAS_XTOL",
326
+ "KN_RC_FEAS_NO_IMPROVE",
327
+ "KN_RC_FEAS_FTOL",
328
+ "KN_RC_TIME_LIMIT_FEAS",
329
+ "KN_RC_FEVAL_LIMIT_FEAS",
330
+ "KN_RC_MIP_EXH_FEAS",
331
+ "KN_RC_MIP_TERM_FEAS",
332
+ "KN_RC_MIP_SOLVE_LIMIT_FEAS",
333
+ "KN_RC_MIP_NODE_LIMIT_FEAS",
334
+ ]
335
+ if solver_stats["return_status"] in list_feas_flags:
336
+ success = True
337
+
338
+ return success, log_level
339
+
340
+ @abstractproperty
341
+ def solver_input(self) -> ca.MX:
342
+ """
343
+ The symbolic input to the NLP solver.
344
+ """
345
+ pass
346
+
347
+ @abstractmethod
348
+ def extract_results(self, ensemble_member: int = 0) -> Dict[str, np.ndarray]:
349
+ """
350
+ Extracts state and control input time series from optimizer results.
351
+
352
+ :returns: A dictionary of result time series.
353
+ """
354
+ pass
355
+
356
+ @property
357
+ def objective_value(self) -> float:
358
+ """
359
+ The last obtained objective function value.
360
+ """
361
+ return self.__objective_value
362
+
363
+ @property
364
+ def solver_output(self) -> np.ndarray:
365
+ """
366
+ The raw output from the last NLP solver run.
367
+ """
368
+ return self.__solver_output
369
+
370
+ @property
371
+ def solver_stats(self) -> Dict[str, Any]:
372
+ """
373
+ The stats from the last NLP solver run.
374
+ """
375
+ return self.__solver_stats
376
+
377
+ @property
378
+ def lagrange_multipliers(self) -> Tuple[Any, Any]:
379
+ """
380
+ The lagrange multipliers at the solution.
381
+ """
382
+ return self.__lam_g, self.__lam_x
383
+
384
+ @property
385
+ def transcribed_problem(self) -> Dict[str, Any]:
386
+ """
387
+ The transcribed problem.
388
+ """
389
+ return self.__transcribed_problem
390
+
391
+ def pre(self) -> None:
392
+ """
393
+ Preprocessing logic is performed here.
394
+ """
395
+ pass
396
+
397
+ @abstractproperty
398
+ def dae_residual(self) -> ca.MX:
399
+ """
400
+ Symbolic DAE residual of the model.
401
+ """
402
+ pass
403
+
404
+ @abstractproperty
405
+ def dae_variables(self) -> Dict[str, List[ca.MX]]:
406
+ """
407
+ Dictionary of symbolic variables for the DAE residual.
408
+ """
409
+ pass
410
+
411
+ @property
412
+ def path_variables(self) -> List[ca.MX]:
413
+ """
414
+ List of additional, time-dependent optimization variables, not covered by the DAE model.
415
+ """
416
+ return []
417
+
418
+ @abstractmethod
419
+ def variable(self, variable: str) -> ca.MX:
420
+ """
421
+ Returns an :class:`MX` symbol for the given variable.
422
+
423
+ :param variable: Variable name.
424
+
425
+ :returns: The associated CasADi :class:`MX` symbol.
426
+ """
427
+ raise NotImplementedError
428
+
429
+ @property
430
+ def extra_variables(self) -> List[ca.MX]:
431
+ """
432
+ List of additional, time-independent optimization variables, not covered by the DAE model.
433
+ """
434
+ return []
435
+
436
+ @property
437
+ def output_variables(self) -> List[ca.MX]:
438
+ """
439
+ List of variables that the user requests to be included in the output files.
440
+ """
441
+ return []
442
+
443
+ def delayed_feedback(self) -> List[Tuple[str, str, float]]:
444
+ """
445
+ Returns the delayed feedback mappings. These are given as a list of triples
446
+ :math:`(x, y, \\tau)`, to indicate that :math:`y = x(t - \\tau)`.
447
+
448
+ :returns: A list of triples.
449
+
450
+ Example::
451
+
452
+ def delayed_feedback(self):
453
+ fb1 = ['x', 'y', 0.1]
454
+ fb2 = ['x', 'z', 0.2]
455
+ return [fb1, fb2]
456
+
457
+ """
458
+ return []
459
+
460
+ @property
461
+ def ensemble_size(self) -> int:
462
+ """
463
+ The number of ensemble members.
464
+ """
465
+ return 1
466
+
467
+ def ensemble_member_probability(self, ensemble_member: int) -> float:
468
+ """
469
+ The probability of an ensemble member occurring.
470
+
471
+ :param ensemble_member: The ensemble member index.
472
+
473
+ :returns: The probability of an ensemble member occurring.
474
+
475
+ :raises: IndexError
476
+ """
477
+ return 1.0
478
+
479
+ def parameters(self, ensemble_member: int) -> AliasDict[str, Union[bool, int, float, ca.MX]]:
480
+ """
481
+ Returns a dictionary of parameters.
482
+
483
+ :param ensemble_member: The ensemble member index.
484
+
485
+ :returns: A dictionary of parameter names and values.
486
+ """
487
+ return AliasDict(self.alias_relation)
488
+
489
+ def string_parameters(self, ensemble_member: int) -> Dict[str, str]:
490
+ """
491
+ Returns a dictionary of string parameters.
492
+
493
+ :param ensemble_member: The ensemble member index.
494
+
495
+ :returns: A dictionary of string parameter names and values.
496
+ """
497
+ return {}
498
+
499
+ def constant_inputs(self, ensemble_member: int) -> AliasDict[str, Timeseries]:
500
+ """
501
+ Returns a dictionary of constant inputs.
502
+
503
+ :param ensemble_member: The ensemble member index.
504
+
505
+ :returns: A dictionary of constant input names and time series.
506
+ """
507
+ return AliasDict(self.alias_relation)
508
+
509
+ def lookup_tables(self, ensemble_member: int) -> AliasDict[str, LookupTable]:
510
+ """
511
+ Returns a dictionary of lookup tables.
512
+
513
+ :param ensemble_member: The ensemble member index.
514
+
515
+ :returns: A dictionary of variable names and lookup tables.
516
+ """
517
+ return AliasDict(self.alias_relation)
518
+
519
+ @staticmethod
520
+ def merge_bounds(a: Tuple[BT, BT], b: Tuple[BT, BT]) -> Tuple[BT, BT]:
521
+ """
522
+ Returns a pair of bounds which is the intersection of the two pairs of
523
+ bounds given as input.
524
+
525
+ :param a: First pair ``(upper, lower)`` bounds
526
+ :param b: Second pair ``(upper, lower)`` bounds
527
+
528
+ :returns: A pair of ``(upper, lower)`` bounds which is the
529
+ intersection of the two input bounds.
530
+ """
531
+ a, A = a
532
+ b, B = b
533
+
534
+ # Make sure we are dealing with the correct types
535
+ if __debug__:
536
+ for v in (a, A, b, B):
537
+ if isinstance(v, np.ndarray):
538
+ assert v.ndim == 1
539
+ assert np.issubdtype(v.dtype, np.number)
540
+ else:
541
+ assert isinstance(v, (float, int, Timeseries))
542
+
543
+ all_bounds = [a, A, b, B]
544
+
545
+ # First make sure that we treat single element vectors as scalars
546
+ for i, v in enumerate(all_bounds):
547
+ if isinstance(v, np.ndarray) and np.prod(v.shape) == 1:
548
+ all_bounds[i] = v.item()
549
+
550
+ # Upcast lower bounds to be of equal type, and upper bounds as well.
551
+ for i, j in [(0, 2), (2, 0), (1, 3), (3, 1)]:
552
+ v1 = all_bounds[i]
553
+ v2 = all_bounds[j]
554
+
555
+ # We only check for v1 being of a "smaller" type than v2, as we
556
+ # know we will encounter the reverse as well.
557
+ if isinstance(v1, type(v2)):
558
+ # Same type, nothing to do.
559
+ continue
560
+ elif isinstance(v1, (int, float)) and isinstance(v2, Timeseries):
561
+ all_bounds[i] = Timeseries(v2.times, np.full_like(v2.values, v1))
562
+ elif isinstance(v1, np.ndarray) and isinstance(v2, Timeseries):
563
+ if v2.values.ndim != 2 or len(v1) != v2.values.shape[1]:
564
+ raise Exception(
565
+ "Mismatching vector size when upcasting to Timeseries, {} vs. {}.".format(
566
+ v1, v2
567
+ )
568
+ )
569
+ all_bounds[i] = Timeseries(v2.times, np.broadcast_to(v1, v2.values.shape))
570
+ elif isinstance(v1, (int, float)) and isinstance(v2, np.ndarray):
571
+ all_bounds[i] = np.full_like(v2, v1)
572
+
573
+ a, A, b, B = all_bounds
574
+
575
+ assert isinstance(a, type(b))
576
+ assert isinstance(A, type(B))
577
+
578
+ # Merge the bounds
579
+ m, M = None, None
580
+
581
+ if isinstance(a, np.ndarray):
582
+ if not a.shape == b.shape:
583
+ raise Exception("Cannot merge vector minimum bounds of non-equal size")
584
+ m = np.maximum(a, b)
585
+ elif isinstance(a, Timeseries):
586
+ if len(a.times) != len(b.times):
587
+ raise Exception("Cannot merge Timeseries minimum bounds with different lengths")
588
+ elif not np.all(a.times == b.times):
589
+ raise Exception("Cannot merge Timeseries minimum bounds with non-equal times")
590
+ elif not a.values.shape == b.values.shape:
591
+ raise Exception("Cannot merge vector Timeseries minimum bounds of non-equal size")
592
+ m = Timeseries(a.times, np.maximum(a.values, b.values))
593
+ else:
594
+ m = max(a, b)
595
+
596
+ if isinstance(A, np.ndarray):
597
+ if not A.shape == B.shape:
598
+ raise Exception("Cannot merge vector maximum bounds of non-equal size")
599
+ M = np.minimum(A, B)
600
+ elif isinstance(A, Timeseries):
601
+ if len(A.times) != len(B.times):
602
+ raise Exception("Cannot merge Timeseries maximum bounds with different lengths")
603
+ elif not np.all(A.times == B.times):
604
+ raise Exception("Cannot merge Timeseries maximum bounds with non-equal times")
605
+ elif not A.values.shape == B.values.shape:
606
+ raise Exception("Cannot merge vector Timeseries maximum bounds of non-equal size")
607
+ M = Timeseries(A.times, np.minimum(A.values, B.values))
608
+ else:
609
+ M = min(A, B)
610
+
611
+ return m, M
612
+
613
+ def bounds(self) -> AliasDict[str, Tuple[BT, BT]]:
614
+ """
615
+ Returns variable bounds as a dictionary mapping variable names to a pair of bounds.
616
+ A bound may be a constant, or a time series.
617
+
618
+ :returns: A dictionary of variable names and ``(upper, lower)`` bound pairs.
619
+ The bounds may be numbers or :class:`.Timeseries` objects.
620
+
621
+ Example::
622
+
623
+ def bounds(self):
624
+ return {'x': (1.0, 2.0), 'y': (2.0, 3.0)}
625
+
626
+ """
627
+ return AliasDict(self.alias_relation)
628
+
629
+ def history(self, ensemble_member: int) -> AliasDict[str, Timeseries]:
630
+ """
631
+ Returns the state history.
632
+
633
+ :param ensemble_member: The ensemble member index.
634
+
635
+ :returns:
636
+ A dictionary of variable names and historical time series (up to and including t0).
637
+ """
638
+ return AliasDict(self.alias_relation)
639
+
640
+ def variable_is_discrete(self, variable: str) -> bool:
641
+ """
642
+ Returns ``True`` if the provided variable is discrete.
643
+
644
+ :param variable: Variable name.
645
+
646
+ :returns: ``True`` if variable is discrete (integer).
647
+ """
648
+ return False
649
+
650
+ def variable_nominal(self, variable: str) -> Union[float, np.ndarray]:
651
+ """
652
+ Returns the nominal value of the variable. Variables are scaled by replacing them with
653
+ their nominal value multiplied by the new variable.
654
+
655
+ :param variable: Variable name.
656
+
657
+ :returns: The nominal value of the variable.
658
+ """
659
+ return 1
660
+
661
+ @property
662
+ def initial_time(self) -> float:
663
+ """
664
+ The initial time in seconds.
665
+ """
666
+ return self.times()[0]
667
+
668
+ @property
669
+ def initial_residual(self) -> ca.MX:
670
+ """
671
+ The initial equation residual.
672
+
673
+ Initial equations are used to find consistent initial conditions.
674
+
675
+ :returns: An :class:`MX` object representing F in the initial equation F = 0.
676
+ """
677
+ return ca.MX(0)
678
+
679
+ def seed(self, ensemble_member: int) -> AliasDict[str, Union[float, Timeseries]]:
680
+ """
681
+ Seeding data. The optimization algorithm is seeded with the data returned by this method.
682
+
683
+ :param ensemble_member: The ensemble member index.
684
+
685
+ :returns: A dictionary of variable names and seed time series.
686
+ """
687
+ return AliasDict(self.alias_relation)
688
+
689
+ def objective(self, ensemble_member: int) -> ca.MX:
690
+ """
691
+ The objective function for the given ensemble member.
692
+
693
+ Call :func:`OptimizationProblem.state_at` to return a symbol representing a model variable
694
+ at a given time.
695
+
696
+ :param ensemble_member: The ensemble member index.
697
+
698
+ :returns: An :class:`MX` object representing the objective function.
699
+
700
+ Example::
701
+
702
+ def objective(self, ensemble_member):
703
+ # Return value of state 'x' at final time:
704
+ times = self.times()
705
+ return self.state_at('x', times[-1], ensemble_member)
706
+
707
+ """
708
+ return ca.MX(0)
709
+
710
+ def path_objective(self, ensemble_member: int) -> ca.MX:
711
+ """
712
+ Returns a path objective the given ensemble member.
713
+
714
+ Path objectives apply to all times and ensemble members simultaneously.
715
+ Call :func:`OptimizationProblem.state` to return a time- and ensemble-member-independent
716
+ symbol representing a model variable.
717
+
718
+ :param ensemble_member: The ensemble member index. This index is currently unused,
719
+ and here for future use only.
720
+
721
+ :returns: A :class:`MX` object representing the path objective.
722
+
723
+ Example::
724
+
725
+ def path_objective(self, ensemble_member):
726
+ # Minimize x(t) for all t
727
+ return self.state('x')
728
+
729
+ """
730
+ return ca.MX(0)
731
+
732
+ def constraints(
733
+ self, ensemble_member: int
734
+ ) -> List[Tuple[ca.MX, Union[float, np.ndarray], Union[float, np.ndarray]]]:
735
+ """
736
+ Returns a list of constraints for the given ensemble member.
737
+
738
+ Call :func:`OptimizationProblem.state_at` to return a symbol representing a model variable
739
+ at a given time.
740
+
741
+ :param ensemble_member: The ensemble member index.
742
+
743
+ :returns: A list of triples ``(f, m, M)``, with an :class:`MX` object representing
744
+ the constraint function ``f``, lower bound ``m``, and upper bound ``M``.
745
+ The bounds must be numbers.
746
+
747
+ Example::
748
+
749
+ def constraints(self, ensemble_member):
750
+ t = 1.0
751
+ constraint1 = (
752
+ 2 * self.state_at('x', t, ensemble_member),
753
+ 2.0, 4.0)
754
+ constraint2 = (
755
+ self.state_at('x', t, ensemble_member) + self.state_at('y', t, ensemble_member),
756
+ 2.0, 3.0)
757
+ return [constraint1, constraint2]
758
+
759
+ """
760
+ return []
761
+
762
+ def path_constraints(
763
+ self, ensemble_member: int
764
+ ) -> List[Tuple[ca.MX, Union[float, np.ndarray], Union[float, np.ndarray]]]:
765
+ """
766
+ Returns a list of path constraints.
767
+
768
+ Path constraints apply to all times and ensemble members simultaneously.
769
+ Call :func:`OptimizationProblem.state` to return a time- and ensemble-member-independent
770
+ symbol representing a model variable.
771
+
772
+ :param ensemble_member: The ensemble member index. This index may only
773
+ be used to supply member-dependent bounds.
774
+
775
+ :returns: A list of triples ``(f, m, M)``, with an :class:`MX` object representing
776
+ the path constraint function ``f``, lower bound ``m``, and upper bound ``M``.
777
+ The bounds may be numbers or :class:`.Timeseries` objects.
778
+
779
+ Example::
780
+
781
+ def path_constraints(self, ensemble_member):
782
+ # 2 * x must lie between 2 and 4 for every time instance.
783
+ path_constraint1 = (2 * self.state('x'), 2.0, 4.0)
784
+ # x + y must lie between 2 and 3 for every time instance
785
+ path_constraint2 = (self.state('x') + self.state('y'), 2.0, 3.0)
786
+ return [path_constraint1, path_constraint2]
787
+
788
+ """
789
+ return []
790
+
791
+ def post(self) -> None:
792
+ """
793
+ Postprocessing logic is performed here.
794
+ """
795
+ pass
796
+
797
+ @property
798
+ def equidistant(self) -> bool:
799
+ """
800
+ ``True`` if all time series are equidistant.
801
+ """
802
+ return False
803
+
804
+ INTERPOLATION_LINEAR = 0
805
+ INTERPOLATION_PIECEWISE_CONSTANT_FORWARD = 1
806
+ INTERPOLATION_PIECEWISE_CONSTANT_BACKWARD = 2
807
+
808
+ def interpolate(
809
+ self,
810
+ t: Union[float, np.ndarray],
811
+ ts: np.ndarray,
812
+ fs: np.ndarray,
813
+ f_left: float = np.nan,
814
+ f_right: float = np.nan,
815
+ mode: int = INTERPOLATION_LINEAR,
816
+ ) -> Union[float, np.ndarray]:
817
+ """
818
+ Linear interpolation over time.
819
+
820
+ :param t: Time at which to evaluate the interpolant.
821
+ :type t: float or vector of floats
822
+ :param ts: Time stamps.
823
+ :type ts: numpy array
824
+ :param fs: Function values at time stamps ts.
825
+ :param f_left: Function value left of leftmost time stamp.
826
+ :param f_right: Function value right of rightmost time stamp.
827
+ :param mode: Interpolation mode.
828
+
829
+ :returns: The interpolated value.
830
+ """
831
+
832
+ if isinstance(fs, np.ndarray) and fs.ndim == 2:
833
+ # 2-D array of values. Interpolate each column separately.
834
+ if len(t) == len(ts) and np.all(t == ts):
835
+ # Early termination; nothing to interpolate
836
+ return fs.copy()
837
+
838
+ fs_int = [
839
+ self.interpolate(t, ts, fs[:, i], f_left, f_right, mode) for i in range(fs.shape[1])
840
+ ]
841
+ return np.stack(fs_int, axis=1)
842
+ elif hasattr(t, "__iter__"):
843
+ if len(t) == len(ts) and np.all(t == ts):
844
+ # Early termination; nothing to interpolate
845
+ return fs.copy()
846
+
847
+ return self.__interpolate(t, ts, fs, f_left, f_right, mode)
848
+ else:
849
+ if ts[0] == t:
850
+ # Early termination; nothing to interpolate
851
+ return fs[0]
852
+
853
+ return self.__interpolate(t, ts, fs, f_left, f_right, mode)
854
+
855
+ def __interpolate(self, t, ts, fs, f_left=np.nan, f_right=np.nan, mode=INTERPOLATION_LINEAR):
856
+ """
857
+ Linear interpolation over time.
858
+
859
+ :param t: Time at which to evaluate the interpolant.
860
+ :type t: float or vector of floats
861
+ :param ts: Time stamps.
862
+ :type ts: numpy array
863
+ :param fs: Function values at time stamps ts.
864
+ :param f_left: Function value left of leftmost time stamp.
865
+ :param f_right: Function value right of rightmost time stamp.
866
+ :param mode: Interpolation mode.
867
+
868
+ Note that it is assumed that `ts` is sorted. No such assumption is made for `t`.
869
+
870
+ :returns: The interpolated value.
871
+ """
872
+
873
+ if f_left is None:
874
+ if (min(t) if hasattr(t, "__iter__") else t) < ts[0]:
875
+ raise Exception("Interpolation: Point {} left of range".format(t))
876
+
877
+ if f_right is None:
878
+ if (max(t) if hasattr(t, "__iter__") else t) > ts[-1]:
879
+ raise Exception("Interpolation: Point {} right of range".format(t))
880
+
881
+ if mode == self.INTERPOLATION_LINEAR:
882
+ # No need to handle f_left / f_right; NumPy already does this for us
883
+ return np.interp(t, ts, fs, f_left, f_right)
884
+ elif mode == self.INTERPOLATION_PIECEWISE_CONSTANT_FORWARD:
885
+ v = fs[np.maximum(np.searchsorted(ts, t, side="right") - 1, 0)]
886
+ elif mode == self.INTERPOLATION_PIECEWISE_CONSTANT_BACKWARD:
887
+ v = fs[np.minimum(np.searchsorted(ts, t, side="left"), len(ts) - 1)]
888
+ else:
889
+ raise NotImplementedError
890
+
891
+ # Handle f_left / f_right:
892
+ if hasattr(t, "__iter__"):
893
+ v[t < ts[0]] = f_left
894
+ v[t > ts[-1]] = f_right
895
+ else:
896
+ if t < ts[0]:
897
+ v = f_left
898
+ elif t > ts[-1]:
899
+ v = f_right
900
+
901
+ return v
902
+
903
+ @abstractproperty
904
+ def controls(self) -> List[str]:
905
+ """
906
+ List of names of the control variables (excluding aliases).
907
+ """
908
+ pass
909
+
910
+ @abstractmethod
911
+ def discretize_controls(
912
+ self, resolved_bounds: AliasDict
913
+ ) -> Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
914
+ """
915
+ Performs the discretization of the control inputs, filling lower and upper
916
+ bound vectors for the resulting optimization variables, as well as an initial guess.
917
+
918
+ :param resolved_bounds: :class:`AliasDict` of numerical bound values. This is the
919
+ same dictionary as returned by :func:`bounds`, but with all
920
+ parameter symbols replaced with their numerical values.
921
+
922
+ :returns: The number of control variables in the optimization problem, a lower
923
+ bound vector, an upper bound vector, a seed vector, and a dictionary
924
+ of offset values.
925
+ """
926
+ pass
927
+
928
+ def dynamic_parameters(self) -> List[ca.MX]:
929
+ """
930
+ Returns a list of parameter symbols that may vary from run to run. The values
931
+ of these parameters are not cached.
932
+
933
+ :returns: A list of parameter symbols.
934
+ """
935
+ return []
936
+
937
+ @abstractmethod
938
+ def extract_controls(self, ensemble_member: int = 0) -> Dict[str, np.ndarray]:
939
+ """
940
+ Extracts state time series from optimizer results.
941
+
942
+ Must return a dictionary of result time series.
943
+
944
+ :param ensemble_member: The ensemble member index.
945
+
946
+ :returns: A dictionary of control input time series.
947
+ """
948
+ pass
949
+
950
+ def control_vector(self, variable: str, ensemble_member: int = 0) -> Union[ca.MX, List[ca.MX]]:
951
+ """
952
+ Return the optimization variables for the entire time horizon of the given state.
953
+
954
+ :param variable: Variable name.
955
+ :param ensemble_member: The ensemble member index.
956
+
957
+ :returns: A vector of control input symbols for the entire time horizon.
958
+
959
+ :raises: KeyError
960
+ """
961
+ return self.state_vector(variable, ensemble_member)
962
+
963
+ def control(self, variable: str) -> ca.MX:
964
+ """
965
+ Returns an :class:`MX` symbol for the given control input, not bound to any time.
966
+
967
+ :param variable: Variable name.
968
+
969
+ :returns: :class:`MX` symbol for given control input.
970
+
971
+ :raises: KeyError
972
+ """
973
+ return self.variable(variable)
974
+
975
+ @abstractmethod
976
+ def control_at(
977
+ self, variable: str, t: float, ensemble_member: int = 0, scaled: bool = False
978
+ ) -> ca.MX:
979
+ """
980
+ Returns an :class:`MX` symbol representing the given control input at the given time.
981
+
982
+ :param variable: Variable name.
983
+ :param t: Time.
984
+ :param ensemble_member: The ensemble member index.
985
+ :param scaled: True to return the scaled variable.
986
+
987
+ :returns: :class:`MX` symbol representing the control input at the given time.
988
+
989
+ :raises: KeyError
990
+ """
991
+ pass
992
+
993
+ @abstractproperty
994
+ def differentiated_states(self) -> List[str]:
995
+ """
996
+ List of names of the differentiated state variables (excluding aliases).
997
+ """
998
+ pass
999
+
1000
+ @abstractproperty
1001
+ def algebraic_states(self) -> List[str]:
1002
+ """
1003
+ List of names of the algebraic state variables (excluding aliases).
1004
+ """
1005
+ pass
1006
+
1007
+ @abstractmethod
1008
+ def discretize_states(
1009
+ self, resolved_bounds: AliasDict
1010
+ ) -> Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
1011
+ """
1012
+ Perform the discretization of the states.
1013
+
1014
+ Fills lower and upper bound vectors for the resulting optimization
1015
+ variables, as well as an initial guess.
1016
+
1017
+ :param resolved_bounds: :class:`AliasDict` of numerical bound values.
1018
+ This is the same dictionary as returned by :func:`bounds`, but
1019
+ with all parameter symbols replaced with their numerical values.
1020
+
1021
+ :returns: The number of control variables in the optimization problem,
1022
+ a lower bound vector, an upper bound vector, a seed vector,
1023
+ and a dictionary of vector offset values.
1024
+ """
1025
+ pass
1026
+
1027
+ @abstractmethod
1028
+ def extract_states(self, ensemble_member: int = 0) -> Dict[str, np.ndarray]:
1029
+ """
1030
+ Extracts state time series from optimizer results.
1031
+
1032
+ Must return a dictionary of result time series.
1033
+
1034
+ :param ensemble_member: The ensemble member index.
1035
+
1036
+ :returns: A dictionary of state time series.
1037
+ """
1038
+ pass
1039
+
1040
+ @abstractmethod
1041
+ def state_vector(self, variable: str, ensemble_member: int = 0) -> Union[ca.MX, List[ca.MX]]:
1042
+ """
1043
+ Return the optimization variables for the entire time horizon of the given state.
1044
+
1045
+ :param variable: Variable name.
1046
+ :param ensemble_member: The ensemble member index.
1047
+
1048
+ :returns: A vector of state symbols for the entire time horizon.
1049
+
1050
+ :raises: KeyError
1051
+ """
1052
+ pass
1053
+
1054
+ def state(self, variable: str) -> ca.MX:
1055
+ """
1056
+ Returns an :class:`MX` symbol for the given state, not bound to any time.
1057
+
1058
+ :param variable: Variable name.
1059
+
1060
+ :returns: :class:`MX` symbol for given state.
1061
+
1062
+ :raises: KeyError
1063
+ """
1064
+ return self.variable(variable)
1065
+
1066
+ @abstractmethod
1067
+ def state_at(
1068
+ self, variable: str, t: float, ensemble_member: int = 0, scaled: bool = False
1069
+ ) -> ca.MX:
1070
+ """
1071
+ Returns an :class:`MX` symbol representing the given variable at the given time.
1072
+
1073
+ :param variable: Variable name.
1074
+ :param t: Time.
1075
+ :param ensemble_member: The ensemble member index.
1076
+ :param scaled: True to return the scaled variable.
1077
+
1078
+ :returns: :class:`MX` symbol representing the state at the given time.
1079
+
1080
+ :raises: KeyError
1081
+ """
1082
+ pass
1083
+
1084
+ @abstractmethod
1085
+ def extra_variable(self, variable: str, ensemble_member: int = 0) -> ca.MX:
1086
+ """
1087
+ Returns an :class:`MX` symbol representing the extra variable inside the state vector.
1088
+
1089
+ :param variable: Variable name.
1090
+ :param ensemble_member: The ensemble member index.
1091
+
1092
+ :returns: :class:`MX` symbol representing the extra variable.
1093
+
1094
+ :raises: KeyError
1095
+ """
1096
+ pass
1097
+
1098
+ @abstractmethod
1099
+ def states_in(
1100
+ self, variable: str, t0: float = None, tf: float = None, ensemble_member: int = 0
1101
+ ) -> Iterator[ca.MX]:
1102
+ """
1103
+ Iterates over symbols for states in the interval [t0, tf].
1104
+
1105
+ :param variable: Variable name.
1106
+ :param t0: Left bound of interval. If equal to None, the initial time is used.
1107
+ :param tf: Right bound of interval. If equal to None, the final time is used.
1108
+ :param ensemble_member: The ensemble member index.
1109
+
1110
+ :raises: KeyError
1111
+ """
1112
+ pass
1113
+
1114
+ @abstractmethod
1115
+ def integral(
1116
+ self, variable: str, t0: float = None, tf: float = None, ensemble_member: int = 0
1117
+ ) -> ca.MX:
1118
+ """
1119
+ Returns an expression for the integral over the interval [t0, tf].
1120
+
1121
+ :param variable: Variable name.
1122
+ :param t0: Left bound of interval. If equal to None, the initial time is used.
1123
+ :param tf: Right bound of interval. If equal to None, the final time is used.
1124
+ :param ensemble_member: The ensemble member index.
1125
+
1126
+ :returns: :class:`MX` object representing the integral.
1127
+
1128
+ :raises: KeyError
1129
+ """
1130
+ pass
1131
+
1132
+ @abstractmethod
1133
+ def der(self, variable: str) -> ca.MX:
1134
+ """
1135
+ Returns an :class:`MX` symbol for the time derivative given state, not bound to any time.
1136
+
1137
+ :param variable: Variable name.
1138
+
1139
+ :returns: :class:`MX` symbol for given state.
1140
+
1141
+ :raises: KeyError
1142
+ """
1143
+ pass
1144
+
1145
+ @abstractmethod
1146
+ def der_at(self, variable: str, t: float, ensemble_member: int = 0) -> ca.MX:
1147
+ """
1148
+ Returns an expression for the time derivative of the specified variable at time t.
1149
+
1150
+ :param variable: Variable name.
1151
+ :param t: Time.
1152
+ :param ensemble_member: The ensemble member index.
1153
+
1154
+ :returns: :class:`MX` object representing the derivative.
1155
+
1156
+ :raises: KeyError
1157
+ """
1158
+ pass
1159
+
1160
+ def get_timeseries(self, variable: str, ensemble_member: int = 0) -> Timeseries:
1161
+ """
1162
+ Looks up a timeseries from the internal data store.
1163
+
1164
+ :param variable: Variable name.
1165
+ :param ensemble_member: The ensemble member index.
1166
+
1167
+ :returns: The requested time series.
1168
+ :rtype: :class:`.Timeseries`
1169
+
1170
+ :raises: KeyError
1171
+ """
1172
+ raise NotImplementedError
1173
+
1174
+ def set_timeseries(
1175
+ self,
1176
+ variable: str,
1177
+ timeseries: Timeseries,
1178
+ ensemble_member: int = 0,
1179
+ output: bool = True,
1180
+ check_consistency: bool = True,
1181
+ ) -> None:
1182
+ """
1183
+ Sets a timeseries in the internal data store.
1184
+
1185
+ :param variable: Variable name.
1186
+ :param timeseries: Time series data.
1187
+ :type timeseries: iterable of floats, or :class:`.Timeseries`
1188
+ :param ensemble_member: The ensemble member index.
1189
+ :param output: Whether to include this time series in output data files.
1190
+ :param check_consistency: Whether to check consistency between the time stamps on
1191
+ the new timeseries object and any existing time stamps.
1192
+ """
1193
+ raise NotImplementedError
1194
+
1195
+ def timeseries_at(self, variable: str, t: float, ensemble_member: int = 0) -> float:
1196
+ """
1197
+ Return the value of a time series at the given time.
1198
+
1199
+ :param variable: Variable name.
1200
+ :param t: Time.
1201
+ :param ensemble_member: The ensemble member index.
1202
+
1203
+ :returns: The interpolated value of the time series.
1204
+
1205
+ :raises: KeyError
1206
+ """
1207
+ raise NotImplementedError
1208
+
1209
+ def map_path_expression(self, expr: ca.MX, ensemble_member: int) -> ca.MX:
1210
+ """
1211
+ Maps the path expression `expr` over the entire time horizon of the optimization problem.
1212
+
1213
+ :param expr: An :class:`MX` path expression.
1214
+
1215
+ :returns: An :class:`MX` expression evaluating `expr` over the entire time horizon.
1216
+ """
1217
+ raise NotImplementedError
1218
+
1219
+ @debug_check(DebugLevel.HIGH)
1220
+ def __debug_check_linearity_constraints(self, nlp):
1221
+ x = nlp["x"]
1222
+ f = nlp["f"]
1223
+ g = nlp["g"]
1224
+
1225
+ expand_f_g = ca.Function("f_g", [x], [f, g]).expand()
1226
+ X_sx = ca.SX.sym("X", *x.shape)
1227
+ f_sx, g_sx = expand_f_g(X_sx)
1228
+
1229
+ jac = ca.Function("j", [X_sx], [ca.jacobian(g_sx, X_sx)]).expand()
1230
+ if jac(np.nan).is_regular():
1231
+ logger.info("The constraints are linear")
1232
+ else:
1233
+ hes = ca.Function("j", [X_sx], [ca.jacobian(ca.jacobian(g_sx, X_sx), X_sx)]).expand()
1234
+ if hes(np.nan).is_regular():
1235
+ logger.info("The constraints are quadratic")
1236
+ else:
1237
+ logger.info("The constraints are nonlinear")
1238
+
1239
+ @debug_check(DebugLevel.VERYHIGH)
1240
+ def __debug_check_linear_independence(self, lbx, ubx, lbg, ubg, nlp):
1241
+ x = nlp["x"]
1242
+ f = nlp["f"]
1243
+ g = nlp["g"]
1244
+
1245
+ expand_f_g = ca.Function("f_g", [x], [f, g]).expand()
1246
+ x_sx = ca.SX.sym("X", *x.shape)
1247
+ f_sx, g_sx = expand_f_g(x_sx)
1248
+
1249
+ x, f, g = x_sx, f_sx, g_sx
1250
+
1251
+ lbg = np.array(ca.vertsplit(ca.veccat(*lbg))).ravel()
1252
+ ubg = np.array(ca.vertsplit(ca.veccat(*ubg))).ravel()
1253
+
1254
+ # Find the linear constraints
1255
+ g_sjac = ca.Function("Af", [x], [ca.jtimes(g, x, x.ones(*x.shape))])
1256
+
1257
+ res = g_sjac(np.nan)
1258
+ res = np.array(res).ravel()
1259
+ g_is_linear = ~np.isnan(res)
1260
+
1261
+ # Find the rows in the jacobian with only a single entry
1262
+ g_jac_csr = ca.DM(ca.Function("tmp", [x], [g]).sparsity_jac(0, 0)).tocsc().tocsr()
1263
+ g_single_variable = np.diff(g_jac_csr.indptr) == 1
1264
+
1265
+ # Find the rows which are equality constraints
1266
+ g_eq_constraint = lbg == ubg
1267
+
1268
+ # The intersection of all selections are constraints like we want
1269
+ g_constant_assignment = g_is_linear & g_single_variable & g_eq_constraint
1270
+
1271
+ # Map of variable (index) to constraints/row numbers
1272
+ var_index_assignment = {}
1273
+ for i in range(g.size1()):
1274
+ if g_constant_assignment[i]:
1275
+ var_ind = g_jac_csr.getrow(i).indices[0]
1276
+ var_index_assignment.setdefault(var_ind, []).append(i)
1277
+
1278
+ var_names, named_x, named_f, named_g = self._debug_get_named_nlp(nlp)
1279
+
1280
+ for vi, g_inds in var_index_assignment.items():
1281
+ if len(g_inds) > 1:
1282
+ logger.info(
1283
+ "Variable '{}' has duplicate constraints setting its value:".format(
1284
+ var_names[vi]
1285
+ )
1286
+ )
1287
+ for g_i in g_inds:
1288
+ logger.info("row {}: {} = {}".format(g_i, named_g[g_i], lbg[g_i]))
1289
+
1290
+ # Find variables for which the bounds are equal, but also an equality
1291
+ # constraint is set. This would result in a constraint `1 = 1` with
1292
+ # the default IPOPT option `fixed_variable_treatment = make_parameter`
1293
+ x_inds = np.flatnonzero(lbx == ubx)
1294
+
1295
+ for vi in x_inds:
1296
+ if vi in var_index_assignment:
1297
+ logger.info(
1298
+ "Variable '{}' has equal bounds (value = {}), "
1299
+ "but also the following equality constraints:".format(var_names[vi], lbx[vi])
1300
+ )
1301
+ for g_i in var_index_assignment[vi]:
1302
+ logger.info("row {}: {} = {}".format(g_i, named_g[g_i], lbg[g_i]))