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.
Files changed (95) hide show
  1. {gotranx-2.3.0/src/gotranx.egg-info → gotranx-2.4.0}/PKG-INFO +1 -1
  2. {gotranx-2.3.0 → gotranx-2.4.0}/pyproject.toml +2 -2
  3. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/python.py +10 -0
  4. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/myokit.py +39 -1
  5. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode.lark +4 -0
  6. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/singularities.py +44 -14
  7. {gotranx-2.3.0 → gotranx-2.4.0/src/gotranx.egg-info}/PKG-INFO +1 -1
  8. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/SOURCES.txt +1 -0
  9. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_benchmarks.py +17 -0
  10. gotranx-2.4.0/tests/test_min_max.py +184 -0
  11. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_singularities.py +48 -0
  12. {gotranx-2.3.0 → gotranx-2.4.0}/LICENSE +0 -0
  13. {gotranx-2.3.0 → gotranx-2.4.0}/README.md +0 -0
  14. {gotranx-2.3.0 → gotranx-2.4.0}/setup.cfg +0 -0
  15. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/__init__.py +0 -0
  16. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/__main__.py +0 -0
  17. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/_enum.py +0 -0
  18. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/atoms.py +0 -0
  19. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/__init__.py +0 -0
  20. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/cellml2ode.py +0 -0
  21. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2c.py +0 -0
  22. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2cytozoo.py +0 -0
  23. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2julia.py +0 -0
  24. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2md.py +0 -0
  25. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2mtk.py +0 -0
  26. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2py.py +0 -0
  27. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/gotran2ufl.py +0 -0
  28. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/ode2cellml.py +0 -0
  29. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/cli/utils.py +0 -0
  30. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/__init__.py +0 -0
  31. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/base.py +0 -0
  32. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/c.py +0 -0
  33. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/cytozoo.py +0 -0
  34. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/jax.py +0 -0
  35. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/julia.py +0 -0
  36. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/markdown.py +0 -0
  37. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/mtk.py +0 -0
  38. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/ode.py +0 -0
  39. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/codegen/ufl.py +0 -0
  40. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/exceptions.py +0 -0
  41. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/expressions.py +0 -0
  42. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/linearization.py +0 -0
  43. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/load.py +0 -0
  44. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode.py +0 -0
  45. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/ode_component.py +0 -0
  46. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/parser.py +0 -0
  47. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/py.typed +0 -0
  48. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/save.py +0 -0
  49. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/schemes.py +0 -0
  50. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/sympytools.py +0 -0
  51. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/__init__.py +0 -0
  52. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/c.py +0 -0
  53. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/cytozoo.py +0 -0
  54. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/jax.py +0 -0
  55. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/julia.py +0 -0
  56. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/markdown.py +0 -0
  57. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/mtk.py +0 -0
  58. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/python.py +0 -0
  59. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/templates/ufl.py +0 -0
  60. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/transformer.py +0 -0
  61. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx/units.py +0 -0
  62. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/dependency_links.txt +0 -0
  63. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/entry_points.txt +0 -0
  64. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/not-zip-safe +0 -0
  65. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/requires.txt +0 -0
  66. {gotranx-2.3.0 → gotranx-2.4.0}/src/gotranx.egg-info/top_level.txt +0 -0
  67. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_atoms.py +0 -0
  68. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_c_codegen.py +0 -0
  69. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_cli.py +0 -0
  70. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_comments.py +0 -0
  71. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_conditionals.py +0 -0
  72. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_cytozoo_codegen.py +0 -0
  73. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_deep_linearization_models.py +0 -0
  74. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_demos.py +0 -0
  75. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_determinism.py +0 -0
  76. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_expressions.py +0 -0
  77. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_julia_codegen.py +0 -0
  78. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_linearization.py +0 -0
  79. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_load.py +0 -0
  80. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_markdown_codegen.py +0 -0
  81. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_mtk_codegen.py +0 -0
  82. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_myokit.py +0 -0
  83. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ode.py +0 -0
  84. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ode_component.py +0 -0
  85. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_operations.py +0 -0
  86. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_python_codegen.py +0 -0
  87. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_save.py +0 -0
  88. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_schemes.py +0 -0
  89. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_singularity_models.py +0 -0
  90. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_subodes.py +0 -0
  91. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_sympytools.py +0 -0
  92. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_assignments.py +0 -0
  93. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_parameters.py +0 -0
  94. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_transformer_states.py +0 -0
  95. {gotranx-2.3.0 → gotranx-2.4.0}/tests/test_ufl_codegen.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gotranx
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: A declarative language describing ordinary differential equations
5
5
  Author-email: Henrik Finsberg <henriknf@simula.no>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gotranx"
7
- version = "2.3.0"
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.3.0"
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 = myokit.formats.sympy.SymPyExpressionReader(model=model)
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 fixed point every time dominated the scan. The cache holds
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
- ``definitions`` is not guaranteed to be topologically sorted, so this
323
- iterates to a fixed point rather than assuming one pass suffices.
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
- changed = True
327
- while changed:
328
- changed = False
329
- for symbol, definition in definitions.items():
330
- if symbol in dependent:
331
- continue
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
- substituted = expr.xreplace({symbol: sympy.Float(x) for symbol, x in defaults.items()})
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 <= set(values):
1114
+ if not definition.free_symbols <= values.keys():
1085
1115
  continue
1086
1116
  try:
1087
1117
  value = _numeric(definition, values)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gotranx
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: A declarative language describing ordinary differential equations
5
5
  Author-email: Henrik Finsberg <henriknf@simula.no>
6
6
  License: MIT
@@ -74,6 +74,7 @@ tests/test_julia_codegen.py
74
74
  tests/test_linearization.py
75
75
  tests/test_load.py
76
76
  tests/test_markdown_codegen.py
77
+ tests/test_min_max.py
77
78
  tests/test_mtk_codegen.py
78
79
  tests/test_myokit.py
79
80
  tests/test_ode.py
@@ -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