Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ode-toolbox-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
run: |
python -m pip install --upgrade pip pytest pycodestyle codecov pytest-cov wheel
python -m pip install -r requirements.txt
python -m pip install -r requirements-testing.txt
export PYTHON_VERSION=`python -c "import sys; print('.'.join(map(str, [sys.version_info.major, sys.version_info.minor])))"`
echo "Python version detected:"
echo $PYTHON_VERSION
Expand All @@ -33,7 +34,7 @@ jobs:

- name: Static code style analysis
run: |
python3 -m pycodestyle $GITHUB_WORKSPACE -v --ignore=E241,E501,E303,E714,E713,E714,E252 --exclude=$GITHUB_WORKSPACE/doc,$GITHUB_WORKSPACE/.eggs,$GITHUB_WORKSPACE/build,$GITHUB_WORKSPACE/.git,$GITHUB_WORKSPACE/odetoolbox.egg-info,$GITHUB_WORKSPACE/dist
python3 -m pycodestyle $GITHUB_WORKSPACE -v --ignore=E241,E501,E714,E713,E714,E252 --exclude=$GITHUB_WORKSPACE/doc,$GITHUB_WORKSPACE/.eggs,$GITHUB_WORKSPACE/build,$GITHUB_WORKSPACE/.git,$GITHUB_WORKSPACE/odetoolbox.egg-info,$GITHUB_WORKSPACE/dist


build:
Expand Down Expand Up @@ -75,6 +76,7 @@ jobs:
python -m pip install numpy
if [ "${{ matrix.with_gsl }}" == "1" ]; then python3 -m pip install -v https://github.com/pygsl/pygsl/archive/refs/tags/v2.4.1.tar.gz ; fi # this should be "pip install pygsl", but see https://github.com/pygsl/pygsl/issues/59
python -m pip install -r requirements.txt
python -m pip install -r requirements-testing.txt
export PYTHON_VERSION=`python -c "import sys; print('.'.join(map(str, [sys.version_info.major, sys.version_info.minor])))"`
echo "Python version detected:"
echo $PYTHON_VERSION
Expand Down
56 changes: 49 additions & 7 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,27 @@ Installation

.. Attention:: To perform solver benchmarking, ODE-toolbox relies on GSL and PyGSL. Currently, the latest PyGSL release is not compatible with GSL. We recommend to use GSL 2.7 for now. This issue is being tracked at https://github.com/pygsl/pygsl/issues/62.

.. Attention:: Versions of sympy before 1.14.0 can introduce numerical precision errors and very long processing times. It is recommended to use the latest sympy version available.


Prerequisites
~~~~~~~~~~~~~

Only Python 3 is supported. ODE-toolbox depends on the Python packages SymPy, Cython, SciPy and NumPy (required), matplotlib and graphviz for visualisation (optional), and pytest for self-tests (also optional). The stiffness tester additionally depends on an installation of `PyGSL <http://pygsl.sourceforge.net/>`__. If PyGSL is not installed, the test for stiffness is skipped during the analysis of the equations.

All required and optional packages can be installed by running
All required packages can be installed by running

.. code:: bash

pip install -r requirements.txt

Optional packages for testing can be installed by running

.. code:: bash

pip install -r requirements-testing.txt


Installing ODE-toolbox
~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -376,14 +385,47 @@ ODE-toolbox will return a list of solvers. **Each solver has the following keys:
- :python:`"initial_values"`\ : a dictionary that maps each variable symbol (in string form) to a SymPy expression. For example :python:`"g" : "e / tau"`.
- :python:`"parameters"`\ : only present when parameters were supplied in the input. The input parameters are copied into the output for convenience.

**Numeric solvers have the following extra entries:**

- :python:`"update_expressions"`\ : a dictionary that maps each variable symbol (in string form) to a SymPy expression that is its Jacobian, that is, for a symbol :math:`x`, the expression is equal to :math:`\frac{\delta x}{\delta t}`.

**Analytic solvers have the following extra entries:**

