Skip to content

Commit b01c792

Browse files
authored
Include parent classes in process name/class reverse lookup (#45)
* include parent classes in process name/class reverse lookup * add tests * DOC: add subsection in create_model section * avoid conda solver conflicts for doc environment * add what's new entry
1 parent d08c334 commit b01c792

File tree

6 files changed

+134
-3
lines changed

6 files changed

+134
-3
lines changed

doc/create_model.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,52 @@ In this latter case, users will have to provide initial values of
291291
possible to modify these instances by adding, updating or removing
292292
processes. Both methods ``.update_processes()`` and
293293
``.drop_processes()`` always return new instances of ``Model``.
294+
295+
Customize existing processes
296+
----------------------------
297+
298+
Sometimes we only want to update an existing model with very minor
299+
changes.
300+
301+
As an example, let's update ``model2`` by using a fixed grid (i.e.,
302+
with hard-coded values for grid spacing and length). One way to
303+
achieve this is to create a small new process class that sets
304+
the values of ``spacing`` and ``length``:
305+
306+
.. literalinclude:: scripts/advection_model.py
307+
:lines: 151-158
308+
309+
However, one drawback of this "additive" approach is that the number
310+
of processes in a model might become unnecessarily high:
311+
312+
.. literalinclude:: scripts/advection_model.py
313+
:lines: 161-161
314+
315+
Alternatively, it is possible to write a process class that inherits
316+
from ``UniformGrid1D``, in which we can re-declare variables *and/or*
317+
re-define "runtime" methods:
318+
319+
.. literalinclude:: scripts/advection_model.py
320+
:lines: 164-172
321+
322+
We can here directly update the model and replace the original process
323+
``UniformGrid1D`` by the inherited class ``FixedGrid``. Foreign
324+
variables that refer to ``UniformGrid1D`` will still correctly point
325+
to the ``grid`` process in the updated model:
326+
327+
.. literalinclude:: scripts/advection_model.py
328+
:lines: 175-175
329+
330+
.. warning::
331+
332+
This feature is experimental! It may be removed in a next version of
333+
xarray-simlab.
334+
335+
In particular, linking foreign variables in a model is ambiguous
336+
when both conditions below are met:
337+
338+
- two different processes in a model inherit from a common class
339+
(except ``object``)
340+
341+
- a third process in the same model has a foreign variable that
342+
links to that common class

doc/environment.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ channels:
55
dependencies:
66
- attrs=19.1.0
77
- python=3.7
8-
- numpy=1.16.0
9-
- pandas=0.23.3
8+
- numpy=1.17.2
9+
- pandas=0.25.1
1010
- xarray=0.13.0
1111
- ipython=7.8.0
1212
- matplotlib=3.0.2

doc/scripts/advection_model.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,30 @@ def initialize(self):
146146

147147

148148
model4 = model2.drop_processes('init')
149+
150+
151+
@xs.process
152+
class FixedGridParams(object):
153+
spacing = xs.foreign(UniformGrid1D, 'spacing', intent='out')
154+
length = xs.foreign(UniformGrid1D, 'length', intent='out')
155+
156+
def initialize(self):
157+
self.spacing = 0.01
158+
self.length = 1.
159+
160+
161+
model5 = model2.update_processes({'fixed_grid_params': FixedGridParams})
162+
163+
164+
@xs.process
165+
class FixedGrid(UniformGrid1D):
166+
spacing = xs.variable(description='uniform spacing', intent='out')
167+
length = xs.variable(description='total length', intent='out')
168+
169+
def initialize(self):
170+
self.spacing = 0.01
171+
self.length = 1.
172+
super(FixedGrid, self).initialize()
173+
174+
175+
model6 = model2.update_processes({'grid': FixedGrid})

doc/whats_new.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ Release Notes
66
v0.3.0 (Unreleased)
77
-------------------
88

9+
Breaking changes
10+
~~~~~~~~~~~~~~~~
11+
12+
- It is now possible to use class inheritance to customize a process
13+
without re-writing the class from scratch and without breaking the
14+
links between (foreign) variables when replacing the process in a
15+
model (:issue:`45`). Although it should work just fine in most
16+
cases, there are potential caveats. This should be considered as an
17+
experimental, possibly breaking change.
18+
919
Enhancements
1020
~~~~~~~~~~~~
1121

xsimlab/model.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self, processes_cls):
4343
self._processes_cls = processes_cls
4444
self._processes_obj = {k: cls() for k, cls in processes_cls.items()}
4545

46-
self._reverse_lookup = {cls: k for k, cls in processes_cls.items()}
46+
self._reverse_lookup = self._get_reverse_lookup(processes_cls)
4747

4848
self._input_vars = None
4949

@@ -53,6 +53,24 @@ def __init__(self, processes_cls):
5353
# a cache for group keys
5454
self._group_keys = {}
5555

56+
def _get_reverse_lookup(self, processes_cls):
57+
"""Return a dictionary with process classes as keys and process names
58+
as values.
59+
60+
Additionally, the returned dictionary maps all parent classes
61+
to one (str) or several (list) process names.
62+
63+
"""
64+
reverse_lookup = defaultdict(list)
65+
66+
for p_name, p_cls in processes_cls.items():
67+
# exclude `object` base class from lookup
68+
for cls in p_cls.mro()[:-1]:
69+
reverse_lookup[cls].append(p_name)
70+
71+
return {k: v[0] if len(v) == 1 else v
72+
for k, v in reverse_lookup.items()}
73+
5674
def bind_processes(self, model_obj):
5775
for p_name, p_obj in self._processes_obj.items():
5876
p_obj.__xsimlab_model__ = model_obj
@@ -92,6 +110,17 @@ def _get_var_key(self, p_name, var):
92110
.format(target_p_cls.__name__, var.name, p_name)
93111
)
94112

113+
elif isinstance(target_p_name, list):
114+
raise ValueError(
115+
"Process class {!r} required by foreign variable '{}.{}' "
116+
"is used (possibly via one its child classes) by multiple "
117+
"processes: {}"
118+
.format(
119+
target_p_cls.__name__, p_name, var.name,
120+
', '.join(['{!r}'.format(n) for n in target_p_name])
121+
)
122+
)
123+
95124
store_key, od_key = self._get_var_key(target_p_name, target_var)
96125

97126
elif var_type == VarType.GROUP:

xsimlab/tests/test_model.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ def test_get_stage_processes(self, model):
109109
expected = [model['roll'], model['profile']]
110110
assert model._p_run_step == expected
111111

112+
def test_process_inheritance(self, model):
113+
@xs.process
114+
class InheritedProfile(Profile):
115+
pass
116+
117+
new_model = model.update_processes(
118+
{'profile': InheritedProfile})
119+
120+
assert type(new_model['profile']) is InheritedProfile
121+
assert isinstance(new_model['profile'], Profile)
122+
123+
with pytest.raises(ValueError) as excinfo:
124+
invalid_model = model.update_processes(
125+
{'profile2': InheritedProfile})
126+
assert "multiple processes" in str(excinfo.value)
127+
112128

113129
class TestModel(object):
114130

0 commit comments

Comments
 (0)