Skip to content

Commit

Permalink
Add copy method
Browse files Browse the repository at this point in the history
  • Loading branch information
enekomartinmartinez committed Dec 24, 2023
1 parent 1cd0cba commit 1f64c9f
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 21 deletions.
5 changes: 4 additions & 1 deletion docs/whats_new.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/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 <https://github.com/enekomartinmartinez>`_)

Breaking changes
~~~~~~~~~~~~~~~~
Expand All @@ -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 <https://github.com/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 <https://github.com/enekomartinmartinez>`_)

Performance
~~~~~~~~~~~
Expand Down
41 changes: 30 additions & 11 deletions pysd/py_backend/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import random
import inspect
import importlib.util
from copy import deepcopy

import numpy as np

Expand Down Expand Up @@ -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
----------
Expand All @@ -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
Expand Down Expand Up @@ -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 "
Expand All @@ -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):
Expand All @@ -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 """
Expand Down
100 changes: 91 additions & 9 deletions pysd/py_backend/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import inspect
import pickle
from pathlib import Path
from copy import deepcopy
from typing import Union

import numpy as np
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 """
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
----
Expand Down Expand Up @@ -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, "
Expand Down Expand Up @@ -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
Expand All @@ -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=[]):
"""
Expand Down Expand Up @@ -2123,13 +2157,61 @@ 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
parameters are back to their original value.
See also
--------
:func:`pysd.py_backend.model.Model.copy`
:func:`pysd.py_backend.model.Model.initialize`
"""
Expand Down
45 changes: 45 additions & 0 deletions tests/pytest_pysd/pytest_pysd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 1f64c9f

Please sign in to comment.