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