gotranx 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gotranx-2.3.0/src/gotranx.egg-info → gotranx-2.4.0}/PKG-INFO +1 -1
- {gotranx-2.3.0 → gotranx-2.4.0}/pyproject.toml +2 -2
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/python.py +10 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/myokit.py +39 -1
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode.lark +4 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/singularities.py +44 -14
- {gotranx-2.3.0 → gotranx-2.4.0/src/gotranx.egg-info}/PKG-INFO +1 -1
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/SOURCES.txt +1 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_benchmarks.py +17 -0
- gotranx-2.4.0/tests/test_min_max.py +184 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_singularities.py +48 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/LICENSE +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/README.md +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/setup.cfg +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/__init__.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/__main__.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/_enum.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/atoms.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/__init__.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/cellml2ode.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2c.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2cytozoo.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2julia.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2md.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2mtk.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2py.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2ufl.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/ode2cellml.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/utils.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/__init__.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/base.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/c.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/cytozoo.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/jax.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/julia.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/markdown.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/mtk.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/ode.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/ufl.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/exceptions.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/expressions.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/linearization.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/load.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode_component.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/parser.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/py.typed +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/save.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/schemes.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/sympytools.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/__init__.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/c.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/cytozoo.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/jax.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/julia.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/markdown.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/mtk.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/python.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/ufl.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/transformer.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/units.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/dependency_links.txt +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/entry_points.txt +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/not-zip-safe +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/requires.txt +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/top_level.txt +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_atoms.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_c_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_cli.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_comments.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_conditionals.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_cytozoo_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_deep_linearization_models.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_demos.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_determinism.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_expressions.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_julia_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_linearization.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_load.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_markdown_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_mtk_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_myokit.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ode.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ode_component.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_operations.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_python_codegen.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_save.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_schemes.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_singularity_models.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_subodes.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_sympytools.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_assignments.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_parameters.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_states.py +0 -0
- {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ufl_codegen.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gotranx"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "A declarative language describing ordinary differential equations"
|
|
9
9
|
authors = [{name = "Henrik Finsberg", email = "henriknf@simula.no"}]
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -172,7 +172,7 @@ tag = true
|
|
|
172
172
|
sign_tags = false
|
|
173
173
|
tag_name = "v{new_version}"
|
|
174
174
|
tag_message = "Bump version: {current_version} → {new_version}"
|
|
175
|
-
current_version = "2.
|
|
175
|
+
current_version = "2.4.0"
|
|
176
176
|
|
|
177
177
|
|
|
178
178
|
[[tool.bumpversion.files]]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
import functools
|
|
2
3
|
import typing
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from sympy.printing.pycode import PythonCodePrinter
|
|
@@ -46,6 +47,15 @@ class GotranPythonCodePrinter(PythonCodePrinter):
|
|
|
46
47
|
def _print_Float(self, flt):
|
|
47
48
|
return self._print(str(float(flt)))
|
|
48
49
|
|
|
50
|
+
def _print_Min(self, expr):
|
|
51
|
+
# numpy.min(a, b) would read b as an axis; numpy.minimum takes two arrays
|
|
52
|
+
args = [self._print(arg) for arg in expr.args]
|
|
53
|
+
return functools.reduce(lambda x, y: f"numpy.minimum({x}, {y})", args)
|
|
54
|
+
|
|
55
|
+
def _print_Max(self, expr):
|
|
56
|
+
args = [self._print(arg) for arg in expr.args]
|
|
57
|
+
return functools.reduce(lambda x, y: f"numpy.maximum({x}, {y})", args)
|
|
58
|
+
|
|
49
59
|
def _print_Piecewise(self, expr):
|
|
50
60
|
result = []
|
|
51
61
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from collections import defaultdict
|
|
3
|
+
import functools
|
|
3
4
|
import re
|
|
4
5
|
import warnings
|
|
5
6
|
from pathlib import Path
|
|
@@ -346,6 +347,43 @@ def myokit_to_gotran(model: "myokit.Model", protocol=None) -> ODE:
|
|
|
346
347
|
)
|
|
347
348
|
|
|
348
349
|
|
|
350
|
+
def _sympy_reader(model: "myokit.Model") -> "myokit.formats.sympy.SymPyExpressionReader":
|
|
351
|
+
"""myokit's sympy expression reader, extended to read ``Min``, ``Max`` and n-ary
|
|
352
|
+
``And``/``Or``.
|
|
353
|
+
|
|
354
|
+
myokit has no min or max, and its ``And`` and ``Or`` take exactly two operands where sympy's
|
|
355
|
+
take any number. Rewriting ``Min``/``Max`` as sympy ``Piecewise`` first does not work: a
|
|
356
|
+
``Piecewise`` nested in a condition, as in the clamp ``Min(Max(x, 0), 1)``, becomes an ``ITE``,
|
|
357
|
+
which the reader cannot read either, and four or more arguments produce an n-ary ``And``. So
|
|
358
|
+
each ``Min``/``Max`` is read into nested myokit ``If`` expressions, and ``And``/``Or`` are
|
|
359
|
+
folded into nested binary ones.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
class Reader(myokit.formats.sympy.SymPyExpressionReader):
|
|
363
|
+
def _build_op_map(self):
|
|
364
|
+
op_map = super()._build_op_map()
|
|
365
|
+
op_map[sp.Min] = self._ex_min
|
|
366
|
+
op_map[sp.Max] = self._ex_max
|
|
367
|
+
return op_map
|
|
368
|
+
|
|
369
|
+
def _fold(self, e, combine):
|
|
370
|
+
return functools.reduce(combine, [self.ex(arg) for arg in e.args])
|
|
371
|
+
|
|
372
|
+
def _ex_min(self, e):
|
|
373
|
+
return self._fold(e, lambda a, b: myokit.If(myokit.LessEqual(a, b), a, b))
|
|
374
|
+
|
|
375
|
+
def _ex_max(self, e):
|
|
376
|
+
return self._fold(e, lambda a, b: myokit.If(myokit.MoreEqual(a, b), a, b))
|
|
377
|
+
|
|
378
|
+
def _ex_and(self, e):
|
|
379
|
+
return self._fold(e, myokit.And)
|
|
380
|
+
|
|
381
|
+
def _ex_or(self, e):
|
|
382
|
+
return self._fold(e, myokit.Or)
|
|
383
|
+
|
|
384
|
+
return Reader(model=model)
|
|
385
|
+
|
|
386
|
+
|
|
349
387
|
def gotran_to_myokit(ode: ODE, time_component="engine", time_unit="s") -> "myokit.Model":
|
|
350
388
|
"""Convert a gotran ODE to myokit model
|
|
351
389
|
|
|
@@ -420,7 +458,7 @@ def gotran_to_myokit(ode: ODE, time_component="engine", time_unit="s") -> "myoki
|
|
|
420
458
|
var.set_unit(to_myokit_unit(intermediate.unit_str))
|
|
421
459
|
global_var_map[intermediate.symbol] = sp.Symbol(var.qname())
|
|
422
460
|
|
|
423
|
-
sympy_reader =
|
|
461
|
+
sympy_reader = _sympy_reader(model)
|
|
424
462
|
# Then we can add expressions
|
|
425
463
|
for component in ode.components:
|
|
426
464
|
comp = model[component_name_map[component.name]]
|
|
@@ -55,6 +55,8 @@ variable: VARIABLE
|
|
|
55
55
|
| ABSL
|
|
56
56
|
| FLOOR
|
|
57
57
|
| MOD
|
|
58
|
+
| MIN
|
|
59
|
+
| MAX
|
|
58
60
|
|
|
59
61
|
?logicalfunc: logicalfuncname "(" expression ("," expression)* (",")? ")"
|
|
60
62
|
?logicalfuncname: CONTINUOUSCONDITIONAL
|
|
@@ -93,6 +95,8 @@ OR: "Or"
|
|
|
93
95
|
EQ: "Eq"
|
|
94
96
|
NOT: "Not"
|
|
95
97
|
MOD: "Mod"
|
|
98
|
+
MIN: "Min"
|
|
99
|
+
MAX: "Max"
|
|
96
100
|
|
|
97
101
|
|
|
98
102
|
PI: "pi"
|
|
@@ -300,7 +300,7 @@ def _depending_on(
|
|
|
300
300
|
"""Cached front for :func:`_compute_depending_on`.
|
|
301
301
|
|
|
302
302
|
Called once per (assignment, variable) pair against the same model, so
|
|
303
|
-
recomputing the
|
|
303
|
+
recomputing the dependents every time dominated the scan. The cache holds
|
|
304
304
|
only the most recent ``definitions`` object and compares by identity, so it
|
|
305
305
|
can never serve a stale answer for a different model.
|
|
306
306
|
"""
|
|
@@ -313,25 +313,47 @@ def _depending_on(
|
|
|
313
313
|
return set(by_seeds[key])
|
|
314
314
|
|
|
315
315
|
|
|
316
|
+
_USERS_CACHE: list = []
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _users(
|
|
320
|
+
definitions: Mapping[sympy.Symbol, sympy.Expr],
|
|
321
|
+
) -> dict[sympy.Symbol, list[sympy.Symbol]]:
|
|
322
|
+
"""Every symbol mapped to the symbols whose definitions use it.
|
|
323
|
+
|
|
324
|
+
The reverse of ``definitions``' dependency graph. Built once per
|
|
325
|
+
``definitions`` object and cached by identity, as :func:`_depending_on`
|
|
326
|
+
caches its answers, since a model asks for the dependents of every state.
|
|
327
|
+
"""
|
|
328
|
+
if not _USERS_CACHE or _USERS_CACHE[0] is not definitions:
|
|
329
|
+
users: dict[sympy.Symbol, list[sympy.Symbol]] = {}
|
|
330
|
+
for symbol, definition in definitions.items():
|
|
331
|
+
for used in definition.free_symbols:
|
|
332
|
+
users.setdefault(used, []).append(symbol)
|
|
333
|
+
_USERS_CACHE[:] = [definitions, users]
|
|
334
|
+
return _USERS_CACHE[1]
|
|
335
|
+
|
|
336
|
+
|
|
316
337
|
def _compute_depending_on(
|
|
317
338
|
definitions: Mapping[sympy.Symbol, sympy.Expr],
|
|
318
339
|
seeds: set[sympy.Symbol],
|
|
319
340
|
) -> set[sympy.Symbol]:
|
|
320
341
|
"""Symbols in ``definitions`` that transitively depend on any of ``seeds``.
|
|
321
342
|
|
|
322
|
-
|
|
323
|
-
|
|
343
|
+
Walks the reverse dependency graph (:func:`_users`) out from the seeds, so
|
|
344
|
+
the cost is linear in the graph whatever order ``definitions`` is in. A
|
|
345
|
+
fixed-point sweep over ``definitions``, which is not guaranteed to be
|
|
346
|
+
topologically sorted, cost a pass per level of dependency for every set of
|
|
347
|
+
seeds, which made the scan quadratic in the size of a model.
|
|
324
348
|
"""
|
|
349
|
+
users = _users(definitions)
|
|
325
350
|
dependent: set[sympy.Symbol] = set()
|
|
326
|
-
|
|
327
|
-
while
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (definition.free_symbols & seeds) or (definition.free_symbols & dependent):
|
|
333
|
-
dependent.add(symbol)
|
|
334
|
-
changed = True
|
|
351
|
+
stack = list(seeds)
|
|
352
|
+
while stack:
|
|
353
|
+
for user in users.get(stack.pop(), ()):
|
|
354
|
+
if user not in dependent:
|
|
355
|
+
dependent.add(user)
|
|
356
|
+
stack.append(user)
|
|
335
357
|
return dependent
|
|
336
358
|
|
|
337
359
|
|
|
@@ -441,7 +463,15 @@ def _numeric(expr: sympy.Expr, defaults: Mapping[sympy.Symbol, float]) -> float
|
|
|
441
463
|
None means "not usable as a guard location or as a check point": complex,
|
|
442
464
|
infinite, NaN, or still carrying a free symbol ``defaults`` does not cover.
|
|
443
465
|
"""
|
|
444
|
-
|
|
466
|
+
# Only the expression's own symbols: converting every default in the model
|
|
467
|
+
# on every call made evaluating all of a model's defaults quadratic.
|
|
468
|
+
substituted = expr.xreplace(
|
|
469
|
+
{
|
|
470
|
+
symbol: sympy.Float(defaults[symbol])
|
|
471
|
+
for symbol in expr.free_symbols
|
|
472
|
+
if symbol in defaults
|
|
473
|
+
}
|
|
474
|
+
)
|
|
445
475
|
try:
|
|
446
476
|
value = complex(substituted.evalf())
|
|
447
477
|
except (TypeError, ValueError, AttributeError):
|
|
@@ -1081,7 +1111,7 @@ def default_values(
|
|
|
1081
1111
|
"""
|
|
1082
1112
|
values = dict(base)
|
|
1083
1113
|
for symbol, definition in definitions.items():
|
|
1084
|
-
if not definition.free_symbols <=
|
|
1114
|
+
if not definition.free_symbols <= values.keys():
|
|
1085
1115
|
continue
|
|
1086
1116
|
try:
|
|
1087
1117
|
value = _numeric(definition, values)
|
|
@@ -57,3 +57,20 @@ def test_c_explicit_euler(ode):
|
|
|
57
57
|
@pytest.mark.benchmark
|
|
58
58
|
def test_c_generalized_rush_larsen(ode):
|
|
59
59
|
gotranx.cli.gotran2c.get_code(ode, scheme=[gotranx.schemes.Scheme.generalized_rush_larsen])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _many_divisions(n):
|
|
63
|
+
"""n states, each rate divided by a sum of states: denominators the scan rejects."""
|
|
64
|
+
states = ", ".join(f"y{i} = 0.5" for i in range(n))
|
|
65
|
+
lines = ['parameters("m", k = 0.01)', f'states("m", {states})', 'expressions("m")']
|
|
66
|
+
for i in range(n):
|
|
67
|
+
j = (i + 1) % n
|
|
68
|
+
lines.append(f"r{i} = y{i}/(y{i} + y{j} + 1.0)")
|
|
69
|
+
lines.append(f"dy{i}_dt = -k*r{i} + k*r{j}")
|
|
70
|
+
return "\n".join(lines) + "\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.benchmark
|
|
74
|
+
def test_load_many_divisions():
|
|
75
|
+
ode = gotranx.load.ode_from_string(_many_divisions(500))
|
|
76
|
+
assert ode.num_states == 500
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""`Min` and `Max` in the `.ode` language, through every backend."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import lark
|
|
6
|
+
import numpy
|
|
7
|
+
import pytest
|
|
8
|
+
import sympy
|
|
9
|
+
|
|
10
|
+
import gotranx
|
|
11
|
+
from gotranx.cli import gotran2c, gotran2julia, gotran2mtk, gotran2py, gotran2ufl
|
|
12
|
+
from gotranx.codegen.c import Format as CFormat
|
|
13
|
+
from gotranx.load import ode_from_string
|
|
14
|
+
from gotranx.schemes import Scheme
|
|
15
|
+
|
|
16
|
+
MODEL = """
|
|
17
|
+
parameters("m", k = 0.5)
|
|
18
|
+
states("m", y = 0.5)
|
|
19
|
+
expressions("m")
|
|
20
|
+
r = k*Max(y - 0.2, 0.0, 2.0*y - 3.0) + Min(y, 1.0, k)
|
|
21
|
+
dy_dt = -r
|
|
22
|
+
"""
|
|
23
|
+
# Both sides of every kink, and exactly on each: Max switches at 0.2 and 2.8, and Min at y = 1.0
|
|
24
|
+
# and y = k. With k = 0.5 the 1.0 in Min is never the smallest, so the per-cell test uses k = 1.5
|
|
25
|
+
# as well.
|
|
26
|
+
Y = numpy.array([-1.0, 0.0, 0.1, 0.2, 0.3, 0.5, 0.9, 1.0, 1.5, 2.0, 2.8, 3.0, 3.5])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _generated(module, ode, **kwargs):
|
|
30
|
+
namespace: dict = {}
|
|
31
|
+
exec(compile(module.get_code(ode, **kwargs), f"<{module.__name__}>", "exec"), namespace)
|
|
32
|
+
return namespace
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expected_rhs(y, k=0.5):
|
|
36
|
+
largest = numpy.maximum(numpy.maximum(y - 0.2, 0.0), 2.0 * y - 3.0)
|
|
37
|
+
smallest = numpy.minimum(numpy.minimum(y, 1.0), k)
|
|
38
|
+
return -(k * largest + smallest)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_min_max_parse_to_sympy():
|
|
42
|
+
r = ode_from_string(MODEL)["r"].expr
|
|
43
|
+
assert r.has(sympy.Max) and r.has(sympy.Min)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_names_starting_with_min_or_max_are_still_variables():
|
|
47
|
+
ode = ode_from_string(
|
|
48
|
+
"parameters(Max_rate = 1.0, Minimum = 2.0, min = 3.0, max = 4.0)\n"
|
|
49
|
+
"states(y = 0.0)\n"
|
|
50
|
+
"dy_dt = Max_rate - Minimum*y + max - min\n"
|
|
51
|
+
)
|
|
52
|
+
assert {p.name for p in ode.parameters} == {"Max_rate", "Minimum", "min", "max"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_min_and_max_are_reserved_in_expressions():
|
|
56
|
+
with pytest.raises(lark.exceptions.UnexpectedToken):
|
|
57
|
+
ode_from_string("parameters(Max = 1.0)\nstates(y = 0.0)\ndy_dt = Max*y\n")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_numpy_rhs_is_numpy_minimum_maximum():
|
|
61
|
+
ns = _generated(gotran2py, ode_from_string(MODEL))
|
|
62
|
+
values = ns["rhs"](0.0, Y[None, :], ns["init_parameter_values"]())
|
|
63
|
+
numpy.testing.assert_allclose(values[0], _expected_rhs(Y), rtol=1e-13, atol=1e-15)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_numpy_rhs_with_per_cell_parameters():
|
|
67
|
+
ns = _generated(gotran2py, ode_from_string(MODEL))
|
|
68
|
+
y, k = (a.ravel() for a in numpy.meshgrid(Y, [0.5, 1.5])) # k on both sides of 1.0
|
|
69
|
+
parameters = numpy.repeat(ns["init_parameter_values"]()[:, None], y.size, axis=1)
|
|
70
|
+
parameters[ns["parameter_index"]("k")] = k
|
|
71
|
+
values = ns["rhs"](0.0, y[None, :], parameters)
|
|
72
|
+
numpy.testing.assert_allclose(values[0], _expected_rhs(y, k), rtol=1e-13, atol=1e-15)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_grl_through_max_is_exact_on_each_side():
|
|
76
|
+
ode = ode_from_string(
|
|
77
|
+
"parameters(k = 2.0, c = 0.5)\nstates(y = 0.0)\ndy_dt = c - k*Max(y - 0.2, 0.0)\n"
|
|
78
|
+
)
|
|
79
|
+
ns = _generated(gotran2py, ode, scheme=[Scheme.generalized_rush_larsen])
|
|
80
|
+
y0 = numpy.array([[0.0, 0.1, 0.2, 0.5, 1.0]])
|
|
81
|
+
dt, k, c = 0.1, 2.0, 0.5
|
|
82
|
+
# numpy.where evaluates both branches: the Rush-Larsen quotient is 0/0 where the rate is 0
|
|
83
|
+
with numpy.errstate(divide="ignore", invalid="ignore"):
|
|
84
|
+
y1 = ns["generalized_rush_larsen"](y0, 0.0, dt, ns["init_parameter_values"]())
|
|
85
|
+
# Flat side: the linearization is 0, so the step is forward Euler. Steep side: exact for the
|
|
86
|
+
# linear dy/dt = c - k*(y - 0.2), whose fixed point is 0.2 + c/k.
|
|
87
|
+
fixed = 0.2 + c / k
|
|
88
|
+
expected = numpy.where(y0 < 0.2, y0 + c * dt, fixed + (y0 - fixed) * math.exp(-k * dt))
|
|
89
|
+
off_kink = y0 != 0.2
|
|
90
|
+
numpy.testing.assert_allclose(y1[off_kink], expected[off_kink], rtol=1e-12)
|
|
91
|
+
assert numpy.isfinite(y1).all()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_jax_prints_numpy_maximum():
|
|
95
|
+
code = gotran2py.get_code(ode_from_string(MODEL), backend=gotran2py.Backend.jax)
|
|
96
|
+
assert "numpy.maximum(" in code and "numpy.minimum(" in code
|
|
97
|
+
assert "numpy.max(" not in code and "numpy.min(" not in code
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_max_keeps_a_removable_pole_guarded():
|
|
101
|
+
ode = ode_from_string(
|
|
102
|
+
"states(y = -50.0)\nr = (y + 40.0)/(exp((y + 40.0)/10.0) - 1.0)*Max(y, -60.0)\ndy_dt = -r\n"
|
|
103
|
+
)
|
|
104
|
+
assert isinstance(ode["r"].expr, sympy.Piecewise)
|
|
105
|
+
ns = _generated(gotran2py, ode)
|
|
106
|
+
# numpy.where evaluates both branches: the unguarded one is 0/0 at the pole
|
|
107
|
+
with numpy.errstate(divide="ignore", invalid="ignore"):
|
|
108
|
+
values = ns["rhs"](0.0, numpy.array([[-40.0, -30.0]]), ns["init_parameter_values"]())
|
|
109
|
+
# x/(exp(x/10) - 1) -> 10 at x = 0, times Max(-40, -60) = -40
|
|
110
|
+
numpy.testing.assert_allclose(values[0], [400.0, 30.0 * 10.0 / (math.e - 1.0)], rtol=1e-6)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
EXPORTED = [
|
|
114
|
+
"k*Max(y - 0.2, 0.0, 2.0*y - 3.0) + Min(y, 1.0, k)", # MODEL's r
|
|
115
|
+
"Min(Max(y, 0.0), 1.0)", # the clamp in docs/grammar.md
|
|
116
|
+
"Max(Min(y, 1.0), k)",
|
|
117
|
+
"Conditional(Ge(Max(y, 0.0), k), y, k)",
|
|
118
|
+
"Max(Conditional(Gt(y, 0.0), y, -y), k)",
|
|
119
|
+
"Max(y, 0.1, k, 2.0*y - 1.0)", # four arguments
|
|
120
|
+
"Min(y, 1.0, k, y*y, -y)", # five
|
|
121
|
+
"Conditional(And(Gt(y, 0.0), Lt(y, 1.0), Gt(k, 0.0)), y, k)", # a three-way And
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize("expr", EXPORTED)
|
|
126
|
+
def test_myokit_export_evaluates_like_numpy(expr):
|
|
127
|
+
pytest.importorskip("myokit")
|
|
128
|
+
from gotranx.myokit import gotran_to_myokit
|
|
129
|
+
|
|
130
|
+
ode = ode_from_string(
|
|
131
|
+
f'parameters("m", k = 0.5)\nstates("m", y = 0.5)\nexpressions("m")\nr = {expr}\n'
|
|
132
|
+
"dy_dt = -r\n"
|
|
133
|
+
)
|
|
134
|
+
model = gotran_to_myokit(ode)
|
|
135
|
+
r = next(v for v in model.variables(deep=True) if v.name() == "r")
|
|
136
|
+
y = next(v for v in model.variables(deep=True) if v.name() == "y")
|
|
137
|
+
ns = _generated(gotran2py, ode)
|
|
138
|
+
parameters = ns["init_parameter_values"]()
|
|
139
|
+
for value in (-1.0, 0.0, 0.3, 0.5, 0.8, 1.0, 2.0, 3.0):
|
|
140
|
+
y.set_initial_value(value)
|
|
141
|
+
expected = -ns["rhs"](0.0, numpy.array([value]), parameters)[0]
|
|
142
|
+
assert r.eval() == pytest.approx(expected, rel=1e-12, abs=1e-15), value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_cellml_export_of_the_clamp(tmp_path):
|
|
146
|
+
pytest.importorskip("myokit")
|
|
147
|
+
from gotranx.myokit import gotran_to_cellml
|
|
148
|
+
|
|
149
|
+
ode = ode_from_string("states(y = 0.5)\nr = Min(Max(y, 0.0), 1.0)\ndy_dt = -r\n")
|
|
150
|
+
gotran_to_cellml(ode, filename=tmp_path / "clamp.cellml")
|
|
151
|
+
assert (tmp_path / "clamp.cellml").read_text().count("<piecewise>") >= 2
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_c_prints_fmin_fmax():
|
|
155
|
+
# format=none: clang-format may be absent
|
|
156
|
+
code = gotran2c.get_code(ode_from_string(MODEL), format=CFormat.none)
|
|
157
|
+
assert "fmax(" in code and "fmin(" in code
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_ufl_min_max_evaluate_like_numpy():
|
|
161
|
+
pytest.importorskip("ufl")
|
|
162
|
+
ode = ode_from_string(MODEL)
|
|
163
|
+
ufl_ns = _generated(gotran2ufl, ode)
|
|
164
|
+
py_ns = _generated(gotran2py, ode)
|
|
165
|
+
for k in (0.5, 1.5): # k on both sides of 1.0, so each argument of Min is the smallest
|
|
166
|
+
params = [float(p) for p in py_ns["init_parameter_values"](k=k)]
|
|
167
|
+
for y in (0.1, 0.2, 0.5, 1.2, 1.5, 3.0):
|
|
168
|
+
value = float(ufl_ns["rhs"](0.0, [y], params)[0])
|
|
169
|
+
assert value == pytest.approx(_expected_rhs(numpy.array(y), k), rel=1e-12), (k, y)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("module", [gotran2julia, gotran2mtk])
|
|
173
|
+
def test_julia_and_mtk_print_without_sympy_names(module):
|
|
174
|
+
code = module.get_code(ode_from_string(MODEL))
|
|
175
|
+
assert "Max(" not in code and "Min(" not in code
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_save_and_load_round_trip(tmp_path):
|
|
179
|
+
ode = ode_from_string(MODEL)
|
|
180
|
+
ode.save(tmp_path / "m.ode")
|
|
181
|
+
again = gotranx.load_ode(tmp_path / "m.ode")
|
|
182
|
+
ns, ns2 = _generated(gotran2py, ode), _generated(gotran2py, again)
|
|
183
|
+
p = ns["init_parameter_values"]()
|
|
184
|
+
numpy.testing.assert_array_equal(ns["rhs"](0.0, Y[None, :], p), ns2["rhs"](0.0, Y[None, :], p))
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
5
8
|
import sympy
|
|
6
9
|
|
|
7
10
|
from gotranx import singularities
|
|
@@ -477,3 +480,48 @@ def test_half_width_does_not_depend_on_one_ulp_differences_in_exp(monkeypatch):
|
|
|
477
480
|
monkeypatch.setattr(sympy, "lambdify", noisy_lambdify)
|
|
478
481
|
assert singularities.half_width(ghk, v, sympy.Integer(0), 3, {}) == reference, seed
|
|
479
482
|
monkeypatch.setattr(sympy, "lambdify", original)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _fixed_point_depending_on(definitions, seeds):
|
|
486
|
+
"""The algorithm on main at d17b977, kept as the reference."""
|
|
487
|
+
dependent, changed = set(), True
|
|
488
|
+
while changed:
|
|
489
|
+
changed = False
|
|
490
|
+
for symbol, definition in definitions.items():
|
|
491
|
+
if symbol not in dependent and definition.free_symbols & (seeds | dependent):
|
|
492
|
+
dependent.add(symbol)
|
|
493
|
+
changed = True
|
|
494
|
+
return dependent
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@pytest.mark.parametrize("seed", range(20))
|
|
498
|
+
def test_depending_on_matches_the_fixed_point(seed):
|
|
499
|
+
rng = random.Random(seed)
|
|
500
|
+
leaves, inner = sympy.symbols("a0:10"), sympy.symbols("s0:50")
|
|
501
|
+
known, definitions = list(leaves), {}
|
|
502
|
+
for s in inner:
|
|
503
|
+
definitions[s] = sympy.Add(*rng.sample(known, rng.randint(1, 3)))
|
|
504
|
+
known.append(s)
|
|
505
|
+
items = list(definitions.items())
|
|
506
|
+
rng.shuffle(items) # not in dependency order: one pass is not enough
|
|
507
|
+
definitions = dict(items)
|
|
508
|
+
seeds = set(rng.sample(leaves, rng.randint(1, 3)))
|
|
509
|
+
if seed % 2:
|
|
510
|
+
seeds.add(rng.choice(inner))
|
|
511
|
+
assert singularities._compute_depending_on(definitions, seeds) == _fixed_point_depending_on(
|
|
512
|
+
definitions, seeds
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_depending_on_does_not_reuse_another_models_graph():
|
|
517
|
+
a, b, x = sympy.symbols("a b x")
|
|
518
|
+
first, second = {x: a + 1}, {x: b + 1}
|
|
519
|
+
assert singularities._depending_on(first, {a}) == {x}
|
|
520
|
+
assert singularities._depending_on(second, {a}) == set()
|
|
521
|
+
assert singularities._depending_on(second, {b}) == {x}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_numeric_substitutes_only_the_expressions_own_symbols():
|
|
525
|
+
a, unrelated = sympy.symbols("a unrelated")
|
|
526
|
+
# sympy.Float("not a number") raises, so only `a`'s default may be converted
|
|
527
|
+
assert singularities._numeric(a + 1, {a: 2.0, unrelated: "not a number"}) == 3.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|