Skip to content

Commit ad41aa8

Browse files
Add random functions tests and truncexpon (#441)
- Add random functions tests - Fix truncation in Vensim's RANDOM NORMAL - Support Vensim's RANDOM EXPONENTIAL
1 parent a6064b7 commit ad41aa8

File tree

8 files changed

+242
-62
lines changed

8 files changed

+242
-62
lines changed

docs/tables/functions.tab

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,7 @@ ALLOCATE AVAILABLE "ALLOCATE AVAILABLE(request, pp, avail)" "AllocateAvailable
4646
ALLOCATE BY PRIORITY "ALLOCATE BY PRIORITY(request, priority, size, width, supply)" "AllocateByPriorityStructure(request, priority, size, width, supply)" allocate_by_priority(request, priority, width, supply)
4747
INITIAL INITIAL(value) init init(value) InitialStructure(value) pysd.statefuls.Initial
4848
SAMPLE IF TRUE "SAMPLE IF TRUE(condition, input, initial_value)" "SampleIfTrueStructure(condition, input, initial_value)" pysd.statefuls.SampleIfTrue(...)
49+
RANDOM 0 1 "RANDOM 0 1()" "CallStructure('random_0_1', ())" np.random.uniform(0, 1, size=final_shape)
50+
RANDOM UNIFORM "RANDOM UNIFORM(m, x, s)" "CallStructure('random_uniform', (m, x, s))" np.random.uniform(m, x, size=final_shape)
51+
RANDOM NORMAL "RANDOM NORMAL(m, x, h, r, s)" "CallStructure('random_normal', (m, x, h, r, s))" stats.truncnorm.rvs((m-h)/r, (x-h)/r, loc=h, scale=r, size=final_shape)
52+
RANDOM EXPONENTIAL "RANDOM EXPONENTIAL(m, x, h, r, s)" "CallStructure('random_exponential', (m, x, h, r, s))" stats.truncexpon.rvs((x-np.maximum(m, h))/r, loc=np.maximum(m, h), scale=r, size=final_shape)

docs/whats_new.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
What's New
22
==========
3+
v3.14.0 (2024/04/24)
4+
--------------------
5+
New Features
6+
~~~~~~~~~~~~
7+
- Support Vensim's `RANDOM EXPONENTIAL <https://www.vensim.com/documentation/fn_random.html>`_ function (:issue:`107`). (`@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
8+
9+
Breaking changes
10+
~~~~~~~~~~~~~~~~
11+
12+
Deprecations
13+
~~~~~~~~~~~~
14+
15+
Bug fixes
16+
~~~~~~~~~
17+
- Fix truncation in Vensim's `RANDOM NORMAL <https://www.vensim.com/documentation/fn_random.html>`_ function translation. (`@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
18+
19+
Documentation
20+
~~~~~~~~~~~~~
21+
- Add supported random functions to the documentation tables. (`@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
22+
23+
Performance
24+
~~~~~~~~~~~
25+
26+
Internal Changes
27+
~~~~~~~~~~~~~~~~
28+
- Add test for random functions including comparison with Vensim outputs and expected values (:issue:`107`). (`@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
29+
- Allow to add multiple imports by the python function call builder. (`@enekomartinmartinez <https://github.com/enekomartinmartinez>`_)
30+
331
v3.13.4 (2024/02/29)
432
--------------------
533
New Features

pysd/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "3.13.4"
1+
__version__ = "3.14.0"

pysd/builders/python/python_expressions_builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,9 +606,9 @@ def build_function_call(self, arguments: dict) -> BuildAST:
606606
"""
607607
# Get the function expression from the functionspace
608608
expression, modules = functionspace[self.function]
609-
if modules:
609+
for module in modules:
610610
# Update module dependencies in imports
611-
self.section.imports.add(*modules)
611+
self.section.imports.add(*module)
612612

613613
calls = self.join_calls(arguments)
614614

pysd/builders/python/python_functions.py

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,118 +2,120 @@
22
# functions that can be diretcly applied over an array
33
functionspace = {
44
# directly build functions without dependencies
5-
"elmcount": ("len(%(0)s)", None),
5+
"elmcount": ("len(%(0)s)", ()),
66

77
# directly build numpy based functions
8-
"pi": ("np.pi", ("numpy",)),
9-
"abs": ("np.abs(%(0)s)", ("numpy",)),
10-
"power": ("np.power(%(0)s,%(1)s)", ("numpy",)),
11-
"min": ("np.minimum(%(0)s, %(1)s)", ("numpy",)),
12-
"max": ("np.maximum(%(0)s, %(1)s)", ("numpy",)),
13-
"exp": ("np.exp(%(0)s)", ("numpy",)),
14-
"sin": ("np.sin(%(0)s)", ("numpy",)),
15-
"cos": ("np.cos(%(0)s)", ("numpy",)),
16-
"tan": ("np.tan(%(0)s)", ("numpy",)),
17-
"arcsin": ("np.arcsin(%(0)s)", ("numpy",)),
18-
"arccos": ("np.arccos(%(0)s)", ("numpy",)),
19-
"arctan": ("np.arctan(%(0)s)", ("numpy",)),
20-
"sinh": ("np.sinh(%(0)s)", ("numpy",)),
21-
"cosh": ("np.cosh(%(0)s)", ("numpy",)),
22-
"tanh": ("np.tanh(%(0)s)", ("numpy",)),
23-
"sqrt": ("np.sqrt(%(0)s)", ("numpy",)),
24-
"ln": ("np.log(%(0)s)", ("numpy",)),
25-
"log": ("(np.log(%(0)s)/np.log(%(1)s))", ("numpy",)),
26-
# NUMPY: "invert_matrix": ("np.linalg.inv(%(0)s)", ("numpy",)),
8+
"pi": ("np.pi", (("numpy",),)),
9+
"abs": ("np.abs(%(0)s)", (("numpy",),)),
10+
"power": ("np.power(%(0)s,%(1)s)", (("numpy",),)),
11+
"min": ("np.minimum(%(0)s, %(1)s)", (("numpy",),)),
12+
"max": ("np.maximum(%(0)s, %(1)s)", (("numpy",),)),
13+
"exp": ("np.exp(%(0)s)", (("numpy",),)),
14+
"sin": ("np.sin(%(0)s)", (("numpy",),)),
15+
"cos": ("np.cos(%(0)s)", (("numpy",),)),
16+
"tan": ("np.tan(%(0)s)", (("numpy",),)),
17+
"arcsin": ("np.arcsin(%(0)s)", (("numpy",),)),
18+
"arccos": ("np.arccos(%(0)s)", (("numpy",),)),
19+
"arctan": ("np.arctan(%(0)s)", (("numpy",),)),
20+
"sinh": ("np.sinh(%(0)s)", (("numpy",),)),
21+
"cosh": ("np.cosh(%(0)s)", (("numpy",),)),
22+
"tanh": ("np.tanh(%(0)s)", (("numpy",),)),
23+
"sqrt": ("np.sqrt(%(0)s)", (("numpy",),)),
24+
"ln": ("np.log(%(0)s)", (("numpy",),)),
25+
"log": ("(np.log(%(0)s)/np.log(%(1)s))", (("numpy",),)),
26+
# NUMPY: "invert_matrix": ("np.linalg.inv(%(0)s)", (("numpy",),)),
2727

2828
# vector functions with axis to apply over
2929
# NUMPY:
30-
# "prod": "np.prod(%(0)s, axis=%(axis)s)", ("numpy",)),
31-
# "sum": "np.sum(%(0)s, axis=%(axis)s)", ("numpy",)),
32-
# "vmax": "np.max(%(0)s, axis=%(axis)s)", ("numpy", )),
33-
# "vmin": "np.min(%(0)s, axis=%(axis)s)", ("numpy",))
34-
"prod": ("prod(%(0)s, dim=%(axis)s)", ("functions", "prod")),
35-
"sum": ("sum(%(0)s, dim=%(axis)s)", ("functions", "sum")),
36-
"vmax": ("vmax(%(0)s, dim=%(axis)s)", ("functions", "vmax")),
37-
"vmin": ("vmin(%(0)s, dim=%(axis)s)", ("functions", "vmin")),
38-
"vmax_xmile": ("vmax(%(0)s)", ("functions", "vmax")),
39-
"vmin_xmile": ("vmin(%(0)s)", ("functions", "vmin")),
30+
# "prod": "np.prod(%(0)s, axis=%(axis)s)", (("numpy",),)),
31+
# "sum": "np.sum(%(0)s, axis=%(axis)s)", (("numpy",),)),
32+
# "vmax": "np.max(%(0)s, axis=%(axis)s)", ("numpy",),)),
33+
# "vmin": "np.min(%(0)s, axis=%(axis)s)", (("numpy",),))
34+
"prod": ("prod(%(0)s, dim=%(axis)s)", (("functions", "prod"),)),
35+
"sum": ("sum(%(0)s, dim=%(axis)s)", (("functions", "sum"),)),
36+
"vmax": ("vmax(%(0)s, dim=%(axis)s)", (("functions", "vmax"),)),
37+
"vmin": ("vmin(%(0)s, dim=%(axis)s)", (("functions", "vmin"),)),
38+
"vmax_xmile": ("vmax(%(0)s)", (("functions", "vmax"),)),
39+
"vmin_xmile": ("vmin(%(0)s)", (("functions", "vmin"),)),
4040
"vector_select": (
4141
"vector_select(%(0)s, %(1)s, %(axis)s, %(2)s, %(3)s, %(4)s)",
42-
("functions", "vector_select")
42+
(("functions", "vector_select"),)
4343
),
4444

4545
# functions defined in pysd.py_bakcend.functions
4646
"active_initial": (
4747
"active_initial(__data[\"time\"].stage, lambda: %(0)s, %(1)s)",
48-
("functions", "active_initial")),
48+
(("functions", "active_initial"),)),
4949
"if_then_else": (
5050
"if_then_else(%(0)s, lambda: %(1)s, lambda: %(2)s)",
51-
("functions", "if_then_else")),
51+
(("functions", "if_then_else"),)),
5252
"integer": (
5353
"integer(%(0)s)",
54-
("functions", "integer")),
54+
(("functions", "integer"),)),
5555
"invert_matrix": ( # NUMPY: remove
5656
"invert_matrix(%(0)s)",
57-
("functions", "invert_matrix")), # NUMPY: remove
57+
(("functions", "invert_matrix"),)), # NUMPY: remove
5858
"modulo": (
5959
"modulo(%(0)s, %(1)s)",
60-
("functions", "modulo")),
60+
(("functions", "modulo"),)),
6161
"pulse": (
6262
"pulse(__data['time'], %(0)s, width=%(1)s)",
63-
("functions", "pulse")),
63+
(("functions", "pulse"),)),
6464
"Xpulse": (
6565
"pulse(__data['time'], %(0)s, magnitude=%(1)s)",
66-
("functions", "pulse")),
66+
(("functions", "pulse"),)),
6767
"pulse_train": (
6868
"pulse(__data['time'], %(0)s, repeat_time=%(1)s, width=%(2)s, "\
6969
"end=%(3)s)",
70-
("functions", "pulse")),
70+
(("functions", "pulse"),)),
7171
"Xpulse_train": (
7272
"pulse(__data['time'], %(0)s, repeat_time=%(1)s, magnitude=%(2)s)",
73-
("functions", "pulse")),
73+
(("functions", "pulse"),)),
7474
"get_time_value": (
7575
"get_time_value(__data['time'], %(0)s, %(1)s, %(2)s)",
76-
("functions", "get_time_value")),
76+
(("functions", "get_time_value"),)),
7777
"quantum": (
7878
"quantum(%(0)s, %(1)s)",
79-
("functions", "quantum")),
79+
(("functions", "quantum"),)),
8080
"Xramp": (
8181
"ramp(__data['time'], %(0)s, %(1)s)",
82-
("functions", "ramp")),
82+
(("functions", "ramp"),)),
8383
"ramp": (
8484
"ramp(__data['time'], %(0)s, %(1)s, %(2)s)",
85-
("functions", "ramp")),
85+
(("functions", "ramp"),)),
8686
"step": (
8787
"step(__data['time'], %(0)s, %(1)s)",
88-
("functions", "step")),
88+
(("functions", "step"),)),
8989
"xidz": (
9090
"xidz(%(0)s, %(1)s, %(2)s)",
91-
("functions", "xidz")),
91+
(("functions", "xidz"),)),
9292
"zidz": (
9393
"zidz(%(0)s, %(1)s)",
94-
("functions", "zidz")),
94+
(("functions", "zidz"),)),
9595
"vector_sort_order": (
9696
"vector_sort_order(%(0)s, %(1)s)",
97-
("functions", "vector_sort_order")),
97+
(("functions", "vector_sort_order"),)),
9898
"vector_reorder": (
9999
"vector_reorder(%(0)s, %(1)s)",
100-
("functions", "vector_reorder")),
100+
(("functions", "vector_reorder"),)),
101101
"vector_rank": (
102102
"vector_rank(%(0)s, %(1)s)",
103-
("functions", "vector_rank")),
103+
(("functions", "vector_rank"),)),
104104

105105
# random functions must have the shape of the component subscripts
106106
# most of them are shifted, scaled and truncated
107-
# TODO: it is difficult to find same parametrization in Python,
108-
# maybe build a new model
109107
"random_0_1": (
110108
"np.random.uniform(0, 1, size=%(size)s)",
111-
("numpy",)),
109+
(("numpy",),)),
112110
"random_uniform": (
113111
"np.random.uniform(%(0)s, %(1)s, size=%(size)s)",
114-
("numpy",)),
112+
(("numpy",),)),
115113
"random_normal": (
116-
"stats.truncnorm.rvs(%(0)s, %(1)s, loc=%(2)s, scale=%(3)s,"
117-
" size=%(size)s)",
118-
("scipy", "stats")),
114+
"stats.truncnorm.rvs((%(0)s-%(2)s)/%(3)s, (%(1)s-%(2)s)/%(3)s,"
115+
" loc=%(2)s, scale=%(3)s, size=%(size)s)",
116+
(("scipy", "stats"),)),
117+
"random_exponential": (
118+
"stats.truncexpon.rvs((%(1)s-np.maximum(%(0)s, %(2)s))/%(3)s,"
119+
" loc=np.maximum(%(0)s, %(2)s), scale=%(3)s, size=%(size)s)",
120+
(("scipy", "stats"), ("numpy",),)),
119121
}

tests/conftest.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import shutil
22
from pathlib import Path
3+
from dataclasses import dataclass
4+
35

46
import pytest
7+
58
from pysd import read_vensim, read_xmile, load
69
from pysd.translators.vensim.vensim_utils import supported_extensions as\
710
vensim_extensions
811
from pysd.translators.xmile.xmile_utils import supported_extensions as\
912
xmile_extensions
1013

14+
from pysd.builders.python.imports import ImportsManager
15+
1116

1217
@pytest.fixture(scope="session")
1318
def _root():
@@ -21,6 +26,12 @@ def _test_models(_root):
2126
return _root.joinpath("test-models/tests")
2227

2328

29+
@pytest.fixture(scope="session")
30+
def _test_random(_root):
31+
# test-models directory
32+
return _root.joinpath("test-models/random")
33+
34+
2435
@pytest.fixture(scope="class")
2536
def shared_tmpdir(tmp_path_factory):
2637
# shared temporary directory for each class
@@ -58,3 +69,38 @@ def ignore_warns():
5869
"future version. Use timezone-aware objects to represent datetimes "
5970
"in UTC.*",
6071
]
72+
73+
74+
@pytest.fixture(scope="session")
75+
def random_size():
76+
# size of generated random samples
77+
return int(1e6)
78+
79+
80+
@dataclass
81+
class FakeComponent:
82+
element: str
83+
section: object
84+
subscripts_dict: dict
85+
86+
87+
@dataclass
88+
class FakeSection:
89+
namespace: object
90+
macrospace: dict
91+
imports: object
92+
93+
94+
@dataclass
95+
class FakeNamespace:
96+
cleanspace: dict
97+
98+
99+
@pytest.fixture(scope="function")
100+
def fake_component():
101+
# fake_component used to translate random functions to python
102+
return FakeComponent(
103+
'',
104+
FakeSection(FakeNamespace({}), {}, ImportsManager()),
105+
{}
106+
)

0 commit comments

Comments
 (0)