- :python:`"update_expressions"`\ : a dictionary that maps each variable symbol (in string form) to a SymPy propagator expression. The interpretation of an entry :python:`"g" : "g * __P__g__g + h * __P__g__h"` is that, at each integration timestep, when the state of the system needs to be updated from the current time :math:`t` to the next step :math:`t + \Delta t`, we assign the new value :python:`"g * __P__g__g + h * __P__g__h"` to the variable :python:`g`. Note that the expression is always evaluated at the old time :math:`t`; this means that when more than one state variable needs to be updated, all of the expressions have to be calculated before updating any of the variables.
- :python:`propagators`\ : a dictionary that maps each propagator matrix entry to its defining expression; for example :python:`"__P__g__h" : "__h*exp(-__h/tau)"`
In case of a solver without conditions:

**Numeric solvers have the following extra entries:**
- :python:`"update_expressions"`\ : a dictionary that maps each variable symbol (in string form) to a SymPy propagator expression. The interpretation of an entry :python:`"g" : "g * __P__g__g + h * __P__g__h"` is that, at each integration timestep, when the state of the system needs to be updated from the current time :math:`t` to the next step :math:`t + \Delta t`, we assign the new value :python:`"g * __P__g__g + h * __P__g__h"` to the variable :python:`g`. Note that the expression is always evaluated at the old time :math:`t`; this means that when more than one state variable needs to be updated, all of the expressions have to be calculated before updating any of the variables.
- :python:`propagators`\ : a dictionary that maps each propagator matrix entry to its defining expression; for example :python:`"__P__g__h" : "__h*exp(-__h/tau)"`

- :python:`"update_expressions"`\ : a dictionary that maps each variable symbol (in string form) to a SymPy expression that is its Jacobian, that is, for a symbol :math:`x`, the expression is equal to :math:`\frac{\delta x}{\delta t}`.
In some cases, parameter choices of the input can lead to numerical singularities (divisions by zero; see the sections :ref:`Computing the propagator matrix` and :ref:`Computing the update expressions` for more details). In this case, certain propagators and update expressions are only valid under certain conditions. These conditions, and the propagators and update expressions for each condition, are returned as follows:

- :python:`"conditions"`\ : a dictionary that maps conditional expressions (as strings) to a nested dictionary containing the keys :python:`"update_expressions"` and :python:`"propagators"` as described in the previous paragraph. The default solver (in case none of the other conditions hold) is indicated by the key :python:`"default"`\ .

For example, if the condition :math:`d=-p` will cause a singularity in the update expression for the state variable :python:`z`, two separate solvers are returned, one for the condition :math:`d=-p`, and another for the default ("otherwise") condition:

.. code:: python

{
"conditions": {
"(d == -p)": {
"propagators": {
"__P__z__z": "1"
},
"update_expressions": {
"z": "__P__z__z*z + 1.5*__h*p/tau_z"
}
},
"default": {
"propagators": {
"__P__z__z": "exp(-__h*(d + p)/tau_z)"
},
"update_expressions": {
"z": "(__P__z__z*(0.5*d - p + z*(d + p)) - 0.5*d + p)/(d + p)"
}
}
}
}

In general, there can be from 2 to any number of conditions. Conditions can involve boolean logic through the ``"&&"`` symbol for logical AND, ``"||"`` for logical OR, and the use of parentheses. The equality symbol, separating the left- and right-hand sides of the comparison, is written as ``"=="``.


Analytic solver generation
Expand Down Expand Up @@ -480,7 +522,7 @@ The propagator matrix :math:`\mathbf{P}` is derived from the system matrix by ma

If the imaginary unit :math:`i` is found in any of the entries in :math:`\mathbf{P}`, fail. This usually indicates an unstable (diverging) dynamical system. Double-check the dynamical equations.

