Skip to content

Commit e51f1c3

Browse files
Merge pull request #390 from rogersamso/subview_sep
Fix issues with the separation of Vensim views into modules and submodules
2 parents f3f3c56 + e68ac57 commit e51f1c3

File tree

10 files changed

+229
-41
lines changed

10 files changed

+229
-41
lines changed

docs/command_line_usage.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ In order to split the Vensim model views in different files, as explained in :do
5858

5959
.. code-block:: text
6060
61-
python -m pysd --split-views many_views_model.mdl
61+
python -m pysd many_views_model.mdl --split-views
62+
63+
The previous code will put each model view in a separate Python module. Additionally, if the names of the views include the concepts of subsubmodules (e.g., ENERGY-transformation.efficiency_improvement), the *--subview-sep* (subview separators) argument may be used to further classify the model equations:
64+
65+
.. code-block:: text
66+
67+
python -m pysd many_views_and_subviews_model.mdl --split-views --subview-sep - .
68+
69+
Note that passing any positional argument right after the *--subview-sep* argument will raise an error, so it is recommended to pass this argument as the last one.
6270

6371

6472
Outputting various run information
@@ -102,7 +110,7 @@ modified using the *-I/--initial_time*, *-F/--final-time*, *-T/--time-step* and
102110

103111
.. code-block:: text
104112
105-
python -m pysd -I 2005 --final-time=2010 --time-step=1 Teacup.mdl
113+
python -m pysd -I=2005 --final-time=2010 --time-step=1 Teacup.mdl
106114
107115
will set the initial time to 2005, the final time to 2010 and the time step to 1.
108116

docs/development/adding_functions.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Adding new functions
22
====================
3-
In this section you may found some helpful examples for adding a new function to the PySD Python builder. Before starting adding any new feature or fuction, please, make sure that no one is working on it. Search if any open issue exists with the feature you want to work on or open a new one if it does not exist. Then, claim that you are working on it.
3+
In this section you may find some helpful examples for adding a new function to the PySD Python builder. Before starting adding any new feature or function, please, make sure that no one is working on it. Search if any open issue exists with the feature you want to work on or open a new one if it does not exist. Then, claim that you are working on it.
44

55
Adding a hardcoded function
66
---------------------------

docs/whats_new.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,22 @@ Breaking changes
1313
Deprecations
1414
~~~~~~~~~~~~
1515

16+
Bug fixes
17+
~~~~~~~~~
18+
- Fix issue with the classification of variables in modules and submodules (:issue:`388`). When a model had a view with 3 sublevels (e.g. energy-transformation.losses) but another view was defined with only two of them (e.g. energy-transformation), the variables in the second view were placed in the main model file. Now, if this happens, the variables in the second view will be placed in a main.py file (i.e. energy/transformation/main.py). (`@rogersamso <https://github.com/rogersamso>`_)
19+
- Fix bug on the CLI when passing a hyphen as first value to the *--subview-sep* argument (:issue:`388`). (`@rogersamso <https://github.com/rogersamso>`_)
20+
- Fix bug on the CLI when parsing initial conditions (:issue:`395`). (`@rogersamso <https://github.com/rogersamso>`_)
21+
1622
Documentation
1723
~~~~~~~~~~~~~
24+
- The `Splitting Vensim views in different files` section in :doc:`command_line_usage` has been updated to include an example of the usage of the *--subview-sep* CLI argument. (`@rogersamso <https://github.com/rogersamso>`_)
1825

1926
Performance
2027
~~~~~~~~~~~
2128

