simcardemsx 0.1.0__py3-none-any.whl
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.
- simcardemsx/__init__.py +18 -0
- simcardemsx/averaging.py +112 -0
- simcardemsx/backends/__init__.py +37 -0
- simcardemsx/backends/base.py +120 -0
- simcardemsx/backends/generated.py +660 -0
- simcardemsx/backends/segregated.py +565 -0
- simcardemsx/backends/zeta_split.py +415 -0
- simcardemsx/checkpoint.py +727 -0
- simcardemsx/config.py +43 -0
- simcardemsx/controller.py +323 -0
- simcardemsx/datacollector.py +503 -0
- simcardemsx/interpolation.py +41 -0
- simcardemsx/mechanics.py +219 -0
- simcardemsx/ode_model.py +137 -0
- simcardemsx/provenance.py +68 -0
- simcardemsx/py.typed +0 -0
- simcardemsx/results.py +324 -0
- simcardemsx/transfer_plan.py +362 -0
- simcardemsx/units.py +101 -0
- simcardemsx/utils.py +12 -0
- simcardemsx/zero_d.py +294 -0
- simcardemsx-0.1.0.dist-info/METADATA +213 -0
- simcardemsx-0.1.0.dist-info/RECORD +26 -0
- simcardemsx-0.1.0.dist-info/WHEEL +5 -0
- simcardemsx-0.1.0.dist-info/licenses/LICENSE +7 -0
- simcardemsx-0.1.0.dist-info/top_level.txt +1 -0
simcardemsx/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from importlib.metadata import metadata
|
|
2
|
+
|
|
3
|
+
meta = metadata("simcardemsx")
|
|
4
|
+
__version__ = meta.get("Version")
|
|
5
|
+
__author__ = meta.get("Author-email")
|
|
6
|
+
__license__ = meta.get("license-expression")
|
|
7
|
+
__email__ = meta.get("Author-email")
|
|
8
|
+
__program_name__ = meta.get("Name")
|
|
9
|
+
|
|
10
|
+
from . import backends, controller, interpolation, ode_model, utils
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"backends",
|
|
14
|
+
"controller",
|
|
15
|
+
"ode_model",
|
|
16
|
+
"utils",
|
|
17
|
+
"interpolation",
|
|
18
|
+
]
|
simcardemsx/averaging.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Averaging a discontinuous field into a continuous or cellwise-constant target.
|
|
2
|
+
|
|
3
|
+
Values sent from the mechanics mesh back to the EP mesh (fibre stretch λ, active
|
|
4
|
+
tension, ...) are discontinuous across cells -- there is no single "the" value at a
|
|
5
|
+
shared vertex. Point interpolation of such a field into a continuous (P1) space picks
|
|
6
|
+
up whichever neighbouring cell's value the interpolation happens to visit last, so the
|
|
7
|
+
result silently depends on cell numbering. :func:`make_averager` instead builds a
|
|
8
|
+
proper average: a mass-lumped nodal average for a P1 target, an exact cell average for
|
|
9
|
+
a DG0 target.
|
|
10
|
+
|
|
11
|
+
Both source and target are assumed to live on the same mesh -- this module averages
|
|
12
|
+
*within* a mesh; :mod:`simcardemsx.interpolation` moves values *between* meshes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
import dolfinx
|
|
20
|
+
import ufl
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def family_name(element) -> str:
|
|
24
|
+
"""The element's family name, normalizing a discontinuous element to ``"DG"``.
|
|
25
|
+
|
|
26
|
+
dolfinx reports a discontinuous Lagrange element's own ``family_name`` as
|
|
27
|
+
``"P"`` (with ``discontinuous=True``); callers building a label like
|
|
28
|
+
``"DG0"`` need ``"DG"`` instead.
|
|
29
|
+
"""
|
|
30
|
+
return "DG" if element.discontinuous else element.family_name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_averager(
|
|
34
|
+
source: dolfinx.fem.Function,
|
|
35
|
+
target: dolfinx.fem.Function,
|
|
36
|
+
) -> Callable[[], None]:
|
|
37
|
+
"""Build a callable that refreshes ``target`` in place with an average of ``source``.
|
|
38
|
+
|
|
39
|
+
Compiles the assembly forms once, here, rather than on every call. The returned
|
|
40
|
+
closure re-assembles the numerator against the current ``source`` values each time
|
|
41
|
+
it is called (the denominator -- cell volumes / patch measures -- does not depend on
|
|
42
|
+
``source`` and is assembled once, up front).
|
|
43
|
+
|
|
44
|
+
Supported ``target`` spaces:
|
|
45
|
+
|
|
46
|
+
- continuous Lagrange degree 1 (P1): the lumped nodal average
|
|
47
|
+
``target_i = (∫ source φ_i dx) / (∫ φ_i dx)``. Because a nodal basis function
|
|
48
|
+
φ_i vanishes outside the cells touching node i, this is exactly the volume-weighted
|
|
49
|
+
average of ``source`` over the cells sharing that node.
|
|
50
|
+
- discontinuous Lagrange degree 0 (DG0): the exact cell average
|
|
51
|
+
``target_K = (∫_K source dx) / |K|``. DG0 basis functions do not overlap between
|
|
52
|
+
cells, so the same numerator/denominator machinery reduces to a per-cell average
|
|
53
|
+
with no approximation.
|
|
54
|
+
|
|
55
|
+
Any other ``target`` space raises :class:`NotImplementedError`.
|
|
56
|
+
|
|
57
|
+
When ``source`` lives on a quadrature space, the measure is given
|
|
58
|
+
``metadata={"quadrature_degree": source's degree}`` so FFCx reuses the same
|
|
59
|
+
quadrature rule the values were stored at, instead of picking its own (which would
|
|
60
|
+
not line up with the stored point values at all).
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
source : dolfinx.fem.Function
|
|
65
|
+
The (generally discontinuous, possibly quadrature) function to average.
|
|
66
|
+
target : dolfinx.fem.Function
|
|
67
|
+
The function refreshed, in place, by the returned callable. Its function space
|
|
68
|
+
determines which averaging scheme is used.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
Callable[[], None]
|
|
73
|
+
Call it with no arguments to refresh ``target`` from the current ``source``.
|
|
74
|
+
"""
|
|
75
|
+
V_target = target.function_space
|
|
76
|
+
target_element = V_target.ufl_element()
|
|
77
|
+
family = family_name(target_element)
|
|
78
|
+
degree = target_element.degree
|
|
79
|
+
|
|
80
|
+
is_p1 = family == "P" and degree == 1
|
|
81
|
+
is_dg0 = family == "DG" and degree == 0
|
|
82
|
+
if not (is_p1 or is_dg0):
|
|
83
|
+
raise NotImplementedError(
|
|
84
|
+
"make_averager only supports a continuous P1 or a DG0 target, got "
|
|
85
|
+
f"family={family!r}, degree={degree}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
mesh = V_target.mesh
|
|
89
|
+
source_element = source.function_space.ufl_element()
|
|
90
|
+
metadata = None
|
|
91
|
+
if source_element.family_name == "quadrature":
|
|
92
|
+
metadata = {"quadrature_degree": source_element.degree}
|
|
93
|
+
dx = ufl.dx(domain=mesh, metadata=metadata)
|
|
94
|
+
|
|
95
|
+
v = ufl.TestFunction(V_target)
|
|
96
|
+
numerator_form = dolfinx.fem.form(source * v * dx)
|
|
97
|
+
denominator_form = dolfinx.fem.form(v * dx)
|
|
98
|
+
|
|
99
|
+
# The denominator (cell volumes for DG0, nodal patch measures for P1) depends only
|
|
100
|
+
# on the mesh and V_target, both fixed -- assemble it once.
|
|
101
|
+
denominator = dolfinx.fem.assemble_vector(denominator_form)
|
|
102
|
+
denominator.scatter_reverse(dolfinx.la.InsertMode.add)
|
|
103
|
+
denominator.scatter_forward()
|
|
104
|
+
|
|
105
|
+
def refresh() -> None:
|
|
106
|
+
numerator = dolfinx.fem.assemble_vector(numerator_form)
|
|
107
|
+
numerator.scatter_reverse(dolfinx.la.InsertMode.add)
|
|
108
|
+
numerator.scatter_forward()
|
|
109
|
+
target.x.array[:] = numerator.array / denominator.array
|
|
110
|
+
target.x.scatter_forward()
|
|
111
|
+
|
|
112
|
+
return refresh
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Activation backends.
|
|
2
|
+
|
|
3
|
+
Each backend generates active tension and hands it to the mechanics solve, but
|
|
4
|
+
they differ in where the EP/mechanics split is cut and in how force generation
|
|
5
|
+
is coupled to mechanics. See :mod:`simcardemsx.backends.base` for the
|
|
6
|
+
``CoupledBackend`` protocol and why the differences matter.
|
|
7
|
+
|
|
8
|
+
The controller drives a backend through :class:`CoupledBackend`;
|
|
9
|
+
:class:`GeneratedActivation` and :class:`CrossbridgeSegregated` are two. What crosses
|
|
10
|
+
between EP and activation is derived from the EP module and the backend's
|
|
11
|
+
``missing``/``provides`` by :func:`simcardemsx.transfer_plan.resolve` and moved by
|
|
12
|
+
:class:`simcardemsx.transfer_plan.TransferPlan`. :class:`ZetaSplitUFL` is driven
|
|
13
|
+
directly against ``pulse`` and declares its crossings with :class:`Transfer`.
|
|
14
|
+
|
|
15
|
+
- :class:`GeneratedActivation`: any split of an ``.ode`` file, coupled monolithically in
|
|
16
|
+
Newton (the default), stabilized-segregated, or naive segregated (``scheme``).
|
|
17
|
+
- :class:`CrossbridgeSegregated`: a crossbridge model on the Ca_i split,
|
|
18
|
+
stabilized-segregated (R&Q), or naive segregated (``stabilized=False``).
|
|
19
|
+
- :class:`ZetaSplitUFL` (deprecated): the hand-ported Land model of the zeta split.
|
|
20
|
+
|
|
21
|
+
An external-operator crossbridge backend, coupling monolithically without
|
|
22
|
+
symbolic differentiation, is planned; it would implement :class:`CoupledBackend`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .base import CoupledBackend, Transfer
|
|
26
|
+
from .generated import GeneratedActivation
|
|
27
|
+
from .segregated import CrossbridgeSegregated
|
|
28
|
+
from .zeta_split import Scheme, ZetaSplitUFL
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CoupledBackend",
|
|
32
|
+
"Transfer",
|
|
33
|
+
"GeneratedActivation",
|
|
34
|
+
"ZetaSplitUFL",
|
|
35
|
+
"CrossbridgeSegregated",
|
|
36
|
+
"Scheme",
|
|
37
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The activation-backend interface.
|
|
2
|
+
|
|
3
|
+
An *activation backend* owns everything about how active tension is generated
|
|
4
|
+
and how it reaches the mechanics solve. Different backends make genuinely
|
|
5
|
+
different numerical choices -- where the EP/mechanics split is cut, and whether
|
|
6
|
+
the force-generation model is coupled monolithically or in a segregated way.
|
|
7
|
+
|
|
8
|
+
The controller drives a backend through the :class:`CoupledBackend` protocol below;
|
|
9
|
+
what crosses between EP and activation is derived from the EP module and the backend's
|
|
10
|
+
``missing``/``provides`` by :func:`simcardemsx.transfer_plan.resolve` and moved by
|
|
11
|
+
:class:`simcardemsx.transfer_plan.TransferPlan`. :class:`~simcardemsx.backends.generated.
|
|
12
|
+
GeneratedActivation` and :class:`~simcardemsx.backends.segregated.CrossbridgeSegregated`
|
|
13
|
+
are two; ``CrossbridgeSegregated`` also documents the units of its crossings with
|
|
14
|
+
:class:`Transfer` records. :class:`ZetaSplitUFL` is driven directly against ``pulse``,
|
|
15
|
+
and declares its crossings with :class:`Transfer` instead.
|
|
16
|
+
|
|
17
|
+
That matters more than usual here, because the choices are not equivalent.
|
|
18
|
+
A segregated coupling of force generation to mechanics is unstable, and in
|
|
19
|
+
fact not convergent, once active stiffness exceeds passive stiffness
|
|
20
|
+
(Regazzoni & Quarteroni 2021); a monolithic one is stable but expensive. Having
|
|
21
|
+
the alternatives behind one interface is what makes them comparable on the same
|
|
22
|
+
mesh, in the same process, from the same configuration, rather than by
|
|
23
|
+
comparing published numbers.
|
|
24
|
+
|
|
25
|
+
A backend is also a ``pulse.ActiveModel``: it supplies ``S``/``P`` and is handed
|
|
26
|
+
straight to ``pulse.CardiacModel``. Note that ``S`` is the primary contract, not
|
|
27
|
+
``strain_energy``. Only a segregated backend has a clean potential to
|
|
28
|
+
differentiate; for the zeta split ``Ta`` depends on the stretch through
|
|
29
|
+
``zeta_s(lambda_dot)``, so no closed-form potential exists, and for an
|
|
30
|
+
external-operator backend ``Ta`` is opaque by construction. This is fine:
|
|
31
|
+
``pulse.StaticProblem`` assembles ``model.S(C)`` and never touches
|
|
32
|
+
``strain_energy``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Literal, Mapping, Protocol, runtime_checkable
|
|
39
|
+
|
|
40
|
+
import dolfinx
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Transfer:
|
|
45
|
+
"""One variable crossing between the EP and mechanics subsystems.
|
|
46
|
+
|
|
47
|
+
A bare name is not enough to drive a transfer. The direction is carried by
|
|
48
|
+
which backend method returns the ``Transfer`` (``wants_from_ep`` or
|
|
49
|
+
``gives_to_ep``); the rest is here.
|
|
50
|
+
|
|
51
|
+
Attributes
|
|
52
|
+
----------
|
|
53
|
+
name:
|
|
54
|
+
The variable's name in the generated ODE module, e.g. ``"cai"`` or
|
|
55
|
+
``"XS"``. Used to look up its index via ``state_index``/``monitor_index``.
|
|
56
|
+
unit:
|
|
57
|
+
The unit *as the producing side emits it*, e.g. ``"mM"`` for ToR-ORd's
|
|
58
|
+
``cai``. The consumer may want something else -- crossbridge wants
|
|
59
|
+
micromolar -- and carrying the unit here is what lets the coupler
|
|
60
|
+
convert explicitly instead of relying on a lookup table keyed
|
|
61
|
+
invisibly by name.
|
|
62
|
+
kind:
|
|
63
|
+
Whether ``name`` is an ODE state or a monitored (derived) expression.
|
|
64
|
+
They need different index lookups in the generated module.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
unit: str = "dimensionless"
|
|
69
|
+
kind: Literal["state", "monitor"] = "state"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class CoupledBackend(Protocol):
|
|
74
|
+
"""What :class:`~simcardemsx.controller.SimulationController` requires of a backend.
|
|
75
|
+
|
|
76
|
+
Once per mechanics time step the controller calls, in this order::
|
|
77
|
+
|
|
78
|
+
backend.begin_step(t_n, dt) # t_n is the old time, dt the mechanics step (ms)
|
|
79
|
+
mechanics.advance(t_n, dt) # the Newton solve
|
|
80
|
+
backend.post_solve() # accept the step
|
|
81
|
+
# then TransferPlan.backward() reads ``outputs``
|
|
82
|
+
|
|
83
|
+
Nothing is accepted before ``post_solve``: until then the backend's stored states,
|
|
84
|
+
``outputs`` and ``active_tension`` are those of the last accepted step, so a step
|
|
85
|
+
whose solve fails leaves them untouched. ``begin_step`` only prepares the step.
|
|
86
|
+
|
|
87
|
+
What crosses between EP and the backend is derived by
|
|
88
|
+
:func:`simcardemsx.transfer_plan.resolve` from ``missing`` and ``provides``, the
|
|
89
|
+
name -> index dicts of the generated activation module, and moved through
|
|
90
|
+
``inputs`` (EP -> backend, filled before ``begin_step``) and ``outputs``
|
|
91
|
+
(backend -> EP, read after ``post_solve``), all on ``space``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def missing(self) -> Mapping[str, int]:
|
|
96
|
+
"""What the activation module needs from EP, name -> index."""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def provides(self) -> Mapping[str, int]:
|
|
101
|
+
"""What the activation module hands to EP, name -> index."""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def quadrature_degree(self) -> int | None:
|
|
106
|
+
"""The degree of the quadrature space the states live on, if they live on one."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
inputs: dict[str, dolfinx.fem.Function]
|
|
110
|
+
outputs: dict[str, dolfinx.fem.Function]
|
|
111
|
+
space: dolfinx.fem.FunctionSpace
|
|
112
|
+
active_tension: dolfinx.fem.Function
|
|
113
|
+
|
|
114
|
+
def begin_step(self, t_n: float, dt: float) -> None:
|
|
115
|
+
"""Prepare the step from ``t_n`` to ``t_n + dt`` (ms)."""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
def post_solve(self) -> None:
|
|
119
|
+
"""Accept the converged step."""
|
|
120
|
+
...
|