In some cases, elements of :math:`\mathbf{P}` may contain fractions that have a factor of the form :python:`param1 - param2` in their denominator. If at a later stage, the numerical value of :python:`param1` is chosen equal to that of :python:`param2`, a numerical singularity (division by zero) occurs. To avoid this issue, it is necessary to eliminate either :python:`param1` or :python:`param2` in the input, before the propagator matrix is generated. ODE-toolbox will detect conditions (in this example, :python:`param1 = param2`) under which these singularities can occur. If any conditions were found, log warning messages will be emitted during the computation of the propagator matrix. A condition is only reported if the system matrix :math:`A` is defined under that condition, ensuring that only those conditions are returned that are purely an artifact of the propagator computation.
In some cases, elements of :math:`\mathbf{P}` may contain fractions that have a factor of the form :python:`param1 - param2` in their denominator. If at a later stage, the numerical value of :python:`param1` is chosen equal to that of :python:`param2`, a numerical singularity (division by zero) occurs. To avoid this issue, it is necessary to eliminate either :python:`param1` or :python:`param2` in the input, before the propagator matrix is generated. ODE-toolbox will detect conditions (in this example, :python:`param1 = param2`) under which these singularities can occur. In case a potential division by zero is detected, separate, conditional solvers are generated, so that a valid solver can be selected (for the given choice of parameter values) during numerical integration. Internally, conditions are generated based on `sympy.calculus.singularities.singularities <https://docs.sympy.org/latest/modules/calculus/index.html#sympy.calculus.singularities.singularities>`_.

To speed up processing, the final system matrix :math:`\mathbf{A}` is rewritten as a block-diagonal matrix :math:`\mathbf{A} = \text{diag}(\mathbf{A}_1, \mathbf{A}_2, \dots, \mathbf{A}_k)`, where each of :math:`\mathbf{A}_1, \mathbf{A}_2, \dots, \mathbf{A}_k` is square. Then, the propagator matrix is computed for each individual block separately, making use of the following identity:

Expand Down Expand Up @@ -535,7 +577,7 @@ the update equation is:

x \leftarrow P (x - 1.618) + 1.618

In some cases, elements of :math:`\mathbf{A}` may contain terms that involve a parameter of the system to be integrated. If at a later stage, the numerical value of these parameters is chosen equal to zero, a numerical singularity (division by zero) occurs. To avoid this issue, it is necessary to invoke ODE-toolbox separately to generate an analytic solver for the special case that the value of :math:`\mathbf{A}` becomes equal to zero for inhomogeneous equations. ODE-toolbox will detect the conditions (for instance, :python:`param1 - param2 = 0`) under which these singularities occur. If any conditions were found, log warning messages will be emitted during the computation of the analytic solver.
In some cases, elements of :math:`\mathbf{A}` may contain terms that involve a parameter of the system to be integrated. If at a later stage, the numerical value of these parameters is chosen equal to zero, a numerical singularity (division by zero) occurs. ODE-toolbox will detect the conditions (for instance, :python:`param1 - param2 = 0`) under which these singularities occur. In case potential division by zero is detected, separate, conditional solvers are generated, so that a valid solver can be selected (for the given choice of parameter values) during numerical integration.


Working with large expressions
Expand Down
75 changes: 52 additions & 23 deletions odetoolbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from sympy.core.expr import Expr as SympyExpr

from .config import Config
from .sympy_helpers import _check_numerical_issue, _check_forbidden_name, _find_in_matrix, _is_zero, _is_sympy_type, SympyPrinter
from .sympy_helpers import _check_numerical_issue, _check_forbidden_name, _find_in_matrix, _is_zero, _is_sympy_type, SympyPrinter, _sympy_parse_real
from .system_of_shapes import SystemOfShapes
from .shapes import MalformedInputException, Shape

