diff --git a/doc/optimization.rst b/doc/optimization.rst index 704b0f3f..59e17898 100644 --- a/doc/optimization.rst +++ b/doc/optimization.rst @@ -11,6 +11,7 @@ Contents: optimization/modelica_models optimization/csv_io optimization/fews_io + optimization/multi_seeding optimization/linearization optimization/lookup_tables optimization/homotopy diff --git a/doc/optimization/multi_seeding.rst b/doc/optimization/multi_seeding.rst new file mode 100644 index 00000000..1d7d518f --- /dev/null +++ b/doc/optimization/multi_seeding.rst @@ -0,0 +1,10 @@ +Using multiple seeds +==================== + +A seed can be defined by implementing the :py:meth:`OptimizaionProblem.seed` method. +To implement a workflow for solving an optimization problem by trying multiple seeds, +one can use the :py:class:`MultiSeedMixin` class. + +.. autoclass:: rtctools.optimization.multi_seed_mixin.MultiSeedMixin + :members: use_seed_id, selected_seed_id, seed_ids, seed_from_id + :show-inheritance: diff --git a/src/rtctools/optimization/multi_seed_mixin.py b/src/rtctools/optimization/multi_seed_mixin.py new file mode 100644 index 00000000..6c0ad5a9 --- /dev/null +++ b/src/rtctools/optimization/multi_seed_mixin.py @@ -0,0 +1,79 @@ +from typing import Any, Dict, Iterable + +from rtctools.optimization.optimization_problem import OptimizationProblem +from rtctools.optimization.timeseries import Timeseries + + +class MultiSeedMixin(OptimizationProblem): + """ + Enables a workflow to solve an optimization problem by trying multiple seeds. + """ + + def __init__(self, **kwargs): + self.__selected_seed_id = None + super().__init__(**kwargs) + + def use_seed_id(self) -> bool: + """Return ``True`` if the selected seed id is used. + + Return ``True`` if the seed corresponding to the selected seed identifier is used + for the current run. + By default, this is ``True``. + Overwrite this method when in some cases the seed is not based on the seed id, + e.g. in case of goal programming, return False after the first priority, + or in case of homotopy, return ``False`` after the homotopy parameter is increased. + """ + return True + + def selected_seed_id(self) -> Any: + """Get the selected seed identifier. + + If the selected seed id is None, use the default seed. + """ + return self.__selected_seed_id + + def seed_ids(self) -> Iterable: + """Return a list or iterator of seed identifiers. + + When the id is None, the default seed will be used. + This method should be implemented by the user. + """ + raise NotImplementedError("This method should be implemented by the user.") + + def seed_from_id(self, seed_id) -> Dict[str, Timeseries]: + """ + Get the seed timeseries from the selected identifier. + + This method should be implemented by the user. + """ + del seed_id + raise NotImplementedError("This method should be implemented by the user.") + + def seed(self, ensemble_member): + if self.selected_seed_id() is None or not self.use_seed_id(): + return super().seed(ensemble_member) + seed: dict = super().seed(ensemble_member).copy() # Copy to prevent updating cached seeds. + seed_from_id = self.seed_from_id(self.selected_seed_id()) + seed.update(seed_from_id) + return seed + + def optimize( + self, + preprocessing: bool = True, + postprocessing: bool = True, + log_solver_failure_as_error: bool = True, + ) -> bool: + if preprocessing: + self.pre() + for seed_id in self.seed_ids(): + self.__selected_seed_id = seed_id + success = super().optimize( + preprocessing=False, + postprocessing=False, + log_solver_failure_as_error=log_solver_failure_as_error, + ) + if success: + break + if postprocessing: + self.post() + return success diff --git a/tests/optimization/data/reservoir/.gitignore b/tests/optimization/data/reservoir/.gitignore new file mode 100644 index 00000000..6cf9204d --- /dev/null +++ b/tests/optimization/data/reservoir/.gitignore @@ -0,0 +1 @@ +timeseries_export.* \ No newline at end of file diff --git a/tests/optimization/data/reservoir/initial_state.csv b/tests/optimization/data/reservoir/initial_state.csv new file mode 100644 index 00000000..f56b4869 --- /dev/null +++ b/tests/optimization/data/reservoir/initial_state.csv @@ -0,0 +1,2 @@ +volume +15.0 \ No newline at end of file diff --git a/tests/optimization/data/reservoir/reservoir.mo b/tests/optimization/data/reservoir/reservoir.mo new file mode 100644 index 00000000..4460416a --- /dev/null +++ b/tests/optimization/data/reservoir/reservoir.mo @@ -0,0 +1,9 @@ +model Reservoir + // Basic model for in/outlow of a reservoir + parameter Real theta; + input Real q_in(fixed=true); + input Real q_out(fixed=false, min=0.0, max=5.0); + output Real volume; +equation + der(volume) = q_in - (2 - theta) * q_out; +end Reservoir; diff --git a/tests/optimization/data/reservoir/rtcDataConfig.xml b/tests/optimization/data/reservoir/rtcDataConfig.xml new file mode 100644 index 00000000..695d382b --- /dev/null +++ b/tests/optimization/data/reservoir/rtcDataConfig.xml @@ -0,0 +1,15 @@ + + + + + Seeds + Q + + + + + Inputs + Q + + + diff --git a/tests/optimization/data/reservoir/seed.csv b/tests/optimization/data/reservoir/seed.csv new file mode 100644 index 00000000..8938f03f --- /dev/null +++ b/tests/optimization/data/reservoir/seed.csv @@ -0,0 +1,6 @@ +Time,q_out +2020-01-01 00:00:00, +2020-01-01 00:00:01,1.0 +2020-01-01 00:00:02, +2020-01-01 00:00:03,3.0 +2020-01-01 00:00:04, \ No newline at end of file diff --git a/tests/optimization/data/reservoir/seed.xml b/tests/optimization/data/reservoir/seed.xml new file mode 100644 index 00000000..b88c829f --- /dev/null +++ b/tests/optimization/data/reservoir/seed.xml @@ -0,0 +1,21 @@ + + 0.0 + + + instantaneous + Seeds + Q + + + + + -999.0 + m3/s + + + + + + + + \ No newline at end of file diff --git a/tests/optimization/data/reservoir/timeseries_import.csv b/tests/optimization/data/reservoir/timeseries_import.csv new file mode 100644 index 00000000..f8ca4418 --- /dev/null +++ b/tests/optimization/data/reservoir/timeseries_import.csv @@ -0,0 +1,6 @@ +Time,q_in +2020-01-01 00:00:00,0 +2020-01-01 00:00:01,1.0 +2020-01-01 00:00:02,2.0 +2020-01-01 00:00:03,3.0 +2020-01-01 00:00:04,4.0 \ No newline at end of file diff --git a/tests/optimization/data/reservoir/timeseries_import.xml b/tests/optimization/data/reservoir/timeseries_import.xml new file mode 100644 index 00000000..5bdf9bc4 --- /dev/null +++ b/tests/optimization/data/reservoir/timeseries_import.xml @@ -0,0 +1,21 @@ + + 0.0 + + + instantaneous + Inputs + Q + + + + + -999.0 + m3/s + + + + + + + + \ No newline at end of file diff --git a/tests/optimization/test_multi_seed_mixin.py b/tests/optimization/test_multi_seed_mixin.py new file mode 100644 index 00000000..1bed33ad --- /dev/null +++ b/tests/optimization/test_multi_seed_mixin.py @@ -0,0 +1,228 @@ +from pathlib import Path + +from rtctools.data.pi import Timeseries as PiTimeseries +from rtctools.data.util import ( + fill_nan_in_timeseries, +) +from rtctools.optimization.collocated_integrated_optimization_problem import ( + CollocatedIntegratedOptimizationProblem, +) +from rtctools.optimization.csv_mixin import CSVMixin, get_timeseries_from_csv +from rtctools.optimization.goal_programming_mixin import Goal, GoalProgrammingMixin, StateGoal +from rtctools.optimization.homotopy_mixin import HomotopyMixin +from rtctools.optimization.io_mixin import IOMixin +from rtctools.optimization.modelica_mixin import ModelicaMixin +from rtctools.optimization.multi_seed_mixin import MultiSeedMixin +from rtctools.optimization.optimization_problem import OptimizationProblem +from rtctools.optimization.pi_mixin import PIMixin +from rtctools.optimization.timeseries import Timeseries +from test_case import TestCase + +DATA_DIR = Path(__file__).parent / "data" / "reservoir" + + +class WaterVolumeGoal(StateGoal): + """Keep the volume within a given range.""" + + priority = 1 + state = "volume" + target_min = 10 + target_max = 15 + + +class MinimizeQOutGoal(Goal): + """Minimize the outflow.""" + + priority = 2 + + def function(self, optimization_problem, ensemble_member): + del self + del ensemble_member + return optimization_problem.integral("q_out") + + +class Reservoir( + MultiSeedMixin, + HomotopyMixin, + GoalProgrammingMixin, + IOMixin, + ModelicaMixin, + CollocatedIntegratedOptimizationProblem, + OptimizationProblem, +): + """Optimization problem for controlling a reservoir.""" + + def __init__(self, **kwargs): + kwargs["model_name"] = "Reservoir" + kwargs["input_folder"] = DATA_DIR + kwargs["output_folder"] = DATA_DIR + kwargs["model_folder"] = DATA_DIR + super().__init__(**kwargs) + + def bounds(self): + bounds = super().bounds() + bounds["volume"] = (0, 20.0) + return bounds + + def goals(self): + del self + return [MinimizeQOutGoal()] + + def path_goals(self): + return [WaterVolumeGoal(self)] + + def seed_ids(self): + return [DATA_DIR / "seed.csv", None] + + def use_seed_id(self): + if not self._gp_first_run: + return False + theta_name = self.homotopy_options()["homotopy_parameter"] + theta = self.parameters(ensemble_member=0)[theta_name] + theta_start = self.homotopy_options()["theta_start"] + if theta > theta_start: + return False + return True + + def homotopy_options(self): + options = super().homotopy_options() + if self.selected_seed_id() is not None: + options["theta_start"] = 1.0 + return options + + +class ReservoirCSV( + Reservoir, + CSVMixin, + ModelicaMixin, + CollocatedIntegratedOptimizationProblem, +): + """Reservoir class using CSV files.""" + + def seed_from_id(self, seed_id: Path): + times, var_dict = get_timeseries_from_csv(seed_id) + times_sec = self.io.datetime_to_sec(times, self.io.reference_datetime) + seed = {} + for var, values in var_dict.items(): + values = fill_nan_in_timeseries(times, values) + seed[var] = Timeseries(times_sec, values) + return seed + + +class ReservoirPI( + Reservoir, + PIMixin, + ModelicaMixin, + CollocatedIntegratedOptimizationProblem, +): + """Reservoir class using Delft-FEWS Published Interface files.""" + + pi_parameter_config_basenames = [] + + def seed_from_id(self, seed_id: Path): + timeseries = PiTimeseries( + data_config=self.data_config, + folder=seed_id.parent, + basename=seed_id.stem, + binary=False, + ) + times = timeseries.times + times_sec = self.io.datetime_to_sec(times, self.io.reference_datetime) + seed = {} + for var, values in timeseries.items(): + values = fill_nan_in_timeseries(times, values) + seed[var] = Timeseries(times_sec, values) + return seed + + +class DummySolver(OptimizationProblem): + """Class for enforcing a solver result for testing purposes.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.success = [] # Keep track of solver results. + + def enforced_solver_result(self): + """Return the enforced solver result.""" + del self + return None + + def optimize( + self, + preprocessing: bool = True, + postprocessing: bool = True, + log_solver_failure_as_error: bool = True, + ) -> bool: + success = super().optimize(preprocessing, postprocessing, log_solver_failure_as_error) + if self.enforced_solver_result() is not None: + success = self.enforced_solver_result() + self.success.append(success) + return success + + +class ReservoirTest(Reservoir, DummySolver): + """Class for testing a reservoir model.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Keep track of seeds/priorities/thetas of each run. + self.seeds_q_out = [] + self.priorities = [] + self.thetas = [] + self.used_seeds = [] + + def enforced_solver_result(self): + # Enforce failure when using a seed. + return False if self.selected_seed_id() is not None else None + + def priority_started(self, priority: int): + super().priority_started(priority) + # Keep track of seeds/priorities/thetas for testing purposes. + seed = self.seed(ensemble_member=0) + self.seeds_q_out.append(seed.get("q_out")) + self.used_seeds.append(self.selected_seed_id() is not None) + self.priorities.append(priority) + self.thetas.append(self.parameters(ensemble_member=0)["theta"]) + + +class ReservoirCSVTest(ReservoirTest, ReservoirCSV, DummySolver): + """ReservoirTest class using CSV files.""" + + pass + + +class ReservoirPITest(ReservoirTest, ReservoirPI, DummySolver): + """ReservoirTest class using Delft-FEWS Published Interface files.""" + + pass + + +class TestSeedMixin(TestCase): + """Test class for seeding with fallback.""" + + def _test_seeding_with_fallback(self, model: ReservoirTest): + """Test using a seed from a file with a fallback option.""" + model.optimize() + ref_used_seeds = [True, False, False, False, False] + ref_thetas = [1, 0, 0, 1, 1] + ref_priorities = [1, 1, 2, 1, 2] + ref_success = [False, True, True, True, True] + ref_seeds = [ + Timeseries([0, 1, 2, 3, 4], [1, 1, 2, 3, 3]), + None, + ] + self.assertEqual(model.used_seeds, ref_used_seeds) + self.assertEqual(model.thetas, ref_thetas) + self.assertEqual(model.priorities, ref_priorities) + self.assertEqual(model.success, ref_success) + self.assertEqual(model.seeds_q_out[:2], ref_seeds) + + def test_seeding_with_fallback_csv(self): + """Test using a seed from a CSV file with a fallback option.""" + model = ReservoirCSVTest() + self._test_seeding_with_fallback(model) + + def test_seeding_with_fallback_pi(self): + """Test using a seed from a PI file with a fallback option.""" + model = ReservoirPITest() + self._test_seeding_with_fallback(model)