bioelectrics 0.3.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.
- bioelectrics/__init__.py +34 -0
- bioelectrics/attractors.py +463 -0
- bioelectrics/channels.py +154 -0
- bioelectrics/constants.py +51 -0
- bioelectrics/control.py +183 -0
- bioelectrics/gap_junctions.py +92 -0
- bioelectrics/integrators.py +247 -0
- bioelectrics/interventions.py +461 -0
- bioelectrics/io/__init__.py +5 -0
- bioelectrics/io/betse.py +334 -0
- bioelectrics/ionic/__init__.py +14 -0
- bioelectrics/ionic/channel_library.py +152 -0
- bioelectrics/ionic/model.py +385 -0
- bioelectrics/ionic/profiles.py +95 -0
- bioelectrics/ionic/transport.py +95 -0
- bioelectrics/pharmacology.py +370 -0
- bioelectrics/tissue.py +119 -0
- bioelectrics/viz.py +402 -0
- bioelectrics/zoo/__init__.py +11 -0
- bioelectrics/zoo/cam2016.py +77 -0
- bioelectrics-0.3.0.dist-info/METADATA +245 -0
- bioelectrics-0.3.0.dist-info/RECORD +26 -0
- bioelectrics-0.3.0.dist-info/WHEEL +5 -0
- bioelectrics-0.3.0.dist-info/licenses/LICENSE +24 -0
- bioelectrics-0.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- bioelectrics-0.3.0.dist-info/top_level.txt +1 -0
bioelectrics/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""bioelectrics: a differentiable engine for somatic bioelectricity.
|
|
2
|
+
|
|
3
|
+
Subpackages and modules
|
|
4
|
+
-----------------------
|
|
5
|
+
* ``bioelectrics.ionic``: ion-level physics (Na+, K+, Cl-, Ca2+; GHK fluxes, pumps,
|
|
6
|
+
Hodgkin-Huxley channels, electrodiffusive gap junctions), BETSE-compatible.
|
|
7
|
+
* ``bioelectrics.interventions``: channel misexpression, drugs, gap junction blockers,
|
|
8
|
+
optogenetics, and their inverse design (``InterventionOptimizer``,
|
|
9
|
+
``ChronicInterventionOptimizer``).
|
|
10
|
+
* ``bioelectrics.attractors``: steady states, stability, basins, dose ramps, and
|
|
11
|
+
implicit gradients of steady states.
|
|
12
|
+
* ``bioelectrics.pharmacology``: drugs with published potency values, each carrying its
|
|
13
|
+
assay, species and citation.
|
|
14
|
+
* ``bioelectrics.zoo``: published models with reproduction tests.
|
|
15
|
+
* Voltage-level (CAM-type) models: ``ChannelProfile``, ``Tissue``, ``PatternOptimizer``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from . import attractors, constants, interventions, ionic, pharmacology, zoo
|
|
19
|
+
from .channels import (
|
|
20
|
+
ChannelProfile,
|
|
21
|
+
find_fixed_points,
|
|
22
|
+
ghk_current,
|
|
23
|
+
i_dep,
|
|
24
|
+
i_leak,
|
|
25
|
+
i_pol,
|
|
26
|
+
ionic_current,
|
|
27
|
+
membrane_rhs,
|
|
28
|
+
)
|
|
29
|
+
from .control import OptimizationResult, PatternOptimizer
|
|
30
|
+
from .gap_junctions import GapJunctionProfile, gj_conductance, grid_conductances
|
|
31
|
+
from .integrators import euler_step, integrate, integrate_adaptive, integrate_rosenbrock, rk4_step
|
|
32
|
+
from .tissue import BioelectricState, Tissue, tumor_prepattern
|
|
33
|
+
|
|
34
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Attractors of ion-level tissues: steady states, stability, basins and implicit gradients.
|
|
2
|
+
|
|
3
|
+
A bioelectric pattern memory is a stable steady state of the tissue dynamics
|
|
4
|
+
``dx/dt = f(x, theta)``. This module finds such states directly, without long
|
|
5
|
+
simulations, and differentiates them with the implicit function theorem.
|
|
6
|
+
|
|
7
|
+
* :func:`find_steady_state` uses pseudo-transient continuation: implicit Euler
|
|
8
|
+
steps ``(I/h - J) dx = f`` with a step ``h`` that grows to infinity (Newton's
|
|
9
|
+
method in the limit). For small ``h`` the iteration follows the dynamics, so it
|
|
10
|
+
converges to the attractor of the basin that contains the start state, not to
|
|
11
|
+
a nearby saddle. ``J`` is the exact sparse Jacobian from forward-mode autograd;
|
|
12
|
+
a distance-2 coloring of the cell graph needs only ``5 n`` Jacobian-vector
|
|
13
|
+
products on a grid (``n`` = state components per cell), for any tissue size.
|
|
14
|
+
* :func:`steady_state_implicit` returns the steady state with gradients for any
|
|
15
|
+
parameter ``theta``: ``dL/dtheta = -lambda^T df/dtheta`` with
|
|
16
|
+
``J^T lambda = dL/dx*``. One sparse linear solve replaces backpropagation through
|
|
17
|
+
the whole relaxation.
|
|
18
|
+
* :func:`enumerate_attractors`, :func:`dose_response` and :func:`basin_map` map the
|
|
19
|
+
attractor landscape.
|
|
20
|
+
|
|
21
|
+
State components whose time derivative is identically zero (for example
|
|
22
|
+
impermeant proteins) are held fixed and removed from the linear systems.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Callable, Sequence
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import scipy.sparse as sp
|
|
32
|
+
import scipy.sparse.linalg as spla
|
|
33
|
+
import torch
|
|
34
|
+
from torch.func import jvp
|
|
35
|
+
|
|
36
|
+
from .constants import DTYPE
|
|
37
|
+
from .ionic.model import NO_MODIFIERS, IonicState, IonicTissue, Modifiers
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"SteadyState",
|
|
41
|
+
"Attractor",
|
|
42
|
+
"sparse_jacobian",
|
|
43
|
+
"find_steady_state",
|
|
44
|
+
"steady_state_implicit",
|
|
45
|
+
"enumerate_attractors",
|
|
46
|
+
"random_vmem_states",
|
|
47
|
+
"dose_response",
|
|
48
|
+
"basin_map",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------ Jacobian
|
|
53
|
+
def _cell_edges(tissue: IonicTissue, cells: tuple[int, ...]) -> np.ndarray:
|
|
54
|
+
"""Undirected cell-cell edges ``(2, E)`` of the coupling graph (flat cell indices)."""
|
|
55
|
+
if tissue.edges is not None:
|
|
56
|
+
return tissue.edges.detach().cpu().numpy().astype(np.int64).reshape(2, -1)
|
|
57
|
+
if len(cells) != 2:
|
|
58
|
+
raise ValueError("a tissue without explicit edges needs a 2D grid (H, W)")
|
|
59
|
+
idx = np.arange(cells[0] * cells[1]).reshape(cells)
|
|
60
|
+
horiz = np.stack([idx[:, :-1].ravel(), idx[:, 1:].ravel()])
|
|
61
|
+
vert = np.stack([idx[:-1, :].ravel(), idx[1:, :].ravel()])
|
|
62
|
+
return np.concatenate([horiz, vert], axis=1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coloring(tissue: IonicTissue, cells: tuple[int, ...], edges: np.ndarray) -> np.ndarray:
|
|
66
|
+
"""Distance-2 coloring: cells of one color have disjoint closed neighbourhoods."""
|
|
67
|
+
n_cells = int(np.prod(cells))
|
|
68
|
+
if tissue.edges is None:
|
|
69
|
+
r, c = np.meshgrid(np.arange(cells[0]), np.arange(cells[1]), indexing="ij")
|
|
70
|
+
return ((r + 2 * c) % 5).ravel() # 4-neighbour grid: 5 colors suffice
|
|
71
|
+
nbrs: list[set[int]] = [set() for _ in range(n_cells)]
|
|
72
|
+
for a, b in edges.T: # setup only, not ODE evaluation
|
|
73
|
+
nbrs[a].add(int(b))
|
|
74
|
+
nbrs[b].add(int(a))
|
|
75
|
+
color = -np.ones(n_cells, dtype=np.int64)
|
|
76
|
+
for p in range(n_cells):
|
|
77
|
+
used = {color[q] for q in nbrs[p]}
|
|
78
|
+
for q in nbrs[p]:
|
|
79
|
+
used |= {color[s] for s in nbrs[q]}
|
|
80
|
+
k = 0
|
|
81
|
+
while k in used:
|
|
82
|
+
k += 1
|
|
83
|
+
color[p] = k
|
|
84
|
+
return color
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _source_table(color: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
88
|
+
"""``src[k, p]`` = the cell of color ``k`` in the closed neighbourhood of ``p`` (or -1)."""
|
|
89
|
+
n_colors, n_cells = int(color.max()) + 1, color.size
|
|
90
|
+
src = -np.ones((n_colors, n_cells), dtype=np.int64)
|
|
91
|
+
src[color, np.arange(n_cells)] = np.arange(n_cells)
|
|
92
|
+
a, b = edges
|
|
93
|
+
src[color[b], a] = b
|
|
94
|
+
src[color[a], b] = a
|
|
95
|
+
return src
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sparse_jacobian(
|
|
99
|
+
tissue: IonicTissue, x: torch.Tensor, modifiers: Modifiers = NO_MODIFIERS
|
|
100
|
+
) -> sp.csr_matrix:
|
|
101
|
+
"""Exact Jacobian ``df/dx`` of ``tissue.rhs`` at ``x`` (batch size 1) as a sparse matrix.
|
|
102
|
+
|
|
103
|
+
Rows and columns use the flat index ``component * n_cells + cell``.
|
|
104
|
+
"""
|
|
105
|
+
if x.shape[0] != 1:
|
|
106
|
+
raise ValueError("sparse_jacobian needs batch size 1")
|
|
107
|
+
n_comp, cells = x.shape[1], tuple(x.shape[2:])
|
|
108
|
+
n_cells = int(np.prod(cells))
|
|
109
|
+
edges = _cell_edges(tissue, cells)
|
|
110
|
+
color = _coloring(tissue, cells, edges)
|
|
111
|
+
src = _source_table(color, edges)
|
|
112
|
+
n_colors = src.shape[0]
|
|
113
|
+
color_t = torch.as_tensor(color).reshape(cells)
|
|
114
|
+
x = x.detach()
|
|
115
|
+
# All seeds at once: the batch dimension of rhs carries one seed per (color, component).
|
|
116
|
+
seeds = torch.zeros((n_colors, n_comp, n_comp, *cells), dtype=x.dtype)
|
|
117
|
+
masks = (color_t.unsqueeze(0) == torch.arange(n_colors).reshape(-1, *([1] * len(cells)))).to(x.dtype)
|
|
118
|
+
idx = torch.arange(n_comp)
|
|
119
|
+
seeds[:, idx, idx] = masks.unsqueeze(1)
|
|
120
|
+
seeds = seeds.reshape(n_colors * n_comp, n_comp, *cells)
|
|
121
|
+
xb = x.expand(seeds.shape[0], *x.shape[1:]).contiguous()
|
|
122
|
+
with torch.no_grad():
|
|
123
|
+
_, df = jvp(lambda z: tissue.rhs(z, modifiers), (xb,), (seeds,))
|
|
124
|
+
df = df.reshape(n_colors, n_comp, n_comp, n_cells).cpu().numpy() # (color, j, i, cell)
|
|
125
|
+
rows, cols, vals = [], [], []
|
|
126
|
+
comp = np.arange(n_comp)[:, None]
|
|
127
|
+
for k in range(n_colors): # colors, not cells
|
|
128
|
+
have = src[k] >= 0
|
|
129
|
+
p = np.nonzero(have)[0]
|
|
130
|
+
q = src[k][have]
|
|
131
|
+
for j in range(n_comp):
|
|
132
|
+
d = df[k, j][:, p] # (n_comp, len(p))
|
|
133
|
+
rows.append((comp * n_cells + p[None, :]).ravel())
|
|
134
|
+
cols.append(np.broadcast_to(j * n_cells + q[None, :], d.shape).ravel())
|
|
135
|
+
vals.append(d.ravel())
|
|
136
|
+
n = n_comp * n_cells
|
|
137
|
+
jac = sp.coo_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
|
|
138
|
+
jac = jac.tocsr()
|
|
139
|
+
jac.eliminate_zeros()
|
|
140
|
+
return jac
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _active_components(jac: sp.csr_matrix, n_comp: int, n_cells: int) -> np.ndarray:
|
|
144
|
+
"""Flat indices of components that change in time (nonzero Jacobian rows)."""
|
|
145
|
+
row_nnz = np.diff(jac.indptr).reshape(n_comp, n_cells).sum(axis=1)
|
|
146
|
+
comps = np.nonzero(row_nnz > 0)[0]
|
|
147
|
+
return (comps[:, None] * n_cells + np.arange(n_cells)[None, :]).ravel()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------- steady states
|
|
151
|
+
@dataclass
|
|
152
|
+
class SteadyState:
|
|
153
|
+
"""Result of :func:`find_steady_state` (one entry per batch element)."""
|
|
154
|
+
|
|
155
|
+
state: IonicState
|
|
156
|
+
vmem: torch.Tensor
|
|
157
|
+
stable: list[bool | None]
|
|
158
|
+
max_real_eig: list[float | None]
|
|
159
|
+
slowest_time_s: list[float | None]
|
|
160
|
+
n_iter: list[int]
|
|
161
|
+
residual: list[float]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _solve_one(
|
|
165
|
+
tissue: IonicTissue,
|
|
166
|
+
x0: torch.Tensor,
|
|
167
|
+
mods: Modifiers,
|
|
168
|
+
h0: float,
|
|
169
|
+
growth: float,
|
|
170
|
+
dv_max: float,
|
|
171
|
+
max_iter: int,
|
|
172
|
+
conc_tol: float,
|
|
173
|
+
gate_tol: float,
|
|
174
|
+
dc_rel_max: float = 0.25,
|
|
175
|
+
):
|
|
176
|
+
"""Pseudo-transient continuation for one batch element. Returns ``(x, jac, active, iters)``."""
|
|
177
|
+
n_comp, cells = x0.shape[1], tuple(x0.shape[2:])
|
|
178
|
+
n_cells = int(np.prod(cells))
|
|
179
|
+
tol = np.full(n_comp, gate_tol)
|
|
180
|
+
tol[: tissue.n_ions] = conc_tol
|
|
181
|
+
tol = np.repeat(tol, n_cells)
|
|
182
|
+
x = x0.detach().clone()
|
|
183
|
+
h = h0
|
|
184
|
+
v_old = tissue.vmem(x[:, : tissue.n_ions])
|
|
185
|
+
for it in range(1, max_iter + 1):
|
|
186
|
+
with torch.no_grad():
|
|
187
|
+
f = tissue.rhs(x, mods).reshape(-1).cpu().numpy()
|
|
188
|
+
jac = sparse_jacobian(tissue, x, mods)
|
|
189
|
+
act = _active_components(jac, n_comp, n_cells)
|
|
190
|
+
j_aa = jac[act][:, act]
|
|
191
|
+
m = sp.identity(act.size, format="csc") / h - j_aa.tocsc()
|
|
192
|
+
try:
|
|
193
|
+
dx_a = spla.splu(m).solve(f[act])
|
|
194
|
+
except RuntimeError: # singular for this h
|
|
195
|
+
h /= 4.0
|
|
196
|
+
continue
|
|
197
|
+
dx = np.zeros(x.numel())
|
|
198
|
+
dx[act] = dx_a
|
|
199
|
+
x_new = x + torch.as_tensor(dx, dtype=x.dtype).reshape(x.shape)
|
|
200
|
+
v_new = tissue.vmem(x_new[:, : tissue.n_ions])
|
|
201
|
+
c_old, c_new = x[:, : tissue.n_ions], x_new[:, : tissue.n_ions]
|
|
202
|
+
gates = x_new[:, tissue.n_ions :]
|
|
203
|
+
ok = (
|
|
204
|
+
bool(torch.isfinite(x_new).all())
|
|
205
|
+
and float((v_new - v_old).abs().max()) <= dv_max
|
|
206
|
+
and bool((c_new >= 0).all()) # concentrations stay physical
|
|
207
|
+
and bool(((c_new - c_old).abs() <= dc_rel_max * c_old + 1e-9).all())
|
|
208
|
+
and bool(((gates >= -1e-3) & (gates <= 1 + 1e-3)).all())
|
|
209
|
+
)
|
|
210
|
+
if not ok:
|
|
211
|
+
h /= 4.0
|
|
212
|
+
if h < 1e-12:
|
|
213
|
+
raise FloatingPointError("steady-state search failed: step size underflow")
|
|
214
|
+
continue
|
|
215
|
+
x, v_old = x_new, v_new
|
|
216
|
+
if h >= 1e8 and np.all(np.abs(dx) <= tol):
|
|
217
|
+
return x, sparse_jacobian(tissue, x, mods), act, it
|
|
218
|
+
h *= growth
|
|
219
|
+
raise RuntimeError(f"steady-state search did not converge in {max_iter} iterations")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _stability(jac: sp.csr_matrix, act: np.ndarray, dense_limit: int):
|
|
223
|
+
"""``(max Re(eig), slowest time constant [s])`` of the active Jacobian, or ``(None, None)``."""
|
|
224
|
+
if act.size > dense_limit:
|
|
225
|
+
return None, None
|
|
226
|
+
ev = np.linalg.eigvals(jac[act][:, act].toarray()).real
|
|
227
|
+
neg = ev[ev < 0]
|
|
228
|
+
slow = float(1.0 / np.abs(neg).min()) if neg.size else None
|
|
229
|
+
return float(ev.max()), slow
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_steady_state(
|
|
233
|
+
tissue: IonicTissue,
|
|
234
|
+
state: IonicState,
|
|
235
|
+
modifiers: Modifiers | None = None,
|
|
236
|
+
*,
|
|
237
|
+
h0: float = 1e-3,
|
|
238
|
+
growth: float = 3.0,
|
|
239
|
+
dv_max: float = 0.01,
|
|
240
|
+
max_iter: int = 300,
|
|
241
|
+
conc_tol: float = 1e-10,
|
|
242
|
+
gate_tol: float = 1e-8,
|
|
243
|
+
stability: bool = True,
|
|
244
|
+
dense_limit: int = 3000,
|
|
245
|
+
) -> SteadyState:
|
|
246
|
+
"""Steady state reached from ``state`` under constant ``modifiers``.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
h0, growth: first pseudo-time step [s] and its growth factor per accepted step.
|
|
251
|
+
dv_max: largest V_mem change [V] in any cell allowed in one step. Small values
|
|
252
|
+
keep the iteration in the basin of the start state. A step is also rejected
|
|
253
|
+
when a concentration becomes negative or changes by more than 25 %, or a gate
|
|
254
|
+
leaves [0, 1].
|
|
255
|
+
conc_tol, gate_tol: convergence limits for the last Newton step
|
|
256
|
+
(``1e-10`` mol/m^3 is about 2 uV of V_mem).
|
|
257
|
+
stability: compute the eigenvalues of ``J`` (dense, up to ``dense_limit`` unknowns).
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
:class:`SteadyState`. ``stable`` is ``True`` when all eigenvalues of ``J`` have
|
|
262
|
+
negative real parts. ``slowest_time_s`` is ``1 / min |Re(eig)|``: the time scale of
|
|
263
|
+
the slowest approach (often the slow exchange of ions with the bath).
|
|
264
|
+
"""
|
|
265
|
+
mods = modifiers or NO_MODIFIERS
|
|
266
|
+
xs, stable, mre, slow, iters, res = [], [], [], [], [], []
|
|
267
|
+
for b in range(state.x.shape[0]): # batch elements, not cells
|
|
268
|
+
x, jac, act, it = _solve_one(tissue, state.x[b : b + 1], mods, h0, growth, dv_max, max_iter,
|
|
269
|
+
conc_tol, gate_tol)
|
|
270
|
+
xs.append(x)
|
|
271
|
+
iters.append(it)
|
|
272
|
+
with torch.no_grad():
|
|
273
|
+
res.append(float(tissue.rhs(x, mods).abs().max()))
|
|
274
|
+
if stability:
|
|
275
|
+
e_max, t_slow = _stability(jac, act, dense_limit)
|
|
276
|
+
stable.append(None if e_max is None else e_max < 0.0)
|
|
277
|
+
mre.append(e_max)
|
|
278
|
+
slow.append(t_slow)
|
|
279
|
+
else:
|
|
280
|
+
stable.append(None)
|
|
281
|
+
mre.append(None)
|
|
282
|
+
slow.append(None)
|
|
283
|
+
out = IonicState(torch.cat(xs), float("inf"))
|
|
284
|
+
return SteadyState(out, tissue.vmem_of(out), stable, mre, slow, iters, res)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ----------------------------------------------------------- implicit gradient
|
|
288
|
+
class _ImplicitSteadyState(torch.autograd.Function):
|
|
289
|
+
@staticmethod
|
|
290
|
+
def forward(ctx, closure, *params):
|
|
291
|
+
ctx.closure = closure
|
|
292
|
+
return closure["x"].clone()
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def backward(ctx, grad_x):
|
|
296
|
+
c = ctx.closure
|
|
297
|
+
g = grad_x.detach().reshape(-1).cpu().numpy()
|
|
298
|
+
act = c["active"]
|
|
299
|
+
lam = np.zeros_like(g)
|
|
300
|
+
lam[act] = c["lu"].solve(g[act], trans="T")
|
|
301
|
+
lam_t = torch.as_tensor(lam, dtype=grad_x.dtype).reshape(grad_x.shape)
|
|
302
|
+
with torch.enable_grad():
|
|
303
|
+
fx = c["tissue"].rhs(c["x"], c["modifiers_fn"]())
|
|
304
|
+
grads = torch.autograd.grad(fx, c["params"], grad_outputs=-lam_t, allow_unused=True)
|
|
305
|
+
return (None, *grads)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def steady_state_implicit(
|
|
309
|
+
tissue: IonicTissue,
|
|
310
|
+
state: IonicState,
|
|
311
|
+
modifiers_fn: Callable[[], Modifiers] | None,
|
|
312
|
+
params: Sequence[torch.Tensor],
|
|
313
|
+
**kwargs,
|
|
314
|
+
) -> IonicState:
|
|
315
|
+
"""Steady state from ``state`` that is differentiable with respect to ``params``.
|
|
316
|
+
|
|
317
|
+
``modifiers_fn()`` must build the modifiers from ``params`` (it is called again in
|
|
318
|
+
the backward pass, so the autograd graph to ``params`` is rebuilt there). Tissue
|
|
319
|
+
parameters that ``tissue.rhs`` reads directly (for example a ``max_d`` tensor
|
|
320
|
+
with ``requires_grad=True``) can also be listed in ``params``. Batch size 1.
|
|
321
|
+
|
|
322
|
+
The gradient follows the implicit function theorem at ``f(x*, theta) = 0``; it is
|
|
323
|
+
the derivative of the attractor that the start state relaxes to. It does not
|
|
324
|
+
include the jump when a parameter change moves the start state into another basin.
|
|
325
|
+
"""
|
|
326
|
+
if state.x.shape[0] != 1:
|
|
327
|
+
raise ValueError("steady_state_implicit needs batch size 1")
|
|
328
|
+
modifiers_fn = modifiers_fn or (lambda: NO_MODIFIERS)
|
|
329
|
+
with torch.no_grad():
|
|
330
|
+
mods = modifiers_fn()
|
|
331
|
+
x, jac, act, _ = _solve_one(
|
|
332
|
+
tissue, state.x, _detach(mods), kwargs.get("h0", 1e-3), kwargs.get("growth", 3.0),
|
|
333
|
+
kwargs.get("dv_max", 0.01), kwargs.get("max_iter", 300), kwargs.get("conc_tol", 1e-10),
|
|
334
|
+
kwargs.get("gate_tol", 1e-8),
|
|
335
|
+
)
|
|
336
|
+
lu = spla.splu(jac[act][:, act].tocsc())
|
|
337
|
+
closure = {"x": x.detach(), "active": act, "lu": lu, "tissue": tissue,
|
|
338
|
+
"modifiers_fn": modifiers_fn, "params": list(params)}
|
|
339
|
+
x_star = _ImplicitSteadyState.apply(closure, *params)
|
|
340
|
+
return IonicState(x_star, float("inf"))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _detach(m: Modifiers) -> Modifiers:
|
|
344
|
+
d = lambda v: v.detach() if isinstance(v, torch.Tensor) else v
|
|
345
|
+
return Modifiers({k: d(v) for k, v in m.channel_scale.items()}, {k: d(v) for k, v in m.channel_add.items()},
|
|
346
|
+
{k: d(v) for k, v in m.channel_gate.items()}, d(m.nak_scale), d(m.ca_pump_scale),
|
|
347
|
+
d(m.gj_scale))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------ landscape
|
|
351
|
+
@dataclass
|
|
352
|
+
class Attractor:
|
|
353
|
+
"""One distinct stable pattern found by :func:`enumerate_attractors`."""
|
|
354
|
+
|
|
355
|
+
vmem: torch.Tensor # (*cells) [V]
|
|
356
|
+
state: IonicState
|
|
357
|
+
count: int # number of start states that reached it
|
|
358
|
+
stable: bool | None
|
|
359
|
+
slowest_time_s: float | None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def random_vmem_states(
|
|
363
|
+
tissue: IonicTissue,
|
|
364
|
+
cells: tuple[int, ...],
|
|
365
|
+
n: int,
|
|
366
|
+
v_range: tuple[float, float] = (-0.09, 0.0),
|
|
367
|
+
seed: int = 0,
|
|
368
|
+
) -> IonicState:
|
|
369
|
+
"""``n`` start states with independent uniform random V_mem per cell [V]."""
|
|
370
|
+
g = torch.Generator().manual_seed(seed)
|
|
371
|
+
v = v_range[0] + (v_range[1] - v_range[0]) * torch.rand((n, *cells), generator=g, dtype=DTYPE)
|
|
372
|
+
return tissue.with_vmem(tissue.initial_state(cells, batch=n), v)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def enumerate_attractors(
|
|
376
|
+
tissue: IonicTissue,
|
|
377
|
+
starts: IonicState,
|
|
378
|
+
modifiers: Modifiers | None = None,
|
|
379
|
+
v_tol: float = 1e-3,
|
|
380
|
+
**kwargs,
|
|
381
|
+
) -> list[Attractor]:
|
|
382
|
+
"""Relax every start state and group the steady states whose V_mem differ by < ``v_tol``.
|
|
383
|
+
|
|
384
|
+
Returns the attractors sorted by ``count`` (largest basin sample first).
|
|
385
|
+
"""
|
|
386
|
+
ss = find_steady_state(tissue, starts, modifiers, **kwargs)
|
|
387
|
+
found: list[Attractor] = []
|
|
388
|
+
for b in range(starts.x.shape[0]):
|
|
389
|
+
v = ss.vmem[b]
|
|
390
|
+
for a in found:
|
|
391
|
+
if float((a.vmem - v).abs().max()) < v_tol:
|
|
392
|
+
a.count += 1
|
|
393
|
+
break
|
|
394
|
+
else:
|
|
395
|
+
found.append(Attractor(v, IonicState(ss.state.x[b : b + 1], float("inf")), 1, ss.stable[b],
|
|
396
|
+
ss.slowest_time_s[b]))
|
|
397
|
+
return sorted(found, key=lambda a: -a.count)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def dose_response(
|
|
401
|
+
tissue: IonicTissue,
|
|
402
|
+
state: IonicState,
|
|
403
|
+
modifiers_at: Callable[[float], Modifiers],
|
|
404
|
+
values: Sequence[float],
|
|
405
|
+
**kwargs,
|
|
406
|
+
) -> tuple[torch.Tensor, list[bool | None]]:
|
|
407
|
+
"""Follow the attractor of ``state`` while a parameter takes ``values`` in order.
|
|
408
|
+
|
|
409
|
+
Natural continuation: each solve starts from the previous steady state, as in a
|
|
410
|
+
slow dose ramp. Run it with increasing and with decreasing ``values`` to see
|
|
411
|
+
hysteresis (a pattern memory). Returns ``(V* of shape (len(values), *cells), stable)``.
|
|
412
|
+
"""
|
|
413
|
+
vs, stable = [], []
|
|
414
|
+
cur = state
|
|
415
|
+
for val in values: # parameter values, not cells
|
|
416
|
+
ss = find_steady_state(tissue, cur, modifiers_at(float(val)), **kwargs)
|
|
417
|
+
cur = ss.state
|
|
418
|
+
vs.append(ss.vmem[0])
|
|
419
|
+
stable.append(ss.stable[0])
|
|
420
|
+
return torch.stack(vs), stable
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def basin_map(
|
|
424
|
+
tissue: IonicTissue,
|
|
425
|
+
cells: tuple[int, ...],
|
|
426
|
+
region_a: torch.Tensor,
|
|
427
|
+
region_b: torch.Tensor,
|
|
428
|
+
v_values: Sequence[float],
|
|
429
|
+
modifiers: Modifiers | None = None,
|
|
430
|
+
v_tol: float = 1e-3,
|
|
431
|
+
**kwargs,
|
|
432
|
+
) -> tuple[torch.Tensor, list[Attractor]]:
|
|
433
|
+
"""Attractor label for each pair of start voltages of two regions.
|
|
434
|
+
|
|
435
|
+
Start state ``(i, j)``: cells in ``region_a`` at ``v_values[i]``, cells in
|
|
436
|
+
``region_b`` at ``v_values[j]`` (boolean masks of shape ``cells``; other cells
|
|
437
|
+
at the mean of the two). Returns ``(labels (len, len), attractors)``.
|
|
438
|
+
"""
|
|
439
|
+
vv = torch.as_tensor(list(v_values), dtype=DTYPE)
|
|
440
|
+
n = vv.numel()
|
|
441
|
+
va = vv.reshape(n, 1).expand(n, n).reshape(-1)
|
|
442
|
+
vb = vv.reshape(1, n).expand(n, n).reshape(-1)
|
|
443
|
+
shape = (n * n,) + (1,) * len(cells)
|
|
444
|
+
v0 = 0.5 * (va + vb).reshape(shape).expand(n * n, *cells).clone()
|
|
445
|
+
a, b = region_a.bool(), region_b.bool()
|
|
446
|
+
v0 = torch.where(a, va.reshape(shape), v0)
|
|
447
|
+
v0 = torch.where(b, vb.reshape(shape), v0)
|
|
448
|
+
starts = tissue.with_vmem(tissue.initial_state(cells, batch=n * n), v0)
|
|
449
|
+
ss = find_steady_state(tissue, starts, modifiers, **kwargs)
|
|
450
|
+
found: list[Attractor] = []
|
|
451
|
+
labels = torch.empty(n * n, dtype=torch.long)
|
|
452
|
+
for k in range(n * n):
|
|
453
|
+
v = ss.vmem[k]
|
|
454
|
+
for idx, att in enumerate(found):
|
|
455
|
+
if float((att.vmem - v).abs().max()) < v_tol:
|
|
456
|
+
att.count += 1
|
|
457
|
+
labels[k] = idx
|
|
458
|
+
break
|
|
459
|
+
else:
|
|
460
|
+
labels[k] = len(found)
|
|
461
|
+
found.append(Attractor(v, IonicState(ss.state.x[k : k + 1], float("inf")), 1, ss.stable[k],
|
|
462
|
+
ss.slowest_time_s[k]))
|
|
463
|
+
return labels.reshape(n, n), found
|
bioelectrics/channels.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Ion channel "hardware" and membrane currents of the Cervera-Alcaraz-Mafe model.
|
|
2
|
+
|
|
3
|
+
The genome specifies the hardware (channel expression levels and gating). This
|
|
4
|
+
module holds that hardware in :class:`ChannelProfile` and computes the membrane
|
|
5
|
+
currents it produces for a given ``V_mem`` (the software state).
|
|
6
|
+
|
|
7
|
+
Sign convention: currents are outward positive. ``C_m dV/dt = -I_ion + I_ext``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, fields
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import torch
|
|
16
|
+
from scipy.optimize import brentq
|
|
17
|
+
|
|
18
|
+
from .constants import DTYPE, F, R, T, V_T
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_tensor(value, dtype=DTYPE, device=None) -> torch.Tensor:
|
|
22
|
+
if isinstance(value, torch.Tensor):
|
|
23
|
+
return value
|
|
24
|
+
return torch.tensor(value, dtype=dtype, device=device)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ChannelProfile:
|
|
29
|
+
"""Fixed channel "hardware" of a cell type (SI units).
|
|
30
|
+
|
|
31
|
+
Every field is a tensor. A field can be a scalar or broadcastable to the
|
|
32
|
+
``V_mem`` tensor, so different cells can express different hardware. Any
|
|
33
|
+
field can have ``requires_grad=True``.
|
|
34
|
+
|
|
35
|
+
Defaults are calibrated so that a single cell is bistable with stable fixed
|
|
36
|
+
points at -70 mV (polarized) and -20 mV (depolarized) and a saddle at -45 mV.
|
|
37
|
+
See ``MATH_REFERENCE.md``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
g_pol0: torch.Tensor = 1.0e-9 # [S]
|
|
41
|
+
g_dep0: torch.Tensor = 1.76452e-9 # [S]
|
|
42
|
+
z: torch.Tensor = 2.11341 # gating charge [-]
|
|
43
|
+
v_th_pol: torch.Tensor = -0.02096 # [V]
|
|
44
|
+
v_th_dep: torch.Tensor = -0.03900 # [V]
|
|
45
|
+
e_pol: torch.Tensor = -0.080 # [V]
|
|
46
|
+
e_dep: torch.Tensor = 0.0 # [V]
|
|
47
|
+
g_leak: torch.Tensor = 0.0 # [S]
|
|
48
|
+
e_leak: torch.Tensor = 0.0 # [V]
|
|
49
|
+
c_m: torch.Tensor = 1.0e-11 # [F]
|
|
50
|
+
v_t: torch.Tensor = V_T # [V]
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
for f in fields(self):
|
|
54
|
+
setattr(self, f.name, _as_tensor(getattr(self, f.name)))
|
|
55
|
+
|
|
56
|
+
def to(self, dtype=None, device=None) -> "ChannelProfile":
|
|
57
|
+
"""Return a copy with all tensors moved to ``dtype`` / ``device``."""
|
|
58
|
+
return ChannelProfile(
|
|
59
|
+
**{f.name: getattr(self, f.name).to(dtype=dtype, device=device) for f in fields(self)}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def requires_grad_(self, *names: str) -> "ChannelProfile":
|
|
63
|
+
"""Enable gradients on the named fields (default: the conductances)."""
|
|
64
|
+
for name in names or ("g_pol0", "g_dep0", "g_leak"):
|
|
65
|
+
setattr(self, name, getattr(self, name).detach().clone().requires_grad_(True))
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def g_pol(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
70
|
+
"""Conductance of the hyperpolarizing channel [S]. Closes on depolarization."""
|
|
71
|
+
return p.g_pol0 * torch.sigmoid(-p.z * (v - p.v_th_pol) / p.v_t)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def g_dep(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
75
|
+
"""Conductance of the depolarizing channel [S]. Opens on depolarization."""
|
|
76
|
+
return p.g_dep0 * torch.sigmoid(p.z * (v - p.v_th_dep) / p.v_t)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def i_pol(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
80
|
+
"""Outward hyperpolarizing (potassium-like) current [A].
|
|
81
|
+
|
|
82
|
+
``I_pol = G_pol0 (V - E_pol) / (1 + exp(z (V - V_th,pol) / V_T))``
|
|
83
|
+
"""
|
|
84
|
+
return g_pol(v, p) * (v - p.e_pol)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def i_dep(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
88
|
+
"""Inward depolarizing (sodium/calcium-like) current [A].
|
|
89
|
+
|
|
90
|
+
``I_dep = G_dep0 (V - E_dep) / (1 + exp(-z (V - V_th,dep) / V_T))``
|
|
91
|
+
"""
|
|
92
|
+
return g_dep(v, p) * (v - p.e_dep)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def i_leak(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
96
|
+
"""Ohmic leak current [A]: ``G_leak (V - E_leak)``."""
|
|
97
|
+
return p.g_leak * (v - p.e_leak)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ionic_current(v: torch.Tensor, p: ChannelProfile) -> torch.Tensor:
|
|
101
|
+
"""Total outward ionic current ``I_pol + I_dep + I_leak`` [A]."""
|
|
102
|
+
return i_pol(v, p) + i_dep(v, p) + i_leak(v, p)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def membrane_rhs(v: torch.Tensor, p: ChannelProfile, i_ext=0.0) -> torch.Tensor:
|
|
106
|
+
"""Single-cell ``dV/dt = (-I_ion(V) + I_ext) / C_m`` [V/s]."""
|
|
107
|
+
return (i_ext - ionic_current(v, p)) / p.c_m
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ghk_current(
|
|
111
|
+
v: torch.Tensor,
|
|
112
|
+
c_in: torch.Tensor,
|
|
113
|
+
c_out: torch.Tensor,
|
|
114
|
+
permeability: torch.Tensor,
|
|
115
|
+
valence: float,
|
|
116
|
+
temperature: float = T,
|
|
117
|
+
) -> torch.Tensor:
|
|
118
|
+
"""Goldman-Hodgkin-Katz current density for one ion species [A/m^2], outward positive.
|
|
119
|
+
|
|
120
|
+
``I = P z^2 F^2 V / (R T) * (c_in - c_out exp(-a)) / (1 - exp(-a))`` with
|
|
121
|
+
``a = z F V / (R T)``. Concentrations in mol/m^3, permeability in m/s.
|
|
122
|
+
Same form as ``electroflux`` in BETSE ``sim_toolbox.py``. The limit ``V -> 0``
|
|
123
|
+
is handled without division by zero.
|
|
124
|
+
"""
|
|
125
|
+
a = valence * F * v / (R * temperature)
|
|
126
|
+
small = a.abs() < 1e-6
|
|
127
|
+
a_safe = torch.where(small, torch.ones_like(a), a)
|
|
128
|
+
# a / (1 - exp(-a)) -> 1 + a/2 as a -> 0
|
|
129
|
+
factor = torch.where(small, 1.0 + a / 2.0, a_safe / (-torch.expm1(-a_safe)))
|
|
130
|
+
return permeability * valence * F * factor * (c_in - c_out * torch.exp(-a))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_fixed_points(
|
|
134
|
+
p: ChannelProfile, v_min: float = -0.1, v_max: float = 0.02, n: int = 4001
|
|
135
|
+
) -> list[tuple[float, bool]]:
|
|
136
|
+
"""Return the single-cell fixed points ``[(V*, is_stable), ...]`` in ascending V.
|
|
137
|
+
|
|
138
|
+
A fixed point is a root of ``I_ion(V)``. It is stable when ``dI_ion/dV > 0``.
|
|
139
|
+
Uses a grid scan and Brent refinement. Works on scalar profiles.
|
|
140
|
+
"""
|
|
141
|
+
p0 = p.to(dtype=DTYPE)
|
|
142
|
+
|
|
143
|
+
def f(x: float) -> float:
|
|
144
|
+
with torch.no_grad():
|
|
145
|
+
return float(ionic_current(torch.tensor(x, dtype=DTYPE), p0))
|
|
146
|
+
|
|
147
|
+
grid = np.linspace(v_min, v_max, n)
|
|
148
|
+
with torch.no_grad():
|
|
149
|
+
vals = ionic_current(torch.tensor(grid, dtype=DTYPE), p0).numpy()
|
|
150
|
+
out = []
|
|
151
|
+
for k in np.nonzero(np.sign(vals[:-1]) != np.sign(vals[1:]))[0]:
|
|
152
|
+
root = brentq(f, grid[k], grid[k + 1], xtol=1e-12)
|
|
153
|
+
out.append((root, bool(vals[k + 1] > vals[k])))
|
|
154
|
+
return out
|