Skip to content

Commit

Permalink
Add support for vector ordering functions
Browse files Browse the repository at this point in the history
  • Loading branch information
enekomartinmartinez committed Jun 2, 2022
1 parent 45b6b16 commit a818da5
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 7 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Description

(add the description of your changes here)

## Related issues

(add any related issues here)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## PR verification (to be filled by reviewers)

- [ ] The code follows the [PEP 8 style](https://peps.python.org/pep-0008/)
- [ ] The new code has been tested properly for all the possible cases
- [ ] The overall coverage has not dropped and other features have not been broken.
- [ ] If new features have been added, they have been properly documented
- [ ] *docs/whats_new.rst* has been updated
- [ ] PySD version has been updated
122 changes: 122 additions & 0 deletions docs/development/adding_functions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
Adding new functions
====================
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.

Adding a hardcoded function
---------------------------
The most simple cases are when the existing Abstract Structure :py:class:`pysd.translators.structures.abstract_expressions.CallStructure` can be used. This structure holds a reference to the function name and the passed arguments in a :py:class:`tuple`. Sometimes the function can be directly added to the model file without the needing of defining any specific function. This can be done when the function is already implemented in Python or a Python library, and the behaviour is the same. For example, `Vensim's ABS <https://www.vensim.com/documentation/fn_abs.html>`_ and `XMILE's ABS <http://docs.oasis-open.org/xmile/xmile/v1.0/xmile-v1.0.pdf#page=30>`_ functions can be replaced by :py:func:`numpy.abs`.

In this case we only need to include the translation in the :py:data:`functionspace` dictionary from :py:mod:`pysd.builders.python.python_functions.py`::

"abs": ("np.abs(%(0)s)", ("numpy",)),

They key (:py:data:`"abs"`) is the name of the Vensim/XMILE function in lowercase. The first argument in the value (:py:data:`"np.abs(%(0)s)"`) is the python repressentation, the :py:data:`%(0)s` standd for the first argument of the original function. The last arguments stands for the dependencies of that function, in this case the used functions is included in :py:mod:`numpy` module. Hence, we need to import `numpy` in our model file, which is done by adding the dependency :py:data:`("numpy",)`, note that the dependency is a tuple.

The next step is to test the new function, in order to do that we need to include integration tests in the `test-models repo <https://github.com/SDXorg/test-models>`_, please follow the instructions to add a new test in the `README of that repo <https://github.com/SDXorg/test-models/blob/master/README.md>`_. In this case, we would need to add test models for Vensim's `mdl`file and a XMILE file, as we are adding support for both. The tests should cover all the possible cases, in this case we should test the absolute of positive and negative floats and positive, negative and mixed arrays. In this case, we included the tests `test-models/tests/abs/test_abs.mdl` and `test-models/tests/abs/test_abs.xmile`, with their corresponding outputs file. Now we include the test in the testing script. We need to add the following entry in the :py:data:`vensim_test` dictionary of :py:mod:`tests/pytest_integration/pytest_integration_test_vensim_pathway.py`::

"abs": {
"folder": "abs",
"file": "test_abs.mdl"
},

and the following one in the :py:data:`xmile_test` dictionary of :py:mod:`tests/pytest_integration/pytest_integration_test_xmile_pathway.py`::

"abs": {
"folder": "abs",
"file": "test_abs.xmile"
},

At this point we should be able to run the test and, if the implementation was correctly done, pass it. We need to make sure that we did not break any other feature by running all the tests.

In order to finish the contribution, we should update the documentation. The tables of :ref:`supported Vensim functions <Vensim supported functions>`, :ref:`supported Xmile functions <Xmile supported functions>`, and :ref:`supported Python functions <Python supported functions>` are automatically generated from `docs/tables/*.tab`, which are tab separated files. In this case, we should add the following line to `docs/tables/functions.tab`:

.. list-table:: ABS
:header-rows: 1

* - Vensim
- Vensim example
- Xmile
- Xmile example
- Abstract Syntax
- Python Translation
* - ABS
- ABS(A)
- abs
- abs(A)
- CallStructure('abs', (A,))
- numpy.abs(A)

To finish, we create a new release notes block at the top of `docs/whats_new.rst` file and update the software version. Commit all the changes, includying the test-models repo, and open a new PR.


Adding a simple function
------------------------
The second most simple case is we still are able to use the Abstract Structure :py:class:`pysd.translators.structures.abstract_expressions.CallStructure`, but we need to define a new function as the complexity of the source function would mess up the model code.

Let's suppose we want to add support for `Vensim's VECTOR SORT ORDER function <https://www.vensim.com/documentation/fn_vector_sort_order.html>`_. First of all, we may need to check Vensim's documentation to see how this function works and try to think what is the fatest way to solve it. VECTOR SORT ORDER functions takes two arguments, `vector` and `direction`. The function returns the order of the elements of the `vector`` based on the `direction`. Therefore, we do not need to save previous states information or to pass other information as arguments, we should have enought with a basic Python function that takes the same arguments.

Then, we define the Python function base on the Vensim's documentation. We also include the docstring with the same style of other functions and add this function to the file :py:mod:`pysd.py_backend.functions`::


def vector_sort_order(vector, direction):
"""
Implements Vensim's VECTOR SORT ORDER function. Sorting is done on
the complete vector relative to the last subscript.
https://www.vensim.com/documentation/fn_vector_sort_order.html

Parameters
-----------
vector: xarray.DataArray
The vector to sort.
direction: float
The direction to sort the vector. If direction > 1 it will sort
the vector entries are from smallest to biggest, otherwise from
biggest to smallest.

Returns
-------
vector_sorted: xarray.DataArray
The sorted vector.

"""
if direction <= 0:
flip = np.flip(vector.argsort(), axis=-1)
return xr.DataArray(flip.values, vector.coords, vector.dims)
return vector.argsort()

Now, we need to link the defined function with its corresponent abstract repressentation. So we include the following entry in the :py:data:`functionspace` dictionary from :py:mod:`pysd.builders.python.python_functions.py`::

"vector_sort_order": (
"vector_sort_order(%(0)s, %(1)s)",
("functions", "vector_sort_order"))

They key (:py:data:`"vector_sort_order"`) is the name of the Vensim function in lowercase and replacing the whitespaces by underscores. The first argument in the value (:py:data:`"vector_sort_order(%(0)s, %(1)s)"`) is the python repressentation, the :py:data:`%(0)s` and :py:data:`%(1)s` stand for the first and second argument of the original function, respectively. In this case, the repressentation is quite similar to the one in Vensim, we will move from `VECTOR SORT ORDER(vec, direction)` to `vector_sort_order(vec, direction)`. The last arguments stands for the dependencies of that function, in this case the function has been included in the functions submodule. Hence, we need to import `vector_sort_order` from `functions`, which is done by adding the dependency :py:data:`("functions", "vector_sort_order")`.

The next step is to add a test model for Vensim's `mdl`file. The test should cover all the possible cases, in this case, we should test the results for one and more dimensions arrays with different values along dimensions to genrate different order combinations, we also include cases for the both possible directions. We included the test `test-models/tests/vector_order/test_vector_order.mdl`, with its corresponding outputs file. Now we include the test in the testing script. We need to add the following entry in the :py:data:`vensim_test` dictionary of :py:mod:`tests/pytest_integration/pytest_integration_test_vensim_pathway.py`::

"vector_order": {
"folder": "vector_order",
"file": "test_vector_order.mdl"
},

At this point we should be able to run the test and, if the implementation was correctly done, pass it. We need to make sure that we did not break any other feature by running all the tests.

In order to finish the contribution, we should update the documentation by adding the following line to `docs/tables/functions.tab`:

.. list-table:: VECTOR SORT ORDER
:header-rows: 1

* - Vensim
- Vensim example
- Xmile
- Xmile example
- Abstract Syntax
- Python Translation
* - VECTOR SORT ORDER
- VECTOR SORT ORDER(vec, direction)
-
-
- CallStructure('vector_sort_order', (vec, direction))
- vector_sort_order(vec, direction)

To finish, we create a new release notes block at the top of `docs/whats_new.rst` file and update the software version. Commit all the changes, includying the test-models repo, and open a new PR.
2 changes: 2 additions & 0 deletions docs/development/development_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Developer Documentation

guidelines
pathway
adding_functions
pysd_architecture_views/4+1view_model

In order to contribut to PySD check the :doc:`guidelines` and the :doc:`pathway`.
You also will find helpful the :doc:`Structure of the PySD library <../../structure/structure_index>` to understand better how it works.
If you want to add a missing function to the PySD Python builder you may find useful the example in :doc:`adding_functions`.


1 change: 1 addition & 0 deletions docs/structure/vensim_translation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ All the basic operators are supported, this includes the ones shown in the table

Moreover, the Vensim :EXCEPT: operator is also supported to manage exceptions in the subscripts. See the :ref:`Subscripts section` section.


Functions
^^^^^^^^^
The list of currentlty supported Vensim functions are detailed below:
Expand Down
5 changes: 4 additions & 1 deletion docs/tables/functions.tab
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Vensim Vensim example Xmile Xmile example Abstract Syntax Python Translation Vensim comments Xmile comments Python comments
ABS ABS(A) abs(A) abs(A) "CallStructure('abs', (A,))" numpy.abs(A)
ABS ABS(A) abs abs(A) "CallStructure('abs', (A,))" numpy.abs(A)
MIN "MIN(A, B)" min "min(A, B)" "CallStructure('min', (A, B))" "numpy.minimum(A, B)"
MAX "MAX(A, B)" max "max(A, B)" "CallStructure('max', (A, B))" "numpy.maximum(A, B)"
SQRT SQRT(A) sqrt sqrt(A) "CallStructure('sqrt', (A,))" numpy.sqrt
Expand Down Expand Up @@ -33,6 +33,9 @@ PULSE TRAIN PULSE TRAIN(start, width, tbetween, end) "CallStructure('pulse_tra
RAMP RAMP(slope, start_time, end_time) ramp ramp(slope, start_time, end_time) "CallStructure('ramp', (slope, start_time, end_time))" pysd.functions.ramp(time, slope, start_time, end_time) Not tested for Xmile!
ramp ramp(slope, start_time) "CallStructure('ramp', (slope, start_time))" pysd.functions.ramp(time, slope, start_time) Not tested for Xmile!
STEP STEP(height, step_time) step step(height, step_time) "CallStructure('step', (height, step_time))" pysd.functions.step(time, height, step_time) Not tested for Xmile!
VECTOR RANK VECTOR RANK(vec, direction) "CallStructure('vector_rank', (vec, direction))" vector_rank(vec, direction)
VECTOR REORDER VECTOR REORDER(vec, svec) "CallStructure('vector_reorder', (vec, svec))" vector_reorder(vec, svec)
VECTOR SORT ORDER VECTOR SORT ORDER(vec, direction) "CallStructure('vector_sort_order', (vec, direction))" vector_sort_order(vec, direction)
GAME GAME(A) GameStructure(A) A
INITIAL INITIAL(value) init init(value) InitialStructure(value) pysd.statefuls.Initial
SAMPLE IF TRUE "SAMPLE IF TRUE(condition, input, initial_value)" "SampleIfTrueStructure(condition, input, initial_value)" pysd.statefuls.SampleIfTrue(...)
29 changes: 29 additions & 0 deletions docs/whats_new.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@

What's New
==========
v3.1.0 (unreleased)
-------------------

New Features
~~~~~~~~~~~~
- Add support for Vensim's `VECTOR SORT ORDER <https://www.vensim.com/documentation/fn_vector_sort_order.html>`_ (:func:`pysd.py_backend.functions.vector_sort_order`) function (:issue:`326`).
- Add support for Vensim's `VECTOR RANK <https://www.vensim.com/documentation/fn_vector_rank.html>`_ (:func:`pysd.py_backend.functions.vector_rank`) function (:issue:`326`).
- Add support for Vensim's `VECTOR REORDER <https://www.vensim.com/documentation/fn_vector_reorder.html>`_ (:func:`pysd.py_backend.functions.vector_reorder`) function (:issue:`326`).

Breaking changes
~~~~~~~~~~~~~~~~

Deprecations
~~~~~~~~~~~~

Bug fixes
~~~~~~~~~

Documentation
~~~~~~~~~~~~~
- Add the section :doc:`/development/adding_functions` with examples for developers.

Performance
~~~~~~~~~~~

Internal Changes
~~~~~~~~~~~~~~~~


v3.0.1 (2022/05/26)
-------------------

Expand Down
2 changes: 1 addition & 1 deletion pysd/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.0.1"
__version__ = "3.1.0"
9 changes: 9 additions & 0 deletions pysd/builders/python/python_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
"zidz": (
"zidz(%(0)s, %(1)s)",
("functions", "zidz")),
"vector_sort_order": (
"vector_sort_order(%(0)s, %(1)s)",
("functions", "vector_sort_order")),
"vector_reorder": (
"vector_reorder(%(0)s, %(1)s)",
("functions", "vector_reorder")),
"vector_rank": (
"vector_rank(%(0)s, %(1)s)",
("functions", "vector_rank")),

# random functions must have the shape of the component subscripts
# most of them are shifted, scaled and truncated
Expand Down
4 changes: 2 additions & 2 deletions pysd/builders/python/python_model_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ def build_model(self) -> Path:
class SectionBuilder:
"""
SectionBuilder allows building a section of the PySD model. Each
section will be a file unless the model has been setted to be
split in modules.
section will be a file unless the model has been set to be split
in modules.
Parameters
----------
Expand Down
108 changes: 107 additions & 1 deletion pysd/py_backend/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import numpy as np
import xarray as xr

from . import utils

small_vensim = 1e-6 # What is considered zero according to Vensim Help


Expand Down Expand Up @@ -206,7 +208,7 @@ def xidz(numerator, denominator, x):
def zidz(numerator, denominator):
"""
This function bypasses divide-by-zero errors,
implementing Vensim's ZIDZ function
implementing Vensim's ZIDZ function.
https://www.vensim.com/documentation/fn_zidz.htm
Parameters
Expand Down Expand Up @@ -471,3 +473,107 @@ def invert_matrix(mat):
# NUMPY: avoid converting to xarray, put directly the expression
# in the model
return xr.DataArray(np.linalg.inv(mat.values), mat.coords, mat.dims)


def vector_sort_order(vector, direction):
"""
Implements Vensim's VECTOR SORT ORDER function. Sorting is done on
the complete vector relative to the last subscript.
https://www.vensim.com/documentation/fn_vector_sort_order.html
Parameters
-----------
vector: xarray.DataArray
The vector to sort.
direction: float
The direction to sort the vector. If direction > 1 it will sort
the vector entries are from smallest to biggest, otherwise from
biggest to smallest.
Returns
-------
vector_sorted: xarray.DataArray
The sorted vector.
"""
# TODO: can direction be an array? In this case this will fail
if direction <= 0:
# NUMPY: return flip directly
flip = np.flip(vector.argsort(), axis=-1)
return xr.DataArray(flip.values, vector.coords, vector.dims)
return vector.argsort()


def vector_reorder(vector, svector):
"""
Implements Vensim's VECTOR REORDER function. Reordering is done on
the complete vector relative to the last subscript.
https://www.vensim.com/documentation/fn_vector_reorder.html
Parameters
-----------
vector: xarray.DataArray
The vector to sort.
svector: xarray.DataArray
The vector to specify the order.
Returns
-------
vector_sorted: xarray.DataArray
The sorted vector.
"""
# NUMPY: Use directly numpy sort functions, no need to assign coords later
if len(svector.dims) > 1:
# TODO this may be simplified
new_vector = vector.copy()
dims = svector.dims
# create an empty array to hold the orderings (only last dim)
arr = xr.DataArray(
np.nan,
{dims[-1]: vector.coords[dims[-1]].values},
dims[-1:]
)
# split the ordering array in 0-dim arrays
svectors = utils.xrsplit(svector)
orders = {}
for sv in svectors:
# regrup the ordering arrays using last dimensions
pos = {dim: str(sv.coords[dim].values) for dim in dims[:-1]}
key = ";".join(pos.values())
if key not in orders.keys():
orders[key] = (pos, arr.copy())
orders[key][1].loc[sv.coords[dims[-1]]] = sv.values

for pos, array in orders.values():
# get the reordered array
values = [vector.loc[pos].values[int(i)] for i in array.values]
new_vector.loc[pos] = values

return new_vector

return vector[svector.values].assign_coords(vector.coords)


def vector_rank(vector, direction):
"""
Implements Vensim's VECTOR RANK function. Ranking is done on the
complete vector relative to the last subscript.
https://www.vensim.com/documentation/fn_vector_rank.html
Parameters
-----------
vector: xarray.DataArray
The vector to sort.
direction: flot
The direction to sort the vector. If direction > 1 it will rank
the vector entries are from smallest to biggest, otherwise from
biggest to smallest.
Returns
-------
vector_rank: xarray.DataArray
The rank of the vector.
"""
return vector_sort_order(vector, direction).argsort() + 1
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@
"folder": "variable_ranges",
"file": "test_variable_ranges.mdl"
},
"vector_order": {
"folder": "vector_order",
"file": "test_vector_order.mdl"
},
"xidz_zidz": {
"folder": "xidz_zidz",
"file": "xidz_zidz.mdl"
Expand Down Expand Up @@ -580,4 +584,4 @@ def test_read_vensim_file(self, model_path, data_path, kwargs):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
output, canon = runner(model_path, data_files=data_path)
assert_frames_close(output, canon, **kwargs)
assert_frames_close(output, canon, verbose=True, **kwargs)

0 comments on commit a818da5

Please sign in to comment.