2229
Internal Changes
2330
~~~~~~~~~~~~~~~~
31+
- The :py:meth:`_merge_nested_dicts` method from the :py:class:`pysd.translators.vensim.vensim_file.VensimFile` class has been made a static method, as it does not need to access any attribute of the instance, and it does facilitate unit testing. (`@rogersamso <https://github.com/rogersamso>`_)
2432
- The `pysd/translators/vensim/parsing_grammars/element_object.peg` grammar has been modified to be able to parse reality check elements. (`@rogersamso <https://github.com/rogersamso>`_)
2533
- :py:class:`pysd.translators.vensim.vensim_element.Constraint` and :py:class:`pysd.translators.vensim.vensim_element.TestInputs` classes have been added, which inherit from the also newly created :py:class:`pysd.translators.vensim.vensim_element.GenericComponent`, which include the :py:meth:`parse` and :py:meth:`get_abstract_component` methods. (`@rogersamso <https://github.com/rogersamso>`_ and `@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
2634
- The :py:class:`pysd.translators.structures.abstract_model.AbstractSection` class now has two extra attributes (:py:data:`constraints` and :py:data:`input_tests`), which hold the :py:class:`pysd.translators.structures.abstract_model.AbstractConstraint` and :py:class:`pysd.translators.structures.abstract_model.AbstractTestInputs` objects. (`@rogersamso <https://github.com/rogersamso>`_)

pysd/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .parser import parser
1414

15+
1516
def main(args):
1617
"""
1718
Main function. Reads user arguments, loads the models,

pysd/cli/parser.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
cmdline parser
33
"""
44
import os
5+
import re
56
from ast import literal_eval
67
import numpy as np
78
import pandas as pd
@@ -113,15 +114,6 @@ def split_timestamps(string):
113114
f'See {docs} for examples.')
114115

115116

