driftlet 1.2.4 → 1.3.2

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,795 @@
1
+ // The equations at the boundaries: each face between regions (its φ law, the species' links
2
+ // across it, its reactions and gates), and the terminals (the ports inside the device, the two
3
+ // contacts, and the circuit rows of the floating ones), with what each passes into the device.
4
+ //
5
+ // CONTRACT
6
+ //
7
+ // Methods of the Solver, called by assemble() (src/assemble.js, whose contract sets the rows'
8
+ // conventions) and its bookkeeping, after the regions' terms (src/regions.js), whose
9
+ // concentrations and derivatives (_dlnc, _Kz) they use. Units: a face's slots are per area (its
10
+ // flux N_i toward +x, its D, its reactions' rates), and enter the edge nodes' rows times the
11
+ // face's area Af; a port's terms are per volume of its window (times vol·fill, the part of each
12
+ // node's box inside the window: port.fill, src/device.js), its reactions' per area of electrode
13
+ // (times vol and the electrode's area per volume of each node's box, portArea, fill in it); a contact's
14
+ // links are per area, times the area at the end node. V_T = RT/F; a terminal's voltage V (termV)
15
+ // is the outside's level per charge.
16
+ //
17
+ // A face f (_face; its flux block bf between edge nodes bL, bR):
18
+ // φ law: pinned, φ̂_R − φ̂_L − dipole/V_T = 0; capacitive, D + C V_T (φ̂_R − φ̂_L − dipole/V_T)
19
+ // = 0; neutral, no D. Against a metal (itf.conductor, not neutral), the other side's edge φ
20
+ // is tied to the metal's Fermi level V_F = V_T η/z instead: capacitive, D toward the other
21
+ // side = C (V_F − zeroCharge − φ) (pinned, V_F − zeroCharge − φ = 0, is written too, but
22
+ // normalizeDevice refuses it: a metal holds its surface charge in a finite capacitance). D enters the left Gauss row
23
+ // as +Af·D, the right one's as −Af·(D + sheetCharge); a metal side's charge is its carriers'
24
+ // (src/regions.js).
25
+ // links, per species: blocked, no flux; equilibrium, η_L = η_R (the flux whatever the
26
+ // balances need); permeability, N = −P B(d) c_L expm1(η_R − η_L), d = z Δφ̂ + the step in
27
+ // μ°/RT − ln c_ref (Goldman–Hodgkin–Katz between like solutions); conductance, N = G V_T
28
+ // (η_L − η_R)/(z² F). Gated, P or G times Π x^p over its gates, each x taken in [0, 1]. N
29
+ // enters the left node's balance as +Af·N, the right's as −Af·N.
30
+ // reactions (_faceReaction): its rate r an unknown, row r − rate = 0, with a = fixedA − Σ ν η
31
+ // over its participants (ν > 0 made, at either side's edge node); rate Butler–Volmer, k0
32
+ // Π (c/c_ref)^e g(a) (e = |ν|(1 − α) for what it consumes, να for what it makes; a metal's
33
+ // carrier at activity 1), or saturating (vmax), or SRH; −Af·ν·r into each participant's row.
34
+ // gates (_faceGates): (x − x_old)/dt − α(V)(1 − x) + β(V)x = 0, V = φ_R − φ_L (no storage at
35
+ // dt = ∞).
36
+ //
37
+ // The terminals (_terminals), in order: the sources refreshed (_refreshSources); the live
38
+ // terminals' termB, termC zeroed, and termDI, termDX, termI, qg, qgSlope; each port's terms but
39
+ // its held levels, then each port's held levels; the left contact, the right; each floating
40
+ // terminal's circuit row.
41
+ // port k (terminal 2 + k, _port), at each node of its window: an equilibrium link holds η at
42
+ // the port's level (portEta) at the nodes inside the window (port.inside), replacing the
43
+ // balance row, whose residual just before is the flux it passes (not at a device end node
44
+ // whose contact links that species); conductance
45
+ // and exchange links supply vol·fill·k(η_out − η), k = G V_T/(z² F) or the link's k (gated as at
46
+ // a face); reactions (_portReaction) as at a face against the electrode's carrier at the
47
+ // port's level, explicit (no unknown), −vol·area·ν·rate into the region's species' rows,
48
+ // −ν·rate into a surface species' (its rows are per area of electrode); a capacitance (_portCapacitance), σ =
49
+ // C (V − zeroCharge − φ) per electrode area, −vol·area·σ into each Gauss row (not at an end
50
+ // node whose contact sets φ, nor where φ is undefined), its charge portQ and per-node qg;
51
+ // gates as at a face, in V_m = φ − (V − zeroCharge); a surface's storage, Γ(θ − θ_old)/dt.
52
+ // termI: Σ z F × what it passes, plus its capacitance's charging, (Q − Q at the step's
53
+ // start)/dt (steady at no current: Q − its captured charge).
54
+ // contact (_contact): first, at the end node, each active balance residual (all of the box's
55
+ // other terms) is the flux the contact passes in: contactFlux (toward +x), and the current
56
+ // Σ z F × that; then, at a lateStorage node on a time step, the ions' storage (in the rows
57
+ // and contactFlux, not the current); then its links: exchange, A k (μ_out/RT − η); conductance,
58
+ // A G (V + offset − V_i)/(zF), V_i = V_T η/z; velocity, A v c (e^{η_out − η} − 1) (dilute,
59
+ // A v (c_eq − c)); equilibrium, the row replaced by η = η_out (contactEta), any other
60
+ // terminal's ∂/∂V in it cleared. Its φ law: capacitive, D_in = A C (V − zeroCharge − V_T φ̂)
61
+ // (φ̂ as u alone, as the displacement reads it) out of the Gauss row; pinned or bulk, D_in =
62
+ // the Gauss row's residual so far, the row replaced by φ̂ = (V − zeroCharge)/V_T or by
63
+ // neutrality (F Σ z c + ρ_fixed = 0, its ∂/∂η_i = F (Kz)_i, K being symmetric); the current gains (D_in − D_in at the step's start)/dt (not at a lateStorage
64
+ // node on a time step; steady at no current: D_in − its captured charge); contactD = ±D_in
65
+ // toward +x. termI: that current.
66
+ // A terminal whose current is read from rows (a contact's from its end box, a port's held
67
+ // level from the rows it replaces) takes their ∂/∂V's with it (_captureRow): another live
68
+ // terminal j's termB entries there into termDX[k][j] (a port's window in a contact's end
69
+ // box), its own into termDI[k].
70
+ // circuit (_circuit), floating k: termRes = I − I_set, or I − (V_src − V)/R.
71
+ //
72
+ // Invariants (each says what a reviewer should be able to check here):
73
+ // 1. Every Jacobian entry is its residual's derivative; termB[k] is ∂res/∂V_k, termC[k]
74
+ // ∂termI_k/∂(unknowns), and termDI[k], termDX[k][j] ∂termI_k/∂V_k and /∂V_j (with a circuit
75
+ // row's own), for the live terminals (_liveTerms).
76
+ // 2. termI[k] is the current into the device through terminal k: what its links and reactions
77
+ // pass, times z F, and its displacement current; a contact's equals what the end box needs
78
+ // from outside, read from its residual (so the current is conserved through the device).
79
+ // 3. A face's flux enters its two edge nodes with opposite signs, times Af.
80
+ // 4. Where μ̄ is level across a link (or a = 0 for a reaction), its flux (rate) is exactly 0.
81
+ // 5. The readouts (contactFlux, contactD, portFlux, portQ, portRates) are those of the state
82
+ // assembled, written at every assembly.
83
+ // Tests: test/contacts.test.js, test/ports.test.js, test/terminals.test.js, test/circuit.test.js,
84
+ // test/gates.test.js, test/membrane.test.js, test/kinetics.test.js, test/coverage.test.js,
85
+ // test/capacitance.test.js, test/metal.test.js, test/jacobian.test.js.
86
+
87
+ import { sgFlux } from './bernoulli.js';
88
+ import { FARADAY } from './constants.js';
89
+ import { sourceAt } from './device.js';
90
+ import { powi, powr } from './pow.js';
91
+
92
+ // Butler–Volmer factor g(a) = e^{αa} − e^{−(1−α)a} = e^{−(1−α)a}·expm1(a) (precise near a = 0),
93
+ // and its derivative g′(a) = α e^{αa} + (1−α) e^{−(1−α)a}.
94
+ function bvFactor(a, alpha) {
95
+ const e = Math.exp(-(1 - alpha) * a);
96
+ // (far from equilibrium, the plain difference: the product would give 0·∞ there)
97
+ const g = Math.abs(a) > 50 ? Math.exp(alpha * a) - e : e * Math.expm1(a);
98
+ return { g, gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
99
+ }
100
+
101
+ // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
102
+ export function gateRate(law, V) {
103
+ const { rate, midpoint, scale } = law;
104
+ if (law.type === 'exp') {
105
+ // (held finite where Newton's iterates stray to absurd voltages: β_m at −13 V would overflow)
106
+ const y = (V - midpoint) / scale, v = rate * Math.exp(Math.min(y, 700));
107
+ return [v, y > 700 ? 0 : v / scale];
108
+ }
109
+ if (law.type === 'sigmoid') {
110
+ const e = Math.exp((midpoint - V) / scale), d = 1 + e;
111
+ return Number.isFinite(e) ? [rate / d, (rate * e) / (scale * d * d)] : [0, 0];
112
+ }
113
+ // expLinear: rate·y/(1 − e^−y), smooth through y = 0 (rate there) and → rate·y for large y
114
+ const y = (V - midpoint) / scale;
115
+ if (Math.abs(y) < 1e-6) return [rate * (1 + y / 2), rate / (2 * scale)];
116
+ if (y < -700) return [0, 0];
117
+ const em = -Math.expm1(-y), ey = Math.exp(-y); // 1 − e^−y, e^−y
118
+ return [(rate * y) / em, (rate * (em - y * ey)) / (em * em * scale)];
119
+ }
120
+
121
+ export class BoundaryTerms {
122
+ // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
123
+ _portRate(port, rx, g) {
124
+ const { n, M, u, uLo, c, nSurf, th } = this, b = this.blockOfNode[g], RT = this.model.RT;
125
+ let pref = rx.k0, a = rx.fixedA;
126
+ for (const p of rx.part) {
127
+ if (p.side === 1) {
128
+ a -= p.nu * this.portEta(port, p.i);
129
+ continue;
130
+ }
131
+ const o = b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
132
+ a -= p.nu * (u[o] + uLo[o]);
133
+ pref *= p.side === 2 ? powr(th[g * nSurf + p.s], e) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
134
+ }
135
+ if (rx.sites) pref *= powr(this.th0[g], rx.sites);
136
+ return pref * bvFactor(a, rx.alpha).g;
137
+ }
138
+
139
+ // A face: its flux node's rows are the interface laws (φ law, species links, reactions), and
140
+ // its fluxes enter the two edge nodes' balances.
141
+ _face(f) {
142
+ const { model, n, M, u, uLo, res, z, VT } = this, R = this.rix, F = FARADAY;
143
+ const grid = model.grid, interfaces = model.interfaces;
144
+ const bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
145
+ const itf = interfaces[f];
146
+ // φ law: pinned jump (dipole), Helmholtz capacitor, or no charge at all (neutral).
147
+ const law = itf.phi.type;
148
+ const condSide = itf.conductor && law !== 'neutral' ? itf.conductor.side : null;
149
+ if (condSide) {
150
+ // Against a metal: the other side's φ is tied to the metal's Fermi level V_F = V_T η/z.
151
+ const im = itf.conductor.i, bm = condSide === 'left' ? bL : bR, bo = condSide === 'left' ? bR : bL;
152
+ const sg = condSide === 'left' ? 1 : -1;
153
+ const vf = (u[bm * M + 1 + im] + uLo[bm * M + 1 + im]) / z[im]; // V_F / V_T
154
+ const gap = vf - itf.zeroCharge / VT - (u[bo * M] + uLo[bo * M]); // (V_F − zeroCharge − φ_edge)/V_T
155
+ if (law === 'pinned') {
156
+ res[R[bf * M]] = gap;
157
+ this._j(bf, 0, bm, 1 + im, 1 / z[im]);
158
+ this._j(bf, 0, bo, 0, -1);
159
+ } else {
160
+ // D toward the other side = C (V_F − zeroCharge − φ_edge); toward +x that's sg times it.
161
+ const kC = itf.phi.C * VT;
162
+ res[R[bf * M]] = u[bf * M] - sg * kC * gap;
163
+ this._j(bf, 0, bf, 0, 1);
164
+ this._j(bf, 0, bm, 1 + im, (-sg * kC) / z[im]);
165
+ this._j(bf, 0, bo, 0, sg * kC);
166
+ }
167
+ } else if (law === 'neutral') {
168
+ // D = 0 (not an unknown); the jump is whatever each side's neutrality needs
169
+ } else {
170
+ const jump = u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) - itf.dipole / VT;
171
+ if (law === 'pinned') {
172
+ res[R[bf * M]] = jump;
173
+ this._j(bf, 0, bR, 0, 1);
174
+ this._j(bf, 0, bL, 0, -1);
175
+ } else {
176
+ // D = −C (φ_R − φ_L − dipole): displacement toward +x drops across the layer.
177
+ const kC = itf.phi.C * VT;
178
+ res[R[bf * M]] = u[bf * M] + kC * jump;
179
+ this._j(bf, 0, bf, 0, 1);
180
+ this._j(bf, 0, bR, 0, kC);
181
+ this._j(bf, 0, bL, 0, -kC);
182
+ }
183
+ }
184
+ // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead. The
185
+ // flux node's unknowns are per area, so what they carry is that times the face's area.
186
+ const gL = grid.regionEnd[f], gR = gL + 1, Af = grid.area[gL];
187
+ if (this.nodeConductor[gL] < 0) {
188
+ res[R[bL * M]] += Af * u[bf * M];
189
+ this._j(bL, 0, bf, 0, Af);
190
+ }
191
+ if (this.nodeConductor[gR] < 0) {
192
+ res[R[bR * M]] -= Af * (u[bf * M] + itf.sheetCharge);
193
+ this._j(bR, 0, bf, 0, -Af);
194
+ }
195
+ for (let i = 0; i < n; i++) {
196
+ const r = 1 + i, o = bf * M + r;
197
+ const type = itf.links[i].type;
198
+ const deta = u[bL * M + r] - u[bR * M + r] + (uLo[bL * M + r] - uLo[bR * M + r]); // η_L − η_R
199
+ if (type === 'blocked') continue;
200
+ if (type === 'equilibrium') {
201
+ res[R[o]] = -deta; // μ̄ continuous
202
+ this._j(bf, r, bR, r, 1);
203
+ this._j(bf, r, bL, r, -1);
204
+ } else if (type === 'permeability') {
205
+ // Electrodiffusion through a thin membrane in a constant field: Scharfetter–Gummel across
206
+ // the face with D/h → P, its drift taken over the face's whole jump in the standard level
207
+ // zφ̂ + μ°/RT − ln c_ref (the φ jump of a capacitive face, and any step between the two
208
+ // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
209
+ // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
210
+ // and it's exactly zero where μ̄ is level.
211
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
212
+ const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
213
+ const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
214
+ // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
215
+ const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
216
+ const N1 = gated.length ? sgFlux(link.P, d, -deta, this.c[kl], this.c[kr], this.sg)[0] : 0; // (fully open)
217
+ const P = link.P * (gated.length ? this._open(gated, gx) : 1);
218
+ const sg = sgFlux(P, d, -deta, this.c[kl], this.c[kr], this.sg); // (−deta = η_R − η_L)
219
+ const N = sg[0], dNdd = sg[1];
220
+ res[R[o]] = u[o] - N;
221
+ this._j(bf, r, bf, r, 1);
222
+ for (const [q, p] of gated) {
223
+ const dO = this._dOpen(gated, q, p, gx);
224
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -N1 * dO);
225
+ }
226
+ this._j(bf, r, bL, r, -sg[2]);
227
+ this._j(bf, r, bR, r, sg[3]);
228
+ if (zi !== 0) {
229
+ this._j(bf, r, bL, 0, zi * dNdd + zi * N);
230
+ this._j(bf, r, bR, 0, -zi * dNdd);
231
+ }
232
+ } else {
233
+ // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
234
+ // G times Π x^p
235
+ const gated = itf.links[i].gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
236
+ const gG0 = (itf.links[i].G * VT) / (z[i] * z[i] * F), gG = gG0 * (gated.length ? this._open(gated, gx) : 1);
237
+ res[R[o]] = u[o] - gG * deta;
238
+ this._j(bf, r, bf, r, 1);
239
+ this._j(bf, r, bL, r, -gG);
240
+ this._j(bf, r, bR, r, gG);
241
+ for (const [q, p] of gated) {
242
+ const dO = this._dOpen(gated, q, p, gx);
243
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -gG0 * deta * dO);
244
+ }
245
+ }
246
+ res[R[bL * M + r]] += Af * u[o];
247
+ this._j(bL, r, bf, r, Af);
248
+ res[R[bR * M + r]] -= Af * u[o];
249
+ this._j(bR, r, bf, r, -Af);
250
+ }
251
+ itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
252
+ if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
253
+ }
254
+
255
+ // Full index of face f's gate q (its fraction open).
256
+ _gateSlot(f, q) {
257
+ return this.blockOfFace[f] * this.M + 1 + this.n + this.model.interfaces[f].reactions.length + q;
258
+ }
259
+
260
+ // The voltage a gate follows: across its face, or across its port's capacitance at its node.
261
+ _gateVoltage(gt) {
262
+ return gt.port === undefined ? this._faceVoltage(gt.f) : this._portVoltage(gt.port, gt.g);
263
+ }
264
+
265
+ // The voltage across port k's capacitance at node g, φ − (V − zeroCharge) (V): a membrane's,
266
+ // inside (the region) minus outside.
267
+ _portVoltage(k, g) {
268
+ const { M, u, uLo, VT } = this, b = this.blockOfNode[g], port = this.model.ports[k];
269
+ return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
270
+ }
271
+
272
+ // The voltage across face f, φ_right − φ_left (V).
273
+ _faceVoltage(f) {
274
+ const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
275
+ return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
276
+ }
277
+
278
+ // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
279
+ _faceGates(f, bf, bL, bR) {
280
+ const { res, VT } = this, R = this.rix, itf = this.model.interfaces[f], dt = this.dtNow;
281
+ const V = this._faceVoltage(f), k0 = 1 + this.n + itf.reactions.length;
282
+ itf.gates.forEach((gate, q) => {
283
+ const o = this._gateSlot(f, q), x = this.u[o];
284
+ const [a, da] = gateRate(gate.alpha, V), [b, db] = gateRate(gate.beta, V);
285
+ const store = Number.isFinite(dt) ? 1 / dt : 0, j = this.gateIndex[f] + q;
286
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - b * x);
287
+ this._j(bf, k0 + q, bf, k0 + q, store + a + b);
288
+ const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
289
+ this._j(bf, k0 + q, bR, 0, dV);
290
+ this._j(bf, k0 + q, bL, 0, -dV);
291
+ });
292
+ }
293
+
294
+ // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
295
+ // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
296
+ // there when ν > 0). The rate couples the two edge nodes only through the face block between
297
+ // them, which keeps the system block-tridiagonal.
298
+ _faceReaction(rx, k, f, bf, bL, bR, Af) {
299
+ const { n, M, u, uLo, c, res } = this;
300
+ const R = this.rix;
301
+ const grid = this.model.grid;
302
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
303
+ const al = rx.alpha;
304
+ const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
305
+ let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
306
+ for (const p of rx.part) {
307
+ const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
308
+ aHi -= p.nu * u[o];
309
+ aLo -= p.nu * uLo[o];
310
+ if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
311
+ const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
312
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
313
+ this._dlnc(g, p.i, e, p.side ? dR : dL);
314
+ }
315
+ if (rx.vmax) {
316
+ // Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
317
+ let S = 1;
318
+ rx.part.forEach((p, q) => {
319
+ if (p.nu >= 0) return;
320
+ const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
321
+ S *= powi(cc / (cc + K), -p.nu);
322
+ this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
323
+ });
324
+ const a = aHi + aLo, back = Math.exp(-a);
325
+ rate = rx.vmax * S * -Math.expm1(-a);
326
+ for (let s = 0; s < M; s++) {
327
+ dL[s] *= rate;
328
+ dR[s] *= rate;
329
+ }
330
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
331
+ } else if (rx.srh) {
332
+ // SRH, with n and p each at its own side's edge node.
333
+ const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
334
+ const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo, ...this._srhDegen(gn, gq, { n: pn, p: pp }));
335
+ rate = s.rate;
336
+ this._srhInto(gn, pn.i, s.Cn, s.Cxn, pn.side ? dR : dL);
337
+ this._srhInto(gq, pp.i, s.Cp, s.Cxp, pp.side ? dR : dL);
338
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= s.Ca * p.nu;
339
+ } else {
340
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
341
+ rate = pref * bv;
342
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
343
+ for (let s = 0; s < M; s++) {
344
+ dL[s] *= rate;
345
+ dR[s] *= rate;
346
+ }
347
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
348
+ }
349
+ const slot = 1 + n + k, o = bf * M + slot;
350
+ res[R[o]] = u[o] - rate;
351
+ this._j(bf, slot, bf, slot, 1);
352
+ for (let s = 0; s < M; s++) {
353
+ if (dL[s] !== 0) this._j(bf, slot, bL, s, -dL[s]);
354
+ if (dR[s] !== 0) this._j(bf, slot, bR, s, -dR[s]);
355
+ }
356
+ for (const p of rx.part) {
357
+ const b = p.side ? bR : bL;
358
+ res[R[b * M + 1 + p.i]] -= Af * p.nu * u[o];
359
+ this._j(b, 1 + p.i, bf, slot, -Af * p.nu);
360
+ }
361
+ }
362
+
363
+ // The terminals whose ∂res/∂V (termB) and ∂I/∂x (termC) are kept, exactly: the floating ones,
364
+ // which the Newton solve borders on, or all of them (allTerminals: for the impedance, which reads
365
+ // its terminal's). A held terminal's are otherwise left as they fall (written but not kept
366
+ // consistent), which spares a device with dozens of ports (an axon's nodes of Ranvier) the cost
367
+ // of carrying each one's two dense vectors through every assembly.
368
+ _liveTerms() {
369
+ if (this.allTerminals) return this._allTerms ?? (this._allTerms = this.terms.map((_, k) => k));
370
+ return this.floating;
371
+ }
372
+
373
+ // Every terminal's terms: its voltage held or floating, how the residual depends on it
374
+ // (termB), its current into the device (termI) and how that depends on the state (termC).
375
+ _terminals(dt) {
376
+ this._refreshSources();
377
+ for (const k of this._liveTerms()) {
378
+ this.termB[k].fill(0);
379
+ this.termC[k].fill(0);
380
+ }
381
+ this.termDI.fill(0);
382
+ for (const x of this.termDX) x.fill(0);
383
+ this.termI.fill(0);
384
+ this.qg.fill(0);
385
+ this.qgSlope.fill(0);
386
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
387
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, true));
388
+ this._contact('left', dt);
389
+ this._contact('right', dt);
390
+ for (const k of this.floating) this._circuit(k);
391
+ }
392
+
393
+ // A floating terminal's circuit law: I − I_set = 0 (driven by a current), or I − (V_src − V)/R = 0
394
+ // (a source behind a resistance).
395
+ _circuit(k) {
396
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
397
+ if (d.kind === 'I') this.termRes[k] = this.termI[k] - src;
398
+ else {
399
+ this.termRes[k] = this.termI[k] - (src - this.termV[k]) / d.R;
400
+ this.termDI[k] += 1 / d.R;
401
+ }
402
+ }
403
+
404
+ // into += w × (the Jacobian row of block b, slot s), over compact columns; and where a terminal
405
+ // k reads that row as its current, its ∂/∂V's too: w × each live terminal's termB entry in it,
406
+ // into termDI[k] (its own) or termDX[k][j].
407
+ _captureRow(b, s, w, into, k = -1) {
408
+ const l = this.loc[b * this.M + s];
409
+ if (l < 0 || w === 0) return;
410
+ if (k >= 0) {
411
+ const o = this.rix[b * this.M + s];
412
+ for (const j of this._liveTerms()) {
413
+ const v = this.termB[j][o];
414
+ if (v === 0) continue;
415
+ if (j === k) this.termDI[k] += w * v;
416
+ else this.termDX[k][j] += w * v;
417
+ }
418
+ }
419
+ if (this.lin) this.lin.capture(this.rix[b * this.M + s], w, this.lin.rate, into);
420
+ const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
421
+ for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
422
+ if (nb < 0 || nb >= nB) continue;
423
+ const mc = sizes[nb], o = off[b] + l * mc, arr = sys[X], c0 = offX[nb];
424
+ for (let c = 0; c < mc; c++) if (arr[o + c] !== 0) into[c0 + c] += w * arr[o + c];
425
+ }
426
+ return m;
427
+ }
428
+
429
+ // An internal port (terminal k): its exchange with each node of its window, as a source per
430
+ // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
431
+ // row's residual, read just before. Its current into the device is Σ z F × the sources. In two
432
+ // passes over the ports: everything else, then the held levels (`holds`), so that a level reads
433
+ // every other port's terms at its nodes, and none is added into its row after.
434
+ _port(port, flux, k, dt, holds) {
435
+ const R = this.rix;
436
+ const { n, M, u, uLo, res, z, VT } = this;
437
+ const F = FARADAY, vol = this.model.grid.vol;
438
+ const B = this.termB[k], C = this.termC[k];
439
+ if (!holds) flux.fill(0);
440
+ for (let i = 0; i < n; i++) {
441
+ const link = port.species[i];
442
+ if (link.type === 'blocked' || (link.type === 'equilibrium') !== holds) continue;
443
+ const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
444
+ for (let w = 0; w < port.nodes.length; w++) {
445
+ const g = port.nodes[w], b = this.blockOfNode[g], o = b * M + r, v = vol[g] * port.fill[w];
446
+ const deta = target - u[o] - uLo[o]; // (μ̄_out − μ̄)/RT
447
+ if (link.type === 'equilibrium') {
448
+ if (!port.inside[w]) continue; // (a held level holds the nodes inside the window)
449
+ // a device end node's level is its contact's business, where the contact links it
450
+ if ((g === 0 && this.model.contacts.left.species[i].type !== 'blocked') || (g === this.nNodes - 1 && this.model.contacts.right.species[i].type !== 'blocked')) continue;
451
+ flux[i] += res[R[o]];
452
+ this._captureRow(b, r, zF, C, k);
453
+ this._replaceRow(b, r);
454
+ for (const Bk of this.termB) Bk[R[o]] = 0; // (other ports' terms in the row replaced)
455
+ this._j(b, r, b, r, 1);
456
+ res[R[o]] = -deta;
457
+ B[R[o]] += -z[i] / VT;
458
+ continue;
459
+ }
460
+ // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η). Gated, G times
461
+ // Π x^p, the gates at this node (each taken in [0, 1]).
462
+ const kk0 = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
463
+ const gated = link.gates ?? [], q0 = 1 + n + port.surface.length;
464
+ const open = gated.length ? this._open(gated, (q) => u[b * M + q0 + q]) : 1, kk = kk0 * open;
465
+ res[R[o]] -= v * kk * deta;
466
+ this._j(b, r, b, r, v * kk);
467
+ flux[i] += v * kk * deta;
468
+ if (z[i] !== 0) {
469
+ B[R[o]] += (-v * kk * z[i]) / VT;
470
+ C[R[o]] += -zF * v * kk;
471
+ this.termDI[k] += (zF * v * kk * z[i]) / VT;
472
+ }
473
+ for (const [q, p] of gated) {
474
+ const d = this._dOpen(gated, q, p, (qq) => u[b * M + q0 + qq]);
475
+ if (d === 0) continue;
476
+ this._j(b, r, b, q0 + q, -v * kk0 * deta * d);
477
+ if (z[i] !== 0) C[R[b * M + q0 + q]] += zF * v * kk0 * deta * d;
478
+ }
479
+ }
480
+ }
481
+ if (holds) {
482
+ for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
483
+ return;
484
+ }
485
+ port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
486
+ if (port.capacitance) this._portCapacitance(port, k, dt);
487
+ if (port.gates.length > 0) this._portGates(port, k, dt);
488
+ if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
489
+ }
490
+
491
+ // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
492
+ // the electrode's carrier at the port's level (activity 1), times the electrode's area per
493
+ // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
494
+ // species it makes and consumes, and the port's current.
495
+ _portReaction(port, rx, area, rates, flux, k) {
496
+ const { n, M, u, uLo, c, res, z, VT, nSurf, th } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol, RT = this.model.RT;
497
+ const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA, surf = port.surface;
498
+ // The carrier's part of the affinity, and how it moves with the port's voltage.
499
+ let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
500
+ for (const p of rx.part) {
501
+ if (p.side === 1) {
502
+ aV -= p.nu * this.portEta(port, p.i);
503
+ daV -= (p.nu * z[p.i]) / VT;
504
+ } else if (p.side === 0) q += p.nu * z[p.i];
505
+ }
506
+ const slot = (p) => (p.side === 2 ? 1 + n + p.s : 1 + p.i);
507
+ port.nodes.forEach((g, w) => {
508
+ const b = this.blockOfNode[g], s = vol[g] * area[w];
509
+ d.fill(0);
510
+ let pref = rx.k0, aHi = aV, aLo = 0;
511
+ for (const p of rx.part) {
512
+ if (p.side === 1) continue;
513
+ const o = b * M + slot(p);
514
+ aHi -= p.nu * u[o];
515
+ aLo -= p.nu * uLo[o];
516
+ const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
517
+ if (p.side === 2) {
518
+ // a surface species at its coverage θ = θ₀e^ζ, with ∂ln θ_s/∂η_x = δ_sx − θ_x
519
+ pref *= powr(th[g * nSurf + p.s], e);
520
+ d[1 + n + p.s] += e;
521
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= e * th[g * nSurf + x];
522
+ } else {
523
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
524
+ this._dlnc(g, p.i, e, d);
525
+ }
526
+ }
527
+ if (rx.sites) {
528
+ // the bare sites its transition state holds: θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
529
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= rx.sites * th[g * nSurf + x];
530
+ pref *= powr(this.th0[g], rx.sites);
531
+ }
532
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
533
+ const rate = pref * bv;
534
+ rates[w] = rate;
535
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η = −ν
536
+ for (let t = 0; t < M; t++) d[t] *= rate;
537
+ for (const p of rx.part) if (p.side !== 1) d[slot(p)] -= pref * gp * p.nu;
538
+ const dV = pref * gp * daV;
539
+ for (const p of rx.part) {
540
+ if (p.side === 1) continue;
541
+ // the region's species per volume of the window; a surface's per area of electrode
542
+ const row = slot(p), o = b * M + row, f = p.side === 2 ? 1 : s;
543
+ res[R[o]] -= f * p.nu * rate;
544
+ if (p.side === 0) flux[p.i] += s * p.nu * rate;
545
+ for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -f * p.nu * d[t]);
546
+ B[R[o]] -= f * p.nu * dV;
547
+ }
548
+ for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
549
+ this.termDI[k] += q * F * s * dV;
550
+ });
551
+ }
552
+
553
+ // A membrane port's gates, at each node of its window: (x − x_old)/dt = α(1 − x) − βx in the
554
+ // voltage across its capacitance there, φ − (V − zeroCharge).
555
+ _portGates(port, k, dt) {
556
+ const { n, res, VT } = this, R = this.rix, B = this.termB[k], store = Number.isFinite(dt) ? 1 / dt : 0, q0 = 1 + n + port.surface.length;
557
+ port.nodes.forEach((g, w) => {
558
+ const b = this.blockOfNode[g], V = this._portVoltage(k - 2, g);
559
+ port.gates.forEach((gate, q) => {
560
+ const o = b * this.M + q0 + q, x = this.u[o], j = this.portGateIndex[k - 2] + w * port.gates.length + q;
561
+ const [a, da] = gateRate(gate.alpha, V), [bb, db] = gateRate(gate.beta, V);
562
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - bb * x);
563
+ this._j(b, q0 + q, b, q0 + q, store + a + bb);
564
+ const s = -(da * (1 - x) - db * x); // ∂/∂V_m, with V_m = V_T φ̂ − V + zeroCharge
565
+ this._j(b, q0 + q, b, 0, s * VT);
566
+ B[R[o]] += -s;
567
+ });
568
+ });
569
+ }
570
+
571
+ // Π x^p over a link's gates (each x taken in [0, 1]), and its derivative in gate q.
572
+ _open(gated, x) {
573
+ let open = 1;
574
+ for (const [q, p] of gated) open *= powi(Math.min(1, Math.max(0, x(q))), p);
575
+ return open;
576
+ }
577
+
578
+ _dOpen(gated, q, p, x) {
579
+ const xq = x(q);
580
+ if (!(xq > 0 && xq < 1)) return 0;
581
+ let others = 1;
582
+ for (const [q2, p2] of gated) if (q2 !== q) others *= powi(Math.min(1, Math.max(0, x(q2))), p2);
583
+ return p * powi(xq, p - 1) * others;
584
+ }
585
+
586
+ // A capacitance spread through a port's window: per area of electrode, σ = C (V − zeroCharge − φ)
587
+ // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
588
+ // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
589
+ // contact sets is left to the contact.
590
+ _portCapacitance(port, k, dt) {
591
+ const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
592
+ const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
593
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
594
+ // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
595
+ // charge it keeps in place of that (see captureLaws in src/lawRows.js).
596
+ const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
597
+ let Q = 0, dQdV = 0;
598
+ port.nodes.forEach((g, w) => {
599
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
600
+ const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
601
+ const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
602
+ this.qg[g] += area[w] * Cs * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
603
+ this.qgSlope[g] += area[w] * Cs;
604
+ Q += q;
605
+ dQdV += s;
606
+ res[R[b * M]] -= q;
607
+ this._j(b, 0, b, 0, s * VT);
608
+ B[R[b * M]] -= s;
609
+ if (rate > 0) Ct[R[b * M]] += -s * VT * rate;
610
+ });
611
+ this.portQ[k - 2] = Q;
612
+ if (rate > 0) {
613
+ this.termI[k] += (Q - (dt < Infinity ? this.portQStart[k - 2] : this.chargeRefs.get(k))) * rate;
614
+ this.termDI[k] += dQdV * rate;
615
+ }
616
+ }
617
+
618
+ // An electrode surface's storage, per area of electrode: Γ (θ_s − θ_s,old)/dt in each surface
619
+ // species' row, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
620
+ _surfaceStorage(port, dt) {
621
+ const { n, M, res, nSurf, th, thOld } = this, R = this.rix, surf = port.surface, G = surf[0].capacity;
622
+ for (const g of port.nodes) {
623
+ const b = this.blockOfNode[g];
624
+ // Near full coverage θ − θ_old cancels (both ~1, the change ~θ₀): the dominant species'
625
+ // change comes from the small ones instead, Δθ = −Δθ₀ − Σ_others Δθ.
626
+ let top = 0;
627
+ for (let q = 1; q < surf.length; q++) if (th[g * nSurf + q] > th[g * nSurf + top]) top = q;
628
+ for (let q = 0; q < surf.length; q++) {
629
+ const tq = th[g * nSurf + q];
630
+ let change = tq - thOld[g * nSurf + q];
631
+ if (q === top && tq > 0.5) {
632
+ change = -(this.th0[g] - this.th0Old[g]);
633
+ for (let x = 0; x < surf.length; x++) if (x !== q) change -= th[g * nSurf + x] - thOld[g * nSurf + x];
634
+ }
635
+ res[R[b * M + 1 + n + q]] += (G * change) / dt;
636
+ for (let x = 0; x < surf.length; x++) this._j(b, 1 + n + q, b, 1 + n + x, ((G * tq) / dt) * ((q === x ? 1 : 0) - th[g * nSurf + x]));
637
+ }
638
+ }
639
+ }
640
+
641
+ // One contact (terminal 0 or 1): record the flux through its outer face and the current into
642
+ // the device (read from the end box before anything here is added: whatever the box needs
643
+ // comes through the contact), then add its exchange terms, its equilibrium links and its φ law.
644
+ _contact(side, dt) {
645
+ const R = this.rix;
646
+ const { model, n, M, u, uLo, res, c, z, VT } = this;
647
+ const F = FARADAY;
648
+ const ct = model.contacts[side];
649
+ const k = side === 'left' ? 0 : 1, Vt = this.termV[k];
650
+ const B = this.termB[k], C = this.termC[k];
651
+ const g = side === 'left' ? 0 : this.nNodes - 1;
652
+ const b = this.blockOfNode[g];
653
+ const sgn = side === 'left' ? 1 : -1;
654
+ const flux = this.contactFlux[side];
655
+ const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
656
+ const dyn = Number.isFinite(dt);
657
+
658
+ // Before anything is added, each balance residual is the flux into the device here. (A
659
+ // closed end solved in charge rows has its ions' storage left out until later: none of them
660
+ // passes, so their flux is zero.)
661
+ const closedIons = this.combining && dyn && this.chargeNode[g] === 1;
662
+ let I = 0;
663
+ const held = this.portHeldEnd.subarray(k * n, (k + 1) * n);
664
+ for (let i = 0; i < n; i++) {
665
+ const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0) && !held[i];
666
+ const nIn = active ? res[R[b * M + 1 + i]] : 0;
667
+ flux[i] = sgn * nIn; // (toward +x)
668
+ if (active && z[i] !== 0) {
669
+ I += z[i] * F * nIn;
670
+ this._captureRow(b, 1 + i, z[i] * F, C, k);
671
+ }
672
+ }
673
+ // The ions' storage, left out until now (see lateStorage): in each balance, and in what
674
+ // each species brings in, but not in the current.
675
+ if (this.combining && dyn && this.lateStorage[g] === 1) {
676
+ const v = model.grid.vol[g], { cOld } = this;
677
+ for (let i = 0; i < n; i++) {
678
+ if (z[i] === 0 || this.loc[b * M + 1 + i] < 0 || held[i]) continue;
679
+ const ci = c[g * n + i], st = (v * (ci - cOld[g * n + i])) / dt;
680
+ res[R[b * M + 1 + i]] += st;
681
+ flux[i] += sgn * st;
682
+ this._j(b, 1 + i, b, 1 + i, (v * ci) / dt);
683
+ this._j(b, 1 + i, b, 0, (-v * z[i] * ci) / dt);
684
+ }
685
+ }
686
+
687
+ // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
688
+ // links (neutral species): N_in = k (μ_out − μ)/RT; velocity links, v (c_eq − c).
689
+ for (let i = 0; i < n; i++) {
690
+ const link = ct.species[i];
691
+ const o = b * M + 1 + i;
692
+ if (link.type === 'exchange') {
693
+ res[R[o]] -= Ac * link.k * (link.mu / model.RT - u[o] - uLo[o]);
694
+ this._j(b, 1 + i, b, 1 + i, Ac * link.k);
695
+ } else if (link.type === 'conductance') {
696
+ const Vi = (VT * (u[o] + uLo[o])) / z[i];
697
+ res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
698
+ this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
699
+ B[R[o]] += (-Ac * link.G) / (z[i] * F);
700
+ } else if (link.type === 'velocity') {
701
+ // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
702
+ const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - u[o] - uLo[o], 700));
703
+ const N = Ac * link.v * ci * (E - 1), d = this.dA;
704
+ res[R[o]] -= N;
705
+ d.fill(0);
706
+ this._dlnc(g, i, 1, d);
707
+ for (let s = 0; s <= n; s++) if (d[s] !== 0) this._j(b, 1 + i, b, s, -N * d[s]);
708
+ this._j(b, 1 + i, b, 1 + i, Ac * link.v * ci * E);
709
+ if (z[i] !== 0) B[R[o]] += (-Ac * link.v * ci * E * z[i]) / VT;
710
+ }
711
+ }
712
+
713
+ // Equilibrium links: Dirichlet on the known outside level.
714
+ for (let i = 0; i < n; i++) {
715
+ const link = ct.species[i];
716
+ if (link.type !== 'equilibrium') continue;
717
+ const o = b * M + 1 + i;
718
+ this._replaceRow(b, 1 + i);
719
+ for (const Bk of this.termB) Bk[R[o]] = 0; // (a port's terms in the row replaced)
720
+ this._j(b, 1 + i, b, 1 + i, 1);
721
+ res[R[o]] = u[o] - this.contactEta(side, i) + uLo[o]; // (the big terms first: u + uLo rounds to u)
722
+ if (z[i] !== 0) B[R[o]] += -z[i] / VT;
723
+ }
724
+
725
+ // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
726
+ // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
727
+ const link = ct.phi, Dstart = sgn * this.contactDStart[side];
728
+ // (In a steady solve, what a law in this circuit row counts of D_in: see applyConstraints in
729
+ // src/lawRows.js.)
730
+ const q = !dyn && this.steady && this.contactLaw[k] ? (this.contactQ[k] ??= { row: new Float64Array(this.sys.size + 1), D: 0, dV: 0 }) : null;
731
+ if (q) {
732
+ q.row.fill(0);
733
+ q.D = 0;
734
+ q.dV = 0;
735
+ }
736
+ if (this.phiUndefined[g]) {
737
+ this.contactD[side] = 0;
738
+ } else if (link.type === 'capacitive') {
739
+ // Gate, or metal across a Stern layer, at φ_g = V_t − zeroCharge. D toward +x.
740
+ const Din = Ac * link.C * (Vt - link.zeroCharge - VT * u[b * M]);
741
+ res[R[b * M]] -= Din;
742
+ this._j(b, 0, b, 0, Ac * link.C * VT);
743
+ B[R[b * M]] += -Ac * link.C;
744
+ this.contactD[side] = sgn * Din;
745
+ if (q && this.loc[b * M] >= 0) [q.D, q.row[R[b * M]], q.dV] = [Din, -Ac * link.C * VT, Ac * link.C];
746
+ // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
747
+ // the charge it keeps in place of that (see captureLaws in src/lawRows.js).
748
+ const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
749
+ if (rate > 0) {
750
+ I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
751
+ C[R[b * M]] += -Ac * link.C * VT * rate;
752
+ this.termDI[k] += Ac * link.C * rate;
753
+ }
754
+ } else if (link.type === 'pinned' || link.type === 'bulk') {
755
+ // The Poisson residual is the outside's charge, D_in.
756
+ const Din = res[R[b * M]];
757
+ this.contactD[side] = sgn * Din;
758
+ if (q) {
759
+ q.D = Din;
760
+ this._captureRow(b, 0, 1, q.row);
761
+ }
762
+ if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
763
+ I += (Din - Dstart) / dt;
764
+ this._captureRow(b, 0, 1 / dt, C, k);
765
+ } else if (!dyn && this.steady && this.chargeRefs.has(k)) {
766
+ I += Din - this.chargeRefs.get(k);
767
+ this._captureRow(b, 0, 1, C, k);
768
+ }
769
+ this._replaceRow(b, 0);
770
+ for (const Bk of this.termB) Bk[R[b * M]] = 0;
771
+ if (link.type === 'pinned') {
772
+ // φ_edge = V_t − zeroCharge (the C → ∞ limit).
773
+ this._j(b, 0, b, 0, 1);
774
+ res[R[b * M]] = u[b * M] - (Vt - link.zeroCharge) / VT + uLo[b * M];
775
+ B[R[b * M]] += -1 / VT;
776
+ } else {
777
+ // Plain bulk: local neutrality.
778
+ let q = this.rhoFixed[g], dq = 0;
779
+ for (let i = 0; i < n; i++) {
780
+ const kk = g * n + i;
781
+ if (!this.present[kk]) continue;
782
+ const Kz = this._Kz(g, i);
783
+ q += F * z[i] * c[kk];
784
+ dq -= F * z[i] * Kz;
785
+ if (Kz !== 0) this._j(b, 0, b, 1 + i, F * Kz);
786
+ }
787
+ this._j(b, 0, b, 0, dq);
788
+ res[R[b * M]] = q;
789
+ }
790
+ } else {
791
+ this.contactD[side] = 0;
792
+ }
793
+ this.termI[k] = I;
794
+ }
795
+ }