From 1f64c9fa76695f437c2d37e40e99ea328f9cce0c Mon Sep 17 00:00:00 2001 From: Eneko Martin-Martinez Date: Sun, 24 Dec 2023 01:27:02 +0100 Subject: [PATCH] Add copy method --- docs/whats_new.rst | 5 +- pysd/py_backend/components.py | 41 ++++++-- pysd/py_backend/model.py | 100 +++++++++++++++++-- tests/pytest_pysd/pytest_pysd.py | 45 +++++++++ tests/pytest_pysd/pytest_select_submodel.py | 105 ++++++++++++++++++++ 5 files changed, 275 insertions(+), 21 deletions(-) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 04f20e71..5f8723c3 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -1,9 +1,11 @@ What's New ========== -v3.12.1 (2023/12/XX) +v3.13.0 (2023/12/25) -------------------- New Features ~~~~~~~~~~~~ +- Include new method :py:meth:`pysd.py_backend.model.Model.copy` which allows copying a model (:issue:`131`). (`@enekomartinmartinez `_) +- :py:meth:`pysd.py_backend.model.Model.select_submodel` now takes an optional argument `inplace` when set to :py:data:`False` it will return a modified copy of the model instead of modifying the original model (:issue:`131`). (`@enekomartinmartinez `_) Breaking changes ~~~~~~~~~~~~~~~~ @@ -18,6 +20,7 @@ Bug fixes Documentation ~~~~~~~~~~~~~ - Improve documentation of methods in :py:class:`pysd.py_backend.model.Model` and :py:class:`pysd.py_backend.model.Macro` includying cross-references and rewrite the one from :py:meth:`pysd.py_backend.model.Macro.set_components`. (`@enekomartinmartinez `_) +- Include documentation about the new method :py:meth:`pysd.py_backend.model.Model.copy` and update documentation from :py:meth:`pysd.py_backend.model.Model.select_submodel`. (`@enekomartinmartinez `_) Performance ~~~~~~~~~~~ diff --git a/pysd/py_backend/components.py b/pysd/py_backend/components.py index 140ac9b2..d84d80d4 100644 --- a/pysd/py_backend/components.py +++ b/pysd/py_backend/components.py @@ -7,6 +7,7 @@ import random import inspect import importlib.util +from copy import deepcopy import numpy as np @@ -129,13 +130,23 @@ def __init__(self): self._time = None self.stage = None self.return_timestamps = None + self._next_return = None + self._control_vars_tracker = {} def __call__(self): return self._time + def _copy(self, other): + """Copy values from other Time object, used by Model.copy""" + self.set_control_vars(**other._control_vars_tracker) + self._time = deepcopy(other._time) + self.stage = deepcopy(other.stage) + self.return_timestamps = deepcopy(other.return_timestamps) + self._next_return = deepcopy(other._next_return) + def set_control_vars(self, **kwargs): """ - Set the control variables valies + Set the control variables values Parameters ---------- @@ -149,6 +160,14 @@ def set_control_vars(self, **kwargs): saveper: float, callable or None Saveper. + """ + self._control_vars_tracker.update(kwargs) + self._set_control_vars(**kwargs) + + def _set_control_vars(self, **kwargs): + """ + Set the control variables values. Private version to be used + to avoid tracking changes. """ def _convert_value(value): # this function is necessary to avoid copying the pointer in the @@ -184,16 +203,16 @@ def in_return(self): if self.return_timestamps is not None: # this allows managing float precision error - if self.next_return is None: + if self._next_return is None: return False - if np.isclose(self._time, self.next_return, prec): + if np.isclose(self._time, self._next_return, prec): self._update_next_return() return True else: - while self.next_return is not None\ - and self._time > self.next_return: + while self._next_return is not None\ + and self._time > self._next_return: warn( - f"The returning time stamp '{self.next_return}' " + f"The returning time stamp '{self._next_return}' " "seems to not be a multiple of the time step. " "This value will not be saved in the output. " "Please, modify the returning timestamps or the " @@ -218,12 +237,12 @@ def add_return_timestamps(self, return_timestamps): and len(return_timestamps) > 0: self.return_timestamps = list(return_timestamps) self.return_timestamps.sort(reverse=True) - self.next_return = self.return_timestamps.pop() + self._next_return = self.return_timestamps.pop() elif isinstance(return_timestamps, (float, int)): - self.next_return = return_timestamps + self._next_return = return_timestamps self.return_timestamps = [] else: - self.next_return = None + self._next_return = None self.return_timestamps = None def update(self, value): @@ -233,9 +252,9 @@ def update(self, value): def _update_next_return(self): """ Update the next_return value """ if self.return_timestamps: - self.next_return = self.return_timestamps.pop() + self._next_return = self.return_timestamps.pop() else: - self.next_return = None + self._next_return = None def reset(self): """ Reset time value to the initial """ diff --git a/pysd/py_backend/model.py b/pysd/py_backend/model.py index ae0a19b7..fefa3f77 100644 --- a/pysd/py_backend/model.py +++ b/pysd/py_backend/model.py @@ -10,6 +10,7 @@ import inspect import pickle from pathlib import Path +from copy import deepcopy from typing import Union import numpy as np @@ -85,6 +86,11 @@ def __init__(self, py_model_file, params=None, return_func=None, self.lookups_loaded = False # Functions with constant cache self._constant_funcs = set() + # Attributes that are set later + self.stateful_initial_dependencies = None + self.initialize_order = None + self.cache_type = None + self._components_setter_tracker = {} # Load model/macro from file and save in components self.components = Components(str(py_model_file), self.set_components) @@ -1085,6 +1091,7 @@ def set_components(self, params): :func:`pysd.py_backend.model.Macro.get_args` """ + self._components_setter_tracker.update(params) self._set_components(params, new=False) def _set_components(self, params, new): @@ -1218,9 +1225,9 @@ def _timeseries_component(self, series, dims): else: # the interpolation will be time dependent - return lambda:\ - np.interp(self.time(), series.index, series.values),\ - {'time': 1} + return lambda: np.interp( + self.time(), series.index, series.values + ), {'time': 1} def _constant_component(self, value, dims): """ Internal function for creating a constant model element """ @@ -1431,12 +1438,19 @@ def __init__(self, py_model_file, data_files, initialize, missing_values): """ Sets up the Python objects """ super().__init__(py_model_file, None, None, Time(), data_files=data_files) - self.time.stage = 'Load' - self.time.set_control_vars(**self.components._control_vars) self.data_files = data_files self.missing_values = missing_values + # set time component + self.time.stage = 'Load' + # set control var privately to do not change it when copying + self.time._set_control_vars(**self.components._control_vars) + # Attributes that are set later self.progress = None self.output = None + self.capture_elements = None + self.return_addresses = None + self._stepper_mode = None + self._submodel_tracker = {} if initialize: self.initialize() @@ -1710,7 +1724,7 @@ def _config_simulation(self, params, return_columns, return_timestamps, saveper) if params: - self._set_components(params, new=False) + self.set_components(params) if self._stepper_mode: for step_var in kwargs["step_vars"]: @@ -1815,7 +1829,8 @@ def _set_control_vars(self, return_timestamps, final_time, time_step, self.time.set_control_vars( final_time=final_time, time_step=time_step, saveper=saveper) - def select_submodel(self, vars=[], modules=[], exogenous_components={}): + def select_submodel(self, vars=[], modules=[], exogenous_components={}, + inplace=True): """ Select a submodel from the original model. After selecting a submodel only the necessary stateful objects for integrating this submodel will @@ -1843,9 +1858,16 @@ def select_submodel(self, vars=[], modules=[], exogenous_components={}): set_components method. By default it is an empty dict and the needed exogenous components will be set to a numpy.nan value. + inplace: bool (optional) + If True it will modify current object and will return None. + If False it will create a copy of the model and return it + keeping the original model unchange. Default is True. + Returns ------- - None + None or pysd.py_backend.model.Model + If inplace=False it will return a modified copy of the + original model. Note ---- @@ -1886,6 +1908,17 @@ def select_submodel(self, vars=[], modules=[], exogenous_components={}): :func:`pysd.py_backend.model.Model.get_dependencies` """ + if inplace: + self._select_submodel(vars, modules, exogenous_components) + else: + return self.copy()._select_submodel( + vars, modules, exogenous_components) + + def _select_submodel(self, vars, modules, exogenous_components={}): + self._submodel_tracker = { + "vars": vars, + "modules": modules + } deps = self.get_dependencies(vars, modules) warnings.warn( "Selecting submodel, " @@ -1955,7 +1988,7 @@ def select_submodel(self, vars=[], modules=[], exogenous_components={}): self._namespace)[1]: value }) for key, value in exogenous_components.items()] - self._set_components(new_components, new=False) + self.set_components(new_components) # show a warning message if exogenous values are needed for a # dependency @@ -1971,6 +2004,7 @@ def select_submodel(self, vars=[], modules=[], exogenous_components={}): # re-assign the cache_type and initialization order self._assign_cache_type() self._get_initialize_order() + return self def get_dependencies(self, vars=[], modules=[]): """ @@ -2123,6 +2157,53 @@ def get_vars_in_module(self, module): return vars + def copy(self, reload=False): + """ + Create a copy of the current model. + + Parameters + ---------- + reload: bool (optional) + If true creates a copy with a reloaded model from the + translated model file. + + See also + -------- + :func:`pysd.py_backend.model.Model.reload` + """ + # initialize the new model? + initialize = self.time.stage != 'Load' + # create a new model + new_model = type(self)( + py_model_file=deepcopy(self.py_model_file), + data_files=deepcopy(self.data_files), + initialize=initialize, + missing_values=deepcopy(self.missing_values) + ) + if reload: + # return reloaded copy + return new_model + # copy the values of the stateful objects + if initialize: + states = deepcopy({ + name: element.export() + for name, element in self._stateful_elements.items() + }) + new_model._set_stateful(states) + # copy time object values + new_model.time._copy(self.time) + # set other components + with warnings.catch_warnings(): + # ignore warnings that have been already shown in original model + warnings.simplefilter("ignore") + # substract submodel + if self._submodel_tracker: + new_model._select_submodel(**self._submodel_tracker) + # copy modified parameters + new_model.set_components(self._components_setter_tracker) + + return new_model + def reload(self): """ Reloads the model from the translated model file, so that all the @@ -2130,6 +2211,7 @@ def reload(self): See also -------- + :func:`pysd.py_backend.model.Model.copy` :func:`pysd.py_backend.model.Model.initialize` """ diff --git a/tests/pytest_pysd/pytest_pysd.py b/tests/pytest_pysd/pytest_pysd.py index d5cddea8..c85888f3 100644 --- a/tests/pytest_pysd/pytest_pysd.py +++ b/tests/pytest_pysd/pytest_pysd.py @@ -1329,6 +1329,51 @@ def test_files(self, model, model_path, tmp_path): def test_performance_dataframe(self, model): model.run() + @pytest.mark.parametrize("model_path", [test_model]) + def test_copy(self, model): + # check copying the model and running one of them + model2 = model.copy() + assert model['room_temperature'] == model2['room_temperature'] + assert model['room_temperature'] != 3 + model2.components.room_temperature = 3 + assert model['room_temperature'] != model2['room_temperature'] + model.run() + assert model.time() == 30 + assert np.isclose(model['teacup_temperature'], 75.374) + assert model2.time() == 0 + assert np.isclose(model2['teacup_temperature'], 180) + + # check that time is copied + model3 = model.copy() + assert model3.time() == 30 + assert np.isclose(model3['teacup_temperature'], 75.374) + + # check that changes in variables are done again + assert model['room_temperature'] == 70 + model.components.room_temperature = 200 + model4 = model.copy() + assert model4['room_temperature'] == 200 + + # check that changes in control vars are copied + model.run(initial_condition=(3, {}), final_time=5) + model5 = model.copy() + assert model5.time.initial_time() == 3 + assert model5.time.final_time() == 5 + assert model5.time.time_step() == model.time.time_step() + assert model5.time.saveper() == model.time.saveper() + model.run(time_step=3, saveper=6, final_time=16) + model6 = model.copy() + assert model6.time.initial_time() == model.time.initial_time() + assert model6.time.final_time() == 16 + assert model6.time.time_step() == 3 + assert model6.time.saveper() == 6 + + # check a reloaded model + modelr = model.copy(reload=True) + assert modelr['room_temperature'] == 70 + assert modelr['teacup_temperature'] == 180 + assert modelr.time() == 0 + class TestModelInteraction(): """ The tests in this class test pysd's interaction with itself diff --git a/tests/pytest_pysd/pytest_select_submodel.py b/tests/pytest_pysd/pytest_select_submodel.py index 5001f859..fba77029 100644 --- a/tests/pytest_pysd/pytest_select_submodel.py +++ b/tests/pytest_pysd/pytest_select_submodel.py @@ -190,6 +190,111 @@ def test_select_submodel(self, model, variables, modules, assert not np.any(np.isnan(model.run())) + def test_select_submodel_copy(self, model, variables, modules, + n_deps, dep_vars): + + # assert original stateful elements + assert len(model._dynamicstateful_elements) == 2 + assert "_integ_other_stock" in model._stateful_elements + assert "_integ_other_stock" in model._dependencies + assert "other_stock" in model._dependencies + assert "other stock" in model._namespace + assert "other stock" in model._doc["Real Name"].to_list() + assert "other_stock" in model._doc["Py Name"].to_list() + assert "_integ_stock" in model._stateful_elements + assert "_integ_stock" in model._dependencies + assert "stock" in model._dependencies + assert "Stock" in model._namespace + assert "Stock" in model._doc["Real Name"].to_list() + assert "stock" in model._doc["Py Name"].to_list() + + # select submodel + with pytest.warns(UserWarning) as record: + model2 = model.select_submodel(vars=variables, modules=modules, + inplace=False) + + # assert warning + assert str(record[0].message) == self.warning + + # assert original stateful elements + assert len(model._dynamicstateful_elements) == 2 + assert "_integ_other_stock" in model._stateful_elements + assert "_integ_other_stock" in model._dependencies + assert "other_stock" in model._dependencies + assert "other stock" in model._namespace + assert "other stock" in model._doc["Real Name"].to_list() + assert "other_stock" in model._doc["Py Name"].to_list() + assert "_integ_stock" in model._stateful_elements + assert "_integ_stock" in model._dependencies + assert "stock" in model._dependencies + assert "Stock" in model._namespace + assert "Stock" in model._doc["Real Name"].to_list() + assert "stock" in model._doc["Py Name"].to_list() + + # assert stateful elements change + assert len(model2._dynamicstateful_elements) == 1 + assert "_integ_other_stock" not in model2._stateful_elements + assert "_integ_other_stock" not in model2._dependencies + assert "other_stock" not in model2._dependencies + assert "other stock" not in model2._namespace + assert "other stock" not in model2._doc["Real Name"].to_list() + assert "other_stock" not in model2._doc["Py Name"].to_list() + assert "_integ_stock" in model2._stateful_elements + assert "_integ_stock" in model2._dependencies + assert "stock" in model2._dependencies + assert "Stock" in model2._namespace + assert "Stock" in model2._doc["Real Name"].to_list() + assert "stock" in model2._doc["Py Name"].to_list() + + if not dep_vars: + # totally independent submodels can run without producing + # nan values + assert not np.any(np.isnan(model2.run())) + else: + # running the model without redefining dependencies will + # produce nan values + assert "Exogenous components for the following variables are"\ + + " necessary but not given:" in str(record[-1].message) + assert "Please, set them before running the model using "\ + + "set_components method..." in str(record[-1].message) + for var in dep_vars: + assert var in str(record[-1].message) + assert np.any(np.isnan(model2.run())) + # redefine dependencies + warn_message = "Replacing a variable by a constant value." + with pytest.warns(UserWarning, match=warn_message): + out = model2.run(params=dep_vars) + assert not np.any(np.isnan(out)) + + # select submodel using contour values + with pytest.warns(UserWarning) as record: + model2 = model.select_submodel(vars=variables, modules=modules, + exogenous_components=dep_vars, + inplace=False) + + assert not np.any(np.isnan(model2.run())) + + # copy of submodel + model3 = model2.copy() + # assert stateful elements change + assert len(model3._dynamicstateful_elements) == 1 + assert "_integ_other_stock" not in model3._stateful_elements + assert "_integ_other_stock" not in model3._dependencies + assert "other_stock" not in model3._dependencies + assert "other stock" not in model3._namespace + assert "other stock" not in model3._doc["Real Name"].to_list() + assert "other_stock" not in model3._doc["Py Name"].to_list() + assert "_integ_stock" in model3._stateful_elements + assert "_integ_stock" in model3._dependencies + assert "stock" in model3._dependencies + assert "Stock" in model3._namespace + assert "Stock" in model3._doc["Real Name"].to_list() + assert "stock" in model3._doc["Py Name"].to_list() + + # dep_vars should be already set + out = model3.run() + assert not np.any(np.isnan(out)) + @pytest.mark.parametrize( "model_path,split_views,module,raise_type,error_message",