Expand Down Expand Up @@ -127,6 +127,7 @@ def _from_json_to_shapes(indict, parameters=None) -> Tuple[List[Shape], Dict[sym
# validate input for forbidden names
for var in set(all_variable_symbols) | all_parameter_symbols:
_check_forbidden_name(var)
assert var.is_real

# validate parameters
for param in all_parameter_symbols:
Expand Down Expand Up @@ -182,6 +183,32 @@ def _get_all_first_order_variables(indict) -> Iterable[str]:
return variable_names


def symbol_appears_in_any_expr(param_name, solver_json) -> bool:
if "update_expressions" in solver_json.keys():
for sym, expr in solver_json["update_expressions"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
return True

if "propagators" in solver_json.keys():
for sym, expr in solver_json["propagators"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
return True

if "conditions" in solver_json.keys():
for conditional_solver_json in solver_json["conditions"].values():
if "update_expressions" in conditional_solver_json.keys():
for sym, expr in conditional_solver_json["update_expressions"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
return True

if "propagators" in conditional_solver_json.keys():
for sym, expr in solver_json["propagators"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
return True

return False


def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_solver: bool = False, disable_singularity_detection: bool = False, preserve_expressions: Union[bool, Iterable[str]] = False, log_level: Union[str, int] = logging.WARNING) -> Tuple[List[Dict], SystemOfShapes, List[Shape]]:
r"""
Like analysis(), but additionally returns ``shape_sys`` and ``shapes``.
Expand All @@ -208,14 +235,13 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so
parameters = {}
for k, v in indict["parameters"].items():
if type(k) is str:
parameters[sympy.Symbol(k)] = v
parameters[sympy.Symbol(k, real=True)] = v
else:
assert type(k) is sympy.Symbol
parameters[k] = v

_check_forbidden_name(k)


#
# create Shapes and SystemOfShapes
#
Expand All @@ -236,7 +262,6 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so
logging.debug("b = " + str(shape_sys.b_))
logging.debug("c = " + str(shape_sys.c_))


#
# generate analytical solutions (propagators) where possible
#
Expand All @@ -255,7 +280,6 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so
analytic_solver_json["solver"] = "analytical"
solvers_json.append(analytic_solver_json)


#
# generate numerical solvers for the remainder
#
Expand Down Expand Up @@ -293,15 +317,14 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so

solvers_json.append(solver_json)


#
# copy the initial values from the input to the output for convenience; convert to numeric values
#

for solver_json in solvers_json:
solver_json["initial_values"] = {}
for shape in shapes:
all_shape_symbols = [str(sympy.Symbol(str(shape.symbol) + Config().differential_order_symbol * i)) for i in range(shape.order)]
all_shape_symbols = [str(sympy.Symbol(str(shape.symbol) + Config().differential_order_symbol * i, real=True)) for i in range(shape.order)]
for sym in all_shape_symbols:
if sym in solver_json["state_variables"]:
iv_expr = shape.get_initial_value(sym.replace(Config().differential_order_symbol, "'"))
Expand All @@ -320,21 +343,8 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so
solver_json["parameters"] = {}
for param_name, param_expr in indict["parameters"].items():
# only make parameters appear in a solver if they are actually used there
symbol_appears_in_any_expr = False
if "update_expressions" in solver_json.keys():
for sym, expr in solver_json["update_expressions"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
symbol_appears_in_any_expr = True
break

if "propagators" in solver_json.keys():
for sym, expr in solver_json["propagators"].items():
if param_name in [str(sym) for sym in list(expr.atoms())]:
symbol_appears_in_any_expr = True
break

if symbol_appears_in_any_expr:
sympy_expr = sympy.parsing.sympy_parser.parse_expr(param_expr, global_dict=Shape._sympy_globals)
if symbol_appears_in_any_expr(sym, solver_json):
sympy_expr = _sympy_parse_real(param_expr, global_dict=Shape._sympy_globals)

# validate output for numerical problems
for var in sympy_expr.atoms():
Expand All @@ -349,7 +359,6 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so

solver_json["parameters"][param_name] = str(sympy_expr)


#
# convert expressions from sympy to string
#
Expand Down Expand Up @@ -388,6 +397,26 @@ def _analysis(indict, disable_stiffness_check: bool = False, disable_analytic_so
for sym, expr in solver_json["propagators"].items():
solver_json["propagators"][sym] = str(expr)

if "conditions" in solver_json.keys():
for cond, cond_solver in solver_json["conditions"].items():
if "update_expressions" in cond_solver:
for sym, expr in cond_solver["update_expressions"].items():
cond_solver["update_expressions"][sym] = str(expr)

if preserve_expressions and sym in preserve_expressions:
if "analytic" in solver_json["solver"]:
logging.warning("Not preserving expression for variable \"" + sym + "\" as it is solved by propagator solver")
continue

logging.info("Preserving expression for variable \"" + sym + "\"")
var_def_str = _find_variable_definition(indict, sym, order=1)
assert var_def_str is not None
cond_solver["update_expressions"][sym] = var_def_str.replace("'", Config().differential_order_symbol)

if "propagators" in cond_solver:
for sym, expr in cond_solver["propagators"].items():
cond_solver["propagators"][sym] = str(expr)

logging.info("In ode-toolbox: returning outdict = ")
logging.info(json.dumps(solvers_json, indent=4, sort_keys=True))

Expand Down
Loading