rtc-tools-interface 0.10.2a3__tar.gz → 0.10.2a4__tar.gz

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