116-
def split_subview_sep(string):
117-
"""
118-
Splits the subview separators
119-
--subview-sep ' - ,.' -> [' - ', '.']
120-
121-
"""
122-
return string.split(",")
123-
124-
125117
def split_vars(string):
126118
"""
127119
Splits the arguments from new_values.
@@ -141,8 +133,8 @@ def split_vars(string):
141133
var, value = string.split(':')
142134
type = 'initial'
143135

144-
if value.strip().isnumeric():
145-
# value is float
136+
if re.match(r"^[+-]?(\d*\.)?\d+$", value.strip()):
137+
# value is a number
146138
return {var.strip(): (type, float(value))}
147139

148140
# value is series
@@ -193,7 +185,7 @@ def __call__(self, parser, namespace, values, option_string=None):
193185
parser.add_argument(
194186
'-o', '--output-file', dest='output_file',
195187
type=check_output, metavar='FILE',
196-
help='output file to save run outputs (.tab or .csv)')
188+
help='output file to save run outputs (.tab, .csv or .nc)')
197189

198190
parser.add_argument(
199191
'-p', '--progress', dest='progress',
@@ -282,11 +274,12 @@ def __call__(self, parser, namespace, values, option_string=None):
282274

283275
trans_arguments.add_argument(
284276
'--subview-sep', dest='subview_sep',
285-
action='store', type=split_subview_sep, default=[],
286-
metavar='\'STRING1,STRING2,..,STRINGN\'',
287-
help='further division of views split in subviews, by identifying the'
277+
action='store', nargs="*", default=[],
278+
metavar='separator_1 separator_2 ... separator_n',
279+
help='further division of views into subviews, by identifying the '
288280
'separator string in the view name, only availabe if --split-views'
289-
' is used')
281+
' is used. Passing positional arguments after this argument will'
282+
' not work')
290283

291284

292285
#######################
@@ -316,17 +309,17 @@ def __call__(self, parser, namespace, values, option_string=None):
316309
parser.add_argument('new_values',
317310
metavar='variable=new_value', type=split_vars,
318311
nargs='*', action=SplitVarsAction,
319-
help='redefine the value of variable with new value.'
320-
'variable must be a model component, new_value can be a '
321-
'float or a a list of two list')
312+
help='redefine the value of variable with new value. '
313+
'variable must be a model component, new_value may be a '
314+
'float or a list of two lists')
322315

323-
# The destionation new_values2 will never used as the previous argument
316+
# The destination new_values2 will never be used as the previous argument
324317
# is given also with nargs='*'. Nevertheless, the following variable
325318
# is declared for documentation
326319
parser.add_argument('new_values2',
327320
metavar='variable:initial_value', type=split_vars,
328321
nargs='*', action=SplitVarsAction,
329-
help='redefine the initial value of variable.'
322+
help='redefine the initial value of variable. '
330323
'variable must be a model stateful element, initial_value'
331324
' must be a float')
332325

pysd/py_backend/allocation.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -682,13 +682,11 @@ def allocate_by_priority(request, priority, width, supply):
682682

683683
if np.any(width <= 0):
684684
raise ValueError(
685-
f"width={width} is not allowed. width should be greater than 0."
686-
)
685+
f"width={width} \n is not allowed. width must be greater than 0.")
687686

688687
if np.any(supply < 0):
689688
raise ValueError(
690-
f"supply={supply} is not allowed. supply should be non-negative."
691-
)
689+
f"supply={supply} \n is not allowed. supply must not be negative.")
692690

693691
if len(request.shape) == 1:
694692
# NUMPY: avoid '.values' and return directly the result of the

pysd/translators/vensim/vensim_file.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def parse_sketch(self, subview_sep: List[str]) -> None:
216216
nested_dict = {item: nested_dict}
217217
# merging the new nested_dict into the views_dict, preserving
218218
# repeated keys
219-
self._merge_nested_dicts(views_dict, nested_dict)
219+
VensimFile._merge_nested_dicts(views_dict, nested_dict)
220220
else:
221221
# view names do not have separators or separator characters
222222
# not provided
@@ -276,29 +276,41 @@ def _clean_file_names(*args):
276276
).lstrip("0123456789")
277277
for name in args]
278278

279-
def _merge_nested_dicts(self, original_dict, dict_to_merge):
279+
@staticmethod
280+
def _merge_nested_dicts(original, to_merge):
280281
"""
281282
Merge dictionaries recursively, preserving common keys.
282283
283284
Parameters
284285
----------
285-
original_dict: dict
286+
original: dict
286287
Dictionary onto which the merge is executed.
287288
288-
dict_to_merge: dict
289+
to_merge: dict or set
289290
Dictionary to be merged to the original_dict.
290291
291292
Returns
292293
-------
293294
None
294295
295296
"""
296-
for key, value in dict_to_merge.items():
297-
if (key in original_dict and isinstance(original_dict[key], dict)
298-
and isinstance(value, Mapping)):
299-
self._merge_nested_dicts(original_dict[key], value)
297+
if isinstance(to_merge, Mapping):
298+
for key, value in to_merge.items():
299+
if key in original:
300+
if isinstance(original[key], set):
301+
# convert it into a dict
302+
original[key] = {"main": original[key]}
303+
VensimFile._merge_nested_dicts(original[key], value)
304+
else:
305+
original[key] = value
306+
else: # it's a set
307+
if "main" not in original:
308+
original["main"] = to_merge
300309
else:
301-
original_dict[key] = value
310+
original["main"].update(to_merge)
311+
warnings.warn("Two views with same names but different "
312+
"separators where identified. They will be "
313+
"joined in a single module")
302314

303315

304316
class FileSectionsVisitor(parsimonious.NodeVisitor):

tests/pytest_pysd/pytest_allocation.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,24 +430,24 @@ def test__allocate_by_priority_1d(self, requests, priority, width,
430430
0,
431431
5,
432432
ValueError,
433-
r"width=0 is not allowed\. width should be greater than 0\."
433+
r"width=0 \n is not allowed\. width must be greater than 0\."
434434
),
435435
( # negative width
436436
xr.DataArray([6, 3, 3], {'dim': ["A", "B", "C"]}),
437437
xr.DataArray([10, 5, 0], {'dim': ["A", "B", "C"]}),
438438
-3,
439439
7.5,
440440
ValueError,
441-
r"width=-3 is not allowed\. width should be greater than 0\."
441+
r"width=-3 \n is not allowed\. width must be greater than 0\."
442442
),
443443
( # negative supply
444444
xr.DataArray([6, 3, 3], {'dim': ["A", "B", "C"]}),
445445
xr.DataArray([10, 5, 0], {'dim': ["A", "B", "C"]}),
446446
3,
447447
-7.5,
448448
ValueError,
449-
r"supply=-7\.5 is not allowed\. "
450-
r"supply should be non-negative\."
449+
r"supply=-7\.5 \n is not allowed\. "
450+
r"supply must not be negative\."
451451
),
452452
],
453453
)

tests/pytest_pysd/pytest_cli.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import pysd
1414
from pysd.tools.benchmarking import load_outputs, assert_frames_close
15+
from pysd.cli.parser import parser
1516

1617
test_model_ven = "test-models/samples/teacup/teacup.mdl"
1718

@@ -254,6 +255,30 @@ def test_read_vensim_split_model_subviews(self, test_copy):
254255
assert (modules_dirname / "view_1" / "submodule_2.py").exists()
255256
assert (modules_dirname / "view_2.py").exists()
256257

258+
@pytest.mark.parametrize(
259+
"model", ["more-tests/split_model/test_split_model_subviews.mdl"]
260+
)
261+
@pytest.mark.parametrize(
262+
"separators,expected",
263+
[
264+
([], []),
265+
(['--subview-sep', '-'], ["-"]),
266+
(['--subview-sep', '-', ","], ["-", ","]),
267+
(['--subview-sep', '.', "-"], [".", "-"])
268+
269+
])
270+
def test_read_vensim_split_model_subviews_hyphen(self, test_copy,
271+
separators, expected):
272+
"""
273+
Test that several arguments to the subview-sep option are correctly
274+
parsed, and particularly when passing a hyphen.
275+
"""
276+
mdl_path = str(test_copy)
277+
args = [mdl_path, '--translate', '--split-views'] + separators
278+
279+
options = parser.parse_args(args)
280+
options.subview_sep = expected
281+
257282
@pytest.mark.parametrize("model", [test_model_ven])
258283
def test_run_return_timestamps(self, out_csv, out_tab, test_copy):
259284

@@ -538,3 +563,71 @@ def test_save_without_name(self, out_tab, test_copy, tmp_path):
538563
out = load_outputs(tmp_path / outputs)
539564
out2 = pysd.read_vensim(test_copy).run()
540565
assert_frames_close(out, out2)
566+
567+
568+
@pytest.mark.parametrize(
569+
"model", ["more-tests/split_model/test_split_model_sub_subviews.mdl"]
570+
)
571+
class TestCLI():
572+
"""To test the CLI, ordering arguments differently"""
573+
574+
@pytest.mark.parametrize(
575+
"cli_args,expected",
576+
[(['-I=1.0', '--final-time=101',
577+
'--split-views', '--subview-sep', '-', '.'],
578+
{"new_values": {"param": {"another var": 2.0},
579+
"initial": {"Stock": -1.1}},
580+
"initial_time": 1.0,
581+
"final_time": 101,
582+
"split_views": True,
583+
"subview_sep": ["-", "."]
584+
}
585+
),
586+
(['--subview-sep', '-', '.', '-I=1.0', '--final-time=101',
587+
'--split-views'],
588+
{"new_values": {"param": {"another var": 2.0},
589+
"initial": {"Stock": -1.1}},
590+
"initial_time": 1.0,
591+
"final_time": 101,
592+
"split_views": True,
593+
"subview_sep": ["-", "."]
594+
}
595+
)
596+
],
597+
ids=["subview_sep_last", "subview_sep_first"]
598+
)
599+
def test_cli_arguments_parsing(self, test_copy, cli_args, expected):
600+
601+
mdl_path = str(test_copy)
602+
603+
positional_args = [mdl_path, 'another var=2', 'Stock:-1.1']
604+
605+
# putting positional arguments at the begining
606+
positionals_first = positional_args + cli_args
607+
parsed = parser.parse_args(positionals_first)
608+
609+
for arg in expected:
610+
assert expected[arg] == getattr(parsed, arg)
611+
612+
@pytest.mark.xfail(
613+
reason="Passing positional arguments after nargs argument")
614+
@pytest.mark.parametrize(
615+
"cli_args,expected",
616+
[(['--split-views', '--subview-sep', '-', '.'],
617+
{"split_views": True, "subview_sep": ["-", "."]}
618+
),
619+
(['another var=2', 'Stock:-1.1'],
620+
{"new_values": {"param": {"another var": 2.0},
621+
"initial": {"Stock": -1.1}}}
622+
)
623+
]
624+
)
625+
def test_positional_arguments_after_subview_sep(elf, test_copy, cli_args,
626+
expected):
627+
628+
mdl_path = str(test_copy)
629+
630+
# putting positional arguments after the subview_sep argument
631+
positionals_last = cli_args + [mdl_path]
632+
633+
parsed = parser.parse_args(positionals_last)

0 commit comments

Comments
 (0)