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.
@@ -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
+ ]
@@ -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
+ ...