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,1293 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import itertools
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from typing import List, Union
|
|
9
|
+
|
|
10
|
+
# Python 3.9's importlib.metadata does not support the "group" parameter to
|
|
11
|
+
# entry_points yet.
|
|
12
|
+
if sys.version_info < (3, 10):
|
|
13
|
+
import importlib_metadata
|
|
14
|
+
else:
|
|
15
|
+
from importlib import metadata as importlib_metadata
|
|
16
|
+
|
|
17
|
+
import casadi as ca
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pymoca
|
|
20
|
+
import pymoca.backends.casadi.api
|
|
21
|
+
|
|
22
|
+
from rtctools._internal.alias_tools import AliasDict
|
|
23
|
+
from rtctools._internal.caching import cached
|
|
24
|
+
from rtctools._internal.debug_check_helpers import DebugLevel
|
|
25
|
+
from rtctools.data.storage import DataStoreAccessor
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("rtctools")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Variable:
|
|
31
|
+
"""
|
|
32
|
+
Modeled after the Variable class in pymoca.backends.casadi.model, with modifications to make it
|
|
33
|
+
easier for the common case in RTC-Tools to instantiate them.
|
|
34
|
+
|
|
35
|
+
That means:
|
|
36
|
+
- pass in name instead of ca.MX symbol
|
|
37
|
+
- only scalars are allowed (shape = (1, 1))
|
|
38
|
+
- no aliases
|
|
39
|
+
- no "python_type"
|
|
40
|
+
- able to specify nominal/min/max in constructor
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name, /, min=-np.inf, max=np.inf, nominal=1.0):
|
|
44
|
+
self.name = name
|
|
45
|
+
self.min = min
|
|
46
|
+
self.max = max
|
|
47
|
+
self.nominal = nominal
|
|
48
|
+
self._symbol = ca.MX.sym(name)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def symbol(self):
|
|
52
|
+
return self._symbol
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SimulationProblem(DataStoreAccessor):
|
|
56
|
+
"""
|
|
57
|
+
Implements the `BMI <http://csdms.colorado.edu/wiki/BMI_Description>`_ Interface.
|
|
58
|
+
|
|
59
|
+
Base class for all Simulation problems. Loads the Modelica Model.
|
|
60
|
+
|
|
61
|
+
:cvar modelica_library_folders: Folders containing any referenced Modelica libraries. Default
|
|
62
|
+
is an empty list.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_debug_check_level = DebugLevel.MEDIUM
|
|
67
|
+
_debug_check_options = {}
|
|
68
|
+
|
|
69
|
+
# Folders in which the referenced Modelica libraries are found
|
|
70
|
+
modelica_library_folders = []
|
|
71
|
+
|
|
72
|
+
# Force workaround for delay support by assuming zero delay. This flag
|
|
73
|
+
# will be removed when proper delay support is added.
|
|
74
|
+
_force_zero_delay = False
|
|
75
|
+
|
|
76
|
+
def __init__(self, **kwargs):
|
|
77
|
+
# Check arguments
|
|
78
|
+
assert "model_folder" in kwargs
|
|
79
|
+
|
|
80
|
+
# Log pymoca version
|
|
81
|
+
logger.debug("Using pymoca {}.".format(pymoca.__version__))
|
|
82
|
+
|
|
83
|
+
# Transfer model from the Modelica .mo file to CasADi using pymoca
|
|
84
|
+
if "model_name" in kwargs:
|
|
85
|
+
model_name = kwargs["model_name"]
|
|
86
|
+
else:
|
|
87
|
+
if hasattr(self, "model_name"):
|
|
88
|
+
model_name = self.model_name
|
|
89
|
+
else:
|
|
90
|
+
model_name = self.__class__.__name__
|
|
91
|
+
|
|
92
|
+
# Load model from pymoca backend
|
|
93
|
+
compiler_options = self.compiler_options()
|
|
94
|
+
logger.info(f"Loading/compiling model {model_name}.")
|
|
95
|
+
try:
|
|
96
|
+
self.__pymoca_model = pymoca.backends.casadi.api.transfer_model(
|
|
97
|
+
kwargs["model_folder"], model_name, compiler_options
|
|
98
|
+
)
|
|
99
|
+
except RuntimeError as error:
|
|
100
|
+
if compiler_options.get("cache", False):
|
|
101
|
+
raise error
|
|
102
|
+
compiler_options["cache"] = False
|
|
103
|
+
logger.warning(f"Loading model {model_name} using a cache file failed: {error}.")
|
|
104
|
+
logger.info(f"Compiling model {model_name}.")
|
|
105
|
+
self.__pymoca_model = pymoca.backends.casadi.api.transfer_model(
|
|
106
|
+
kwargs["model_folder"], model_name, compiler_options
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Extract the CasADi MX variables used in the model
|
|
110
|
+
self.__mx = {}
|
|
111
|
+
self.__mx["time"] = [self.__pymoca_model.time]
|
|
112
|
+
self.__mx["states"] = [v.symbol for v in self.__pymoca_model.states]
|
|
113
|
+
self.__mx["derivatives"] = [v.symbol for v in self.__pymoca_model.der_states]
|
|
114
|
+
self.__mx["algebraics"] = [v.symbol for v in self.__pymoca_model.alg_states]
|
|
115
|
+
self.__mx["parameters"] = [v.symbol for v in self.__pymoca_model.parameters]
|
|
116
|
+
self.__mx["constant_inputs"] = []
|
|
117
|
+
self.__mx["lookup_tables"] = []
|
|
118
|
+
|
|
119
|
+
for v in self.__pymoca_model.inputs:
|
|
120
|
+
if v.symbol.name() in self.__pymoca_model.delay_states:
|
|
121
|
+
# Delayed feedback variables are local to each ensemble, and
|
|
122
|
+
# therefore belong to the collection of algebraic variables,
|
|
123
|
+
# rather than to the control inputs.
|
|
124
|
+
self.__mx["algebraics"].append(v.symbol)
|
|
125
|
+
else:
|
|
126
|
+
if v.symbol.name() in kwargs.get("lookup_tables", []):
|
|
127
|
+
self.__mx["lookup_tables"].append(v.symbol)
|
|
128
|
+
else:
|
|
129
|
+
# All inputs are constant inputs
|
|
130
|
+
self.__mx["constant_inputs"].append(v.symbol)
|
|
131
|
+
|
|
132
|
+
# Set timestep size
|
|
133
|
+
self._dt_is_fixed = False
|
|
134
|
+
self.__dt = None
|
|
135
|
+
fixed_dt = kwargs.get("fixed_dt", None)
|
|
136
|
+
if fixed_dt is not None:
|
|
137
|
+
self._dt_is_fixed = True
|
|
138
|
+
self.__dt = fixed_dt
|
|
139
|
+
|
|
140
|
+
# Add auxiliary variables for keeping track of delay expressions to the algebraic states
|
|
141
|
+
n_delay_states = len(self.__pymoca_model.delay_states)
|
|
142
|
+
self.__delay_times = []
|
|
143
|
+
if n_delay_states > 0:
|
|
144
|
+
if fixed_dt is None and not self._force_zero_delay:
|
|
145
|
+
raise ValueError("fixed_dt should be set when using delay equations.")
|
|
146
|
+
self.__delay_times = self._get_delay_times()
|
|
147
|
+
delay_expression_states = self._create_delay_expression_states()
|
|
148
|
+
self.__mx["algebraics"] += delay_expression_states
|
|
149
|
+
|
|
150
|
+
# Log variables in debug mode
|
|
151
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
152
|
+
logger.debug(
|
|
153
|
+
"SimulationProblem: Found states {}".format(
|
|
154
|
+
", ".join([var.name() for var in self.__mx["states"]])
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
logger.debug(
|
|
158
|
+
"SimulationProblem: Found derivatives {}".format(
|
|
159
|
+
", ".join([var.name() for var in self.__mx["derivatives"]])
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
logger.debug(
|
|
163
|
+
"SimulationProblem: Found algebraics {}".format(
|
|
164
|
+
", ".join([var.name() for var in self.__mx["algebraics"]])
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
logger.debug(
|
|
168
|
+
"SimulationProblem: Found constant inputs {}".format(
|
|
169
|
+
", ".join([var.name() for var in self.__mx["constant_inputs"]])
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
logger.debug(
|
|
173
|
+
"SimulationProblem: Found parameters {}".format(
|
|
174
|
+
", ".join([var.name() for var in self.__mx["parameters"]])
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Get the extra variables that are user defined
|
|
179
|
+
self.__extra_variables = self.extra_variables()
|
|
180
|
+
self.__extra_variables_symbols = [v.symbol for v in self.__extra_variables]
|
|
181
|
+
|
|
182
|
+
# Store the types in an AliasDict
|
|
183
|
+
self.__python_types = AliasDict(self.alias_relation)
|
|
184
|
+
model_variable_types = [
|
|
185
|
+
"states",
|
|
186
|
+
"der_states",
|
|
187
|
+
"alg_states",
|
|
188
|
+
"inputs",
|
|
189
|
+
"constants",
|
|
190
|
+
"parameters",
|
|
191
|
+
]
|
|
192
|
+
for t in model_variable_types:
|
|
193
|
+
for v in getattr(self.__pymoca_model, t):
|
|
194
|
+
self.__python_types[v.symbol.name()] = v.python_type
|
|
195
|
+
|
|
196
|
+
# Store the nominals in an AliasDict
|
|
197
|
+
self.__nominals = AliasDict(self.alias_relation)
|
|
198
|
+
for v in itertools.chain(self.__pymoca_model.states, self.__pymoca_model.alg_states):
|
|
199
|
+
sym_name = v.symbol.name()
|
|
200
|
+
|
|
201
|
+
# If the nominal is 0.0 or 1.0 or -1.0, ignore: get_variable_nominal returns a default
|
|
202
|
+
# of 1.0
|
|
203
|
+
# TODO: handle nominal vectors (update() will need to load them)
|
|
204
|
+
if (
|
|
205
|
+
ca.MX(v.nominal).is_zero()
|
|
206
|
+
or ca.MX(v.nominal - 1).is_zero()
|
|
207
|
+
or ca.MX(v.nominal + 1).is_zero()
|
|
208
|
+
):
|
|
209
|
+
continue
|
|
210
|
+
else:
|
|
211
|
+
if ca.MX(v.nominal).size1() != 1:
|
|
212
|
+
logger.error("Vector Nominals not supported yet. ({})".format(sym_name))
|
|
213
|
+
self.__nominals[sym_name] = ca.fabs(v.nominal)
|
|
214
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
215
|
+
logger.debug(
|
|
216
|
+
"SimulationProblem: Setting nominal value for variable {} to {}".format(
|
|
217
|
+
sym_name, self.__nominals[sym_name]
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for v in self.__extra_variables:
|
|
222
|
+
self.__nominals[v.name] = v.nominal
|
|
223
|
+
|
|
224
|
+
# Initialize DAE and initial residuals
|
|
225
|
+
variable_lists = ["states", "der_states", "alg_states", "inputs", "constants", "parameters"]
|
|
226
|
+
function_arguments = [self.__pymoca_model.time] + [
|
|
227
|
+
ca.veccat(*[v.symbol for v in getattr(self.__pymoca_model, variable_list)])
|
|
228
|
+
for variable_list in variable_lists
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
self.__dae_residual = self.__pymoca_model.dae_residual_function(*function_arguments)
|
|
232
|
+
|
|
233
|
+
if self.__dae_residual is None:
|
|
234
|
+
# DAE is empty, that can happen if we add the only (non-aliasing) equations
|
|
235
|
+
# in Python.
|
|
236
|
+
self.__dae_residual = ca.MX()
|
|
237
|
+
|
|
238
|
+
self.__initial_residual = self.__pymoca_model.initial_residual_function(*function_arguments)
|
|
239
|
+
if self.__initial_residual is None:
|
|
240
|
+
self.__initial_residual = ca.MX()
|
|
241
|
+
|
|
242
|
+
# Construct state vector
|
|
243
|
+
self.__sym_list = (
|
|
244
|
+
self.__mx["states"]
|
|
245
|
+
+ self.__mx["algebraics"]
|
|
246
|
+
+ self.__mx["derivatives"]
|
|
247
|
+
+ self.__extra_variables_symbols
|
|
248
|
+
+ self.__mx["time"]
|
|
249
|
+
+ self.__mx["constant_inputs"]
|
|
250
|
+
+ self.__mx["parameters"]
|
|
251
|
+
)
|
|
252
|
+
n_elements = np.array([var.numel() for var in self.__sym_list])
|
|
253
|
+
i_end = n_elements.cumsum()
|
|
254
|
+
i_start = np.array([0, *(i_end[:-1])])
|
|
255
|
+
self.__state_vector = np.full(n_elements.sum(), np.nan)
|
|
256
|
+
|
|
257
|
+
# A very handy index
|
|
258
|
+
self.__n_state_symbols = (
|
|
259
|
+
len(self.__mx["states"])
|
|
260
|
+
+ len(self.__mx["algebraics"])
|
|
261
|
+
+ len(self.__mx["derivatives"])
|
|
262
|
+
+ len(self.__extra_variables)
|
|
263
|
+
)
|
|
264
|
+
self.__n_states = i_end[self.__n_state_symbols - 1]
|
|
265
|
+
|
|
266
|
+
# NOTE: Backwards compatibility allowing set_var() for parameters. These
|
|
267
|
+
# variables check that this is only done before calling initialize().
|
|
268
|
+
self.__parameters = AliasDict(self.alias_relation)
|
|
269
|
+
self.__parameters.update({v.name(): v for v in self.__mx["parameters"]})
|
|
270
|
+
self.__parameters_set_var = True
|
|
271
|
+
|
|
272
|
+
# Construct a dict to look up symbols by name (or iterate over)
|
|
273
|
+
self.__sym_dict = OrderedDict(((sym.name(), sym) for sym in self.__sym_list))
|
|
274
|
+
|
|
275
|
+
# Generate a dictionary that we can use to lookup the index in the state vector.
|
|
276
|
+
# To avoid repeated and relatively expensive `canonical_signed` calls, we
|
|
277
|
+
# make a dictionary for all variables and their aliases.
|
|
278
|
+
self.__i_start = {}
|
|
279
|
+
self.__i_end = {}
|
|
280
|
+
for i, k in enumerate(self.__sym_dict.keys()):
|
|
281
|
+
for alias in self.alias_relation.aliases(k):
|
|
282
|
+
if alias.startswith("-"):
|
|
283
|
+
self.__i_start[alias[1:]] = (i_start[i], -1.0)
|
|
284
|
+
self.__i_end[alias[1:]] = (i_end[i], -1.0)
|
|
285
|
+
else:
|
|
286
|
+
self.__i_start[alias] = (i_start[i], 1.0)
|
|
287
|
+
self.__i_end[alias] = (i_end[i], 1.0)
|
|
288
|
+
self.__indices = self.__i_start
|
|
289
|
+
|
|
290
|
+
# Call parent class for default behaviour.
|
|
291
|
+
super().__init__(**kwargs)
|
|
292
|
+
|
|
293
|
+
def _get_delay_times(self):
|
|
294
|
+
"""
|
|
295
|
+
Get the delay times for each delay equation.
|
|
296
|
+
"""
|
|
297
|
+
if self._force_zero_delay:
|
|
298
|
+
return [0] * len(self.__pymoca_model.delay_states)
|
|
299
|
+
parameter_symbols = [v.symbol for v in self.__pymoca_model.parameters]
|
|
300
|
+
parameter_values = [v.value for v in self.__pymoca_model.parameters]
|
|
301
|
+
delay_time_expressions = [
|
|
302
|
+
delay_arg.duration for delay_arg in self.__pymoca_model.delay_arguments
|
|
303
|
+
]
|
|
304
|
+
delay_time_fun = ca.Function(
|
|
305
|
+
"delay_time_function", parameter_symbols, delay_time_expressions
|
|
306
|
+
)
|
|
307
|
+
delay_time_values = delay_time_fun(*parameter_values)
|
|
308
|
+
if len(delay_time_expressions) == 1:
|
|
309
|
+
return [delay_time_values]
|
|
310
|
+
return list(delay_time_values)
|
|
311
|
+
|
|
312
|
+
def _create_delay_expression_states(self):
|
|
313
|
+
"""
|
|
314
|
+
Create auxiliary states for delay equations.
|
|
315
|
+
|
|
316
|
+
Create states to keep track of the history of delay expressions.
|
|
317
|
+
For example, if we have a delay equation of the form
|
|
318
|
+
|
|
319
|
+
.. math::
|
|
320
|
+
x = delay(5 * y, 2 * dt),
|
|
321
|
+
|
|
322
|
+
Then we need variables to store :math:`5 * y` at time :math`t` and time :math:`t - dt`
|
|
323
|
+
(For each state, we also store the previous value,
|
|
324
|
+
so if we have a state for :math:`5 * y` at :math:`t - dt`,
|
|
325
|
+
then its previous value is :math:`5 * y` at :math:`t - 2 * dt`).
|
|
326
|
+
"""
|
|
327
|
+
delay_expression_states = []
|
|
328
|
+
for delay_state, delay_time in zip(self.__pymoca_model.delay_states, self.__delay_times):
|
|
329
|
+
if delay_time > 0:
|
|
330
|
+
n_previous_values = int(np.ceil(delay_time / self.get_time_step()))
|
|
331
|
+
else:
|
|
332
|
+
n_previous_values = 1
|
|
333
|
+
expression_state = delay_state + "_expr"
|
|
334
|
+
expression_symbol = ca.MX.sym(expression_state, n_previous_values)
|
|
335
|
+
delay_expression_states.append(expression_symbol)
|
|
336
|
+
return delay_expression_states
|
|
337
|
+
|
|
338
|
+
def initialize(self, config_file=None):
|
|
339
|
+
"""
|
|
340
|
+
Initialize state vector with default values
|
|
341
|
+
|
|
342
|
+
Initial values are first read from the given Modelica files.
|
|
343
|
+
If an initial value equals zero or is not provided by a Modelica file,
|
|
344
|
+
and the variable is not marked as fixed,
|
|
345
|
+
then the initial value is tried to be set with the initial_state method.
|
|
346
|
+
When using CSVMixin, this method by default looks for initial values
|
|
347
|
+
in an initial_state.csv file.
|
|
348
|
+
Furthermore, if a variable is not marked as fixed
|
|
349
|
+
and no initial value is given by the initial_state method,
|
|
350
|
+
the initial value can be overwritten using the seed method.
|
|
351
|
+
When a variable is marked as fixed, the initial value is only read from the Modelica file.
|
|
352
|
+
|
|
353
|
+
:param config_file: Path to an initialization file.
|
|
354
|
+
"""
|
|
355
|
+
if config_file:
|
|
356
|
+
# TODO read start and stop time from config_file and call:
|
|
357
|
+
# self.setup_experiment(start,stop)
|
|
358
|
+
# for now, assume that setup_experiment was called beforehand
|
|
359
|
+
raise NotImplementedError
|
|
360
|
+
|
|
361
|
+
# Short-hand notation for the model
|
|
362
|
+
model = self.__pymoca_model
|
|
363
|
+
|
|
364
|
+
# Set values of parameters defined in the model into the state vector
|
|
365
|
+
for var in self.__pymoca_model.parameters:
|
|
366
|
+
# First check to see if parameter is already set (this allows child classes to override
|
|
367
|
+
# model defaults)
|
|
368
|
+
if np.isfinite(self.get_var(var.symbol.name())):
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
# Also test to see if the value is constant
|
|
372
|
+
if isinstance(var.value, ca.MX) and not var.value.is_constant():
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
# Try to extract the value
|
|
376
|
+
try:
|
|
377
|
+
# Extract the value as a python type
|
|
378
|
+
val = var.python_type(var.value)
|
|
379
|
+
except ValueError:
|
|
380
|
+
# var.value is a float NaN being cast to non-float
|
|
381
|
+
continue
|
|
382
|
+
else:
|
|
383
|
+
# If val is finite, we set it
|
|
384
|
+
if np.isfinite(val):
|
|
385
|
+
logger.debug(
|
|
386
|
+
"SimulationProblem: Setting parameter {} = {}".format(
|
|
387
|
+
var.symbol.name(), val
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
self.set_var(var.symbol.name(), val)
|
|
391
|
+
|
|
392
|
+
# Nominals can be symbolic, written in terms of parameters. After all
|
|
393
|
+
# parameter values are known, we evaluate the numeric values of the
|
|
394
|
+
# nominals.
|
|
395
|
+
nominal_vars = list(self.__nominals.keys())
|
|
396
|
+
symbolic_nominals = ca.vertcat(*[self.get_variable_nominal(v) for v in nominal_vars])
|
|
397
|
+
nominal_evaluator = ca.Function(
|
|
398
|
+
"nominal_evaluator", self.__mx["parameters"], [symbolic_nominals]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
n_parameters = len(self.__mx["parameters"])
|
|
402
|
+
if n_parameters > 0:
|
|
403
|
+
[evaluated_nominals] = nominal_evaluator.call(self.__state_vector[-n_parameters:])
|
|
404
|
+
else:
|
|
405
|
+
[evaluated_nominals] = nominal_evaluator.call([])
|
|
406
|
+
|
|
407
|
+
evaluated_nominals = np.array(evaluated_nominals).ravel()
|
|
408
|
+
|
|
409
|
+
nominal_dict = dict(zip(nominal_vars, evaluated_nominals))
|
|
410
|
+
|
|
411
|
+
self.__nominals.update(nominal_dict)
|
|
412
|
+
|
|
413
|
+
# The variables that need a mutually consistent initial condition
|
|
414
|
+
X = ca.vertcat(*self.__sym_list[: self.__n_state_symbols])
|
|
415
|
+
X_prev = ca.vertcat(
|
|
416
|
+
*[
|
|
417
|
+
ca.MX.sym(sym.name() + "_prev", sym.shape)
|
|
418
|
+
for sym in self.__sym_list[: self.__n_state_symbols]
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Assemble initial residuals and set values from start attributes into the state vector
|
|
423
|
+
minimized_residuals = []
|
|
424
|
+
for var in itertools.chain(self.__pymoca_model.states, self.__pymoca_model.alg_states):
|
|
425
|
+
var_name = var.symbol.name()
|
|
426
|
+
var_nominal = self.get_variable_nominal(var_name)
|
|
427
|
+
start_values = {}
|
|
428
|
+
|
|
429
|
+
# Attempt to cast var.start to python type
|
|
430
|
+
mx_start = ca.MX(var.start)
|
|
431
|
+
if mx_start.is_constant():
|
|
432
|
+
# cast var.start to python type
|
|
433
|
+
start_value_pymoca = var.python_type(mx_start.to_DM())
|
|
434
|
+
if start_value_pymoca is not None and start_value_pymoca != 0:
|
|
435
|
+
start_values["modelica"] = start_value_pymoca
|
|
436
|
+
else:
|
|
437
|
+
start_values["modelica"] = mx_start
|
|
438
|
+
|
|
439
|
+
if not var.fixed:
|
|
440
|
+
# To make initialization easier, we allow setting initial states by providing
|
|
441
|
+
# timeseries with names that match a symbol in the model. We only check for this
|
|
442
|
+
# matching if the start and fixed attributes were left as default
|
|
443
|
+
try:
|
|
444
|
+
start_values["initial_state"] = self.initial_state()[var_name]
|
|
445
|
+
except KeyError:
|
|
446
|
+
pass
|
|
447
|
+
else:
|
|
448
|
+
# An initial state was found- add it to the constrained residuals
|
|
449
|
+
logger.debug(
|
|
450
|
+
"Initialize: Added {} = {} to initial equations "
|
|
451
|
+
"(found matching timeseries).".format(
|
|
452
|
+
var_name, start_values["initial_state"]
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
# Set var to be fixed
|
|
456
|
+
var.fixed = True
|
|
457
|
+
|
|
458
|
+
if not var.fixed:
|
|
459
|
+
# To make initialization easier, we allow setting initial guesses by providing
|
|
460
|
+
# timeseries with names that match a symbol in the model. We only check for this
|
|
461
|
+
# matching if the start and fixed attributes were left as default
|
|
462
|
+
try:
|
|
463
|
+
start_values["seed"] = self.seed()[var_name]
|
|
464
|
+
except KeyError:
|
|
465
|
+
pass
|
|
466
|
+
else:
|
|
467
|
+
# An initial state was found- add it to the constrained residuals
|
|
468
|
+
logger.debug(
|
|
469
|
+
"Initialize: Added {} = {} as initial guess "
|
|
470
|
+
"(found matching timeseries).".format(var_name, start_values["seed"])
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Set the start value based on the different inputs.
|
|
474
|
+
if "seed" in start_values:
|
|
475
|
+
input_source = "seed"
|
|
476
|
+
source_description = "seed method"
|
|
477
|
+
elif "modelica" in start_values:
|
|
478
|
+
input_source = "modelica"
|
|
479
|
+
source_description = "modelica file"
|
|
480
|
+
elif "initial_state" in start_values:
|
|
481
|
+
input_source = "initial_state"
|
|
482
|
+
source_description = "initial_state method (typically reads initial_state.csv)"
|
|
483
|
+
else:
|
|
484
|
+
start_values["modelica"] = start_value_pymoca
|
|
485
|
+
input_source = "modelica"
|
|
486
|
+
source_description = "modelica file or default value"
|
|
487
|
+
start_val = start_values.get(input_source, None)
|
|
488
|
+
start_is_numeric = start_val is not None and not isinstance(start_val, ca.MX)
|
|
489
|
+
numeric_start_val = start_val if start_is_numeric else 0.0
|
|
490
|
+
if len(start_values) > 1:
|
|
491
|
+
logger.warning(
|
|
492
|
+
"Initialize: Multiple initial values for {} are provided: {}.".format(
|
|
493
|
+
var_name, start_values
|
|
494
|
+
)
|
|
495
|
+
+ " Value from {} will be used to continue.".format(source_description)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Attempt to set start_val in the state vector. Default to zero if unknown.
|
|
499
|
+
try:
|
|
500
|
+
self.set_var(var_name, numeric_start_val)
|
|
501
|
+
except KeyError:
|
|
502
|
+
logger.warning(
|
|
503
|
+
"Initialize: {} not found in state vector. Initial value of {} not set.".format(
|
|
504
|
+
var_name, numeric_start_val
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Add a residual for the difference between the state and its starting expression
|
|
509
|
+
start_expr = start_val
|
|
510
|
+
min_is_symbolic = isinstance(var.min, ca.MX)
|
|
511
|
+
max_is_symbolic = isinstance(var.max, ca.MX)
|
|
512
|
+
if var.fixed:
|
|
513
|
+
# Set bounds to be equal to each other, such that IPOPT can
|
|
514
|
+
# turn the decision variable into a parameter.
|
|
515
|
+
if min_is_symbolic or max_is_symbolic or var.min != -np.inf or var.max != np.inf:
|
|
516
|
+
logger.info(
|
|
517
|
+
"Initialize: bounds of {} will be overwritten".format(var_name)
|
|
518
|
+
+ " by the start value given by {}.".format(source_description)
|
|
519
|
+
)
|
|
520
|
+
var.min = start_expr
|
|
521
|
+
var.max = start_expr
|
|
522
|
+
else:
|
|
523
|
+
# minimize residual
|
|
524
|
+
minimized_residuals.append((var.symbol - start_expr) / var_nominal)
|
|
525
|
+
|
|
526
|
+
# Check that the start_value is in between the variable bounds.
|
|
527
|
+
if start_is_numeric and not min_is_symbolic and not max_is_symbolic:
|
|
528
|
+
if not (var.min <= start_val and start_val <= var.max):
|
|
529
|
+
logger.log(
|
|
530
|
+
(
|
|
531
|
+
logging.WARNING
|
|
532
|
+
if source_description != "modelica file or default value"
|
|
533
|
+
else logging.DEBUG
|
|
534
|
+
),
|
|
535
|
+
f"Initialize: start value {var_name} = {start_val} "
|
|
536
|
+
f"is not in between bounds {var.min} and {var.max} and will be adjusted.",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Default start var for ders is zero
|
|
540
|
+
for der_var in self.__mx["derivatives"]:
|
|
541
|
+
self.set_var(der_var.name(), 0.0)
|
|
542
|
+
|
|
543
|
+
# Residuals for initial values for the delay states / expressions.
|
|
544
|
+
for delay_state, delay_argument in zip(model.delay_states, model.delay_arguments):
|
|
545
|
+
expression_state = delay_state + "_expr"
|
|
546
|
+
i_delay_state, _ = self.__indices[delay_state]
|
|
547
|
+
i_expr_start, _ = self.__i_start[expression_state]
|
|
548
|
+
i_expr_end, _ = self.__i_end[expression_state]
|
|
549
|
+
minimized_residuals.append(X[i_expr_start:i_expr_end] - delay_argument.expr)
|
|
550
|
+
minimized_residuals.append(X[i_delay_state] - delay_argument.expr)
|
|
551
|
+
|
|
552
|
+
# Warn for nans in state vector (verify we didn't miss anything)
|
|
553
|
+
self.__warn_for_nans()
|
|
554
|
+
|
|
555
|
+
# Optionally encourage a steady-state initial condition
|
|
556
|
+
if getattr(self, "encourage_steady_state_initial_conditions", False):
|
|
557
|
+
# add penalty for der(var) != 0.0
|
|
558
|
+
for d in self.__mx["derivatives"]:
|
|
559
|
+
logger.debug("Added {} to the minimized residuals.".format(d.name()))
|
|
560
|
+
minimized_residuals.append(d)
|
|
561
|
+
|
|
562
|
+
# Make minimized_residuals into a single symbolic object
|
|
563
|
+
if minimized_residuals:
|
|
564
|
+
minimized_residual = ca.vertcat(*minimized_residuals)
|
|
565
|
+
else:
|
|
566
|
+
# DAE is empty
|
|
567
|
+
minimized_residual = ca.MX(0)
|
|
568
|
+
|
|
569
|
+
# Extra equations
|
|
570
|
+
extra_equations = self.extra_equations()
|
|
571
|
+
|
|
572
|
+
# Assemble symbolics needed to make a function describing the initial condition of the model
|
|
573
|
+
# We constrain every entry in this MX to zero
|
|
574
|
+
equality_constraints = ca.vertcat(
|
|
575
|
+
self.__dae_residual, self.__initial_residual, *extra_equations
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Make a list of unscaled symbols and a list of their scaled equivalent
|
|
579
|
+
unscaled_symbols = []
|
|
580
|
+
scaled_symbols = []
|
|
581
|
+
for sym_name, nominal in self.__nominals.items():
|
|
582
|
+
# Note that sym_name is always a canonical state
|
|
583
|
+
index, _ = self.__indices[sym_name]
|
|
584
|
+
|
|
585
|
+
# If the symbol is a state, Add the symbol to the lists
|
|
586
|
+
if index <= self.__n_states:
|
|
587
|
+
unscaled_symbols.append(X[index])
|
|
588
|
+
scaled_symbols.append(X[index] * nominal)
|
|
589
|
+
|
|
590
|
+
# Also scale previous states
|
|
591
|
+
unscaled_symbols.append(X_prev[index])
|
|
592
|
+
scaled_symbols.append(X_prev[index] * nominal)
|
|
593
|
+
|
|
594
|
+
unscaled_symbols = ca.vertcat(*unscaled_symbols)
|
|
595
|
+
scaled_symbols = ca.vertcat(*scaled_symbols)
|
|
596
|
+
|
|
597
|
+
# Substitute unscaled terms for scaled terms
|
|
598
|
+
equality_constraints = ca.substitute(equality_constraints, unscaled_symbols, scaled_symbols)
|
|
599
|
+
minimized_residual = ca.substitute(minimized_residual, unscaled_symbols, scaled_symbols)
|
|
600
|
+
|
|
601
|
+
logger.debug("SimulationProblem: Initial Equations are " + str(equality_constraints))
|
|
602
|
+
logger.debug("SimulationProblem: Minimized Residuals are " + str(minimized_residual))
|
|
603
|
+
|
|
604
|
+
# State bounds can be symbolic, written in terms of parameters. After all
|
|
605
|
+
# parameter values are known, we evaluate the numeric values of bounds.
|
|
606
|
+
bound_vars = (
|
|
607
|
+
self.__pymoca_model.states
|
|
608
|
+
+ self.__pymoca_model.alg_states
|
|
609
|
+
+ self.__pymoca_model.der_states
|
|
610
|
+
+ self.__extra_variables
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
symbolic_bounds = ca.vertcat(*[ca.horzcat(v.min, v.max) for v in bound_vars])
|
|
614
|
+
bound_evaluator = ca.Function("bound_evaluator", self.__mx["parameters"], [symbolic_bounds])
|
|
615
|
+
|
|
616
|
+
# Evaluate bounds using values of parameters
|
|
617
|
+
n_parameters = len(self.__mx["parameters"])
|
|
618
|
+
if n_parameters > 0:
|
|
619
|
+
[evaluated_bounds] = bound_evaluator.call(self.__state_vector[-n_parameters:])
|
|
620
|
+
else:
|
|
621
|
+
[evaluated_bounds] = bound_evaluator.call([])
|
|
622
|
+
|
|
623
|
+
# Scale the bounds with the nominals
|
|
624
|
+
nominals = []
|
|
625
|
+
for var in bound_vars:
|
|
626
|
+
nominals.append(self.get_variable_nominal(var.symbol.name()))
|
|
627
|
+
|
|
628
|
+
evaluated_bounds = np.array(evaluated_bounds) / np.array(nominals)[:, None]
|
|
629
|
+
|
|
630
|
+
# Update with the bounds of delayed states / expressions
|
|
631
|
+
if model.delay_states:
|
|
632
|
+
i_start_first_delay_state, _ = self.__indices[model.delay_states[0]]
|
|
633
|
+
i_end_last_delay_expr, _ = self.__i_end[model.delay_states[-1] + "_expr"]
|
|
634
|
+
n_delay = i_end_last_delay_expr - i_start_first_delay_state
|
|
635
|
+
delay_bounds = np.array([-np.inf, np.inf] * n_delay).reshape((n_delay, 2))
|
|
636
|
+
# offset = len(self.__pymoca_model.states) + len(self.__pymoca_model.alg_states)
|
|
637
|
+
offset = i_start_first_delay_state
|
|
638
|
+
evaluated_bounds = np.vstack(
|
|
639
|
+
(evaluated_bounds[:offset, :], delay_bounds, evaluated_bounds[offset:, :])
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Construct arrays of state bounds (used in the initialize() nlp, but not in __do_step
|
|
643
|
+
# rootfinder)
|
|
644
|
+
self.__lbx = evaluated_bounds[:, 0]
|
|
645
|
+
self.__ubx = evaluated_bounds[:, 1]
|
|
646
|
+
|
|
647
|
+
# Constrain model equation residuals to zero
|
|
648
|
+
lbg = np.zeros(equality_constraints.size1())
|
|
649
|
+
ubg = np.zeros(equality_constraints.size1())
|
|
650
|
+
|
|
651
|
+
# Construct objective function from the input residual
|
|
652
|
+
objective_function = ca.dot(minimized_residual, minimized_residual)
|
|
653
|
+
|
|
654
|
+
# Substitute constants and parameters
|
|
655
|
+
const_and_par = ca.vertcat(
|
|
656
|
+
*self.__mx["time"], *self.__mx["constant_inputs"], *self.__mx["parameters"]
|
|
657
|
+
)
|
|
658
|
+
const_and_par_values = self.__state_vector[self.__n_states :]
|
|
659
|
+
|
|
660
|
+
objective_function = ca.substitute(objective_function, const_and_par, const_and_par_values)
|
|
661
|
+
equality_constraints = ca.substitute(
|
|
662
|
+
equality_constraints, const_and_par, const_and_par_values
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Construct nlp and solver to find initial state using ipopt
|
|
666
|
+
# Note that some operations cannot be expanded, e.g. ca.interpolant. So
|
|
667
|
+
# we _try_ to expand, but fall back on ca.MX evaluation if we cannot.
|
|
668
|
+
try:
|
|
669
|
+
expand_f_g = ca.Function("f", [X], [objective_function, equality_constraints]).expand()
|
|
670
|
+
except RuntimeError as e:
|
|
671
|
+
if "eval_sx" not in str(e):
|
|
672
|
+
raise
|
|
673
|
+
else:
|
|
674
|
+
logger.info(
|
|
675
|
+
"Cannot expand objective/constraints to SX, falling back to MX evaluation"
|
|
676
|
+
)
|
|
677
|
+
nlp = {"x": X, "f": objective_function, "g": equality_constraints}
|
|
678
|
+
else:
|
|
679
|
+
X_sx = ca.SX.sym("X", X.shape)
|
|
680
|
+
objective_function_sx, equality_constraints_sx = expand_f_g(X_sx)
|
|
681
|
+
nlp = {"x": X_sx, "f": objective_function_sx, "g": equality_constraints_sx}
|
|
682
|
+
|
|
683
|
+
solver = ca.nlpsol("solver", "ipopt", nlp, self.solver_options())
|
|
684
|
+
|
|
685
|
+
# Construct guess
|
|
686
|
+
guess = ca.vertcat(*np.nan_to_num(self.__state_vector[: self.__n_states]))
|
|
687
|
+
|
|
688
|
+
# Find initial state
|
|
689
|
+
initial_state = solver(x0=guess, lbx=self.__lbx, ubx=self.__ubx, lbg=lbg, ubg=ubg)
|
|
690
|
+
|
|
691
|
+
# If unsuccessful, stop.
|
|
692
|
+
return_status = solver.stats()["return_status"]
|
|
693
|
+
if return_status not in {"Solve_Succeeded", "Solved_To_Acceptable_Level"}:
|
|
694
|
+
if return_status == "Infeasible_Problem_Detected":
|
|
695
|
+
message = (
|
|
696
|
+
"Initialization Failed with return status: {}. ".format(return_status)
|
|
697
|
+
+ "This means no initial state could be found "
|
|
698
|
+
+ "that satisfies all equations and constraints."
|
|
699
|
+
)
|
|
700
|
+
else:
|
|
701
|
+
message = "Initialization Failed with return status: {}. ".format(return_status)
|
|
702
|
+
raise Exception(message)
|
|
703
|
+
|
|
704
|
+
# Update state vector with initial conditions
|
|
705
|
+
self.__state_vector[: self.__n_states] = initial_state["x"][: self.__n_states].T
|
|
706
|
+
|
|
707
|
+
# make a copy of the initialized initial state vector in case we want to run the model again
|
|
708
|
+
self.__initialized_state_vector = copy.deepcopy(self.__state_vector)
|
|
709
|
+
|
|
710
|
+
# Warn for nans in state vector after initialization
|
|
711
|
+
self.__warn_for_nans()
|
|
712
|
+
|
|
713
|
+
# No longer allow setting parameters with set_var(), as we want to be
|
|
714
|
+
# clear that that does not work
|
|
715
|
+
self.__parameters_set_var = False
|
|
716
|
+
|
|
717
|
+
self.__parameter_names_including_aliases = set()
|
|
718
|
+
for p in self.__parameters.keys():
|
|
719
|
+
self.__parameter_names_including_aliases |= self.alias_relation.aliases(p)
|
|
720
|
+
|
|
721
|
+
# Construct the rootfinder
|
|
722
|
+
|
|
723
|
+
# Assemble some symbolics, including those needed for a backwards Euler derivative
|
|
724
|
+
# approximation
|
|
725
|
+
dt = ca.MX.sym("delta_t")
|
|
726
|
+
parameters = ca.vertcat(*self.__mx["parameters"])
|
|
727
|
+
if n_parameters > 0:
|
|
728
|
+
constants = ca.vertcat(X_prev, *self.__sym_list[self.__n_state_symbols : -n_parameters])
|
|
729
|
+
else:
|
|
730
|
+
constants = ca.vertcat(X_prev, *self.__sym_list[self.__n_state_symbols :])
|
|
731
|
+
|
|
732
|
+
# Make a list of derivative approximations using backwards Euler formulation
|
|
733
|
+
derivative_approximation_residuals = []
|
|
734
|
+
for index, derivative_state in enumerate(self.__mx["derivatives"]):
|
|
735
|
+
derivative_approximation_residuals.append(
|
|
736
|
+
derivative_state - (X[index] - X_prev[index]) / dt
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# Delayed feedback
|
|
740
|
+
delay_equations = []
|
|
741
|
+
for delay_state, delay_argument, delay_time in zip(
|
|
742
|
+
model.delay_states,
|
|
743
|
+
model.delay_arguments,
|
|
744
|
+
self.__delay_times,
|
|
745
|
+
):
|
|
746
|
+
expression_state = delay_state + "_expr"
|
|
747
|
+
i_delay_state, _ = self.__indices[delay_state]
|
|
748
|
+
i_expr_start, _ = self.__i_start[expression_state]
|
|
749
|
+
i_expr_end, _ = self.__i_end[expression_state]
|
|
750
|
+
delay_equations.append(X[i_expr_start] - delay_argument.expr)
|
|
751
|
+
delay_equations.append(
|
|
752
|
+
X[i_expr_start + 1 : i_expr_end] - X_prev[i_expr_start : i_expr_end - 1]
|
|
753
|
+
)
|
|
754
|
+
n_previous_values = self.__sym_dict[expression_state].numel()
|
|
755
|
+
interpolation_weight = n_previous_values - delay_time / self.__dt
|
|
756
|
+
delay_equations.append(
|
|
757
|
+
X[i_delay_state]
|
|
758
|
+
- interpolation_weight * X[i_expr_end - 1]
|
|
759
|
+
- (1 - interpolation_weight) * X_prev[i_expr_end - 1]
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Append residuals for derivative approximations
|
|
763
|
+
dae_residual = ca.vertcat(
|
|
764
|
+
self.__dae_residual,
|
|
765
|
+
*derivative_approximation_residuals,
|
|
766
|
+
*delay_equations,
|
|
767
|
+
*extra_equations,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# TODO: implement lookup_tables
|
|
771
|
+
|
|
772
|
+
# Substitute unscaled terms for scaled terms
|
|
773
|
+
dae_residual = ca.substitute(dae_residual, unscaled_symbols, scaled_symbols)
|
|
774
|
+
|
|
775
|
+
# Substitute the parameters
|
|
776
|
+
if n_parameters > 0:
|
|
777
|
+
parameters_values = self.__state_vector[-n_parameters:]
|
|
778
|
+
dae_residual = ca.substitute(dae_residual, parameters, parameters_values)
|
|
779
|
+
|
|
780
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
781
|
+
logger.debug("SimulationProblem: DAE Residual is " + str(dae_residual))
|
|
782
|
+
|
|
783
|
+
if X.size1() != dae_residual.size1():
|
|
784
|
+
logger.error(
|
|
785
|
+
"Formulation Error: Number of states ({}) "
|
|
786
|
+
"does not equal number of equations ({})".format(X.size1(), dae_residual.size1())
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
# Construct a function res_vals that returns the numerical residuals of a numerical state
|
|
790
|
+
self.__res_vals = ca.Function("res_vals", [X, dt, constants], [dae_residual])
|
|
791
|
+
try:
|
|
792
|
+
self.__res_vals = self.__res_vals.expand()
|
|
793
|
+
except RuntimeError as e:
|
|
794
|
+
if "eval_sx" not in str(e):
|
|
795
|
+
raise
|
|
796
|
+
else:
|
|
797
|
+
pass
|
|
798
|
+
|
|
799
|
+
# Use rootfinder() to make a function that takes a step forward in time by trying to zero
|
|
800
|
+
# res_vals()
|
|
801
|
+
options = self.rootfinder_options()
|
|
802
|
+
solver = options["solver"]
|
|
803
|
+
solver_options = options["solver_options"]
|
|
804
|
+
self.__do_step = ca.rootfinder("next_state", solver, self.__res_vals, solver_options)
|
|
805
|
+
|
|
806
|
+
def pre(self):
|
|
807
|
+
"""
|
|
808
|
+
Any preprocessing takes place here.
|
|
809
|
+
"""
|
|
810
|
+
pass
|
|
811
|
+
|
|
812
|
+
def post(self):
|
|
813
|
+
"""
|
|
814
|
+
Any postprocessing takes place here.
|
|
815
|
+
"""
|
|
816
|
+
pass
|
|
817
|
+
|
|
818
|
+
def setup_experiment(self, start, stop, dt):
|
|
819
|
+
"""
|
|
820
|
+
Method for subclasses (PIMixin, CSVMixin, or user classes) to set timing information for a
|
|
821
|
+
simulation run.
|
|
822
|
+
|
|
823
|
+
:param start: Start time for the simulation.
|
|
824
|
+
:param stop: Final time for the simulation.
|
|
825
|
+
:param dt: Time step size.
|
|
826
|
+
"""
|
|
827
|
+
|
|
828
|
+
# Set class vars with start/stop/dt values
|
|
829
|
+
self.__start = start
|
|
830
|
+
self.__stop = stop
|
|
831
|
+
self.set_time_step(dt)
|
|
832
|
+
|
|
833
|
+
# Set time in state vector
|
|
834
|
+
self.set_var("time", start)
|
|
835
|
+
|
|
836
|
+
def update(self, dt):
|
|
837
|
+
"""
|
|
838
|
+
Performs one timestep.
|
|
839
|
+
|
|
840
|
+
The methods ``setup_experiment`` and ``initialize`` must have been called before.
|
|
841
|
+
|
|
842
|
+
:param dt: Time step size.
|
|
843
|
+
"""
|
|
844
|
+
if dt > 0:
|
|
845
|
+
self.set_time_step(dt)
|
|
846
|
+
dt = self.get_time_step()
|
|
847
|
+
|
|
848
|
+
logger.debug("Taking a step at {} with size {}".format(self.get_current_time(), dt))
|
|
849
|
+
|
|
850
|
+
# increment time
|
|
851
|
+
self.set_var("time", self.get_current_time() + dt)
|
|
852
|
+
|
|
853
|
+
# take a step
|
|
854
|
+
if np.isnan(self.__state_vector).any():
|
|
855
|
+
logger.error("Found a nan in the state vector (before making the step)")
|
|
856
|
+
guess = self.__state_vector[: self.__n_states]
|
|
857
|
+
if len(self.__mx["parameters"]) > 0:
|
|
858
|
+
next_state = self.__do_step(
|
|
859
|
+
guess, dt, self.__state_vector[: -len(self.__mx["parameters"])]
|
|
860
|
+
)
|
|
861
|
+
else:
|
|
862
|
+
next_state = self.__do_step(guess, dt, self.__state_vector)
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
if np.isnan(next_state).any():
|
|
866
|
+
index_to_name = {index[0]: name for name, index in self.__indices.items()}
|
|
867
|
+
named_next_state = {
|
|
868
|
+
index_to_name[i]: float(next_state[i]) for i in range(0, next_state.shape[0])
|
|
869
|
+
}
|
|
870
|
+
variables_with_nan = [
|
|
871
|
+
name for name, value in named_next_state.items() if np.isnan(value)
|
|
872
|
+
]
|
|
873
|
+
if variables_with_nan:
|
|
874
|
+
logger.error(
|
|
875
|
+
f"Found nan(s) in the next_state vector for:\n\t {variables_with_nan}"
|
|
876
|
+
)
|
|
877
|
+
except (KeyError, IndexError, TypeError):
|
|
878
|
+
logger.warning("Something went wrong while checking for nans in the next_state vector")
|
|
879
|
+
|
|
880
|
+
# Check convergence of rootfinder
|
|
881
|
+
rootfinder_stats = self.__do_step.stats()
|
|
882
|
+
|
|
883
|
+
if not rootfinder_stats["success"]:
|
|
884
|
+
message = (
|
|
885
|
+
"Simulation has failed to converge at time {}. Solver failed with status {}"
|
|
886
|
+
).format(self.get_current_time(), rootfinder_stats["nlpsol"]["return_status"])
|
|
887
|
+
logger.error(message)
|
|
888
|
+
raise Exception(message)
|
|
889
|
+
|
|
890
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
891
|
+
# compute max residual
|
|
892
|
+
largest_res = ca.norm_inf(
|
|
893
|
+
self.__res_vals(
|
|
894
|
+
next_state, self.__dt, self.__state_vector[: -len(self.__mx["parameters"])]
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
logger.debug("Residual maximum magnitude: {:.2E}".format(float(largest_res)))
|
|
898
|
+
|
|
899
|
+
# Update state vector
|
|
900
|
+
self.__state_vector[: self.__n_states] = next_state.toarray().ravel()
|
|
901
|
+
|
|
902
|
+
def simulate(self):
|
|
903
|
+
"""
|
|
904
|
+
Run model from start_time to end_time.
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
# Do any preprocessing, which may include changing parameter values on
|
|
908
|
+
# the model
|
|
909
|
+
logger.info("Preprocessing")
|
|
910
|
+
self.pre()
|
|
911
|
+
|
|
912
|
+
# Initialize model
|
|
913
|
+
logger.info("Initializing")
|
|
914
|
+
self.initialize()
|
|
915
|
+
|
|
916
|
+
# Perform all timesteps
|
|
917
|
+
logger.info("Running")
|
|
918
|
+
while self.get_current_time() < self.get_end_time():
|
|
919
|
+
self.update(-1)
|
|
920
|
+
|
|
921
|
+
# Do any postprocessing
|
|
922
|
+
logger.info("Postprocessing")
|
|
923
|
+
self.post()
|
|
924
|
+
|
|
925
|
+
def reset(self):
|
|
926
|
+
"""
|
|
927
|
+
Reset the FMU.
|
|
928
|
+
"""
|
|
929
|
+
self.__state_vector = copy.deepcopy(self.__initialized_state_vector)
|
|
930
|
+
|
|
931
|
+
def get_start_time(self):
|
|
932
|
+
"""
|
|
933
|
+
Return start time of experiment.
|
|
934
|
+
|
|
935
|
+
:returns: The start time of the experiment.
|
|
936
|
+
"""
|
|
937
|
+
return self.__start
|
|
938
|
+
|
|
939
|
+
def get_end_time(self):
|
|
940
|
+
"""
|
|
941
|
+
Return end time of experiment.
|
|
942
|
+
|
|
943
|
+
:returns: The end time of the experiment.
|
|
944
|
+
"""
|
|
945
|
+
return self.__stop
|
|
946
|
+
|
|
947
|
+
def get_current_time(self):
|
|
948
|
+
"""
|
|
949
|
+
Return current time of simulation.
|
|
950
|
+
|
|
951
|
+
:returns: The current simulation time.
|
|
952
|
+
"""
|
|
953
|
+
return self.get_var("time")
|
|
954
|
+
|
|
955
|
+
def get_time_step(self):
|
|
956
|
+
"""
|
|
957
|
+
Return simulation timestep.
|
|
958
|
+
|
|
959
|
+
:returns: The simulation timestep.
|
|
960
|
+
"""
|
|
961
|
+
return self.__dt
|
|
962
|
+
|
|
963
|
+
def get_var(self, name):
|
|
964
|
+
"""
|
|
965
|
+
Return a numpy array from FMU.
|
|
966
|
+
|
|
967
|
+
:param name: Variable name.
|
|
968
|
+
|
|
969
|
+
:returns: The value of the variable.
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
# Get the index of the canonical state and sign
|
|
973
|
+
index, sign = self.__indices[name]
|
|
974
|
+
value = self.__state_vector[index]
|
|
975
|
+
|
|
976
|
+
# Adjust sign if needed
|
|
977
|
+
if sign < 0:
|
|
978
|
+
value *= sign
|
|
979
|
+
|
|
980
|
+
# Adjust for nominal value if not default
|
|
981
|
+
if index <= self.__n_states:
|
|
982
|
+
nominal = self.get_variable_nominal(name)
|
|
983
|
+
value *= nominal
|
|
984
|
+
|
|
985
|
+
return value
|
|
986
|
+
|
|
987
|
+
def get_var_count(self):
|
|
988
|
+
"""
|
|
989
|
+
Return the number of variables in the model.
|
|
990
|
+
|
|
991
|
+
:returns: The number of variables in the model.
|
|
992
|
+
"""
|
|
993
|
+
return len(self.get_variables())
|
|
994
|
+
|
|
995
|
+
def get_var_name(self, i):
|
|
996
|
+
"""
|
|
997
|
+
Returns the name of a variable.
|
|
998
|
+
|
|
999
|
+
:param i: Index in ordered dictionary returned by method get_variables.
|
|
1000
|
+
|
|
1001
|
+
:returns: The name of the variable.
|
|
1002
|
+
"""
|
|
1003
|
+
return list(self.get_variables())[i]
|
|
1004
|
+
|
|
1005
|
+
def get_var_type(self, name):
|
|
1006
|
+
"""
|
|
1007
|
+
Return type, compatible with numpy.
|
|
1008
|
+
|
|
1009
|
+
:param name: String variable name.
|
|
1010
|
+
|
|
1011
|
+
:returns: The numpy-compatible type of the variable.
|
|
1012
|
+
|
|
1013
|
+
:raises: KeyError
|
|
1014
|
+
"""
|
|
1015
|
+
return self.__python_types[name]
|
|
1016
|
+
|
|
1017
|
+
def get_var_rank(self, name):
|
|
1018
|
+
"""
|
|
1019
|
+
Not implemented
|
|
1020
|
+
"""
|
|
1021
|
+
raise NotImplementedError
|
|
1022
|
+
|
|
1023
|
+
def get_var_shape(self, name):
|
|
1024
|
+
"""
|
|
1025
|
+
Not implemented
|
|
1026
|
+
"""
|
|
1027
|
+
raise NotImplementedError
|
|
1028
|
+
|
|
1029
|
+
def get_variables(self):
|
|
1030
|
+
"""
|
|
1031
|
+
Return all variables (both internal and user defined)
|
|
1032
|
+
|
|
1033
|
+
:returns: An ordered dictionary of all variables supported by the model.
|
|
1034
|
+
"""
|
|
1035
|
+
return AliasDict(self.alias_relation, self.__sym_dict)
|
|
1036
|
+
|
|
1037
|
+
@cached
|
|
1038
|
+
def get_state_variables(self):
|
|
1039
|
+
return AliasDict(
|
|
1040
|
+
self.alias_relation,
|
|
1041
|
+
{sym.name(): sym for sym in (self.__mx["states"] + self.__mx["algebraics"])},
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
@cached
|
|
1045
|
+
def get_parameter_variables(self):
|
|
1046
|
+
return AliasDict(self.alias_relation, {sym.name(): sym for sym in self.__mx["parameters"]})
|
|
1047
|
+
|
|
1048
|
+
@cached
|
|
1049
|
+
def get_input_variables(self):
|
|
1050
|
+
return AliasDict(
|
|
1051
|
+
self.alias_relation, {sym.name(): sym for sym in self.__mx["constant_inputs"]}
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
@cached
|
|
1055
|
+
def get_output_variables(self):
|
|
1056
|
+
return self.__pymoca_model.outputs
|
|
1057
|
+
|
|
1058
|
+
def __warn_for_nans(self):
|
|
1059
|
+
"""
|
|
1060
|
+
Test state vector for missing values and warn
|
|
1061
|
+
"""
|
|
1062
|
+
value_is_nan = np.isnan(self.__state_vector)
|
|
1063
|
+
if any(value_is_nan):
|
|
1064
|
+
for sym, isnan in zip(self.__sym_list, value_is_nan):
|
|
1065
|
+
if isnan:
|
|
1066
|
+
logger.warning("Variable {} has no value.".format(sym))
|
|
1067
|
+
|
|
1068
|
+
def set_time_step(self, dt):
|
|
1069
|
+
"""
|
|
1070
|
+
Set the timestep size.
|
|
1071
|
+
|
|
1072
|
+
:param dt: Timestep size of the simulation.
|
|
1073
|
+
"""
|
|
1074
|
+
if self._dt_is_fixed:
|
|
1075
|
+
assert math.isclose(self.__dt, dt), (
|
|
1076
|
+
"Timestep size dt is marked as constant and cannot be changed."
|
|
1077
|
+
)
|
|
1078
|
+
else:
|
|
1079
|
+
self.__dt = dt
|
|
1080
|
+
|
|
1081
|
+
def set_var(self, name, value):
|
|
1082
|
+
"""
|
|
1083
|
+
Set the value of the given variable.
|
|
1084
|
+
|
|
1085
|
+
:param name: Name of variable to set.
|
|
1086
|
+
:param value: Value(s).
|
|
1087
|
+
"""
|
|
1088
|
+
|
|
1089
|
+
# TODO: sanitize input
|
|
1090
|
+
|
|
1091
|
+
# Check if it is a parameter, and if it is allowed to be set
|
|
1092
|
+
if not self.__parameters_set_var:
|
|
1093
|
+
if name in self.__parameter_names_including_aliases:
|
|
1094
|
+
raise Exception("Cannot set parameters after initialize() has been called.")
|
|
1095
|
+
|
|
1096
|
+
# Get the index of the canonical state and sign
|
|
1097
|
+
index, sign = self.__indices[name]
|
|
1098
|
+
if sign < 0:
|
|
1099
|
+
value *= sign
|
|
1100
|
+
|
|
1101
|
+
# Adjust for nominal value if not default
|
|
1102
|
+
if index <= self.__n_states:
|
|
1103
|
+
nominal = self.get_variable_nominal(name)
|
|
1104
|
+
value /= nominal
|
|
1105
|
+
|
|
1106
|
+
# Store value in state vector
|
|
1107
|
+
self.__state_vector[index] = value
|
|
1108
|
+
|
|
1109
|
+
def set_var_slice(self, name, start, count, var):
|
|
1110
|
+
"""
|
|
1111
|
+
Not implemented.
|
|
1112
|
+
"""
|
|
1113
|
+
raise NotImplementedError
|
|
1114
|
+
|
|
1115
|
+
def set_var_index(self, name, index, var):
|
|
1116
|
+
"""
|
|
1117
|
+
Not implemented.
|
|
1118
|
+
"""
|
|
1119
|
+
raise NotImplementedError
|
|
1120
|
+
|
|
1121
|
+
def inq_compound(self, name):
|
|
1122
|
+
"""
|
|
1123
|
+
Not implemented.
|
|
1124
|
+
"""
|
|
1125
|
+
raise NotImplementedError
|
|
1126
|
+
|
|
1127
|
+
def inq_compound_field(self, name, index):
|
|
1128
|
+
"""
|
|
1129
|
+
Not implemented.
|
|
1130
|
+
"""
|
|
1131
|
+
raise NotImplementedError
|
|
1132
|
+
|
|
1133
|
+
def solver_options(self):
|
|
1134
|
+
"""
|
|
1135
|
+
Returns a dictionary of CasADi nlpsol() solver options.
|
|
1136
|
+
|
|
1137
|
+
:returns: A dictionary of CasADi :class:`nlpsol` options. See the CasADi
|
|
1138
|
+
documentation for details.
|
|
1139
|
+
"""
|
|
1140
|
+
return {
|
|
1141
|
+
"ipopt.fixed_variable_treatment": "make_parameter",
|
|
1142
|
+
"ipopt.print_level": 0,
|
|
1143
|
+
"print_time": False,
|
|
1144
|
+
"error_on_fail": False,
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
def rootfinder_options(self):
|
|
1148
|
+
"""
|
|
1149
|
+
Returns a dictionary of CasADi rootfinder() options.
|
|
1150
|
+
|
|
1151
|
+
The dictionary has the following items:
|
|
1152
|
+
* solver: the solver used for rootfinding, e.g. nlpsol, fast_newton, etc.
|
|
1153
|
+
* solver_options: options for the solver
|
|
1154
|
+
See the CasADi documentation for details on solvers and solver options.
|
|
1155
|
+
|
|
1156
|
+
:returns: A dictionary of CasADi :class:`rootfinder` options.
|
|
1157
|
+
"""
|
|
1158
|
+
return {
|
|
1159
|
+
"solver": "nlpsol",
|
|
1160
|
+
"solver_options": {
|
|
1161
|
+
"nlpsol": "ipopt",
|
|
1162
|
+
"nlpsol_options": self.solver_options(),
|
|
1163
|
+
"error_on_fail": False,
|
|
1164
|
+
},
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
def get_variable_nominal(self, variable) -> Union[float, ca.MX]:
|
|
1168
|
+
"""
|
|
1169
|
+
Get the value of the nominal attribute of a variable
|
|
1170
|
+
|
|
1171
|
+
NOTE: Due to backwards compatibility for allowing parameters to be set
|
|
1172
|
+
with set_var() instead of overriding parameters(), this method can
|
|
1173
|
+
return a symbolic value for nominals defined in the Modelica file. It
|
|
1174
|
+
can only do so until the initializion() method in this class is
|
|
1175
|
+
called/completed, after which it will return numeric values only.
|
|
1176
|
+
"""
|
|
1177
|
+
return self.__nominals.get(variable, 1.0)
|
|
1178
|
+
|
|
1179
|
+
def timeseries_at(self, variable, t):
|
|
1180
|
+
"""
|
|
1181
|
+
Get value of timeseries variable at time t: should be overridden by pi or csv mixin
|
|
1182
|
+
"""
|
|
1183
|
+
raise NotImplementedError
|
|
1184
|
+
|
|
1185
|
+
@cached
|
|
1186
|
+
def initial_state(self) -> AliasDict[str, float]:
|
|
1187
|
+
"""
|
|
1188
|
+
The initial state.
|
|
1189
|
+
|
|
1190
|
+
:returns: A dictionary of variable names and initial state (t0) values.
|
|
1191
|
+
"""
|
|
1192
|
+
t0 = self.get_start_time()
|
|
1193
|
+
initial_state_dict = AliasDict(self.alias_relation)
|
|
1194
|
+
|
|
1195
|
+
for variable in list(self.get_state_variables()) + list(self.get_input_variables()):
|
|
1196
|
+
try:
|
|
1197
|
+
initial_state_dict[variable] = self.timeseries_at(variable, t0)
|
|
1198
|
+
except KeyError:
|
|
1199
|
+
pass
|
|
1200
|
+
except NotImplementedError:
|
|
1201
|
+
pass
|
|
1202
|
+
else:
|
|
1203
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
1204
|
+
logger.debug("Read intial state for {}".format(variable))
|
|
1205
|
+
|
|
1206
|
+
return initial_state_dict
|
|
1207
|
+
|
|
1208
|
+
@cached
|
|
1209
|
+
def seed(self) -> AliasDict[str, float]:
|
|
1210
|
+
"""
|
|
1211
|
+
Seed values providing an initial guess for the t0 states.
|
|
1212
|
+
|
|
1213
|
+
:returns: A dictionary of variable names and seed (t0) values.
|
|
1214
|
+
"""
|
|
1215
|
+
return AliasDict(self.alias_relation)
|
|
1216
|
+
|
|
1217
|
+
@cached
|
|
1218
|
+
def parameters(self):
|
|
1219
|
+
"""
|
|
1220
|
+
Return a dictionary of parameter values extracted from the Modelica model
|
|
1221
|
+
"""
|
|
1222
|
+
# Create AliasDict
|
|
1223
|
+
parameters = AliasDict(self.alias_relation)
|
|
1224
|
+
|
|
1225
|
+
# Update with model parameters
|
|
1226
|
+
parameters.update({p.symbol.name(): p.value for p in self.__pymoca_model.parameters})
|
|
1227
|
+
|
|
1228
|
+
return parameters
|
|
1229
|
+
|
|
1230
|
+
def extra_variables(self) -> List[Variable]:
|
|
1231
|
+
return []
|
|
1232
|
+
|
|
1233
|
+
def extra_equations(self) -> List[ca.MX]:
|
|
1234
|
+
return []
|
|
1235
|
+
|
|
1236
|
+
@property
|
|
1237
|
+
@cached
|
|
1238
|
+
def alias_relation(self):
|
|
1239
|
+
return self.__pymoca_model.alias_relation
|
|
1240
|
+
|
|
1241
|
+
@cached
|
|
1242
|
+
def compiler_options(self):
|
|
1243
|
+
"""
|
|
1244
|
+
Subclasses can configure the `pymoca <http://github.com/pymoca/pymoca>`_ compiler options
|
|
1245
|
+
here.
|
|
1246
|
+
|
|
1247
|
+
:returns:
|
|
1248
|
+
A dictionary of pymoca compiler options. See the pymoca documentation for details.
|
|
1249
|
+
"""
|
|
1250
|
+
|
|
1251
|
+
# Default options
|
|
1252
|
+
compiler_options = {}
|
|
1253
|
+
|
|
1254
|
+
# Expand vector states to multiple scalar component states.
|
|
1255
|
+
compiler_options["expand_vectors"] = True
|
|
1256
|
+
|
|
1257
|
+
# Where imported model libraries are located.
|
|
1258
|
+
library_folders = self.modelica_library_folders.copy()
|
|
1259
|
+
|
|
1260
|
+
for ep in importlib_metadata.entry_points(group="rtctools.libraries.modelica"):
|
|
1261
|
+
if ep.name == "library_folder":
|
|
1262
|
+
library_folders.append(str(importlib.resources.files(ep.module).joinpath(ep.attr)))
|
|
1263
|
+
|
|
1264
|
+
compiler_options["library_folders"] = library_folders
|
|
1265
|
+
|
|
1266
|
+
# Eliminate equations of the type 'var = const'.
|
|
1267
|
+
compiler_options["eliminate_constant_assignments"] = True
|
|
1268
|
+
|
|
1269
|
+
# Eliminate constant symbols from model, replacing them with the values
|
|
1270
|
+
# specified in the model.
|
|
1271
|
+
compiler_options["replace_constant_values"] = True
|
|
1272
|
+
|
|
1273
|
+
# Replace any constant expressions into the model.
|
|
1274
|
+
compiler_options["replace_constant_expressions"] = True
|
|
1275
|
+
|
|
1276
|
+
# Replace any parameter expressions into the model.
|
|
1277
|
+
compiler_options["replace_parameter_expressions"] = True
|
|
1278
|
+
|
|
1279
|
+
# Eliminate variables starting with underscores.
|
|
1280
|
+
compiler_options["eliminable_variable_expression"] = r"(.*[.]|^)_\w+(\[[\d,]+\])?\Z"
|
|
1281
|
+
|
|
1282
|
+
# Pymoca currently requires `expand_mx` to be set for
|
|
1283
|
+
# `eliminable_variable_expression` to work.
|
|
1284
|
+
compiler_options["expand_mx"] = True
|
|
1285
|
+
|
|
1286
|
+
# Automatically detect and eliminate alias variables.
|
|
1287
|
+
compiler_options["detect_aliases"] = True
|
|
1288
|
+
|
|
1289
|
+
# Cache the model on disk
|
|
1290
|
+
compiler_options["cache"] = True
|
|
1291
|
+
|
|
1292
|
+
# Done
|
|
1293
|
+
return compiler_options
|