rtc-tools-interface 0.7.6__tar.gz → 0.8.0b2__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.7.6 → rtc-tools-interface-0.8.0b2}/PKG-INFO +1 -1
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/README.md +35 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/PKG-INFO +1 -1
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/SOURCES.txt +8 -0
- rtc-tools-interface-0.8.0b2/rtctools_interface/__init__.py +7 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/_version.py +3 -3
- rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/closed_loop_dates.py +43 -0
- rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/results_construction.py +108 -0
- rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/runner.py +156 -0
- rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/time_series_handler.py +430 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/base_goal.py +5 -5
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_performance_metrics.py +4 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/helpers/statistics_mixin.py +7 -2
- rtc-tools-interface-0.8.0b2/tests/closed_loop/test_read_xml.py +9 -0
- rtc-tools-interface-0.8.0b2/tests/closed_loop/test_run_optization_problem_closed_loop.py +98 -0
- rtc-tools-interface-0.8.0b2/tests/simulation/__init__.py +0 -0
- rtc-tools-interface-0.8.0b2/tests/utils/__init__.py +0 -0
- rtc-tools-interface-0.7.6/rtctools_interface/__init__.py +0 -3
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/COPYING.LESSER +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/dependency_links.txt +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/requires.txt +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/top_level.txt +0 -0
- {rtc-tools-interface-0.7.6/rtctools_interface/optimization → rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6/rtctools_interface/optimization/helpers → rtc-tools-interface-0.8.0b2/rtctools_interface/optimization}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/base_optimization_problem.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_generator_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_table_schema.py +0 -0
- {rtc-tools-interface-0.7.6/rtctools_interface/plotting → rtc-tools-interface-0.8.0b2/rtctools_interface/optimization/helpers}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/plot_goals_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/plot_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/read_goals.py +0 -0
- {rtc-tools-interface-0.7.6/rtctools_interface/simulation → rtc-tools-interface-0.8.0b2/rtctools_interface/plotting}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/plotting/plot_tools.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/plotting/subplot_classes.py +0 -0
- {rtc-tools-interface-0.7.6/rtctools_interface/utils → rtc-tools-interface-0.8.0b2/rtctools_interface/simulation}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/simulation/base_simulation_problem.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/simulation/plot_mixin.py +0 -0
- {rtc-tools-interface-0.7.6/tests → rtc-tools-interface-0.8.0b2/rtctools_interface/utils}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/plot_table_schema.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/read_goals_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/read_plot_table.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/results_collection.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/serialization.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/type_definitions.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/setup.cfg +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/setup.py +0 -0
- {rtc-tools-interface-0.7.6/tests/optimization → rtc-tools-interface-0.8.0b2/tests}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6/tests/simulation → rtc-tools-interface-0.8.0b2/tests/closed_loop}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6/tests/utils → rtc-tools-interface-0.8.0b2/tests/optimization}/__init__.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_base_optimization_problem.py +1 -1
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_passing_goals_directly.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_plot_goals_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_read_goals.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/simulation/test_base_simulation_problem.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/simulation/test_plot_mixin.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/utils/get_test.py +0 -0
- {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/versioneer.py +0 -0
|
@@ -8,6 +8,12 @@ This is rtc-tools-interface, a toolbox for user-interfaces for [rtc-tools](https
|
|
|
8
8
|
pip install rtc-tools-interface
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Table of Contents
|
|
12
|
+
1. [Goal generator](#goal-generator)
|
|
13
|
+
2. [Goal performance metrics](#goal-performance-metrics)
|
|
14
|
+
3. [Automatic plotting of results](#automatic-plotting-of-results)
|
|
15
|
+
4. [Closed loop runner](#closed-loop-runner)
|
|
16
|
+
|
|
11
17
|
## Goal generator
|
|
12
18
|
The `goal generator` can be used to automatically add goals based on a csv file. Currently, the following goal types are supported:
|
|
13
19
|
- range (default order is 2)
|
|
@@ -164,3 +170,32 @@ The table could thus look like:
|
|
|
164
170
|
|
|
165
171
|
|
|
166
172
|
After running the model, in your output folder the folder `figures` containing the figures is created.
|
|
173
|
+
|
|
174
|
+
## Closed loop runner
|
|
175
|
+
To run a closed loop experiment one can use the `run_optimization_problem_closed_loop` function from `run_closed_loop`.
|
|
176
|
+
This function is a drop-in replacement for the `run_optimization_problem` of rtc-tools. The user only needs to specify the `closed_loop_dates.csv` in the input folder of the optimization problem.
|
|
177
|
+
|
|
178
|
+
### Setup
|
|
179
|
+
Import `run_optimization_problem_closed_loop` with:
|
|
180
|
+
```python
|
|
181
|
+
from rtctools_interface import run_optimization_problem_closed_loop
|
|
182
|
+
```
|
|
183
|
+
Add a table named `closed_loop_dates.csv` to the input folder of your optimization problem. The table should contain two columns: `start_date` and `end_date`.
|
|
184
|
+
Each row of the table corresponds to one modelling period.
|
|
185
|
+
|
|
186
|
+
Example table `closed_loop_dates.csv`:
|
|
187
|
+
```
|
|
188
|
+
start_date, end_date
|
|
189
|
+
2024-05-19, 2024-05-23
|
|
190
|
+
2024-05-23, 2024-05-25
|
|
191
|
+
```
|
|
192
|
+
With this table rtc-tools will run two optimization problems (modelling periods): one with the data from 2024-05-19 upto and including 2024-05-23 and one from 2024-05-23 upto and including 2024-05-25.
|
|
193
|
+
The `run_optimization_problem_closed_loop` will automatically set the final results from the previous as initial conditions of the next run. Note that this happens for:
|
|
194
|
+
- All variables available at the first time step in original timeseries_import, but not available at any timestep in the modelling period.
|
|
195
|
+
- All variables in the `initial_state.csv` (if the csv_mixin is used).
|
|
196
|
+
|
|
197
|
+
### Notes
|
|
198
|
+
- The first start_date in your `closed_loop_dates.csv` should be equal to the start_date of your timeseries_import.
|
|
199
|
+
- The different horizons should overlap with at least one day (to allow retrieving and setting initial values). An overlap of more days is allowed.
|
|
200
|
+
- Currently only a single timestep is copied as an initial value.
|
|
201
|
+
- The closed_loop runner only works in combination with the CSVMixin or the PIMixin. The CDFMixin is not supported.
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/SOURCES.txt
RENAMED
|
@@ -10,6 +10,11 @@ rtc_tools_interface.egg-info/requires.txt
|
|
|
10
10
|
rtc_tools_interface.egg-info/top_level.txt
|
|
11
11
|
rtctools_interface/__init__.py
|
|
12
12
|
rtctools_interface/_version.py
|
|
13
|
+
rtctools_interface/closed_loop/__init__.py
|
|
14
|
+
rtctools_interface/closed_loop/closed_loop_dates.py
|
|
15
|
+
rtctools_interface/closed_loop/results_construction.py
|
|
16
|
+
rtctools_interface/closed_loop/runner.py
|
|
17
|
+
rtctools_interface/closed_loop/time_series_handler.py
|
|
13
18
|
rtctools_interface/optimization/__init__.py
|
|
14
19
|
rtctools_interface/optimization/base_goal.py
|
|
15
20
|
rtctools_interface/optimization/base_optimization_problem.py
|
|
@@ -35,6 +40,9 @@ rtctools_interface/utils/results_collection.py
|
|
|
35
40
|
rtctools_interface/utils/serialization.py
|
|
36
41
|
rtctools_interface/utils/type_definitions.py
|
|
37
42
|
tests/__init__.py
|
|
43
|
+
tests/closed_loop/__init__.py
|
|
44
|
+
tests/closed_loop/test_read_xml.py
|
|
45
|
+
tests/closed_loop/test_run_optization_problem_closed_loop.py
|
|
38
46
|
tests/optimization/__init__.py
|
|
39
47
|
tests/optimization/test_base_optimization_problem.py
|
|
40
48
|
tests/optimization/test_passing_goals_directly.py
|
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "2024-05-
|
|
11
|
+
"date": "2024-05-28T11:08:07+0200",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "0.
|
|
14
|
+
"full-revisionid": "706b384c4fcf2b2621e9370973ec8ecc78765fb8",
|
|
15
|
+
"version": "0.8.0b2"
|
|
16
16
|
}
|
|
17
17
|
''' # END VERSION_JSON
|
|
18
18
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_closed_loop_dates(file_path: Path):
|
|
7
|
+
"""Read horizon config from a csv file"""
|
|
8
|
+
if not file_path.exists():
|
|
9
|
+
raise FileNotFoundError(
|
|
10
|
+
f"The closed_loop_dates csv does not exist. Please create a horizon config file in {file_path}."
|
|
11
|
+
)
|
|
12
|
+
try:
|
|
13
|
+
closed_loop_dates = pd.read_csv(file_path)
|
|
14
|
+
except pd.errors.EmptyDataError:
|
|
15
|
+
raise ValueError(
|
|
16
|
+
"The closed_loop_dates csv is empty. Please provide a valid file with start_date and end_date column."
|
|
17
|
+
)
|
|
18
|
+
closed_loop_dates.columns = closed_loop_dates.columns.str.replace(" ", "")
|
|
19
|
+
if not all([col in closed_loop_dates.columns for col in ["start_date", "end_date"]]):
|
|
20
|
+
raise ValueError("The closed_loop_dates csv should have both 'start_date' and 'end_date' columns.")
|
|
21
|
+
closed_loop_dates["start_date"] = pd.to_datetime(closed_loop_dates["start_date"])
|
|
22
|
+
closed_loop_dates["end_date"] = pd.to_datetime(closed_loop_dates["end_date"])
|
|
23
|
+
for i in range(1, len(closed_loop_dates)):
|
|
24
|
+
if closed_loop_dates["start_date"].iloc[i] > closed_loop_dates["end_date"].iloc[i - 1]:
|
|
25
|
+
raise ValueError(f"Closed loop date table: Start date at row {i} is later than the previous end date. ")
|
|
26
|
+
if any(closed_loop_dates["start_date"] < closed_loop_dates["start_date"].shift(1)):
|
|
27
|
+
raise ValueError("Closed loop date table: The start dates are not in ascending order.")
|
|
28
|
+
if any(closed_loop_dates["end_date"] < closed_loop_dates["end_date"].shift(1)):
|
|
29
|
+
raise ValueError("Closed loop date table: The end dates are not in ascending order.")
|
|
30
|
+
if any(closed_loop_dates["end_date"] < closed_loop_dates["start_date"]):
|
|
31
|
+
raise ValueError("Closed loop date table: For one or more rows the end date is before the start date.")
|
|
32
|
+
if any(closed_loop_dates["start_date"] > closed_loop_dates["end_date"]):
|
|
33
|
+
raise ValueError("Closed loop date table: For one or more rows the start date is after the end date.")
|
|
34
|
+
if (
|
|
35
|
+
any(closed_loop_dates["start_date"].dt.hour != 0)
|
|
36
|
+
or any(closed_loop_dates["start_date"].dt.minute != 0)
|
|
37
|
+
or any(closed_loop_dates["end_date"].dt.hour != 0)
|
|
38
|
+
or any(closed_loop_dates["end_date"].dt.minute != 0)
|
|
39
|
+
):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Closed loop date table: Currently, the date ranges can only be specific up to the level of days."
|
|
42
|
+
)
|
|
43
|
+
return closed_loop_dates
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from rtctools.data import rtc
|
|
8
|
+
from rtctools.data import pi
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("rtctools")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def combine_xml_exports(output_base_path: Path, original_input_timeseries_path: Path, write_csv_out: bool = False):
|
|
14
|
+
"""Combine the xml exports of multiple periods into a single xml file."""
|
|
15
|
+
logger.info("Combining XML exports.")
|
|
16
|
+
dataconfig = rtc.DataConfig(folder=original_input_timeseries_path)
|
|
17
|
+
|
|
18
|
+
ts_import_orig = pi.Timeseries(
|
|
19
|
+
data_config=dataconfig, folder=original_input_timeseries_path, basename="timeseries_import", binary=False
|
|
20
|
+
)
|
|
21
|
+
orig_start_datetime = ts_import_orig.start_datetime
|
|
22
|
+
orig_end_datetime = ts_import_orig.end_datetime
|
|
23
|
+
|
|
24
|
+
ts_export = pi.Timeseries(
|
|
25
|
+
data_config=dataconfig, folder=output_base_path / "period_0", basename="timeseries_export", binary=False
|
|
26
|
+
) # Use the first timeseries export as a starting point for the combined timeseries export.
|
|
27
|
+
ts_export._Timeseries__path_xml = os.path.join(output_base_path.parent, "timeseries_export.xml")
|
|
28
|
+
ts_export.resize(orig_start_datetime, orig_end_datetime)
|
|
29
|
+
|
|
30
|
+
i = 0
|
|
31
|
+
while os.path.isfile(os.path.join(output_base_path, f"period_{i}", "timeseries_export.xml")):
|
|
32
|
+
ts_export_step = pi.Timeseries(
|
|
33
|
+
data_config=dataconfig,
|
|
34
|
+
folder=os.path.join(output_base_path, f"period_{i}"),
|
|
35
|
+
basename="timeseries_export",
|
|
36
|
+
binary=False,
|
|
37
|
+
)
|
|
38
|
+
all_times = ts_import_orig.times # Workaround to map indices to times, as ts_export does
|
|
39
|
+
# not contain all times. TODO Check whether the assumption that these times map to
|
|
40
|
+
# the correct indices for ts_export always holds.
|
|
41
|
+
for loc_par in dataconfig._DataConfig__location_parameter_ids:
|
|
42
|
+
try:
|
|
43
|
+
current_values = ts_export.get(loc_par)
|
|
44
|
+
new_values = ts_export_step.get(loc_par)
|
|
45
|
+
except KeyError:
|
|
46
|
+
logger.debug("Variable {} not found in output of model horizon: {}".format(loc_par, i))
|
|
47
|
+
continue
|
|
48
|
+
new_times = ts_export_step.times
|
|
49
|
+
try:
|
|
50
|
+
start_new_data_index = all_times.index(new_times[0])
|
|
51
|
+
except ValueError:
|
|
52
|
+
if all_times[-1] + ts_export.dt == new_times[0]:
|
|
53
|
+
start_new_data_index = len(all_times)
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Could not match the start data of the timeseries export file "
|
|
57
|
+
+ "with the end of the previous."
|
|
58
|
+
)
|
|
59
|
+
combined_values = copy.deepcopy(current_values)
|
|
60
|
+
combined_values[start_new_data_index : start_new_data_index + len(new_values)] = new_values # noqa
|
|
61
|
+
ts_export.set(loc_par, combined_values)
|
|
62
|
+
i += 1
|
|
63
|
+
ts_export.write()
|
|
64
|
+
|
|
65
|
+
if write_csv_out:
|
|
66
|
+
data = pd.DataFrame()
|
|
67
|
+
data["date"] = all_times
|
|
68
|
+
for timeseries_id in dataconfig._DataConfig__location_parameter_ids:
|
|
69
|
+
try:
|
|
70
|
+
values = ts_export.get(timeseries_id)
|
|
71
|
+
except KeyError:
|
|
72
|
+
logger.debug("Variable {} not found in output of model horizon: {}".format(timeseries_id, i))
|
|
73
|
+
continue
|
|
74
|
+
data[timeseries_id] = values
|
|
75
|
+
data.round(6).to_csv(output_base_path.parent / "timeseries_export.csv", index=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def combine_dataframes(dfs: List[pd.DataFrame], index_col: str = "time"):
|
|
79
|
+
"""Combine multiple dataframes with the same index column.
|
|
80
|
+
The dataframes are combined in the order they are passed, with the last dataframe taking precedence
|
|
81
|
+
in case of overlapping indices."""
|
|
82
|
+
combined_df = pd.DataFrame()
|
|
83
|
+
for df in dfs:
|
|
84
|
+
df.set_index(index_col, inplace=True)
|
|
85
|
+
combined_df = df.combine_first(combined_df)
|
|
86
|
+
combined_df.reset_index(inplace=True)
|
|
87
|
+
return combined_df
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def combine_csv_exports(output_base_path: Path):
|
|
91
|
+
"""Combine the csv exports of multiple periods into a single csv file."""
|
|
92
|
+
i = 0
|
|
93
|
+
dfs = []
|
|
94
|
+
while os.path.isfile(os.path.join(output_base_path, f"period_{i}", "timeseries_export.csv")):
|
|
95
|
+
df = pd.read_csv(os.path.join(output_base_path, f"period_{i}", "timeseries_export.csv"))
|
|
96
|
+
dfs.append(df)
|
|
97
|
+
i += 1
|
|
98
|
+
combined_df = combine_dataframes(dfs)
|
|
99
|
+
combined_df.round(6).to_csv(output_base_path.parent / "timeseries_export.csv", index=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
closed_loop_test_folder = Path(__file__).parents[2] / "tests" / "closed_loop"
|
|
104
|
+
output_base_path = closed_loop_test_folder / Path(
|
|
105
|
+
r"test_models\goal_programming_xml\output\output_modelling_periods_reference"
|
|
106
|
+
)
|
|
107
|
+
original_input_timeseries_path = closed_loop_test_folder / Path(r"test_models\goal_programming_xml\input")
|
|
108
|
+
combine_xml_exports(output_base_path, original_input_timeseries_path, True)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import os
|
|
3
|
+
import copy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from rtctools.optimization.pi_mixin import PIMixin
|
|
9
|
+
from rtctools.optimization.csv_mixin import CSVMixin
|
|
10
|
+
from rtctools.util import run_optimization_problem, _resolve_folder
|
|
11
|
+
from rtctools_interface.closed_loop.closed_loop_dates import read_closed_loop_dates
|
|
12
|
+
from rtctools_interface.closed_loop.results_construction import combine_csv_exports, combine_xml_exports
|
|
13
|
+
from rtctools_interface.closed_loop.time_series_handler import XMLTimeSeriesFile, CSVTimeSeriesFile, TimeSeriesHandler
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("rtctools")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def set_initial_values_from_previous_run(
|
|
20
|
+
results_previous_run: Optional[dict],
|
|
21
|
+
timeseries: TimeSeriesHandler,
|
|
22
|
+
previous_run_datetimes: List[datetime.datetime],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Modifies the initial values of `timeseries` based on the results of the previous run (if any)"""
|
|
25
|
+
if results_previous_run is not None:
|
|
26
|
+
variables_to_set = {key: value for key, value in results_previous_run.items() if not timeseries.is_set(key)}
|
|
27
|
+
if timeseries.forecast_date:
|
|
28
|
+
index_of_initial_value = previous_run_datetimes.index(timeseries.forecast_date)
|
|
29
|
+
else:
|
|
30
|
+
raise ValueError("Could not find forecast date in timeseries import.")
|
|
31
|
+
for key, values in variables_to_set.items():
|
|
32
|
+
if values is not None:
|
|
33
|
+
timeseries.set_initial_value(key, values[index_of_initial_value])
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"Could not find initial value for {key}.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_input_folder(
|
|
39
|
+
modelling_period_input_folder_i: Path,
|
|
40
|
+
original_input_folder: Path,
|
|
41
|
+
timeseries_import: TimeSeriesHandler,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write the input folder for the current modelling period.
|
|
44
|
+
Copies the original input folder to the modelling period input folder and writes the new
|
|
45
|
+
timeseries_import."""
|
|
46
|
+
modelling_period_input_folder_i.mkdir(exist_ok=True)
|
|
47
|
+
for file in original_input_folder.iterdir():
|
|
48
|
+
if file.is_file():
|
|
49
|
+
shutil.copy(file, modelling_period_input_folder_i / file.name)
|
|
50
|
+
elif file.is_dir():
|
|
51
|
+
shutil.copytree(file, modelling_period_input_folder_i / file.name)
|
|
52
|
+
timeseries_import.write(modelling_period_input_folder_i)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_optimization_problem_closed_loop(
|
|
56
|
+
optimization_problem_class,
|
|
57
|
+
base_folder="..",
|
|
58
|
+
log_level=logging.INFO,
|
|
59
|
+
profile=False,
|
|
60
|
+
**kwargs,
|
|
61
|
+
):
|
|
62
|
+
"""Runs an optimization problem in closed loop mode.
|
|
63
|
+
This function is a drop-in replacement for the run_optimization_problem of rtc-tools. The user
|
|
64
|
+
needs to specify a closed_loop_dates.csv in the input folder of the optimization problem. This
|
|
65
|
+
CSV file should contain two columns: start_date and end_date, each row corresponding to one
|
|
66
|
+
modelling period. The function will run optimization problems for each modelling period specified
|
|
67
|
+
in the CSV file, setting the final results from the previous run as initial conditions for the next.
|
|
68
|
+
See the readme.md for more details.
|
|
69
|
+
|
|
70
|
+
Notes:
|
|
71
|
+
- The first start_date in your closed_loop_dates.csv should be equal to the start_date of
|
|
72
|
+
your timeseries_import.
|
|
73
|
+
- The different horizons should overlap with at least one day to allow retrieving and setting
|
|
74
|
+
initial values. More days of overlap are allowed.
|
|
75
|
+
"""
|
|
76
|
+
base_folder = Path(base_folder)
|
|
77
|
+
if not os.path.isabs(base_folder):
|
|
78
|
+
base_folder = Path(sys.path[0]) / base_folder
|
|
79
|
+
original_input_folder = Path(_resolve_folder(kwargs, base_folder, "input_folder", "input"))
|
|
80
|
+
original_output_folder = Path(_resolve_folder(kwargs, base_folder, "output_folder", "output"))
|
|
81
|
+
original_output_folder.mkdir(exist_ok=True)
|
|
82
|
+
|
|
83
|
+
if issubclass(optimization_problem_class, PIMixin):
|
|
84
|
+
original_import = XMLTimeSeriesFile(original_input_folder)
|
|
85
|
+
original_import.set_reference_data(copy.deepcopy(original_import))
|
|
86
|
+
elif issubclass(optimization_problem_class, CSVMixin):
|
|
87
|
+
original_import = CSVTimeSeriesFile(original_input_folder)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError("Optimization problem class must be derived from PIMixin or CSVMixin.")
|
|
90
|
+
|
|
91
|
+
variables_in_import = original_import.get_all_internal_ids()
|
|
92
|
+
|
|
93
|
+
modelling_period_input_folder = base_folder / "input_modelling_periods"
|
|
94
|
+
if modelling_period_input_folder.exists():
|
|
95
|
+
shutil.rmtree(modelling_period_input_folder)
|
|
96
|
+
modelling_period_input_folder.mkdir(exist_ok=True)
|
|
97
|
+
|
|
98
|
+
modelling_periods_output_folder = original_output_folder / "output_modelling_periods"
|
|
99
|
+
if modelling_periods_output_folder.exists():
|
|
100
|
+
shutil.rmtree(modelling_periods_output_folder)
|
|
101
|
+
modelling_periods_output_folder.mkdir(exist_ok=True)
|
|
102
|
+
|
|
103
|
+
original_date_range = original_import.get_datetime_range()
|
|
104
|
+
closed_loop_dates = read_closed_loop_dates(original_input_folder / "closed_loop_dates.csv")
|
|
105
|
+
assert (
|
|
106
|
+
min(closed_loop_dates["start_date"]).date() == original_date_range[0].date()
|
|
107
|
+
), "The start day of the first optimization run is not equal to the start day of the timeseries import."
|
|
108
|
+
assert (
|
|
109
|
+
max(closed_loop_dates["end_date"]).date() <= original_date_range[1].date()
|
|
110
|
+
), "The end date of one or more optimization runs is later than the end date of the timeseries import."
|
|
111
|
+
|
|
112
|
+
results_previous_run = None
|
|
113
|
+
previous_run_datetimes = None
|
|
114
|
+
for i, date_range in closed_loop_dates.iterrows():
|
|
115
|
+
timeseries_import = copy.deepcopy(original_import)
|
|
116
|
+
|
|
117
|
+
timeseries_import.select_time_range(
|
|
118
|
+
start_date=date_range["start_date"],
|
|
119
|
+
end_date=date_range["end_date"] + datetime.timedelta(days=1) - datetime.timedelta(seconds=1),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
set_initial_values_from_previous_run(results_previous_run, timeseries_import, previous_run_datetimes)
|
|
123
|
+
|
|
124
|
+
modelling_period_name = f"period_{i}"
|
|
125
|
+
modelling_period_output_folder_i = modelling_periods_output_folder / modelling_period_name
|
|
126
|
+
modelling_period_output_folder_i.mkdir(exist_ok=True)
|
|
127
|
+
modelling_period_input_folder_i = modelling_period_input_folder / modelling_period_name
|
|
128
|
+
write_input_folder(modelling_period_input_folder_i, original_input_folder, timeseries_import)
|
|
129
|
+
|
|
130
|
+
result = run_optimization_problem(
|
|
131
|
+
optimization_problem_class,
|
|
132
|
+
base_folder,
|
|
133
|
+
log_level,
|
|
134
|
+
profile,
|
|
135
|
+
input_folder=modelling_period_input_folder_i,
|
|
136
|
+
output_folder=modelling_period_output_folder_i,
|
|
137
|
+
**kwargs,
|
|
138
|
+
)
|
|
139
|
+
logger.info(f"Finished optimization run {i} with result {result}")
|
|
140
|
+
|
|
141
|
+
results_previous_run = {key: result.extract_results().get(key) for key in variables_in_import}
|
|
142
|
+
previous_run_datetimes = result.io.datetimes
|
|
143
|
+
if len(results_previous_run) != len(variables_in_import):
|
|
144
|
+
logger.warning("Could not find the results for all input variables.")
|
|
145
|
+
logger.warning("Missing variables: " + str(set(variables_in_import) - set(results_previous_run.keys())))
|
|
146
|
+
raise ValueError("Could not find the results for all input variables.")
|
|
147
|
+
|
|
148
|
+
logger.info("Finished all optimization runs.")
|
|
149
|
+
if issubclass(optimization_problem_class, PIMixin):
|
|
150
|
+
combine_xml_exports(modelling_periods_output_folder, original_input_folder, write_csv_out=True)
|
|
151
|
+
elif issubclass(optimization_problem_class, CSVMixin):
|
|
152
|
+
combine_csv_exports(modelling_periods_output_folder)
|
|
153
|
+
else:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Could not combine exports because the optimization problem class is not derived from PIMixin or CSVMixin."
|
|
156
|
+
)
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from rtctools.data import pi
|
|
10
|
+
from rtctools.data import rtc
|
|
11
|
+
from rtctools.data import csv
|
|
12
|
+
|
|
13
|
+
ns = {"fews": "http://www.wldelft.nl/fews", "pi": "http://www.wldelft.nl/fews/PI"}
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("rtctools")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TimeSeriesHandler(ABC):
|
|
19
|
+
"""ABC for handling timeseries data."""
|
|
20
|
+
|
|
21
|
+
# The forecast date determines at which date the optimization starts.
|
|
22
|
+
forecast_date: Optional[datetime.datetime] = None
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def read(self, file_name: str) -> None:
|
|
26
|
+
"""Read the timeseries."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime) -> None:
|
|
30
|
+
"""Select a time range from the timeseries data. Removes data outside the interval.
|
|
31
|
+
The specified range is inclusive on both sides."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def write(self, file_path: Path) -> None:
|
|
35
|
+
"""Write the timeseries data to a file."""
|
|
36
|
+
|
|
37
|
+
def set_reference_data(self, reference_data: "TimeSeriesHandler"):
|
|
38
|
+
"""Set the reference TimeSeriesHandler. Only relevant for XMLTimeSeriesFile.
|
|
39
|
+
Required when setting initial values for variables that are not in the modelling period data range."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_timestep(self) -> datetime.timedelta:
|
|
43
|
+
"""Get the timestep of the timeseries data."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get_datetime_range(self) -> Tuple[datetime.datetime, datetime.datetime]:
|
|
47
|
+
"""Get the date range of the timeseries data (min, max)."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def get_all_internal_ids(self) -> List[str]:
|
|
51
|
+
"""Get all internal id's of the timeseries data."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def set_initial_value(self, internal_id: str, value: float) -> None:
|
|
55
|
+
"""Set the initial value of a variable in the timeseries data."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def is_set(self, internal_id: str) -> bool:
|
|
59
|
+
"""Check whether the variable exists in the timeseries data and whether it has a least one non-nan value"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CSVTimeSeriesFile(TimeSeriesHandler):
|
|
63
|
+
"""Timeseries handler for csv files."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
input_folder: Path,
|
|
68
|
+
timeseries_import_basename: str = "timeseries_import",
|
|
69
|
+
csv_delimiter=",",
|
|
70
|
+
initial_state_base_name: str = "initial_state",
|
|
71
|
+
):
|
|
72
|
+
self.data = pd.DataFrame()
|
|
73
|
+
self.input_folder = input_folder
|
|
74
|
+
self.csv_delimiter = csv_delimiter
|
|
75
|
+
self.read(timeseries_import_basename, initial_state_base_name)
|
|
76
|
+
|
|
77
|
+
def read(self, file_name: str, initial_state_base_name=None):
|
|
78
|
+
timeseries = csv.load(
|
|
79
|
+
(self.input_folder / file_name).with_suffix(".csv"),
|
|
80
|
+
delimiter=self.csv_delimiter,
|
|
81
|
+
with_time=True,
|
|
82
|
+
)
|
|
83
|
+
self.data = pd.DataFrame(timeseries)
|
|
84
|
+
if self.data is not None:
|
|
85
|
+
self.date_col = self.data.columns[0]
|
|
86
|
+
self.forecast_date = self.data[self.date_col].iloc[0]
|
|
87
|
+
else:
|
|
88
|
+
raise ValueError("No data to read.")
|
|
89
|
+
if initial_state_base_name is not None:
|
|
90
|
+
initial_state_file = self.input_folder / initial_state_base_name
|
|
91
|
+
if initial_state_file.with_suffix(".csv").exists():
|
|
92
|
+
initial_state = csv.load(
|
|
93
|
+
initial_state_file.with_suffix(".csv"),
|
|
94
|
+
delimiter=self.csv_delimiter,
|
|
95
|
+
with_time=False,
|
|
96
|
+
)
|
|
97
|
+
self.initial_state: Optional[dict] = {
|
|
98
|
+
field: float(initial_state[field]) for field in initial_state.dtype.names
|
|
99
|
+
}
|
|
100
|
+
else:
|
|
101
|
+
self.initial_state = None
|
|
102
|
+
|
|
103
|
+
def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime):
|
|
104
|
+
mask = (self.data[self.date_col] >= start_date) & (self.data[self.date_col] <= end_date)
|
|
105
|
+
self.data = self.data.loc[mask]
|
|
106
|
+
self.forecast_date = start_date
|
|
107
|
+
|
|
108
|
+
def write(self, file_path: Path):
|
|
109
|
+
self.write_timeseries(file_path)
|
|
110
|
+
self.write_initial_state(file_path)
|
|
111
|
+
|
|
112
|
+
def write_timeseries(self, file_path: Path, file_name: str = "timeseries_import"):
|
|
113
|
+
self.data.to_csv(
|
|
114
|
+
(file_path / file_name).with_suffix(".csv"),
|
|
115
|
+
index=False,
|
|
116
|
+
date_format="%Y-%m-%d %H:%M:%S",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def write_initial_state(self, file_path: Path, file_name: str = "initial_state"):
|
|
120
|
+
if self.initial_state is not None:
|
|
121
|
+
initial_state = pd.DataFrame(self.initial_state, index=[0])
|
|
122
|
+
initial_state.to_csv((file_path / file_name).with_suffix(".csv"), header=True, index=False)
|
|
123
|
+
|
|
124
|
+
def get_timestep(self):
|
|
125
|
+
return self.data[self.date_col].diff().min()
|
|
126
|
+
|
|
127
|
+
def get_datetime_range(self):
|
|
128
|
+
return self.data[self.date_col].min(), self.data[self.date_col].max()
|
|
129
|
+
|
|
130
|
+
def get_all_internal_ids(self):
|
|
131
|
+
ids = list(self.data.columns[1:])
|
|
132
|
+
if self.initial_state is not None:
|
|
133
|
+
ids.extend(list(self.initial_state.keys()))
|
|
134
|
+
return ids
|
|
135
|
+
|
|
136
|
+
def set_initial_value(self, internal_id, value):
|
|
137
|
+
if self.initial_state is None or internal_id not in self.initial_state:
|
|
138
|
+
self.data[internal_id].iloc[0] = value
|
|
139
|
+
else:
|
|
140
|
+
self.initial_state[internal_id] = value
|
|
141
|
+
|
|
142
|
+
def is_set(self, internal_id):
|
|
143
|
+
val_is_set = False
|
|
144
|
+
if internal_id in self.data.columns:
|
|
145
|
+
val_is_set = not self.data[internal_id].isna().all()
|
|
146
|
+
if self.initial_state is not None and internal_id in self.initial_state:
|
|
147
|
+
val_is_set = False
|
|
148
|
+
return val_is_set
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class XMLTimeSeriesFile(TimeSeriesHandler):
|
|
152
|
+
""" "Timeseries handler for xml files"""
|
|
153
|
+
|
|
154
|
+
# Whether the timeseries data has a forecast date in the header.
|
|
155
|
+
forecast_date_in_header = False
|
|
156
|
+
|
|
157
|
+
def __parse_date_time(self, el):
|
|
158
|
+
return datetime.datetime.strptime(el.get("date") + " " + el.get("time"), "%Y-%m-%d %H:%M:%S")
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
input_folder: Path,
|
|
163
|
+
timeseries_import_basename: str = "timeseries_import",
|
|
164
|
+
):
|
|
165
|
+
self.input_folder = input_folder
|
|
166
|
+
self.pi_binary_timeseries = False
|
|
167
|
+
self.pi_validate_timeseries = True
|
|
168
|
+
self.read(timeseries_import_basename)
|
|
169
|
+
|
|
170
|
+
def read(self, file_name: str):
|
|
171
|
+
"""Read the timeseries data from a file."""
|
|
172
|
+
timeseries_import_basename = file_name
|
|
173
|
+
self.data_config = rtc.DataConfig(self.input_folder)
|
|
174
|
+
self.pi_timeseries = pi.Timeseries(
|
|
175
|
+
self.data_config,
|
|
176
|
+
self.input_folder,
|
|
177
|
+
timeseries_import_basename,
|
|
178
|
+
binary=self.pi_binary_timeseries,
|
|
179
|
+
pi_validate_times=self.pi_validate_timeseries,
|
|
180
|
+
)
|
|
181
|
+
tree = self.pi_timeseries._Timeseries__tree
|
|
182
|
+
self.root = tree.getroot()
|
|
183
|
+
if self.root is None:
|
|
184
|
+
raise ValueError("No data to read.")
|
|
185
|
+
self.set_forecast_date()
|
|
186
|
+
|
|
187
|
+
def set_forecast_date(self):
|
|
188
|
+
"""Set the internal attribute `forecast_date` of the timeseries data.
|
|
189
|
+
|
|
190
|
+
The forecast date is set to the first event date of the first series
|
|
191
|
+
if no forecast date is present in the header."""
|
|
192
|
+
first_series = self.root.find("pi:series", ns)
|
|
193
|
+
first_header = first_series.find("pi:header", ns)
|
|
194
|
+
forecast_date_element = first_header.find("pi:forecastDate", ns)
|
|
195
|
+
if forecast_date_element is not None:
|
|
196
|
+
self.forecast_date = self.__parse_date_time(forecast_date_element)
|
|
197
|
+
self.forecast_date_in_header = True
|
|
198
|
+
else:
|
|
199
|
+
first_event = first_series.find("pi:event", ns)
|
|
200
|
+
self.forecast_date = self.__parse_date_time(first_event)
|
|
201
|
+
self.forecast_date_in_header = False
|
|
202
|
+
|
|
203
|
+
def is_set(self, internal_id):
|
|
204
|
+
"""Check whether the variable exists in the timeseries data and whether it has a value at at least
|
|
205
|
+
one of time steps."""
|
|
206
|
+
location_id, parameter_id, qualifier_id = self.get_external_id_from_internal_id(internal_id)
|
|
207
|
+
for series in self.root.findall("pi:series", ns):
|
|
208
|
+
if (
|
|
209
|
+
location_id,
|
|
210
|
+
parameter_id,
|
|
211
|
+
qualifier_id,
|
|
212
|
+
) == self.get_external_id_from_series(series):
|
|
213
|
+
events = series.findall("pi:event", ns)
|
|
214
|
+
for event in events:
|
|
215
|
+
if event.get("value") is not None:
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def get_internal_id(self, series):
|
|
220
|
+
"""Get the internal id of a series element."""
|
|
221
|
+
pi_header = series.find("pi:header", ns)
|
|
222
|
+
return self.data_config.variable(pi_header)
|
|
223
|
+
|
|
224
|
+
def get_external_id_from_internal_id(self, internal_id):
|
|
225
|
+
"""Get the external id of a series element. Returns a tuple with three elements: location, parameter
|
|
226
|
+
and qualifier ID's."""
|
|
227
|
+
return self.data_config.pi_variable_ids(internal_id)
|
|
228
|
+
|
|
229
|
+
def get_all_internal_ids(self):
|
|
230
|
+
"""Get all internal id's of the timeseries data. Only returns the id's that are also in the dataconfig."""
|
|
231
|
+
all_ids = [self.get_internal_id(series) for series in self.root.findall("pi:series", ns)]
|
|
232
|
+
return [id for id in all_ids if ":" not in id] # Variables that contain ":" are not in the dataconfig.
|
|
233
|
+
|
|
234
|
+
def set_new_forecast_date(self, forecast_date: datetime.datetime):
|
|
235
|
+
for series in self.root.findall("pi:series", ns):
|
|
236
|
+
header = series.find("pi:header", ns)
|
|
237
|
+
start_date, end_date = self.get_single_date_range_from_series(series)
|
|
238
|
+
if start_date <= forecast_date <= end_date:
|
|
239
|
+
header.find("pi:forecastDate", ns).attrib = {
|
|
240
|
+
"date": forecast_date.strftime("%Y-%m-%d"),
|
|
241
|
+
"time": forecast_date.strftime("%H:%M:%S"),
|
|
242
|
+
}
|
|
243
|
+
else:
|
|
244
|
+
raise ValueError("Forecast date is not within the date range of the timeseries data.")
|
|
245
|
+
|
|
246
|
+
def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime):
|
|
247
|
+
assert isinstance(start_date, datetime.datetime) and isinstance(
|
|
248
|
+
end_date, datetime.datetime
|
|
249
|
+
), "Dates must be datetime objects"
|
|
250
|
+
for series in self.root.findall("pi:series", ns):
|
|
251
|
+
new_start_date, new_end_date = datetime.datetime.max, datetime.datetime.min
|
|
252
|
+
events = series.findall("pi:event", ns)
|
|
253
|
+
for event in events:
|
|
254
|
+
event_datetime = self.__parse_date_time(event)
|
|
255
|
+
if event_datetime < start_date or event_datetime > end_date:
|
|
256
|
+
series.remove(event)
|
|
257
|
+
continue
|
|
258
|
+
if event_datetime < new_start_date:
|
|
259
|
+
new_start_date = event_datetime
|
|
260
|
+
if event_datetime > new_end_date:
|
|
261
|
+
new_end_date = event_datetime
|
|
262
|
+
start_date_attrib = {
|
|
263
|
+
"date": new_start_date.strftime("%Y-%m-%d"),
|
|
264
|
+
"time": new_start_date.strftime("%H:%M:%S"),
|
|
265
|
+
}
|
|
266
|
+
end_date_attrib = {
|
|
267
|
+
"date": new_end_date.strftime("%Y-%m-%d"),
|
|
268
|
+
"time": new_end_date.strftime("%H:%M:%S"),
|
|
269
|
+
}
|
|
270
|
+
series.find("pi:header", ns).find("pi:startDate", ns).attrib = start_date_attrib
|
|
271
|
+
series.find("pi:header", ns).find("pi:endDate", ns).attrib = end_date_attrib
|
|
272
|
+
if self.forecast_date_in_header:
|
|
273
|
+
series.find("pi:header", ns).find("pi:forecastDate", ns).attrib = start_date_attrib
|
|
274
|
+
if not series.findall("pi:event", ns):
|
|
275
|
+
self.root.remove(series)
|
|
276
|
+
logger.warning("Removed series with no events.")
|
|
277
|
+
self.set_forecast_date()
|
|
278
|
+
|
|
279
|
+
def write(self, file_path: Path, file_name: str = "timeseries_import"):
|
|
280
|
+
tree = ET.ElementTree(self.root)
|
|
281
|
+
tree.write((file_path / file_name).with_suffix(".xml"))
|
|
282
|
+
|
|
283
|
+
def get_external_id_from_series(self, series) -> Tuple[str, str, List[str]]:
|
|
284
|
+
header = series.find("pi:header", ns)
|
|
285
|
+
locationId = header.find("pi:locationId", ns).text
|
|
286
|
+
parameterId = header.find("pi:parameterId", ns).text
|
|
287
|
+
qualifier_ids = []
|
|
288
|
+
qualifiers_els = header.findall("pi:qualifierId", ns)
|
|
289
|
+
for qualifier in qualifiers_els:
|
|
290
|
+
qualifier_ids.append(qualifier.text)
|
|
291
|
+
return locationId, parameterId, qualifier_ids
|
|
292
|
+
|
|
293
|
+
def get_single_date_range(self, locationId, paramterId, qualifier_ids):
|
|
294
|
+
"""Get the date range of the timeseries data for a single location and parameter"""
|
|
295
|
+
min_date = datetime.datetime.max
|
|
296
|
+
max_date = datetime.datetime.min
|
|
297
|
+
for series in self.root.findall("pi:series", ns):
|
|
298
|
+
if (
|
|
299
|
+
locationId,
|
|
300
|
+
paramterId,
|
|
301
|
+
qualifier_ids,
|
|
302
|
+
) == self.get_external_id_from_series(series):
|
|
303
|
+
events = series.findall("pi:event", ns)
|
|
304
|
+
for event in events:
|
|
305
|
+
event_datetime = self.__parse_date_time(event)
|
|
306
|
+
if event_datetime < min_date:
|
|
307
|
+
min_date = event_datetime
|
|
308
|
+
if event_datetime > max_date:
|
|
309
|
+
max_date = event_datetime
|
|
310
|
+
if min_date == datetime.datetime.max:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
"No data for locationId {}, parameterId {} and qualifierIds {}".format(
|
|
313
|
+
locationId, paramterId, qualifier_ids
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return min_date, max_date
|
|
317
|
+
|
|
318
|
+
def get_single_date_range_from_series(self, series):
|
|
319
|
+
(
|
|
320
|
+
location_id,
|
|
321
|
+
parameter_id,
|
|
322
|
+
qualifier_ids,
|
|
323
|
+
) = self.get_external_id_from_series(series)
|
|
324
|
+
return self.get_single_date_range(location_id, parameter_id, qualifier_ids)
|
|
325
|
+
|
|
326
|
+
def get_datetime_range(self):
|
|
327
|
+
"""Get the date range of the timeseries data, minimum and maximum over all series"""
|
|
328
|
+
min_date = datetime.datetime.max
|
|
329
|
+
max_date = datetime.datetime.min
|
|
330
|
+
for series in self.root.findall("pi:series", ns):
|
|
331
|
+
events = series.findall("pi:event", ns)
|
|
332
|
+
for event in events:
|
|
333
|
+
event_datetime = self.__parse_date_time(event)
|
|
334
|
+
if event_datetime < min_date:
|
|
335
|
+
min_date = event_datetime
|
|
336
|
+
if event_datetime > max_date:
|
|
337
|
+
max_date = event_datetime
|
|
338
|
+
return min_date, max_date
|
|
339
|
+
|
|
340
|
+
def get_timestep(self):
|
|
341
|
+
"""Get the timestep of the timeseries data, raise error if different stepsizes"""
|
|
342
|
+
timestep = None
|
|
343
|
+
for series in self.root.findall("pi:series", ns):
|
|
344
|
+
events = series.findall("pi:event", ns)
|
|
345
|
+
for i, event in enumerate(events):
|
|
346
|
+
if i == 0:
|
|
347
|
+
continue
|
|
348
|
+
event_datetime = self.__parse_date_time(event)
|
|
349
|
+
previous_event_datetime = self.__parse_date_time(events[i - 1])
|
|
350
|
+
if timestep is None:
|
|
351
|
+
timestep = event_datetime - previous_event_datetime
|
|
352
|
+
elif timestep != event_datetime - previous_event_datetime:
|
|
353
|
+
raise ValueError("Different timesteps in timeseries data.")
|
|
354
|
+
return timestep
|
|
355
|
+
|
|
356
|
+
def set_reference_data(self, reference_data):
|
|
357
|
+
"""Set the the reference XMLTimeSeriesFile object to use for creating new series."""
|
|
358
|
+
self._reference_data = reference_data
|
|
359
|
+
|
|
360
|
+
def get_series(
|
|
361
|
+
self,
|
|
362
|
+
location_id: str,
|
|
363
|
+
parameter_id: str,
|
|
364
|
+
qualifier_ids: List[str],
|
|
365
|
+
from_reference=False,
|
|
366
|
+
) -> ET.Element:
|
|
367
|
+
"""Get the XML series element for a location and parameter.
|
|
368
|
+
If from_reference is True, the series is taken from the reference data."""
|
|
369
|
+
if from_reference:
|
|
370
|
+
if hasattr(self, "_reference_data"):
|
|
371
|
+
root = self._reference_data.root
|
|
372
|
+
else:
|
|
373
|
+
raise ValueError("No reference data available.")
|
|
374
|
+
elif hasattr(self, "root") and self.root is not None:
|
|
375
|
+
root = self.root
|
|
376
|
+
else:
|
|
377
|
+
raise ValueError("No data to select from, use read first.")
|
|
378
|
+
for series in root.findall("pi:series", ns):
|
|
379
|
+
if (
|
|
380
|
+
location_id,
|
|
381
|
+
parameter_id,
|
|
382
|
+
qualifier_ids,
|
|
383
|
+
) == self.get_external_id_from_series(series):
|
|
384
|
+
return series
|
|
385
|
+
raise ValueError("No series found for locationId {} and parameterId {}".format(location_id, parameter_id))
|
|
386
|
+
|
|
387
|
+
def set_initial_value(self, internal_id: str, value: float):
|
|
388
|
+
location_id, parameter_id, qualifier_ids = self.get_external_id_from_internal_id(internal_id)
|
|
389
|
+
for series in self.root.findall("pi:series", ns):
|
|
390
|
+
if (
|
|
391
|
+
location_id,
|
|
392
|
+
parameter_id,
|
|
393
|
+
qualifier_ids,
|
|
394
|
+
) == self.get_external_id_from_series(series):
|
|
395
|
+
logger.info(
|
|
396
|
+
"Overwriting initial value for locationId {} and parameterId {} and qualifier ids {}".format(
|
|
397
|
+
location_id, parameter_id, qualifier_ids
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
event = series.find("pi:event", ns)
|
|
401
|
+
event.attrib["value"] = str(value)
|
|
402
|
+
return
|
|
403
|
+
else:
|
|
404
|
+
# if no series found, create a new series with just one event (based on the reference data)
|
|
405
|
+
reference_series = self.get_series(location_id, parameter_id, qualifier_ids, from_reference=True)
|
|
406
|
+
new_series = copy.deepcopy(reference_series)
|
|
407
|
+
start_date, end_date = self.get_datetime_range()
|
|
408
|
+
first_event = new_series.find("pi:event", ns)
|
|
409
|
+
if first_event is None:
|
|
410
|
+
raise ValueError("No event found in reference data.")
|
|
411
|
+
first_event.attrib["value"] = str(value)
|
|
412
|
+
first_event.attrib["date"] = start_date.strftime("%Y-%m-%d")
|
|
413
|
+
first_event.attrib["time"] = start_date.strftime("%H:%M:%S")
|
|
414
|
+
header = new_series.find("pi:header", ns)
|
|
415
|
+
if header is None:
|
|
416
|
+
raise ValueError("No header found in reference data.")
|
|
417
|
+
header.find("pi:startDate", ns).attrib = {
|
|
418
|
+
"date": start_date.strftime("%Y-%m-%d"),
|
|
419
|
+
"time": start_date.strftime("%H:%M:%S"),
|
|
420
|
+
}
|
|
421
|
+
header.find("pi:endDate", ns).attrib = {
|
|
422
|
+
"date": end_date.strftime("%Y-%m-%d"),
|
|
423
|
+
"time": end_date.strftime("%H:%M:%S"),
|
|
424
|
+
}
|
|
425
|
+
if self.forecast_date_in_header:
|
|
426
|
+
header.find("pi:forecastDate", ns).attrib = {
|
|
427
|
+
"date": start_date.strftime("%Y-%m-%d"),
|
|
428
|
+
"time": start_date.strftime("%H:%M:%S"),
|
|
429
|
+
}
|
|
430
|
+
self.root.append(new_series)
|
|
@@ -124,9 +124,9 @@ class BaseGoal(Goal):
|
|
|
124
124
|
):
|
|
125
125
|
"""Set function bounds either by user specified value or calculated"""
|
|
126
126
|
state_range = self._get_state_range(optimization_problem, self.state)
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
):
|
|
127
|
+
if (~np.isfinite(function_min) & ~np.isfinite(state_range[0])).any() or (
|
|
128
|
+
~np.isfinite(function_max) & ~np.isfinite(state_range[1])
|
|
129
|
+
).any():
|
|
130
130
|
raise ValueError(
|
|
131
131
|
f"The upper/lower bound for state {self.state} for goal with id={self.goal_id} is not specified"
|
|
132
132
|
+ " so the function range should be specified!"
|
|
@@ -215,8 +215,8 @@ class BaseGoal(Goal):
|
|
|
215
215
|
"goal_id": self.goal_id,
|
|
216
216
|
"state": self.state,
|
|
217
217
|
"goal_type": self.goal_type,
|
|
218
|
-
"function_min": self.function_range[0] if np.isfinite(self.function_range[0]) else None,
|
|
219
|
-
"function_max": self.function_range[1] if np.isfinite(self.function_range[1]) else None,
|
|
218
|
+
"function_min": self.function_range[0] if np.any(np.isfinite(self.function_range[0])) else None,
|
|
219
|
+
"function_max": self.function_range[1] if np.any(np.isfinite(self.function_range[1])) else None,
|
|
220
220
|
"function_nominal": self.function_nominal if np.isfinite(self.function_nominal) else None,
|
|
221
221
|
"target_min_series": None,
|
|
222
222
|
"target_max_series": None,
|
|
@@ -25,12 +25,16 @@ def get_mean_absolute_percentual_difference(timeseries: np.ndarray) -> float:
|
|
|
25
25
|
nonzero_indices = np.nonzero(timeseries)
|
|
26
26
|
timeseries = timeseries[nonzero_indices]
|
|
27
27
|
differences = np.diff(timeseries)
|
|
28
|
+
if len(timeseries) <= 1:
|
|
29
|
+
return 0
|
|
28
30
|
mapd = np.mean(np.abs(differences / timeseries[:-1]))
|
|
29
31
|
return mapd
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
def get_absolute_sum_difference(timeseries: np.ndarray) -> float:
|
|
33
35
|
"""Calculate the mean of absolute first-order difference."""
|
|
36
|
+
if len(timeseries) <= 1:
|
|
37
|
+
return 0
|
|
34
38
|
mad = np.mean(np.abs(np.diff(timeseries)))
|
|
35
39
|
return mad
|
|
36
40
|
|
|
@@ -53,7 +53,8 @@ class StatisticsMixin:
|
|
|
53
53
|
target_max = goal.target_max
|
|
54
54
|
return target_min, target_max
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
supported_goal_types = ["range", "range_rate_of_change"]
|
|
57
|
+
if goal.goal_type in supported_goal_types:
|
|
57
58
|
if goal.target_data_type == "parameter":
|
|
58
59
|
target_min, target_max = get_parameter_ranges(goal)
|
|
59
60
|
elif goal.target_data_type == "value":
|
|
@@ -64,5 +65,9 @@ class StatisticsMixin:
|
|
|
64
65
|
message = "Target type {} not known for goal {}.".format(goal.target_data_type, goal.goal_id)
|
|
65
66
|
logger.error(message)
|
|
66
67
|
raise ValueError(message)
|
|
67
|
-
|
|
68
|
+
else:
|
|
69
|
+
message = "Goal type {} not supported for target collection.".format(goal.goal_type)
|
|
70
|
+
logger.error(message)
|
|
71
|
+
raise ValueError(message)
|
|
72
|
+
target_dict: TargetDict = {"target_min": target_min, "target_max": target_max}
|
|
68
73
|
return target_dict
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Test the closed loop runner"""
|
|
2
|
+
import math
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from unittest import TestCase
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from rtctools_interface.closed_loop.runner import run_optimization_problem_closed_loop
|
|
8
|
+
from .test_models.goal_programming_xml.src.example import Example as ExampleXml
|
|
9
|
+
from .test_models.goal_programming_csv.src.example import Example as ExampleCsv
|
|
10
|
+
|
|
11
|
+
ns = {"fews": "http://www.wldelft.nl/fews", "pi": "http://www.wldelft.nl/fews/PI"}
|
|
12
|
+
|
|
13
|
+
# Elementwise comparisons are practially disabled.
|
|
14
|
+
A_TOL = 0.1
|
|
15
|
+
R_TOL = 0.1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compare_xml_file(file_result: Path, file_ref: Path):
|
|
19
|
+
"""Compare two timeseries_export files elementwise."""
|
|
20
|
+
tree_result = ET.parse(file_result)
|
|
21
|
+
tree_ref = ET.parse(file_ref)
|
|
22
|
+
series_result = tree_result.findall("pi:series", ns)
|
|
23
|
+
series_ref = tree_ref.findall("pi:series", ns)
|
|
24
|
+
assert len(series_result) == len(series_ref), "Different number of series found in exports."
|
|
25
|
+
for serie_result, serie_ref in zip(series_result, series_ref):
|
|
26
|
+
for event_result, event_ref in zip(serie_result.findall("pi:event", ns), serie_ref.findall("pi:event", ns)):
|
|
27
|
+
value_result = float(event_result.attrib["value"])
|
|
28
|
+
value_ref = float(event_ref.attrib["value"])
|
|
29
|
+
assert math.isclose(
|
|
30
|
+
value_result, value_ref, rel_tol=R_TOL, abs_tol=A_TOL
|
|
31
|
+
), f"Difference found in event: {value_result} != {value_ref}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compare_xml_files(output_modelling_period_folder: Path, reference_folder: Path):
|
|
35
|
+
"""Compare the timeseries_export.xml files in the output and reference folders."""
|
|
36
|
+
for folder in output_modelling_period_folder.iterdir():
|
|
37
|
+
if not folder.is_dir():
|
|
38
|
+
continue
|
|
39
|
+
file_name = "timeseries_export.xml"
|
|
40
|
+
file_result = folder / file_name
|
|
41
|
+
file_ref = reference_folder / folder.name / file_name
|
|
42
|
+
compare_xml_file(file_result, file_ref)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestClosedLoop(TestCase):
|
|
46
|
+
"""
|
|
47
|
+
Class for testing closed loop runner.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def test_running_closed_loop_csv(self):
|
|
51
|
+
"""
|
|
52
|
+
Check if test model runs without problems and generates same results.
|
|
53
|
+
"""
|
|
54
|
+
base_folder = Path(__file__).parent / "test_models" / "goal_programming_csv"
|
|
55
|
+
run_optimization_problem_closed_loop(ExampleCsv, base_folder=base_folder)
|
|
56
|
+
|
|
57
|
+
output_modelling_period_folder = base_folder / "output" / "output_modelling_periods"
|
|
58
|
+
self.assertTrue(output_modelling_period_folder.exists(), "Output modelling period folder should be created.")
|
|
59
|
+
self.assertEqual(
|
|
60
|
+
len(list(output_modelling_period_folder.iterdir())), 3, "Three modelling periods should be created."
|
|
61
|
+
)
|
|
62
|
+
for folder in output_modelling_period_folder.iterdir():
|
|
63
|
+
self.assertTrue((folder / "timeseries_export.csv").exists())
|
|
64
|
+
reference_folder = base_folder / "output" / "output_modelling_periods_reference"
|
|
65
|
+
for folder in output_modelling_period_folder.iterdir():
|
|
66
|
+
reference_folder_i = reference_folder / folder.name
|
|
67
|
+
for file in folder.iterdir():
|
|
68
|
+
df_result = pd.read_csv(file)
|
|
69
|
+
df_ref = pd.read_csv(reference_folder_i / file.name)
|
|
70
|
+
pd.testing.assert_frame_equal(df_result, df_ref, atol=A_TOL, rtol=R_TOL)
|
|
71
|
+
# Also compare the combined timeseries_export.csv
|
|
72
|
+
df_result = pd.read_csv(base_folder / "output" / "timeseries_export.csv")
|
|
73
|
+
df_ref = pd.read_csv(base_folder / "output" / "timeseries_export_reference.csv")
|
|
74
|
+
pd.testing.assert_frame_equal(df_result, df_ref, atol=A_TOL, rtol=R_TOL)
|
|
75
|
+
|
|
76
|
+
def test_running_closed_loop_xml(self):
|
|
77
|
+
"""
|
|
78
|
+
Check if test model runs without problems and generates same results.
|
|
79
|
+
"""
|
|
80
|
+
base_folder = Path(__file__).parent / "test_models" / "goal_programming_xml"
|
|
81
|
+
run_optimization_problem_closed_loop(ExampleXml, base_folder=base_folder)
|
|
82
|
+
|
|
83
|
+
output_modelling_period_folder = base_folder / "output" / "output_modelling_periods"
|
|
84
|
+
self.assertTrue(output_modelling_period_folder.exists(), "Output modelling period folder should be created.")
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
len([f for f in output_modelling_period_folder.iterdir() if f.is_dir()]),
|
|
87
|
+
3,
|
|
88
|
+
"Three modelling periods should be created.",
|
|
89
|
+
)
|
|
90
|
+
for folder in output_modelling_period_folder.iterdir():
|
|
91
|
+
if folder.is_dir():
|
|
92
|
+
self.assertTrue((folder / "timeseries_export.xml").exists())
|
|
93
|
+
reference_folder = base_folder / "output" / "output_modelling_periods_reference"
|
|
94
|
+
compare_xml_files(output_modelling_period_folder, reference_folder)
|
|
95
|
+
# Also compare the combined timeseries_export
|
|
96
|
+
compare_xml_file(
|
|
97
|
+
base_folder / "output" / "timeseries_export.xml", base_folder / "output" / "timeseries_export_reference.xml"
|
|
98
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/requires.txt
RENAMED
|
File without changes
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/top_level.txt
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.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/plotting/plot_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6/tests → rtc-tools-interface-0.8.0b2/rtctools_interface/utils}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6/tests/optimization → rtc-tools-interface-0.8.0b2/tests}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6/tests/utils → rtc-tools-interface-0.8.0b2/tests/optimization}/__init__.py
RENAMED
|
File without changes
|
|
@@ -5,9 +5,9 @@ from rtctools_interface.optimization.base_optimization_problem import BaseOptimi
|
|
|
5
5
|
from tests.utils.get_test import get_test_data
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
class TestBaseOptimizationProblem(unittest.TestCase):
|
|
10
9
|
"""Test for the base optimization problem class."""
|
|
10
|
+
|
|
11
11
|
def run_test(self, test):
|
|
12
12
|
"""Solve an optimization problem."""
|
|
13
13
|
test_data = get_test_data(test, optimization=True)
|
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_read_goals.py
RENAMED
|
File without changes
|
|
File without changes
|
{rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/simulation/test_plot_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|