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,165 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Union
|
|
3
|
+
|
|
4
|
+
from .optimization_problem import OptimizationProblem
|
|
5
|
+
from .timeseries import Timeseries
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("rtctools")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HomotopyMixin(OptimizationProblem):
|
|
11
|
+
"""
|
|
12
|
+
Adds homotopy to your optimization problem. A homotopy is a continuous transformation between
|
|
13
|
+
two optimization problems, parametrized by a single parameter :math:`\\theta \\in [0, 1]`.
|
|
14
|
+
|
|
15
|
+
Homotopy may be used to solve non-convex optimization problems, by starting with a convex
|
|
16
|
+
approximation at :math:`\\theta = 0.0` and ending with the non-convex problem at
|
|
17
|
+
:math:`\\theta = 1.0`.
|
|
18
|
+
|
|
19
|
+
.. note::
|
|
20
|
+
|
|
21
|
+
It is advised to look for convex reformulations of your problem, before resorting to a use
|
|
22
|
+
of the (potentially expensive) homotopy process.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def seed(self, ensemble_member):
|
|
27
|
+
seed = super().seed(ensemble_member)
|
|
28
|
+
options = self.homotopy_options()
|
|
29
|
+
|
|
30
|
+
# Overwrite the seed only when the results of the latest run are
|
|
31
|
+
# stored within this class. That is, when the GoalProgrammingMixin
|
|
32
|
+
# class is not used or at the first run of the goal programming loop.
|
|
33
|
+
if self.__theta > options["theta_start"] and getattr(self, "_gp_first_run", True):
|
|
34
|
+
for key, result in self.__results[ensemble_member].items():
|
|
35
|
+
times = self.times(key)
|
|
36
|
+
if (result.ndim == 1 and len(result) == len(times)) or (
|
|
37
|
+
result.ndim == 2 and result.shape[0] == len(times)
|
|
38
|
+
):
|
|
39
|
+
# Only include seed timeseries which are consistent
|
|
40
|
+
# with the specified time stamps.
|
|
41
|
+
seed[key] = Timeseries(times, result)
|
|
42
|
+
elif (result.ndim == 1 and len(result) == 1) or (
|
|
43
|
+
result.ndim == 2 and result.shape[0] == 1
|
|
44
|
+
):
|
|
45
|
+
seed[key] = result
|
|
46
|
+
return seed
|
|
47
|
+
|
|
48
|
+
def parameters(self, ensemble_member):
|
|
49
|
+
parameters = super().parameters(ensemble_member)
|
|
50
|
+
|
|
51
|
+
options = self.homotopy_options()
|
|
52
|
+
try:
|
|
53
|
+
# Only set the theta if we are in the optimization loop. We want
|
|
54
|
+
# to avoid accidental usage of the parameter value in e.g. pre().
|
|
55
|
+
# Note that we use a try-except here instead of hasattr, to avoid
|
|
56
|
+
# explicit name mangling.
|
|
57
|
+
parameters[options["homotopy_parameter"]] = self.__theta
|
|
58
|
+
except AttributeError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return parameters
|
|
62
|
+
|
|
63
|
+
def homotopy_options(self) -> Dict[str, Union[str, float]]:
|
|
64
|
+
"""
|
|
65
|
+
Returns a dictionary of options controlling the homotopy process.
|
|
66
|
+
|
|
67
|
+
+------------------------+------------+---------------+
|
|
68
|
+
| Option | Type | Default value |
|
|
69
|
+
+========================+============+===============+
|
|
70
|
+
| ``theta_start`` | ``float`` | ``0.0`` |
|
|
71
|
+
+------------------------+------------+---------------+
|
|
72
|
+
| ``delta_theta_0`` | ``float`` | ``1.0`` |
|
|
73
|
+
+------------------------+------------+---------------+
|
|
74
|
+
| ``delta_theta_min`` | ``float`` | ``0.01`` |
|
|
75
|
+
+------------------------+------------+---------------+
|
|
76
|
+
| ``homotopy_parameter`` | ``string`` | ``theta`` |
|
|
77
|
+
+------------------------+------------+---------------+
|
|
78
|
+
|
|
79
|
+
The homotopy process is controlled by the homotopy parameter in the model, specified by the
|
|
80
|
+
option ``homotopy_parameter``. The homotopy parameter is initialized to ``theta_start``,
|
|
81
|
+
and increases to a value of ``1.0`` with a dynamically changing step size. This step size
|
|
82
|
+
is initialized with the value of the option ``delta_theta_0``. If this step size is too
|
|
83
|
+
large, i.e., if the problem with the increased homotopy parameter fails to converge, the
|
|
84
|
+
step size is halved. The process of halving terminates when the step size falls below the
|
|
85
|
+
minimum value specified by the option ``delta_theta_min``.
|
|
86
|
+
|
|
87
|
+
:returns: A dictionary of homotopy options.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"theta_start": 0.0,
|
|
92
|
+
"delta_theta_0": 1.0,
|
|
93
|
+
"delta_theta_min": 0.01,
|
|
94
|
+
"homotopy_parameter": "theta",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def dynamic_parameters(self):
|
|
98
|
+
dynamic_parameters = super().dynamic_parameters()
|
|
99
|
+
|
|
100
|
+
if self.__theta > 0:
|
|
101
|
+
# For theta = 0, we don't mark the homotopy parameter as being dynamic,
|
|
102
|
+
# so that the correct sparsity structure is obtained for the linear model.
|
|
103
|
+
options = self.homotopy_options()
|
|
104
|
+
dynamic_parameters.append(self.variable(options["homotopy_parameter"]))
|
|
105
|
+
|
|
106
|
+
return dynamic_parameters
|
|
107
|
+
|
|
108
|
+
def optimize(self, preprocessing=True, postprocessing=True, log_solver_failure_as_error=True):
|
|
109
|
+
# Pre-processing
|
|
110
|
+
if preprocessing:
|
|
111
|
+
self.pre()
|
|
112
|
+
|
|
113
|
+
options = self.homotopy_options()
|
|
114
|
+
delta_theta = options["delta_theta_0"]
|
|
115
|
+
|
|
116
|
+
# Homotopy loop
|
|
117
|
+
self.__theta = options["theta_start"]
|
|
118
|
+
|
|
119
|
+
while self.__theta <= 1.0:
|
|
120
|
+
logger.info("Solving with homotopy parameter theta = {}.".format(self.__theta))
|
|
121
|
+
|
|
122
|
+
success = super().optimize(
|
|
123
|
+
preprocessing=False, postprocessing=False, log_solver_failure_as_error=False
|
|
124
|
+
)
|
|
125
|
+
if success:
|
|
126
|
+
self.__results = [
|
|
127
|
+
self.extract_results(ensemble_member)
|
|
128
|
+
for ensemble_member in range(self.ensemble_size)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
if self.__theta == 0.0:
|
|
132
|
+
self.check_collocation_linearity = False
|
|
133
|
+
self.linear_collocation = False
|
|
134
|
+
|
|
135
|
+
# Recompute the sparsity structure for the nonlinear model family.
|
|
136
|
+
self.clear_transcription_cache()
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
if self.__theta == options["theta_start"]:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
self.__theta -= delta_theta
|
|
143
|
+
delta_theta /= 2
|
|
144
|
+
|
|
145
|
+
if delta_theta < options["delta_theta_min"]:
|
|
146
|
+
failure_message = (
|
|
147
|
+
"Solver failed with homotopy parameter theta = {}. Theta cannot "
|
|
148
|
+
"be decreased further, as that would violate the minimum delta "
|
|
149
|
+
"theta of {}.".format(self.__theta, options["delta_theta_min"])
|
|
150
|
+
)
|
|
151
|
+
if log_solver_failure_as_error:
|
|
152
|
+
logger.error(failure_message)
|
|
153
|
+
else:
|
|
154
|
+
# In this case we expect some higher level process to deal
|
|
155
|
+
# with the solver failure, so we only log it as info here.
|
|
156
|
+
logger.info(failure_message)
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
self.__theta += delta_theta
|
|
160
|
+
|
|
161
|
+
# Post-processing
|
|
162
|
+
if postprocessing:
|
|
163
|
+
self.post()
|
|
164
|
+
|
|
165
|
+
return success
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import List, Tuple, Union
|
|
2
|
+
|
|
3
|
+
from .goal_programming_mixin import Goal, GoalProgrammingMixin
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _MeasurementGoal(Goal):
|
|
7
|
+
def __init__(self, state, measurement_id, max_deviation=1.0):
|
|
8
|
+
self.__state = state
|
|
9
|
+
self.__measurement_id = measurement_id
|
|
10
|
+
|
|
11
|
+
self.function_nominal = max_deviation
|
|
12
|
+
|
|
13
|
+
def function(self, optimization_problem, ensemble_member):
|
|
14
|
+
op = optimization_problem
|
|
15
|
+
return op.state_at(self.__state, op.initial_time, ensemble_member) - op.timeseries_at(
|
|
16
|
+
self.__measurement_id, op.initial_time, ensemble_member
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
order = 2
|
|
20
|
+
priority = -2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SmoothingGoal(Goal):
|
|
24
|
+
def __init__(self, state1, state2, max_deviation=1.0):
|
|
25
|
+
self.__state1 = state1
|
|
26
|
+
self.__state2 = state2
|
|
27
|
+
|
|
28
|
+
self.function_nominal = max_deviation
|
|
29
|
+
|
|
30
|
+
def function(self, optimization_problem, ensemble_member):
|
|
31
|
+
op = optimization_problem
|
|
32
|
+
return op.state_at(self.__state1, op.initial_time, ensemble_member) - op.state_at(
|
|
33
|
+
self.__state2, op.initial_time, ensemble_member
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
order = 2
|
|
37
|
+
priority = -1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InitialStateEstimationMixin(GoalProgrammingMixin):
|
|
41
|
+
"""
|
|
42
|
+
Adds initial state estimation to your optimization problem *using goal programming*.
|
|
43
|
+
|
|
44
|
+
Before any other goals are evaluated, first, the deviation between initial
|
|
45
|
+
state measurements and their respective model states is minimized in the
|
|
46
|
+
least squares sense (1DVAR, priority -2). Secondly, the distance between
|
|
47
|
+
pairs of states is minimized, again in the least squares sense, so that
|
|
48
|
+
"smooth" initial guesses are provided for states without measurements
|
|
49
|
+
(priority -1).
|
|
50
|
+
|
|
51
|
+
.. note::
|
|
52
|
+
|
|
53
|
+
There are types of problems where, in addition to minimizing
|
|
54
|
+
differences between states and measurements, it is advisable to
|
|
55
|
+
perform a steady-state initialization using additional initial-time
|
|
56
|
+
model equations. For hydraulic models, for instance, it is often
|
|
57
|
+
helpful to require that the time-derivative of the flow variables
|
|
58
|
+
vanishes at the initial time.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def initial_state_measurements(self) -> List[Union[Tuple[str, str], Tuple[str, str, float]]]:
|
|
63
|
+
"""
|
|
64
|
+
List of pairs ``(state, measurement_id)`` or triples ``(state, measurement_id,
|
|
65
|
+
max_deviation)``, relating states to measurement time series IDs.
|
|
66
|
+
|
|
67
|
+
The default maximum deviation is ``1.0``.
|
|
68
|
+
"""
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def initial_state_smoothing_pairs(self) -> List[Union[Tuple[str, str], Tuple[str, str, float]]]:
|
|
72
|
+
"""
|
|
73
|
+
List of pairs ``(state1, state2)`` or triples ``(state1, state2, max_deviation)``, relating
|
|
74
|
+
states the distance of which is to be minimized.
|
|
75
|
+
|
|
76
|
+
The default maximum deviation is ``1.0``.
|
|
77
|
+
"""
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def goals(self):
|
|
81
|
+
g = super().goals()
|
|
82
|
+
|
|
83
|
+
for measurement in self.initial_state_measurements():
|
|
84
|
+
g.append(_MeasurementGoal(*measurement))
|
|
85
|
+
|
|
86
|
+
for smoothing_pair in self.initial_state_smoothing_pairs():
|
|
87
|
+
g.append(_SmoothingGoal(*smoothing_pair))
|
|
88
|
+
|
|
89
|
+
return g
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import bisect
|
|
2
|
+
import logging
|
|
3
|
+
import warnings
|
|
4
|
+
from abc import ABCMeta, abstractmethod
|
|
5
|
+
|
|
6
|
+
import casadi as ca
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from rtctools._internal.caching import cached
|
|
10
|
+
from rtctools.optimization.optimization_problem import OptimizationProblem
|
|
11
|
+
from rtctools.optimization.timeseries import Timeseries
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("rtctools")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IOMixin(OptimizationProblem, metaclass=ABCMeta):
|
|
17
|
+
"""
|
|
18
|
+
Base class for all IO methods of optimization problems.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, **kwargs):
|
|
22
|
+
# Call parent class first for default behaviour.
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
|
|
25
|
+
# Additional output variables
|
|
26
|
+
self.__output_timeseries = set()
|
|
27
|
+
self.__equidistant = False
|
|
28
|
+
|
|
29
|
+
def pre(self) -> None:
|
|
30
|
+
# Call parent class first for default behaviour.
|
|
31
|
+
super().pre()
|
|
32
|
+
|
|
33
|
+
# Call read method to read all input
|
|
34
|
+
self.read()
|
|
35
|
+
|
|
36
|
+
# Check equidistancy on read input data
|
|
37
|
+
if self.io.datetimes:
|
|
38
|
+
self.__equidistant = len(set(np.diff(self.io.datetimes))) == 1
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def read(self) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Reads input data from files
|
|
44
|
+
"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def post(self) -> None:
|
|
48
|
+
# Call parent class first for default behaviour.
|
|
49
|
+
super().post()
|
|
50
|
+
|
|
51
|
+
# Call write method to write all output
|
|
52
|
+
self.write()
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def write(self) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Writes output data to files
|
|
58
|
+
"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def times(self, variable=None) -> np.ndarray:
|
|
62
|
+
"""
|
|
63
|
+
Returns the times in seconds from the reference datetime onwards.
|
|
64
|
+
|
|
65
|
+
:param variable:
|
|
66
|
+
"""
|
|
67
|
+
times_sec = self.io.times_sec
|
|
68
|
+
t_idx = bisect.bisect_left(times_sec, 0.0)
|
|
69
|
+
return times_sec[t_idx:]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def equidistant(self):
|
|
73
|
+
return self.__equidistant
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ensemble_size(self):
|
|
77
|
+
return self.io.ensemble_size
|
|
78
|
+
|
|
79
|
+
def get_timeseries(self, variable: str, ensemble_member: int = 0) -> Timeseries:
|
|
80
|
+
return Timeseries(*self.io.get_timeseries_sec(variable, ensemble_member))
|
|
81
|
+
|
|
82
|
+
def set_timeseries(
|
|
83
|
+
self,
|
|
84
|
+
variable: str,
|
|
85
|
+
timeseries: Timeseries,
|
|
86
|
+
ensemble_member: int = 0,
|
|
87
|
+
output: bool = True,
|
|
88
|
+
check_consistency: bool = True,
|
|
89
|
+
):
|
|
90
|
+
def stretch_values(values, t_pos):
|
|
91
|
+
# Construct a values range with preceding and possibly following nans
|
|
92
|
+
new_values = np.full(self.io.times_sec.shape, np.nan)
|
|
93
|
+
new_values[t_pos : t_pos + len(values)] = values
|
|
94
|
+
return new_values
|
|
95
|
+
|
|
96
|
+
if output:
|
|
97
|
+
self.__output_timeseries.add(variable)
|
|
98
|
+
|
|
99
|
+
if isinstance(timeseries, Timeseries):
|
|
100
|
+
if len(timeseries.values) != len(timeseries.times):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"IOMixin: Trying to set timeseries {} with times and values that are of "
|
|
103
|
+
"different length (lengths of {} and {}, respectively).".format(
|
|
104
|
+
variable, len(timeseries.times), len(timeseries.values)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
timeseries_times_sec = self.io.times_sec
|
|
109
|
+
|
|
110
|
+
if not np.array_equal(timeseries_times_sec, timeseries.times):
|
|
111
|
+
if check_consistency:
|
|
112
|
+
if not set(timeseries_times_sec).issuperset(timeseries.times):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"IOMixin: Trying to set timeseries {} with different times "
|
|
115
|
+
"(in seconds) than the imported timeseries. Please make sure the "
|
|
116
|
+
"timeseries covers all timesteps of the longest "
|
|
117
|
+
"imported timeseries.".format(variable)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Determine position of first times of added timeseries within the
|
|
121
|
+
# import times. For this we assume that both time ranges are ordered,
|
|
122
|
+
# and that the times of the added series is a subset of the import
|
|
123
|
+
# times.
|
|
124
|
+
t_pos = bisect.bisect_left(timeseries_times_sec, timeseries.times[0])
|
|
125
|
+
|
|
126
|
+
# Construct a new values range with length of self.io.get_times()
|
|
127
|
+
values = stretch_values(timeseries.values, t_pos)
|
|
128
|
+
else:
|
|
129
|
+
values = timeseries.values
|
|
130
|
+
|
|
131
|
+
else:
|
|
132
|
+
timeseries_times_sec = self.io.times_sec
|
|
133
|
+
|
|
134
|
+
if check_consistency:
|
|
135
|
+
if len(self.times()) != len(timeseries):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"IOMixin: Trying to set values for {} with a different "
|
|
138
|
+
"length ({}) than the forecast length ({}).".format(
|
|
139
|
+
variable, len(timeseries), len(self.times())
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
elif not set(timeseries_times_sec).issuperset(self.times()):
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"IOMixin: Trying to set timeseries {} with different times "
|
|
145
|
+
"(in seconds) than the imported timeseries. Please make sure the "
|
|
146
|
+
"timeseries covers all timesteps of the longest "
|
|
147
|
+
"imported timeseries.".format(variable)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# If times is not supplied with the timeseries, we add the
|
|
151
|
+
# forecast times range to a new Timeseries object. Hereby
|
|
152
|
+
# we assume that the supplied values stretch from T0 to end.
|
|
153
|
+
t_pos = bisect.bisect_left(timeseries_times_sec, self.initial_time)
|
|
154
|
+
|
|
155
|
+
# Construct a new values range with length of self.io.get_times()
|
|
156
|
+
values = stretch_values(timeseries, t_pos)
|
|
157
|
+
|
|
158
|
+
self.io.set_timeseries(variable, self.io.datetimes, values, ensemble_member)
|
|
159
|
+
|
|
160
|
+
def min_timeseries_id(self, variable: str) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Returns the name of the lower bound timeseries for the specified variable.
|
|
163
|
+
|
|
164
|
+
:param variable: Variable name.
|
|
165
|
+
"""
|
|
166
|
+
return "_".join((variable, "Min"))
|
|
167
|
+
|
|
168
|
+
def max_timeseries_id(self, variable: str) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Returns the name of the upper bound timeseries for the specified variable.
|
|
171
|
+
|
|
172
|
+
:param variable: Variable name.
|
|
173
|
+
"""
|
|
174
|
+
return "_".join((variable, "Max"))
|
|
175
|
+
|
|
176
|
+
@cached
|
|
177
|
+
def bounds(self):
|
|
178
|
+
# Call parent class first for default values.
|
|
179
|
+
bounds = super().bounds()
|
|
180
|
+
|
|
181
|
+
io_times = self.io.times_sec
|
|
182
|
+
t_pos = bisect.bisect_left(io_times, self.initial_time)
|
|
183
|
+
|
|
184
|
+
# Load bounds from timeseries
|
|
185
|
+
for variable in self.dae_variables["free_variables"]:
|
|
186
|
+
variable_name = variable.name()
|
|
187
|
+
|
|
188
|
+
m, M = None, None
|
|
189
|
+
|
|
190
|
+
timeseries_id = self.min_timeseries_id(variable_name)
|
|
191
|
+
try:
|
|
192
|
+
_, values = self.io.get_timeseries_sec(timeseries_id, 0)
|
|
193
|
+
m = values[t_pos:]
|
|
194
|
+
except KeyError:
|
|
195
|
+
pass
|
|
196
|
+
else:
|
|
197
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
198
|
+
logger.debug("Read lower bound for variable {}".format(variable_name))
|
|
199
|
+
|
|
200
|
+
timeseries_id = self.max_timeseries_id(variable_name)
|
|
201
|
+
try:
|
|
202
|
+
_, values = self.io.get_timeseries_sec(timeseries_id, 0)
|
|
203
|
+
M = values[t_pos:]
|
|
204
|
+
except KeyError:
|
|
205
|
+
pass
|
|
206
|
+
else:
|
|
207
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
208
|
+
logger.debug("Read upper bound for variable {}".format(variable_name))
|
|
209
|
+
|
|
210
|
+
# Replace NaN with +/- inf, and create Timeseries objects
|
|
211
|
+
if m is not None:
|
|
212
|
+
m[np.isnan(m)] = np.finfo(m.dtype).min
|
|
213
|
+
m = Timeseries(io_times[t_pos:], m)
|
|
214
|
+
if M is not None:
|
|
215
|
+
M[np.isnan(M)] = np.finfo(M.dtype).max
|
|
216
|
+
M = Timeseries(io_times[t_pos:], M)
|
|
217
|
+
|
|
218
|
+
# Store
|
|
219
|
+
if m is not None or M is not None:
|
|
220
|
+
bounds[variable_name] = (m, M)
|
|
221
|
+
return bounds
|
|
222
|
+
|
|
223
|
+
@cached
|
|
224
|
+
def history(self, ensemble_member):
|
|
225
|
+
# Load history
|
|
226
|
+
history = super().history(ensemble_member)
|
|
227
|
+
|
|
228
|
+
end_index = bisect.bisect_left(self.io.times_sec, self.initial_time) + 1
|
|
229
|
+
|
|
230
|
+
variable_list = (
|
|
231
|
+
self.dae_variables["states"]
|
|
232
|
+
+ self.dae_variables["algebraics"]
|
|
233
|
+
+ self.dae_variables["control_inputs"]
|
|
234
|
+
+ self.dae_variables["constant_inputs"]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
for variable in variable_list:
|
|
238
|
+
variable = variable.name()
|
|
239
|
+
try:
|
|
240
|
+
times, values = self.io.get_timeseries_sec(variable, ensemble_member)
|
|
241
|
+
history[variable] = Timeseries(times[:end_index], values[:end_index])
|
|
242
|
+
except KeyError:
|
|
243
|
+
pass
|
|
244
|
+
else:
|
|
245
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
246
|
+
logger.debug("IOMixin: Read history for state {}".format(variable))
|
|
247
|
+
return history
|
|
248
|
+
|
|
249
|
+
@cached
|
|
250
|
+
def seed(self, ensemble_member):
|
|
251
|
+
# Call parent class first for default values.
|
|
252
|
+
seed = super().seed(ensemble_member)
|
|
253
|
+
|
|
254
|
+
# Load seeds
|
|
255
|
+
for variable in self.dae_variables["free_variables"]:
|
|
256
|
+
variable = variable.name()
|
|
257
|
+
try:
|
|
258
|
+
s = Timeseries(*self.io.get_timeseries_sec(variable, ensemble_member))
|
|
259
|
+
except KeyError:
|
|
260
|
+
pass
|
|
261
|
+
else:
|
|
262
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
263
|
+
logger.debug("IOMixin: Seeded free variable {}".format(variable))
|
|
264
|
+
# A seeding of NaN means no seeding
|
|
265
|
+
s.values[np.isnan(s.values)] = 0.0
|
|
266
|
+
seed[variable] = s
|
|
267
|
+
return seed
|
|
268
|
+
|
|
269
|
+
@cached
|
|
270
|
+
def parameters(self, ensemble_member):
|
|
271
|
+
# Call parent class first for default values.
|
|
272
|
+
parameters = super().parameters(ensemble_member)
|
|
273
|
+
|
|
274
|
+
for parameter, value in self.io.parameters(ensemble_member).items():
|
|
275
|
+
parameters[parameter] = value
|
|
276
|
+
|
|
277
|
+
# Done
|
|
278
|
+
return parameters
|
|
279
|
+
|
|
280
|
+
@cached
|
|
281
|
+
def constant_inputs(self, ensemble_member):
|
|
282
|
+
# Call parent class first for default values.
|
|
283
|
+
constant_inputs = super().constant_inputs(ensemble_member)
|
|
284
|
+
|
|
285
|
+
# Load inputs from timeseries
|
|
286
|
+
for variable in self.dae_variables["constant_inputs"]:
|
|
287
|
+
variable = variable.name()
|
|
288
|
+
try:
|
|
289
|
+
timeseries = Timeseries(*self.io.get_timeseries_sec(variable, ensemble_member))
|
|
290
|
+
except KeyError:
|
|
291
|
+
pass
|
|
292
|
+
else:
|
|
293
|
+
inds = timeseries.times >= self.initial_time
|
|
294
|
+
if np.any(np.isnan(timeseries.values[inds])):
|
|
295
|
+
raise Exception("IOMixin: Constant input {} contains NaN".format(variable))
|
|
296
|
+
constant_inputs[variable] = timeseries
|
|
297
|
+
if logger.getEffectiveLevel() == logging.DEBUG:
|
|
298
|
+
logger.debug("IOMixin: Read constant input {}".format(variable))
|
|
299
|
+
return constant_inputs
|
|
300
|
+
|
|
301
|
+
def timeseries_at(self, variable, t, ensemble_member=0):
|
|
302
|
+
return self.interpolate(t, *self.io.get_timeseries_sec(variable, ensemble_member))
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def output_variables(self):
|
|
306
|
+
variables = super().output_variables.copy()
|
|
307
|
+
variables.extend([ca.MX.sym(variable) for variable in self.__output_timeseries])
|
|
308
|
+
return variables
|
|
309
|
+
|
|
310
|
+
def get_forecast_index(self):
|
|
311
|
+
"""
|
|
312
|
+
Deprecated, use `io.reference_datetime` and `io.datetimes`, or override behavior using
|
|
313
|
+
:py:meth:`OptimizationProblem.times` and/or :py:attr:`OptimizationProblem.initial_time`.
|
|
314
|
+
"""
|
|
315
|
+
warnings.warn(
|
|
316
|
+
"get_forecast_index() is deprecated and will be removed in the future",
|
|
317
|
+
FutureWarning,
|
|
318
|
+
stacklevel=1,
|
|
319
|
+
)
|
|
320
|
+
return bisect.bisect_left(self.io.datetimes, self.io.reference_datetime)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
from .optimization_problem import OptimizationProblem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LinearizationMixin(OptimizationProblem):
|
|
7
|
+
"""
|
|
8
|
+
Adds linearized equation parameter bookkeeping to your optimization aproblem.
|
|
9
|
+
|
|
10
|
+
If your model contains linearized equations, this mixin will set the
|
|
11
|
+
parameters of these equations based on the t0 value of an associated
|
|
12
|
+
timeseries.
|
|
13
|
+
|
|
14
|
+
The mapping between linearization parameters and time series is provided
|
|
15
|
+
in the ``linearization_parameters`` method.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def parameters(self, ensemble_member):
|
|
19
|
+
parameters = super().parameters(ensemble_member)
|
|
20
|
+
|
|
21
|
+
for parameter, timeseries_id in self.linearization_parameters().items():
|
|
22
|
+
parameters[parameter] = self.timeseries_at(
|
|
23
|
+
timeseries_id, self.initial_time, ensemble_member
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return parameters
|
|
27
|
+
|
|
28
|
+
def linearization_parameters(self) -> Dict[str, str]:
|
|
29
|
+
"""
|
|
30
|
+
:returns: A dictionary of parameter names mapping to time series identifiers.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
return {}
|