rtc-tools-interface 0.10.2a2__tar.gz → 0.10.2a4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtc_tools_interface-0.10.2a4/PKG-INFO +21 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/README.md +34 -7
- rtc_tools_interface-0.10.2a4/rtc_tools_interface.egg-info/PKG-INFO +21 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/SOURCES.txt +6 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/_version.py +3 -3
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/config.py +4 -4
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/optimization_ranges.py +25 -19
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/results_construction.py +42 -25
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/runner.py +86 -35
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/time_series_handler.py +24 -19
- rtc_tools_interface-0.10.2a4/rtctools_interface/optimization/active_constraint_helpers.py +102 -0
- rtc_tools_interface-0.10.2a4/rtctools_interface/optimization/active_constraint_mixin.py +302 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/base_goal.py +30 -12
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/base_optimization_problem.py +1 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_generator_mixin.py +18 -7
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_performance_metrics.py +33 -14
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/goal_table_schema.py +16 -9
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/helpers/statistics_mixin.py +10 -10
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/plot_goals_mixin.py +4 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/plot_mixin.py +14 -5
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/read_goals.py +31 -13
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/plot_tools.py +60 -25
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/subplot_classes.py +65 -31
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/base_simulation_problem.py +1 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/plot_mixin.py +14 -7
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/plot_table_schema.py +8 -5
- rtc_tools_interface-0.10.2a4/rtctools_interface/utils/read_goals_mixin.py +55 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/read_plot_table.py +27 -9
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/results_collection.py +39 -20
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/serialization.py +7 -2
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/type_definitions.py +34 -13
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/setup.py +0 -1
- rtc_tools_interface-0.10.2a4/tests/closed_loop/test_combine_xml_exports.py +106 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_optimization_ranges.py +20 -18
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_read_xml.py +1 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/test_run_optization_problem_closed_loop.py +31 -38
- rtc_tools_interface-0.10.2a4/tests/closed_loop/test_set_initial_values_from_previous_run.py +61 -0
- rtc_tools_interface-0.10.2a4/tests/optimization/test_active_constraint_helpers.py +87 -0
- rtc_tools_interface-0.10.2a4/tests/optimization/test_active_constraint_mixin.py +89 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_base_optimization_problem.py +1 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_plot_goals_mixin.py +19 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_read_goals.py +7 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/simulation/test_plot_mixin.py +10 -5
- rtc_tools_interface-0.10.2a2/PKG-INFO +0 -14
- rtc_tools_interface-0.10.2a2/rtc_tools_interface.egg-info/PKG-INFO +0 -14
- rtc_tools_interface-0.10.2a2/rtctools_interface/utils/read_goals_mixin.py +0 -31
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/COPYING.LESSER +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/dependency_links.txt +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/requires.txt +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtc_tools_interface.egg-info/top_level.txt +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/closed_loop/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/optimization/helpers/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/plotting/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/simulation/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/utils/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/setup.cfg +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/closed_loop/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/optimization/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/optimization/test_passing_goals_directly.py +1 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/simulation/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/simulation/test_base_simulation_problem.py +1 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/utils/__init__.py +0 -0
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/tests/utils/get_test.py +1 -1
- {rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/versioneer.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rtc_tools_interface
|
|
3
|
+
Version: 0.10.2a4
|
|
4
|
+
Summary: Toolbox for user interfaces for RTC-Tools
|
|
5
|
+
Author: Deltares
|
|
6
|
+
Maintainer: Deltares
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
License-File: COPYING.LESSER
|
|
9
|
+
Requires-Dist: matplotlib
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: plotly
|
|
13
|
+
Requires-Dist: pydantic
|
|
14
|
+
Requires-Dist: casadi!=3.6.6
|
|
15
|
+
Requires-Dist: rtc-tools>=2.7.0a3
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
Dynamic: maintainer
|
|
19
|
+
Dynamic: requires-dist
|
|
20
|
+
Dynamic: requires-python
|
|
21
|
+
Dynamic: summary
|
|
@@ -11,8 +11,9 @@ pip install rtc-tools-interface
|
|
|
11
11
|
## Table of Contents
|
|
12
12
|
1. [Goal generator](#goal-generator)
|
|
13
13
|
2. [Goal performance metrics](#goal-performance-metrics)
|
|
14
|
-
3. [
|
|
15
|
-
4. [
|
|
14
|
+
3. [Active constraint diagnostics](#active-constraint-diagnostics)
|
|
15
|
+
4. [Automatic plotting of results](#automatic-plotting-of-results)
|
|
16
|
+
5. [Closed loop runner](#closed-loop-runner)
|
|
16
17
|
|
|
17
18
|
## Goal generator
|
|
18
19
|
The `goal generator` can be used to automatically add goals based on a csv file. Currently, the following goal types are supported:
|
|
@@ -91,18 +92,19 @@ by minimizing, if any, the sum of exceedances for the timesteps. For more detail
|
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
#### range_rate_of_change
|
|
94
|
-
The range_rate_of_change goal can be used to set a target range on ramp rate. Like the range goal, one needs to set the `target_min` and `target_max` for that. **Importantly** for the range_rate_of_change goal, the supplied values are relative to the nominal of the function. So supplying a `target_max` of `10` corresponds to the aim of having a maximum increase per timestep of 10% * `nominal`, where the nominal automatically set to `maximum rate of change`/2 or specified manually. To formulate the target of having a maximum increase and decrease by of 10% per timestep, one would set the `target_min` to `-10` and the `target_max` to `10`.
|
|
95
|
+
The range_rate_of_change goal can be used to set a target range on ramp rate for a model state. Like the range goal, one needs to set the `target_min` and `target_max` for that. **Importantly** for the range_rate_of_change goal, the supplied values are relative to the nominal of the function. So supplying a `target_max` of `10` corresponds to the aim of having a maximum increase per timestep of 10% * `nominal`, where the nominal automatically set to `maximum rate of change`/2 or specified manually. To formulate the target of having a maximum increase and decrease by of 10% per timestep, one would set the `target_min` to `-10` and the `target_max` to `10`.
|
|
95
96
|
|
|
96
|
-
The equations for the range_rate_of_change goal are almost the same as for the range goal, which can be found above. The only difference is that $x_t$ is replaced by the derivative of $x$ with respect to $t$, calculated as $\frac{x_t - x_{t-1}}{\Delta t}$.
|
|
97
|
+
The equations for the range_rate_of_change goal are almost the same as for the range goal, which can be found above. The only difference is that $x_t$ is replaced by the derivative of $x$ with respect to $t$, calculated as $\frac{x_t - x_{t-1}}{\Delta t}$. RTC-Tools computes the derivative (the ramp rate) internally for the state variable specified in the goal table.
|
|
97
98
|
|
|
98
99
|
### Example goal table
|
|
99
100
|
See the table below for an example content of the `goal_table.csv`.
|
|
100
101
|
|
|
101
102
|
| id | state | active | goal_type | function_min | function_max | function_nominal | target_data_type | target_min | target_max | priority | weight | order |
|
|
102
103
|
|--------|-------|--------|--------------|--------------|--------------|------------------|------------------|------------|------------|----------|--------|-------|
|
|
103
|
-
| goal_1 | reservoir_1_waterlevel | 1 | range | 0 | 15 | 10 | value | 5.0 | 10.0 | 5 |
|
|
104
|
-
| goal_2 | reservoir_2_waterlevel | 1 | range | 0 | 15 | 10 | timeseries | "target_series" | "target_series" | 10 |
|
|
105
|
-
| goal_3 | electricity_cost | 1 | minimization_path | | | | | | | 20 |
|
|
104
|
+
| goal_1 | reservoir_1_waterlevel | 1 | range | 0 | 15 | 10 | value | 5.0 | 10.0 | 5 | 1 | 1 |
|
|
105
|
+
| goal_2 | reservoir_2_waterlevel | 1 | range | 0 | 15 | 10 | timeseries | "target_series" | "target_series" | 10 | 1 | 1 |
|
|
106
|
+
| goal_3 | electricity_cost | 1 | minimization_path | | | | | | | 20 | 1 | 1 |
|
|
107
|
+
| goal_4 | reservoir_2_volume | 1 | range_rate_of_change | -100 | 100 | 10 | value | -10 | 10 | 30 | 1 | 1 |
|
|
106
108
|
|
|
107
109
|
## Goal performance metrics
|
|
108
110
|
For all goals defined with the goal generator this rtc-tools-interface module will also calculate performance metrics. By default, these performance metrics are saved to a .csv in the folder `output/perfomance_metrics`, with one csv file per goal. With the class variable `calculate_performance_metrics` this functionality can be disabled (by default it is enabled).
|
|
@@ -116,6 +118,23 @@ The calculated metrics are:
|
|
|
116
118
|
- `mean_absolute_percentual_difference`: The mean of the absolute percentual difference per timestep over all timesteps (only for range goals).
|
|
117
119
|
- `mean_absolute_difference`: The mean absolute difference per timestep of the state variable over all timesteps (only for range goals).
|
|
118
120
|
|
|
121
|
+
## Active constraint diagnostics
|
|
122
|
+
The `ActiveConstraintMixin` writes diagnostics about active constraints after each RTC-Tools goal-programming priority. A constraint is considered active when its evaluated value hits either its lower or upper bound within `active_constraint_tolerance` (default `1e-6`).
|
|
123
|
+
|
|
124
|
+
To enable the diagnostics, import the mixin and add it before `BaseOptimizationProblem` or before RTC-Tools' `GoalProgrammingMixin` in the class inheritance order:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from rtctools_interface.optimization.active_constraint_mixin import ActiveConstraintMixin
|
|
128
|
+
from rtctools_interface.optimization.base_optimization_problem import BaseOptimizationProblem
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MyOptimizationProblem(ActiveConstraintMixin, BaseOptimizationProblem):
|
|
132
|
+
pass
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
After optimization, the mixin creates `output/active_constraints/active_constraints_of_previous_goals.csv`.
|
|
136
|
+
This file contains detailed rows for active constraints that RTC-Tools created from goals optimized in previous priorities. For each priority with active previous-goal constraints, it reports the total number of previous-goal constraints, how many are active, which bound (`lower`, `upper`, or `both`) was hit, and the corresponding bound value. Path-goal constraints are reported in one row per active path goal; the `active_times` column lists the timesteps at which that path-goal constraint is active.
|
|
137
|
+
|
|
119
138
|
|
|
120
139
|
## Automatic plotting of results
|
|
121
140
|
With the `PlotMixin` one can easily make plots of the results of rtc-tools. This functionality can be used both for optimization and simulation problems. For optimization problems, use:
|
|
@@ -255,3 +274,11 @@ Note that this happens for:
|
|
|
255
274
|
- Currently, only the initial values of the first time step in a given time range are set.
|
|
256
275
|
- The closed_loop runner only works in combination with the CSVMixin or the PIMixin.
|
|
257
276
|
The CDFMixin is not supported.
|
|
277
|
+
|
|
278
|
+
### Matplotlib in Headless Environments
|
|
279
|
+
|
|
280
|
+
For CI or servers without a display, set `MPLBACKEND=Agg` to ensure Matplotlib uses a non-interactive backend:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
export MPLBACKEND=Agg
|
|
284
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rtc_tools_interface
|
|
3
|
+
Version: 0.10.2a4
|
|
4
|
+
Summary: Toolbox for user interfaces for RTC-Tools
|
|
5
|
+
Author: Deltares
|
|
6
|
+
Maintainer: Deltares
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
License-File: COPYING.LESSER
|
|
9
|
+
Requires-Dist: matplotlib
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: plotly
|
|
13
|
+
Requires-Dist: pydantic
|
|
14
|
+
Requires-Dist: casadi!=3.6.6
|
|
15
|
+
Requires-Dist: rtc-tools>=2.7.0a3
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
Dynamic: maintainer
|
|
19
|
+
Dynamic: requires-dist
|
|
20
|
+
Dynamic: requires-python
|
|
21
|
+
Dynamic: summary
|
|
@@ -17,6 +17,8 @@ rtctools_interface/closed_loop/results_construction.py
|
|
|
17
17
|
rtctools_interface/closed_loop/runner.py
|
|
18
18
|
rtctools_interface/closed_loop/time_series_handler.py
|
|
19
19
|
rtctools_interface/optimization/__init__.py
|
|
20
|
+
rtctools_interface/optimization/active_constraint_helpers.py
|
|
21
|
+
rtctools_interface/optimization/active_constraint_mixin.py
|
|
20
22
|
rtctools_interface/optimization/base_goal.py
|
|
21
23
|
rtctools_interface/optimization/base_optimization_problem.py
|
|
22
24
|
rtctools_interface/optimization/goal_generator_mixin.py
|
|
@@ -42,10 +44,14 @@ rtctools_interface/utils/serialization.py
|
|
|
42
44
|
rtctools_interface/utils/type_definitions.py
|
|
43
45
|
tests/__init__.py
|
|
44
46
|
tests/closed_loop/__init__.py
|
|
47
|
+
tests/closed_loop/test_combine_xml_exports.py
|
|
45
48
|
tests/closed_loop/test_optimization_ranges.py
|
|
46
49
|
tests/closed_loop/test_read_xml.py
|
|
47
50
|
tests/closed_loop/test_run_optization_problem_closed_loop.py
|
|
51
|
+
tests/closed_loop/test_set_initial_values_from_previous_run.py
|
|
48
52
|
tests/optimization/__init__.py
|
|
53
|
+
tests/optimization/test_active_constraint_helpers.py
|
|
54
|
+
tests/optimization/test_active_constraint_mixin.py
|
|
49
55
|
tests/optimization/test_base_optimization_problem.py
|
|
50
56
|
tests/optimization/test_passing_goals_directly.py
|
|
51
57
|
tests/optimization/test_plot_goals_mixin.py
|
{rtc_tools_interface-0.10.2a2 → rtc_tools_interface-0.10.2a4}/rtctools_interface/_version.py
RENAMED
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "
|
|
11
|
+
"date": "2026-09-17T07:52:27+0200",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "0.10.
|
|
14
|
+
"full-revisionid": "8d59f3b94a76dba915ac4488e060b927d4820acc",
|
|
15
|
+
"version": "0.10.2a4"
|
|
16
16
|
}
|
|
17
17
|
''' # END VERSION_JSON
|
|
18
18
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Module for configuring a closed-loop optimization problem."""
|
|
2
|
+
|
|
2
3
|
from datetime import timedelta
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import Optional
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class ClosedLoopConfig
|
|
7
|
+
class ClosedLoopConfig:
|
|
8
8
|
"""Configuration of a closed-loop optimization problem."""
|
|
9
9
|
|
|
10
10
|
def __init__(
|
|
@@ -30,8 +30,8 @@ class ClosedLoopConfig():
|
|
|
30
30
|
if file is not None:
|
|
31
31
|
file = Path(file).resolve()
|
|
32
32
|
self._file = file
|
|
33
|
-
self._forecast_timestep:
|
|
34
|
-
self._optimization_period:
|
|
33
|
+
self._forecast_timestep: timedelta | None = None
|
|
34
|
+
self._optimization_period: timedelta | None = None
|
|
35
35
|
self.round_to_dates = round_to_dates
|
|
36
36
|
|
|
37
37
|
@classmethod
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Module for calculating optimization periods."""
|
|
2
|
+
|
|
2
3
|
import bisect
|
|
3
4
|
import datetime
|
|
4
5
|
from pathlib import Path
|
|
@@ -12,30 +13,40 @@ def get_optimization_ranges_from_file(
|
|
|
12
13
|
"""Read horizon config from a csv file"""
|
|
13
14
|
if not file_path.exists():
|
|
14
15
|
raise FileNotFoundError(
|
|
15
|
-
|
|
16
|
+
"The closed_loop_dates csv does not exist. Please create a horizon "
|
|
17
|
+
f"config file in {file_path}."
|
|
16
18
|
)
|
|
17
19
|
try:
|
|
18
20
|
closed_loop_dates = pd.read_csv(file_path)
|
|
19
21
|
except pd.errors.EmptyDataError:
|
|
20
22
|
raise ValueError(
|
|
21
|
-
"The closed_loop_dates csv is empty. Please provide a valid file with
|
|
23
|
+
"The closed_loop_dates csv is empty. Please provide a valid file with "
|
|
24
|
+
"start_date and end_date column."
|
|
22
25
|
)
|
|
23
26
|
closed_loop_dates.columns = closed_loop_dates.columns.str.replace(" ", "")
|
|
24
|
-
if not all(
|
|
25
|
-
raise ValueError(
|
|
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
|
+
)
|
|
26
31
|
closed_loop_dates["start_date"] = pd.to_datetime(closed_loop_dates["start_date"])
|
|
27
32
|
closed_loop_dates["end_date"] = pd.to_datetime(closed_loop_dates["end_date"])
|
|
28
33
|
for i in range(1, len(closed_loop_dates)):
|
|
29
34
|
if closed_loop_dates["start_date"].iloc[i] > closed_loop_dates["end_date"].iloc[i - 1]:
|
|
30
|
-
raise ValueError(
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Closed loop date table: Start date at row {i} is later than previous end date."
|
|
37
|
+
)
|
|
31
38
|
if any(closed_loop_dates["start_date"] < closed_loop_dates["start_date"].shift(1)):
|
|
32
39
|
raise ValueError("Closed loop date table: The start dates are not in ascending order.")
|
|
33
40
|
if any(closed_loop_dates["end_date"] < closed_loop_dates["end_date"].shift(1)):
|
|
34
41
|
raise ValueError("Closed loop date table: The end dates are not in ascending order.")
|
|
35
42
|
if any(closed_loop_dates["end_date"] < closed_loop_dates["start_date"]):
|
|
36
|
-
raise ValueError(
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"Closed loop date table: For one or more rows the end date is before the start date."
|
|
45
|
+
)
|
|
37
46
|
if any(closed_loop_dates["start_date"] > closed_loop_dates["end_date"]):
|
|
38
|
-
raise ValueError(
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Closed loop date table: For one or more rows the start date is after the end date."
|
|
49
|
+
)
|
|
39
50
|
if (
|
|
40
51
|
any(closed_loop_dates["start_date"].dt.hour != 0)
|
|
41
52
|
or any(closed_loop_dates["start_date"].dt.minute != 0)
|
|
@@ -43,17 +54,14 @@ def get_optimization_ranges_from_file(
|
|
|
43
54
|
or any(closed_loop_dates["end_date"].dt.minute != 0)
|
|
44
55
|
):
|
|
45
56
|
raise ValueError(
|
|
46
|
-
"Closed loop date table: Currently, the date ranges can only be specific
|
|
57
|
+
"Closed loop date table: Currently, the date ranges can only be specific "
|
|
58
|
+
"up to the level of days."
|
|
47
59
|
)
|
|
48
|
-
assert (
|
|
49
|
-
min(closed_loop_dates["start_date"]).date() == model_time_range[0].date()
|
|
50
|
-
), (
|
|
60
|
+
assert min(closed_loop_dates["start_date"]).date() == model_time_range[0].date(), (
|
|
51
61
|
"The start day of the first optimization run is not equal"
|
|
52
62
|
" to the start day of the forecast date (or first timestep)."
|
|
53
63
|
)
|
|
54
|
-
assert (
|
|
55
|
-
max(closed_loop_dates["end_date"]).date() <= model_time_range[1].date()
|
|
56
|
-
), (
|
|
64
|
+
assert max(closed_loop_dates["end_date"]).date() <= model_time_range[1].date(), (
|
|
57
65
|
"The end date of one or more optimization runs is later"
|
|
58
66
|
" than the end date of the timeseries import."
|
|
59
67
|
)
|
|
@@ -65,9 +73,7 @@ def get_optimization_ranges_from_file(
|
|
|
65
73
|
|
|
66
74
|
|
|
67
75
|
def _get_next_time_index(
|
|
68
|
-
times: list[datetime.date],
|
|
69
|
-
i_current: int,
|
|
70
|
-
timestep_size: datetime.timedelta
|
|
76
|
+
times: list[datetime.date], i_current: int, timestep_size: datetime.timedelta
|
|
71
77
|
) -> int:
|
|
72
78
|
"""
|
|
73
79
|
Get the next timestep index.
|
|
@@ -91,7 +97,7 @@ def get_optimization_ranges(
|
|
|
91
97
|
model_times: list[datetime.date],
|
|
92
98
|
start_time: datetime.datetime,
|
|
93
99
|
forecast_timestep: datetime.timedelta,
|
|
94
|
-
optimization_period: datetime.timedelta
|
|
100
|
+
optimization_period: datetime.timedelta,
|
|
95
101
|
) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
|
96
102
|
"""Calculate a list of optimization periods."""
|
|
97
103
|
if forecast_timestep > optimization_period:
|
|
@@ -114,7 +120,7 @@ def get_optimization_ranges(
|
|
|
114
120
|
|
|
115
121
|
|
|
116
122
|
def round_datetime_ranges_to_days(
|
|
117
|
-
datetime_ranges: list[tuple[datetime.datetime, datetime.datetime]]
|
|
123
|
+
datetime_ranges: list[tuple[datetime.datetime, datetime.datetime]],
|
|
118
124
|
) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
|
119
125
|
"""Round datetimes to dats in datetime ranges.
|
|
120
126
|
|
|
@@ -2,10 +2,9 @@ import copy
|
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
import pandas as pd
|
|
7
|
-
from rtctools.data import rtc
|
|
8
|
-
from rtctools.data import pi
|
|
7
|
+
from rtctools.data import pi, rtc
|
|
9
8
|
|
|
10
9
|
logger = logging.getLogger("rtctools")
|
|
11
10
|
|
|
@@ -22,7 +21,9 @@ def _get_variables_from_pi(data_config: rtc.DataConfig, timeseries: pi.Timeserie
|
|
|
22
21
|
return variables
|
|
23
22
|
|
|
24
23
|
|
|
25
|
-
def combine_xml_exports(
|
|
24
|
+
def combine_xml_exports(
|
|
25
|
+
output_base_path: Path, original_input_timeseries_path: Path, write_csv_out: bool = False
|
|
26
|
+
):
|
|
26
27
|
"""Combine the xml exports of multiple periods into a single xml file."""
|
|
27
28
|
logger.info("Combining XML exports.")
|
|
28
29
|
dataconfig = rtc.DataConfig(folder=original_input_timeseries_path)
|
|
@@ -34,14 +35,22 @@ def combine_xml_exports(output_base_path: Path, original_input_timeseries_path:
|
|
|
34
35
|
binary=False,
|
|
35
36
|
)
|
|
36
37
|
if ts_import_orig.forecast_datetime > ts_import_orig.start_datetime:
|
|
37
|
-
logger.info(
|
|
38
|
+
logger.info(
|
|
39
|
+
"Timeseries export will start at original forecast date, disregarding data before"
|
|
40
|
+
" forecast date."
|
|
41
|
+
)
|
|
38
42
|
ts_import_orig.resize(ts_import_orig.forecast_datetime, ts_import_orig.end_datetime)
|
|
39
|
-
ts_import_orig.times = ts_import_orig.times[
|
|
43
|
+
ts_import_orig.times = ts_import_orig.times[
|
|
44
|
+
ts_import_orig.times.index(ts_import_orig.forecast_datetime) :
|
|
45
|
+
]
|
|
40
46
|
orig_start_datetime = ts_import_orig.start_datetime
|
|
41
47
|
orig_end_datetime = ts_import_orig.end_datetime
|
|
42
48
|
|
|
43
49
|
ts_export = pi.Timeseries(
|
|
44
|
-
data_config=dataconfig,
|
|
50
|
+
data_config=dataconfig,
|
|
51
|
+
folder=output_base_path / "period_0",
|
|
52
|
+
basename="timeseries_export",
|
|
53
|
+
binary=False,
|
|
45
54
|
) # Use the first timeseries export as a starting point for the combined timeseries export.
|
|
46
55
|
ts_export.resize(orig_start_datetime, orig_end_datetime)
|
|
47
56
|
|
|
@@ -58,26 +67,32 @@ def combine_xml_exports(output_base_path: Path, original_input_timeseries_path:
|
|
|
58
67
|
all_times = ts_import_orig.times # Workaround to map indices to times, as ts_export does
|
|
59
68
|
# not contain all times. TODO Check whether the assumption that these times map to
|
|
60
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
|
+
)
|
|
61
85
|
for loc_par in variables:
|
|
62
86
|
try:
|
|
63
87
|
current_values = ts_export.get(loc_par)
|
|
64
88
|
new_values = ts_export_step.get(loc_par)
|
|
65
89
|
except KeyError:
|
|
66
|
-
logger.debug("Variable {} not found in output of model horizon: {}"
|
|
90
|
+
logger.debug(f"Variable {loc_par} not found in output of model horizon: {i}")
|
|
67
91
|
continue
|
|
68
|
-
new_times = ts_export_step.times
|
|
69
|
-
try:
|
|
70
|
-
start_new_data_index = all_times.index(new_times[0])
|
|
71
|
-
except ValueError:
|
|
72
|
-
if all_times[-1] + ts_export.dt == new_times[0]:
|
|
73
|
-
start_new_data_index = len(all_times)
|
|
74
|
-
else:
|
|
75
|
-
raise ValueError(
|
|
76
|
-
"Could not match the start data of the timeseries export file "
|
|
77
|
-
+ "with the end of the previous."
|
|
78
|
-
)
|
|
79
92
|
combined_values = copy.deepcopy(current_values)
|
|
80
|
-
combined_values[start_new_data_index : start_new_data_index + len(new_values)] =
|
|
93
|
+
combined_values[start_new_data_index : start_new_data_index + len(new_values[1:])] = (
|
|
94
|
+
new_values[1:]
|
|
95
|
+
) # noqa
|
|
81
96
|
ts_export.set(loc_par, combined_values)
|
|
82
97
|
i += 1
|
|
83
98
|
ts_export.write(output_folder=output_base_path.parent, output_filename="timeseries_export")
|
|
@@ -90,16 +105,16 @@ def combine_xml_exports(output_base_path: Path, original_input_timeseries_path:
|
|
|
90
105
|
values = ts_export.get(timeseries_id)
|
|
91
106
|
new_columns.append(pd.Series(values, name=timeseries_id))
|
|
92
107
|
except KeyError:
|
|
93
|
-
logger.debug("Variable {} not found in output of model horizon: {}"
|
|
108
|
+
logger.debug(f"Variable {timeseries_id} not found in output of model horizon: {i}")
|
|
94
109
|
continue
|
|
95
110
|
data = pd.concat([data] + new_columns, axis=1)
|
|
96
111
|
data.round(6).to_csv(output_base_path.parent / "timeseries_export.csv", index=False)
|
|
97
112
|
|
|
98
113
|
|
|
99
|
-
def combine_dataframes(dfs:
|
|
114
|
+
def combine_dataframes(dfs: list[pd.DataFrame], index_col: str = "time"):
|
|
100
115
|
"""Combine multiple dataframes with the same index column.
|
|
101
|
-
The dataframes are combined in the order they are passed, with the last dataframe taking
|
|
102
|
-
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."""
|
|
103
118
|
combined_df = pd.DataFrame()
|
|
104
119
|
for df in dfs:
|
|
105
120
|
df.set_index(index_col, inplace=True)
|
|
@@ -125,5 +140,7 @@ if __name__ == "__main__":
|
|
|
125
140
|
output_base_path = closed_loop_test_folder / Path(
|
|
126
141
|
r"test_models\goal_programming_xml\output\output_modelling_periods_reference"
|
|
127
142
|
)
|
|
128
|
-
original_input_timeseries_path = closed_loop_test_folder / Path(
|
|
143
|
+
original_input_timeseries_path = closed_loop_test_folder / Path(
|
|
144
|
+
r"test_models\goal_programming_xml\input"
|
|
145
|
+
)
|
|
129
146
|
combine_xml_exports(output_base_path, original_input_timeseries_path, True)
|
|
@@ -6,28 +6,38 @@ import os
|
|
|
6
6
|
import shutil
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import List, Optional
|
|
10
9
|
|
|
11
10
|
from rtctools.data.pi import DiagHandler
|
|
12
|
-
from rtctools.optimization.pi_mixin import PIMixin
|
|
13
11
|
from rtctools.optimization.csv_mixin import CSVMixin
|
|
14
|
-
from rtctools.
|
|
15
|
-
from
|
|
12
|
+
from rtctools.optimization.pi_mixin import PIMixin
|
|
13
|
+
from rtctools.util import _resolve_folder, run_optimization_problem
|
|
14
|
+
|
|
16
15
|
import rtctools_interface.closed_loop.optimization_ranges as opt_ranges
|
|
17
|
-
from rtctools_interface.closed_loop.
|
|
18
|
-
from rtctools_interface.closed_loop.
|
|
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
|
+
)
|
|
19
26
|
|
|
20
27
|
logger = logging.getLogger("rtctools")
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
def set_initial_values_from_previous_run(
|
|
24
|
-
results_previous_run:
|
|
31
|
+
results_previous_run: dict | None,
|
|
25
32
|
timeseries: TimeSeriesHandler,
|
|
26
|
-
previous_run_datetimes:
|
|
33
|
+
previous_run_datetimes: list[datetime.datetime],
|
|
27
34
|
) -> None:
|
|
28
|
-
"""Modifies the initial values of `timeseries` based on the results of the previous run
|
|
35
|
+
"""Modifies the initial values of `timeseries` based on the results of the previous run
|
|
36
|
+
(if any)"""
|
|
29
37
|
if results_previous_run is not None:
|
|
30
|
-
variables_to_set = {
|
|
38
|
+
variables_to_set = {
|
|
39
|
+
key: value for key, value in results_previous_run.items() if not timeseries.is_set(key)
|
|
40
|
+
}
|
|
31
41
|
if timeseries.forecast_date:
|
|
32
42
|
index_of_initial_value = previous_run_datetimes.index(timeseries.forecast_date)
|
|
33
43
|
else:
|
|
@@ -63,7 +73,9 @@ def _get_optimization_ranges(
|
|
|
63
73
|
"""Return a list of optimization periods."""
|
|
64
74
|
if config.file is not None:
|
|
65
75
|
datetime_range = input_timeseries.get_datetime_range()
|
|
66
|
-
optimization_ranges = opt_ranges.get_optimization_ranges_from_file(
|
|
76
|
+
optimization_ranges = opt_ranges.get_optimization_ranges_from_file(
|
|
77
|
+
config.file, datetime_range
|
|
78
|
+
)
|
|
67
79
|
elif config.optimization_period is not None:
|
|
68
80
|
datetimes = input_timeseries.get_datetimes()
|
|
69
81
|
optimization_ranges = opt_ranges.get_optimization_ranges(
|
|
@@ -73,7 +85,9 @@ def _get_optimization_ranges(
|
|
|
73
85
|
optimization_period=config.optimization_period,
|
|
74
86
|
)
|
|
75
87
|
else:
|
|
76
|
-
raise ValueError(
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"The closed-loop configuration should have either a file or optimization_period set."
|
|
90
|
+
)
|
|
77
91
|
if config.round_to_dates:
|
|
78
92
|
optimization_ranges = opt_ranges.round_datetime_ranges_to_days(optimization_ranges)
|
|
79
93
|
return optimization_ranges
|
|
@@ -84,8 +98,8 @@ def run_optimization_problem_closed_loop(
|
|
|
84
98
|
base_folder="..",
|
|
85
99
|
log_level=logging.INFO,
|
|
86
100
|
profile=False,
|
|
87
|
-
config:
|
|
88
|
-
modelling_period_input_folder:
|
|
101
|
+
config: ClosedLoopConfig | None = None,
|
|
102
|
+
modelling_period_input_folder: str | None = None,
|
|
89
103
|
**kwargs,
|
|
90
104
|
) -> dict:
|
|
91
105
|
"""
|
|
@@ -111,7 +125,7 @@ def run_optimization_problem_closed_loop(
|
|
|
111
125
|
handler.setFormatter(formatter)
|
|
112
126
|
logger.addHandler(handler)
|
|
113
127
|
if issubclass(optimization_problem_class, PIMixin):
|
|
114
|
-
if not any(
|
|
128
|
+
if not any(isinstance(h, DiagHandler) for h in logger.handlers):
|
|
115
129
|
diag_handler = DiagHandler(original_output_folder)
|
|
116
130
|
logger.addHandler(diag_handler)
|
|
117
131
|
logger.setLevel(log_level)
|
|
@@ -129,11 +143,14 @@ def run_optimization_problem_closed_loop(
|
|
|
129
143
|
if not fixed_input_config_file.exists():
|
|
130
144
|
raise FileNotFoundError(
|
|
131
145
|
f"Could not find fixed inputs configuration file: {fixed_input_config_file}"
|
|
132
|
-
"Create a file with a list of strings that represent the fixed inputs
|
|
146
|
+
"Create a file with a list of strings that represent the fixed inputs"
|
|
147
|
+
" (can be an empty list)."
|
|
133
148
|
)
|
|
134
|
-
with open(fixed_input_config_file
|
|
149
|
+
with open(fixed_input_config_file) as file:
|
|
135
150
|
fixed_input_series = json.load(file)
|
|
136
|
-
if not isinstance(fixed_input_series, list) and all(
|
|
151
|
+
if not isinstance(fixed_input_series, list) and all(
|
|
152
|
+
isinstance(item, str) for item in fixed_input_series
|
|
153
|
+
):
|
|
137
154
|
raise ValueError("Fixed input config file should be a list of strings (or an empty list).")
|
|
138
155
|
|
|
139
156
|
if modelling_period_input_folder is None:
|
|
@@ -157,44 +174,78 @@ def run_optimization_problem_closed_loop(
|
|
|
157
174
|
|
|
158
175
|
timeseries_import.select_time_range(start_date=start_time, end_date=end_time)
|
|
159
176
|
|
|
160
|
-
set_initial_values_from_previous_run(
|
|
177
|
+
set_initial_values_from_previous_run(
|
|
178
|
+
results_previous_run, timeseries_import, previous_run_datetimes
|
|
179
|
+
)
|
|
161
180
|
|
|
162
181
|
modelling_period_name = f"period_{i}"
|
|
163
182
|
modelling_period_output_folder_i = modelling_periods_output_folder / modelling_period_name
|
|
164
183
|
modelling_period_output_folder_i.mkdir(exist_ok=True)
|
|
165
184
|
modelling_period_input_folder_i = modelling_period_input_folder / modelling_period_name
|
|
166
|
-
write_input_folder(
|
|
185
|
+
write_input_folder(
|
|
186
|
+
modelling_period_input_folder_i, original_input_folder, timeseries_import
|
|
187
|
+
)
|
|
167
188
|
|
|
168
189
|
logger.info(f"Running optimization for period {i}: {(str(start_time), str(end_time))}.")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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")
|
|
178
211
|
period = f"period {i} {(str(start_time), str(end_time))}"
|
|
179
212
|
if result.solver_stats["success"]:
|
|
180
213
|
logger.info(f"Successful optimization for {period}.")
|
|
181
214
|
else:
|
|
182
|
-
message =
|
|
215
|
+
message = (
|
|
216
|
+
f"Failed optimization for {period} with status"
|
|
217
|
+
+ f"{result.solver_stats['return_status']}'."
|
|
218
|
+
)
|
|
183
219
|
logger.error(message)
|
|
184
220
|
raise Exception(message)
|
|
185
221
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
)
|
|
189
237
|
previous_run_datetimes = result.io.datetimes
|
|
190
238
|
|
|
191
239
|
logger.info("Finished all optimization runs.")
|
|
192
240
|
if issubclass(optimization_problem_class, PIMixin):
|
|
193
|
-
combine_xml_exports(
|
|
241
|
+
combine_xml_exports(
|
|
242
|
+
modelling_periods_output_folder, original_input_folder, write_csv_out=True
|
|
243
|
+
)
|
|
194
244
|
elif issubclass(optimization_problem_class, CSVMixin):
|
|
195
245
|
combine_csv_exports(modelling_periods_output_folder)
|
|
196
246
|
else:
|
|
197
247
|
logger.warning(
|
|
198
|
-
"Could not combine exports because the optimization problem class is not
|
|
248
|
+
"Could not combine exports because the optimization problem class is not "
|
|
249
|
+
"derived from PIMixin or CSVMixin."
|
|
199
250
|
)
|
|
200
251
|
return result.solver_stats
|