gotranx 2.3.0__tar.gz → 2.5.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 (97) hide show
  1. {gotranx-2.3.0/src/gotranx.egg-info → gotranx-2.5.0}/PKG-INFO +2 -1
  2. {gotranx-2.3.0 → gotranx-2.5.0}/pyproject.toml +5 -3
  3. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/__init__.py +14 -0
  4. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2cytozoo.py +5 -0
  5. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/utils.py +55 -20
  6. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/base.py +6 -4
  7. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/c.py +1 -0
  8. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/cytozoo.py +26 -3
  9. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/python.py +10 -0
  10. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/myokit.py +68 -15
  11. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/ode.lark +4 -0
  12. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/singularities.py +44 -14
  13. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/c.py +4 -2
  14. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/cytozoo.py +32 -12
  15. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/julia.py +4 -2
  16. {gotranx-2.3.0 → gotranx-2.5.0/src/gotranx.egg-info}/PKG-INFO +2 -1
  17. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/SOURCES.txt +3 -0
  18. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/requires.txt +1 -0
  19. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_benchmarks.py +17 -0
  20. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_cli.py +47 -0
  21. gotranx-2.5.0/tests/test_julia_runs.py +116 -0
  22. gotranx-2.5.0/tests/test_min_max.py +184 -0
  23. gotranx-2.5.0/tests/test_missing_variables.py +372 -0
  24. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_myokit.py +31 -0
  25. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_singularities.py +48 -0
  26. {gotranx-2.3.0 → gotranx-2.5.0}/LICENSE +0 -0
  27. {gotranx-2.3.0 → gotranx-2.5.0}/README.md +0 -0
  28. {gotranx-2.3.0 → gotranx-2.5.0}/setup.cfg +0 -0
  29. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/__init__.py +0 -0
  30. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/__main__.py +0 -0
  31. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/_enum.py +0 -0
  32. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/atoms.py +0 -0
  33. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/cellml2ode.py +0 -0
  34. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2c.py +0 -0
  35. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2julia.py +0 -0
  36. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2md.py +0 -0
  37. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2mtk.py +0 -0
  38. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2py.py +0 -0
  39. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/gotran2ufl.py +0 -0
  40. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/cli/ode2cellml.py +0 -0
  41. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/__init__.py +0 -0
  42. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/jax.py +0 -0
  43. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/julia.py +0 -0
  44. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/markdown.py +0 -0
  45. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/mtk.py +0 -0
  46. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/ode.py +0 -0
  47. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/codegen/ufl.py +0 -0
  48. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/exceptions.py +0 -0
  49. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/expressions.py +0 -0
  50. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/linearization.py +0 -0
  51. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/load.py +0 -0
  52. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/ode.py +0 -0
  53. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/ode_component.py +0 -0
  54. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/parser.py +0 -0
  55. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/py.typed +0 -0
  56. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/save.py +0 -0
  57. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/schemes.py +0 -0
  58. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/sympytools.py +0 -0
  59. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/__init__.py +0 -0
  60. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/jax.py +0 -0
  61. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/markdown.py +0 -0
  62. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/mtk.py +0 -0
  63. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/python.py +0 -0
  64. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/templates/ufl.py +0 -0
  65. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/transformer.py +0 -0
  66. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx/units.py +0 -0
  67. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/dependency_links.txt +0 -0
  68. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/entry_points.txt +0 -0
  69. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/not-zip-safe +0 -0
  70. {gotranx-2.3.0 → gotranx-2.5.0}/src/gotranx.egg-info/top_level.txt +0 -0
  71. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_atoms.py +0 -0
  72. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_c_codegen.py +0 -0
  73. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_comments.py +0 -0
  74. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_conditionals.py +0 -0
  75. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_cytozoo_codegen.py +0 -0
  76. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_deep_linearization_models.py +0 -0
  77. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_demos.py +0 -0
  78. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_determinism.py +0 -0
  79. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_expressions.py +0 -0
  80. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_julia_codegen.py +0 -0
  81. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_linearization.py +0 -0
  82. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_load.py +0 -0
  83. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_markdown_codegen.py +0 -0
  84. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_mtk_codegen.py +0 -0
  85. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_ode.py +0 -0
  86. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_ode_component.py +0 -0
  87. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_operations.py +0 -0
  88. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_python_codegen.py +0 -0
  89. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_save.py +0 -0
  90. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_schemes.py +0 -0
  91. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_singularity_models.py +0 -0
  92. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_subodes.py +0 -0
  93. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_sympytools.py +0 -0
  94. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_transformer_assignments.py +0 -0
  95. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_transformer_parameters.py +0 -0
  96. {gotranx-2.3.0 → gotranx-2.5.0}/tests/test_transformer_states.py +0 -0
  97. {gotranx-2.3.0 → gotranx-2.5.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.5.0
4
4
  Summary: A declarative language describing ordinary differential equations
5
5
  Author-email: Henrik Finsberg <henriknf@simula.no>
6
6
  License: MIT
@@ -12,6 +12,7 @@ Requires-Python: >=3.9
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: attrs
15
+ Requires-Dist: black
15
16
  Requires-Dist: lark
16
17
  Requires-Dist: pint
17
18
  Requires-Dist: rich-click
@@ -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.5.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"}
@@ -17,6 +17,7 @@ urls = {Homepage = "https://github.com/finsberg/gotranx"}
17
17
  requires-python = ">=3.9"
18
18
  dependencies = [
19
19
  "attrs",
20
+ "black",
20
21
  "lark",
21
22
  "pint",
22
23
  "rich-click",
@@ -97,7 +98,8 @@ test = "pytest"
97
98
  testpaths = ["tests"]
98
99
  markers = [
99
100
  "benchmark: mark a test as a benchmark.",
100
- "example: mark a test as an example."
101
+ "example: mark a test as an example.",
102
+ "julia: mark a test that runs generated code in Julia."
101
103
  ]
102
104
 
103
105
 
@@ -172,7 +174,7 @@ tag = true
172
174
  sign_tags = false
173
175
  tag_name = "v{new_version}"
174
176
  tag_message = "Bump version: {current_version} → {new_version}"
175
- current_version = "2.3.0"
177
+ current_version = "2.5.0"
176
178
 
177
179
 
178
180
  [[tool.bumpversion.files]]
@@ -669,6 +669,18 @@ def ode2cytozoo(
669
669
  None, "--model-name", help="Julia struct name (default: the ODE name in PascalCase)"
670
670
  ),
671
671
  v_name: str = typer.Option("v", "--v-name", help="State holding the transmembrane potential"),
672
+ remove_component: typing.Optional[typing.List[str]] = typer.Option(
673
+ None,
674
+ "--remove-component",
675
+ help="Generate the model without this component (repeatable). What the rest "
676
+ "reads from it becomes an input: a trailing writable parameter, NaN until set",
677
+ ),
678
+ keep_component: typing.Optional[str] = typer.Option(
679
+ None,
680
+ "--keep-component",
681
+ help="Generate only this component. What it reads from the other components "
682
+ "becomes an input: a trailing writable parameter, NaN until set",
683
+ ),
672
684
  remove_unused: bool = typer.Option(False, "--remove-unused", help="Remove unused variables"),
673
685
  remove_singularities: bool = typer.Option(
674
686
  True,
@@ -720,6 +732,8 @@ def ode2cytozoo(
720
732
  remove_unused=remove_unused,
721
733
  remove_singularities=remove_singularities,
722
734
  verbose=verbose,
735
+ remove_component=remove_component,
736
+ keep_component=keep_component,
723
737
  )
724
738
 
725
739
 
@@ -6,6 +6,7 @@ import structlog
6
6
  from ..codegen.cytozoo import CytoZooCodeGenerator
7
7
  from ..load import load_ode
8
8
  from ..ode import ODE
9
+ from .utils import select_components
9
10
 
10
11
  logger = structlog.get_logger()
11
12
 
@@ -87,6 +88,7 @@ def get_code(
87
88
  "import CytoZoo: num_monitors, monitor_names, monitor_values!",
88
89
  f"const NUM_STATES = {len(ode.states)};",
89
90
  f"const NUM_PARAMS = {len(ode.parameters)};",
91
+ *([f"const NUM_MISSING = {len(ode.missing_variables)};"] if ode.missing_variables else []),
90
92
  codegen.initial_parameter_values(),
91
93
  codegen.initial_state_values(),
92
94
  codegen.rhs(),
@@ -104,10 +106,13 @@ def main(
104
106
  remove_unused: bool = False,
105
107
  remove_singularities: bool = True,
106
108
  verbose: bool = False,
109
+ remove_component: list[str] | None = None,
110
+ keep_component: str | None = None,
107
111
  ) -> None:
108
112
  loglevel = logging.DEBUG if verbose else logging.INFO
109
113
  structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(loglevel))
110
114
  ode = load_ode(fname, remove_singularities=remove_singularities)
115
+ ode = select_components(ode, remove=remove_component, keep=keep_component)
111
116
  code = get_code(
112
117
  ode,
113
118
  model_name=model_name,
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  import typer
6
6
 
7
7
  from ..codegen import CodeGenerator
8
+ from ..ode import ODE
8
9
  from ..schemes import Scheme, get_scheme
9
10
 
10
11
 
@@ -29,14 +30,34 @@ def add_schemes(
29
30
  return comp
30
31
 
31
32
 
32
- def find_pyproject_toml_config() -> Path | None:
33
- """Find the pyproject.toml file."""
34
- from black.files import find_pyproject_toml
33
+ def _toml_loads(text: str) -> dict[str, Any]:
34
+ try:
35
+ import tomllib # Python >= 3.11
36
+ except ImportError:
37
+ import toml as tomllib # type: ignore
35
38
 
36
- path = find_pyproject_toml((str(Path.cwd()),))
37
- if path is None:
38
- return None
39
- return Path(path)
39
+ return tomllib.loads(text)
40
+
41
+
42
+ def find_pyproject_toml_config(start: Path | None = None) -> Path | None:
43
+ """The nearest `pyproject.toml` with a `[tool.gotranx]` table, searching from
44
+ `start` (by default the current directory) up to the root of the file system.
45
+
46
+ A `pyproject.toml` that cannot be parsed is returned too, so that
47
+ `read_config` reports it rather than skipping it silently.
48
+ """
49
+ start = Path.cwd() if start is None else start
50
+ for directory in (start, *start.parents):
51
+ path = directory / "pyproject.toml"
52
+ if not path.is_file():
53
+ continue
54
+ try:
55
+ config = _toml_loads(path.read_text())
56
+ except Exception:
57
+ return path
58
+ if "gotranx" in config.get("tool", {}):
59
+ return path
60
+ return None
40
61
 
41
62
 
42
63
  def read_config(path: Path | None) -> dict[str, Any]:
@@ -50,20 +71,8 @@ def read_config(path: Path | None) -> dict[str, Any]:
50
71
  if path is None:
51
72
  return {}
52
73
 
53
- # Try to read the configuration file
54
74
  try:
55
- # First try to use tomllib which is part of stdlib
56
- import tomllib as toml
57
- except ImportError:
58
- # If tomllib is not available, try to use toml
59
- try:
60
- import toml # type: ignore
61
- except ImportError:
62
- typer.echo("Please install 'tomllib' or 'toml' to read configuration files")
63
- return {}
64
-
65
- try:
66
- config = toml.loads(Path(path).read_text())
75
+ config = _toml_loads(Path(path).read_text())
67
76
  except Exception:
68
77
  typer.echo(f"Could not read configuration file {path}")
69
78
  return {}
@@ -109,3 +118,29 @@ def validate_scheme(scheme: list[Scheme] | list[str]) -> list[Scheme]:
109
118
  else:
110
119
  lst.append(s)
111
120
  return lst
121
+
122
+
123
+ def select_components(ode: ODE, remove: list[str] | None = None, keep: str | None = None) -> ODE:
124
+ """The part of `ode` to generate code for: without the components in `remove`
125
+ (`ode - ode.get_component(name)` for each), or only the component `keep`
126
+ (`ode.get_component(keep).to_ode()`). Names the parts share become missing
127
+ variables of the result."""
128
+ if remove and keep is not None:
129
+ raise typer.BadParameter("give --remove-component or --keep-component, not both")
130
+ names = [c.name for c in ode.components]
131
+ for name in [*(remove or []), *([keep] if keep is not None else [])]:
132
+ if name not in names:
133
+ raise typer.BadParameter(
134
+ f"no component {name!r} in {ode.name}; it has {', '.join(names)}",
135
+ param_hint="--remove-component" if keep is None else "--keep-component",
136
+ )
137
+ if keep is not None:
138
+ return ode.get_component(keep).to_ode()
139
+ if not remove:
140
+ return ode
141
+ # `ode - component` is named "<ode> - <component>", which is not a valid default
142
+ # model name (ode2cytozoo derives the struct name from it).
143
+ name = f"{ode.name}_without_{'_'.join(remove)}"
144
+ for comp in remove:
145
+ ode = ode - ode.get_component(comp)
146
+ return ODE(components=ode.components, t=ode.t, name=name, comments=ode.comments)
@@ -106,6 +106,8 @@ class Shape(str, Enum):
106
106
 
107
107
  class CodeGenerator(abc.ABC):
108
108
  variable_prefix = ""
109
+ #: How a function declares its missing-variables argument (C needs a type).
110
+ missing_variables_argument = "missing_variables"
109
111
 
110
112
  def __init__(
111
113
  self,
@@ -321,7 +323,7 @@ class CodeGenerator(abc.ABC):
321
323
 
322
324
  arguments = rhs.arguments
323
325
  if self._missing_variables:
324
- arguments += ["missing_variables"]
326
+ arguments += [self.missing_variables_argument]
325
327
 
326
328
  values_lst = []
327
329
  index = 0
@@ -383,7 +385,7 @@ class CodeGenerator(abc.ABC):
383
385
 
384
386
  arguments = rhs.arguments
385
387
  if self._missing_variables:
386
- arguments += ["missing_variables"]
388
+ arguments += [self.missing_variables_argument]
387
389
 
388
390
  values_lst = []
389
391
  index = 0
@@ -429,7 +431,7 @@ class CodeGenerator(abc.ABC):
429
431
 
430
432
  arguments = rhs.arguments
431
433
  if self._missing_variables:
432
- arguments += ["missing_variables"]
434
+ arguments += [self.missing_variables_argument]
433
435
 
434
436
  values_lst = []
435
437
  N = len(values)
@@ -495,7 +497,7 @@ class CodeGenerator(abc.ABC):
495
497
 
496
498
  arguments = rhs.arguments
497
499
  if self._missing_variables:
498
- arguments += ["missing_variables"]
500
+ arguments += [self.missing_variables_argument]
499
501
 
500
502
  dt = sympy.Symbol("dt")
501
503
  eqs = f(
@@ -81,6 +81,7 @@ class GotranCCodePrinter(C99CodePrinter):
81
81
 
82
82
  class CCodeGenerator(CodeGenerator):
83
83
  variable_prefix = "const double "
84
+ missing_variables_argument = "const double *__restrict missing_variables"
84
85
 
85
86
  def __init__(
86
87
  self, ode: ODE, format: Format = Format.clang_format, remove_unused: bool = False
@@ -68,7 +68,10 @@ class CytoZooCodeGenerator(JuliaCodeGenerator):
68
68
  return [s.name for s in self.ode.sorted_states()]
69
69
 
70
70
  def _parameter_name_list(self) -> list[str]:
71
- return [p.name for p in self.ode.parameters]
71
+ # The missing variables follow the parameters, in missing_index order, because
72
+ # the adapter stores them in the parameter vector (templates.cytozoo.model_struct).
73
+ missing = sorted(self._missing_variables, key=self._missing_variables.__getitem__)
74
+ return [p.name for p in self.ode.parameters] + missing
72
75
 
73
76
  def _monitor_name_list(self) -> list[str]:
74
77
  # Mirror codegen.base.CodeGenerator.monitor_index (base.py:186) exactly:
@@ -142,18 +145,38 @@ class CytoZooCodeGenerator(JuliaCodeGenerator):
142
145
  )
143
146
  return "\n".join(lines)
144
147
 
148
+ def _missing_variables_assignments(self) -> str:
149
+ """Every missing-variable local, converted to the state's element type.
150
+
151
+ The missing variables are trailing entries of the parameter vector (see
152
+ `templates.cytozoo.model_struct`), so they are converted like the parameters:
153
+ a model built in Float64 and evaluated on Float32 states would otherwise
154
+ compute every term that reads one in Float64.
155
+ """
156
+ if not self._missing_variables:
157
+ return ""
158
+ lines = [
159
+ f"{name} = TYPE(missing_variables[{index + 1}])"
160
+ for name, index in self._missing_variables.items()
161
+ ]
162
+ return "\n".join(["", self._comment("Assign missing variables"), *lines, ""])
163
+
145
164
  def adapter(self) -> str:
146
165
  """The struct, name tuples, index lookups and interface methods."""
147
166
  name = self._model_name
148
167
  monitors = self._monitor_name_list()
149
168
  parts = [
150
- templates.cytozoo.model_struct(name, len(self.ode.parameters)),
169
+ templates.cytozoo.model_struct(
170
+ name, len(self.ode.parameters), len(self._missing_variables)
171
+ ),
151
172
  templates.cytozoo.name_tuples(
152
173
  name, self._state_name_list(), self._parameter_name_list(), monitors
153
174
  ),
154
175
  templates.cytozoo.index_lookups(
155
176
  name, self._state_name_list(), self._parameter_name_list()
156
177
  ),
157
- templates.cytozoo.interface_methods(name, self._potential_index(), len(monitors)),
178
+ templates.cytozoo.interface_methods(
179
+ name, self._potential_index(), len(monitors), len(self._missing_variables)
180
+ ),
158
181
  ]
159
182
  return self._format("\n".join(parts))
@@ -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,20 +1,17 @@
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
6
- from typing import overload
7
+ from types import ModuleType
8
+ from typing import TYPE_CHECKING, overload
7
9
  from collections.abc import Sequence
8
10
 
9
11
  import sympy as sp
10
12
 
11
- try:
13
+ if TYPE_CHECKING:
12
14
  import myokit
13
- import myokit.formats.sympy
14
- import myokit.lib.guess
15
- import myokit.formats.cellml
16
- except ImportError:
17
- myokit = None
18
15
 
19
16
  from . import atoms
20
17
  from .ode_component import BaseComponent, MyokitComponent
@@ -24,9 +21,24 @@ from .exceptions import GotranxImportError
24
21
  reserved_names = {name for name in dir(sp) if not name.startswith("_")}
25
22
 
26
23
 
24
+ def _import_myokit() -> ModuleType:
25
+ """myokit, with the submodules used here, imported by the functions that need it.
26
+
27
+ Importing myokit imports numpy and lxml, which only reading and writing CellML
28
+ and `.mmt` files needs, so `import gotranx` leaves it alone.
29
+ """
30
+ try:
31
+ import myokit
32
+ import myokit.formats.cellml
33
+ import myokit.formats.sympy
34
+ import myokit.lib.guess
35
+ except ImportError as e:
36
+ raise GotranxImportError("myokit") from e
37
+ return myokit
38
+
39
+
27
40
  def assert_has_myokit():
28
- if myokit is None:
29
- raise GotranxImportError("myokit")
41
+ _import_myokit()
30
42
 
31
43
 
32
44
  _INVALID_NAME_CHARS = re.compile(r"\W+")
@@ -223,7 +235,7 @@ def mmt_to_gotran(filename: str | Path) -> ODE:
223
235
  gotranx.ode.ODE
224
236
  The gotran ODE
225
237
  """
226
- assert_has_myokit()
238
+ myokit = _import_myokit()
227
239
  model, protocol, _ = myokit.load(filename)
228
240
 
229
241
  return myokit_to_gotran(model, protocol=protocol)
@@ -244,7 +256,7 @@ def myokit_to_gotran(model: "myokit.Model", protocol=None) -> ODE:
244
256
  gotranx.ode.ODE
245
257
  The gotran ODE
246
258
  """
247
- assert_has_myokit()
259
+ myokit = _import_myokit()
248
260
  # Embed protocol
249
261
  if protocol is not None:
250
262
  model = model.clone()
@@ -346,6 +358,47 @@ def myokit_to_gotran(model: "myokit.Model", protocol=None) -> ODE:
346
358
  )
347
359
 
348
360
 
361
+ def _sympy_reader(model: "myokit.Model") -> "myokit.formats.sympy.SymPyExpressionReader":
362
+ """myokit's sympy expression reader, extended to read ``Min``, ``Max`` and n-ary
363
+ ``And``/``Or``.
364
+
365
+ myokit has no min or max, and its ``And`` and ``Or`` take exactly two operands where sympy's
366
+ take any number. Rewriting ``Min``/``Max`` as sympy ``Piecewise`` first does not work: a
367
+ ``Piecewise`` nested in a condition, as in the clamp ``Min(Max(x, 0), 1)``, becomes an ``ITE``,
368
+ which the reader cannot read either, and four or more arguments produce an n-ary ``And``. So
369
+ each ``Min``/``Max`` is read into nested myokit ``If`` expressions, and ``And``/``Or`` are
370
+ folded into nested binary ones.
371
+ """
372
+ _import_myokit()
373
+ # An import statement rather than `myokit = _import_myokit()`, so that mypy can
374
+ # resolve the base class below.
375
+ import myokit.formats.sympy
376
+
377
+ class Reader(myokit.formats.sympy.SymPyExpressionReader):
378
+ def _build_op_map(self):
379
+ op_map = super()._build_op_map()
380
+ op_map[sp.Min] = self._ex_min
381
+ op_map[sp.Max] = self._ex_max
382
+ return op_map
383
+
384
+ def _fold(self, e, combine):
385
+ return functools.reduce(combine, [self.ex(arg) for arg in e.args])
386
+
387
+ def _ex_min(self, e):
388
+ return self._fold(e, lambda a, b: myokit.If(myokit.LessEqual(a, b), a, b))
389
+
390
+ def _ex_max(self, e):
391
+ return self._fold(e, lambda a, b: myokit.If(myokit.MoreEqual(a, b), a, b))
392
+
393
+ def _ex_and(self, e):
394
+ return self._fold(e, myokit.And)
395
+
396
+ def _ex_or(self, e):
397
+ return self._fold(e, myokit.Or)
398
+
399
+ return Reader(model=model)
400
+
401
+
349
402
  def gotran_to_myokit(ode: ODE, time_component="engine", time_unit="s") -> "myokit.Model":
350
403
  """Convert a gotran ODE to myokit model
351
404
 
@@ -359,7 +412,7 @@ def gotran_to_myokit(ode: ODE, time_component="engine", time_unit="s") -> "myoki
359
412
  myokit.Model
360
413
  The myokit model
361
414
  """
362
- assert_has_myokit()
415
+ myokit = _import_myokit()
363
416
  model = myokit.Model(ode.name)
364
417
  model.meta["author"] = "GotranX API"
365
418
  comp = model.add_component(time_component)
@@ -420,7 +473,7 @@ def gotran_to_myokit(ode: ODE, time_component="engine", time_unit="s") -> "myoki
420
473
  var.set_unit(to_myokit_unit(intermediate.unit_str))
421
474
  global_var_map[intermediate.symbol] = sp.Symbol(var.qname())
422
475
 
423
- sympy_reader = myokit.formats.sympy.SymPyExpressionReader(model=model)
476
+ sympy_reader = _sympy_reader(model)
424
477
  # Then we can add expressions
425
478
  for component in ode.components:
426
479
  comp = model[component_name_map[component.name]]
@@ -468,7 +521,7 @@ def cellml_to_gotran(filename: str | Path) -> ODE:
468
521
  gotranx.ode.ODE
469
522
  The gotran ODE
470
523
  """
471
- assert_has_myokit()
524
+ myokit = _import_myokit()
472
525
  myokit_model = myokit.formats.cellml.CellMLImporter().model(filename)
473
526
  return myokit_to_gotran(myokit_model)
474
527
 
@@ -483,6 +536,6 @@ def gotran_to_cellml(ode: ODE, filename: str | Path) -> None:
483
536
  filename : str | Path
484
537
  The filename of the cellml file
485
538
  """
486
- assert_has_myokit()
539
+ myokit = _import_myokit()
487
540
  model = gotran_to_myokit(ode)
488
541
  myokit.formats.cellml.CellMLExporter().model(filename, model, version="1.0")
@@ -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)
@@ -45,11 +45,13 @@ def method(
45
45
  values: str,
46
46
  return_name: None = None,
47
47
  num_return_values: int = 0,
48
+ missing_variables: str = "",
48
49
  **kwargs,
49
50
  ):
50
51
  indent_states = indent(states, " ")
51
52
  indent_parameters = indent(parameters, " ")
52
53
  indent_values = indent(values, " ")
54
+ indent_missing_variables = indent(missing_variables, " ")
53
55
  return dedent(
54
56
  f"""
55
57
  void {name}({args}){{
@@ -59,7 +61,7 @@ void {name}({args}){{
59
61
 
60
62
  // Assign parameters
61
63
  {indent_parameters}
62
-
64
+ {indent_missing_variables}
63
65
  // Assign expressions
64
66
  {indent_values}
65
67
  }}
@@ -102,4 +104,4 @@ def monitor_index(data: dict[str, int]) -> str:
102
104
 
103
105
 
104
106
  def missing_index(data: dict[str, int]) -> str:
105
- return method_index(data, "monitor")
107
+ return method_index(data, "missing")