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.
Files changed (57) hide show
  1. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/PKG-INFO +1 -1
  2. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/README.md +35 -0
  3. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/PKG-INFO +1 -1
  4. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/SOURCES.txt +8 -0
  5. rtc-tools-interface-0.8.0b2/rtctools_interface/__init__.py +7 -0
  6. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/_version.py +3 -3
  7. rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/closed_loop_dates.py +43 -0
  8. rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/results_construction.py +108 -0
  9. rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/runner.py +156 -0
  10. rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop/time_series_handler.py +430 -0
  11. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/base_goal.py +5 -5
  12. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_performance_metrics.py +4 -0
  13. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/helpers/statistics_mixin.py +7 -2
  14. rtc-tools-interface-0.8.0b2/tests/closed_loop/test_read_xml.py +9 -0
  15. rtc-tools-interface-0.8.0b2/tests/closed_loop/test_run_optization_problem_closed_loop.py +98 -0
  16. rtc-tools-interface-0.8.0b2/tests/simulation/__init__.py +0 -0
  17. rtc-tools-interface-0.8.0b2/tests/utils/__init__.py +0 -0
  18. rtc-tools-interface-0.7.6/rtctools_interface/__init__.py +0 -3
  19. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/COPYING.LESSER +0 -0
  20. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/dependency_links.txt +0 -0
  21. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/requires.txt +0 -0
  22. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtc_tools_interface.egg-info/top_level.txt +0 -0
  23. {rtc-tools-interface-0.7.6/rtctools_interface/optimization → rtc-tools-interface-0.8.0b2/rtctools_interface/closed_loop}/__init__.py +0 -0
  24. {rtc-tools-interface-0.7.6/rtctools_interface/optimization/helpers → rtc-tools-interface-0.8.0b2/rtctools_interface/optimization}/__init__.py +0 -0
  25. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/base_optimization_problem.py +0 -0
  26. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_generator_mixin.py +0 -0
  27. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/goal_table_schema.py +0 -0
  28. {rtc-tools-interface-0.7.6/rtctools_interface/plotting → rtc-tools-interface-0.8.0b2/rtctools_interface/optimization/helpers}/__init__.py +0 -0
  29. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/plot_goals_mixin.py +0 -0
  30. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/plot_mixin.py +0 -0
  31. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/optimization/read_goals.py +0 -0
  32. {rtc-tools-interface-0.7.6/rtctools_interface/simulation → rtc-tools-interface-0.8.0b2/rtctools_interface/plotting}/__init__.py +0 -0
  33. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/plotting/plot_tools.py +0 -0
  34. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/plotting/subplot_classes.py +0 -0
  35. {rtc-tools-interface-0.7.6/rtctools_interface/utils → rtc-tools-interface-0.8.0b2/rtctools_interface/simulation}/__init__.py +0 -0
  36. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/simulation/base_simulation_problem.py +0 -0
  37. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/simulation/plot_mixin.py +0 -0
  38. {rtc-tools-interface-0.7.6/tests → rtc-tools-interface-0.8.0b2/rtctools_interface/utils}/__init__.py +0 -0
  39. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/plot_table_schema.py +0 -0
  40. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/read_goals_mixin.py +0 -0
  41. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/read_plot_table.py +0 -0
  42. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/results_collection.py +0 -0
  43. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/serialization.py +0 -0
  44. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/rtctools_interface/utils/type_definitions.py +0 -0
  45. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/setup.cfg +0 -0
  46. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/setup.py +0 -0
  47. {rtc-tools-interface-0.7.6/tests/optimization → rtc-tools-interface-0.8.0b2/tests}/__init__.py +0 -0
  48. {rtc-tools-interface-0.7.6/tests/simulation → rtc-tools-interface-0.8.0b2/tests/closed_loop}/__init__.py +0 -0
  49. {rtc-tools-interface-0.7.6/tests/utils → rtc-tools-interface-0.8.0b2/tests/optimization}/__init__.py +0 -0
  50. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_base_optimization_problem.py +1 -1
  51. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_passing_goals_directly.py +0 -0
  52. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_plot_goals_mixin.py +0 -0
  53. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/optimization/test_read_goals.py +0 -0
  54. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/simulation/test_base_simulation_problem.py +0 -0
  55. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/simulation/test_plot_mixin.py +0 -0
  56. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/tests/utils/get_test.py +0 -0
  57. {rtc-tools-interface-0.7.6 → rtc-tools-interface-0.8.0b2}/versioneer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rtc-tools-interface
3
- Version: 0.7.6
3
+ Version: 0.8.0b2
4
4
  Summary: Toolbox for user interfaces for RTC-Tools
5
5
  Home-page: UNKNOWN
6
6
  Author: Deltares
@@ -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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rtc-tools-interface
3
- Version: 0.7.6
3
+ Version: 0.8.0b2
4
4
  Summary: Toolbox for user interfaces for RTC-Tools
5
5
  Home-page: UNKNOWN
6
6
  Author: Deltares
@@ -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
@@ -0,0 +1,7 @@
1
+ from . import _version
2
+
3
+ __version__ = _version.get_versions()["version"]
4
+
5
+ from rtctools_interface.closed_loop.runner import run_optimization_problem_closed_loop
6
+
7
+ __all__ = ["run_optimization_problem_closed_loop"]
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2024-05-07T17:02:33+0200",
11
+ "date": "2024-05-28T11:08:07+0200",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "0a20a376b41a3ca0e27f8d19c46f6b6d91536766",
15
- "version": "0.7.6"
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 (not np.isfinite(function_min) and not np.isfinite(state_range[0])) or (
128
- not np.isfinite(function_max) and not np.isfinite(state_range[1])
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
- if goal.goal_type in ["range", "range_rate_of_change"]:
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
- target_dict: TargetDict = {"target_min": target_min, "target_max": target_max}
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,9 @@
1
+ """Tests for the base optimization problem class."""
2
+ import unittest
3
+
4
+
5
+ class TestReadXml(unittest.TestCase):
6
+ """Placeholder for testing reading XML files."""
7
+
8
+ def test_read_xml(self):
9
+ """TODO"""
@@ -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
@@ -1,3 +0,0 @@
1
- from . import _version
2
-
3
- __version__ = _version.get_versions()["version"]
@@ -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)