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.
- rtc_tools-2.7.3.dist-info/METADATA +53 -0
- rtc_tools-2.7.3.dist-info/RECORD +50 -0
- rtc_tools-2.7.3.dist-info/WHEEL +5 -0
- rtc_tools-2.7.3.dist-info/entry_points.txt +3 -0
- rtc_tools-2.7.3.dist-info/licenses/COPYING.LESSER +165 -0
- rtc_tools-2.7.3.dist-info/top_level.txt +1 -0
- rtctools/__init__.py +5 -0
- rtctools/_internal/__init__.py +0 -0
- rtctools/_internal/alias_tools.py +188 -0
- rtctools/_internal/caching.py +25 -0
- rtctools/_internal/casadi_helpers.py +99 -0
- rtctools/_internal/debug_check_helpers.py +41 -0
- rtctools/_version.py +21 -0
- rtctools/data/__init__.py +4 -0
- rtctools/data/csv.py +150 -0
- rtctools/data/interpolation/__init__.py +3 -0
- rtctools/data/interpolation/bspline.py +31 -0
- rtctools/data/interpolation/bspline1d.py +169 -0
- rtctools/data/interpolation/bspline2d.py +54 -0
- rtctools/data/netcdf.py +467 -0
- rtctools/data/pi.py +1236 -0
- rtctools/data/rtc.py +228 -0
- rtctools/data/storage.py +343 -0
- rtctools/optimization/__init__.py +0 -0
- rtctools/optimization/collocated_integrated_optimization_problem.py +3208 -0
- rtctools/optimization/control_tree_mixin.py +221 -0
- rtctools/optimization/csv_lookup_table_mixin.py +462 -0
- rtctools/optimization/csv_mixin.py +300 -0
- rtctools/optimization/goal_programming_mixin.py +769 -0
- rtctools/optimization/goal_programming_mixin_base.py +1094 -0
- rtctools/optimization/homotopy_mixin.py +165 -0
- rtctools/optimization/initial_state_estimation_mixin.py +89 -0
- rtctools/optimization/io_mixin.py +320 -0
- rtctools/optimization/linearization_mixin.py +33 -0
- rtctools/optimization/linearized_order_goal_programming_mixin.py +235 -0
- rtctools/optimization/min_abs_goal_programming_mixin.py +385 -0
- rtctools/optimization/modelica_mixin.py +482 -0
- rtctools/optimization/netcdf_mixin.py +177 -0
- rtctools/optimization/optimization_problem.py +1302 -0
- rtctools/optimization/pi_mixin.py +292 -0
- rtctools/optimization/planning_mixin.py +19 -0
- rtctools/optimization/single_pass_goal_programming_mixin.py +676 -0
- rtctools/optimization/timeseries.py +56 -0
- rtctools/rtctoolsapp.py +131 -0
- rtctools/simulation/__init__.py +0 -0
- rtctools/simulation/csv_mixin.py +171 -0
- rtctools/simulation/io_mixin.py +195 -0
- rtctools/simulation/pi_mixin.py +255 -0
- rtctools/simulation/simulation_problem.py +1293 -0
- 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]))
|