diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst index 0c8f7756..c583b194 100644 --- a/docs/advanced_usage.rst +++ b/docs/advanced_usage.rst @@ -145,6 +145,8 @@ A submodel of a translated model can be run as a standalone model. This can be d .. automethod:: pysd.py_backend.model.Model.select_submodel :noindex: +.. note:: + This method will mutate the original model. If you want to have a copy of the model with the selected variables/modules you can use :py:data:`inplace=False` argument. In order to preview the needed exogenous variables, the :py:meth:`.get_dependencies` method can be used: diff --git a/docs/getting_started.rst b/docs/getting_started.rst index b7ec3172..b1646125 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -272,3 +272,11 @@ We can easily access the current value of a model component using square bracket If you try to get the current values of a lookup variable, the previous method will fail, as lookup variables take arguments. However, it is possible to get the full series of a lookup or data object with :py:meth:`.get_series_data` method:: >>> model.get_series_data('Growth lookup') + + +Copying a model +--------------- +Sometimes, you may want to run several versions of a model. For this purpose, copying an already-loaded model to make changes while keeping an untouched one is useful. The :py:meth:`.copy` method will help do that; it will load a new model from the translated file and apply to it the same changes that have been applied to the original model (modifying components, selecting submodels, etc.). You can also load a copy of the source model (without applying) any change setting the argument :py:data:`reload=True`. + +.. warning:: + The copy function will load a new model from the file and apply the same changes to it. If any of these changes have replaced a variable with a function that references other variables in the model, the copy will not work properly since the function will still reference the variables in the original model, in which case the function should be redefined. diff --git a/docs/python_api/model_class.rst b/docs/python_api/model_class.rst index 55f918d3..3da4238f 100644 --- a/docs/python_api/model_class.rst +++ b/docs/python_api/model_class.rst @@ -11,3 +11,4 @@ Macro class ----------- .. autoclass:: pysd.py_backend.model.Macro :members: + :exclude-members: export diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 04f20e71..4f524802 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -1,9 +1,12 @@ 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 `_) +- :py:meth:`pysd.py_backend.model.Model.export` will now save also time component information if changed (e.g. final time, time step...). (`@enekomartinmartinez `_) Breaking changes ~~~~~~~~~~~~~~~~ @@ -14,10 +17,12 @@ Deprecations Bug fixes ~~~~~~~~~ - Set the pointer of :py:class:`pysd.py_backend.statefuls.DelayFixed` to 0 during initialization (:issue:`427`). (`@enekomartinmartinez `_) +- :py:meth:`pysd.py_backend.model.Model.export` now works with Macros. (`@enekomartinmartinez `_) 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/_version.py b/pysd/_version.py index 555cdb25..62ee17b8 100644 --- a/pysd/_version.py +++ b/pysd/_version.py @@ -1 +1 @@ -__version__ = "3.12.1" +__version__ = "3.13.0" diff --git a/pysd/py_backend/components.py b/pysd/py_backend/components.py index 140ac9b2..8eb79d9b 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,45 @@ 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 export(self): + """Exports time values to a dictionary.""" + return { + "control_vars": self._get_control_vars(), + "stage": self.stage, + "_time": self._time, + "return_timestamps": self.return_timestamps, + "_next_return": self._next_return + } + + def _get_control_vars(self): + """ + Make control vars changes exportable. + """ + out = {} + for cvar, value in self._control_vars_tracker.items(): + if callable(value): + out[cvar] = value() + else: + out[cvar] = value + return out + + def _set_time(self, time_dict): + """Copy values from other Time object, used by Model.copy""" + self.set_control_vars(**time_dict['control_vars']) + for key, value in time_dict.items(): + if key == 'control_vars': + continue + setattr(self, key, value) + def set_control_vars(self, **kwargs): """ - Set the control variables valies + Set the control variables values Parameters ---------- @@ -149,6 +182,20 @@ def set_control_vars(self, **kwargs): saveper: float, callable or None Saveper. + """ + # filter None values + kwargs = { + key: value for key, value in kwargs.items() + if value is not None + } + # track changes + 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 @@ -159,8 +206,7 @@ def _convert_value(value): return lambda: value for key, value in kwargs.items(): - if value is not None: - setattr(self, key, _convert_value(value)) + setattr(self, key, _convert_value(value)) if "initial_time" in kwargs: self._initial_time = self.initial_time() @@ -184,16 +230,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 +264,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 +279,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..3f69a189 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) @@ -208,7 +214,7 @@ def modules(self) -> Union[dict, None]: def clean_caches(self): """ - Clean the cahce of the object and the macros objects that it + Clean the cache of the object and the macros objects that it contains """ self.cache.clean() @@ -482,67 +488,6 @@ def state(self, new_value): [component.update(val) for component, val in zip(self._dynamicstateful_elements, new_value)] - def export(self, file_name): - """ - Export stateful values to pickle file. - - Parameters - ---------- - file_name: str or pathlib.Path - Name of the file to export the values. - - See also - -------- - :func:`pysd.py_backend.model.Macro.import_pickle` - - """ - warnings.warn( - "\nCompatibility of exported states could be broken between" - " different versions of PySD or xarray, current versions:\n" - f"\tPySD {__version__}\n\txarray {xr.__version__}\n" - ) - stateful_elements = { - name: element.export() - for name, element in self._stateful_elements.items() - } - - with open(file_name, 'wb') as file: - pickle.dump( - (self.time(), - stateful_elements, - {'pysd': __version__, 'xarray': xr.__version__} - ), file) - - def import_pickle(self, file_name): - """ - Import stateful values from pickle file. - - Parameters - ---------- - file_name: str or pathlib.Path - Name of the file to import the values from. - - See also - -------- - :func:`pysd.py_backend.model.Macro.export` - - """ - with open(file_name, 'rb') as file: - time, stateful_dict, metadata = pickle.load(file) - - if __version__ != metadata['pysd']\ - or xr.__version__ != metadata['xarray']: # pragma: no cover - warnings.warn( - "\nCompatibility of exported states could be broken between" - " different versions of PySD or xarray. Current versions:\n" - f"\tPySD {__version__}\n\txarray {xr.__version__}\n" - "Loaded versions:\n" - f"\tPySD {metadata['pysd']}\n\txarray {metadata['xarray']}\n" - ) - - self._set_stateful(stateful_dict) - self.time.set_control_vars(initial_time=time) - def initialize_external_data(self, externals=None): """ @@ -1085,6 +1030,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 +1164,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 """ @@ -1330,6 +1276,13 @@ def _get_elements_to_initialize(self, modified_statefuls): return elements_to_initialize + def export(self): + """Exports stateful values to a dictionary.""" + return { + name: element.export() + for name, element in self._stateful_elements.items() + } + def _set_stateful(self, stateful_dict): """ Set stateful values. @@ -1341,8 +1294,14 @@ def _set_stateful(self, stateful_dict): """ for element, attrs in stateful_dict.items(): - for attr, value in attrs.items(): - setattr(getattr(self.components, element), attr, value) + component = getattr(self.components, element) + if hasattr(component, '_set_stateful'): + component._set_stateful(attrs) + else: + [ + setattr(component, attr, value) + for attr, value in attrs.items() + ] def _build_doc(self): """ @@ -1431,12 +1390,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() @@ -1705,12 +1671,13 @@ def _config_simulation(self, params, return_columns, return_timestamps, Internal method to set all simulation config parameters. Arguments to this function are those of the run and set_stepper methods. """ - + # set control var at the beginning in case they are needed to + # initialize any object self._set_control_vars(return_timestamps, final_time, time_step, saveper) if params: - self._set_components(params, new=False) + self.set_components(params) if self._stepper_mode: for step_var in kwargs["step_vars"]: @@ -1719,7 +1686,11 @@ def _config_simulation(self, params, return_columns, return_timestamps, # update cache types after setting params self._assign_cache_type() + # set initial conditions self.set_initial_condition(initial_condition) + # set control vars again in case a pickle has been used + self._set_control_vars(return_timestamps, final_time, time_step, + saveper) # progressbar only makes sense when not running step by step if not self._stepper_mode: @@ -1810,12 +1781,13 @@ def _set_control_vars(self, return_timestamps, final_time, time_step, if self.time.return_timestamps: final_time = self.time.return_timestamps[0] else: - final_time = self.time.next_return + final_time = self.time._next_return 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 +1815,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 +1865,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 +1945,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 +1961,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 +2114,64 @@ 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 the model will be copied without applying to it any + change, the copy will simply load the model again from the + translated file. This would be equivalent to doing + :py:func:`pysd.load` with the same arguments. Otherwise, it + will apply the same changes that have been applied to the + original model and update the states (faithful copy). + Default is False. + + Warning + ------- + The copy function will load a new model from the file and apply + the same changes to it. If any of these changes have replaced a + variable with a function that references other variables in the + model, the copy will not work properly since the function will + still reference the variables in the original model, in which + case the function should be redefined. + + 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: + new_model._set_stateful(deepcopy(super().export())) + # copy time object values + new_model.time._set_time(deepcopy(self.time.export())) + # set other components + with warnings.catch_warnings(): + # filter 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 +2179,7 @@ def reload(self): See also -------- + :func:`pysd.py_backend.model.Model.copy` :func:`pysd.py_backend.model.Model.initialize` """ @@ -2229,6 +2279,7 @@ def set_initial_condition(self, initial_condition): self.set_initial_value(*initial_condition) elif isinstance(initial_condition, Path): self.import_pickle(initial_condition) + self.time.set_control_vars(initial_time=self.time()) elif isinstance(initial_condition, str): if initial_condition.lower() in ["original", "o"]: self.time.set_control_vars( @@ -2291,3 +2342,59 @@ def _integrate_step(self): self._euler_step(self.time.time_step()) self.time.update(self.time()+self.time.time_step()) self.clean_caches() + + def export(self, file_name): + """ + Export stateful values to pickle file. + + Parameters + ---------- + file_name: str or pathlib.Path + Name of the file to export the values. + + See also + -------- + :func:`pysd.py_backend.model.Model.import_pickle` + + """ + warnings.warn( + "\nCompatibility of exported states could be broken between" + " different versions of PySD or xarray, current versions:\n" + f"\tPySD {__version__}\n\txarray {xr.__version__}\n" + ) + with open(file_name, 'wb') as file: + pickle.dump( + (self.time.export(), + super().export(), + {'pysd': __version__, 'xarray': xr.__version__} + ), file) + + def import_pickle(self, file_name): + """ + Import stateful values from pickle file. + + Parameters + ---------- + file_name: str or pathlib.Path + Name of the file to import the values from. + + See also + -------- + :func:`pysd.py_backend.model.Model.export_pickle` + + """ + with open(file_name, 'rb') as file: + time_dict, stateful_dict, metadata = pickle.load(file) + + if __version__ != metadata['pysd']\ + or xr.__version__ != metadata['xarray']: # pragma: no cover + warnings.warn( + "\nCompatibility of exported states could be broken between" + " different versions of PySD or xarray. Current versions:\n" + f"\tPySD {__version__}\n\txarray {xr.__version__}\n" + "Loaded versions:\n" + f"\tPySD {metadata['pysd']}\n\txarray {metadata['xarray']}\n" + ) + + self.time._set_time(time_dict) + self._set_stateful(stateful_dict) diff --git a/tests/pytest_pysd/pytest_pysd.py b/tests/pytest_pysd/pytest_pysd.py index d5cddea8..cb5b85fa 100644 --- a/tests/pytest_pysd/pytest_pysd.py +++ b/tests/pytest_pysd/pytest_pysd.py @@ -22,7 +22,7 @@ + "test_get_lookups_subscripted_args.mdl") test_model_data = Path( "test-models/tests/get_data_args_3d_xls/test_get_data_args_3d_xls.mdl") - +test_macro_stock = Path("test-models/tests/macro_stock/test_macro_stock.mdl") test_model_query = Path("test-models/samples/Query_file/Query_file.mdl") more_tests = Path("more-tests") @@ -1329,6 +1329,58 @@ 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 + + @pytest.mark.parametrize("model_path", [test_macro_stock]) + def test_copy_macro(self, model): + # check that macro's statefuls are copied recursively + out1 = model.run(return_columns=['macro_output']).values[-1] + model2 = model.copy() + assert model2['macro_output'] == out1 + class TestModelInteraction(): """ The tests in this class test pysd's interaction with itself @@ -1582,10 +1634,15 @@ class TestExportImport(): Path('test-models/tests/initial_function/test_initial.mdl'), ([8, 20], [8], [20]), (None, 15, None) + ), + ( + test_macro_stock, + ([8, 20], [8], [20]), + (None, 15, None) ) ], ids=["integ", "delays", "delay_fixed", "forecast", "sample_if_true", - "smooth", "trend", "initial"] + "smooth", "trend", "initial", "macro_stock"] ) @pytest.mark.filterwarnings("ignore") def test_run_export_import(self, tmp_path, model, return_ts, final_t): @@ -1628,6 +1685,15 @@ def test_run_export_import(self, tmp_path, model, return_ts, final_t): if return_ts[2]: assert_frames_close(stocks2, stocks.loc[return_ts[2]]) + # test that time component is exported + model.export(export_path) + model.reload() + model.import_pickle(export_path) + assert model.time._time == finals[2] + assert model.time.final_time() == finals[2] + assert model.time.initial_time() == finals[1] + assert model.time.stage == "Run" + class TestStepper(): 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",