├── tests ├── __init__.py ├── utils │ ├── __init__.py │ └── get_test.py ├── simulation │ ├── __init__.py │ ├── test_base_simulation_problem.py │ └── test_plot_mixin.py ├── closed_loop │ ├── __init__.py │ ├── test_models │ │ ├── goal_programming_csv │ │ │ ├── input │ │ │ │ ├── fixed_inputs.json │ │ │ │ ├── initial_state.csv │ │ │ │ ├── closed_loop_dates.csv │ │ │ │ └── timeseries_import.csv │ │ │ ├── output_fixed_periods │ │ │ │ └── .gitignore │ │ │ ├── output │ │ │ │ ├── output_modelling_periods_reference │ │ │ │ │ ├── period_1 │ │ │ │ │ │ └── timeseries_export.csv │ │ │ │ │ ├── period_0 │ │ │ │ │ │ └── timeseries_export.csv │ │ │ │ │ └── period_2 │ │ │ │ │ │ └── timeseries_export.csv │ │ │ │ └── timeseries_export_reference.csv │ │ │ ├── model │ │ │ │ └── Example.mo │ │ │ └── src │ │ │ │ └── example.py │ │ └── goal_programming_xml │ │ │ ├── input │ │ │ ├── fixed_inputs.json │ │ │ ├── closed_loop_dates.csv │ │ │ ├── rtcParameterConfig.xml │ │ │ ├── rtcDataConfig.xml │ │ │ └── timeseries_import.xml │ │ │ ├── input_with_forecast_date │ │ │ ├── fixed_inputs.json │ │ │ ├── closed_loop_dates.csv │ │ │ ├── rtcParameterConfig.xml │ │ │ ├── rtcDataConfig.xml │ │ │ └── timeseries_import.xml │ │ │ ├── input_with_forecast_date_equal_first_date │ │ │ ├── fixed_inputs.json │ │ │ ├── closed_loop_dates.csv │ │ │ ├── rtcParameterConfig.xml │ │ │ ├── rtcDataConfig.xml │ │ │ └── timeseries_import.xml │ │ │ ├── output_fixed_periods │ │ │ └── .gitignore │ │ │ ├── model │ │ │ └── Example.mo │ │ │ ├── src │ │ │ └── example.py │ │ │ └── output │ │ │ └── output_modelling_periods_reference │ │ │ ├── period_0 │ │ │ └── timeseries_export.xml │ │ │ ├── period_2 │ │ │ └── timeseries_export.xml │ │ │ └── period_1 │ │ │ └── timeseries_export.xml │ ├── test_read_xml.py │ ├── data │ │ └── rtcDataConfig.xml │ ├── test_optimization_ranges.py │ └── test_run_optization_problem_closed_loop.py ├── optimization │ ├── __init__.py │ ├── test_read_goals.py │ ├── test_base_optimization_problem.py │ ├── test_passing_goals_directly.py │ └── test_plot_goals_mixin.py └── data │ ├── model_input │ ├── target_bounds_as_parameters │ │ ├── initial_state.csv │ │ ├── parameters.csv │ │ └── timeseries_import.csv │ ├── target_bounds_as_timeseries │ │ ├── initial_state.csv │ │ └── timeseries_import.csv │ └── basic │ │ └── timeseries_import.csv │ ├── plot_results │ ├── run_1.pickle │ └── run_2.pickle │ ├── simulation │ ├── tests.csv │ └── output │ │ └── basic │ │ └── timeseries_export.csv │ ├── goals │ ├── target_bounds_as_timeseries.csv │ ├── target_bounds_as_parameters.csv │ └── basic.csv │ ├── plot_table │ └── plot_table.csv │ ├── models │ └── BasicModel.mo │ └── optimization │ ├── tests.csv │ └── output │ ├── basic │ └── timeseries_export.csv │ ├── target_bounds_as_parameters │ └── timeseries_export.csv │ └── target_bounds_as_timeseries │ └── timeseries_export.csv ├── rtctools_interface ├── utils │ ├── __init__.py │ ├── read_goals_mixin.py │ ├── type_definitions.py │ ├── plot_table_schema.py │ ├── read_plot_table.py │ ├── serialization.py │ └── results_collection.py ├── closed_loop │ ├── __init__.py │ ├── config.py │ ├── results_construction.py │ ├── optimization_ranges.py │ ├── time_series_handler.py │ └── runner.py ├── optimization │ ├── __init__.py │ ├── helpers │ │ ├── __init__.py │ │ └── statistics_mixin.py │ ├── plot_goals_mixin.py │ ├── base_optimization_problem.py │ ├── plot_mixin.py │ ├── read_goals.py │ ├── goal_table_schema.py │ ├── goal_generator_mixin.py │ ├── goal_performance_metrics.py │ └── base_goal.py ├── plotting │ └── __init__.py ├── simulation │ ├── __init__.py │ ├── base_simulation_problem.py │ └── plot_mixin.py └── __init__.py ├── .gitattributes ├── .pre-commit-config.yaml ├── ruff.toml ├── .gitignore ├── setup.cfg ├── setup.py ├── .github └── workflows │ └── ci.yml └── COPYING.LESSER /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/simulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/closed_loop/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/optimization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/plotting/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/simulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | rtctools_interface/_version.py export-subst 2 | -------------------------------------------------------------------------------- /tests/data/model_input/target_bounds_as_parameters/initial_state.csv: -------------------------------------------------------------------------------- 1 | x, 2 | 5.0, 3 | -------------------------------------------------------------------------------- /tests/data/model_input/target_bounds_as_timeseries/initial_state.csv: -------------------------------------------------------------------------------- 1 | x, 2 | 5.0, 3 | -------------------------------------------------------------------------------- /tests/data/model_input/target_bounds_as_parameters/parameters.csv: -------------------------------------------------------------------------------- 1 | p_min,p_max 2 | 5.0,15.0 -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/input/fixed_inputs.json: -------------------------------------------------------------------------------- 1 | [ 2 | "target_1" 3 | ] -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input/fixed_inputs.json: -------------------------------------------------------------------------------- 1 | [ 2 | "target_1" 3 | ] -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date/fixed_inputs.json: -------------------------------------------------------------------------------- 1 | [ 2 | "target_1" 3 | ] -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/input/initial_state.csv: -------------------------------------------------------------------------------- 1 | storage.V,Q_pump,Q_orifice 2 | 400000.0,0.0,0.0 3 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date_equal_first_date/fixed_inputs.json: -------------------------------------------------------------------------------- 1 | [ 2 | "target_1" 3 | ] -------------------------------------------------------------------------------- /tests/data/plot_results/run_1.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/rtc-tools-interface/main/tests/data/plot_results/run_1.pickle -------------------------------------------------------------------------------- /tests/data/plot_results/run_2.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/rtc-tools-interface/main/tests/data/plot_results/run_2.pickle -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/output_fixed_periods/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all files except this one. 2 | * 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/output_fixed_periods/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all files except this one. 2 | * 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /tests/data/simulation/tests.csv: -------------------------------------------------------------------------------- 1 | test,model_folder,model_name,model_input_folder,plot_table_file,output_folder 2 | basic,.,BasicModel,basic,plot_table.csv,basic -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input/closed_loop_dates.csv: -------------------------------------------------------------------------------- 1 | start_date,end_date 2 | 2024-05-01,2024-05-03 3 | 2024-05-03,2024-05-05 4 | 2024-05-05,2024-05-07 5 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/input/closed_loop_dates.csv: -------------------------------------------------------------------------------- 1 | start_date,end_date 2 | 2024-05-01,2024-05-03 3 | 2024-05-03,2024-05-04 4 | 2024-05-04,2024-05-07 5 | 6 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date/closed_loop_dates.csv: -------------------------------------------------------------------------------- 1 | start_date,end_date 2 | 2024-05-01,2024-05-03 3 | 2024-05-03,2024-05-05 4 | 2024-05-05,2024-05-07 5 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date_equal_first_date/closed_loop_dates.csv: -------------------------------------------------------------------------------- 1 | start_date,end_date 2 | 2024-05-01,2024-05-03 3 | 2024-05-03,2024-05-05 4 | 2024-05-05,2024-05-07 5 | -------------------------------------------------------------------------------- /rtctools_interface/__init__.py: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /tests/data/goals/target_bounds_as_timeseries.csv: -------------------------------------------------------------------------------- 1 | id,state,active,goal_type,function_min,function_max,function_nominal,target_data_type,target_min,target_max,priority,weight,order 2 | goal_1,x,1,range,0,15,10,timeseries,s,s,10,, 3 | goal_2,u,1,minimization_path,,,,,,,20,,2 4 | -------------------------------------------------------------------------------- /tests/data/goals/target_bounds_as_parameters.csv: -------------------------------------------------------------------------------- 1 | id,state,active,goal_type,function_min,function_max,function_nominal,target_data_type,target_min,target_max,priority,weight,order 2 | goal_1,x,1,range,0,20,10,parameter,p_min,p_max,10,, 3 | goal_2,u,1,minimization_path,,,,,,,20,,2 4 | -------------------------------------------------------------------------------- /tests/data/plot_table/plot_table.csv: -------------------------------------------------------------------------------- 1 | id,y_axis_title,variables_style_1,variables_style_2,variables_with_previous_result,custom_title,specified_in 2 | goal_1,Volume ($m^3$),,,,,goal_generator 3 | goal_2,Volume ($m^3$),,,,,goal_generator 4 | goal_3,"y-axis label",,,x,Test Title,python -------------------------------------------------------------------------------- /tests/closed_loop/test_read_xml.py: -------------------------------------------------------------------------------- 1 | """Tests for the base optimization problem class.""" 2 | 3 | import unittest 4 | 5 | 6 | class TestReadXml(unittest.TestCase): 7 | """Placeholder for testing reading XML files.""" 8 | 9 | def test_read_xml(self): 10 | """TODO""" 11 | -------------------------------------------------------------------------------- /tests/data/models/BasicModel.mo: -------------------------------------------------------------------------------- 1 | model BasicModel 2 | input Real u(fixed=false, min=-10, max=15); 3 | input Real f(fixed=true, min=-10, max=15); 4 | parameter Real scale = 3600; 5 | output Real x(start=5); 6 | 7 | equation 8 | der(x) = (-u + f) / scale; 9 | 10 | end BasicModel; 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.14.8 5 | hooks: 6 | # Run the linter. 7 | - id: ruff-check 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | -------------------------------------------------------------------------------- /tests/data/goals/basic.csv: -------------------------------------------------------------------------------- 1 | id,state,active,goal_type,function_min,function_max,function_nominal,target_data_type,target_min,target_max,priority,weight,order 2 | goal_1,x,1,range,0,15,10,value,5.0,10.0,10,, 3 | goal_2,u,1,minimization_path,,,,,,,20,,2 4 | goal_3,u,1,range_rate_of_change,,,,value,-3,3,15,,1 5 | -------------------------------------------------------------------------------- /tests/data/model_input/basic/timeseries_import.csv: -------------------------------------------------------------------------------- 1 | Time,f, 2 | 2023-06-20 00:00:00,4.0, 3 | 2023-06-20 01:00:00,4.0, 4 | 2023-06-20 02:00:00,4.0, 5 | 2023-06-20 03:00:00,4.0, 6 | 2023-06-20 04:00:00,4.0, 7 | 2023-06-20 05:00:00,4.0, 8 | 2023-06-20 06:00:00,0.0, 9 | 2023-06-20 07:00:00,0.0, 10 | 2023-06-20 08:00:00,0.0, 11 | 2023-06-20 09:00:00,0.0, 12 | 2023-06-20 10:00:00,0.0, -------------------------------------------------------------------------------- /tests/data/model_input/target_bounds_as_parameters/timeseries_import.csv: -------------------------------------------------------------------------------- 1 | Time,f, 2 | 2023-06-20 00:00:00,4.0, 3 | 2023-06-20 01:00:00,4.0, 4 | 2023-06-20 02:00:00,4.0, 5 | 2023-06-20 03:00:00,4.0, 6 | 2023-06-20 04:00:00,4.0, 7 | 2023-06-20 05:00:00,4.0, 8 | 2023-06-20 06:00:00,0.0, 9 | 2023-06-20 07:00:00,0.0, 10 | 2023-06-20 08:00:00,0.0, 11 | 2023-06-20 09:00:00,0.0, 12 | 2023-06-20 10:00:00,0.0, -------------------------------------------------------------------------------- /tests/data/simulation/output/basic/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,x 2 | 2023-06-20 00:00:00,5.000000 3 | 2023-06-20 01:00:00,8.000000 4 | 2023-06-20 02:00:00,11.000000 5 | 2023-06-20 03:00:00,14.000000 6 | 2023-06-20 04:00:00,17.000000 7 | 2023-06-20 05:00:00,20.000000 8 | 2023-06-20 06:00:00,19.000000 9 | 2023-06-20 07:00:00,18.000000 10 | 2023-06-20 08:00:00,17.000000 11 | 2023-06-20 09:00:00,16.000000 12 | 2023-06-20 10:00:00,15.000000 13 | -------------------------------------------------------------------------------- /tests/data/model_input/target_bounds_as_timeseries/timeseries_import.csv: -------------------------------------------------------------------------------- 1 | Time,f,s, 2 | 2023-06-20 00:00:00,4.0,5.0, 3 | 2023-06-20 01:00:00,4.0,5.0, 4 | 2023-06-20 02:00:00,4.0,7.0, 5 | 2023-06-20 03:00:00,4.0,9.0, 6 | 2023-06-20 04:00:00,4.0,9.0, 7 | 2023-06-20 05:00:00,4.0,10.0, 8 | 2023-06-20 06:00:00,0.0,10.0, 9 | 2023-06-20 07:00:00,0.0,10.0, 10 | 2023-06-20 08:00:00,0.0,10.0, 11 | 2023-06-20 09:00:00,0.0,10.0, 12 | 2023-06-20 10:00:00,0.0,10.0, -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 100 2 | exclude = ["versioneer.py", "rtctools_interface/_version.py"] 3 | 4 | target-version = "py310" 5 | 6 | [lint] 7 | 8 | ignore = [ 9 | "B904", # Fix someday: raising exceptions within except 10 | ] 11 | select = [ 12 | "B", # flake8-bugbear 13 | "C4", # flake8-comprehensions 14 | "E", # default / pycodestyle 15 | "F", # default / pyflakes 16 | "I", # isort 17 | "UP", # pyupgrade 18 | "W", # pycodestyle 19 | ] 20 | -------------------------------------------------------------------------------- /tests/data/optimization/tests.csv: -------------------------------------------------------------------------------- 1 | test,model_folder,model_name,model_input_folder,goals_file,plot_table_file,output_folder 2 | basic,.,BasicModel,basic,basic.csv,plot_table.csv,basic 3 | target_bounds_as_parameters,.,BasicModel,target_bounds_as_parameters,target_bounds_as_parameters.csv,plot_table.csv,target_bounds_as_parameters 4 | target_bounds_as_timeseries,.,BasicModel,target_bounds_as_timeseries,target_bounds_as_timeseries.csv,plot_table.csv,target_bounds_as_timeseries -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.c 7 | *.so 8 | 9 | # Distribution & Packaging 10 | .eggs 11 | build 12 | dist 13 | 14 | # Testing & Coverage 15 | .pytest_cache 16 | .tox 17 | .coverage 18 | *.egg-info 19 | 20 | # Model caches 21 | *.pymoca_cache 22 | 23 | # Editors 24 | .idea 25 | .ipynb_checkpoints 26 | .vscode 27 | 28 | # Figures & results 29 | *.png 30 | *.html 31 | *.pickle 32 | *.csv 33 | *.json 34 | output/ 35 | input_modelling_periods/ -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = COPYING.LESSER 3 | 4 | # See the docstring in versioneer.py for instructions. Note that you must 5 | # re-run 'versioneer.py setup' after changing this section, and commit the 6 | # resulting files. 7 | 8 | [versioneer] 9 | VCS = git 10 | style = pep440 11 | versionfile_source = rtctools_interface/_version.py 12 | parentdir_prefix = rtctools_interface- 13 | 14 | [flake8] 15 | max-line-length = 120 16 | exclude = 17 | # generated files 18 | rtctools_interface/_version.py 19 | -------------------------------------------------------------------------------- /tests/data/optimization/output/basic/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,u,x 2 | 2023-06-20 00:00:00,1.199915,4.999782 3 | 2023-06-20 01:00:00,2.324915,6.674866 4 | 2023-06-20 02:00:00,3.449916,7.224951 5 | 2023-06-20 03:00:00,3.487373,7.737578 6 | 2023-06-20 04:00:00,3.431187,8.306391 7 | 2023-06-20 05:00:00,2.306187,10.000204 8 | 2023-06-20 06:00:00,1.181186,8.819018 9 | 2023-06-20 07:00:00,0.056186,8.762831 10 | 2023-06-20 08:00:00,0.000000,8.762831 11 | 2023-06-20 09:00:00,0.000000,8.762831 12 | 2023-06-20 10:00:00,0.000000,8.762831 13 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/plot_goals_mixin.py: -------------------------------------------------------------------------------- 1 | """Deprecated, use PlotMixin.""" 2 | 3 | import warnings 4 | 5 | from rtctools_interface.optimization.plot_mixin import PlotMixin 6 | 7 | 8 | class PlotGoalsMixin(PlotMixin): 9 | """ 10 | Deprecated class, use PlotMixin instead. 11 | """ 12 | 13 | def __init__(self, *args, **kwargs): 14 | warnings.warn( 15 | "PlotGoalsMixin is deprecated, use PlotMixin instead", FutureWarning, stacklevel=1 16 | ) 17 | super().__init__(*args, **kwargs) 18 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input/rtcParameterConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/data/optimization/output/target_bounds_as_parameters/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,u,x 2 | 2023-06-20 00:00:00,0.000000,5.000000 3 | 2023-06-20 01:00:00,1.999950,7.000050 4 | 2023-06-20 02:00:00,1.999950,9.000101 5 | 2023-06-20 03:00:00,1.999950,11.000151 6 | 2023-06-20 04:00:00,1.999950,13.000202 7 | 2023-06-20 05:00:00,1.999950,15.000252 8 | 2023-06-20 06:00:00,0.000070,15.000183 9 | 2023-06-20 07:00:00,0.000046,15.000136 10 | 2023-06-20 08:00:00,0.000032,15.000104 11 | 2023-06-20 09:00:00,0.000020,15.000084 12 | 2023-06-20 10:00:00,0.000010,15.000074 13 | -------------------------------------------------------------------------------- /tests/data/optimization/output/target_bounds_as_timeseries/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,u,x 2 | 2023-06-20 00:00:00,0.000000,5.000000 3 | 2023-06-20 01:00:00,3.998886,5.001114 4 | 2023-06-20 02:00:00,2.001053,7.000061 5 | 2023-06-20 03:00:00,2.001053,8.999009 6 | 2023-06-20 04:00:00,3.998352,9.000657 7 | 2023-06-20 05:00:00,3.000139,10.000517 8 | 2023-06-20 06:00:00,0.000055,10.000462 9 | 2023-06-20 07:00:00,0.000031,10.000430 10 | 2023-06-20 08:00:00,0.000020,10.000410 11 | 2023-06-20 09:00:00,0.000013,10.000397 12 | 2023-06-20 10:00:00,0.000006,10.000391 13 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date/rtcParameterConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date_equal_first_date/rtcParameterConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0.0 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/optimization/test_read_goals.py: -------------------------------------------------------------------------------- 1 | """Tests for reading goals from a csv file.""" 2 | 3 | import pathlib 4 | import unittest 5 | 6 | from rtctools_interface.optimization.read_goals import read_goals 7 | 8 | CSV_FILE = pathlib.Path(__file__).parent.parent / "data" / "goals" / "basic.csv" 9 | 10 | 11 | class TestGoalReader(unittest.TestCase): 12 | """Test for reading goals.""" 13 | 14 | def test_read_csv(self): 15 | """Test for reading goals from csv.""" 16 | goals = read_goals(CSV_FILE, path_goal=True) 17 | self.assertEqual(len(goals), 3) 18 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/output/output_modelling_periods_reference/period_1/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,Q_orifice,Q_pump,is_downhill,sea_level,storage_level 2 | 2024-05-03 00:00:00,0.000000,5.019110,0.000000,0.600000,0.434967 3 | 2024-05-03 08:00:00,0.000000,5.005019,0.000000,0.700000,0.434823 4 | 2024-05-03 16:00:00,0.000000,5.001069,0.000000,0.800000,0.434792 5 | 2024-05-04 00:00:00,0.000000,4.991490,0.000000,0.900000,0.435037 6 | 2024-05-04 08:00:00,0.000000,4.827632,0.000000,1.000000,0.440001 7 | 2024-05-04 16:00:00,0.000000,4.999987,0.000000,0.900000,0.440002 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | import versioneer 4 | 5 | setup( 6 | name="rtc_tools_interface", 7 | version=versioneer.get_version(), 8 | maintainer="Deltares", 9 | packages=find_packages("."), 10 | author="Deltares", 11 | description="Toolbox for user interfaces for RTC-Tools", 12 | install_requires=[ 13 | "matplotlib", 14 | "numpy", 15 | "pandas", 16 | "plotly", 17 | "pydantic", 18 | "casadi != 3.6.6", 19 | "rtc-tools >= 2.7.0a3", 20 | ], 21 | tests_require=["pytest", "pytest-runner"], 22 | python_requires=">=3.9", 23 | cmdclass=versioneer.get_cmdclass(), 24 | ) 25 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/output/output_modelling_periods_reference/period_0/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,Q_orifice,Q_pump,is_downhill,sea_level,storage_level 2 | 2024-05-01 00:00:00,0.000000,0.000000,1.000000,0.000000,0.400000 3 | 2024-05-01 08:00:00,3.914858,0.000000,1.000000,0.100000,0.431252 4 | 2024-05-01 16:00:00,5.044142,0.000000,1.000000,0.200000,0.429981 5 | 2024-05-02 00:00:00,3.975639,0.676391,1.000000,0.300000,0.440002 6 | 2024-05-02 08:00:00,2.125126,2.874857,1.000000,0.400000,0.440003 7 | 2024-05-02 16:00:00,0.000000,5.155733,0.000000,0.500000,0.435518 8 | 2024-05-03 00:00:00,0.000000,5.019110,0.000000,0.600000,0.434967 9 | 2024-05-03 08:00:00,0.000000,4.825209,0.000000,0.700000,0.440001 10 | 2024-05-03 16:00:00,0.000000,4.999972,0.000000,0.800000,0.440002 11 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/input/timeseries_import.csv: -------------------------------------------------------------------------------- 1 | UTC,H_sea,Q_in,target_1 2 | 2024-05-01 00:00:00,0.0,5.0,5.0 3 | 2024-05-01 08:00:00,0.1,5.0,5.0 4 | 2024-05-01 16:00:00,0.2,5.0,5.0 5 | 2024-05-02 00:00:00,0.3,5.0, 6 | 2024-05-02 08:00:00,0.4,5.0, 7 | 2024-05-02 16:00:00,0.5,5.0, 8 | 2024-05-03 00:00:00,0.6,5.0, 9 | 2024-05-03 08:00:00,0.7,5.0, 10 | 2024-05-03 16:00:00,0.8,5.0, 11 | 2024-05-04 00:00:00,0.9,5.0, 12 | 2024-05-04 08:00:00,1.0,5.0, 13 | 2024-05-04 16:00:00,0.9,5.0, 14 | 2024-05-05 00:00:00,0.8,5.0, 15 | 2024-05-05 08:00:00,0.7,5.0, 16 | 2024-05-05 16:00:00,0.6,5.0, 17 | 2024-05-06 00:00:00,0.5,5.0, 18 | 2024-05-06 08:00:00,0.4,5.0, 19 | 2024-05-06 16:00:00,0.3,5.0, 20 | 2024-05-07 00:00:00,0.2,5.0, 21 | 2024-05-07 08:00:00,0.1,5.0, 22 | 2024-05-07 16:00:00,0.0,5.0, 23 | -------------------------------------------------------------------------------- /rtctools_interface/simulation/base_simulation_problem.py: -------------------------------------------------------------------------------- 1 | """Module for a basic simulation problem.""" 2 | 3 | from rtctools.simulation.csv_mixin import CSVMixin 4 | from rtctools.simulation.simulation_problem import SimulationProblem 5 | 6 | 7 | class BaseSimulationProblem( 8 | CSVMixin, 9 | SimulationProblem, 10 | ): 11 | # Ignore too many ancestors, since the use of mixin classes is how rtc-tools is set up. 12 | # pylint: disable=too-many-ancestors 13 | """ 14 | Basic simulation problem for a given state. 15 | 16 | :cvar goal_table_file: 17 | path to csv file containing a list of goals. 18 | """ 19 | 20 | def __init__( 21 | self, 22 | **kwargs, 23 | ): 24 | super().__init__(**kwargs) 25 | 26 | def update(self, dt): 27 | self.set_var("u", 1) 28 | super().update(dt) 29 | 30 | def initialize(self): 31 | self.set_var("u", 1) 32 | super().initialize() 33 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/output/output_modelling_periods_reference/period_2/timeseries_export.csv: -------------------------------------------------------------------------------- 1 | time,Q_orifice,Q_pump,is_downhill,sea_level,storage_level 2 | 2024-05-04 00:00:00,0.000000,4.991490,0.000000,0.900000,0.435037 3 | 2024-05-04 08:00:00,0.000000,5.004574,0.000000,1.000000,0.434905 4 | 2024-05-04 16:00:00,0.000000,5.000738,0.000000,0.900000,0.434884 5 | 2024-05-05 00:00:00,0.000000,5.002209,0.000000,0.800000,0.434820 6 | 2024-05-05 08:00:00,0.000000,5.025354,0.000000,0.700000,0.434090 7 | 2024-05-05 16:00:00,0.000000,5.062433,0.000000,0.600000,0.432292 8 | 2024-05-06 00:00:00,0.000000,4.790646,0.000000,0.500000,0.438322 9 | 2024-05-06 08:00:00,2.125115,2.816523,1.000000,0.400000,0.440002 10 | 2024-05-06 16:00:00,3.975637,1.024369,1.000000,0.300000,0.440002 11 | 2024-05-07 00:00:00,5.070749,0.000000,1.000000,0.200000,0.437965 12 | 2024-05-07 08:00:00,5.095738,0.000000,1.000000,0.100000,0.435207 13 | 2024-05-07 16:00:00,5.003009,0.000000,1.000000,0.000000,0.435121 14 | -------------------------------------------------------------------------------- /tests/simulation/test_base_simulation_problem.py: -------------------------------------------------------------------------------- 1 | """Tests for the base simulation problem class.""" 2 | 3 | import unittest 4 | 5 | from rtctools_interface.simulation.base_simulation_problem import BaseSimulationProblem 6 | from tests.utils.get_test import get_test_data 7 | 8 | 9 | class TestBasSimulationProblem(unittest.TestCase): 10 | """Test for the base simulation problem class.""" 11 | 12 | def run_test(self, test): 13 | """Solve an simulation problem.""" 14 | test_data = get_test_data(test, optimization=False) 15 | problem = BaseSimulationProblem( 16 | model_folder=test_data["model_folder"], 17 | model_name=test_data["model_name"], 18 | input_folder=test_data["model_input_folder"], 19 | output_folder=test_data["output_folder"], 20 | ) 21 | problem.simulate() 22 | 23 | # TODO: use pytest instead to parametrise tests. 24 | def test_base_simulation_problem(self): 25 | """Solve several simulation problems.""" 26 | for test in ["basic"]: 27 | self.run_test(test) 28 | -------------------------------------------------------------------------------- /tests/utils/get_test.py: -------------------------------------------------------------------------------- 1 | """Tools for getting optimization or simulation test data.""" 2 | 3 | import pathlib 4 | 5 | import pandas as pd 6 | 7 | DATA_DIR = pathlib.Path(__file__).parent.parent / "data" 8 | 9 | 10 | def get_test_data(test: str, optimization: bool = True) -> dict: 11 | """ 12 | Get the input data and output folder for a given test. 13 | """ 14 | sub_path = "optimization" if optimization else "simulation" 15 | tests_df = pd.read_csv(DATA_DIR / sub_path / "tests.csv", sep=",") 16 | tests_df.set_index("test", inplace=True) 17 | test_data = tests_df.loc[test] 18 | test_data_dict = { 19 | "model_folder": DATA_DIR / "models" / test_data["model_folder"], 20 | "model_name": test_data["model_name"], 21 | "model_input_folder": DATA_DIR / "model_input" / test_data["model_input_folder"], 22 | "plot_table_file": DATA_DIR / "plot_table" / test_data["plot_table_file"], 23 | "output_folder": DATA_DIR / sub_path / "output" / test_data["output_folder"], 24 | } 25 | if optimization: 26 | test_data_dict["goals_file"] = DATA_DIR / "goals" / test_data["goals_file"] 27 | return test_data_dict 28 | -------------------------------------------------------------------------------- /tests/optimization/test_base_optimization_problem.py: -------------------------------------------------------------------------------- 1 | """Tests for the base optimization problem class.""" 2 | 3 | import unittest 4 | 5 | from rtctools_interface.optimization.base_optimization_problem import BaseOptimizationProblem 6 | from tests.utils.get_test import get_test_data 7 | 8 | 9 | class TestBaseOptimizationProblem(unittest.TestCase): 10 | """Test for the base optimization problem class.""" 11 | 12 | def run_test(self, test): 13 | """Solve an optimization problem.""" 14 | test_data = get_test_data(test, optimization=True) 15 | problem = BaseOptimizationProblem( 16 | goal_table_file=test_data["goals_file"], 17 | model_folder=test_data["model_folder"], 18 | model_name=test_data["model_name"], 19 | input_folder=test_data["model_input_folder"], 20 | output_folder=test_data["output_folder"], 21 | ) 22 | problem.optimize() 23 | 24 | # TODO: use pytest instead to parametrise tests. 25 | def test_base_optimization_problem(self): 26 | """Solve several optimization problems.""" 27 | for test in ["basic", "target_bounds_as_parameters", "target_bounds_as_timeseries"]: 28 | self.run_test(test) 29 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/base_optimization_problem.py: -------------------------------------------------------------------------------- 1 | """Module for a basic optimization problem.""" 2 | 3 | from rtctools.optimization.collocated_integrated_optimization_problem import ( 4 | CollocatedIntegratedOptimizationProblem, 5 | ) 6 | from rtctools.optimization.csv_mixin import CSVMixin 7 | from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin 8 | from rtctools.optimization.modelica_mixin import ModelicaMixin 9 | 10 | from rtctools_interface.optimization.goal_generator_mixin import GoalGeneratorMixin 11 | 12 | 13 | class BaseOptimizationProblem( 14 | GoalGeneratorMixin, 15 | GoalProgrammingMixin, 16 | CSVMixin, 17 | ModelicaMixin, 18 | CollocatedIntegratedOptimizationProblem, 19 | ): 20 | # Ignore too many ancestors, since the use of mixin classes is how rtc-tools is set up. 21 | # pylint: disable=too-many-ancestors 22 | """ 23 | Basic optimization problem for a given state. 24 | 25 | :cvar goal_table_file: 26 | path to csv file containing a list of goals. 27 | """ 28 | 29 | def __init__( 30 | self, 31 | goal_table_file=None, 32 | **kwargs, 33 | ): 34 | self.goal_table_file = goal_table_file 35 | super().__init__(**kwargs) 36 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/output/timeseries_export_reference.csv: -------------------------------------------------------------------------------- 1 | time,Q_orifice,Q_pump,is_downhill,sea_level,storage_level 2 | 2024-05-01 00:00:00,0.0,0.0,1.0,0.0,0.4 3 | 2024-05-01 08:00:00,3.914858,0.0,1.0,0.1,0.431252 4 | 2024-05-01 16:00:00,5.044142,0.0,1.0,0.2,0.429981 5 | 2024-05-02 00:00:00,3.975639,0.676391,1.0,0.3,0.440002 6 | 2024-05-02 08:00:00,2.125126,2.874857,1.0,0.4,0.440003 7 | 2024-05-02 16:00:00,0.0,5.155733,0.0,0.5,0.435518 8 | 2024-05-03 00:00:00,0.0,5.01911,0.0,0.6,0.434967 9 | 2024-05-03 08:00:00,0.0,5.005019,0.0,0.7,0.434823 10 | 2024-05-03 16:00:00,0.0,5.001069,0.0,0.8,0.434792 11 | 2024-05-04 00:00:00,0.0,4.99149,0.0,0.9,0.435037 12 | 2024-05-04 08:00:00,0.0,5.004574,0.0,1.0,0.434905 13 | 2024-05-04 16:00:00,0.0,5.000738,0.0,0.9,0.434884 14 | 2024-05-05 00:00:00,0.0,5.002209,0.0,0.8,0.43482 15 | 2024-05-05 08:00:00,0.0,5.025354,0.0,0.7,0.43409 16 | 2024-05-05 16:00:00,0.0,5.062433,0.0,0.6,0.432292 17 | 2024-05-06 00:00:00,0.0,4.790646,0.0,0.5,0.438322 18 | 2024-05-06 08:00:00,2.125115,2.816523,1.0,0.4,0.440002 19 | 2024-05-06 16:00:00,3.975637,1.024369,1.0,0.3,0.440002 20 | 2024-05-07 00:00:00,5.070749,0.0,1.0,0.2,0.437965 21 | 2024-05-07 08:00:00,5.095738,0.0,1.0,0.1,0.435207 22 | 2024-05-07 16:00:00,5.003009,0.0,1.0,0.0,0.435121 23 | -------------------------------------------------------------------------------- /tests/optimization/test_passing_goals_directly.py: -------------------------------------------------------------------------------- 1 | """Tests for the base optimization problem class.""" 2 | 3 | import unittest 4 | 5 | from rtctools_interface.optimization.base_optimization_problem import BaseOptimizationProblem 6 | from rtctools_interface.optimization.read_goals import read_goals_from_csv 7 | from tests.utils.get_test import get_test_data 8 | 9 | 10 | class TestPassingGoalsDirectly(unittest.TestCase): 11 | """Test for the base optimization problem class.""" 12 | 13 | def run_test(self, test): 14 | """Solve an optimization problem.""" 15 | test_data = get_test_data(test, optimization=True) 16 | 17 | goals_to_generate = read_goals_from_csv(test_data["goals_file"]) 18 | problem = BaseOptimizationProblem( 19 | model_folder=test_data["model_folder"], 20 | model_name=test_data["model_name"], 21 | input_folder=test_data["model_input_folder"], 22 | output_folder=test_data["output_folder"], 23 | goals_to_generate=goals_to_generate, 24 | read_goals_from="passed_list", 25 | ) 26 | problem.optimize() 27 | 28 | # TODO: use pytest instead to parametrise tests. 29 | def test_base_optimization_problem(self): 30 | """Solve several optimization problems.""" 31 | for test in ["basic", "target_bounds_as_parameters", "target_bounds_as_timeseries"]: 32 | self.run_test(test) 33 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input/rtcDataConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | H_sea 6 | par_0 7 | 8 | 9 | 10 | 11 | Q_in 12 | par_0 13 | 14 | 15 | 16 | 17 | Q_orifice 18 | par_0 19 | 20 | 21 | 22 | 23 | Q_pump 24 | par_0 25 | 26 | 27 | 28 | 29 | is_downhill 30 | par_0 31 | 32 | 33 | 34 | 35 | sea_level 36 | par_0 37 | 38 | 39 | 40 | 41 | storage_level 42 | par_0 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date/rtcDataConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | H_sea 6 | par_0 7 | 8 | 9 | 10 | 11 | Q_in 12 | par_0 13 | 14 | 15 | 16 | 17 | Q_orifice 18 | par_0 19 | 20 | 21 | 22 | 23 | Q_pump 24 | par_0 25 | 26 | 27 | 28 | 29 | is_downhill 30 | par_0 31 | 32 | 33 | 34 | 35 | sea_level 36 | par_0 37 | 38 | 39 | 40 | 41 | storage_level 42 | par_0 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date_equal_first_date/rtcDataConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | H_sea 6 | par_0 7 | 8 | 9 | 10 | 11 | Q_in 12 | par_0 13 | 14 | 15 | 16 | 17 | Q_orifice 18 | par_0 19 | 20 | 21 | 22 | 23 | Q_pump 24 | par_0 25 | 26 | 27 | 28 | 29 | is_downhill 30 | par_0 31 | 32 | 33 | 34 | 35 | sea_level 36 | par_0 37 | 38 | 39 | 40 | 41 | storage_level 42 | par_0 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /rtctools_interface/utils/read_goals_mixin.py: -------------------------------------------------------------------------------- 1 | """Mixin to read the goal table and store as class variables.""" 2 | 3 | import os 4 | from typing import Literal 5 | 6 | from rtctools_interface.optimization.read_goals import read_goals 7 | 8 | 9 | class ReadGoalsMixin: 10 | """Read the goal table either from the default or specified path.""" 11 | 12 | def load_goals( 13 | self, 14 | read_from: Literal["csv_table", "passed_list"] = "csv_table", 15 | goals_to_generate: list | None = None, 16 | ): 17 | """Read goal table and store as instance variable.""" 18 | goals_to_generate = goals_to_generate if goals_to_generate else [] 19 | if not hasattr(self, "goal_table_file"): 20 | self.goal_table_file = os.path.join(self._input_folder, "goal_table.csv") 21 | 22 | if ( 23 | read_from == "csv_table" 24 | and os.path.isfile(self.goal_table_file) 25 | or read_from == "passed_list" 26 | ): 27 | self._goal_generator_path_goals = read_goals( 28 | self.goal_table_file, 29 | path_goal=True, 30 | read_from=read_from, 31 | goals_to_generate=goals_to_generate, 32 | ) 33 | self._goal_generator_non_path_goals = read_goals( 34 | self.goal_table_file, 35 | path_goal=False, 36 | read_from=read_from, 37 | goals_to_generate=goals_to_generate, 38 | ) 39 | self._all_goal_generator_goals = ( 40 | self._goal_generator_path_goals + self._goal_generator_non_path_goals 41 | ) 42 | else: 43 | self._all_goal_generator_goals = [] 44 | -------------------------------------------------------------------------------- /tests/closed_loop/data/rtcDataConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TroutLake 7 | Inflow 8 | 9 | 10 | 11 | 12 | Alder 13 | Inflow 14 | 15 | 16 | 17 | 18 | TroutLake 19 | Volume 20 | 21 | 22 | 23 | 24 | TroutLake 25 | Q_release_user 26 | 27 | 28 | 29 | 30 | RiverCity 31 | Q 32 | 33 | 34 | 35 | 36 | TroutLake 37 | Q_out 38 | 39 | 40 | 41 | 42 | TroutLake 43 | Q_spill 44 | 45 | 46 | 47 | 48 | TroutLake 49 | Q_turbine 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /rtctools_interface/utils/type_definitions.py: -------------------------------------------------------------------------------- 1 | """Type definitions for rtc_tools interface.""" 2 | 3 | import datetime 4 | import pathlib 5 | from typing import Literal, TypedDict 6 | 7 | import numpy as np 8 | 9 | from rtctools_interface.utils.plot_table_schema import PlotTableRow 10 | 11 | 12 | class TargetDict(TypedDict): 13 | """Target min and max timeseries for a goal.""" 14 | 15 | target_min: np.ndarray 16 | target_max: np.ndarray 17 | 18 | 19 | class GoalConfig(TypedDict): 20 | """Configuration for a goal.""" 21 | 22 | goal_id: str 23 | state: str 24 | goal_type: str 25 | function_min: float | None 26 | function_max: float | None 27 | function_nominal: float | None 28 | target_min: tuple[float, np.ndarray] 29 | target_max: tuple[float, np.ndarray] 30 | target_min_series: np.ndarray | None 31 | target_max_series: np.ndarray | None 32 | priority: int 33 | weight: float 34 | order: int 35 | 36 | 37 | class PrioIndependentData(TypedDict): 38 | """Data for one optimization run, which is independent of the priority.""" 39 | 40 | io_datetimes: list[datetime.datetime] 41 | times: np.ndarray 42 | base_goals: list[GoalConfig] 43 | 44 | 45 | class PlotOptions(TypedDict): 46 | """Plot configuration for on optimization run.""" 47 | 48 | plot_config: list[PlotTableRow] 49 | plot_max_rows: int 50 | output_folder: pathlib.Path 51 | save_plot_to: Literal["image", "stringio"] 52 | 53 | 54 | class IntermediateResult(TypedDict): 55 | """Dict containing the results (timeseries) for one priority optimization.""" 56 | 57 | priority: int 58 | timeseries_data: dict[str, np.ndarray] 59 | 60 | 61 | class PlotDataAndConfig(TypedDict): 62 | """All data and options required to create all plots for one optimization run.""" 63 | 64 | intermediate_results: list[IntermediateResult] 65 | plot_options: PlotOptions 66 | prio_independent_data: PrioIndependentData 67 | config_version: float 68 | -------------------------------------------------------------------------------- /tests/simulation/test_plot_mixin.py: -------------------------------------------------------------------------------- 1 | """Tests for goal-plotting functionalities.""" 2 | 3 | import unittest 4 | 5 | from rtctools_interface.simulation.base_simulation_problem import BaseSimulationProblem 6 | from rtctools_interface.simulation.plot_mixin import PlotMixin 7 | from tests.utils.get_test import get_test_data 8 | 9 | 10 | class BaseSimulationProblemPlotting(PlotMixin, BaseSimulationProblem): 11 | # Ignore too many ancestors, since the use of mixin classes is 12 | # how rtc-tools is set up. 13 | # Ignore abstract-method not implemented, as this is related how 14 | # some todo's are setup in simulation mode. 15 | # pylint: disable=too-many-ancestors, abstract-method 16 | """Simulation problem with plotting functionalities.""" 17 | 18 | def __init__( 19 | self, 20 | plot_table_file, 21 | **kwargs, 22 | ): 23 | self.plot_table_file = plot_table_file 24 | super().__init__(**kwargs) 25 | 26 | 27 | class TestPlotMixin(unittest.TestCase): 28 | """Test for goal-plotting functionalities.""" 29 | 30 | def run_test(self, test, plotting_library): 31 | """Solve an simulation problem.""" 32 | test_data = get_test_data(test, optimization=False) 33 | problem = BaseSimulationProblemPlotting( 34 | plot_table_file=test_data["plot_table_file"], 35 | model_folder=test_data["model_folder"], 36 | model_name=test_data["model_name"], 37 | input_folder=test_data["model_input_folder"], 38 | output_folder=test_data["output_folder"], 39 | plotting_library=plotting_library, 40 | ) 41 | problem.simulate() 42 | 43 | def test_plot_goals_mixin_plotly(self): 44 | """Solve several simulation problems.""" 45 | for test in [ 46 | "basic", 47 | ]: 48 | self.run_test(test, plotting_library="plotly") 49 | 50 | def test_plot_goals_mixin_matplotlib(self): 51 | """Solve several simulation problems.""" 52 | for test in [ 53 | "basic", 54 | ]: 55 | self.run_test(test, plotting_library="matplotlib") 56 | -------------------------------------------------------------------------------- /rtctools_interface/utils/plot_table_schema.py: -------------------------------------------------------------------------------- 1 | """Schema for the plot_table.""" 2 | 3 | from typing import Literal 4 | 5 | import numpy as np 6 | import pandas as pd 7 | from pydantic import BaseModel, field_validator, model_validator 8 | 9 | 10 | def string_to_list(string): 11 | """ 12 | Convert a string to a list of strings 13 | """ 14 | if string == "" or not isinstance(string, str): 15 | return [] 16 | string_without_whitespace = string.replace(" ", "") 17 | list_of_strings = string_without_whitespace.split(",") 18 | return list_of_strings 19 | 20 | 21 | class PlotTableRow(BaseModel): 22 | """Model for one row in the plot table.""" 23 | 24 | specified_in: Literal["python", "goal_generator"] 25 | y_axis_title: str 26 | id: int | str | float = np.nan 27 | variables_style_1: list[str] = [] 28 | variables_style_2: list[str] = [] 29 | variables_with_previous_result: list[str] = [] 30 | custom_title: str | float = np.nan 31 | 32 | @field_validator( 33 | "variables_style_1", "variables_style_2", "variables_with_previous_result", mode="before" 34 | ) 35 | @classmethod 36 | def convert_to_list(cls, value): 37 | """Convert the inputs to a list.""" 38 | if isinstance(value, list): 39 | return value 40 | return string_to_list(value) 41 | 42 | @field_validator("id") 43 | @classmethod 44 | def convert_to_int(cls, value): 45 | """Convert value to integer if possible.""" 46 | try: 47 | return int(value) 48 | except (ValueError, TypeError): 49 | return value 50 | 51 | @model_validator(mode="after") 52 | def check_required_id(self): 53 | """Check if ID is present if specified in goal_generator.""" 54 | if self.specified_in == "goal_generator" and pd.isna(self.id): 55 | raise ValueError("ID is required when goal is specified in the goal generator.") 56 | return self 57 | 58 | def get(self, attribute_name, default=None): 59 | """Similar functionality as dict-get method.""" 60 | try: 61 | return getattr(self, attribute_name) 62 | except AttributeError: 63 | return default 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | 9 | linting: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: actions/setup-python@v6 14 | - uses: pre-commit/action@v3.0.1 15 | 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.10' 23 | - name: Cache pip 24 | uses: actions/cache@v3 25 | with: 26 | path: ~/.cache/pip 27 | key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }} 28 | restore-keys: | 29 | ${{ runner.os }}-pip- 30 | - run: pip install -e . 31 | - run: pip install pytest 32 | - run: pytest tests 33 | 34 | build: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-python@v4 39 | with: 40 | python-version: '3.10' 41 | - name: Cache pip 42 | uses: actions/cache@v3 43 | with: 44 | path: ~/.cache/pip 45 | key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }} 46 | restore-keys: | 47 | ${{ runner.os }}-pip- 48 | - run: pip install setuptools wheel 49 | - run: python setup.py sdist bdist_wheel 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: dist 53 | path: dist/ 54 | 55 | deploy: 56 | needs: build 57 | runs-on: ubuntu-latest 58 | if: startsWith(github.ref, 'refs/tags/') 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-python@v4 62 | with: 63 | python-version: '3.10' 64 | - name: Cache pip 65 | uses: actions/cache@v3 66 | with: 67 | path: ~/.cache/pip 68 | key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }} 69 | restore-keys: | 70 | ${{ runner.os }}-pip- 71 | - run: pip install twine 72 | - uses: actions/download-artifact@v4 73 | with: 74 | name: dist 75 | path: dist 76 | - run: twine upload -u ${{ secrets.PYPI_USER }} -p ${{ secrets.PYPI_PASSWORD }} dist/* 77 | -------------------------------------------------------------------------------- /rtctools_interface/utils/read_plot_table.py: -------------------------------------------------------------------------------- 1 | """Module for reading goals from a csv file.""" 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | import pandas as pd 7 | 8 | from rtctools_interface.utils.plot_table_schema import PlotTableRow 9 | 10 | logger = logging.getLogger("rtctools") 11 | 12 | 13 | def read_plot_config_from_csv(plot_table_file: Path | str) -> list[PlotTableRow]: 14 | """Read plot information from csv file and check values""" 15 | plot_table_file = Path(plot_table_file) 16 | if plot_table_file.is_file(): 17 | try: 18 | raw_plot_table = pd.read_csv(plot_table_file, sep=",") 19 | except pd.errors.EmptyDataError: # Empty plot table 20 | raw_plot_table = pd.DataFrame() 21 | parsed_rows: list[PlotTableRow] = [] 22 | for _, row in raw_plot_table.iterrows(): 23 | parsed_rows.append(PlotTableRow(**row)) 24 | return parsed_rows 25 | message = ( 26 | f"No plot table was found at the default location ({plot_table_file.resolve()})." 27 | + " Please create one before using the PlotMixin." 28 | + f" It should have the following columns: '{list(PlotTableRow.model_fields.keys())}'" 29 | ) 30 | raise FileNotFoundError(message) 31 | 32 | 33 | def read_plot_config_from_list(plot_config: list[PlotTableRow]) -> list[PlotTableRow]: 34 | """Read plot config from a list. Validates whether the elements are of correct type.""" 35 | if not isinstance(plot_config, list): 36 | raise TypeError(f"Pass a list of PlotTableRow elements, not a {type(plot_config)}") 37 | for plot_table_row in plot_config: 38 | if not isinstance(plot_table_row, PlotTableRow): 39 | raise TypeError( 40 | "Each element in the passed plot table should be of type 'PlotTableRow'" 41 | ) 42 | return plot_config 43 | 44 | 45 | def get_plot_config( 46 | plot_table_file=None, plot_config_list=None, read_from="csv_table" 47 | ) -> list[PlotTableRow]: 48 | """Get plot config rows.""" 49 | if read_from == "csv_table": 50 | return read_plot_config_from_csv(plot_table_file) 51 | if read_from == "passed_list": 52 | return read_plot_config_from_list(plot_config_list) 53 | raise ValueError("PlotMixin should either read from 'csv_table' or 'passed_list'") 54 | -------------------------------------------------------------------------------- /tests/optimization/test_plot_goals_mixin.py: -------------------------------------------------------------------------------- 1 | """Tests for goal-plotting functionalities.""" 2 | 3 | import unittest 4 | 5 | from rtctools_interface.optimization.base_optimization_problem import ( 6 | BaseOptimizationProblem, 7 | ) 8 | from rtctools_interface.optimization.plot_mixin import PlotMixin 9 | from tests.utils.get_test import get_test_data 10 | 11 | 12 | class BaseOptimizationProblemPlotting(PlotMixin, BaseOptimizationProblem): 13 | # Ignore too many ancestors, since the use of mixin classes is how rtc-tools is set up. 14 | # pylint: disable=too-many-ancestors 15 | """Optimization problem with plotting functionalities.""" 16 | 17 | def __init__( 18 | self, 19 | plot_table_file, 20 | goal_table_file, 21 | **kwargs, 22 | ): 23 | self.plot_table_file = plot_table_file 24 | super().__init__(goal_table_file=goal_table_file, **kwargs) 25 | 26 | 27 | class TestPlotMixin(unittest.TestCase): 28 | """Test for goal-plotting functionalities.""" 29 | 30 | def run_test(self, test, plotting_library): 31 | """Solve an optimization problem.""" 32 | test_data = get_test_data(test, optimization=True) 33 | problem = BaseOptimizationProblemPlotting( 34 | goal_table_file=test_data["goals_file"], 35 | plot_table_file=test_data["plot_table_file"], 36 | model_folder=test_data["model_folder"], 37 | model_name=test_data["model_name"], 38 | input_folder=test_data["model_input_folder"], 39 | output_folder=test_data["output_folder"], 40 | plotting_library=plotting_library, 41 | ) 42 | problem.optimize() 43 | 44 | def test_plot_goals_mixin_matplotlib(self): 45 | """Solve several optimization problems.""" 46 | for test in [ 47 | "basic", 48 | "target_bounds_as_parameters", 49 | "target_bounds_as_timeseries", 50 | ]: 51 | self.run_test(test, plotting_library="matplotlib") 52 | 53 | def test_plot_goals_mixin_plotly(self): 54 | """Solve several optimization problems.""" 55 | for test in [ 56 | "basic", 57 | "target_bounds_as_parameters", 58 | "target_bounds_as_timeseries", 59 | ]: 60 | self.run_test(test, plotting_library="plotly") 61 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/plot_mixin.py: -------------------------------------------------------------------------------- 1 | """Mixin to store all required data for plotting. Can also call the plot function.""" 2 | 3 | import logging 4 | 5 | from rtctools_interface.optimization.base_goal import BaseGoal 6 | from rtctools_interface.optimization.helpers.statistics_mixin import StatisticsMixin 7 | from rtctools_interface.plotting.plot_tools import ( 8 | create_plot_each_priority, 9 | create_plot_final_results, 10 | ) 11 | from rtctools_interface.utils.results_collection import PlottingBaseMixin 12 | 13 | logger = logging.getLogger("rtctools") 14 | 15 | 16 | class PlotMixin(PlottingBaseMixin, StatisticsMixin): 17 | """ 18 | Class for plotting results. 19 | """ 20 | 21 | optimization_problem = True 22 | 23 | def priority_completed(self, priority: int) -> None: 24 | """Store priority-dependent results required for plotting.""" 25 | timeseries_data = self.collect_timeseries_data( 26 | list(set(self.custom_variables + self.state_variables)) 27 | ) 28 | to_store = {"timeseries_data": timeseries_data, "priority": priority} 29 | self._intermediate_results.append(to_store) 30 | super().priority_completed(priority) 31 | 32 | def post(self): 33 | """Tasks after optimizing. Creates a plot for for each priority.""" 34 | super().post() 35 | 36 | if self.solver_stats["success"]: 37 | base_goals = [ 38 | goal.get_goal_config() 39 | for goal in self.goals() + self.path_goals() 40 | if isinstance(goal, BaseGoal) 41 | ] 42 | current_run = self.create_plot_data_and_config(base_goals) 43 | # Cache results, such that in a next run they can be used for comparison 44 | self._store_current_results(self._cache_folder, current_run) 45 | 46 | # Create the plots 47 | plot_data = {} 48 | if self.plot_results_each_priority: 49 | plot_data = plot_data | create_plot_each_priority( 50 | current_run, plotting_library=self.plotting_library 51 | ) 52 | 53 | if self.plot_final_results: 54 | plot_data = plot_data | create_plot_final_results( 55 | current_run, self._previous_run, plotting_library=self.plotting_library 56 | ) 57 | -------------------------------------------------------------------------------- /rtctools_interface/utils/serialization.py: -------------------------------------------------------------------------------- 1 | """Methods to serialize and deserialize the PlotDataAndConfig objects.""" 2 | 3 | import datetime 4 | import json 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import numpy as np 9 | import pandas as pd 10 | 11 | from rtctools_interface.utils.plot_table_schema import PlotTableRow 12 | 13 | 14 | def custom_encoder(obj): 15 | """Custom JSON encoder for types not supported by default.""" 16 | if isinstance(obj, (datetime.datetime, datetime.date)): 17 | return { 18 | "__type__": "datetime" if isinstance(obj, datetime.datetime) else "date", 19 | "value": obj.isoformat(), 20 | } 21 | if isinstance(obj, np.ndarray): 22 | return {"__type__": "ndarray", "data": obj.tolist()} 23 | if isinstance(obj, Path): 24 | return {"__type__": "path", "value": str(obj)} 25 | if isinstance(obj, pd.DataFrame): 26 | return {"__type__": "pandas_dataframe", "value": obj.to_json()} 27 | if isinstance(obj, PlotTableRow): 28 | return {"__type__": "plot_table_row", "value": obj.model_dump()} 29 | if hasattr(obj, "dict"): 30 | return obj.dict() 31 | raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") 32 | 33 | 34 | def serialize(object_to_serialize: Any) -> str: 35 | """Serialize an object to a JSON string.""" 36 | return json.dumps(object_to_serialize, default=custom_encoder) 37 | 38 | 39 | def custom_decoder(dct: Any) -> Any: 40 | """Custom JSON decoder for types not supported by default.""" 41 | # pylint: disable=too-many-return-statements 42 | if isinstance(dct, dict): 43 | for key, value in dct.items(): 44 | dct[key] = custom_decoder(value) # Recursively process each value 45 | if dct.get("__type__") == "datetime": 46 | return datetime.datetime.fromisoformat(dct["value"]) 47 | if dct.get("__type__") == "date": 48 | return datetime.date.fromisoformat(dct["value"]) 49 | if dct.get("__type__") == "ndarray": 50 | return np.array(dct["data"]) 51 | if dct.get("__type__") == "path" and dct["value"]: 52 | return Path(dct["value"]) 53 | if dct.get("__type__") == "pandas_dataframe": 54 | return pd.read_json(dct["value"]) 55 | if dct.get("__type__") == "plot_table_row": 56 | return PlotTableRow(**dct["value"]) 57 | return dct 58 | if isinstance(dct, list): 59 | return [custom_decoder(item) for item in dct] # Recursively process each item in the list 60 | return dct 61 | 62 | 63 | def deserialize(serialized_str: str) -> dict: 64 | """Deserialize the JSON string.""" 65 | return json.loads(serialized_str, object_hook=custom_decoder) 66 | -------------------------------------------------------------------------------- /tests/closed_loop/test_optimization_ranges.py: -------------------------------------------------------------------------------- 1 | """Tests for getting optimization periods.""" 2 | 3 | import unittest 4 | from datetime import datetime, timedelta 5 | 6 | import rtctools_interface.closed_loop.optimization_ranges as opt_ranges 7 | 8 | 9 | class TestGetOptimizationPeriods(unittest.TestCase): 10 | """Test calculating optimization periods.""" 11 | 12 | def check_ranges( 13 | self, 14 | ranges: list[tuple[datetime, datetime]], 15 | expected_ranges: list[tuple[datetime, datetime]], 16 | ): 17 | """Check if two lists of ranges are the same.""" 18 | n_exp_ranges = len(expected_ranges) 19 | n_ranges = len(ranges) 20 | self.assertEqual(n_ranges, n_exp_ranges) 21 | for i_period in range(n_ranges): 22 | exp_start, exp_end = expected_ranges[i_period] 23 | start, end = expected_ranges[i_period] 24 | self.assertEqual(start, exp_start) 25 | self.assertEqual(end, exp_end) 26 | 27 | def test_get_optimization_ranges(self): 28 | """Test get_optimization_ranges.""" 29 | model_times = [ 30 | datetime(2024, 1, 1), 31 | datetime(2024, 1, 2), 32 | datetime(2024, 1, 4), 33 | datetime(2024, 1, 5), 34 | datetime(2024, 1, 7), 35 | datetime(2024, 1, 8), 36 | datetime(2024, 1, 10), 37 | ] 38 | start_time = datetime(2024, 1, 2) 39 | forecast_timestep = timedelta(days=2) 40 | optimization_period = timedelta(days=5) 41 | ranges = opt_ranges.get_optimization_ranges( 42 | model_times=model_times, 43 | start_time=start_time, 44 | forecast_timestep=forecast_timestep, 45 | optimization_period=optimization_period, 46 | ) 47 | expected_ranges = [ 48 | (datetime(2024, 1, 2), datetime(2024, 1, 7)), 49 | (datetime(2024, 1, 4), datetime(2024, 1, 8)), 50 | (datetime(2024, 1, 5), datetime(2024, 1, 10)), 51 | ] 52 | self.check_ranges(ranges, expected_ranges) 53 | 54 | def test_round_datetime_ranges_to_days(self): 55 | """Test round_datetime_ranges_to_days.""" 56 | ranges = [ 57 | (datetime(2020, 1, 1), datetime(2020, 1, 2)), 58 | (datetime(2020, 1, 2), datetime(2020, 1, 2)), 59 | (datetime(2020, 1, 2, 12), datetime(2020, 1, 5, 23)), 60 | ] 61 | rounded_ranges = opt_ranges.round_datetime_ranges_to_days(ranges) 62 | expected_ranges = [ 63 | (datetime(2020, 1, 1), datetime(2020, 1, 2, 23, 59, 59, 999999)), 64 | (datetime(2020, 1, 2, 12), datetime(2020, 1, 5, 23, 59, 59, 999999)), 65 | ] 66 | self.check_ranges(rounded_ranges, expected_ranges) 67 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/model/Example.mo: -------------------------------------------------------------------------------- 1 | // This example model is a modified version of the goal_programming example model of rtc-tools: https://gitlab.com/deltares/rtc-tools 2 | model Example 3 | // Declare Model Elements 4 | Deltares.ChannelFlow.Hydraulic.Storage.Linear storage(A=1.0e6, H_b=0.0, HQ.H(min=0.0, max=0.5)) annotation(Placement(visible = true, transformation(origin = {32.022, -0}, extent = {{-10, -10}, {10, 10}}, rotation = -270))); 5 | Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge discharge annotation(Placement(visible = true, transformation(origin = {60, -0}, extent = {{10, -10}, {-10, 10}}, rotation = 270))); 6 | Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level level annotation(Placement(visible = true, transformation(origin = {-52.7, 0}, extent = {{-10, -10}, {10, 10}}, rotation = -270))); 7 | Deltares.ChannelFlow.Hydraulic.Structures.Pump pump annotation(Placement(visible = true, transformation(origin = {0, -20}, extent = {{10, -10}, {-10, 10}}, rotation = 0))); 8 | Deltares.ChannelFlow.Hydraulic.Structures.Pump orifice annotation(Placement(visible = true, transformation(origin = {0, 20}, extent = {{10, -10}, {-10, 10}}, rotation = 0))); 9 | 10 | // Define Input/Output Variables and set them equal to model variables 11 | input Modelica.SIunits.VolumeFlowRate Q_pump(fixed=false, min=0.0, max=7.0) = pump.Q; 12 | input Boolean is_downhill; 13 | input Modelica.SIunits.VolumeFlowRate Q_in(fixed=true) = discharge.Q; 14 | input Modelica.SIunits.Position H_sea(fixed=true) = level.H; 15 | input Modelica.SIunits.VolumeFlowRate Q_orifice(fixed=false, min=0.0, max=10.0) = orifice.Q; 16 | output Modelica.SIunits.Position storage_level = storage.HQ.H; 17 | output Modelica.SIunits.Position sea_level = level.H; 18 | equation 19 | // Connect Model Elements 20 | connect(orifice.HQDown, level.HQ) annotation(Line(visible = true, origin = {-33.025, 10}, points = {{25.025, 10}, {-6.675, 10}, {-6.675, -10}, {-11.675, -10}}, color = {0, 0, 255})); 21 | connect(storage.HQ, orifice.HQUp) annotation(Line(visible = true, origin = {43.737, 48.702}, points = {{-3.715, -48.702}, {-3.737, -28.702}, {-35.737, -28.702}}, color = {0, 0, 255})); 22 | connect(storage.HQ, pump.HQUp) annotation(Line(visible = true, origin = {4.669, -31.115}, points = {{35.353, 31.115}, {35.331, 11.115}, {3.331, 11.115}}, color = {0, 0, 255})); 23 | connect(discharge.HQ, storage.HQ) annotation(Line(visible = true, origin = {46.011, -0}, points = {{5.989, 0}, {-5.989, -0}}, color = {0, 0, 255})); 24 | connect(pump.HQDown, level.HQ) annotation(Line(visible = true, origin = {-33.025, -10}, points = {{25.025, -10}, {-6.675, -10}, {-6.675, 10}, {-11.675, 10}}, color = {0, 0, 255})); 25 | annotation(Diagram(coordinateSystem(extent = {{-148.5, -105}, {148.5, 105}}, preserveAspectRatio = true, initialScale = 0.1, grid = {5, 5}))); 26 | end Example; 27 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/model/Example.mo: -------------------------------------------------------------------------------- 1 | // This example model is a modified version of the goal_programming example model of rtc-tools: https://gitlab.com/deltares/rtc-tools 2 | model Example 3 | // Declare Model Elements 4 | Deltares.ChannelFlow.Hydraulic.Storage.Linear storage(A=1.0e6, H_b=0.0, HQ.H(min=0.0, max=0.5)) annotation(Placement(visible = true, transformation(origin = {32.022, -0}, extent = {{-10, -10}, {10, 10}}, rotation = -270))); 5 | Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge discharge annotation(Placement(visible = true, transformation(origin = {60, -0}, extent = {{10, -10}, {-10, 10}}, rotation = 270))); 6 | Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level level annotation(Placement(visible = true, transformation(origin = {-52.7, 0}, extent = {{-10, -10}, {10, 10}}, rotation = -270))); 7 | Deltares.ChannelFlow.Hydraulic.Structures.Pump pump annotation(Placement(visible = true, transformation(origin = {0, -20}, extent = {{10, -10}, {-10, 10}}, rotation = 0))); 8 | Deltares.ChannelFlow.Hydraulic.Structures.Pump orifice annotation(Placement(visible = true, transformation(origin = {0, 20}, extent = {{10, -10}, {-10, 10}}, rotation = 0))); 9 | 10 | // Define Input/Output Variables and set them equal to model variables 11 | input Modelica.SIunits.VolumeFlowRate Q_pump(fixed=false, min=0.0, max=7.0) = pump.Q; 12 | input Boolean is_downhill; 13 | input Modelica.SIunits.VolumeFlowRate Q_in(fixed=true) = discharge.Q; 14 | input Modelica.SIunits.Position H_sea(fixed=true) = level.H; 15 | input Modelica.SIunits.VolumeFlowRate Q_orifice(fixed=false, min=0.0, max=10.0) = orifice.Q; 16 | output Modelica.SIunits.Position storage_level = storage.HQ.H; 17 | output Modelica.SIunits.Position sea_level = level.H; 18 | equation 19 | // Connect Model Elements 20 | connect(orifice.HQDown, level.HQ) annotation(Line(visible = true, origin = {-33.025, 10}, points = {{25.025, 10}, {-6.675, 10}, {-6.675, -10}, {-11.675, -10}}, color = {0, 0, 255})); 21 | connect(storage.HQ, orifice.HQUp) annotation(Line(visible = true, origin = {43.737, 48.702}, points = {{-3.715, -48.702}, {-3.737, -28.702}, {-35.737, -28.702}}, color = {0, 0, 255})); 22 | connect(storage.HQ, pump.HQUp) annotation(Line(visible = true, origin = {4.669, -31.115}, points = {{35.353, 31.115}, {35.331, 11.115}, {3.331, 11.115}}, color = {0, 0, 255})); 23 | connect(discharge.HQ, storage.HQ) annotation(Line(visible = true, origin = {46.011, -0}, points = {{5.989, 0}, {-5.989, -0}}, color = {0, 0, 255})); 24 | connect(pump.HQDown, level.HQ) annotation(Line(visible = true, origin = {-33.025, -10}, points = {{25.025, -10}, {-6.675, -10}, {-6.675, 10}, {-11.675, 10}}, color = {0, 0, 255})); 25 | annotation(Diagram(coordinateSystem(extent = {{-148.5, -105}, {148.5, 105}}, preserveAspectRatio = true, initialScale = 0.1, grid = {5, 5}))); 26 | end Example; 27 | -------------------------------------------------------------------------------- /rtctools_interface/simulation/plot_mixin.py: -------------------------------------------------------------------------------- 1 | """Mixin to store all required data for plotting. Can also call the plot function.""" 2 | 3 | import logging 4 | 5 | import numpy as np 6 | 7 | from rtctools_interface.plotting.plot_tools import create_plot_final_results 8 | from rtctools_interface.utils.results_collection import PlottingBaseMixin 9 | 10 | logger = logging.getLogger("rtctools") 11 | 12 | 13 | class PlotMixin(PlottingBaseMixin): 14 | """ 15 | Class for plotting results based on the plot_table. 16 | """ 17 | 18 | optimization_problem = False 19 | _manual_extracted_states: dict[str, list] = {} 20 | 21 | def manual_extraction_from_state_vector(self): 22 | for variable in self.custom_variables: 23 | try: 24 | self._manual_extracted_states[variable].append(self.get_var(variable)) 25 | except KeyError: 26 | logger.debug(f"Variable {variable} not found in output of model.") 27 | 28 | def initialize(self, *args, **kwargs): 29 | super().initialize(*args, **kwargs) 30 | self._manual_extracted_states = {variable: [] for variable in self.custom_variables} 31 | self.manual_extraction_from_state_vector() 32 | 33 | def update(self, dt): 34 | super().update(dt) 35 | self.manual_extraction_from_state_vector() 36 | 37 | def post(self): 38 | """Tasks after optimizing.""" 39 | super().post() 40 | 41 | # find empty arrays in self._manual_extracted_states 42 | # for these variables try to use self.get_timeseries(variable) 43 | for variable in self.custom_variables: 44 | if ( 45 | not self._manual_extracted_states[variable] 46 | or len(self._manual_extracted_states[variable]) == 0 47 | ): 48 | logger.debug(f"Variable {variable} has empty data collected.") 49 | try: 50 | self._manual_extracted_states[variable] = self.get_timeseries(variable) 51 | except KeyError: 52 | logger.warning(f"Variable {variable} not found in output of model.") 53 | 54 | timeseries_data = self.collect_timeseries_data(self.custom_variables) 55 | self._intermediate_results.append({"timeseries_data": timeseries_data, "priority": 0}) 56 | current_run = self.create_plot_data_and_config([]) 57 | self._store_current_results(self._cache_folder, current_run) 58 | 59 | if self.plot_final_results: 60 | create_plot_final_results( 61 | current_run, self._previous_run, plotting_library=self.plotting_library 62 | ) 63 | 64 | def collect_timeseries_data(self, all_variables_to_store: list[str]) -> dict[str, np.ndarray]: 65 | return { 66 | variable: np.array(self._manual_extracted_states[variable]) 67 | for variable in all_variables_to_store 68 | } 69 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/config.py: -------------------------------------------------------------------------------- 1 | """Module for configuring a closed-loop optimization problem.""" 2 | 3 | from datetime import timedelta 4 | from pathlib import Path 5 | 6 | 7 | class ClosedLoopConfig: 8 | """Configuration of a closed-loop optimization problem.""" 9 | 10 | def __init__( 11 | self, 12 | file: Path = None, 13 | round_to_dates: bool = False, 14 | ): 15 | """ 16 | Create a configuration for closed-loop optimization. 17 | 18 | :param file: CSV file with two columns 'start_date' and 'end_date'. 19 | Each row indicates a time range for which to optimize a given problem. 20 | Note: 21 | * The start time of the first time range should coincide with the 22 | start time of the input timeseries. 23 | * The start time of the next time range should be less or equal 24 | to the end time of the current time range. 25 | :param round_to_dates: If true, the start time and end time of each time range 26 | is rounded to just the date. 27 | In particular, the start time is rounded to the start of the day 28 | and the end time is rounded to the end of the day. 29 | """ 30 | if file is not None: 31 | file = Path(file).resolve() 32 | self._file = file 33 | self._forecast_timestep: timedelta | None = None 34 | self._optimization_period: timedelta | None = None 35 | self.round_to_dates = round_to_dates 36 | 37 | @classmethod 38 | def from_fixed_periods( 39 | cls, 40 | forecast_timestep: timedelta, 41 | optimization_period: timedelta, 42 | round_to_dates: bool = False, 43 | ): 44 | """ 45 | Create a closed loop configuration based on fixed periods. 46 | 47 | :param forecast_timestep: The time between the start of each optimization time range. 48 | :param optimization_period: the duration of each optimization time range 49 | """ 50 | if forecast_timestep > optimization_period: 51 | raise ValueError( 52 | f"The forecast timestep ({forecast_timestep}) should be less than or equal to" 53 | f" the optimization period ({optimization_period})." 54 | ) 55 | config = cls() 56 | config._forecast_timestep = forecast_timestep 57 | config._optimization_period = optimization_period 58 | config.round_to_dates = round_to_dates 59 | return config 60 | 61 | @property 62 | def file(self): 63 | """Get the file that defines the closed-loop periods.""" 64 | return self._file 65 | 66 | @property 67 | def forecast_timestep(self): 68 | """Get the forecast timestep of the closed-loop periods.""" 69 | return self._forecast_timestep 70 | 71 | @property 72 | def optimization_period(self): 73 | """Get the optimization period of the closed-loop periods.""" 74 | return self._optimization_period 75 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/helpers/statistics_mixin.py: -------------------------------------------------------------------------------- 1 | """Base mixin for retrieving particular stats for goals and plotting.""" 2 | 3 | import logging 4 | 5 | import numpy as np 6 | 7 | from rtctools_interface.optimization.base_goal import BaseGoal 8 | from rtctools_interface.utils.type_definitions import TargetDict 9 | 10 | logger = logging.getLogger("rtctools") 11 | 12 | 13 | class StatisticsMixin: 14 | # TODO: remove pylint disable below once we have more public functions. 15 | # pylint: disable=too-few-public-methods 16 | """A mixin class providing methods for collecting data and statistics from optimization results, 17 | useful for solution performance analysis.""" 18 | 19 | def collect_range_target_values( 20 | self, 21 | base_goals: list[BaseGoal], 22 | ) -> dict[str, TargetDict]: 23 | """For the goals with targets, collect the actual timeseries with these targets.""" 24 | target_series: dict[str, TargetDict] = {} 25 | for goal in base_goals: 26 | if goal.goal_type in ["range", "range_rate_of_change"]: 27 | target_dict = self.collect_range_target_values_from_basegoal(goal) 28 | target_series[str(goal.goal_id)] = target_dict 29 | return target_series 30 | 31 | def collect_range_target_values_from_basegoal(self, goal: BaseGoal) -> TargetDict: 32 | """Collect the target timeseries for a single basegoal.""" 33 | t = self.times() 34 | 35 | def get_parameter_ranges(goal) -> tuple[np.ndarray, np.ndarray]: 36 | target_min = np.full_like(t, 1) * float(goal.target_min) 37 | target_max = np.full_like(t, 1) * float(goal.target_max) 38 | return target_min, target_max 39 | 40 | def get_value_ranges(goal) -> tuple[np.ndarray, np.ndarray]: 41 | target_min = np.full_like(t, 1) * float(goal.target_min) 42 | target_max = np.full_like(t, 1) * float(goal.target_max) 43 | return target_min, target_max 44 | 45 | def get_timeseries_ranges(goal) -> tuple[np.ndarray, np.ndarray]: 46 | try: 47 | target_min = goal.target_min.values 48 | except AttributeError: 49 | target_min = goal.target_min 50 | try: 51 | target_max = goal.target_max.values 52 | except AttributeError: 53 | target_max = goal.target_max 54 | return target_min, target_max 55 | 56 | supported_goal_types = ["range", "range_rate_of_change"] 57 | if goal.goal_type in supported_goal_types: 58 | if goal.target_data_type == "parameter": 59 | target_min, target_max = get_parameter_ranges(goal) 60 | elif goal.target_data_type == "value": 61 | target_min, target_max = get_value_ranges(goal) 62 | elif goal.target_data_type == "timeseries": 63 | target_min, target_max = get_timeseries_ranges(goal) 64 | else: 65 | message = f"Target type {goal.target_data_type} not known for goal {goal.goal_id}." 66 | logger.error(message) 67 | raise ValueError(message) 68 | else: 69 | message = f"Goal type {goal.goal_type} not supported for target collection." 70 | logger.error(message) 71 | raise ValueError(message) 72 | target_dict: TargetDict = {"target_min": target_min, "target_max": target_max} 73 | return target_dict 74 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/read_goals.py: -------------------------------------------------------------------------------- 1 | """Module for reading goals from a csv file.""" 2 | 3 | import pandas as pd 4 | 5 | from rtctools_interface.optimization.goal_table_schema import ( 6 | GOAL_TYPES, 7 | NON_PATH_GOALS, 8 | PATH_GOALS, 9 | BaseGoalModel, 10 | MaximizationGoalModel, 11 | MinimizationGoalModel, 12 | RangeGoalModel, 13 | RangeRateOfChangeGoalModel, 14 | ) 15 | 16 | 17 | def goal_table_checks(goal_table): 18 | """Validate input goal table.""" 19 | if "goal_type" not in goal_table: 20 | raise ValueError("Goal type column not in goal table.") 21 | if "active" not in goal_table: 22 | raise ValueError("Active column not in goal table.") 23 | for _, row in goal_table.iterrows(): 24 | if row["goal_type"] not in GOAL_TYPES.keys(): 25 | raise ValueError( 26 | f"Goal of type {row['goal_type']} is not allowed. Allowed are {GOAL_TYPES.keys()}" 27 | ) 28 | if int(row["active"]) not in [0, 1]: 29 | raise ValueError("Value in active column should be either 0 or 1.") 30 | 31 | 32 | def validate_goal_list(goal_list): 33 | """Validate list of goals on correct type and uniqueness of id's""" 34 | ids = [goal.goal_id for goal in goal_list] 35 | if len(ids) != len(set(ids)): 36 | raise ValueError("ID's in goal generator table should be unique!") 37 | 38 | 39 | def read_goals_from_csv( 40 | file, 41 | ) -> list[ 42 | RangeGoalModel | RangeRateOfChangeGoalModel | MinimizationGoalModel | MaximizationGoalModel 43 | ]: 44 | """Read goals from csv file and validate values.""" 45 | raw_goal_table = pd.read_csv(file, sep=",") 46 | goal_table_checks(raw_goal_table) 47 | 48 | parsed_goals = [] 49 | for _, row in raw_goal_table.iterrows(): 50 | if int(row["active"]) == 1: 51 | parsed_goals.append(GOAL_TYPES[row["goal_type"]](**row)) 52 | return parsed_goals 53 | 54 | 55 | def read_goals_from_list( 56 | goals_to_generate, 57 | ) -> list[ 58 | RangeGoalModel | RangeRateOfChangeGoalModel | MinimizationGoalModel | MaximizationGoalModel 59 | ]: 60 | """Read goals from a list. Validates whether the goals are of correct type.""" 61 | if not isinstance(goals_to_generate, list): 62 | raise TypeError(f"Pass a list of goal elements, not a {type(goals_to_generate)}") 63 | for base_goal in goals_to_generate: 64 | if not isinstance(base_goal, BaseGoalModel): 65 | raise TypeError( 66 | "Each element in the list of goals to generate should be a child of BaseGoalModel" 67 | ) 68 | active_goals = [] 69 | for goal in goals_to_generate: 70 | if int(goal.active) == 1: 71 | active_goals.append(goal) 72 | return active_goals 73 | 74 | 75 | def read_goals( 76 | file=None, path_goal: bool = True, read_from="csv_table", goals_to_generate=None 77 | ) -> list[ 78 | RangeGoalModel | RangeRateOfChangeGoalModel | MinimizationGoalModel | MaximizationGoalModel 79 | ]: 80 | """Read goals from a csv file 81 | 82 | Returns either only the path_goals or only the non_path goals. 83 | In either case only the active goals. 84 | """ 85 | if read_from == "csv_table": 86 | parsed_goals = read_goals_from_csv(file) 87 | elif read_from == "passed_list": 88 | parsed_goals = read_goals_from_list(goals_to_generate) 89 | else: 90 | raise ValueError("GoalGeneratorMixin should either read from 'csv_table' or 'passed_list'") 91 | validate_goal_list(parsed_goals) 92 | requested_goal_types = PATH_GOALS.keys() if path_goal else NON_PATH_GOALS.keys() 93 | return [goal for goal in parsed_goals if goal.goal_type in requested_goal_types] 94 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/goal_table_schema.py: -------------------------------------------------------------------------------- 1 | """Schema for the goal_table.""" 2 | 3 | from typing import Literal 4 | 5 | import numpy as np 6 | import pandas as pd 7 | from pydantic import BaseModel, Field, field_validator, model_validator 8 | 9 | 10 | class BaseGoalModel(BaseModel): 11 | """BaseModel for a goal.""" 12 | 13 | goal_id: int | str = Field(..., alias="id") 14 | active: Literal[0, 1] 15 | state: str 16 | goal_type: str 17 | priority: int 18 | function_nominal: float = np.nan 19 | weight: float = np.nan 20 | order: float = np.nan 21 | 22 | @field_validator("goal_type") 23 | @classmethod 24 | def validate_goal_type(cls, value): 25 | """Check whether the supplied goal type is supported""" 26 | if value not in GOAL_TYPES.keys(): 27 | raise ValueError( 28 | f"Invalid goal_type '{value}'. Allowed values are {GOAL_TYPES.keys()}." 29 | ) 30 | return value 31 | 32 | @field_validator("goal_id", "active") 33 | @classmethod 34 | def convert_to_int(cls, value): 35 | """Convert value to integer if possible.""" 36 | try: 37 | return int(value) 38 | except (ValueError, TypeError): 39 | return value 40 | 41 | def get(self, attribute_name, default=None): 42 | """Similar functionality as dict-get method.""" 43 | try: 44 | return getattr(self, attribute_name) 45 | except AttributeError: 46 | return default 47 | 48 | 49 | class MaximizationGoalModel(BaseGoalModel): 50 | """Model for a minimization and maximization goal.""" 51 | 52 | 53 | MinimizationGoalModel = MaximizationGoalModel 54 | 55 | 56 | class RangeGoalModel(BaseGoalModel): 57 | """Model for a range goal.""" 58 | 59 | target_data_type: str 60 | function_min: float = np.nan 61 | function_max: float = np.nan 62 | target_min: float | str = np.nan 63 | target_max: float | str = np.nan 64 | 65 | @field_validator("target_min", "target_max") 66 | @classmethod 67 | def convert_to_float(cls, value): 68 | """Convert value to float if possible.""" 69 | try: 70 | return float(value) 71 | except (ValueError, TypeError): 72 | return value 73 | 74 | @model_validator(mode="after") 75 | def validate_targets(self): 76 | """Check whether required columns for the range_goal are available.""" 77 | try: 78 | assert not (pd.isna(self.target_min) and pd.isna(self.target_max)) 79 | except AssertionError as exc: 80 | raise ValueError( 81 | "For a range goal, at least one of target_min and target_max should be set." 82 | ) from exc 83 | return self 84 | 85 | @model_validator(mode="after") 86 | def validate_target_type_and_value(self): 87 | """Check if target_min and target_max dtype correspond to target_data_type.""" 88 | try: 89 | if self.target_data_type == "value": 90 | assert isinstance(self.target_min, float) 91 | assert isinstance(self.target_max, float) 92 | elif self.target_data_type in ["parameter", "timeseries"]: 93 | assert isinstance(self.target_min, str) or pd.isna(self.target_min) 94 | assert isinstance(self.target_max, str) or pd.isna(self.target_max) 95 | except AssertionError as exc: 96 | raise ValueError( 97 | "The type in the target_min/target_max column does not " 98 | "correspond to the target_data_type." 99 | ) from exc 100 | return self 101 | 102 | 103 | class RangeRateOfChangeGoalModel(RangeGoalModel): 104 | """Model for a rate of change range goal.""" 105 | 106 | 107 | PATH_GOALS = { 108 | "minimization_path": MinimizationGoalModel, 109 | "maximization_path": MaximizationGoalModel, 110 | "range": RangeGoalModel, 111 | "range_rate_of_change": RangeRateOfChangeGoalModel, 112 | } 113 | NON_PATH_GOALS: dict = {} 114 | GOAL_TYPES = PATH_GOALS | NON_PATH_GOALS 115 | TARGET_DATA_TYPES = [ 116 | "value", 117 | "parameter", 118 | "timeseries", 119 | ] 120 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/goal_generator_mixin.py: -------------------------------------------------------------------------------- 1 | """Module for a basic optimization problem.""" 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | import pandas as pd 7 | 8 | from rtctools_interface.optimization.base_goal import BaseGoal 9 | from rtctools_interface.optimization.goal_performance_metrics import get_performance_metrics 10 | from rtctools_interface.optimization.helpers.statistics_mixin import StatisticsMixin 11 | from rtctools_interface.utils.read_goals_mixin import ReadGoalsMixin 12 | 13 | logger = logging.getLogger("rtctools") 14 | 15 | 16 | def write_performance_metrics( 17 | performance_metrics: dict[str, pd.DataFrame], output_path: str | Path 18 | ): 19 | """Write the performance metrics for each goal to a csv file.""" 20 | output_path = Path(output_path) / "performance_metrics" 21 | output_path.mkdir(parents=True, exist_ok=True) 22 | for goal_id, performance_metric_table in performance_metrics.items(): 23 | performance_metric_table.to_csv(output_path / f"{goal_id}.csv") 24 | 25 | 26 | class GoalGeneratorMixin(ReadGoalsMixin, StatisticsMixin): 27 | # TODO: remove pylint disable below once we have more public functions. 28 | # pylint: disable=too-few-public-methods 29 | """Add path goals as specified in the goal_table. 30 | 31 | By default, the mixin looks for the csv in the in the default input 32 | folder. One can also set the path to the goal_table_file manually 33 | with the `goal_table_file` class variable. 34 | """ 35 | 36 | calculate_performance_metrics = True 37 | 38 | def __init__(self, **kwargs): 39 | super().__init__(**kwargs) 40 | if not hasattr(self, "_all_goal_generator_goals"): 41 | goals_to_generate = kwargs.get("goals_to_generate", []) 42 | read_from = kwargs.get("read_goals_from", "csv_table") 43 | self.load_goals(read_from, goals_to_generate) 44 | if self.calculate_performance_metrics: 45 | # A dataframe for each goal defined by the goal generator 46 | self._performance_metrics = {} 47 | for goal in self._all_goal_generator_goals: 48 | self._performance_metrics[goal.goal_id] = pd.DataFrame() 49 | 50 | def path_goals(self): 51 | """Return the list of path goals.""" 52 | goals = super().path_goals() 53 | new_goals = self._goal_generator_path_goals 54 | if new_goals: 55 | goals = goals + [ 56 | BaseGoal(optimization_problem=self, **goal.__dict__) for goal in new_goals 57 | ] 58 | return goals 59 | 60 | def goals(self): 61 | """Return the list of goals.""" 62 | goals = super().goals() 63 | new_goals = self._goal_generator_non_path_goals 64 | if new_goals: 65 | goals = goals + [ 66 | BaseGoal(optimization_problem=self, **goal.__dict__) for goal in new_goals 67 | ] 68 | return goals 69 | 70 | def store_performance_metrics(self, label): 71 | """Calculate and store performance metrics.""" 72 | results = self.extract_results() 73 | goal_generator_goals = self._all_goal_generator_goals 74 | all_base_goals = [ 75 | goal for goal in self.goals() + self.path_goals() if isinstance(goal, BaseGoal) 76 | ] 77 | targets = self.collect_range_target_values(all_base_goals) 78 | for goal in goal_generator_goals: 79 | next_row = get_performance_metrics(results, goal, targets.get(str(goal.goal_id))) 80 | if next_row is not None: 81 | next_row.rename(label, inplace=True) 82 | self._performance_metrics[goal.goal_id] = pd.concat( 83 | [self._performance_metrics[goal.goal_id].T, next_row], axis=1 84 | ).T 85 | 86 | def priority_completed(self, priority): 87 | """Tasks after priority optimization.""" 88 | super().priority_completed(priority) 89 | if self.calculate_performance_metrics: 90 | self.store_performance_metrics(priority) 91 | 92 | def post(self): 93 | """Tasks after all optimization steps.""" 94 | super().post() 95 | if self.calculate_performance_metrics: 96 | self.store_performance_metrics("final_results") 97 | write_performance_metrics(self._performance_metrics, self._output_folder) 98 | 99 | def get_performance_metrics(self): 100 | """Get the plot data and config from the current run.""" 101 | return self._performance_metrics 102 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/goal_performance_metrics.py: -------------------------------------------------------------------------------- 1 | """This file contains functions to get performance metrics for the BaseGoal.""" 2 | 3 | import logging 4 | 5 | import numpy as np 6 | import pandas as pd 7 | 8 | from rtctools_interface.optimization.goal_table_schema import ( 9 | BaseGoalModel, 10 | MaximizationGoalModel, 11 | MinimizationGoalModel, 12 | RangeGoalModel, 13 | RangeRateOfChangeGoalModel, 14 | ) 15 | from rtctools_interface.utils.type_definitions import TargetDict 16 | 17 | logger = logging.getLogger("rtctools") 18 | 19 | ABS_TOL = 0.001 20 | 21 | 22 | def get_mean_absolute_percentual_difference(timeseries: np.ndarray) -> float: 23 | """Calculate the mean absolute percentual difference, ignoring entries where timeseries = 0.""" 24 | nonzero_indices = np.nonzero(timeseries) 25 | timeseries = timeseries[nonzero_indices] 26 | differences = np.diff(timeseries) 27 | if len(timeseries) <= 1: 28 | return 0 29 | mapd = np.mean(np.abs(differences / timeseries[:-1])) 30 | return mapd 31 | 32 | 33 | def get_absolute_sum_difference(timeseries: np.ndarray) -> float: 34 | """Calculate the mean of absolute first-order difference.""" 35 | if len(timeseries) <= 1: 36 | return 0 37 | mad = np.mean(np.abs(np.diff(timeseries))) 38 | return mad 39 | 40 | 41 | def get_max_difference(timeseries: np.ndarray) -> float: 42 | """Get maximum one step difference""" 43 | return max(np.diff(timeseries)) 44 | 45 | 46 | def get_basic_metrics(timeseries: np.ndarray) -> dict[str, float]: 47 | """Get general metrics applicable for each goal type.""" 48 | metrics = { 49 | "timeseries_sum": sum(timeseries), 50 | "timeseries_min": min(timeseries), 51 | "timeseries_max": max(timeseries), 52 | "timeseries_avg": np.mean(timeseries), 53 | "mean_absolute_percentual_difference": get_mean_absolute_percentual_difference(timeseries), 54 | "mean_absolute_difference": get_absolute_sum_difference(timeseries), 55 | "max_difference": get_max_difference(timeseries), 56 | } 57 | return metrics 58 | 59 | 60 | def performance_metrics_minmaximization( 61 | results: dict[str, np.ndarray], goal: MinimizationGoalModel 62 | ) -> pd.Series: 63 | """Get all relevant statistics for a min/maximization goal.""" 64 | state_timeseries = results[goal.state] 65 | metrics = get_basic_metrics(state_timeseries) 66 | return pd.Series(metrics) 67 | 68 | 69 | def get_range_percentual_exceedance( 70 | timeseries: np.ndarray, goal: RangeGoalModel, targets: TargetDict 71 | ) -> dict[str, float | None] | None: 72 | """Calculate percentage of timesteps in which target is exceeded""" 73 | if goal.goal_type not in ["range", "range_rate_of_change"]: 74 | below_target = None 75 | above_target = None 76 | else: 77 | below_target = float( 78 | sum(np.where(timeseries + ABS_TOL < targets["target_min"], 1, 0)) / len(timeseries) 79 | ) 80 | above_target = float( 81 | sum(np.where(timeseries - ABS_TOL > targets["target_max"], 1, 0)) / len(timeseries) 82 | ) 83 | return {"perc_below_target": below_target, "perc_above_target": above_target} 84 | 85 | 86 | def get_range_total_exceedance( 87 | timeseries: np.ndarray, goal: RangeGoalModel, targets: TargetDict 88 | ) -> dict[str, float | None] | None: 89 | """Calculate sum of absolute exceedances of the target""" 90 | if goal.goal_type not in ["range", "range_rate_of_change"]: 91 | below_target = None 92 | above_target = None 93 | else: 94 | below_target = float( 95 | sum( 96 | np.abs( 97 | np.where( 98 | timeseries < targets["target_min"], timeseries - targets["target_min"], 0 99 | ) 100 | ) 101 | ) 102 | ) 103 | above_target = float( 104 | sum( 105 | np.abs( 106 | np.where( 107 | timeseries > targets["target_max"], timeseries - targets["target_max"], 0 108 | ) 109 | ) 110 | ) 111 | ) 112 | return {"sum_below_target": below_target, "sum_above_target": above_target} 113 | 114 | 115 | def performance_metrics_range( 116 | results: dict[str, np.ndarray], goal: RangeGoalModel, targets: TargetDict 117 | ) -> pd.Series: 118 | """Get all relevant statistics for a range goal.""" 119 | metrics: dict = {} 120 | state_timeseries = results[goal.state] 121 | metrics = metrics | get_basic_metrics(state_timeseries) 122 | metrics = metrics | get_range_percentual_exceedance(state_timeseries, goal, targets) 123 | metrics = metrics | get_range_total_exceedance(state_timeseries, goal, targets) 124 | return pd.Series(metrics) 125 | 126 | 127 | def performance_metrics_rangerateofchange( 128 | results: dict[str, np.ndarray], goal: RangeGoalModel, _targets: TargetDict 129 | ) -> pd.Series: 130 | """Get all relevant statistics for a range-rate-of-change goal.""" 131 | metrics: dict[str, float | None] = {} 132 | state_timeseries = results[goal.state] 133 | metrics = metrics | get_basic_metrics(state_timeseries) 134 | return pd.Series(metrics) 135 | 136 | 137 | def get_performance_metrics(results, goal: BaseGoalModel, targets: TargetDict) -> pd.Series | None: 138 | """Returns a series with performance metrics for each goal.""" 139 | if type(goal) in [MinimizationGoalModel, MaximizationGoalModel]: # pylint: disable=unidiomatic-typecheck 140 | return performance_metrics_minmaximization(results, goal) 141 | if type(goal) in [RangeGoalModel]: 142 | return performance_metrics_range(results, goal, targets) 143 | if type(goal) in [RangeRateOfChangeGoalModel]: 144 | return performance_metrics_rangerateofchange(results, goal, targets) 145 | logger.info("No performance metrics are implemented for goal of type: %s", str(type(goal))) 146 | return None 147 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/src/example.py: -------------------------------------------------------------------------------- 1 | """This example model is a modified version of the goal_programming example model of 2 | rtc-tools: https://gitlab.com/deltares/rtc-tools""" 3 | 4 | import numpy as np 5 | from rtctools.optimization.collocated_integrated_optimization_problem import ( 6 | CollocatedIntegratedOptimizationProblem, 7 | ) 8 | from rtctools.optimization.goal_programming_mixin import Goal, GoalProgrammingMixin, StateGoal 9 | from rtctools.optimization.modelica_mixin import ModelicaMixin 10 | from rtctools.optimization.pi_mixin import PIMixin 11 | 12 | from rtctools_interface.closed_loop.runner import run_optimization_problem_closed_loop 13 | 14 | 15 | class WaterLevelRangeGoal(StateGoal): 16 | # Applying a state goal to every time step is easily done by defining a goal 17 | # that inherits StateGoal. StateGoal is a helper class that uses the state 18 | # to determine the function, function range, and function nominal 19 | # automatically. 20 | state = "storage.HQ.H" 21 | # One goal can introduce a single or two constraints (min and/or max). Our 22 | # target water level range is 0.43 - 0.44. We might not always be able to 23 | # realize this, but we want to try. 24 | target_min = 0.43 25 | target_max = 0.44 26 | 27 | # Because we want to satisfy our water level target first, this has a 28 | # higher priority (=lower number). 29 | priority = 1 30 | 31 | 32 | class MinimizeQpumpGoal(Goal): 33 | # This goal does not use a helper class, so we have to define the function 34 | # method, range and nominal explicitly. We do not specify a target_min or 35 | # target_max in this class, so the goal programming mixin will try to 36 | # minimize the expression returned by the function method. 37 | def function(self, optimization_problem, ensemble_member): 38 | return optimization_problem.integral("Q_pump") 39 | 40 | # The nominal is used to scale the value returned by 41 | # the function method so that the value is on the order of 1. 42 | function_nominal = 100.0 43 | # The lower the number returned by this function, the higher the priority. 44 | priority = 2 45 | # The penalty variable is taken to the order'th power. 46 | order = 1 47 | 48 | 49 | class MinimizeChangeInQpumpGoal(Goal): 50 | # To reduce pump power cycles, we add a third goal to minimize changes in 51 | # Q_pump. This will be passed into the optimization problem as a path goal 52 | # because it is an an individual goal that should be applied at every time 53 | # step. 54 | def function(self, optimization_problem, ensemble_member): 55 | return optimization_problem.der("Q_pump") 56 | 57 | function_nominal = 5.0 58 | priority = 3 59 | # Default order is 2, but we want to be explicit 60 | order = 2 61 | 62 | 63 | class Example( 64 | GoalProgrammingMixin, PIMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem 65 | ): 66 | """ 67 | An introductory example to goal programming in RTC-Tools 68 | """ 69 | 70 | def path_constraints(self, ensemble_member): 71 | # We want to add a few hard constraints to our problem. The goal 72 | # programming mixin however also generates constraints (and objectives) 73 | # from on our goals, so we have to call super() here. 74 | constraints = super().path_constraints(ensemble_member) 75 | 76 | # Release through orifice downhill only. This constraint enforces the 77 | # fact that water only flows downhill 78 | constraints.append( 79 | (self.state("Q_orifice") + (1 - self.state("is_downhill")) * 10, 0.0, 10.0) 80 | ) 81 | 82 | # Make sure is_downhill is true only when the sea is lower than the 83 | # water level in the storage. 84 | M = 2 # The so-called "big-M" 85 | constraints.append( 86 | ( 87 | self.state("H_sea") 88 | - self.state("storage.HQ.H") 89 | - (1 - self.state("is_downhill")) * M, 90 | -np.inf, 91 | 0.0, 92 | ) 93 | ) 94 | constraints.append( 95 | ( 96 | self.state("H_sea") - self.state("storage.HQ.H") + self.state("is_downhill") * M, 97 | 0.0, 98 | np.inf, 99 | ) 100 | ) 101 | 102 | # Orifice flow constraint. Uses the equation: 103 | # Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5 104 | # Note that this equation is only valid for orifices that are submerged 105 | # units: description: 106 | w = 3.0 # m width of orifice 107 | d = 0.8 # m hight of orifice 108 | C = 1.0 # none orifice constant 109 | g = 9.8 # m/s^2 gravitational acceleration 110 | constraints.append( 111 | ( 112 | ((self.state("Q_orifice") / (w * C * d)) ** 2) / (2 * g) 113 | + self.state("orifice.HQDown.H") 114 | - self.state("orifice.HQUp.H") 115 | - M * (1 - self.state("is_downhill")), 116 | -np.inf, 117 | 0.0, 118 | ) 119 | ) 120 | 121 | return constraints 122 | 123 | def goals(self): 124 | return [MinimizeQpumpGoal()] 125 | 126 | def path_goals(self): 127 | # Sorting goals on priority is done in the goal programming mixin. We 128 | # do not have to worry about order here. 129 | return [WaterLevelRangeGoal(self), MinimizeChangeInQpumpGoal()] 130 | 131 | def pre(self): 132 | # Call super() class to not overwrite default behaviour 133 | super().pre() 134 | # We keep track of our intermediate results, so that we can print some 135 | # information about the progress of goals at the end of our run. 136 | self.intermediate_results = [] 137 | 138 | # Any solver options can be set here 139 | def solver_options(self): 140 | options = super().solver_options() 141 | solver = options["solver"] 142 | options[solver]["print_level"] = 1 143 | return options 144 | 145 | 146 | # Run 147 | if __name__ == "__main__": 148 | run_optimization_problem_closed_loop(Example) 149 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_csv/src/example.py: -------------------------------------------------------------------------------- 1 | """This example model is a modified version of the goal_programming example model of 2 | rtc-tools: https://gitlab.com/deltares/rtc-tools""" 3 | 4 | import numpy as np 5 | from rtctools.optimization.collocated_integrated_optimization_problem import ( 6 | CollocatedIntegratedOptimizationProblem, 7 | ) 8 | from rtctools.optimization.csv_mixin import CSVMixin 9 | from rtctools.optimization.goal_programming_mixin import Goal, GoalProgrammingMixin, StateGoal 10 | from rtctools.optimization.modelica_mixin import ModelicaMixin 11 | 12 | from rtctools_interface.closed_loop.runner import run_optimization_problem_closed_loop 13 | 14 | 15 | class WaterLevelRangeGoal(StateGoal): 16 | # Applying a state goal to every time step is easily done by defining a goal 17 | # that inherits StateGoal. StateGoal is a helper class that uses the state 18 | # to determine the function, function range, and function nominal 19 | # automatically. 20 | state = "storage.HQ.H" 21 | # One goal can introduce a single or two constraints (min and/or max). Our 22 | # target water level range is 0.43 - 0.44. We might not always be able to 23 | # realize this, but we want to try. 24 | target_min = 0.43 25 | target_max = 0.44 26 | 27 | # Because we want to satisfy our water level target first, this has a 28 | # higher priority (=lower number). 29 | priority = 1 30 | 31 | 32 | class MinimizeQpumpGoal(Goal): 33 | # This goal does not use a helper class, so we have to define the function 34 | # method, range and nominal explicitly. We do not specify a target_min or 35 | # target_max in this class, so the goal programming mixin will try to 36 | # minimize the expression returned by the function method. 37 | def function(self, optimization_problem, ensemble_member): 38 | return optimization_problem.integral("Q_pump") 39 | 40 | # The nominal is used to scale the value returned by 41 | # the function method so that the value is on the order of 1. 42 | function_nominal = 100.0 43 | # The lower the number returned by this function, the higher the priority. 44 | priority = 2 45 | # The penalty variable is taken to the order'th power. 46 | order = 1 47 | 48 | 49 | class MinimizeChangeInQpumpGoal(Goal): 50 | # To reduce pump power cycles, we add a third goal to minimize changes in 51 | # Q_pump. This will be passed into the optimization problem as a path goal 52 | # because it is an an individual goal that should be applied at every time 53 | # step. 54 | def function(self, optimization_problem, ensemble_member): 55 | return optimization_problem.der("Q_pump") 56 | 57 | function_nominal = 5.0 58 | priority = 3 59 | # Default order is 2, but we want to be explicit 60 | order = 2 61 | 62 | 63 | class Example( 64 | GoalProgrammingMixin, CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem 65 | ): 66 | """ 67 | An introductory example to goal programming in RTC-Tools 68 | """ 69 | 70 | def path_constraints(self, ensemble_member): 71 | # We want to add a few hard constraints to our problem. The goal 72 | # programming mixin however also generates constraints (and objectives) 73 | # from on our goals, so we have to call super() here. 74 | constraints = super().path_constraints(ensemble_member) 75 | 76 | # Release through orifice downhill only. This constraint enforces the 77 | # fact that water only flows downhill 78 | constraints.append( 79 | (self.state("Q_orifice") + (1 - self.state("is_downhill")) * 10, 0.0, 10.0) 80 | ) 81 | 82 | # Make sure is_downhill is true only when the sea is lower than the 83 | # water level in the storage. 84 | M = 2 # The so-called "big-M" 85 | constraints.append( 86 | ( 87 | self.state("H_sea") 88 | - self.state("storage.HQ.H") 89 | - (1 - self.state("is_downhill")) * M, 90 | -np.inf, 91 | 0.0, 92 | ) 93 | ) 94 | constraints.append( 95 | ( 96 | self.state("H_sea") - self.state("storage.HQ.H") + self.state("is_downhill") * M, 97 | 0.0, 98 | np.inf, 99 | ) 100 | ) 101 | 102 | # Orifice flow constraint. Uses the equation: 103 | # Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5 104 | # Note that this equation is only valid for orifices that are submerged 105 | # units: description: 106 | w = 3.0 # m width of orifice 107 | d = 0.8 # m hight of orifice 108 | C = 1.0 # none orifice constant 109 | g = 9.8 # m/s^2 gravitational acceleration 110 | constraints.append( 111 | ( 112 | ((self.state("Q_orifice") / (w * C * d)) ** 2) / (2 * g) 113 | + self.state("orifice.HQDown.H") 114 | - self.state("orifice.HQUp.H") 115 | - M * (1 - self.state("is_downhill")), 116 | -np.inf, 117 | 0.0, 118 | ) 119 | ) 120 | 121 | return constraints 122 | 123 | def goals(self): 124 | return [MinimizeQpumpGoal()] 125 | 126 | def path_goals(self): 127 | # Sorting goals on priority is done in the goal programming mixin. We 128 | # do not have to worry about order here. 129 | return [WaterLevelRangeGoal(self), MinimizeChangeInQpumpGoal()] 130 | 131 | def pre(self): 132 | # Call super() class to not overwrite default behaviour 133 | super().pre() 134 | # We keep track of our intermediate results, so that we can print some 135 | # information about the progress of goals at the end of our run. 136 | self.intermediate_results = [] 137 | 138 | # Any solver options can be set here 139 | def solver_options(self): 140 | options = super().solver_options() 141 | solver = options["solver"] 142 | options[solver]["print_level"] = 1 143 | return options 144 | 145 | 146 | # Run 147 | if __name__ == "__main__": 148 | run_optimization_problem_closed_loop(Example) 149 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/results_construction.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import os 4 | from pathlib import Path 5 | 6 | import pandas as pd 7 | from rtctools.data import pi, rtc 8 | 9 | logger = logging.getLogger("rtctools") 10 | 11 | 12 | def _get_variables_from_pi(data_config: rtc.DataConfig, timeseries: pi.Timeseries): 13 | """Get all variables of a PI timeseries that are in the data configuration.""" 14 | variables = [] 15 | for var, _ in timeseries.items(): 16 | try: 17 | data_config.pi_variable_ids(var) 18 | variables.append(var) 19 | except KeyError: 20 | pass 21 | return variables 22 | 23 | 24 | def combine_xml_exports( 25 | output_base_path: Path, original_input_timeseries_path: Path, write_csv_out: bool = False 26 | ): 27 | """Combine the xml exports of multiple periods into a single xml file.""" 28 | logger.info("Combining XML exports.") 29 | dataconfig = rtc.DataConfig(folder=original_input_timeseries_path) 30 | 31 | ts_import_orig = pi.Timeseries( 32 | data_config=dataconfig, 33 | folder=original_input_timeseries_path, 34 | basename="timeseries_import", 35 | binary=False, 36 | ) 37 | if ts_import_orig.forecast_datetime > ts_import_orig.start_datetime: 38 | logger.info( 39 | "Timeseries export will start at original forecast date, " 40 | "disregarding data before forecast date." 41 | ) 42 | ts_import_orig.resize(ts_import_orig.forecast_datetime, ts_import_orig.end_datetime) 43 | ts_import_orig.times = ts_import_orig.times[ 44 | ts_import_orig.times.index(ts_import_orig.forecast_datetime) : 45 | ] 46 | orig_start_datetime = ts_import_orig.start_datetime 47 | orig_end_datetime = ts_import_orig.end_datetime 48 | 49 | ts_export = pi.Timeseries( 50 | data_config=dataconfig, 51 | folder=output_base_path / "period_0", 52 | basename="timeseries_export", 53 | binary=False, 54 | ) # Use the first timeseries export as a starting point for the combined timeseries export. 55 | ts_export.resize(orig_start_datetime, orig_end_datetime) 56 | 57 | variables = _get_variables_from_pi(data_config=dataconfig, timeseries=ts_export) 58 | 59 | i = 0 60 | while os.path.isfile(os.path.join(output_base_path, f"period_{i}", "timeseries_export.xml")): 61 | ts_export_step = pi.Timeseries( 62 | data_config=dataconfig, 63 | folder=os.path.join(output_base_path, f"period_{i}"), 64 | basename="timeseries_export", 65 | binary=False, 66 | ) 67 | all_times = ts_import_orig.times # Workaround to map indices to times, as ts_export does 68 | # not contain all times. TODO Check whether the assumption that these times map to 69 | # the correct indices for ts_export always holds. 70 | for loc_par in variables: 71 | try: 72 | current_values = ts_export.get(loc_par) 73 | new_values = ts_export_step.get(loc_par) 74 | except KeyError: 75 | logger.debug(f"Variable {loc_par} not found in output of model horizon: {i}") 76 | continue 77 | new_times = ts_export_step.times 78 | try: 79 | start_new_data_index = all_times.index(new_times[0]) 80 | except ValueError: 81 | if all_times[-1] + ts_export.dt == new_times[0]: 82 | start_new_data_index = len(all_times) 83 | else: 84 | raise ValueError( 85 | "Could not match the start data of the timeseries export file " 86 | + "with the end of the previous." 87 | ) 88 | combined_values = copy.deepcopy(current_values) 89 | combined_values[start_new_data_index : start_new_data_index + len(new_values)] = ( 90 | new_values # noqa 91 | ) 92 | ts_export.set(loc_par, combined_values) 93 | i += 1 94 | ts_export.write(output_folder=output_base_path.parent, output_filename="timeseries_export") 95 | 96 | if write_csv_out: 97 | data = pd.DataFrame({"date": all_times}) 98 | new_columns = [] 99 | for timeseries_id in variables: 100 | try: 101 | values = ts_export.get(timeseries_id) 102 | new_columns.append(pd.Series(values, name=timeseries_id)) 103 | except KeyError: 104 | logger.debug(f"Variable {timeseries_id} not found in output of model horizon: {i}") 105 | continue 106 | data = pd.concat([data] + new_columns, axis=1) 107 | data.round(6).to_csv(output_base_path.parent / "timeseries_export.csv", index=False) 108 | 109 | 110 | def combine_dataframes(dfs: list[pd.DataFrame], index_col: str = "time"): 111 | """Combine multiple dataframes with the same index column. 112 | The dataframes are combined in the order they are passed, 113 | with the last dataframe taking precedence in case of overlapping indices.""" 114 | combined_df = pd.DataFrame() 115 | for df in dfs: 116 | df.set_index(index_col, inplace=True) 117 | combined_df = df.combine_first(combined_df) 118 | combined_df.reset_index(inplace=True) 119 | return combined_df 120 | 121 | 122 | def combine_csv_exports(output_base_path: Path): 123 | """Combine the csv exports of multiple periods into a single csv file.""" 124 | i = 0 125 | dfs = [] 126 | while os.path.isfile(os.path.join(output_base_path, f"period_{i}", "timeseries_export.csv")): 127 | df = pd.read_csv(os.path.join(output_base_path, f"period_{i}", "timeseries_export.csv")) 128 | dfs.append(df) 129 | i += 1 130 | combined_df = combine_dataframes(dfs) 131 | combined_df.round(6).to_csv(output_base_path.parent / "timeseries_export.csv", index=False) 132 | 133 | 134 | if __name__ == "__main__": 135 | closed_loop_test_folder = Path(__file__).parents[2] / "tests" / "closed_loop" 136 | output_base_path = closed_loop_test_folder / Path( 137 | r"test_models\goal_programming_xml\output\output_modelling_periods_reference" 138 | ) 139 | original_input_timeseries_path = closed_loop_test_folder / Path( 140 | r"test_models\goal_programming_xml\input" 141 | ) 142 | combine_xml_exports(output_base_path, original_input_timeseries_path, True) 143 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/optimization_ranges.py: -------------------------------------------------------------------------------- 1 | """Module for calculating optimization periods.""" 2 | 3 | import bisect 4 | import datetime 5 | from pathlib import Path 6 | 7 | import pandas as pd 8 | 9 | 10 | def get_optimization_ranges_from_file( 11 | file_path: Path, model_time_range: tuple[datetime.datetime, datetime.datetime] 12 | ): 13 | """Read horizon config from a csv file""" 14 | if not file_path.exists(): 15 | raise FileNotFoundError( 16 | "The closed_loop_dates csv does not exist. Please create a horizon " 17 | f"config file in {file_path}." 18 | ) 19 | try: 20 | closed_loop_dates = pd.read_csv(file_path) 21 | except pd.errors.EmptyDataError: 22 | raise ValueError( 23 | "The closed_loop_dates csv is empty. Please provide a valid file with " 24 | "start_date and end_date column." 25 | ) 26 | closed_loop_dates.columns = closed_loop_dates.columns.str.replace(" ", "") 27 | if not all(col in closed_loop_dates.columns for col in ["start_date", "end_date"]): 28 | raise ValueError( 29 | "The closed_loop_dates csv should have both 'start_date' and 'end_date' columns." 30 | ) 31 | closed_loop_dates["start_date"] = pd.to_datetime(closed_loop_dates["start_date"]) 32 | closed_loop_dates["end_date"] = pd.to_datetime(closed_loop_dates["end_date"]) 33 | for i in range(1, len(closed_loop_dates)): 34 | if closed_loop_dates["start_date"].iloc[i] > closed_loop_dates["end_date"].iloc[i - 1]: 35 | raise ValueError( 36 | f"Closed loop date table: Start date at row {i} is later than previous end date." 37 | ) 38 | if any(closed_loop_dates["start_date"] < closed_loop_dates["start_date"].shift(1)): 39 | raise ValueError("Closed loop date table: The start dates are not in ascending order.") 40 | if any(closed_loop_dates["end_date"] < closed_loop_dates["end_date"].shift(1)): 41 | raise ValueError("Closed loop date table: The end dates are not in ascending order.") 42 | if any(closed_loop_dates["end_date"] < closed_loop_dates["start_date"]): 43 | raise ValueError( 44 | "Closed loop date table: For one or more rows the end date is before the start date." 45 | ) 46 | if any(closed_loop_dates["start_date"] > closed_loop_dates["end_date"]): 47 | raise ValueError( 48 | "Closed loop date table: For one or more rows the start date is after the end date." 49 | ) 50 | if ( 51 | any(closed_loop_dates["start_date"].dt.hour != 0) 52 | or any(closed_loop_dates["start_date"].dt.minute != 0) 53 | or any(closed_loop_dates["end_date"].dt.hour != 0) 54 | or any(closed_loop_dates["end_date"].dt.minute != 0) 55 | ): 56 | raise ValueError( 57 | "Closed loop date table: Currently, the date ranges can only be specific " 58 | "up to the level of days." 59 | ) 60 | assert min(closed_loop_dates["start_date"]).date() == model_time_range[0].date(), ( 61 | "The start day of the first optimization run is not equal" 62 | " to the start day of the forecast date (or first timestep)." 63 | ) 64 | assert max(closed_loop_dates["end_date"]).date() <= model_time_range[1].date(), ( 65 | "The end date of one or more optimization runs is later" 66 | " than the end date of the timeseries import." 67 | ) 68 | closed_loop_dates = [ 69 | (optimization_range["start_date"], optimization_range["end_date"]) 70 | for _, optimization_range in closed_loop_dates.iterrows() 71 | ] 72 | return closed_loop_dates 73 | 74 | 75 | def _get_next_time_index( 76 | times: list[datetime.date], i_current: int, timestep_size: datetime.timedelta 77 | ) -> int: 78 | """ 79 | Get the next timestep index. 80 | 81 | The next timestep index i_next is the highest index such that: 82 | * times[i_next] > times[i_current] 83 | * times[i_next] <= times[i_current] + timestep_size 84 | In case i_current >= i_max, i_next will be set to i_max, where i_max = len(times) - 1. 85 | """ 86 | i_max = len(times) - 1 87 | if i_current >= i_max: 88 | return i_max 89 | current_time = times[i_current] 90 | next_time = current_time + timestep_size 91 | i_next = bisect.bisect_right(times, next_time) - 1 92 | i_next = max(i_current + 1, i_next) 93 | return i_next 94 | 95 | 96 | def get_optimization_ranges( 97 | model_times: list[datetime.date], 98 | start_time: datetime.datetime, 99 | forecast_timestep: datetime.timedelta, 100 | optimization_period: datetime.timedelta, 101 | ) -> list[tuple[datetime.datetime, datetime.datetime]]: 102 | """Calculate a list of optimization periods.""" 103 | if forecast_timestep > optimization_period: 104 | raise ValueError( 105 | f"Forecast timestep {forecast_timestep} cannot be larger than" 106 | f" the optimization period {optimization_period}." 107 | ) 108 | if start_time not in model_times: 109 | raise ValueError(f"Start time {start_time} is not in the given model times.") 110 | i_max = len(model_times) - 1 111 | i_start = model_times.index(start_time) 112 | i_end = _get_next_time_index(model_times, i_start, optimization_period) 113 | optimization_periods = [] 114 | optimization_periods.append((model_times[i_start], model_times[i_end])) 115 | while i_end < i_max: 116 | i_start = _get_next_time_index(model_times, i_start, forecast_timestep) 117 | i_end = _get_next_time_index(model_times, i_start, optimization_period) 118 | optimization_periods.append((model_times[i_start], model_times[i_end])) 119 | return optimization_periods 120 | 121 | 122 | def round_datetime_ranges_to_days( 123 | datetime_ranges: list[tuple[datetime.datetime, datetime.datetime]], 124 | ) -> list[tuple[datetime.datetime, datetime.datetime]]: 125 | """Round datetimes to dats in datetime ranges. 126 | 127 | The start of the range is rounded to the start of the day 128 | and the end of the range is rounded to the end of the day. 129 | """ 130 | rounded_ranges = [] 131 | for start, end in datetime_ranges: 132 | if start.date() == end.date(): 133 | continue 134 | start = datetime.datetime.combine(start.date(), datetime.time.min) 135 | end = datetime.datetime.combine(end.date(), datetime.time.max) 136 | rounded_ranges.append((start, end)) 137 | return rounded_ranges 138 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input/timeseries_import.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -6.0 4 | 5 |
6 | instantaneous 7 | H_sea 8 | par_0 9 | 10 | 11 | 12 | -999.0 13 | test 14 | 2024-01-01 15 | 11:00:00 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 |
41 | instantaneous 42 | Q_in 43 | par_0 44 | 45 | 46 | 47 | -999.0 48 | test 49 | 2024-01-01 50 | 11:00:00 51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 |
76 | instantaneous 77 | storage_level 78 | par_0 79 | 80 | 81 | 82 | -999.0 83 | test 84 | 2024-01-01 85 | 11:00:00 86 |
87 | 88 |
89 | 90 |
91 | instantaneous 92 | Q_orifice 93 | par_0 94 | 95 | 96 | 97 | -999.0 98 | test 99 | 2024-01-01 100 | 11:00:00 101 |
102 | 103 |
104 | 105 |
106 | instantaneous 107 | Q_pump 108 | par_0 109 | 110 | 111 | 112 | -999.0 113 | test 114 | 2024-01-01 115 | 11:00:00 116 |
117 | 118 |
119 |
120 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date_equal_first_date/timeseries_import.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -6.0 4 | 5 |
6 | instantaneous 7 | H_sea 8 | par_0 9 | 10 | 11 | 12 | 13 | -999.0 14 | test 15 | 2024-01-01 16 | 11:00:00 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | 41 |
42 | instantaneous 43 | Q_in 44 | par_0 45 | 46 | 47 | 48 | 49 | -999.0 50 | test 51 | 2024-01-01 52 | 11:00:00 53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 | 77 |
78 | instantaneous 79 | storage_level 80 | par_0 81 | 82 | 83 | 84 | 85 | -999.0 86 | test 87 | 2024-01-01 88 | 11:00:00 89 |
90 | 91 |
92 | 93 |
94 | instantaneous 95 | Q_orifice 96 | par_0 97 | 98 | 99 | 100 | 101 | -999.0 102 | test 103 | 2024-01-01 104 | 11:00:00 105 |
106 | 107 |
108 | 109 |
110 | instantaneous 111 | Q_pump 112 | par_0 113 | 114 | 115 | 116 | 117 | -999.0 118 | test 119 | 2024-01-01 120 | 11:00:00 121 |
122 | 123 |
124 |
125 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/input_with_forecast_date/timeseries_import.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -6.0 4 | 5 |
6 | instantaneous 7 | H_sea 8 | par_0 9 | 10 | 11 | 12 | 13 | -999.0 14 | test 15 | 2024-01-01 16 | 11:00:00 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 |
43 | instantaneous 44 | Q_in 45 | par_0 46 | 47 | 48 | 49 | 50 | -999.0 51 | test 52 | 2024-01-01 53 | 11:00:00 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 79 |
80 | instantaneous 81 | storage_level 82 | par_0 83 | 84 | 85 | 86 | 87 | -999.0 88 | test 89 | 2024-01-01 90 | 11:00:00 91 |
92 | 93 | 94 |
95 | 96 |
97 | instantaneous 98 | Q_orifice 99 | par_0 100 | 101 | 102 | 103 | 104 | -999.0 105 | test 106 | 2024-01-01 107 | 11:00:00 108 |
109 | 110 | 111 |
112 | 113 |
114 | instantaneous 115 | Q_pump 116 | par_0 117 | 118 | 119 | 120 | 121 | -999.0 122 | test 123 | 2024-01-01 124 | 11:00:00 125 |
126 | 127 | 128 |
129 |
130 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/output/output_modelling_periods_reference/period_0/timeseries_export.xml: -------------------------------------------------------------------------------- 1 | 2 | -6.0 3 | 4 | 5 | instantaneous 6 | H_sea 7 | par_0 8 | 9 | 10 | 11 | -999 12 | H_sea 13 | test 14 | 2024-06-13 15 | 17:01:45 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | instantaneous 30 | Q_orifice 31 | par_0 32 | 33 | 34 | 35 | -999 36 | Q_orifice 37 | test 38 | 2024-06-13 39 | 17:01:45 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | instantaneous 54 | Q_pump 55 | par_0 56 | 57 | 58 | 59 | -999 60 | Q_pump 61 | test 62 | 2024-06-13 63 | 17:01:45 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | instantaneous 78 | is_downhill 79 | par_0 80 | 81 | 82 | 83 | -999 84 | is_downhill 85 | unit_unknown 86 | 2024-06-13 87 | 17:01:45 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | instantaneous 102 | sea_level 103 | par_0 104 | 105 | 106 | 107 | -999 108 | sea_level 109 | unit_unknown 110 | 2024-06-13 111 | 17:01:45 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | instantaneous 126 | storage_level 127 | par_0 128 | 129 | 130 | 131 | -999 132 | storage_level 133 | test 134 | 2024-06-13 135 | 17:01:45 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/output/output_modelling_periods_reference/period_2/timeseries_export.xml: -------------------------------------------------------------------------------- 1 | 2 | -6.0 3 | 4 | 5 | instantaneous 6 | H_sea 7 | par_0 8 | 9 | 10 | 11 | -999 12 | H_sea 13 | test 14 | 2024-06-13 15 | 17:01:49 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | instantaneous 30 | Q_orifice 31 | par_0 32 | 33 | 34 | 35 | -999 36 | Q_orifice 37 | test 38 | 2024-06-13 39 | 17:01:49 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | instantaneous 54 | Q_pump 55 | par_0 56 | 57 | 58 | 59 | -999 60 | Q_pump 61 | test 62 | 2024-06-13 63 | 17:01:49 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | instantaneous 78 | is_downhill 79 | par_0 80 | 81 | 82 | 83 | -999 84 | is_downhill 85 | unit_unknown 86 | 2024-06-13 87 | 17:01:49 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | instantaneous 102 | sea_level 103 | par_0 104 | 105 | 106 | 107 | -999 108 | sea_level 109 | unit_unknown 110 | 2024-06-13 111 | 17:01:49 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | instantaneous 126 | storage_level 127 | par_0 128 | 129 | 130 | 131 | -999 132 | storage_level 133 | test 134 | 2024-06-13 135 | 17:01:49 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /tests/closed_loop/test_models/goal_programming_xml/output/output_modelling_periods_reference/period_1/timeseries_export.xml: -------------------------------------------------------------------------------- 1 | 2 | -6.0 3 | 4 | 5 | instantaneous 6 | H_sea 7 | par_0 8 | 9 | 10 | 11 | -999 12 | H_sea 13 | test 14 | 2024-06-13 15 | 17:01:47 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | instantaneous 30 | Q_orifice 31 | par_0 32 | 33 | 34 | 35 | -999 36 | Q_orifice 37 | test 38 | 2024-06-13 39 | 17:01:47 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | instantaneous 54 | Q_pump 55 | par_0 56 | 57 | 58 | 59 | -999 60 | Q_pump 61 | test 62 | 2024-06-13 63 | 17:01:47 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | instantaneous 78 | is_downhill 79 | par_0 80 | 81 | 82 | 83 | -999 84 | is_downhill 85 | unit_unknown 86 | 2024-06-13 87 | 17:01:47 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | instantaneous 102 | sea_level 103 | par_0 104 | 105 | 106 | 107 | -999 108 | sea_level 109 | unit_unknown 110 | 2024-06-13 111 | 17:01:47 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | instantaneous 126 | storage_level 127 | par_0 128 | 129 | 130 | 131 | -999 132 | storage_level 133 | test 134 | 2024-06-13 135 | 17:01:47 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /COPYING.LESSER: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /rtctools_interface/utils/results_collection.py: -------------------------------------------------------------------------------- 1 | """Mixin to store all required data for plotting.""" 2 | 3 | import copy 4 | import logging 5 | import os 6 | import time 7 | from pathlib import Path 8 | 9 | import numpy as np 10 | 11 | from rtctools_interface.utils.plot_table_schema import PlotTableRow 12 | from rtctools_interface.utils.read_goals_mixin import ReadGoalsMixin 13 | from rtctools_interface.utils.read_plot_table import get_plot_config 14 | from rtctools_interface.utils.serialization import deserialize, serialize 15 | from rtctools_interface.utils.type_definitions import ( 16 | PlotDataAndConfig, 17 | PlotOptions, 18 | PrioIndependentData, 19 | ) 20 | 21 | MAX_NUM_CACHED_FILES = 5 22 | CONFIG_VERSION: float = 1.0 23 | 24 | logger = logging.getLogger("rtctools") 25 | 26 | 27 | def get_most_recent_cache(cache_folder): 28 | """Get the most recent pickle file, based on its name.""" 29 | cache_folder = Path(cache_folder) 30 | json_files = list(cache_folder.glob("*.json")) 31 | 32 | if json_files: 33 | return max(json_files, key=lambda file: int(file.stem), default=None) 34 | return None 35 | 36 | 37 | def clean_cache_folder(cache_folder, max_files=10): 38 | """Clean the cache folder with pickles. 39 | 40 | remove the oldest ones when there are more than `max_files`. 41 | """ 42 | cache_path = Path(cache_folder) 43 | files = [f for f in cache_path.iterdir() if f.suffix == ".json"] 44 | 45 | if len(files) > max_files: 46 | files.sort(key=lambda x: int(x.stem)) 47 | files_to_delete = len(files) - max_files 48 | for i in range(files_to_delete): 49 | file_to_delete = cache_path / files[i] 50 | file_to_delete.unlink() 51 | 52 | 53 | def write_cache_file(cache_folder: Path, results_to_store: PlotDataAndConfig): 54 | """Write a file to the cache folder as a pickle file with the linux time as name.""" 55 | os.makedirs(cache_folder, exist_ok=True) 56 | file_name = int(time.time()) 57 | with open(cache_folder / f"{file_name}.json", "w", encoding="utf-8") as json_file: 58 | json_file.write(serialize(results_to_store)) 59 | 60 | clean_cache_folder(cache_folder, MAX_NUM_CACHED_FILES) 61 | 62 | 63 | def read_cache_file_from_folder(cache_folder: Path) -> PlotDataAndConfig | None: 64 | """Read the most recent file from the cache folder.""" 65 | cache_file_path = get_most_recent_cache(cache_folder) 66 | loaded_data: PlotDataAndConfig | None 67 | if cache_file_path: 68 | with open(cache_file_path, encoding="utf-8") as handle: 69 | loaded_data: dict = deserialize(handle.read()) 70 | if loaded_data.get("config_version", 0) < CONFIG_VERSION: 71 | logger.warning( 72 | """The cache file that was found is not supported by 73 | the current version of rtc-tools-interface!""" 74 | ) 75 | loaded_data = None 76 | else: 77 | loaded_data = None 78 | return loaded_data 79 | 80 | 81 | def get_plot_variables(plot_config: list[PlotTableRow]) -> list[str]: 82 | """Get list of variable-names that are in the plot table.""" 83 | variables_style_1 = [ 84 | var for subplot_config in plot_config for var in subplot_config.variables_style_1 85 | ] 86 | variables_style_2 = [ 87 | var for subplot_config in plot_config for var in subplot_config.variables_style_2 88 | ] 89 | variables_with_previous_result = [ 90 | var 91 | for subplot_config in plot_config 92 | for var in subplot_config.variables_with_previous_result 93 | ] 94 | return list(set(variables_style_1 + variables_style_2 + variables_with_previous_result)) 95 | 96 | 97 | def filter_plot_config( 98 | plot_config: list[PlotTableRow], all_goal_generator_goals 99 | ) -> list[PlotTableRow]: 100 | """ "Remove PlotTableRows corresponding to non-existing goals in the goal generator.""" 101 | goal_generator_goal_ids = [goal.goal_id for goal in all_goal_generator_goals] 102 | new_plot_config = [ 103 | plot_table_row 104 | for plot_table_row in plot_config 105 | if plot_table_row.id in goal_generator_goal_ids or plot_table_row.specified_in == "python" 106 | ] 107 | return new_plot_config 108 | 109 | 110 | class PlottingBaseMixin(ReadGoalsMixin): 111 | """Base class for creating plots. 112 | 113 | Reads the plot table, if available the goal table, and contains 114 | functions to store all required data for plots.""" 115 | 116 | plot_max_rows = 4 117 | plot_results_each_priority = True 118 | plot_final_results = True 119 | 120 | def __init__(self, **kwargs): 121 | super().__init__(**kwargs) 122 | try: 123 | plot_table_file = self.plot_table_file 124 | except AttributeError: 125 | plot_table_file = os.path.join(self._input_folder, "plot_table.csv") 126 | plot_config_list = kwargs.get("plot_config_list", []) 127 | read_from = kwargs.get("read_goals_from", "csv_table") 128 | self.save_plot_to = kwargs.get("save_plot_to", "image") 129 | self.plotting_library = kwargs.get("plotting_library", "plotly") 130 | self.plot_config = get_plot_config(plot_table_file, plot_config_list, read_from) 131 | 132 | self.custom_variables = get_plot_variables(self.plot_config) 133 | 134 | if not hasattr(self, "_all_goal_generator_goals") and self.optimization_problem: 135 | goals_to_generate = kwargs.get("goals_to_generate", []) 136 | read_from = kwargs.get("read_goals_from", "csv_table") 137 | self.load_goals(read_from, goals_to_generate) 138 | 139 | if self.optimization_problem: 140 | all_goal_generator_goals = self._all_goal_generator_goals 141 | self.state_variables = list({base_goal.state for base_goal in all_goal_generator_goals}) 142 | else: 143 | self.state_variables = [] 144 | all_goal_generator_goals = [] 145 | 146 | self.plot_config = filter_plot_config(self.plot_config, all_goal_generator_goals) 147 | 148 | self._cache_folder = Path(self._output_folder) / "cached_results" 149 | if "previous_run_plot_config" in kwargs: 150 | self._previous_run = kwargs["previous_run_plot_config"] 151 | else: 152 | self._previous_run = read_cache_file_from_folder(self._cache_folder) 153 | 154 | def pre(self): 155 | """Tasks before optimizing.""" 156 | super().pre() 157 | self._intermediate_results = [] 158 | 159 | def collect_timeseries_data(self, all_variables_to_store: list[str]) -> dict[str, np.ndarray]: 160 | """Collect the timeseries data for a list of variables.""" 161 | extracted_results = copy.deepcopy(self.extract_results()) 162 | timeseries_data = {} 163 | for timeseries_name in all_variables_to_store: 164 | try: 165 | timeseries_data[timeseries_name] = extracted_results[timeseries_name] 166 | except KeyError: 167 | try: 168 | timeseries_data[timeseries_name] = self.io.get_timeseries(timeseries_name)[1] 169 | except KeyError as exc: 170 | raise KeyError(f"Cannot find timeseries for {timeseries_name}") from exc 171 | return timeseries_data 172 | 173 | def create_plot_data_and_config(self, base_goals: list) -> PlotDataAndConfig: 174 | """Create the PlotDataAndConfig dict.""" 175 | prio_independent_data: PrioIndependentData = { 176 | "io_datetimes": [ 177 | dt 178 | for idx, dt in enumerate(self.io.datetimes) 179 | if self.io.times_sec[idx] in self.times() 180 | ], 181 | "times": self.times(), 182 | "base_goals": base_goals, 183 | } 184 | plot_options: PlotOptions = { 185 | "plot_config": self.plot_config, 186 | "plot_max_rows": self.plot_max_rows, 187 | "output_folder": self._output_folder, 188 | "save_plot_to": self.save_plot_to, 189 | } 190 | plot_data_and_config: PlotDataAndConfig = { 191 | "intermediate_results": self._intermediate_results, 192 | "plot_options": plot_options, 193 | "prio_independent_data": prio_independent_data, 194 | "config_version": CONFIG_VERSION, 195 | } 196 | return plot_data_and_config 197 | 198 | def _store_current_results(self, cache_folder, results_to_store): 199 | write_cache_file(cache_folder, results_to_store) 200 | self._plot_data_and_config = results_to_store 201 | 202 | @property 203 | def get_plot_data_and_config(self): 204 | """Get the plot data and config from the current run.""" 205 | return self._plot_data_and_config 206 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/time_series_handler.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import datetime 3 | import logging 4 | from abc import ABC, abstractmethod 5 | from pathlib import Path 6 | 7 | import numpy as np 8 | import pandas as pd 9 | from rtctools.data import csv, pi, rtc 10 | 11 | ns = {"fews": "http://www.wldelft.nl/fews", "pi": "http://www.wldelft.nl/fews/PI"} 12 | 13 | logger = logging.getLogger("rtctools") 14 | 15 | 16 | class TimeSeriesHandler(ABC): 17 | """ABC for handling timeseries data.""" 18 | 19 | # The forecast date determines at which date the optimization starts. 20 | forecast_date: datetime.datetime | None = None 21 | 22 | @abstractmethod 23 | def read(self, file_name: str) -> None: 24 | """Read the timeseries.""" 25 | 26 | @abstractmethod 27 | def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime) -> None: 28 | """Select a time range from the timeseries data. Removes data outside the interval. 29 | The specified range is inclusive on both sides.""" 30 | 31 | @abstractmethod 32 | def write(self, file_path: Path) -> None: 33 | """Write the timeseries data to a file.""" 34 | 35 | @abstractmethod 36 | def get_timestep(self) -> datetime.timedelta: 37 | """Get the timestep of the timeseries data.""" 38 | 39 | @abstractmethod 40 | def get_datetimes(self) -> list[datetime.datetime]: 41 | """Get the dates of the timeseries.""" 42 | 43 | @abstractmethod 44 | def get_datetime_range(self) -> tuple[datetime.datetime, datetime.datetime]: 45 | """Get the date range of the timeseries data (min, max).""" 46 | 47 | @abstractmethod 48 | def get_all_internal_ids(self) -> list[str]: 49 | """Get all internal id's of the timeseries data.""" 50 | 51 | @abstractmethod 52 | def set_initial_value(self, internal_id: str, value: float) -> None: 53 | """Set the initial value of a variable in the timeseries data.""" 54 | 55 | @abstractmethod 56 | def is_set(self, internal_id: str) -> bool: 57 | """Check whether the variable exists in the timeseries data 58 | and whether it has a least one non-nan value""" 59 | 60 | 61 | class CSVTimeSeriesFile(TimeSeriesHandler): 62 | """Timeseries handler for csv files.""" 63 | 64 | def __init__( 65 | self, 66 | input_folder: Path, 67 | timeseries_import_basename: str = "timeseries_import", 68 | csv_delimiter=",", 69 | initial_state_base_name: str = "initial_state", 70 | ): 71 | self.data = pd.DataFrame() 72 | self.input_folder = input_folder 73 | self.csv_delimiter = csv_delimiter 74 | self.data_col = None 75 | self.initial_state = None 76 | self.read(timeseries_import_basename, initial_state_base_name) 77 | 78 | def read(self, file_name: str, initial_state_base_name=None): 79 | timeseries = csv.load( 80 | (self.input_folder / file_name).with_suffix(".csv"), 81 | delimiter=self.csv_delimiter, 82 | with_time=True, 83 | ) 84 | self.data = pd.DataFrame(timeseries) 85 | if self.data is not None: 86 | self.date_col = self.data.columns[0] 87 | self.forecast_date = self.data[self.date_col].iloc[0] 88 | else: 89 | raise ValueError("No data to read.") 90 | if initial_state_base_name is not None: 91 | initial_state_file = self.input_folder / initial_state_base_name 92 | if initial_state_file.with_suffix(".csv").exists(): 93 | initial_state = csv.load( 94 | initial_state_file.with_suffix(".csv"), 95 | delimiter=self.csv_delimiter, 96 | with_time=False, 97 | ) 98 | self.initial_state: dict | None = { 99 | field: float(initial_state[field]) for field in initial_state.dtype.names 100 | } 101 | else: 102 | self.initial_state = None 103 | 104 | def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime): 105 | mask = (self.data[self.date_col] >= start_date) & (self.data[self.date_col] <= end_date) 106 | self.data = self.data.loc[mask] 107 | self.forecast_date = start_date 108 | 109 | def write(self, file_path: Path): 110 | self.write_timeseries(file_path) 111 | self.write_initial_state(file_path) 112 | 113 | def write_timeseries(self, file_path: Path, file_name: str = "timeseries_import"): 114 | self.data.to_csv( 115 | (file_path / file_name).with_suffix(".csv"), 116 | index=False, 117 | date_format="%Y-%m-%d %H:%M:%S", 118 | ) 119 | 120 | def write_initial_state(self, file_path: Path, file_name: str = "initial_state"): 121 | if self.initial_state is not None: 122 | initial_state = pd.DataFrame(self.initial_state, index=[0]) 123 | initial_state.to_csv( 124 | (file_path / file_name).with_suffix(".csv"), header=True, index=False 125 | ) 126 | 127 | def get_timestep(self): 128 | return self.data[self.date_col].diff().min() 129 | 130 | def get_datetimes(self): 131 | return self.data[self.date_col].to_list() 132 | 133 | def get_datetime_range(self): 134 | return self.data[self.date_col].min(), self.data[self.date_col].max() 135 | 136 | def get_all_internal_ids(self): 137 | ids = list(self.data.columns[1:]) 138 | if self.initial_state is not None: 139 | ids.extend(list(self.initial_state.keys())) 140 | return ids 141 | 142 | def set_initial_value(self, internal_id, value): 143 | if self.initial_state is None or internal_id not in self.initial_state: 144 | self.data[internal_id].iloc[0] = value 145 | else: 146 | self.initial_state[internal_id] = value 147 | 148 | def is_set(self, internal_id): 149 | val_is_set = False 150 | if internal_id in self.data.columns: 151 | val_is_set = not self.data[internal_id].isna().all() 152 | if self.initial_state is not None and internal_id in self.initial_state: 153 | val_is_set = False 154 | return val_is_set 155 | 156 | 157 | class XMLTimeSeriesFile(TimeSeriesHandler): 158 | """ "Timeseries handler for xml files""" 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.data_config = None 169 | self.pi_timeseries = None 170 | self.read(timeseries_import_basename) 171 | 172 | if self.get_datetime_range()[0] < self.forecast_date: 173 | logger.warning( 174 | "Currently, the closed loop runner does support data before the forecast date." 175 | ) 176 | logger.warning("Removing data before forecast date.") 177 | self.select_time_range(self.forecast_date, self.pi_timeseries.times[-1]) 178 | 179 | def read(self, file_name: str): 180 | """Read the timeseries data from a file.""" 181 | timeseries_import_basename = file_name 182 | self.data_config = rtc.DataConfig(self.input_folder) 183 | self.pi_timeseries = pi.Timeseries( 184 | self.data_config, 185 | self.input_folder, 186 | timeseries_import_basename, 187 | binary=self.pi_binary_timeseries, 188 | pi_validate_times=self.pi_validate_timeseries, 189 | ) 190 | self.forecast_date = self.pi_timeseries.forecast_datetime 191 | 192 | def is_set(self, internal_id): 193 | """Check whether the variable exists in the timeseries data. 194 | 195 | and whether it has a value at at least one of time steps.""" 196 | try: 197 | var: np.ndarray = self.pi_timeseries.get(variable=internal_id) 198 | return not np.isnan(var).all() 199 | except KeyError: 200 | return False 201 | 202 | def _is_in_dataconfig(self, internal_id): 203 | """Check if an internal id is in the data configuration.""" 204 | try: 205 | self.data_config.pi_variable_ids(internal_id) 206 | return True 207 | except KeyError: 208 | return False 209 | 210 | def get_all_internal_ids(self): 211 | """Get all internal id's of the timeseries data. 212 | 213 | Only returns the id's that are also in the dataconfig.""" 214 | all_ids = [var for var, _ in self.pi_timeseries.items() if self._is_in_dataconfig(var)] 215 | return all_ids 216 | 217 | def select_time_range(self, start_date: datetime.datetime, end_date: datetime.datetime): 218 | times = self.pi_timeseries.times 219 | i_start = bisect.bisect_left(times, start_date) 220 | i_end = bisect.bisect_right(times, end_date) - 1 221 | self.pi_timeseries.resize(start_datetime=times[i_start], end_datetime=times[i_end]) 222 | self.pi_timeseries.times = times[i_start : i_end + 1] 223 | self.pi_timeseries.forecast_datetime = times[i_start] 224 | self.forecast_date = times[i_start] 225 | 226 | def write(self, file_path: Path, file_name: str = "timeseries_import"): 227 | # By setting make_new_file headers will be recreated, 228 | # neceesary for writing new forecast date 229 | self.pi_timeseries.make_new_file = True 230 | self.pi_timeseries.write(output_folder=file_path, output_filename=file_name) 231 | 232 | def get_datetimes(self): 233 | """Get the dates of all timeseries data.""" 234 | return self.pi_timeseries.times 235 | 236 | def get_datetime_range(self): 237 | """Get the date range of the timeseries data, minimum and maximum over all series""" 238 | times = self.pi_timeseries.times 239 | return times[0], times[-1] 240 | 241 | def get_timestep(self): 242 | """Get the timestep of the timeseries data, raise error if different stepsizes""" 243 | return self.pi_timeseries.dt 244 | 245 | def set_initial_value(self, internal_id: str, value: float): 246 | self.pi_timeseries.set(internal_id, [value]) 247 | -------------------------------------------------------------------------------- /rtctools_interface/closed_loop/runner.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | import json 4 | import logging 5 | import os 6 | import shutil 7 | import sys 8 | from pathlib import Path 9 | 10 | from rtctools.data.pi import DiagHandler 11 | from rtctools.optimization.csv_mixin import CSVMixin 12 | from rtctools.optimization.pi_mixin import PIMixin 13 | from rtctools.util import _resolve_folder, run_optimization_problem 14 | 15 | import rtctools_interface.closed_loop.optimization_ranges as opt_ranges 16 | from rtctools_interface.closed_loop.config import ClosedLoopConfig 17 | from rtctools_interface.closed_loop.results_construction import ( 18 | combine_csv_exports, 19 | combine_xml_exports, 20 | ) 21 | from rtctools_interface.closed_loop.time_series_handler import ( 22 | CSVTimeSeriesFile, 23 | TimeSeriesHandler, 24 | XMLTimeSeriesFile, 25 | ) 26 | 27 | logger = logging.getLogger("rtctools") 28 | 29 | 30 | def set_initial_values_from_previous_run( 31 | results_previous_run: dict | None, 32 | timeseries: TimeSeriesHandler, 33 | previous_run_datetimes: list[datetime.datetime], 34 | ) -> None: 35 | """Modifies the initial values of `timeseries` based on the results of the previous run 36 | (if any)""" 37 | if results_previous_run is not None: 38 | variables_to_set = { 39 | key: value for key, value in results_previous_run.items() if not timeseries.is_set(key) 40 | } 41 | if timeseries.forecast_date: 42 | index_of_initial_value = previous_run_datetimes.index(timeseries.forecast_date) 43 | else: 44 | raise ValueError("Could not find forecast date in timeseries import.") 45 | for key, values in variables_to_set.items(): 46 | if values is not None: 47 | timeseries.set_initial_value(key, values[index_of_initial_value]) 48 | else: 49 | raise ValueError(f"Could not find initial value for {key}.") 50 | 51 | 52 | def write_input_folder( 53 | modelling_period_input_folder_i: Path, 54 | original_input_folder: Path, 55 | timeseries_import: TimeSeriesHandler, 56 | ) -> None: 57 | """Write the input folder for the current modelling period. 58 | Copies the original input folder to the modelling period input folder and writes the new 59 | timeseries_import.""" 60 | modelling_period_input_folder_i.mkdir(exist_ok=True) 61 | for file in original_input_folder.iterdir(): 62 | if file.is_file(): 63 | shutil.copy(file, modelling_period_input_folder_i / file.name) 64 | elif file.is_dir(): 65 | shutil.copytree(file, modelling_period_input_folder_i / file.name) 66 | timeseries_import.write(modelling_period_input_folder_i) 67 | 68 | 69 | def _get_optimization_ranges( 70 | config: ClosedLoopConfig, 71 | input_timeseries: TimeSeriesHandler, 72 | ) -> list[tuple[datetime.datetime, datetime.datetime]]: 73 | """Return a list of optimization periods.""" 74 | if config.file is not None: 75 | datetime_range = input_timeseries.get_datetime_range() 76 | optimization_ranges = opt_ranges.get_optimization_ranges_from_file( 77 | config.file, datetime_range 78 | ) 79 | elif config.optimization_period is not None: 80 | datetimes = input_timeseries.get_datetimes() 81 | optimization_ranges = opt_ranges.get_optimization_ranges( 82 | model_times=datetimes, 83 | start_time=datetimes[0], 84 | forecast_timestep=config.forecast_timestep, 85 | optimization_period=config.optimization_period, 86 | ) 87 | else: 88 | raise ValueError( 89 | "The closed-loop configuration should have either a file or optimization_period set." 90 | ) 91 | if config.round_to_dates: 92 | optimization_ranges = opt_ranges.round_datetime_ranges_to_days(optimization_ranges) 93 | return optimization_ranges 94 | 95 | 96 | def run_optimization_problem_closed_loop( 97 | optimization_problem_class, 98 | base_folder="..", 99 | log_level=logging.INFO, 100 | profile=False, 101 | config: ClosedLoopConfig | None = None, 102 | modelling_period_input_folder: str | None = None, 103 | **kwargs, 104 | ) -> dict: 105 | """ 106 | Runs an optimization problem in closed loop mode. 107 | 108 | This function is a drop-in replacement for the run_optimization_problem of rtc-tools. 109 | The user needs to specify a closed-loop configuration that describes the time ranges 110 | for which to subsequentially solve the optimization problem. 111 | The results from the previous run will be used to set the initial values of the next run. 112 | See README.md for more details. 113 | """ 114 | base_folder = Path(base_folder) 115 | if not os.path.isabs(base_folder): 116 | base_folder = Path(sys.path[0]) / base_folder 117 | original_input_folder = Path(_resolve_folder(kwargs, base_folder, "input_folder", "input")) 118 | original_output_folder = Path(_resolve_folder(kwargs, base_folder, "output_folder", "output")) 119 | original_output_folder.mkdir(exist_ok=True) 120 | 121 | # Set logging handlers. 122 | if not logger.hasHandlers(): 123 | handler = logging.StreamHandler() 124 | formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 125 | handler.setFormatter(formatter) 126 | logger.addHandler(handler) 127 | if issubclass(optimization_problem_class, PIMixin): 128 | if not any(isinstance(h, DiagHandler) for h in logger.handlers): 129 | diag_handler = DiagHandler(original_output_folder) 130 | logger.addHandler(diag_handler) 131 | logger.setLevel(log_level) 132 | 133 | if issubclass(optimization_problem_class, PIMixin): 134 | original_import = XMLTimeSeriesFile(original_input_folder) 135 | elif issubclass(optimization_problem_class, CSVMixin): 136 | original_import = CSVTimeSeriesFile(original_input_folder) 137 | else: 138 | raise ValueError("Optimization problem class must be derived from PIMixin or CSVMixin.") 139 | 140 | variables_in_import = original_import.get_all_internal_ids() 141 | 142 | fixed_input_config_file = (original_input_folder / "fixed_inputs.json").resolve() 143 | if not fixed_input_config_file.exists(): 144 | raise FileNotFoundError( 145 | f"Could not find fixed inputs configuration file: {fixed_input_config_file}" 146 | "Create a file with a list of strings that represent the fixed inputs" 147 | " (can be an empty list)." 148 | ) 149 | with open(fixed_input_config_file) as file: 150 | fixed_input_series = json.load(file) 151 | if not isinstance(fixed_input_series, list) and all( 152 | isinstance(item, str) for item in fixed_input_series 153 | ): 154 | raise ValueError("Fixed input config file should be a list of strings (or an empty list).") 155 | 156 | if modelling_period_input_folder is None: 157 | modelling_period_input_folder = base_folder / "input_modelling_periods" 158 | if modelling_period_input_folder.exists(): 159 | shutil.rmtree(modelling_period_input_folder) 160 | modelling_period_input_folder.mkdir(exist_ok=True) 161 | 162 | modelling_periods_output_folder = original_output_folder / "output_modelling_periods" 163 | if modelling_periods_output_folder.exists(): 164 | shutil.rmtree(modelling_periods_output_folder) 165 | modelling_periods_output_folder.mkdir(exist_ok=True) 166 | 167 | if config is None: 168 | config = ClosedLoopConfig(original_input_folder / "closed_loop_dates.csv") 169 | optimization_ranges = _get_optimization_ranges(config, original_import) 170 | results_previous_run = None 171 | previous_run_datetimes = None 172 | for i, (start_time, end_time) in enumerate(optimization_ranges): 173 | timeseries_import = copy.deepcopy(original_import) 174 | 175 | timeseries_import.select_time_range(start_date=start_time, end_date=end_time) 176 | 177 | set_initial_values_from_previous_run( 178 | results_previous_run, timeseries_import, previous_run_datetimes 179 | ) 180 | 181 | modelling_period_name = f"period_{i}" 182 | modelling_period_output_folder_i = modelling_periods_output_folder / modelling_period_name 183 | modelling_period_output_folder_i.mkdir(exist_ok=True) 184 | modelling_period_input_folder_i = modelling_period_input_folder / modelling_period_name 185 | write_input_folder( 186 | modelling_period_input_folder_i, original_input_folder, timeseries_import 187 | ) 188 | 189 | logger.info(f"Running optimization for period {i}: {(str(start_time), str(end_time))}.") 190 | result = run_optimization_problem( 191 | optimization_problem_class, 192 | base_folder, 193 | log_level, 194 | profile, 195 | input_folder=modelling_period_input_folder_i, 196 | output_folder=modelling_period_output_folder_i, 197 | **kwargs, 198 | ) 199 | period = f"period {i} {(str(start_time), str(end_time))}" 200 | if result.solver_stats["success"]: 201 | logger.info(f"Successful optimization for {period}.") 202 | else: 203 | message = ( 204 | f"Failed optimization for {period} with status" 205 | + f"{result.solver_stats['return_status']}'." 206 | ) 207 | logger.error(message) 208 | raise Exception(message) 209 | 210 | results_previous_run = { 211 | key: result.extract_results().get(key) 212 | for key in variables_in_import 213 | if key not in fixed_input_series 214 | } 215 | previous_run_datetimes = result.io.datetimes 216 | 217 | logger.info("Finished all optimization runs.") 218 | if issubclass(optimization_problem_class, PIMixin): 219 | combine_xml_exports( 220 | modelling_periods_output_folder, original_input_folder, write_csv_out=True 221 | ) 222 | elif issubclass(optimization_problem_class, CSVMixin): 223 | combine_csv_exports(modelling_periods_output_folder) 224 | else: 225 | logger.warning( 226 | "Could not combine exports because the optimization problem class is not " 227 | "derived from PIMixin or CSVMixin." 228 | ) 229 | return result.solver_stats 230 | -------------------------------------------------------------------------------- /tests/closed_loop/test_run_optization_problem_closed_loop.py: -------------------------------------------------------------------------------- 1 | """Test the closed loop runner""" 2 | 3 | import math 4 | import xml.etree.ElementTree as ET 5 | from datetime import timedelta 6 | from pathlib import Path 7 | from unittest import TestCase 8 | 9 | import pandas as pd 10 | 11 | from rtctools_interface.closed_loop.config import ClosedLoopConfig 12 | from rtctools_interface.closed_loop.runner import run_optimization_problem_closed_loop 13 | 14 | from .test_models.goal_programming_csv.src.example import Example as ExampleCsv 15 | from .test_models.goal_programming_xml.src.example import Example as ExampleXml 16 | 17 | ns = {"fews": "http://www.wldelft.nl/fews", "pi": "http://www.wldelft.nl/fews/PI"} 18 | 19 | # Elementwise comparisons are practially disabled. 20 | A_TOL = 0.1 21 | R_TOL = 0.1 22 | 23 | 24 | def compare_xml_file(file_result: Path, file_ref: Path): 25 | """Compare two timeseries_export files elementwise.""" 26 | tree_result = ET.parse(file_result) 27 | tree_ref = ET.parse(file_ref) 28 | series_result = tree_result.findall("pi:series", ns) 29 | series_ref = tree_ref.findall("pi:series", ns) 30 | assert len(series_result) == len(series_ref), "Different number of series found in exports." 31 | for serie_result, serie_ref in zip(series_result, series_ref, strict=False): 32 | for event_result, event_ref in zip( 33 | serie_result.findall("pi:event", ns), serie_ref.findall("pi:event", ns), strict=False 34 | ): 35 | value_result = float(event_result.attrib["value"]) 36 | value_ref = float(event_ref.attrib["value"]) 37 | assert math.isclose(value_result, value_ref, rel_tol=R_TOL, abs_tol=A_TOL), ( 38 | f"Difference found in event: {value_result} != {value_ref}" 39 | ) 40 | 41 | 42 | def compare_xml_files(output_modelling_period_folder: Path, reference_folder: Path): 43 | """Compare the timeseries_export.xml files in the output and reference folders.""" 44 | for folder in output_modelling_period_folder.iterdir(): 45 | if not folder.is_dir(): 46 | continue 47 | file_name = "timeseries_export.xml" 48 | file_result = folder / file_name 49 | file_ref = reference_folder / folder.name / file_name 50 | compare_xml_file(file_result, file_ref) 51 | 52 | 53 | class TestClosedLoop(TestCase): 54 | """ 55 | Class for testing closed loop runner. 56 | """ 57 | 58 | def compare_csv_files(self, output_folder: Path, reference_folder: Path, n_periods: int): 59 | """Compare the csv files in output and reference subfolders.""" 60 | self.assertTrue(output_folder.exists(), "Output modelling period folder should be created.") 61 | self.assertEqual( 62 | len(list(output_folder.iterdir())), 63 | n_periods, 64 | f"Error: {n_periods} modelling periods should be created.", 65 | ) 66 | for folder in output_folder.iterdir(): 67 | self.assertTrue((folder / "timeseries_export.csv").exists()) 68 | for folder in output_folder.iterdir(): 69 | reference_folder_i = reference_folder / folder.name 70 | for file in folder.iterdir(): 71 | df_result = pd.read_csv(file) 72 | df_ref = pd.read_csv(reference_folder_i / file.name) 73 | pd.testing.assert_frame_equal(df_result, df_ref, atol=A_TOL, rtol=R_TOL) 74 | 75 | def compare_csv_file(self, output_file: Path, reference_file: Path): 76 | """Compare the main csv output file in output and reference folder.""" 77 | df_result = pd.read_csv(output_file) 78 | df_ref = pd.read_csv(reference_file) 79 | pd.testing.assert_frame_equal(df_result, df_ref, atol=A_TOL, rtol=R_TOL) 80 | 81 | def compare_xml_files(self, output_folder: Path, reference_folder: Path, n_periods: int): 82 | """Compare the xml files in output and reference subfolders.""" 83 | self.assertTrue(output_folder.exists(), "Output modelling period folder should be created.") 84 | self.assertEqual( 85 | len([f for f in output_folder.iterdir() if f.is_dir()]), 86 | n_periods, 87 | f"Error: {n_periods} modelling periods should be created.", 88 | ) 89 | for folder in output_folder.iterdir(): 90 | if folder.is_dir(): 91 | self.assertTrue((folder / "timeseries_export.xml").exists()) 92 | compare_xml_files(output_folder, reference_folder) 93 | 94 | def compare_xml_file(self, output_file: Path, reference_file: Path): 95 | """Compare the main xml output file in output and reference folder.""" 96 | compare_xml_file(output_file, reference_file) 97 | 98 | def test_running_closed_loop_csv(self): 99 | """ 100 | Check if test model runs without problems and generates same results. 101 | """ 102 | base_folder = Path(__file__).parent / "test_models" / "goal_programming_csv" 103 | config = ClosedLoopConfig( 104 | file=base_folder / "input" / "closed_loop_dates.csv", round_to_dates=True 105 | ) 106 | run_optimization_problem_closed_loop(ExampleCsv, base_folder=base_folder, config=config) 107 | self.compare_csv_files( 108 | output_folder=base_folder / "output" / "output_modelling_periods", 109 | reference_folder=base_folder / "output" / "output_modelling_periods_reference", 110 | n_periods=3, 111 | ) 112 | self.compare_csv_file( 113 | output_file=base_folder / "output" / "timeseries_export.csv", 114 | reference_file=base_folder / "output" / "timeseries_export_reference.csv", 115 | ) 116 | 117 | def test_running_closed_loop_csv_fixed_periods(self): 118 | """ 119 | Check if test model runs for fixed optimization periods. 120 | """ 121 | base_folder = Path(__file__).parent / "test_models" / "goal_programming_csv" 122 | output_folder = "output_fixed_periods" 123 | config = ClosedLoopConfig.from_fixed_periods( 124 | optimization_period=timedelta(days=3), forecast_timestep=timedelta(days=2) 125 | ) 126 | run_optimization_problem_closed_loop( 127 | ExampleCsv, base_folder=base_folder, config=config, output_folder=output_folder 128 | ) 129 | self.compare_csv_file( 130 | output_file=base_folder / output_folder / "timeseries_export.csv", 131 | reference_file=base_folder / "output" / "timeseries_export_reference.csv", 132 | ) 133 | 134 | def test_running_closed_loop_xml(self): 135 | """ 136 | Check if test model runs without problems and generates same results. 137 | """ 138 | test_cases = [ 139 | { 140 | "description": "without forecast date", 141 | "input_folder": "input", 142 | }, 143 | { 144 | "description": "with forecast date unequal to first date", 145 | "input_folder": "input_with_forecast_date", 146 | }, 147 | { 148 | "description": "with forecast date equal to first date", 149 | "input_folder": "input_with_forecast_date_equal_first_date", 150 | }, 151 | ] 152 | 153 | base_folder = Path(__file__).parent / "test_models" / "goal_programming_xml" 154 | 155 | for case in test_cases: 156 | with self.subTest(case["description"]): 157 | config = ClosedLoopConfig( 158 | file=base_folder / case["input_folder"] / "closed_loop_dates.csv", 159 | round_to_dates=True, 160 | ) 161 | run_optimization_problem_closed_loop( 162 | ExampleXml, 163 | base_folder=base_folder, 164 | config=config, 165 | input_folder=case["input_folder"], 166 | ) 167 | 168 | self.compare_xml_files( 169 | output_folder=base_folder / "output" / "output_modelling_periods", 170 | reference_folder=base_folder / "output" / "output_modelling_periods_reference", 171 | n_periods=3, 172 | ) 173 | self.compare_xml_file( 174 | output_file=base_folder / "output" / "timeseries_export.xml", 175 | reference_file=base_folder / "output" / "timeseries_export_reference.xml", 176 | ) 177 | 178 | def test_running_closed_loop_xml_fixed_periods(self): 179 | """ 180 | Check if test model runs for fixed optimization periods. 181 | """ 182 | test_cases = [ 183 | { 184 | "description": "without forecast date", 185 | "output_folder": "output_fixed_periods", 186 | "input_folder": "input", 187 | "optimization_period": timedelta(days=3), 188 | "forecast_timestep": timedelta(days=2), 189 | }, 190 | { 191 | "description": "with forecast date unequal to first date", 192 | "output_folder": "output_fixed_periods", 193 | "input_folder": "input_with_forecast_date", 194 | "optimization_period": timedelta(days=3), 195 | "forecast_timestep": timedelta(days=2), 196 | }, 197 | { 198 | "description": "with forecast date equal to first date", 199 | "output_folder": "output_fixed_periods", 200 | "input_folder": "input_with_forecast_date_equal_first_date", 201 | "optimization_period": timedelta(days=3), 202 | "forecast_timestep": timedelta(days=2), 203 | }, 204 | ] 205 | 206 | base_folder = Path(__file__).parent / "test_models" / "goal_programming_xml" 207 | 208 | for case in test_cases: 209 | with self.subTest(case["description"]): 210 | config = ClosedLoopConfig.from_fixed_periods( 211 | optimization_period=case["optimization_period"], 212 | forecast_timestep=case["forecast_timestep"], 213 | ) 214 | run_optimization_problem_closed_loop( 215 | ExampleXml, 216 | base_folder=base_folder, 217 | config=config, 218 | output_folder=case["output_folder"], 219 | input_folder=case["input_folder"], 220 | ) 221 | self.compare_xml_file( 222 | output_file=base_folder / case["output_folder"] / "timeseries_export.xml", 223 | reference_file=base_folder / "output" / "timeseries_export_reference.xml", 224 | ) 225 | -------------------------------------------------------------------------------- /rtctools_interface/optimization/base_goal.py: -------------------------------------------------------------------------------- 1 | """Module for a basic Goal.""" 2 | 3 | import logging 4 | 5 | import numpy as np 6 | from rtctools.optimization.goal_programming_mixin import Goal 7 | from rtctools.optimization.optimization_problem import OptimizationProblem 8 | from rtctools.optimization.timeseries import Timeseries 9 | 10 | from rtctools_interface.optimization.goal_table_schema import GOAL_TYPES, TARGET_DATA_TYPES 11 | from rtctools_interface.utils.type_definitions import GoalConfig 12 | 13 | logger = logging.getLogger("rtctools") 14 | 15 | 16 | class BaseGoal(Goal): 17 | """ 18 | Basic optimization goal for a given state. 19 | 20 | :cvar goal_type: 21 | Type of goal ('range' or 'minimization_path' or 'maximization_path') 22 | :cvar target_data_type: 23 | Type of target data ('value', 'parameter', 'timeseries'). 24 | If 'value', set the target bounds by value. 25 | If 'parameter', set the bounds by a parameter. The target_min 26 | and/or target_max are expected to be the name of the parameter. 27 | If 'timeseries', set the bounds by a timeseries. The target_min 28 | and/or target_max are expected to be the name of the timeseries. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | optimization_problem: OptimizationProblem, 34 | state, 35 | *, 36 | goal_type="minimization_path", 37 | function_min=np.nan, 38 | function_max=np.nan, 39 | function_nominal=np.nan, 40 | target_data_type="value", 41 | target_min=np.nan, 42 | target_max=np.nan, 43 | priority=1, 44 | weight=1.0, 45 | order=2, 46 | goal_id=None, 47 | **_kwargs, 48 | ): 49 | self.goal_id = goal_id 50 | self.state = state 51 | self.target_data_type = target_data_type 52 | self._set_goal_type(goal_type) 53 | if goal_type in ["range", "range_rate_of_change"]: 54 | self._set_function_bounds( 55 | optimization_problem=optimization_problem, 56 | function_min=function_min, 57 | function_max=function_max, 58 | ) 59 | self._set_function_nominal(function_nominal) 60 | if goal_type in ["range", "range_rate_of_change"]: 61 | self._set_target_bounds( 62 | optimization_problem=optimization_problem, 63 | target_min=target_min, 64 | target_max=target_max, 65 | ) 66 | self.priority = priority if np.isfinite(priority) else 1 67 | self.weight = weight if np.isfinite(weight) else 1.0 68 | self._set_order(order) 69 | 70 | def function(self, optimization_problem, ensemble_member): 71 | del ensemble_member 72 | if self.goal_type == "maximization_path": 73 | return -optimization_problem.state(self.state) 74 | if self.goal_type in ["minimization_path", "range"]: 75 | return optimization_problem.state(self.state) 76 | if self.goal_type in ["range_rate_of_change"]: 77 | return optimization_problem.der(self.state) 78 | raise ValueError( 79 | f"Unsupported goal type '{self.goal_type}', supported are {GOAL_TYPES.keys()}" 80 | ) 81 | 82 | def _set_order(self, order): 83 | """Set the order of the goal.""" 84 | if np.isfinite(order): 85 | self.order = order 86 | elif self.goal_type in ["maximization_path", "minimization_path"]: 87 | self.order = 1 88 | else: 89 | self.order = 2 90 | if self.goal_type == "maximization_path" and self.order % 2 == 0: 91 | logger.warning( 92 | "Using even order '%i' for a maximization_path goal" 93 | + " results in a minimization_path goal.", 94 | self.order, 95 | ) 96 | 97 | def _set_goal_type( 98 | self, 99 | goal_type, 100 | ): 101 | """Set the goal type.""" 102 | if goal_type in GOAL_TYPES: 103 | self.goal_type = goal_type 104 | else: 105 | raise ValueError(f"goal_type should be one of {GOAL_TYPES.keys()}.") 106 | 107 | def _get_state_range(self, optimization_problem, state_name): 108 | if isinstance(optimization_problem.bounds()[state_name][0], float): 109 | state_range_0 = optimization_problem.bounds()[state_name][0] 110 | elif isinstance(optimization_problem.bounds()[state_name][0], Timeseries): 111 | state_range_0 = optimization_problem.bounds()[state_name][0].values 112 | else: 113 | state_range_0 = np.nan 114 | if isinstance(optimization_problem.bounds()[state_name][1], float): 115 | state_range_1 = optimization_problem.bounds()[state_name][1] 116 | elif isinstance(optimization_problem.bounds()[state_name][1], Timeseries): 117 | state_range_1 = optimization_problem.bounds()[state_name][1].values 118 | else: 119 | state_range_1 = np.nan 120 | return (state_range_0, state_range_1) 121 | 122 | def _set_function_bounds( 123 | self, 124 | optimization_problem: OptimizationProblem, 125 | function_min=np.nan, 126 | function_max=np.nan, 127 | ): 128 | """Set function bounds either by user specified value or calculated""" 129 | state_range = self._get_state_range(optimization_problem, self.state) 130 | if (~np.isfinite(function_min) & ~np.isfinite(state_range[0])).any() or ( 131 | ~np.isfinite(function_max) & ~np.isfinite(state_range[1]) 132 | ).any(): 133 | raise ValueError( 134 | f"The upper/lower bound for state {self.state} for " 135 | + f"goal with id={self.goal_id} is not specified" 136 | + " so the function range should be specified!" 137 | ) 138 | if self.goal_type in ["range_rate_of_change"]: 139 | maximum_scaled_difference = (state_range[1] - state_range[0]) / np.diff( 140 | optimization_problem.times() 141 | ).min() 142 | calculated_range = (-maximum_scaled_difference, maximum_scaled_difference) 143 | else: 144 | calculated_range = state_range 145 | 146 | function_range_0 = function_min if np.isfinite(function_min) else calculated_range[0] 147 | function_range_1 = function_max if np.isfinite(function_max) else calculated_range[1] 148 | self.function_range = (function_range_0, function_range_1) 149 | 150 | def _set_function_nominal(self, function_nominal): 151 | """Set function nominal""" 152 | self.function_nominal = function_nominal 153 | if not np.isfinite(self.function_nominal): 154 | if isinstance(self.function_range, (list, tuple)): 155 | if np.all(np.isfinite(self.function_range)): 156 | self.function_nominal = ( 157 | abs(self.function_range[0]) + abs(self.function_range[1]) 158 | ) / 2 159 | return 160 | self.function_nominal = 1.0 161 | logger.warning( 162 | "Function nominal for goal with id '%s' not specified, nominal is set to 1.0", 163 | self.goal_id, 164 | ) 165 | 166 | def _set_target_bounds( 167 | self, 168 | optimization_problem: OptimizationProblem, 169 | target_min=np.nan, 170 | target_max=np.nan, 171 | ): 172 | """Set the target bounds.""" 173 | 174 | def set_value_target(): 175 | if self.goal_type == "range_rate_of_change": 176 | self.target_min = float(target_min) / 100 * self.function_nominal 177 | self.target_max = float(target_max) / 100 * self.function_nominal 178 | else: 179 | self.target_min = float(target_min) 180 | self.target_max = float(target_max) 181 | 182 | def set_parameter_target(): 183 | if isinstance(target_max, str): 184 | self.target_max = optimization_problem.parameters(0)[target_max] 185 | if self.target_max is None: 186 | self.target_max = optimization_problem.io.get_parameter(target_max) 187 | elif np.isnan(target_max): 188 | self.target_max = np.nan 189 | if isinstance(target_min, str): 190 | self.target_min = optimization_problem.parameters(0)[target_min] 191 | if self.target_min is None: 192 | self.target_min = optimization_problem.io.get_parameter(target_min) 193 | elif np.isnan(target_min): 194 | self.target_min = np.nan 195 | 196 | def set_timeseries_target(): 197 | if isinstance(target_max, str): 198 | self.target_max = optimization_problem.get_timeseries(target_max) 199 | elif np.isnan(target_max): 200 | self.target_max = np.nan 201 | if isinstance(target_min, str): 202 | self.target_min = optimization_problem.get_timeseries(target_min) 203 | elif np.isnan(target_min): 204 | self.target_min = np.nan 205 | 206 | if self.target_data_type not in TARGET_DATA_TYPES: 207 | raise ValueError(f"target_data_type should be one of {TARGET_DATA_TYPES}.") 208 | 209 | if self.goal_type == "range_rate_of_change" and self.target_data_type != "value": 210 | raise ValueError( 211 | "For range_rate_of_change goal only the `value` target type is supported." 212 | ) 213 | 214 | if self.target_data_type == "value": 215 | set_value_target() 216 | elif self.target_data_type == "parameter": 217 | set_parameter_target() 218 | elif self.target_data_type == "timeseries": 219 | set_timeseries_target() 220 | 221 | self._target_dict = optimization_problem.collect_range_target_values_from_basegoal(self) 222 | 223 | def get_goal_config(self) -> GoalConfig: 224 | """ 225 | Serialize the goal configuration into a dictionary. 226 | """ 227 | goal_config: GoalConfig = { 228 | "goal_id": self.goal_id, 229 | "state": self.state, 230 | "goal_type": self.goal_type, 231 | "function_min": self.function_range[0] 232 | if np.any(np.isfinite(self.function_range[0])) 233 | else None, 234 | "function_max": self.function_range[1] 235 | if np.any(np.isfinite(self.function_range[1])) 236 | else None, 237 | "function_nominal": self.function_nominal 238 | if np.any(np.isfinite(self.function_nominal)) 239 | else None, 240 | "target_min_series": None, 241 | "target_max_series": None, 242 | "target_min": self.target_min, 243 | "target_max": self.target_max, 244 | "priority": self.priority, 245 | "weight": self.weight, 246 | "order": self.order, 247 | } 248 | if isinstance(self.target_min, Timeseries): 249 | goal_config["target_min"] = self.target_min.values 250 | if isinstance(self.target_max, Timeseries): 251 | goal_config["target_max"] = self.target_max.values 252 | 253 | if hasattr(self, "_target_dict"): 254 | goal_config["target_min_series"] = self._target_dict["target_min"] 255 | goal_config["target_max_series"] = self._target_dict["target_max"] 256 | return goal_config 257 | --------------------------------------------------------------------------------