scalcs 1.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.
scalcs/cjumps.py ADDED
@@ -0,0 +1,835 @@
1
+ """Macroscopic responses to concentration jumps.
2
+
3
+ This module calculates the time course of open probability and state
4
+ occupancies when the agonist (or any effector) concentration is changed as
5
+ a function of time. The calculation uses the Q-matrix formalism of
6
+ Colquhoun & Hawkes (1977, 1981) and is the basis for simulating
7
+ concentration-clamp or fast-perfusion experiments.
8
+
9
+ Public API
10
+ ----------
11
+ Pulse dataclasses (one per concentration profile shape)
12
+ ErfPulse Realistic jump: error-function rise and fall. This is the
13
+ profile used in fast-perfusion ('rcj') experiments.
14
+ SquarePulse Ideal square step.
15
+ InstExpPulse Instantaneous rise, exponential decay.
16
+ PairedSquarePulse Two square pulses separated by a gap.
17
+
18
+ Result dataclasses
19
+ JumpResult Time-course output of solve(). Supports 4-tuple unpacking
20
+ ``t, c, Popen, P = result`` for backward compatibility.
21
+ RelaxationResult Analytical on/off time constants and amplitudes returned by
22
+ relaxation_taus().
23
+
24
+ Functions
25
+ solve(mec, pulse, reclen, step, method='ode')
26
+ Compute the macroscopic open-probability time course.
27
+
28
+ relaxation_taus(mec, pulse)
29
+ Analytical on/off relaxation time constants for a SquarePulse.
30
+
31
+ jump_summary(mec, pulse, gamma=30e-12, Vm=-80e-3)
32
+ All analytical jump properties as a plain dict — no formatting.
33
+
34
+ printout(mec, pulse, gamma=30e-12, Vm=-80e-3)
35
+ Human-readable report built from jump_summary().
36
+
37
+ Private helpers (prefixed ``_``)
38
+ _dPdt, _P_t, _coefficient_calc, _solve_ode, _solve_matrix
39
+
40
+ Notes
41
+ -----
42
+ ``relaxation_taus``, ``jump_summary`` and ``printout`` require a
43
+ ``SquarePulse`` because the analytical expressions for on/off relaxation are
44
+ derived for ideal concentration steps. Pass an ``ErfPulse`` and a
45
+ ``TypeError`` is raised with a clear message.
46
+
47
+ References
48
+ ----------
49
+ Colquhoun D & Hawkes AG (1977) Relaxation and fluctuations of membrane
50
+ currents that flow through drug-operated channels.
51
+ Proc R Soc Lond B 199, 231-262.
52
+
53
+ Colquhoun D & Hawkes AG (1981) On the stochastic properties of single ion
54
+ channels. Proc R Soc Lond B 211, 205-235.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import math
60
+ from dataclasses import dataclass, field
61
+ from typing import Union
62
+
63
+ import numpy as np
64
+ import scipy.integrate as scpi
65
+ from scipy.special import erf
66
+
67
+ from scalcs import qmatlib as qml
68
+
69
+
70
+ # ===========================================================================
71
+ # Pulse profile dataclasses
72
+ # ===========================================================================
73
+
74
+ @dataclass
75
+ class ErfPulse:
76
+ """Realistic concentration jump with error-function rise and fall.
77
+
78
+ This profile matches the waveform produced by fast perfusion ('rcj')
79
+ experiments in which the solution exchange is not instantaneous. The
80
+ concentration is::
81
+
82
+ c(t) = cb + cmax/2 * [erf((t - centre + width/2) / rise)
83
+ - erf((t - centre - width/2) / decay)]
84
+
85
+ Parameters
86
+ ----------
87
+ cmax : float
88
+ Peak concentration (M). Must be > 0.
89
+ width : float
90
+ Pulse half-width (s). Must be > 0.
91
+ cb : float
92
+ Background (baseline) concentration (M). Default 0.
93
+ centre : float
94
+ Time of the pulse centre (s). Default 10 ms.
95
+ rise : float
96
+ 10–90 % rise time constant for the error function (s). Default 200 µs.
97
+ decay : float
98
+ 90–10 % decay time constant for the error function (s). Default 200 µs.
99
+ """
100
+
101
+ cmax: float
102
+ width: float
103
+ cb: float = 0.0
104
+ centre: float = 10e-3
105
+ rise: float = 200e-6
106
+ decay: float = 200e-6
107
+
108
+ def __post_init__(self):
109
+ if self.cmax <= 0:
110
+ raise ValueError(f"ErfPulse: cmax must be > 0, got {self.cmax}")
111
+ if self.width <= 0:
112
+ raise ValueError(f"ErfPulse: width must be > 0, got {self.width}")
113
+ if self.rise <= 0:
114
+ raise ValueError(f"ErfPulse: rise must be > 0, got {self.rise}")
115
+ if self.decay <= 0:
116
+ raise ValueError(f"ErfPulse: decay must be > 0, got {self.decay}")
117
+
118
+ def profile(self, t: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
119
+ """Concentration at time(s) *t* (M).
120
+
121
+ Parameters
122
+ ----------
123
+ t : float or ndarray
124
+ Time in seconds.
125
+
126
+ Returns
127
+ -------
128
+ c : float or ndarray
129
+ Concentration in M. Same shape as *t*.
130
+ """
131
+ conc = (self.cmax * 0.5 *
132
+ (erf((t - self.centre + self.width / 2.) / self.rise) -
133
+ erf((t - self.centre - self.width / 2.) / self.decay)))
134
+ return conc + self.cb
135
+
136
+
137
+ @dataclass
138
+ class SquarePulse:
139
+ """Ideal square concentration step.
140
+
141
+ The concentration profile is::
142
+
143
+ c(t) = cmax if prepulse < t <= prepulse + width
144
+ cb otherwise
145
+
146
+ Parameters
147
+ ----------
148
+ cmax : float
149
+ Pulse concentration (M). Must be > 0.
150
+ width : float
151
+ Pulse duration (s). Must be > 0.
152
+ cb : float
153
+ Background concentration (M). Default 0.
154
+ prepulse : float
155
+ Time before the pulse starts (s). Default 5 ms.
156
+ """
157
+
158
+ cmax: float
159
+ width: float
160
+ cb: float = 0.0
161
+ prepulse: float = 5e-3
162
+
163
+ def __post_init__(self):
164
+ if self.cmax <= 0:
165
+ raise ValueError(f"SquarePulse: cmax must be > 0, got {self.cmax}")
166
+ if self.width <= 0:
167
+ raise ValueError(f"SquarePulse: width must be > 0, got {self.width}")
168
+
169
+ def profile(self, t: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
170
+ """Concentration at time(s) *t* (M)."""
171
+ if np.isscalar(t):
172
+ conc = (self.cmax
173
+ if self.prepulse < t <= self.prepulse + self.width
174
+ else 0.0)
175
+ else:
176
+ t = np.asarray(t)
177
+ conc = np.where(
178
+ (t > self.prepulse) & (t <= self.prepulse + self.width),
179
+ self.cmax, 0.0)
180
+ return conc + self.cb
181
+
182
+
183
+ @dataclass
184
+ class InstExpPulse:
185
+ """Concentration pulse with instantaneous rise and exponential decay.
186
+
187
+ The profile is::
188
+
189
+ c(t) = cb if t <= prepulse
190
+ cb + cmax * exp(-(t-prepulse) / tdec) if t > prepulse
191
+
192
+ Parameters
193
+ ----------
194
+ cmax : float
195
+ Peak concentration immediately after the step (M). Must be > 0.
196
+ tdec : float
197
+ Exponential decay time constant (s). Must be > 0.
198
+ cb : float
199
+ Background concentration (M). Default 0.
200
+ prepulse : float
201
+ Time before the step (s). Default 5 ms.
202
+ """
203
+
204
+ cmax: float
205
+ tdec: float
206
+ cb: float = 0.0
207
+ prepulse: float = 5e-3
208
+
209
+ def __post_init__(self):
210
+ if self.cmax <= 0:
211
+ raise ValueError(f"InstExpPulse: cmax must be > 0, got {self.cmax}")
212
+ if self.tdec <= 0:
213
+ raise ValueError(f"InstExpPulse: tdec must be > 0, got {self.tdec}")
214
+
215
+ def profile(self, t: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
216
+ """Concentration at time(s) *t* (M)."""
217
+ if np.isscalar(t):
218
+ conc = (self.cmax * math.exp(-(t - self.prepulse) / self.tdec)
219
+ if t > self.prepulse else 0.0)
220
+ else:
221
+ t = np.asarray(t, dtype=float)
222
+ conc = np.where(
223
+ t > self.prepulse,
224
+ self.cmax * np.exp(-(t - self.prepulse) / self.tdec),
225
+ 0.0)
226
+ return conc + self.cb
227
+
228
+
229
+ @dataclass
230
+ class PairedSquarePulse:
231
+ """Two square concentration pulses separated by a gap.
232
+
233
+ The profile is::
234
+
235
+ c(t) = cmax if prepulse < t <= prepulse + width
236
+ cmax if prepulse + width + inter < t
237
+ <= prepulse + 2*width + inter
238
+ cb otherwise
239
+
240
+ Parameters
241
+ ----------
242
+ cmax : float
243
+ Pulse concentration (M). Must be > 0.
244
+ width : float
245
+ Duration of each pulse (s). Must be > 0.
246
+ inter : float
247
+ Gap between the two pulses (s). Must be >= 0.
248
+ cb : float
249
+ Background concentration (M). Default 0.
250
+ prepulse : float
251
+ Time before the first pulse (s). Default 5 ms.
252
+ """
253
+
254
+ cmax: float
255
+ width: float
256
+ inter: float
257
+ cb: float = 0.0
258
+ prepulse: float = 5e-3
259
+
260
+ def __post_init__(self):
261
+ if self.cmax <= 0:
262
+ raise ValueError(f"PairedSquarePulse: cmax must be > 0, got {self.cmax}")
263
+ if self.width <= 0:
264
+ raise ValueError(f"PairedSquarePulse: width must be > 0, got {self.width}")
265
+ if self.inter < 0:
266
+ raise ValueError(f"PairedSquarePulse: inter must be >= 0, got {self.inter}")
267
+
268
+ def profile(self, t: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
269
+ """Concentration at time(s) *t* (M)."""
270
+ p, w, g = self.prepulse, self.width, self.inter
271
+ if np.isscalar(t):
272
+ in_p1 = p < t <= p + w
273
+ in_p2 = p + w + g < t <= p + 2 * w + g
274
+ conc = self.cmax if (in_p1 or in_p2) else 0.0
275
+ else:
276
+ t = np.asarray(t, dtype=float)
277
+ in_p1 = (t > p) & (t <= p + w)
278
+ in_p2 = (t > p + w + g) & (t <= p + 2 * w + g)
279
+ conc = np.where(in_p1 | in_p2, self.cmax, 0.0)
280
+ return conc + self.cb
281
+
282
+
283
+ # Convenience type alias used in type hints below
284
+ AnyPulse = Union[ErfPulse, SquarePulse, InstExpPulse, PairedSquarePulse]
285
+
286
+
287
+ # ===========================================================================
288
+ # Result dataclasses
289
+ # ===========================================================================
290
+
291
+ @dataclass
292
+ class JumpResult:
293
+ """Output of :func:`solve`.
294
+
295
+ Attributes
296
+ ----------
297
+ t : ndarray, shape (n,)
298
+ Time axis (s).
299
+ c : ndarray, shape (n,)
300
+ Concentration profile (M) as seen by the mechanism.
301
+ Popen : ndarray, shape (n,)
302
+ Open probability at each time point.
303
+ P : ndarray, shape (k, n)
304
+ Occupancy of every state (rows) at every time point (columns).
305
+ pulse : AnyPulse
306
+ The pulse object used to generate this result.
307
+
308
+ Notes
309
+ -----
310
+ The result supports 4-tuple unpacking for backward compatibility::
311
+
312
+ t, c, Popen, P = solve(mec, pulse, reclen, step)
313
+ """
314
+
315
+ t: np.ndarray
316
+ c: np.ndarray
317
+ Popen: np.ndarray
318
+ P: np.ndarray
319
+ pulse: AnyPulse
320
+
321
+ def __iter__(self):
322
+ """Yield (t, c, Popen, P) so the result can be unpacked as a 4-tuple."""
323
+ yield self.t
324
+ yield self.c
325
+ yield self.Popen
326
+ yield self.P
327
+
328
+
329
+ @dataclass
330
+ class RelaxationResult:
331
+ """Output of :func:`relaxation_taus`.
332
+
333
+ Attributes
334
+ ----------
335
+ tau_on_weighted : float
336
+ Amplitude-weighted mean on-relaxation time constant (s).
337
+ tau_off_weighted : float
338
+ Amplitude-weighted mean off-relaxation time constant (s).
339
+ tau_on : ndarray, shape (k-1,)
340
+ Individual on-relaxation time constants (s), sorted fastest first.
341
+ tau_off : ndarray, shape (k-1,)
342
+ Individual off-relaxation time constants (s), sorted fastest first.
343
+ ampl_on : ndarray, shape (k-1,)
344
+ Relative amplitudes for each on-relaxation component.
345
+ ampl_off : ndarray, shape (k-1,)
346
+ Relative amplitudes for each off-relaxation component.
347
+ """
348
+
349
+ tau_on_weighted: float
350
+ tau_off_weighted: float
351
+ tau_on: np.ndarray
352
+ tau_off: np.ndarray
353
+ ampl_on: np.ndarray
354
+ ampl_off: np.ndarray
355
+
356
+
357
+ # ===========================================================================
358
+ # Private numerical core
359
+ # ===========================================================================
360
+
361
+ def _dPdt(P, t, mec, pulse):
362
+ """Rate of change of state occupancies: dP/dt = P · Q(c(t)).
363
+
364
+ Used as the right-hand side for scipy.odeint.
365
+
366
+ Parameters
367
+ ----------
368
+ P : ndarray, shape (k,)
369
+ Current state occupancies.
370
+ t : float
371
+ Current time (s).
372
+ mec : Mechanism
373
+ The mechanism (Q matrix updated via set_eff).
374
+ pulse : AnyPulse
375
+ Concentration profile; supplies c(t) via pulse.profile(t).
376
+ """
377
+ mec.set_eff('c', float(pulse.profile(t)))
378
+ return np.dot(P, mec.Q)
379
+
380
+
381
+ def _P_t(t, eigs, w):
382
+ """State occupancies at time *t* from eigendecomposition.
383
+
384
+ Parameters
385
+ ----------
386
+ t : float
387
+ Time elapsed since the step (s).
388
+ eigs : ndarray, shape (k,)
389
+ Eigenvalues of Q.
390
+ w : ndarray, shape (k, k)
391
+ Weighted spectral components from :func:`_coefficient_calc`.
392
+
393
+ Returns
394
+ -------
395
+ Pt : ndarray, shape (k,)
396
+ State occupancies at time *t*.
397
+ """
398
+ Pt = np.zeros(eigs.shape)
399
+ for i in range(eigs.size):
400
+ Pt[i] = np.sum(w[:, i] * np.exp(eigs * t))
401
+ return Pt
402
+
403
+
404
+ def _coefficient_calc(k, A, p_occup):
405
+ """Weighted spectral components p · A_n for each spectral matrix A_n.
406
+
407
+ Parameters
408
+ ----------
409
+ k : int
410
+ Number of states.
411
+ A : ndarray, shape (k, k, k)
412
+ Spectral matrices of Q.
413
+ p_occup : ndarray, shape (k,)
414
+ State occupancies at the start of the interval.
415
+
416
+ Returns
417
+ -------
418
+ w : ndarray, shape (k, k)
419
+ Element w[n, i] = (p · A_n)[i].
420
+ """
421
+ w = np.zeros((k, k))
422
+ for n in range(k):
423
+ w[n, :] = np.dot(p_occup, A[n, :, :])
424
+ return w
425
+
426
+
427
+ def _solve_ode(mec, pulse, reclen, step):
428
+ """Solve by ODE integration (scipy.odeint).
429
+
430
+ Accurate for smoothly varying concentration profiles (e.g. ErfPulse).
431
+ Also works for piecewise profiles, though the matrix method may be faster
432
+ for those.
433
+
434
+ Parameters
435
+ ----------
436
+ mec : Mechanism
437
+ pulse : AnyPulse
438
+ reclen : float Recording length (s).
439
+ step : float Sampling interval (s).
440
+
441
+ Returns
442
+ -------
443
+ t, c, Popen, P : as described in JumpResult.
444
+ """
445
+ t = np.arange(0, reclen, step)
446
+ mec.set_eff('c', pulse.cb)
447
+ P0 = qml.pinf(mec.Q)
448
+ Pt = scpi.odeint(_dPdt, P0, t, args=(mec, pulse),
449
+ atol=1e-8, rtol=1e-6)
450
+ P = Pt.transpose()
451
+ Popen = np.sum(P[:mec.kA], axis=0)
452
+ c = pulse.profile(t)
453
+ return t, c, Popen, P
454
+
455
+
456
+ def _solve_matrix(mec, pulse, reclen, step):
457
+ """Solve step-wise using the Q-matrix eigendecomposition.
458
+
459
+ At each time step the concentration is treated as constant and the
460
+ occupancies are propagated analytically via P(t+dt) = P(t) · exp(Q·dt).
461
+ Accurate for profiles that are piecewise constant (SquarePulse,
462
+ PairedSquarePulse) and useful as an independent check against _solve_ode.
463
+
464
+ Parameters
465
+ ----------
466
+ mec : Mechanism
467
+ pulse : AnyPulse
468
+ reclen : float
469
+ step : float
470
+
471
+ Returns
472
+ -------
473
+ t, c, Popen, P : as described in JumpResult.
474
+ """
475
+ t = np.arange(0, reclen, step)
476
+ c = pulse.profile(t)
477
+ mec.set_eff('c', pulse.cb)
478
+ pi = qml.pinf(mec.Q)
479
+ Pt = np.array([pi.copy()])
480
+
481
+ for i in range(1, t.shape[0]):
482
+ mec.set_eff('c', float(c[i]))
483
+ eigenvals, A = qml.eigs_sorted(mec.Q)
484
+ w = _coefficient_calc(mec.k, A, pi)
485
+ pi = _P_t(step, eigenvals, w)
486
+ Pt = np.append(Pt, [pi.copy()], axis=0)
487
+
488
+ P = Pt.transpose()
489
+ Popen = np.sum(P[:mec.kA], axis=0)
490
+ return t, c, Popen, P
491
+
492
+
493
+ # ===========================================================================
494
+ # Public API
495
+ # ===========================================================================
496
+
497
+ def solve(mec, pulse: AnyPulse, reclen: float, step: float,
498
+ method: str = 'ode') -> JumpResult:
499
+ """Compute the macroscopic open-probability response to a concentration pulse.
500
+
501
+ Starting from the equilibrium state at the background concentration
502
+ ``pulse.cb``, the state occupancies are evolved forward in time according
503
+ to dP/dt = P · Q(c(t)).
504
+
505
+ Parameters
506
+ ----------
507
+ mec : Mechanism
508
+ The ion-channel mechanism (must have set_eff, Q, kA, k attributes).
509
+ pulse : ErfPulse | SquarePulse | InstExpPulse | PairedSquarePulse
510
+ Concentration profile. The pulse's ``.cb`` attribute sets the initial
511
+ equilibrium condition.
512
+ reclen : float
513
+ Total recording length (s).
514
+ step : float
515
+ Sampling interval (s). ``reclen / step`` time points are returned.
516
+ method : {'ode', 'matrix'}
517
+ Numerical method:
518
+
519
+ ``'ode'``
520
+ Scipy ODE integrator (``odeint``). Robust for all pulse shapes,
521
+ especially smooth profiles like :class:`ErfPulse`.
522
+ ``'matrix'``
523
+ Step-wise Q-matrix eigendecomposition. Each time step is treated
524
+ as a constant-concentration interval. Useful as an independent
525
+ numerical check; may be slower.
526
+
527
+ Returns
528
+ -------
529
+ JumpResult
530
+ Named result with attributes ``t``, ``c``, ``Popen``, ``P``, ``pulse``.
531
+ Supports 4-tuple unpacking: ``t, c, Popen, P = result``.
532
+
533
+ Raises
534
+ ------
535
+ ValueError
536
+ If *method* is not ``'ode'`` or ``'matrix'``.
537
+
538
+ Examples
539
+ --------
540
+ >>> from scalcs.samples.samples import CH82
541
+ >>> from scalcs.cjumps import ErfPulse, solve
542
+ >>> mec = CH82()
543
+ >>> pulse = ErfPulse(cmax=1e-6, width=10e-3)
544
+ >>> result = solve(mec, pulse, reclen=50e-3, step=5e-6)
545
+ >>> t, c, Popen, P = result # backward-compatible unpacking
546
+ """
547
+ if method == 'ode':
548
+ t, c, Popen, P = _solve_ode(mec, pulse, reclen, step)
549
+ elif method == 'matrix':
550
+ t, c, Popen, P = _solve_matrix(mec, pulse, reclen, step)
551
+ else:
552
+ raise ValueError(
553
+ f"solve: unknown method {method!r}. Choose 'ode' or 'matrix'.")
554
+ return JumpResult(t=t, c=c, Popen=Popen, P=P, pulse=pulse)
555
+
556
+
557
+ def relaxation_taus(mec, pulse: SquarePulse) -> RelaxationResult:
558
+ """Analytical on/off relaxation time constants for a square concentration step.
559
+
560
+ For a SquarePulse the on-relaxation is governed by the eigenvalues of Q at
561
+ ``pulse.cmax`` (starting from equilibrium at ``pulse.cb``), and the
562
+ off-relaxation by the eigenvalues of Q at ``pulse.cb`` (starting from the
563
+ occupancies at the end of the pulse).
564
+
565
+ Parameters
566
+ ----------
567
+ mec : Mechanism
568
+ pulse : SquarePulse
569
+ Must be a :class:`SquarePulse`. Passing any other pulse type raises
570
+ ``TypeError`` because the analytical expressions assume an ideal step.
571
+
572
+ Returns
573
+ -------
574
+ RelaxationResult
575
+ Attributes: ``tau_on_weighted``, ``tau_off_weighted`` (floats),
576
+ ``tau_on``, ``tau_off``, ``ampl_on``, ``ampl_off`` (ndarrays, k-1).
577
+
578
+ Raises
579
+ ------
580
+ TypeError
581
+ If *pulse* is not a :class:`SquarePulse`.
582
+
583
+ Notes
584
+ -----
585
+ This replaces the old ``weighted_taus()`` which returned only 2 scalars,
586
+ breaking the ``sccurves`` call that expected 4 return values.
587
+ """
588
+ if not isinstance(pulse, SquarePulse):
589
+ raise TypeError(
590
+ "relaxation_taus() requires a SquarePulse (ideal step). "
591
+ f"Got {type(pulse).__name__}. "
592
+ "For non-square profiles use solve() and inspect the Popen trace.")
593
+
594
+ # Equilibrium at background concentration
595
+ mec.set_eff('c', pulse.cb)
596
+ eigs0, A0 = qml.eigs_sorted(mec.Q)
597
+ P0 = qml.pinf(mec.Q)
598
+
599
+ # Equilibrium at pulse concentration
600
+ mec.set_eff('c', pulse.cmax)
601
+ eigsInf, Ainf = qml.eigs_sorted(mec.Q)
602
+
603
+ # On-relaxation: step from cb → cmax
604
+ w_on = _coefficient_calc(mec.k, Ainf, P0)
605
+ ampl_on_raw = np.sum(w_on[:, :mec.kA], axis=1) # shape (k,)
606
+ max_ampl_on = np.max(np.abs(ampl_on_raw))
607
+ rel_ampl_on = ampl_on_raw / max_ampl_on # normalised
608
+ tau_on = -1.0 / eigsInf[:-1] # k-1 time constants (s)
609
+ # The dominant component typically has a negative relative amplitude
610
+ # (it drives occupancy toward the new equilibrium). Negating gives the
611
+ # conventional positive weighted time constant (matches DCPROGS HJC_JUMP).
612
+ tau_on_weighted = float(np.sum(-rel_ampl_on[:-1] * tau_on))
613
+
614
+ # Occupancies at end of pulse
615
+ Pt = _P_t(pulse.width, eigsInf, w_on)
616
+
617
+ # Off-relaxation: step from cmax → cb
618
+ w_off = _coefficient_calc(mec.k, A0, Pt)
619
+ ampl_off_raw = np.sum(w_off[:, :mec.kA], axis=1)
620
+ max_ampl_off = np.max(np.abs(ampl_off_raw))
621
+ rel_ampl_off = ampl_off_raw / max_ampl_off
622
+ tau_off = -1.0 / eigs0[:-1]
623
+ tau_off_weighted = float(np.sum(rel_ampl_off[:-1] * tau_off))
624
+
625
+ return RelaxationResult(
626
+ tau_on_weighted=tau_on_weighted,
627
+ tau_off_weighted=tau_off_weighted,
628
+ tau_on=tau_on,
629
+ tau_off=tau_off,
630
+ ampl_on=rel_ampl_on[:-1],
631
+ ampl_off=rel_ampl_off[:-1],
632
+ )
633
+
634
+
635
+ def jump_summary(mec, pulse: SquarePulse,
636
+ gamma: float = 30e-12,
637
+ Vm: float = -80e-3) -> dict:
638
+ """Analytical properties of a square concentration jump.
639
+
640
+ Computes all quantities needed for ``printout()`` and returns them as a
641
+ plain dict so that each quantity is individually testable.
642
+
643
+ Parameters
644
+ ----------
645
+ mec : Mechanism
646
+ pulse : SquarePulse
647
+ Must be a :class:`SquarePulse`; raises ``TypeError`` otherwise.
648
+ gamma : float
649
+ Single-channel conductance (S). Default 30 pS.
650
+ Vm : float
651
+ Transmembrane voltage (V). Default −80 mV.
652
+
653
+ Returns
654
+ -------
655
+ dict with keys:
656
+
657
+ ``p0`` ndarray — equilibrium occupancies before pulse.
658
+ ``pinf`` ndarray — equilibrium occupancies at cmax.
659
+ ``pt`` ndarray — occupancies at end of pulse.
660
+ ``eigenvalues_on`` ndarray, shape (k-1,) — eigenvalues during on-phase (s⁻¹).
661
+ ``eigenvalues_off`` ndarray, shape (k-1,) — eigenvalues during off-phase (s⁻¹).
662
+ ``amplitudes_on`` ndarray, shape (k-1,) — current amplitudes, on-phase (A).
663
+ ``amplitudes_off`` ndarray, shape (k-1,) — current amplitudes, off-phase (A).
664
+ ``rel_amplitudes_on`` ndarray — normalised (dimensionless).
665
+ ``rel_amplitudes_off`` ndarray — normalised.
666
+ ``areas_on`` ndarray, shape (k-1,) — charge under each on component (C).
667
+ ``areas_off`` ndarray, shape (k-1,) — charge under each off component (C).
668
+ ``tau_on_weighted`` float — weighted on time constant (s).
669
+ ``tau_off_weighted`` float — weighted off time constant (s).
670
+ ``gamma`` float — conductance used (S).
671
+ ``Vm`` float — voltage used (V).
672
+
673
+ Raises
674
+ ------
675
+ TypeError
676
+ If *pulse* is not a :class:`SquarePulse`.
677
+ """
678
+ if not isinstance(pulse, SquarePulse):
679
+ raise TypeError(
680
+ "jump_summary() requires a SquarePulse. "
681
+ f"Got {type(pulse).__name__}.")
682
+
683
+ # --- equilibrium before pulse ---
684
+ mec.set_eff('c', pulse.cb)
685
+ P0 = qml.pinf(mec.Q)
686
+ eigs0, A0 = qml.eigs_sorted(mec.Q)
687
+
688
+ # --- equilibrium at peak concentration ---
689
+ mec.set_eff('c', pulse.cmax)
690
+ Pinf = qml.pinf(mec.Q)
691
+ eigsInf, Ainf = qml.eigs_sorted(mec.Q)
692
+
693
+ # --- on-relaxation ---
694
+ w_on = _coefficient_calc(mec.k, Ainf, P0)
695
+ ampl_on_raw = np.sum(w_on[:, :mec.kA], axis=1) # shape (k,)
696
+ cur_on = ampl_on_raw * gamma * Vm # current (A)
697
+ max_ampl_on = np.max(np.abs(ampl_on_raw))
698
+ rel_ampl_on = ampl_on_raw / max_ampl_on
699
+ tau_on = -1.0 / eigsInf[:-1] # k-1 time constants (s)
700
+ area_on = -cur_on[:-1] / eigsInf[:-1] # charge (C)
701
+ tau_on_weighted = float(np.sum(-rel_ampl_on[:-1] * tau_on))
702
+
703
+ # --- occupancies at end of pulse ---
704
+ Pt = _P_t(pulse.width, eigsInf, w_on)
705
+
706
+ # --- off-relaxation ---
707
+ w_off = _coefficient_calc(mec.k, A0, Pt)
708
+ ampl_off_raw = np.sum(w_off[:, :mec.kA], axis=1)
709
+ cur_off = ampl_off_raw * gamma * Vm
710
+ max_ampl_off = np.max(np.abs(ampl_off_raw))
711
+ rel_ampl_off = ampl_off_raw / max_ampl_off
712
+ tau_off = -1.0 / eigs0[:-1]
713
+ area_off = np.zeros(mec.k - 1)
714
+ for i in range(mec.k - 1):
715
+ area_off[i] = -1000.0 * cur_off[i] / eigs0[i] # in pC·ms (×1000)
716
+ tau_off_weighted = float(np.sum(rel_ampl_off[:-1] * tau_off))
717
+
718
+ return {
719
+ 'p0': P0,
720
+ 'pinf': Pinf,
721
+ 'pt': Pt,
722
+ 'eigenvalues_on': eigsInf[:-1],
723
+ 'eigenvalues_off': eigs0[:-1],
724
+ 'amplitudes_on': cur_on[:-1],
725
+ 'amplitudes_off': cur_off[:-1],
726
+ 'rel_amplitudes_on': rel_ampl_on[:-1],
727
+ 'rel_amplitudes_off': rel_ampl_off[:-1],
728
+ 'areas_on': area_on,
729
+ 'areas_off': area_off,
730
+ 'tau_on_weighted': tau_on_weighted,
731
+ 'tau_off_weighted': tau_off_weighted,
732
+ 'gamma': gamma,
733
+ 'Vm': Vm,
734
+ }
735
+
736
+
737
+ def printout(mec, pulse: SquarePulse,
738
+ gamma: float = 30e-12,
739
+ Vm: float = -80e-3) -> str:
740
+ """Human-readable summary of concentration jump properties.
741
+
742
+ Produces the same analytical printout as the original ``cjumps.printout``
743
+ but with configurable single-channel conductance and voltage (no more
744
+ hard-coded values).
745
+
746
+ Parameters
747
+ ----------
748
+ mec : Mechanism
749
+ pulse : SquarePulse
750
+ Must be a :class:`SquarePulse`; raises ``TypeError`` otherwise.
751
+ gamma : float
752
+ Single-channel conductance (S). Default 30 pS.
753
+ Vm : float
754
+ Transmembrane voltage (V). Default −80 mV.
755
+
756
+ Returns
757
+ -------
758
+ str
759
+ Formatted text report.
760
+
761
+ Raises
762
+ ------
763
+ TypeError
764
+ If *pulse* is not a :class:`SquarePulse`.
765
+ """
766
+ if not isinstance(pulse, SquarePulse):
767
+ raise TypeError(
768
+ "printout() requires a SquarePulse. "
769
+ f"Got {type(pulse).__name__}. "
770
+ "For other profiles, use solve() to obtain the Popen time course.")
771
+
772
+ s = jump_summary(mec, pulse, gamma=gamma, Vm=Vm)
773
+ k = mec.k
774
+
775
+ out = ('\n*******************************************\n'
776
+ 'CONCENTRATION JUMPS\n')
777
+
778
+ out += ('\nEquilibrium occupancies before t=0, at concentration = '
779
+ '{:.5g} mM:\n'.format(pulse.cb * 1000))
780
+ for i in range(k):
781
+ out += ' p0({:d}) = {:.5g}\n'.format(i + 1, s['p0'][i])
782
+
783
+ out += ('\nEquilibrium occupancies at peak concentration = '
784
+ '{:.5g} mM:\n'.format(pulse.cmax * 1000))
785
+ for i in range(k):
786
+ out += ' pinf({:d}) = {:.5g}\n'.format(i + 1, s['pinf'][i])
787
+
788
+ out += '\nOccupancies at end of {:.5g} ms pulse:\n'.format(pulse.width * 1000)
789
+ for i in range(k):
790
+ out += ' pt({:d}) = {:.5g}\n'.format(i + 1, s['pt'][i])
791
+
792
+ out += ('\nSingle-channel conductance: {:.5g} pS\n'
793
+ 'Transmembrane voltage: {:.5g} mV\n'.format(
794
+ gamma * 1e12, Vm * 1e3))
795
+
796
+ out += ('\nON-RELAXATION (ideal step {:s} → {:s} mM):\n'
797
+ ' {:>4s} {:>14s} {:>10s} {:>12s} {:>12s}\n'.format(
798
+ '{:.5g}'.format(pulse.cb * 1000),
799
+ '{:.5g}'.format(pulse.cmax * 1000),
800
+ 'Comp', 'Eigenvalue', 'Tau (ms)',
801
+ 'Ampl (t=0, pA)', 'Rel. ampl.'))
802
+ for i in range(k - 1):
803
+ out += (' {:>4d} {:>14.5g} {:>10.5g} {:>12.5g} {:>12.5g}\n'.format(
804
+ i + 1,
805
+ s['eigenvalues_on'][i],
806
+ -1000.0 / s['eigenvalues_on'][i],
807
+ s['amplitudes_on'][i] * 1e12, # pA
808
+ s['rel_amplitudes_on'][i]))
809
+ out += (' Weighted on tau = {:.5g} ms\n'.format(s['tau_on_weighted'] * 1000))
810
+ out += (' Total current at t=0 = {:.5g} pA\n'.format(
811
+ np.sum(s['amplitudes_on']) * 1e12))
812
+ cur_on_eq = s['amplitudes_on'] # last component is equilibrium term
813
+ out += (' Total area (on) = {:.5g} pC\n'.format(
814
+ np.sum(s['areas_on']) * 1e3)) # stored in C, convert to pC
815
+
816
+ out += ('\nOFF-RELAXATION (ideal step {:s} → {:s} mM):\n'
817
+ ' {:>4s} {:>14s} {:>10s} {:>12s} {:>12s}\n'.format(
818
+ '{:.5g}'.format(pulse.cmax * 1000),
819
+ '{:.5g}'.format(pulse.cb * 1000),
820
+ 'Comp', 'Eigenvalue', 'Tau (ms)',
821
+ 'Ampl (t=0, pA)', 'Rel. ampl.'))
822
+ for i in range(k - 1):
823
+ out += (' {:>4d} {:>14.5g} {:>10.5g} {:>12.5g} {:>12.5g}\n'.format(
824
+ i + 1,
825
+ s['eigenvalues_off'][i],
826
+ -1000.0 / s['eigenvalues_off'][i],
827
+ s['amplitudes_off'][i] * 1e12,
828
+ s['rel_amplitudes_off'][i]))
829
+ out += (' Weighted off tau = {:.5g} ms\n'.format(s['tau_off_weighted'] * 1000))
830
+ out += (' Total current at t=0 = {:.5g} pA\n'.format(
831
+ np.sum(s['amplitudes_off']) * 1e12))
832
+ out += (' Total area (off) = {:.5g} pC\n'.format(
833
+ np.sum(s['areas_off'])))
834
+
835
+ return out