rtc-tools-interface 0.10.2a3__tar.gz → 0.10.2a4__tar.gz
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_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/PKG-INFO +1 -1
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/README.md +26 -7
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/PKG-INFO +1 -1
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/SOURCES.txt +6 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/_version.py +3 -3
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/results_construction.py +22 -18
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/runner.py +36 -14
- rtc_tools_interface-0.10.2a4/rtctools_interface/optimization/active_constraint_helpers.py +102 -0
- rtc_tools_interface-0.10.2a4/rtctools_interface/optimization/active_constraint_mixin.py +302 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/type_definitions.py +19 -0
- rtc_tools_interface-0.10.2a4/tests/closed_loop/test_combine_xml_exports.py +106 -0
- rtc_tools_interface-0.10.2a4/tests/closed_loop/test_set_initial_values_from_previous_run.py +61 -0
- rtc_tools_interface-0.10.2a4/tests/optimization/test_active_constraint_helpers.py +87 -0
- rtc_tools_interface-0.10.2a4/tests/optimization/test_active_constraint_mixin.py +89 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/COPYING.LESSER +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/dependency_links.txt +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/requires.txt +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/top_level.txt +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/config.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/optimization_ranges.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/time_series_handler.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/base_goal.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/base_optimization_problem.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_generator_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_performance_metrics.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_table_schema.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/helpers/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/helpers/statistics_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/plot_goals_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/plot_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/read_goals.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/plot_tools.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/subplot_classes.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/base_simulation_problem.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/plot_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/plot_table_schema.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/read_goals_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/read_plot_table.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/results_collection.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/serialization.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/setup.cfg +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/setup.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_optimization_ranges.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_read_xml.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_run_optization_problem_closed_loop.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_base_optimization_problem.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_passing_goals_directly.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_plot_goals_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_read_goals.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/simulation/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/simulation/test_base_simulation_problem.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/simulation/test_plot_mixin.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/utils/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/utils/get_test.py +0 -0
- {rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/versioneer.py +0 -0
|
@@ -11,8 +11,9 @@ pip install rtc-tools-interface
|
|
|
11
11
|
## Table of Contents
|
|
12
12
|
1. [Goal generator](#goal-generator)
|
|
13
13
|
2. [Goal performance metrics](#goal-performance-metrics)
|
|
14
|
-
3. [
|
|
15
|
-
4. [
|
|
14
|
+
3. [Active constraint diagnostics](#active-constraint-diagnostics)
|
|
15
|
+
4. [Automatic plotting of results](#automatic-plotting-of-results)
|
|
16
|
+
5. [Closed loop runner](#closed-loop-runner)
|
|
16
17
|
|
|
17
18
|
## Goal generator
|
|
18
19
|
The `goal generator` can be used to automatically add goals based on a csv file. Currently, the following goal types are supported:
|
|
@@ -91,18 +92,19 @@ by minimizing, if any, the sum of exceedances for the timesteps. For more detail
|
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
#### range_rate_of_change
|
|
94
|
-
The range_rate_of_change goal can be used to set a target range on ramp rate. Like the range goal, one needs to set the `target_min` and `target_max` for that. **Importantly** for the range_rate_of_change goal, the supplied values are relative to the nominal of the function. So supplying a `target_max` of `10` corresponds to the aim of having a maximum increase per timestep of 10% * `nominal`, where the nominal automatically set to `maximum rate of change`/2 or specified manually. To formulate the target of having a maximum increase and decrease by of 10% per timestep, one would set the `target_min` to `-10` and the `target_max` to `10`.
|
|
95
|
+
The range_rate_of_change goal can be used to set a target range on ramp rate for a model state. Like the range goal, one needs to set the `target_min` and `target_max` for that. **Importantly** for the range_rate_of_change goal, the supplied values are relative to the nominal of the function. So supplying a `target_max` of `10` corresponds to the aim of having a maximum increase per timestep of 10% * `nominal`, where the nominal automatically set to `maximum rate of change`/2 or specified manually. To formulate the target of having a maximum increase and decrease by of 10% per timestep, one would set the `target_min` to `-10` and the `target_max` to `10`.
|
|
95
96
|
|
|
96
|
-
The equations for the range_rate_of_change goal are almost the same as for the range goal, which can be found above. The only difference is that $x_t$ is replaced by the derivative of $x$ with respect to $t$, calculated as $\frac{x_t - x_{t-1}}{\Delta t}$.
|
|
97
|
+
The equations for the range_rate_of_change goal are almost the same as for the range goal, which can be found above. The only difference is that $x_t$ is replaced by the derivative of $x$ with respect to $t$, calculated as $\frac{x_t - x_{t-1}}{\Delta t}$. RTC-Tools computes the derivative (the ramp rate) internally for the state variable specified in the goal table.
|
|
97
98
|
|
|
98
99
|
### Example goal table
|
|
99
100
|
See the table below for an example content of the `goal_table.csv`.
|
|
100
101
|
|
|
101
102
|
| id | state | active | goal_type | function_min | function_max | function_nominal | target_data_type | target_min | target_max | priority | weight | order |
|
|
102
103
|
|--------|-------|--------|--------------|--------------|--------------|------------------|------------------|------------|------------|----------|--------|-------|
|
|
103
|
-
| goal_1 | reservoir_1_waterlevel | 1 | range | 0 | 15 | 10 | value | 5.0 | 10.0 | 5 |
|
|
104
|
-
| goal_2 | reservoir_2_waterlevel | 1 | range | 0 | 15 | 10 | timeseries | "target_series" | "target_series" | 10 |
|
|
105
|
-
| goal_3 | electricity_cost | 1 | minimization_path | | | | | | | 20 |
|
|
104
|
+
| goal_1 | reservoir_1_waterlevel | 1 | range | 0 | 15 | 10 | value | 5.0 | 10.0 | 5 | 1 | 1 |
|
|
105
|
+
| goal_2 | reservoir_2_waterlevel | 1 | range | 0 | 15 | 10 | timeseries | "target_series" | "target_series" | 10 | 1 | 1 |
|
|
106
|
+
| goal_3 | electricity_cost | 1 | minimization_path | | | | | | | 20 | 1 | 1 |
|
|
107
|
+
| goal_4 | reservoir_2_volume | 1 | range_rate_of_change | -100 | 100 | 10 | value | -10 | 10 | 30 | 1 | 1 |
|
|
106
108
|
|
|
107
109
|
## Goal performance metrics
|
|
108
110
|
For all goals defined with the goal generator this rtc-tools-interface module will also calculate performance metrics. By default, these performance metrics are saved to a .csv in the folder `output/perfomance_metrics`, with one csv file per goal. With the class variable `calculate_performance_metrics` this functionality can be disabled (by default it is enabled).
|
|
@@ -116,6 +118,23 @@ The calculated metrics are:
|
|
|
116
118
|
- `mean_absolute_percentual_difference`: The mean of the absolute percentual difference per timestep over all timesteps (only for range goals).
|
|
117
119
|
- `mean_absolute_difference`: The mean absolute difference per timestep of the state variable over all timesteps (only for range goals).
|
|
118
120
|
|
|
121
|
+
## Active constraint diagnostics
|
|
122
|
+
The `ActiveConstraintMixin` writes diagnostics about active constraints after each RTC-Tools goal-programming priority. A constraint is considered active when its evaluated value hits either its lower or upper bound within `active_constraint_tolerance` (default `1e-6`).
|
|
123
|
+
|
|
124
|
+
To enable the diagnostics, import the mixin and add it before `BaseOptimizationProblem` or before RTC-Tools' `GoalProgrammingMixin` in the class inheritance order:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from rtctools_interface.optimization.active_constraint_mixin import ActiveConstraintMixin
|
|
128
|
+
from rtctools_interface.optimization.base_optimization_problem import BaseOptimizationProblem
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MyOptimizationProblem(ActiveConstraintMixin, BaseOptimizationProblem):
|
|
132
|
+
pass
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
After optimization, the mixin creates `output/active_constraints/active_constraints_of_previous_goals.csv`.
|
|
136
|
+
This file contains detailed rows for active constraints that RTC-Tools created from goals optimized in previous priorities. For each priority with active previous-goal constraints, it reports the total number of previous-goal constraints, how many are active, which bound (`lower`, `upper`, or `both`) was hit, and the corresponding bound value. Path-goal constraints are reported in one row per active path goal; the `active_times` column lists the timesteps at which that path-goal constraint is active.
|
|
137
|
+
|
|
119
138
|
|
|
120
139
|
## Automatic plotting of results
|
|
121
140
|
With the `PlotMixin` one can easily make plots of the results of rtc-tools. This functionality can be used both for optimization and simulation problems. For optimization problems, use:
|
|
@@ -17,6 +17,8 @@ rtctools_interface/closed_loop/results_construction.py
|
|
|
17
17
|
rtctools_interface/closed_loop/runner.py
|
|
18
18
|
rtctools_interface/closed_loop/time_series_handler.py
|
|
19
19
|
rtctools_interface/optimization/__init__.py
|
|
20
|
+
rtctools_interface/optimization/active_constraint_helpers.py
|
|
21
|
+
rtctools_interface/optimization/active_constraint_mixin.py
|
|
20
22
|
rtctools_interface/optimization/base_goal.py
|
|
21
23
|
rtctools_interface/optimization/base_optimization_problem.py
|
|
22
24
|
rtctools_interface/optimization/goal_generator_mixin.py
|
|
@@ -42,10 +44,14 @@ rtctools_interface/utils/serialization.py
|
|
|
42
44
|
rtctools_interface/utils/type_definitions.py
|
|
43
45
|
tests/__init__.py
|
|
44
46
|
tests/closed_loop/__init__.py
|
|
47
|
+
tests/closed_loop/test_combine_xml_exports.py
|
|
45
48
|
tests/closed_loop/test_optimization_ranges.py
|
|
46
49
|
tests/closed_loop/test_read_xml.py
|
|
47
50
|
tests/closed_loop/test_run_optization_problem_closed_loop.py
|
|
51
|
+
tests/closed_loop/test_set_initial_values_from_previous_run.py
|
|
48
52
|
tests/optimization/__init__.py
|
|
53
|
+
tests/optimization/test_active_constraint_helpers.py
|
|
54
|
+
tests/optimization/test_active_constraint_mixin.py
|
|
49
55
|
tests/optimization/test_base_optimization_problem.py
|
|
50
56
|
tests/optimization/test_passing_goals_directly.py
|
|
51
57
|
tests/optimization/test_plot_goals_mixin.py
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/_version.py
RENAMED
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "2026-
|
|
11
|
+
"date": "2026-09-17T07:52:27+0200",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "0.10.
|
|
14
|
+
"full-revisionid": "8d59f3b94a76dba915ac4488e060b927d4820acc",
|
|
15
|
+
"version": "0.10.2a4"
|
|
16
16
|
}
|
|
17
17
|
''' # END VERSION_JSON
|
|
18
18
|
|
|
@@ -36,8 +36,8 @@ def combine_xml_exports(
|
|
|
36
36
|
)
|
|
37
37
|
if ts_import_orig.forecast_datetime > ts_import_orig.start_datetime:
|
|
38
38
|
logger.info(
|
|
39
|
-
"Timeseries export will start at original forecast date, "
|
|
40
|
-
"
|
|
39
|
+
"Timeseries export will start at original forecast date, disregarding data before"
|
|
40
|
+
" forecast date."
|
|
41
41
|
)
|
|
42
42
|
ts_import_orig.resize(ts_import_orig.forecast_datetime, ts_import_orig.end_datetime)
|
|
43
43
|
ts_import_orig.times = ts_import_orig.times[
|
|
@@ -67,6 +67,21 @@ def combine_xml_exports(
|
|
|
67
67
|
all_times = ts_import_orig.times # Workaround to map indices to times, as ts_export does
|
|
68
68
|
# not contain all times. TODO Check whether the assumption that these times map to
|
|
69
69
|
# the correct indices for ts_export always holds.
|
|
70
|
+
new_times = ts_export_step.times
|
|
71
|
+
if len(new_times) <= 1:
|
|
72
|
+
logger.debug(f"Skipping model horizon {i}: only a single timestep was exported.")
|
|
73
|
+
i += 1
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
start_new_data_index = all_times.index(new_times[1])
|
|
77
|
+
except ValueError:
|
|
78
|
+
if all_times[-1] + ts_export.dt == new_times[0]:
|
|
79
|
+
start_new_data_index = len(all_times)
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Could not match the start data of the timeseries export file "
|
|
83
|
+
+ "with the end of the previous."
|
|
84
|
+
)
|
|
70
85
|
for loc_par in variables:
|
|
71
86
|
try:
|
|
72
87
|
current_values = ts_export.get(loc_par)
|
|
@@ -74,21 +89,10 @@ def combine_xml_exports(
|
|
|
74
89
|
except KeyError:
|
|
75
90
|
logger.debug(f"Variable {loc_par} not found in output of model horizon: {i}")
|
|
76
91
|
continue
|
|
77
|
-
new_times = ts_export_step.times
|
|
78
|
-
try:
|
|
79
|
-
start_new_data_index = all_times.index(new_times[0])
|
|
80
|
-
except ValueError:
|
|
81
|
-
if all_times[-1] + ts_export.dt == new_times[0]:
|
|
82
|
-
start_new_data_index = len(all_times)
|
|
83
|
-
else:
|
|
84
|
-
raise ValueError(
|
|
85
|
-
"Could not match the start data of the timeseries export file "
|
|
86
|
-
+ "with the end of the previous."
|
|
87
|
-
)
|
|
88
92
|
combined_values = copy.deepcopy(current_values)
|
|
89
|
-
combined_values[start_new_data_index : start_new_data_index + len(new_values)] = (
|
|
90
|
-
new_values
|
|
91
|
-
)
|
|
93
|
+
combined_values[start_new_data_index : start_new_data_index + len(new_values[1:])] = (
|
|
94
|
+
new_values[1:]
|
|
95
|
+
) # noqa
|
|
92
96
|
ts_export.set(loc_par, combined_values)
|
|
93
97
|
i += 1
|
|
94
98
|
ts_export.write(output_folder=output_base_path.parent, output_filename="timeseries_export")
|
|
@@ -109,8 +113,8 @@ def combine_xml_exports(
|
|
|
109
113
|
|
|
110
114
|
def combine_dataframes(dfs: list[pd.DataFrame], index_col: str = "time"):
|
|
111
115
|
"""Combine multiple dataframes with the same index column.
|
|
112
|
-
The dataframes are combined in the order they are passed,
|
|
113
|
-
|
|
116
|
+
The dataframes are combined in the order they are passed, with the last dataframe taking
|
|
117
|
+
precedence in case of overlapping indices."""
|
|
114
118
|
combined_df = pd.DataFrame()
|
|
115
119
|
for df in dfs:
|
|
116
120
|
df.set_index(index_col, inplace=True)
|
|
@@ -187,15 +187,27 @@ def run_optimization_problem_closed_loop(
|
|
|
187
187
|
)
|
|
188
188
|
|
|
189
189
|
logger.info(f"Running optimization for period {i}: {(str(start_time), str(end_time))}.")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
190
|
+
run_number_in_fallback_list = 1
|
|
191
|
+
# Fallback mechanism: allows optimization class to request a retry
|
|
192
|
+
# run_number_in_fallback_list: 1=first attempt, 2=retry with fallback, 3=done
|
|
193
|
+
for _k in range(2):
|
|
194
|
+
if run_number_in_fallback_list < 3:
|
|
195
|
+
result = run_optimization_problem(
|
|
196
|
+
optimization_problem_class,
|
|
197
|
+
base_folder,
|
|
198
|
+
log_level,
|
|
199
|
+
profile,
|
|
200
|
+
input_folder=modelling_period_input_folder_i,
|
|
201
|
+
output_folder=modelling_period_output_folder_i,
|
|
202
|
+
run_number_in_fallback_list=run_number_in_fallback_list,
|
|
203
|
+
**kwargs,
|
|
204
|
+
)
|
|
205
|
+
if hasattr(result, "run_number_in_fallback_list"):
|
|
206
|
+
run_number_in_fallback_list = result.run_number_in_fallback_list
|
|
207
|
+
else:
|
|
208
|
+
run_number_in_fallback_list = 3
|
|
209
|
+
if run_number_in_fallback_list == 2:
|
|
210
|
+
logger.info("The model failed, it will run it again with a fallback option")
|
|
199
211
|
period = f"period {i} {(str(start_time), str(end_time))}"
|
|
200
212
|
if result.solver_stats["success"]:
|
|
201
213
|
logger.info(f"Successful optimization for {period}.")
|
|
@@ -207,11 +219,21 @@ def run_optimization_problem_closed_loop(
|
|
|
207
219
|
logger.error(message)
|
|
208
220
|
raise Exception(message)
|
|
209
221
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if key not in fixed_input_series
|
|
214
|
-
|
|
222
|
+
extracted_results = result.extract_results() or {}
|
|
223
|
+
results_previous_run = {}
|
|
224
|
+
for key in variables_in_import:
|
|
225
|
+
if key not in fixed_input_series:
|
|
226
|
+
extracted_value = extracted_results.get(key)
|
|
227
|
+
if extracted_value is not None:
|
|
228
|
+
results_previous_run[key] = extracted_value
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
fallback_value = result.get_timeseries(key)
|
|
232
|
+
except KeyError:
|
|
233
|
+
fallback_value = None
|
|
234
|
+
results_previous_run[key] = (
|
|
235
|
+
fallback_value.values if fallback_value is not None else None
|
|
236
|
+
)
|
|
215
237
|
previous_run_datetimes = result.io.datetimes
|
|
216
238
|
|
|
217
239
|
logger.info("Finished all optimization runs.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Helper functions for active-constraint diagnostics."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import casadi as ca
|
|
8
|
+
import numpy as np
|
|
9
|
+
from rtctools.optimization.timeseries import Timeseries
|
|
10
|
+
|
|
11
|
+
from rtctools_interface.utils.type_definitions import PreviousGoalConstraintRow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_flat_float_array(value: object) -> np.ndarray:
|
|
15
|
+
"""Convert CasADi/numeric values to a one-dimensional float array."""
|
|
16
|
+
if isinstance(value, Timeseries):
|
|
17
|
+
value = value.values
|
|
18
|
+
if isinstance(value, (list, tuple)):
|
|
19
|
+
if not value:
|
|
20
|
+
return np.array([], dtype=float)
|
|
21
|
+
value = ca.veccat(*value)
|
|
22
|
+
array = np.array(value, dtype=float)
|
|
23
|
+
return array.reshape(-1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bound_to_array(bound: object, size: int) -> np.ndarray:
|
|
27
|
+
"""Return a flat bound array matching an evaluated constraint size."""
|
|
28
|
+
if isinstance(bound, Timeseries):
|
|
29
|
+
bound = bound.values
|
|
30
|
+
array = np.array(bound, dtype=float)
|
|
31
|
+
if array.size == 1 and size != 1:
|
|
32
|
+
return np.full(size, float(array.reshape(-1)[0]))
|
|
33
|
+
return array.reshape(-1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def component_time(
|
|
37
|
+
times: np.ndarray | None, component_index: int, n_components_per_time: int
|
|
38
|
+
) -> float | str:
|
|
39
|
+
"""Return the time associated with a flattened path-constraint component."""
|
|
40
|
+
if times is None or len(times) == 0:
|
|
41
|
+
return ""
|
|
42
|
+
return times[component_index // n_components_per_time]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_active_times(
|
|
46
|
+
times: np.ndarray | None, active_indices: np.ndarray, values_size: int
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Format unique active timesteps for a flattened path-constraint vector."""
|
|
49
|
+
if times is None or len(times) == 0:
|
|
50
|
+
return ""
|
|
51
|
+
if values_size % len(times) != 0:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"Cannot map path-goal constraint components to times: "
|
|
54
|
+
f"values size {values_size} is not divisible by {len(times)} timesteps."
|
|
55
|
+
)
|
|
56
|
+
n_components_per_time = values_size // len(times)
|
|
57
|
+
active_times = [
|
|
58
|
+
component_time(times, int(index), n_components_per_time) for index in active_indices
|
|
59
|
+
]
|
|
60
|
+
return format_values(active_times)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_indexed_values(values: np.ndarray, indices: np.ndarray) -> str:
|
|
64
|
+
"""Format values at selected indices for aggregated CSV cells."""
|
|
65
|
+
return format_values([values[index] for index in indices])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_values(values: Iterable[object]) -> str:
|
|
69
|
+
"""Format unique, non-empty values as a semicolon-separated string."""
|
|
70
|
+
formatted_values = []
|
|
71
|
+
for value in values:
|
|
72
|
+
if value == "" or (isinstance(value, float) and np.isnan(value)):
|
|
73
|
+
continue
|
|
74
|
+
formatted_value = str(value)
|
|
75
|
+
if formatted_value not in formatted_values:
|
|
76
|
+
formatted_values.append(formatted_value)
|
|
77
|
+
return ";".join(formatted_values)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def active_bound_description(
|
|
81
|
+
lower_active: bool, upper_active: bool, lower_bound: float, upper_bound: float
|
|
82
|
+
) -> tuple[str, str | float]:
|
|
83
|
+
"""Return a text label and value for the bound hit by a constraint."""
|
|
84
|
+
if lower_active and upper_active:
|
|
85
|
+
if np.isclose(lower_bound, upper_bound, rtol=0.0, atol=0.0):
|
|
86
|
+
return "both", lower_bound
|
|
87
|
+
return "both", f"{lower_bound};{upper_bound}"
|
|
88
|
+
if lower_active:
|
|
89
|
+
return "lower", lower_bound
|
|
90
|
+
if upper_active:
|
|
91
|
+
return "upper", upper_bound
|
|
92
|
+
return "", ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_csv(
|
|
96
|
+
file_path: Path, fieldnames: list[str], rows: list[PreviousGoalConstraintRow]
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Write rows to a CSV file with a stable header."""
|
|
99
|
+
with file_path.open("w", newline="") as csv_file:
|
|
100
|
+
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
|
101
|
+
writer.writeheader()
|
|
102
|
+
writer.writerows(rows)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Mixin for reporting active constraints after goal-programming priorities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import casadi as ca
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from rtctools_interface.optimization.active_constraint_helpers import (
|
|
10
|
+
active_bound_description,
|
|
11
|
+
as_flat_float_array,
|
|
12
|
+
bound_to_array,
|
|
13
|
+
format_active_times,
|
|
14
|
+
format_indexed_values,
|
|
15
|
+
format_values,
|
|
16
|
+
write_csv,
|
|
17
|
+
)
|
|
18
|
+
from rtctools_interface.utils.type_definitions import PreviousGoalConstraintRow
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _PreviousGoalConstraintInternalRow(PreviousGoalConstraintRow):
|
|
22
|
+
"""Previous-goal constraint row while active-state filtering is still needed."""
|
|
23
|
+
|
|
24
|
+
is_active: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ActiveConstraintMixin:
|
|
28
|
+
"""Write active-constraint diagnostics for RTC-Tools optimizations.
|
|
29
|
+
|
|
30
|
+
Add this mixin before ``GoalProgrammingMixin``/``BaseOptimizationProblem`` in
|
|
31
|
+
the inheritance list. After optimization,
|
|
32
|
+
``active_constraints_of_previous_goals.csv`` is written to
|
|
33
|
+
``<output_folder>/active_constraints`` with details for active constraints that
|
|
34
|
+
RTC-Tools created from goals optimized in previous priorities.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
active_constraint_output_folder = "active_constraints"
|
|
38
|
+
active_constraint_tolerance = 1e-6
|
|
39
|
+
|
|
40
|
+
def __init__(self, **kwargs):
|
|
41
|
+
self._previous_goal_constraint_rows: list[PreviousGoalConstraintRow] = []
|
|
42
|
+
super().__init__(**kwargs)
|
|
43
|
+
|
|
44
|
+
def priority_completed(self, priority: int) -> None:
|
|
45
|
+
"""Collect active-constraint diagnostics after a priority is solved."""
|
|
46
|
+
self._collect_previous_goal_constraint_details(priority)
|
|
47
|
+
super().priority_completed(priority)
|
|
48
|
+
|
|
49
|
+
def post(self) -> None:
|
|
50
|
+
"""Write active-constraint diagnostics after optimization."""
|
|
51
|
+
super().post()
|
|
52
|
+
if self.solver_stats["success"]:
|
|
53
|
+
self._write_active_constraint_csv_files()
|
|
54
|
+
|
|
55
|
+
def _active_bound_masks(
|
|
56
|
+
self, values: np.ndarray, lower_bounds: np.ndarray, upper_bounds: np.ndarray
|
|
57
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
58
|
+
"""Return masks for lower-bound and upper-bound active components."""
|
|
59
|
+
tolerance = self.active_constraint_tolerance
|
|
60
|
+
lower_active = np.isfinite(lower_bounds) & np.isclose(
|
|
61
|
+
values, lower_bounds, rtol=0, atol=tolerance
|
|
62
|
+
)
|
|
63
|
+
upper_active = np.isfinite(upper_bounds) & np.isclose(
|
|
64
|
+
values, upper_bounds, rtol=0, atol=tolerance
|
|
65
|
+
)
|
|
66
|
+
return lower_active, upper_active
|
|
67
|
+
|
|
68
|
+
def _collect_previous_goal_constraint_details(self, priority: int) -> None:
|
|
69
|
+
"""Collect rows for constraints created from goals of previous priorities."""
|
|
70
|
+
rows = []
|
|
71
|
+
rows.extend(self._goal_constraint_rows(priority, "goal", is_path_goal=False))
|
|
72
|
+
rows.extend(self._goal_constraint_rows(priority, "path_goal", is_path_goal=True))
|
|
73
|
+
rows.extend(
|
|
74
|
+
self._goal_constraint_rows(
|
|
75
|
+
priority, "kept_soft_goal", is_path_goal=False, include_problem_constraints=True
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
rows.extend(
|
|
79
|
+
self._goal_constraint_rows(
|
|
80
|
+
priority,
|
|
81
|
+
"kept_soft_path_goal",
|
|
82
|
+
is_path_goal=True,
|
|
83
|
+
include_problem_constraints=True,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
total_constraints = len(rows)
|
|
88
|
+
active_rows = [row for row in rows if row.pop("is_active")]
|
|
89
|
+
active_constraints = len(active_rows)
|
|
90
|
+
if active_constraints == 0:
|
|
91
|
+
self._previous_goal_constraint_rows.append(
|
|
92
|
+
self._inactive_priority_row(priority, total_constraints)
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for row in active_rows:
|
|
97
|
+
row["total_previous_goal_constraints"] = total_constraints
|
|
98
|
+
row["active_previous_goals_constraints"] = active_constraints
|
|
99
|
+
self._previous_goal_constraint_rows.extend(active_rows)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _inactive_priority_row(priority: int, total_constraints: int) -> PreviousGoalConstraintRow:
|
|
103
|
+
"""Build a summary row for a priority without active previous-goal constraints."""
|
|
104
|
+
row = cast(
|
|
105
|
+
PreviousGoalConstraintRow,
|
|
106
|
+
dict.fromkeys(PreviousGoalConstraintRow.__annotations__, ""),
|
|
107
|
+
)
|
|
108
|
+
row.update(
|
|
109
|
+
{
|
|
110
|
+
"priority": priority,
|
|
111
|
+
"total_previous_goal_constraints": total_constraints,
|
|
112
|
+
"active_previous_goals_constraints": 0,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
return row
|
|
116
|
+
|
|
117
|
+
def _goal_constraint_rows(
|
|
118
|
+
self,
|
|
119
|
+
priority: int,
|
|
120
|
+
constraint_source: str,
|
|
121
|
+
*,
|
|
122
|
+
is_path_goal: bool,
|
|
123
|
+
include_problem_constraints: bool = False,
|
|
124
|
+
) -> list[_PreviousGoalConstraintInternalRow]:
|
|
125
|
+
"""Evaluate private RTC-Tools goal constraints and build detail rows."""
|
|
126
|
+
rows: list[_PreviousGoalConstraintInternalRow] = []
|
|
127
|
+
constraint_collections = self._get_goal_constraint_collections(
|
|
128
|
+
is_path_goal=is_path_goal, include_problem_constraints=include_problem_constraints
|
|
129
|
+
)
|
|
130
|
+
times = self.times() if is_path_goal else None
|
|
131
|
+
|
|
132
|
+
for ensemble_member, constraints in enumerate(constraint_collections):
|
|
133
|
+
for function_key, constraint in constraints:
|
|
134
|
+
goal = getattr(constraint, "goal", None)
|
|
135
|
+
if goal is not None and int(goal.priority) >= int(priority):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
values = self._evaluate_goal_constraint(
|
|
139
|
+
constraint, ensemble_member, is_path_goal=is_path_goal
|
|
140
|
+
)
|
|
141
|
+
lower_bounds = bound_to_array(constraint.min, values.size)
|
|
142
|
+
upper_bounds = bound_to_array(constraint.max, values.size)
|
|
143
|
+
lower_active, upper_active = self._active_bound_masks(
|
|
144
|
+
values, lower_bounds, upper_bounds
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if is_path_goal:
|
|
148
|
+
rows.append(
|
|
149
|
+
self._path_goal_constraint_row(
|
|
150
|
+
priority,
|
|
151
|
+
constraint_source,
|
|
152
|
+
function_key,
|
|
153
|
+
ensemble_member,
|
|
154
|
+
goal,
|
|
155
|
+
times,
|
|
156
|
+
values,
|
|
157
|
+
lower_bounds,
|
|
158
|
+
upper_bounds,
|
|
159
|
+
lower_active,
|
|
160
|
+
upper_active,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
for component_index, value in enumerate(values):
|
|
166
|
+
active_bound, active_bound_value = active_bound_description(
|
|
167
|
+
lower_active[component_index],
|
|
168
|
+
upper_active[component_index],
|
|
169
|
+
lower_bounds[component_index],
|
|
170
|
+
upper_bounds[component_index],
|
|
171
|
+
)
|
|
172
|
+
rows.append(
|
|
173
|
+
{
|
|
174
|
+
"priority": priority,
|
|
175
|
+
"total_previous_goal_constraints": "",
|
|
176
|
+
"active_previous_goals_constraints": "",
|
|
177
|
+
"ensemble_member": ensemble_member,
|
|
178
|
+
"constraint_source": constraint_source,
|
|
179
|
+
"function_key": function_key,
|
|
180
|
+
"goal_priority": (int(goal.priority) if goal is not None else ""),
|
|
181
|
+
"goal_class": goal.__class__.__name__ if goal is not None else "",
|
|
182
|
+
"active_times": "",
|
|
183
|
+
"value": float(value),
|
|
184
|
+
"lower_bound": float(lower_bounds[component_index]),
|
|
185
|
+
"upper_bound": float(upper_bounds[component_index]),
|
|
186
|
+
"is_active": bool(
|
|
187
|
+
lower_active[component_index] or upper_active[component_index]
|
|
188
|
+
),
|
|
189
|
+
"active_bound": active_bound,
|
|
190
|
+
"active_bound_value": active_bound_value,
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
return rows
|
|
194
|
+
|
|
195
|
+
def _path_goal_constraint_row(
|
|
196
|
+
self,
|
|
197
|
+
priority: int,
|
|
198
|
+
constraint_source: str,
|
|
199
|
+
function_key: str,
|
|
200
|
+
ensemble_member: int,
|
|
201
|
+
goal: Any,
|
|
202
|
+
times: np.ndarray | None,
|
|
203
|
+
values: np.ndarray,
|
|
204
|
+
lower_bounds: np.ndarray,
|
|
205
|
+
upper_bounds: np.ndarray,
|
|
206
|
+
lower_active: np.ndarray,
|
|
207
|
+
upper_active: np.ndarray,
|
|
208
|
+
) -> _PreviousGoalConstraintInternalRow:
|
|
209
|
+
"""Build a single summary row for a path-goal constraint."""
|
|
210
|
+
active = lower_active | upper_active
|
|
211
|
+
active_indices = np.flatnonzero(active)
|
|
212
|
+
active_bound_descriptions = [
|
|
213
|
+
active_bound_description(
|
|
214
|
+
lower_active[index],
|
|
215
|
+
upper_active[index],
|
|
216
|
+
lower_bounds[index],
|
|
217
|
+
upper_bounds[index],
|
|
218
|
+
)
|
|
219
|
+
for index in active_indices
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"priority": priority,
|
|
224
|
+
"total_previous_goal_constraints": "",
|
|
225
|
+
"active_previous_goals_constraints": "",
|
|
226
|
+
"ensemble_member": ensemble_member,
|
|
227
|
+
"constraint_source": constraint_source,
|
|
228
|
+
"function_key": function_key,
|
|
229
|
+
"goal_priority": int(goal.priority) if goal is not None else "",
|
|
230
|
+
"goal_class": goal.__class__.__name__ if goal is not None else "",
|
|
231
|
+
"active_times": format_active_times(times, active_indices, values.size),
|
|
232
|
+
"value": format_indexed_values(values, active_indices),
|
|
233
|
+
"lower_bound": format_indexed_values(lower_bounds, active_indices),
|
|
234
|
+
"upper_bound": format_indexed_values(upper_bounds, active_indices),
|
|
235
|
+
"is_active": bool(np.any(active)),
|
|
236
|
+
"active_bound": format_values(
|
|
237
|
+
[description[0] for description in active_bound_descriptions]
|
|
238
|
+
),
|
|
239
|
+
"active_bound_value": format_values(
|
|
240
|
+
[description[1] for description in active_bound_descriptions]
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def _get_goal_constraint_collections(
|
|
245
|
+
self, *, is_path_goal: bool, include_problem_constraints: bool
|
|
246
|
+
) -> list[list[tuple[str, Any]]]:
|
|
247
|
+
"""Read goal-constraint stores from RTC-Tools goal programming internals."""
|
|
248
|
+
if include_problem_constraints:
|
|
249
|
+
attribute = (
|
|
250
|
+
"_GoalProgrammingMixin__problem_path_constraints"
|
|
251
|
+
if is_path_goal
|
|
252
|
+
else "_GoalProgrammingMixin__problem_constraints"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
attribute = (
|
|
256
|
+
"_GoalProgrammingMixin__path_constraint_store"
|
|
257
|
+
if is_path_goal
|
|
258
|
+
else "_GoalProgrammingMixin__constraint_store"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
constraint_store = getattr(self, attribute, [])
|
|
262
|
+
collections = []
|
|
263
|
+
for ensemble_member in range(self.ensemble_size):
|
|
264
|
+
try:
|
|
265
|
+
constraints = constraint_store[ensemble_member]
|
|
266
|
+
except (IndexError, KeyError, TypeError):
|
|
267
|
+
constraints = []
|
|
268
|
+
if hasattr(constraints, "items"):
|
|
269
|
+
collections.append(list(constraints.items()))
|
|
270
|
+
else:
|
|
271
|
+
collections.append(
|
|
272
|
+
[
|
|
273
|
+
(f"{attribute}_{ensemble_member}_{i}", constraint)
|
|
274
|
+
for i, constraint in enumerate(constraints)
|
|
275
|
+
]
|
|
276
|
+
)
|
|
277
|
+
return collections
|
|
278
|
+
|
|
279
|
+
def _evaluate_goal_constraint(
|
|
280
|
+
self, constraint: Any, ensemble_member: int, *, is_path_goal: bool
|
|
281
|
+
) -> np.ndarray:
|
|
282
|
+
"""Evaluate a goal constraint at the current solver solution."""
|
|
283
|
+
expression = constraint.function(self)
|
|
284
|
+
if is_path_goal:
|
|
285
|
+
expression = self.map_path_expression(expression, ensemble_member)
|
|
286
|
+
function = ca.Function(
|
|
287
|
+
f"active_goal_constraint_{ensemble_member}_{id(constraint)}",
|
|
288
|
+
[self.solver_input],
|
|
289
|
+
[expression],
|
|
290
|
+
)
|
|
291
|
+
return as_flat_float_array(function(self.solver_output))
|
|
292
|
+
|
|
293
|
+
def _write_active_constraint_csv_files(self) -> None:
|
|
294
|
+
"""Write collected active-constraint diagnostics to CSV files."""
|
|
295
|
+
output_folder = Path(self._output_folder) / self.active_constraint_output_folder
|
|
296
|
+
output_folder.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
write_csv(
|
|
299
|
+
output_folder / "active_constraints_of_previous_goals.csv",
|
|
300
|
+
list(PreviousGoalConstraintRow.__annotations__),
|
|
301
|
+
self._previous_goal_constraint_rows,
|
|
302
|
+
)
|
|
@@ -58,6 +58,25 @@ class IntermediateResult(TypedDict):
|
|
|
58
58
|
timeseries_data: dict[str, np.ndarray]
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
class PreviousGoalConstraintRow(TypedDict):
|
|
62
|
+
"""CSV row for active constraints created from previous goals."""
|
|
63
|
+
|
|
64
|
+
priority: int
|
|
65
|
+
total_previous_goal_constraints: int | str
|
|
66
|
+
active_previous_goals_constraints: int | str
|
|
67
|
+
ensemble_member: int | str
|
|
68
|
+
constraint_source: str
|
|
69
|
+
function_key: str
|
|
70
|
+
goal_priority: int | str
|
|
71
|
+
goal_class: str
|
|
72
|
+
active_times: str
|
|
73
|
+
value: float | str
|
|
74
|
+
lower_bound: float | str
|
|
75
|
+
upper_bound: float | str
|
|
76
|
+
active_bound: str
|
|
77
|
+
active_bound_value: float | str
|
|
78
|
+
|
|
79
|
+
|
|
61
80
|
class PlotDataAndConfig(TypedDict):
|
|
62
81
|
"""All data and options required to create all plots for one optimization run."""
|
|
63
82
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tests for the base optimization problem class."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
from rtctools_interface.closed_loop.results_construction import combine_xml_exports
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_timeseries_data_from_xml(path, location_id_to_extract):
|
|
14
|
+
tree = ET.parse(path)
|
|
15
|
+
root = tree.getroot()
|
|
16
|
+
|
|
17
|
+
ns = {"pi": "http://www.wldelft.nl/fews/PI"}
|
|
18
|
+
|
|
19
|
+
for series in root.findall("pi:series", ns):
|
|
20
|
+
header = series.find("pi:header", ns)
|
|
21
|
+
location_id = header.find("pi:locationId", ns)
|
|
22
|
+
if location_id_to_extract in location_id.text:
|
|
23
|
+
values = []
|
|
24
|
+
for event in series.findall("pi:event", ns):
|
|
25
|
+
values.append(float(event.get("value")))
|
|
26
|
+
break
|
|
27
|
+
return values
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestCombineXmlExports(unittest.TestCase):
|
|
31
|
+
"""Combining XML output files."""
|
|
32
|
+
|
|
33
|
+
def test_combine_xml_exports(self):
|
|
34
|
+
TEST_DIR = Path(__file__).parent
|
|
35
|
+
|
|
36
|
+
original_input_timeseries_path = TEST_DIR / "test_models" / "goal_programming_xml" / "input"
|
|
37
|
+
|
|
38
|
+
output_base_path = (
|
|
39
|
+
TEST_DIR
|
|
40
|
+
/ "test_models"
|
|
41
|
+
/ "goal_programming_xml"
|
|
42
|
+
/ "output"
|
|
43
|
+
/ "output_modelling_periods_reference"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
combine_xml_exports(output_base_path, original_input_timeseries_path)
|
|
47
|
+
forecast_timestep = timedelta(days=2)
|
|
48
|
+
timestep = timedelta(hours=8)
|
|
49
|
+
|
|
50
|
+
values_per_period = {}
|
|
51
|
+
|
|
52
|
+
for period_number in range(3):
|
|
53
|
+
xml_path = os.path.join(
|
|
54
|
+
output_base_path, Path("period_" + str(period_number) + "/timeseries_export.xml")
|
|
55
|
+
)
|
|
56
|
+
values = read_timeseries_data_from_xml(xml_path, "Q_orifice")
|
|
57
|
+
values_per_period[period_number] = values
|
|
58
|
+
|
|
59
|
+
# Logic for glueing the series together without taking the first timestep
|
|
60
|
+
aggregates_values = values_per_period[0][0 : int(forecast_timestep / timestep) + 1]
|
|
61
|
+
aggregates_values = (
|
|
62
|
+
aggregates_values + values_per_period[1][1 : int(forecast_timestep / timestep) + 1]
|
|
63
|
+
)
|
|
64
|
+
aggregates_values = aggregates_values + values_per_period[2][1:]
|
|
65
|
+
|
|
66
|
+
reference_values = aggregates_values
|
|
67
|
+
|
|
68
|
+
xml_path = os.path.join(output_base_path, Path("../timeseries_export.xml"))
|
|
69
|
+
values_to_be_compared = read_timeseries_data_from_xml(xml_path, "Q_orifice")
|
|
70
|
+
|
|
71
|
+
# Ensure we checked multiple periods
|
|
72
|
+
assert reference_values == values_to_be_compared
|
|
73
|
+
|
|
74
|
+
@patch("rtctools_interface.closed_loop.results_construction.pi.Timeseries")
|
|
75
|
+
@patch("rtctools_interface.closed_loop.results_construction.rtc.DataConfig")
|
|
76
|
+
@patch("rtctools_interface.closed_loop.results_construction.os.path.isfile")
|
|
77
|
+
def test_combine_xml_exports_skips_single_timestep_period(
|
|
78
|
+
self, isfile_mock, dataconfig_mock, timeseries_mock
|
|
79
|
+
):
|
|
80
|
+
dataconfig = MagicMock()
|
|
81
|
+
dataconfig.pi_variable_ids.return_value = ("location", "parameter")
|
|
82
|
+
dataconfig_mock.return_value = dataconfig
|
|
83
|
+
|
|
84
|
+
ts_import_orig = MagicMock()
|
|
85
|
+
ts_import_orig.forecast_datetime = 0
|
|
86
|
+
ts_import_orig.start_datetime = 0
|
|
87
|
+
ts_import_orig.end_datetime = 2
|
|
88
|
+
ts_import_orig.times = [0, 1, 2]
|
|
89
|
+
|
|
90
|
+
ts_export = MagicMock()
|
|
91
|
+
ts_export.items.return_value = [("Q_orifice", None)]
|
|
92
|
+
ts_export.get.return_value = [1.0, 2.0, 3.0]
|
|
93
|
+
|
|
94
|
+
ts_export_step = MagicMock()
|
|
95
|
+
ts_export_step.times = [1]
|
|
96
|
+
ts_export_step.get.return_value = [9.0]
|
|
97
|
+
|
|
98
|
+
timeseries_mock.side_effect = [ts_import_orig, ts_export, ts_export_step]
|
|
99
|
+
isfile_mock.side_effect = [True, False]
|
|
100
|
+
|
|
101
|
+
combine_xml_exports(Path("/unused/output"), Path("/unused/input"))
|
|
102
|
+
|
|
103
|
+
ts_export.set.assert_not_called()
|
|
104
|
+
ts_export.write.assert_called_once_with(
|
|
105
|
+
output_folder=Path("/unused/output").parent, output_filename="timeseries_export"
|
|
106
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for set_initial_values_from_previous_run."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from rtctools_interface.closed_loop.runner import set_initial_values_from_previous_run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSetInitialValuesFromPreviousRun(unittest.TestCase):
|
|
13
|
+
"""set_initial_values_from_previous_run should fail loudly when no initial value is
|
|
14
|
+
available for a variable, rather than silently skipping it."""
|
|
15
|
+
|
|
16
|
+
def _make_timeseries(self, forecast_date):
|
|
17
|
+
timeseries = MagicMock()
|
|
18
|
+
timeseries.forecast_date = forecast_date
|
|
19
|
+
timeseries.is_set.return_value = False
|
|
20
|
+
return timeseries
|
|
21
|
+
|
|
22
|
+
def test_sets_initial_value_when_present(self):
|
|
23
|
+
forecast_date = datetime.datetime(2020, 1, 1)
|
|
24
|
+
timeseries = self._make_timeseries(forecast_date)
|
|
25
|
+
|
|
26
|
+
set_initial_values_from_previous_run(
|
|
27
|
+
results_previous_run={"var_a": [1.0, 2.0, 3.0]},
|
|
28
|
+
timeseries=timeseries,
|
|
29
|
+
previous_run_datetimes=[forecast_date],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
timeseries.set_initial_value.assert_called_once_with("var_a", 1.0)
|
|
33
|
+
|
|
34
|
+
def test_raises_when_value_missing_for_variable(self):
|
|
35
|
+
"""A variable with no value from either extract_results() or get_timeseries() must
|
|
36
|
+
be represented as None, not omitted, so the missing-value check still fires."""
|
|
37
|
+
forecast_date = datetime.datetime(2020, 1, 1)
|
|
38
|
+
timeseries = self._make_timeseries(forecast_date)
|
|
39
|
+
|
|
40
|
+
with pytest.raises(ValueError, match="Could not find initial value for var_a"):
|
|
41
|
+
set_initial_values_from_previous_run(
|
|
42
|
+
results_previous_run={"var_a": None},
|
|
43
|
+
timeseries=timeseries,
|
|
44
|
+
previous_run_datetimes=[forecast_date],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def test_absent_key_is_invisible_to_consumer(self):
|
|
48
|
+
"""A key left out of results_previous_run entirely (instead of being set to None)
|
|
49
|
+
cannot be detected here: no error is raised and no initial value is set for it.
|
|
50
|
+
Producers must therefore always set missing values to None rather than omitting the
|
|
51
|
+
key, so this consumer's missing-value check can still fire."""
|
|
52
|
+
forecast_date = datetime.datetime(2020, 1, 1)
|
|
53
|
+
timeseries = self._make_timeseries(forecast_date)
|
|
54
|
+
|
|
55
|
+
set_initial_values_from_previous_run(
|
|
56
|
+
results_previous_run={},
|
|
57
|
+
timeseries=timeseries,
|
|
58
|
+
previous_run_datetimes=[forecast_date],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
timeseries.set_initial_value.assert_not_called()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tests for active constraint helper functions."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_active_constraint_helpers():
|
|
13
|
+
"""Load active constraint helpers with runtime dependencies stubbed."""
|
|
14
|
+
module_names = [
|
|
15
|
+
"casadi",
|
|
16
|
+
"rtctools",
|
|
17
|
+
"rtctools.optimization",
|
|
18
|
+
"rtctools.optimization.timeseries",
|
|
19
|
+
"rtctools_interface",
|
|
20
|
+
"rtctools_interface.utils",
|
|
21
|
+
"rtctools_interface.utils.type_definitions",
|
|
22
|
+
]
|
|
23
|
+
previous_modules = {name: sys.modules.get(name) for name in module_names}
|
|
24
|
+
|
|
25
|
+
casadi = types.ModuleType("casadi")
|
|
26
|
+
casadi.veccat = lambda *values: values
|
|
27
|
+
casadi.DM = types.SimpleNamespace(zeros=lambda size: np.zeros(size))
|
|
28
|
+
|
|
29
|
+
timeseries = types.ModuleType("rtctools.optimization.timeseries")
|
|
30
|
+
timeseries.Timeseries = type("Timeseries", (), {})
|
|
31
|
+
|
|
32
|
+
type_definitions = types.ModuleType("rtctools_interface.utils.type_definitions")
|
|
33
|
+
type_definitions.PreviousGoalConstraintRow = dict
|
|
34
|
+
|
|
35
|
+
sys.modules["casadi"] = casadi
|
|
36
|
+
sys.modules["rtctools"] = types.ModuleType("rtctools")
|
|
37
|
+
sys.modules["rtctools.optimization"] = types.ModuleType("rtctools.optimization")
|
|
38
|
+
sys.modules["rtctools.optimization.timeseries"] = timeseries
|
|
39
|
+
sys.modules["rtctools_interface"] = types.ModuleType("rtctools_interface")
|
|
40
|
+
sys.modules["rtctools_interface.utils"] = types.ModuleType("rtctools_interface.utils")
|
|
41
|
+
sys.modules["rtctools_interface.utils.type_definitions"] = type_definitions
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
helper_path = (
|
|
45
|
+
Path(__file__).parents[2]
|
|
46
|
+
/ "rtctools_interface"
|
|
47
|
+
/ "optimization"
|
|
48
|
+
/ "active_constraint_helpers.py"
|
|
49
|
+
)
|
|
50
|
+
spec = importlib.util.spec_from_file_location(
|
|
51
|
+
"active_constraint_helpers_under_test", helper_path
|
|
52
|
+
)
|
|
53
|
+
module = importlib.util.module_from_spec(spec)
|
|
54
|
+
spec.loader.exec_module(module)
|
|
55
|
+
finally:
|
|
56
|
+
for name, previous_module in previous_modules.items():
|
|
57
|
+
if previous_module is None:
|
|
58
|
+
sys.modules.pop(name, None)
|
|
59
|
+
else:
|
|
60
|
+
sys.modules[name] = previous_module
|
|
61
|
+
|
|
62
|
+
return module
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
active_constraint_helpers = _load_active_constraint_helpers()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestActiveConstraintHelpers(unittest.TestCase):
|
|
69
|
+
"""Test active constraint helper functions."""
|
|
70
|
+
|
|
71
|
+
def test_format_active_times_maps_vector_components_to_timesteps(self):
|
|
72
|
+
"""Map flattened vector-valued path-goal components to their timestep."""
|
|
73
|
+
times = np.array([10.0, 20.0, 30.0])
|
|
74
|
+
active_indices = np.array([1, 2, 4])
|
|
75
|
+
|
|
76
|
+
self.assertEqual(
|
|
77
|
+
active_constraint_helpers.format_active_times(times, active_indices, values_size=6),
|
|
78
|
+
"10.0;20.0;30.0",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def test_format_active_times_rejects_uneven_component_count(self):
|
|
82
|
+
"""Reject path-goal values that cannot be evenly mapped to timesteps."""
|
|
83
|
+
times = np.array([10.0, 20.0, 30.0])
|
|
84
|
+
active_indices = np.array([0])
|
|
85
|
+
|
|
86
|
+
with self.assertRaisesRegex(ValueError, "not divisible"):
|
|
87
|
+
active_constraint_helpers.format_active_times(times, active_indices, values_size=5)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tests for the active constraint mixin."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from rtctools_interface.optimization.active_constraint_mixin import ActiveConstraintMixin
|
|
10
|
+
from rtctools_interface.optimization.base_optimization_problem import BaseOptimizationProblem
|
|
11
|
+
from tests.utils.get_test import get_test_data
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActiveConstraintProblem(ActiveConstraintMixin, BaseOptimizationProblem):
|
|
15
|
+
"""Optimization problem with active constraint diagnostics enabled."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestActiveConstraintMixin(unittest.TestCase):
|
|
19
|
+
"""Test active constraint CSV generation."""
|
|
20
|
+
|
|
21
|
+
def test_active_constraint_csv_files_are_written(self):
|
|
22
|
+
"""Solve a goal-programming problem and inspect generated CSV files."""
|
|
23
|
+
test_data = get_test_data("basic", optimization=True)
|
|
24
|
+
output_folder = Path(test_data["output_folder"])
|
|
25
|
+
active_constraint_folder = output_folder / "active_constraints"
|
|
26
|
+
shutil.rmtree(active_constraint_folder, ignore_errors=True)
|
|
27
|
+
|
|
28
|
+
problem = ActiveConstraintProblem(
|
|
29
|
+
goal_table_file=test_data["goals_file"],
|
|
30
|
+
model_folder=test_data["model_folder"],
|
|
31
|
+
model_name=test_data["model_name"],
|
|
32
|
+
input_folder=test_data["model_input_folder"],
|
|
33
|
+
output_folder=output_folder,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self.assertTrue(problem.optimize())
|
|
37
|
+
|
|
38
|
+
previous_goal_file = active_constraint_folder / "active_constraints_of_previous_goals.csv"
|
|
39
|
+
self.assertFalse((active_constraint_folder / "active_constraints_by_priority.csv").exists())
|
|
40
|
+
self.assertFalse((active_constraint_folder / "previous_goal_constraints.csv").exists())
|
|
41
|
+
self.assertTrue(previous_goal_file.exists())
|
|
42
|
+
|
|
43
|
+
previous_goal_constraints = pd.read_csv(previous_goal_file)
|
|
44
|
+
self.assertNotIn("goal_id", previous_goal_constraints.columns)
|
|
45
|
+
self.assertNotIn("component_index", previous_goal_constraints.columns)
|
|
46
|
+
self.assertNotIn("time", previous_goal_constraints.columns)
|
|
47
|
+
self.assertNotIn("is_active", previous_goal_constraints.columns)
|
|
48
|
+
self.assertIn("active_times", previous_goal_constraints.columns)
|
|
49
|
+
self.assertIn("active_bound_value", previous_goal_constraints.columns)
|
|
50
|
+
self.assertNotIn("active_previous_goal_constraints", previous_goal_constraints.columns)
|
|
51
|
+
self.assertIn("active_previous_goals_constraints", previous_goal_constraints.columns)
|
|
52
|
+
self.assertEqual(set(previous_goal_constraints["priority"].to_list()), {10, 15, 20})
|
|
53
|
+
self.assertTrue((previous_goal_constraints["total_previous_goal_constraints"] >= 0).all())
|
|
54
|
+
self.assertTrue(
|
|
55
|
+
(
|
|
56
|
+
previous_goal_constraints["total_previous_goal_constraints"]
|
|
57
|
+
>= previous_goal_constraints["active_previous_goals_constraints"]
|
|
58
|
+
).all()
|
|
59
|
+
)
|
|
60
|
+
self.assertEqual(
|
|
61
|
+
previous_goal_constraints[previous_goal_constraints["priority"] == 10][
|
|
62
|
+
"active_previous_goals_constraints"
|
|
63
|
+
].iloc[0],
|
|
64
|
+
0,
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(
|
|
67
|
+
previous_goal_constraints[previous_goal_constraints["priority"] == 15][
|
|
68
|
+
"active_previous_goals_constraints"
|
|
69
|
+
].iloc[0],
|
|
70
|
+
0,
|
|
71
|
+
)
|
|
72
|
+
self.assertTrue(
|
|
73
|
+
(
|
|
74
|
+
previous_goal_constraints[previous_goal_constraints["priority"] == 20][
|
|
75
|
+
"active_previous_goals_constraints"
|
|
76
|
+
]
|
|
77
|
+
> 0
|
|
78
|
+
).all()
|
|
79
|
+
)
|
|
80
|
+
path_goal_constraints = previous_goal_constraints[
|
|
81
|
+
previous_goal_constraints["constraint_source"] == "path_goal"
|
|
82
|
+
]
|
|
83
|
+
self.assertEqual(len(path_goal_constraints[path_goal_constraints["priority"] == 15]), 0)
|
|
84
|
+
self.assertEqual(len(path_goal_constraints[path_goal_constraints["priority"] == 20]), 2)
|
|
85
|
+
self.assertTrue(
|
|
86
|
+
path_goal_constraints[path_goal_constraints["priority"] == 20]["active_times"]
|
|
87
|
+
.notna()
|
|
88
|
+
.all()
|
|
89
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_read_xml.py
RENAMED
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_read_goals.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc_tools_interface-0.10.2a3 → rtc_tools_interface-0.10.2a4}/tests/simulation/test_plot_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|