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.
package/src/regions.js ADDED
@@ -0,0 +1,1009 @@
1
+ // The equations inside the regions: each node's statistics (c from the levels), its storage and
2
+ // space charge, the fluxes along each segment, and the bulk reactions; and the strictly neutral
3
+ // nodes' rows in better-conditioned terms.
4
+ //
5
+ // CONTRACT
6
+ //
7
+ // Methods of the Solver, called by assemble() (src/assemble.js, whose contract sets the rows'
8
+ // conventions: a balance row is storage + flux out − flux in + consumed − made, the Gauss row
9
+ // Σ D out − vol·ρ, totals over the box) and its bookkeeping. They read the state u + uLo (φ̂, η),
10
+ // the per-node data the Solver's constructor set (cRef, mu0hat, rhoFixed, present,
11
+ // nodeMaterial, nodeIdeal, nodeConductor, sheetFace, phiUndefined, chargeNode, lateStorage, the
12
+ // bulk reactions rxs), cOld, and write res and sys through _j or by local index (the dilute fast
13
+ // paths), or their terms into this.lin where it's set.
14
+ //
15
+ // Statistics, computeConcentrations(): at every node, from ζ_i = η_i − μ°_i/RT − z_i φ̂ (η
16
+ // compensated, u + uLo; φ̂ as u alone):
17
+ // ideal: c_i = cRef_i e^ζ_i, and K = diag(c) (not stored);
18
+ // non-ideal: each material's statistics models (src/statistics.js) give c and K = ∂c/∂ζ
19
+ // (n×n at each node, symmetric), and ex_i = ζ_i − ln(c_i/cRef_i) the excess chemical
20
+ // potential; a species no model holds is ideal there;
21
+ // a conductor's node: no bulk concentration; its carrier's c is the excess charge of a charged
22
+ // face's sheet, sheetSign·D·A/(zF·vol);
23
+ // absent species: c = 0; then the coverages (_coverages: Langmuir, θ_s = e^{ζ_s}/(1 + Σe^ζ)).
24
+ // ∂c_i/∂η_j = K_ij, ∂c_i/∂φ̂ = −(Kz)_i (_Kz; _dlnc adds these as ∂ln c, _dc sets them as ∂c).
25
+ // At a non-ideal node of a region with eddy mixing (nodeDK), K's slope dK = ∂K/∂ζ as well.
26
+ //
27
+ // The terms:
28
+ // node (dilute, concentrated): storage vol·(c − c_old)/dt in each present species' row, and
29
+ // −vol·ρ in the Gauss row (none where φ is undefined, or, where the node's charge rows will
30
+ // carry them, at a chargeNode on a time step: there only a neutral species' storage); at a
31
+ // lateStorage node on a time step the ions' storage is left to the contact (_contact); a
32
+ // species a port holds at a device end node keeps its storage here.
33
+ // node (conductor): its carrier's storage (a charged face's sheet, through that face's D).
34
+ // displacement: D = −ε V_T (φ̂_R − φ̂_L)/h toward +x, out of the left box and into the right;
35
+ // none where ε = 0 or φ is undefined at the segment's left node.
36
+ // Scharfetter–Gummel (dilute): N = −(D_i/h) B(Δ) c_L expm1(Δη), Δ = z Δφ̂ − Pe, Δη the
37
+ // compensated η_R − η_L − Pe, Pe = v h/D_i (advection at the region's velocity; flow is
38
+ // planar only, so h is the segment's length): exactly zero where μ̄ is level and v = 0, and
39
+ // exact in the cancellation of drift against diffusion. Past a drift Δ of SG_FAR (300), the
40
+ // same flux from the downstream end, g B(−Δ) c_R expm1(−Δη) (sgFlux in src/bernoulli.js):
41
+ // B(Δ) and expm1(Δη) underflow and overflow there, toward −x at a large Péclet number.
42
+ // concentrated: the same with Δ = zΔφ̂ + Δex − Pe, and c_L, ex through K at both ends.
43
+ // conductor: the flux J in slot 0 of the segment's left node, its row η_R − η_L + J/g = 0,
44
+ // g = σRT/(z²F²h) (Ohm's law), and J out of the left node's carrier balance, into the right's.
45
+ // eddy mixing: N = −(D_mix/h) P̄ Δη, P̄ = K̄ − (K̄z)(K̄z)ᵀ/(zᵀK̄z) over the mobile species: no
46
+ // current, and zero where μ̄ is level. Between ideal ends K̄ = C̄, the logarithmic means of
47
+ // the two ends' c, and a neutral species' mixing is exactly −D_mix Δc/h; elsewhere
48
+ // (_mixCrowded) K̄ = ½(R_L K_L R_L + R_R K_R R_R), R = diag(√(c̄/c)), and in a neutral
49
+ // solution every species' mixing is −D_mix Δc/h to second order (eddies mix concentration).
50
+ // bulk reaction at a node: rate r = P(1 − e^{−a}), a = fixedA + Σ ν η (reactants ν > 0,
51
+ // products ν < 0; compensated), P = k_f Π c^ν over reactants (times generationScale for a
52
+ // generation); or SRH (_srhRate) where the material has an SRH law, its derivatives through
53
+ // ln c and the excess potentials ex = ζ − ln c (_srhInto: exact for any statistics, the
54
+ // carriers' coupled or not). vol·ν·r in each
55
+ // participant's row. bulkRate, bulkForward and bulkOneWay read the same rate, its forward
56
+ // one-way part, and the larger one-way part, at the present state.
57
+ // charge rows (_chargeRows, after the laws, on a time step where combining): at each
58
+ // chargeNode, the balance of the charged species with the largest z²c becomes Σ (z_i/z_k)
59
+ // × the charged balances (current continuity, without storage); the columns become φ̂' and
60
+ // η'_i = η_i − z_i φ̂' (in every block row and termC that reads them; _untransform maps an
61
+ // update back); then the other charged species' storage and the neutrality row, Σ z F c +
62
+ // ρ_fixed = 0 (with a port's capacitance there, its charge, and the ions' storage that
63
+ // follows from it in the combined row).
64
+ //
65
+ // Invariants (each says what a reviewer should be able to check here):
66
+ // 1. Every term's Jacobian entries are its residual's derivatives (via c(ζ) and K); in
67
+ // difference form (this.lin), the same terms, each on differences of δη and δφ̂. One
68
+ // exception: crowded mixing's K̄ needs K's own slope, which no statistics model gives, so it
69
+ // comes from central differences of the model (_statSlopes: near 1e-10 of K).
70
+ // 2. A segment's flux goes into its two nodes' rows with opposite signs, the same number.
71
+ // 3. At a level μ̄ along a segment (Δη = 0, no velocity), its flux and mixing are exactly 0;
72
+ // at a = 0 a bulk reaction's rate is exactly 0.
73
+ // 4. computeConcentrations writes c, K, ex, th, th0, dK at the nodeDK nodes (and scratch:
74
+ // zeta, the statistics models' work arrays) and nothing else; the kernels write res,
75
+ // sys (or lin), termB/termC (the charge rows), and scratch kept on the Solver (dA, dB, jL,
76
+ // jR, sg, mixWork, mixCrowd) or cached on a material (mobile).
77
+ // 5. The charge rows are an invertible transformation of the equations at the node (the same
78
+ // solution), and _untransform inverts the column change exactly.
79
+ // Tests: test/jacobian.test.js (1), test/layout.test.js (difference form), test/srh.test.js,
80
+ // test/statistics.test.js, the validation suite (analytic profiles: test/equilibrium.test.js,
81
+ // test/junction.test.js, test/neutral.test.js and others).
82
+
83
+ import { sgFlux } from './bernoulli.js';
84
+ import { EPS0, FARADAY } from './constants.js';
85
+ import { powi } from './pow.js';
86
+
87
+ // The logarithmic mean of a and b = a·e^l, a·f(l) with f = expm1(l)/l, into cb[k], with
88
+ // ∂/∂a = f − f′ and ∂/∂b = a f′/b into dLa[k] and dLb[k].
89
+ function logMean(a, b, l, cb, dLa, dLb, k) {
90
+ let f, fp;
91
+ if (Math.abs(l) < 1e-3) {
92
+ f = 1 + l / 2 + (l * l) / 6 + (l * l * l) / 24;
93
+ fp = 0.5 + l / 3 + (l * l) / 8 + (l * l * l) / 30;
94
+ } else {
95
+ const em = Math.expm1(l);
96
+ f = em / l;
97
+ fp = (l * (em + 1) - em) / (l * l);
98
+ }
99
+ cb[k] = a * f;
100
+ dLa[k] = f - fp;
101
+ dLb[k] = b > 0 ? (a * fp) / b : 0.5;
102
+ }
103
+
104
+ // _statSlopes' step in ζ.
105
+ const STAT_STEP = 1e-5;
106
+
107
+ // _mixCrowded's work arrays.
108
+ function mixWork(n, M) {
109
+ const W = {};
110
+ for (const k of ['a', 'b', 'cb', 'dLa', 'dLb', 'rL', 'rR', 'de', 'zc', 'g', 'kz', 'N', 'tg', 'tkz', 'tr', 'tcbr']) W[k] = new Float64Array(n);
111
+ for (const k of ['KL', 'KR', 'Kb', 'tK']) W[k] = new Float64Array(n * n);
112
+ W.Jl = new Float64Array(n * (n + 1));
113
+ W.Jr = new Float64Array(n * (n + 1));
114
+ W.dcs = new Float64Array(n * M);
115
+ return W;
116
+ }
117
+
118
+ export class RegionTerms {
119
+ // Each electrode surface's coverages from its η: Langmuir on shared sites, θ_s = e^{ζ_s}/(1 + Σ e^{ζ}),
120
+ // ζ = η − μ°/RT (so θ_s/θ₀ = e^{ζ_s}), at every node of a surfaced window.
121
+ _coverages() {
122
+ const { n, M, u, uLo, nSurf, th } = this, RT = this.model.RT;
123
+ for (let g = 0; g < this.nNodes; g++) {
124
+ const k = this.surfPort[g];
125
+ if (k < 0) continue;
126
+ const surf = this.model.ports[k].surface, b = this.blockOfNode[g];
127
+ let m = 0;
128
+ for (let q = 0; q < surf.length; q++) m = Math.max(m, u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT);
129
+ let sum = Math.exp(-m);
130
+ for (let q = 0; q < surf.length; q++) sum += (th[g * nSurf + q] = Math.exp(u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT - m));
131
+ for (let q = 0; q < surf.length; q++) th[g * nSurf + q] /= sum;
132
+ this.th0[g] = Math.exp(-m) / sum;
133
+ }
134
+ }
135
+
136
+ computeConcentrations() {
137
+ const { n, M, u, uLo, c, z } = this;
138
+ for (let g = 0; g < this.nNodes; g++) {
139
+ const b = this.blockOfNode[g];
140
+ const phiHat = u[b * M];
141
+ if (!this.nodeIdeal[g]) {
142
+ this._nodeStatistics(g, b, phiHat);
143
+ continue;
144
+ }
145
+ const im = this.nodeConductor[g];
146
+ if (im >= 0) {
147
+ // Excess carriers: zero in the bulk, the surface sheet at a charged face.
148
+ for (let i = 0; i < n; i++) c[g * n + i] = 0;
149
+ const f = this.sheetFace[g];
150
+ if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M] * this.model.grid.area[g]) / (z[im] * FARADAY * this.model.grid.vol[g]);
151
+ continue;
152
+ }
153
+ for (let i = 0; i < n; i++) {
154
+ const k = g * n + i;
155
+ c[k] = this.present[k] ? this.cRef[k] * Math.exp(u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat) : 0;
156
+ }
157
+ }
158
+ this._coverages();
159
+ }
160
+
161
+ _work(md) {
162
+ let w = this.scratch.get(md);
163
+ if (!w) {
164
+ const k = md.idx.length;
165
+ w = { z: new Float64Array(k), c: new Float64Array(k), K: new Float64Array(k * k), fixed: new Uint8Array(k), t: new Float64Array(k) };
166
+ this.scratch.set(md, w);
167
+ }
168
+ return w;
169
+ }
170
+
171
+ // c, K and the excess at a node of a non-ideal material.
172
+ _nodeStatistics(g, b, phiHat) {
173
+ const { n, M, u, uLo, c, z, K, ex, zeta } = this;
174
+ const mat = this.model.materials[this.nodeMaterial[g]];
175
+ const Kg = g * n * n;
176
+ K.fill(0, Kg, Kg + n * n);
177
+ for (let i = 0; i < n; i++) {
178
+ const k = g * n + i;
179
+ ex[k] = 0;
180
+ if (!this.present[k]) {
181
+ c[k] = 0;
182
+ continue;
183
+ }
184
+ zeta[i] = u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat;
185
+ if (mat.modelOf[i] < 0) {
186
+ c[k] = this.cRef[k] * Math.exp(zeta[i]);
187
+ K[Kg + i * n + i] = c[k];
188
+ }
189
+ }
190
+ for (const md of mat.models) {
191
+ const w = this._work(md), idx = md.idx, k = idx.length;
192
+ for (let a = 0; a < k; a++) w.z[a] = zeta[idx[a]];
193
+ md.evaluate(w.z, w.c, w.K, this.background[g]);
194
+ for (let a = 0; a < k; a++) {
195
+ const i = idx[a], ci = w.c[a];
196
+ c[g * n + i] = ci;
197
+ ex[g * n + i] = ci > 0 ? zeta[i] - Math.log(ci / this.cRef[g * n + i]) : 0;
198
+ for (let q = 0; q < k; q++) K[Kg + i * n + idx[q]] = w.K[a * k + q];
199
+ }
200
+ }
201
+ if (this.nodeDK?.[g]) this._statSlopes(g, mat);
202
+ }
203
+
204
+ // K's own slope at node g, dK[g·n³ + (i·n + j)·n + r] = ∂K_ij/∂ζ_r, which no statistics model
205
+ // gives: by central differences of each model's evaluate, a step of 1e-5 in ζ (truncation
206
+ // and round-off near 1e-10 of K). Only where crowded eddy mixing needs it (nodeDK).
207
+ _statSlopes(g, mat) {
208
+ const { n, c, dK, zeta } = this;
209
+ const o = g * n * n * n;
210
+ dK.fill(0, o, o + n * n * n);
211
+ for (let i = 0; i < n; i++) if (this.present[g * n + i] && mat.modelOf[i] < 0) dK[o + (i * n + i) * n + i] = c[g * n + i];
212
+ for (const md of mat.models) {
213
+ const w = this._work(md), idx = md.idx, k = idx.length;
214
+ w.zp ??= new Float64Array(k);
215
+ w.cp ??= new Float64Array(k);
216
+ w.Kp ??= new Float64Array(k * k);
217
+ w.Km ??= new Float64Array(k * k);
218
+ for (let r = 0; r < k; r++) {
219
+ for (const [sgn, Kx] of [[1, w.Kp], [-1, w.Km]]) {
220
+ for (let a = 0; a < k; a++) w.zp[a] = zeta[idx[a]];
221
+ w.zp[r] += sgn * STAT_STEP;
222
+ md.evaluate(w.zp, w.cp, Kx, this.background[g]);
223
+ }
224
+ for (let a = 0; a < k; a++) {
225
+ for (let q = 0; q < k; q++) dK[o + (idx[a] * n + idx[q]) * n + idx[r]] = (w.Kp[a * k + q] - w.Km[a * k + q]) / (2 * STAT_STEP);
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ // Σ_j K_ij z_j at node g (= z_i c_i for ideal statistics).
232
+ _Kz(g, i) {
233
+ const { n, z } = this;
234
+ if (this.nodeIdeal[g]) return z[i] * this.c[g * n + i];
235
+ let s = 0;
236
+ const o = g * n * n + i * n;
237
+ for (let j = 0; j < n; j++) s += this.K[o + j] * z[j];
238
+ return s;
239
+ }
240
+
241
+ /** zᵀKz at node g (per RT): the charge response that sets the screening length. */
242
+ screening(g) {
243
+ let s = 0;
244
+ for (let i = 0; i < this.n; i++) s += this.z[i] * this._Kz(g, i);
245
+ return s;
246
+ }
247
+
248
+ // Add w·∂(ln c_i)/∂(slot) at node g into d (slot 0 = φ̂, 1 + j = η_j).
249
+ _dlnc(g, i, w, d) {
250
+ const { n, z } = this;
251
+ if (this.nodeIdeal[g]) {
252
+ d[1 + i] += w;
253
+ d[0] -= w * z[i];
254
+ return;
255
+ }
256
+ const ci = this.c[g * n + i];
257
+ if (!(ci > 0)) return;
258
+ const o = g * n * n + i * n, f = w / ci;
259
+ for (let j = 0; j < n; j++) d[1 + j] += f * this.K[o + j];
260
+ d[0] -= f * this._Kz(g, i);
261
+ }
262
+
263
+ // A conductor node: only its carrier's balance (the segment flux J has its own row, in
264
+ // _segmentConductor). Its bulk stores nothing; a charged face's sheet sits in the edge node.
265
+ _nodeConductor(g, dt) {
266
+ const { n, M, res, c, cOld, z } = this, R = this.rix;
267
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g], im = this.nodeConductor[g];
268
+ const r = 1 + im, k = g * n + im;
269
+ res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
270
+ const f = this.sheetFace[g];
271
+ if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, (this.sheetSign[g] * this.model.grid.area[g]) / (z[im] * FARADAY * dt));
272
+ }
273
+
274
+ // Whether a port holds species i at node g, a device end node (see portHeldEnd).
275
+ _portHeldAt(g, i) {
276
+ return g === 0 ? this.portHeldEnd[i] === 1 : g === this.nNodes - 1 && this.portHeldEnd[this.n + i] === 1;
277
+ }
278
+
279
+ // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
280
+ // charge in the Gauss row, written straight into the diagonal blocks by local index. A
281
+ // dielectric is the case with no species.
282
+ _nodesDilute(g0, g1, dt) {
283
+ const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode, lin } = this, R = this.rix, F = FARADAY;
284
+ const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
285
+ for (let g = g0; g <= g1; g++) {
286
+ const b = blockOfNode[g], v = vol[g];
287
+ const m = sizes[b], oB = offB[b], lb = b * M, p = loc[lb]; // p: φ's row, −1 where undefined
288
+ if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
289
+ // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
290
+ for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
291
+ continue;
292
+ }
293
+ let q = rhoFixed[g], dq = 0;
294
+ const late = this.combining && this.lateStorage[g] === 1 && dt !== Infinity; // (the ions' storage: in _contact)
295
+ for (let i = 0; i < n; i++) {
296
+ const k = g * n + i;
297
+ if (!present[k]) continue;
298
+ const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0 && !this._portHeldAt(g, i));
299
+ if (stores) res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
300
+ q += F * z[i] * ck;
301
+ dq += F * z[i] * z[i] * ck;
302
+ if (lin) {
303
+ lin.node(R[lb + 1 + i], R[lb], z[i], stores ? v * ck : 0, -v * F * z[i] * ck);
304
+ continue;
305
+ }
306
+ if (stores) {
307
+ JB[oB + l * m + l] += (v * ck) / dt;
308
+ if (p >= 0 && z[i] !== 0) JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
309
+ }
310
+ if (p >= 0 && z[i] !== 0) JB[oB + p * m + l] += -v * F * z[i] * ck;
311
+ }
312
+ if (p >= 0) {
313
+ res[R[lb]] -= v * q;
314
+ if (!lin) JB[oB + p * m + p] += v * dq;
315
+ }
316
+ }
317
+ }
318
+
319
+ // A concentrated node (any statistics): ∂c_i/∂η_j = K_ij and ∂c_i/∂φ̂ = −(Kz)_i.
320
+ _nodeConcentrated(g, dt) {
321
+ const { n, M, res, c, z } = this, R = this.rix, F = FARADAY;
322
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g];
323
+ if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
324
+ // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
325
+ for (let i = 0; i < n; i++) if (this.present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
326
+ return;
327
+ }
328
+ let q = this.rhoFixed[g], dq = 0;
329
+ const lin = this.lin;
330
+ for (let i = 0; i < n; i++) {
331
+ const k = g * n + i, r = 1 + i;
332
+ if (!this.present[k]) continue;
333
+ const Kz = this._Kz(g, i);
334
+ if (lin) {
335
+ // In difference form (see DifferenceTerms): storage of i and its charge, in each
336
+ // δη_j − z_j δφ̂ through K_ij.
337
+ res[R[b * M + r]] += (v * (c[k] - this.cOld[k])) / dt;
338
+ q += F * z[i] * c[k];
339
+ const Kg = g * n * n + i * n;
340
+ for (let j = 0; j < n; j++) {
341
+ const Kij = this.nodeIdeal[g] ? (i === j ? c[k] : 0) : this.K[Kg + j];
342
+ if (Kij !== 0) lin.node(R[b * M + 1 + j], R[b * M], z[j], v * Kij, -v * F * z[i] * Kij, R[b * M + r]);
343
+ }
344
+ continue;
345
+ }
346
+ this._storage(g, i, dt, Kz);
347
+ q += F * z[i] * c[k];
348
+ dq += F * z[i] * Kz;
349
+ if (Kz !== 0) this._j(b, 0, b, r, -v * F * Kz); // K symmetric: ∂(Σ z c)/∂η_i = (Kz)_i
350
+ }
351
+ // (where no charge responds and no field reaches, φ isn't defined, nor an unknown)
352
+ if (!this.phiUndefined[g]) {
353
+ res[R[b * M]] -= v * q;
354
+ if (!lin) this._j(b, 0, b, 0, v * dq);
355
+ }
356
+ }
357
+
358
+ // Species i's storage at node g, v(c − c_old)/dt, in its balance row and the Jacobian
359
+ // (∂c_i/∂η_j = K_ij, ∂c_i/∂φ̂ = −(Kz)_i; for ideal statistics K is diagonal, c_i).
360
+ _storage(g, i, dt, Kz = this._Kz(g, i)) {
361
+ const { n, M, res, c, cOld } = this, R = this.rix;
362
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g], k = g * n + i, r = 1 + i;
363
+ res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
364
+ if (this.nodeIdeal[g]) this._j(b, r, b, r, (v * c[k]) / dt);
365
+ else {
366
+ const Kg = g * n * n;
367
+ for (let j = 0; j < n; j++) {
368
+ const Kij = this.K[Kg + i * n + j];
369
+ if (Kij !== 0) this._j(b, r, b, 1 + j, (v * Kij) / dt);
370
+ }
371
+ }
372
+ if (Kz !== 0 && !this.phiUndefined[g]) this._j(b, r, b, 0, (-v * Kz) / dt);
373
+ }
374
+
375
+ // Strictly neutral nodes on a transient step, solved in better-conditioned terms. There, a
376
+ // change of φ̂ with every η_i shifted by z_i times it leaves every concentration as it was, so
377
+ // storage and neutrality don't see it; but in (φ̂, η) they see it as pairs of huge entries that
378
+ // cancel, and round-off in that cancellation (storage/flux ~ h²/(D·dt), times the range of
379
+ // concentrations: a trace ion beside 3 M KCl) swamps the fluxes that do fix it. So, at each node
380
+ // that stays neutral (see chargeNode), with storage and neutrality left out of assembly:
381
+ // - rows: the balance of the most abundant charged species (by z²c) becomes Σ (z_i/z_k) × each
382
+ // balance, which without storage is current continuity;
383
+ // - columns: the unknowns become φ̂' and η'_i = η_i − z_i φ̂ (newton() maps the update back),
384
+ // in which storage and neutrality have no φ̂' term at all;
385
+ // - then storage (on the other balances) and neutrality go in, exactly, in those terms.
386
+ // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
387
+ // row holds the new state neutral.
388
+ _chargeRows(dt) {
389
+ const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY, live = this._liveTerms();
390
+ const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
391
+ for (const g of this.chargeNodes) {
392
+ const b = this.blockOfNode[g], lb = b * M, m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < last ? sizes[b + 1] : 0;
393
+ const p = loc[lb], gn = g * n;
394
+ let k = -1, best = -1;
395
+ for (let i = 0; i < n; i++) {
396
+ if (!present[gn + i] || z[i] === 0) continue;
397
+ const w = z[i] * z[i] * c[gn + i];
398
+ if (w > best) {
399
+ k = i;
400
+ best = w;
401
+ }
402
+ }
403
+ if (k < 0 || p < 0) continue;
404
+ // Rows: the pivot's balance becomes the z-weighted sum (all still without storage).
405
+ const lk = loc[lb + 1 + k], rk = R[lb + 1 + k];
406
+ for (let i = 0; i < n; i++) {
407
+ if (i === k || !present[gn + i] || z[i] === 0) continue;
408
+ const li = loc[lb + 1 + i], w = z[i] / z[k], ri = R[lb + 1 + i];
409
+ for (let q = 0, o = offA[b] + lk * mp, s = offA[b] + li * mp; q < mp; q++) A[o + q] += w * A[s + q];
410
+ for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
411
+ for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
412
+ res[rk] += w * res[ri];
413
+ for (const t of live) termB[t][rk] += w * termB[t][ri];
414
+ if (this.lin) this.lin.combine(rk, ri, w);
415
+ }
416
+ // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
417
+ // unknowns appear (its own rows, its neighbours', a terminal's current).
418
+ for (let i = 0; i < n; i++) {
419
+ if (!present[gn + i] || z[i] === 0) continue;
420
+ const li = loc[lb + 1 + i], zi = z[i];
421
+ for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
422
+ if (b > 0) for (let r = 0, mr = mp, o = offC[b - 1]; r < mr; r++) C[o + r * m + p] += zi * C[o + r * m + li];
423
+ if (b < last) for (let r = 0, mr = mn, o = offA[b + 1]; r < mr; r++) A[o + r * m + p] += zi * A[o + r * m + li];
424
+ for (const t of live) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
425
+ }
426
+ // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
427
+ const v = vol[g], oB = offB[b];
428
+ let q = this.rhoFixed[g];
429
+ if (this.nodeIdeal[g]) {
430
+ for (let i = 0; i < n; i++) {
431
+ if (!present[gn + i] || z[i] === 0) continue;
432
+ const ci = c[gn + i], li = loc[lb + 1 + i];
433
+ if (i !== k) {
434
+ res[R[lb + 1 + i]] += (v * (ci - cOld[gn + i])) / dt;
435
+ B[oB + li * m + li] += (v * ci) / dt;
436
+ }
437
+ q += F * z[i] * ci;
438
+ B[oB + p * m + li] += -v * F * z[i] * ci;
439
+ }
440
+ } else {
441
+ for (let i = 0; i < n; i++) {
442
+ if (!present[gn + i]) continue;
443
+ if (i !== k && z[i] !== 0) this._storage(g, i, dt, 0);
444
+ q += F * z[i] * c[gn + i];
445
+ const Kz = this._Kz(g, i); // (nonzero for a neutral species only on a shared lattice)
446
+ if (Kz !== 0) this._j(b, 0, b, 1 + i, -v * F * Kz);
447
+ }
448
+ }
449
+ res[R[lb]] -= v * q;
450
+ // The pivot's row (charge continuity) left storage out, the ions' net charge being constant
451
+ // under neutrality. Against a capacitance it isn't: neutrality makes it −(aσ + ρ_fixed)/F,
452
+ // so their storage is that less what the step started with, Σ z c_old, over dt (in units
453
+ // of the pivot's balance).
454
+ if (this.qgTerms[g].length > 0) {
455
+ const f = -v / (FARADAY * z[k] * dt);
456
+ let qOld = this.rhoFixed[g];
457
+ for (let i = 0; i < n; i++) if (present[gn + i]) qOld += F * z[i] * cOld[gn + i];
458
+ res[rk] += f * (this.qg[g] + qOld);
459
+ B[offB[b] + lk * m + p] += -f * this.qgSlope[g] * this.VT;
460
+ for (const [kq, w] of this.qgTerms[g]) termB[kq][rk] += f * this.portArea[kq - 2][w] * this.model.ports[kq - 2].capacitance.C;
461
+ }
462
+ }
463
+ }
464
+
465
+ // Newton's update in the transformed unknowns (see _chargeRows) back in η: η_i = η'_i + z_i φ̂'.
466
+ _untransform(delta) {
467
+ const { n, M, z, loc, present } = this, R = this.rix;
468
+ for (const g of this.chargeNodes) {
469
+ const lb = this.blockOfNode[g] * M;
470
+ if (loc[lb] < 0) continue;
471
+ const dphi = delta[R[lb]];
472
+ for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] !== 0) delta[R[lb + 1 + i]] += z[i] * dphi;
473
+ }
474
+ }
475
+
476
+ // Bulk reactions at node g: r = k_f Π c_R^ν · (−expm1(−a)), with a = A/RT from the
477
+ // (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
478
+ _bulkReactions(g, rxs) {
479
+ const { n, M, u, uLo, res, c } = this, R = this.rix;
480
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g];
481
+ const dr = this.dA;
482
+ for (let x = 0; x < rxs.length; x++) {
483
+ const rx = rxs[x];
484
+ const law = rx.srh?.[this.nodeMaterial[g]];
485
+ if (law) {
486
+ // SRH: dr is ∂rate/∂slot directly, from its sensitivities to ln n, ln p, a and the excesses.
487
+ let aHi = rx.fixedA, aLo = 0;
488
+ for (let p = 0; p < rx.sp.length; p++) {
489
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
490
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
491
+ }
492
+ const { rate, Cn, Cp, Ca, Cxn, Cxp } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law));
493
+ dr.fill(0);
494
+ this._srhInto(g, law.n.i, Cn, Cxn, dr);
495
+ this._srhInto(g, law.p.i, Cp, Cxp, dr);
496
+ for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Ca * rx.nu[p];
497
+ for (let p = 0; p < rx.sp.length; p++) {
498
+ const row = 1 + rx.sp[p], w = v * rx.nu[p];
499
+ res[R[b * M + row]] += w * rate;
500
+ for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
501
+ }
502
+ continue;
503
+ }
504
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
505
+ dr.fill(0); // ∂ ln P / ∂slot
506
+ // Participants: reactants with ν > 0, then products with ν < 0.
507
+ for (let p = 0; p < rx.sp.length; p++) {
508
+ const i = rx.sp[p], nu = rx.nu[p];
509
+ if (nu > 0) {
510
+ const ci = c[g * n + i];
511
+ P *= nu === 1 ? ci : powi(ci, nu);
512
+ this._dlnc(g, i, nu, dr);
513
+ }
514
+ aHi += nu * u[b * M + 1 + i];
515
+ aLo += nu * uLo[b * M + 1 + i];
516
+ }
517
+ const a = aHi + aLo;
518
+ const f = -Math.expm1(-a); // 1 − e^{−a}
519
+ const rate = P * f;
520
+ const Pd = P * (1 - f); // P·df/da, df/da = e^{−a}
521
+ // ∂rate/∂slot = rate·∂lnP + P·f′·∂a
522
+ for (let k = 0; k < M; k++) dr[k] *= rate;
523
+ for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Pd * rx.nu[p];
524
+ for (let p = 0; p < rx.sp.length; p++) {
525
+ const row = 1 + rx.sp[p], w = v * rx.nu[p];
526
+ res[R[b * M + row]] += w * rate;
527
+ for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
528
+ }
529
+ }
530
+ }
531
+
532
+ // Displacement along a segment where φ is defined: D = −ε(φ_R − φ_L)/h into the two Gauss rows.
533
+ _segmentDisplacement(s, bL, bR, mat, h) {
534
+ const { M, u, res, sys, loc } = this, R = this.rix;
535
+ const k = this.phiUndefined[s] ? 0 : (mat.epsr * EPS0 * this.VT) / h; // ε = 0: no displacement
536
+ if (k === 0) return;
537
+ const D = -k * (u[bR * M] - u[bL * M]);
538
+ const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
539
+ res[R[bL * M]] += D;
540
+ res[R[bR * M]] -= D;
541
+ if (this.lin) return this.lin.displacement(R[bL * M], R[bR * M], k);
542
+ sys.B[sys.offB[bL] + pL * mL + pL] += k;
543
+ sys.C[sys.offC[bL] + pL * mR + pR] -= k;
544
+ sys.A[sys.offA[bR] + pR * mL + pL] -= k;
545
+ sys.B[sys.offB[bR] + pR * mR + pR] += k;
546
+ }
547
+
548
+ // Scharfetter–Gummel fluxes along a dilute region's segments (nodes g0…g1), written
549
+ // by local index. The flux
550
+ // N = g[B(Δ)c_L − B(−Δ)c_R] is rewritten with B(−Δ) = B(Δ)e^Δ and c_R e^Δ = c_L e^{Δη} as
551
+ // N = −g·B(Δ)·c_L·expm1(Δη). This is precise relative to the quasi-Fermi difference Δη, so tiny
552
+ // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
553
+ // two huge drift and diffusion terms.
554
+ _segmentsDilute(g0, g1, region, mat, vel) {
555
+ const { n, M, u, uLo, res, c, z, sys, loc, lin } = this, R = this.rix, sg = this.sg;
556
+ const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
557
+ const { segLength, segArea } = this.model.grid;
558
+ for (let s = g0; s < g1; s++) {
559
+ const bL = s + region, bR = bL + 1, h = segLength[s] / segArea[s]; // (fluxes are totals through the cross-section)
560
+ const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
561
+ const phiL = u[bL * M], phiR = u[bR * M];
562
+ for (let i = 0; i < n; i++) {
563
+ if (!mat.present[i] || mat.D[i] === 0) continue;
564
+ const r = 1 + i, zi = z[i];
565
+ const cL = c[s * n + i];
566
+ const g = mat.D[i] / h;
567
+ const pe = (vel * h) / mat.D[i]; // advection: a Péclet shift of the drift potential
568
+ const d = zi * (phiR - phiL) - pe;
569
+ const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
570
+ sgFlux(g, d, deta, cL, c[(s + 1) * n + i], sg);
571
+ const N = sg[0], dNdd = sg[1];
572
+ const dNdEtaL = sg[2];
573
+ const dNdEtaR = -sg[3];
574
+ const dNdPhiL = -zi * dNdd - zi * N; // via Δ, and via c_L ∝ e^{−zφ̂_L}
575
+ const dNdPhiR = zi * dNdd;
576
+ res[R[bL * M + r]] += N;
577
+ res[R[bR * M + r]] -= N;
578
+ if (lin) {
579
+ lin.segment(R[bL * M + r], R[bR * M + r], R[bL * M], R[bR * M], zi, N, dNdEtaR, zi * dNdd);
580
+ continue;
581
+ }
582
+ // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
583
+ const lL = loc[bL * M + r], lR = loc[bR * M + r];
584
+ const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
585
+ JB[oBL + lL] += dNdEtaL;
586
+ JC[oCL + lR] += dNdEtaR;
587
+ JA[oAR + lL] -= dNdEtaL;
588
+ JB[oBR + lR] -= dNdEtaR;
589
+ if (zi !== 0 && pL >= 0) {
590
+ JB[oBL + pL] += dNdPhiL;
591
+ JC[oCL + pR] += dNdPhiR;
592
+ JA[oAR + pL] -= dNdPhiL;
593
+ JB[oBR + pR] -= dNdPhiR;
594
+ }
595
+ }
596
+ }
597
+ }
598
+
599
+ // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
600
+ // extra potential, linear along the segment like φ, so Δ = zΔφ̂ + Δex and
601
+ // N = −(D/h)·B(Δ)·c_L·expm1(η_R − η_L),
602
+ // still exactly zero at equilibrium. c_L and ex depend on every ζ at their node through K.
603
+ _segmentConcentrated(s, bL, bR, mat, h, vel) {
604
+ const R = this.rix;
605
+ const { n, M, res, z, K } = this;
606
+ const KL = s * n * n, KR = (s + 1) * n * n;
607
+ for (let i = 0; i < n; i++) {
608
+ if (!mat.present[i] || mat.D[i] === 0) continue;
609
+ const r = 1 + i, zi = z[i];
610
+ const { N, gBcE1, dNdd, fL, cR } = this._fluxConcentrated(s, bL, bR, mat, h, vel, i);
611
+ res[R[bL * M + r]] += N;
612
+ res[R[bR * M + r]] -= N;
613
+ if (this.lin) {
614
+ // In difference form (see DifferenceTerms): b on Δη_i and z_i·∂N/∂d on Δφ̂, and the rest
615
+ // as side terms in each δη_j − z_j δφ̂ at either end (c_L and the excess potentials).
616
+ const rL = R[bL * M + r], rR = R[bR * M + r], pL = R[bL * M], pR = R[bR * M];
617
+ this.lin.segment(rL, rR, pL, pR, zi, 0, -gBcE1, zi * dNdd);
618
+ for (let j = 0; j < n; j++) {
619
+ const wL = fL * K[KL + i * n + j] - (j === i ? dNdd : 0);
620
+ const wR = cR > 0 ? dNdd * ((j === i ? 1 : 0) - K[KR + i * n + j] / cR) : 0;
621
+ if (wL !== 0) this.lin.side(rL, rR, R[bL * M + 1 + j], pL, z[j], wL);
622
+ if (wR !== 0) this.lin.side(rL, rR, R[bR * M + 1 + j], pR, z[j], wR);
623
+ }
624
+ continue;
625
+ }
626
+ const { jL, jR } = this;
627
+ for (let k = 0; k < M; k++) {
628
+ if (jL[k] !== 0) {
629
+ this._j(bL, r, bL, k, jL[k]);
630
+ this._j(bR, r, bL, k, -jL[k]);
631
+ }
632
+ if (jR[k] !== 0) {
633
+ this._j(bL, r, bR, k, jR[k]);
634
+ this._j(bR, r, bR, k, -jR[k]);
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ // Species i's flux across segment s of a non-ideal region, toward +x, and its derivatives in
641
+ // this.jL and this.jR (over the unknowns of the blocks bL and bR).
642
+ _fluxConcentrated(s, bL, bR, mat, h, vel, i) {
643
+ const { n, M, u, uLo, c, z, K, ex, jL, jR } = this;
644
+ const gL = s, gR = s + 1, KL = gL * n * n, KR = gR * n * n, r = 1 + i, zi = z[i];
645
+ const cL = c[gL * n + i], cR = c[gR * n + i];
646
+ const g = mat.D[i] / h;
647
+ const pe = (vel * h) / mat.D[i];
648
+ const d = zi * (u[bR * M] - u[bL * M]) + ex[gR * n + i] - ex[gL * n + i] - pe;
649
+ const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
650
+ const sg = sgFlux(g, d, deta, cL, cR, this.sg);
651
+ const N = sg[0], dNdd = sg[1], gBcE1 = sg[3]; // (gBcE1 = g·B·c_L·e^{Δη})
652
+ // Left node: ∂N/∂ζ_Lj = −gE(B + B′)K_L,ij − dNdd·δ_ij, plus the direct η_L,i term.
653
+ const fL = sg[4];
654
+ jL[0] = -fL * this._Kz(gL, i);
655
+ for (let j = 0; j < n; j++) jL[1 + j] = fL * K[KL + i * n + j];
656
+ jL[r] += -dNdd + gBcE1;
657
+ // Right node: ∂N/∂ζ_Rj = dNdd·(δ_ij − K_R,ij/c_R), plus the direct η_R,i term.
658
+ jR.fill(0);
659
+ if (cR > 0) {
660
+ const fR = -dNdd / cR;
661
+ jR[0] = -fR * this._Kz(gR, i);
662
+ for (let j = 0; j < n; j++) jR[1 + j] = fR * K[KR + i * n + j];
663
+ } else {
664
+ jR[0] = dNdd * zi; // K/c → δ as c → 0
665
+ jR[r] -= dNdd;
666
+ }
667
+ jR[r] += dNdd - gBcE1;
668
+ return { N, gBcE1, dNdd, fL, cR };
669
+ }
670
+
671
+ // Ohmic conduction in a metal, in mixed form. The flux J (slot 0 of the left node) obeys
672
+ // η_R − η_L + J/g = 0, g = σ RT/(z²F² h) (Ohm's law, J = −g Δη),
673
+ // and enters the two nodes' carrier balances as outflow and inflow.
674
+ _segmentConductor(s, bL, bR, mat, h) {
675
+ const R = this.rix;
676
+ const { M, u, uLo, res, z } = this;
677
+ const i = mat.conductor.i, r = 1 + i;
678
+ const g = (mat.conductor.sigma * this.model.RT) / (z[i] * z[i] * FARADAY * FARADAY * h);
679
+ const J = u[bL * M];
680
+ res[R[bL * M]] = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) + J / g;
681
+ this._j(bL, 0, bR, r, 1);
682
+ this._j(bL, 0, bL, r, -1);
683
+ this._j(bL, 0, bL, 0, 1 / g);
684
+ res[R[bL * M + r]] += J;
685
+ res[R[bR * M + r]] -= J;
686
+ this._j(bL, r, bL, 0, 1);
687
+ this._j(bR, r, bL, 0, -1);
688
+ }
689
+
690
+ // Eddy mixing: N = −(D_mix/RT) P ∇μ̄ with P = K − (Kz)(Kz)ᵀ/(zᵀKz), K = ∂c/∂ζ. It mixes
691
+ // composition without carrying current (zᵀP = 0) and vanishes exactly at equilibrium; in a
692
+ // neutral solution it's −D_mix ∇c for every species, under any statistics (eddies carry parcels
693
+ // of fluid, so they mix concentration). On a segment, N_i = −(D_mix/h) Σ_j P̄_ij Δη_j, with
694
+ // P̄ from K̄ = C̄, the logarithmic mean of each c, where both ends are ideal (so K̄ Δζ = Δc
695
+ // exactly), and from the secant K̄ of _mixCrowded otherwise. Only mobile species (D > 0) take part.
696
+ _segmentMixing(s, bL, bR, Dm, h, mat) {
697
+ const R = this.rix;
698
+ const { n, M, u, uLo, res, c, z } = this;
699
+ const gL = s, gR = s + 1, k0 = Dm / h;
700
+ const on = mat.mobile ?? (mat.mobile = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0));
701
+ if (on.length === 0) return;
702
+ if (!this.nodeIdeal[gL] || !this.nodeIdeal[gR]) return this._mixCrowded(gL, gR, bL, bR, on, k0);
703
+ const w = this.mixWork ?? (this.mixWork = [0, 1, 2, 3].map(() => new Float64Array(n)));
704
+ const [cb, dLa, dLb, de] = w;
705
+ let S = 0, Q = 0;
706
+ for (const k of on) {
707
+ const a = c[gL * n + k], b = c[gR * n + k];
708
+ // l = ln(c_R/c_L), from the compensated potentials (statistics ideal at both ends)
709
+ const l = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]) - z[k] * (u[bR * M] - u[bL * M]);
710
+ logMean(a, b, l, cb, dLa, dLb, k);
711
+ de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
712
+ S += z[k] * z[k] * cb[k];
713
+ Q += z[k] * cb[k] * de[k];
714
+ }
715
+ const q = S > 0 ? Q / S : 0;
716
+ const { jL, jR } = this;
717
+ const dcL = this.dA, dcR = this.dB; // ∂c_k/∂slot at each end
718
+ for (const i of on) {
719
+ const r = 1 + i;
720
+ const N = -k0 * cb[i] * (de[i] - z[i] * q);
721
+ jL.fill(0);
722
+ jR.fill(0);
723
+ for (const j of on) {
724
+ // direct dependence on Δη_j
725
+ const P = cb[i] * ((i === j ? 1 : 0) - (S > 0 ? (z[i] * z[j] * cb[j]) / S : 0));
726
+ jL[1 + j] += k0 * P;
727
+ jR[1 + j] -= k0 * P;
728
+ }
729
+ // through c̄_k: ∂a_i/∂c̄_k = δ_ik (Δη_i − z_i q) − c̄_i z_i (z_k Δη_k − q z_k²)/S
730
+ for (const k of on) {
731
+ let da = (i === k ? de[i] - z[i] * q : 0) - (S > 0 ? (cb[i] * z[i] * (z[k] * de[k] - q * z[k] * z[k])) / S : 0);
732
+ if (da === 0) continue;
733
+ da *= -k0;
734
+ this._dc(gL, k, dcL);
735
+ this._dc(gR, k, dcR);
736
+ for (let t = 0; t < M; t++) {
737
+ jL[t] += da * dLa[k] * dcL[t];
738
+ jR[t] += da * dLb[k] * dcR[t];
739
+ }
740
+ }
741
+ res[R[bL * M + r]] += N;
742
+ res[R[bR * M + r]] -= N;
743
+ for (let t = 0; t < M; t++) {
744
+ if (jL[t] !== 0) {
745
+ this._j(bL, r, bL, t, jL[t]);
746
+ this._j(bR, r, bL, t, -jL[t]);
747
+ }
748
+ if (jR[t] !== 0) {
749
+ this._j(bL, r, bR, t, jR[t]);
750
+ this._j(bR, r, bR, t, -jR[t]);
751
+ }
752
+ }
753
+ }
754
+ }
755
+
756
+ // Eddy mixing where either end is crowded (non-ideal): N = −k0 P̄ Δη as in the dilute case,
757
+ // with C̄ replaced by K̄ = ½(R_L K_L R_L + R_R K_R R_R), R = diag(√(c̄/c)) at each end (c̄ the
758
+ // logarithmic mean): each end's K rescaled to the segment's mean concentrations, and averaged.
759
+ // It's C̄ where statistics are ideal, symmetric and positive definite, and smooth in the state,
760
+ // so P̄ keeps everything the dilute term has (no current, exactly zero at a level μ̄); and in
761
+ // a neutral solution it gives −k0 Δc to second order in the segment's Δζ. Its Jacobian runs
762
+ // through K's own slope, dK (_statSlopes).
763
+ _mixCrowded(gL, gR, bL, bR, on, k0) {
764
+ const R = this.rix;
765
+ const { n, M, res } = this;
766
+ const m = on.length, W = this._mixBase(gL, gR, bL, bR, on);
767
+ const { N, a, b, cb, dLa, dLb, rL, rR, KL, KR, Kb, de, zc, kz, tK, tg, tkz, tr, dcs } = W;
768
+ const { S, Q } = W;
769
+ const Jl = W.Jl, Jr = W.Jr; // ∂N_p/∂slot t at either end, m × (n + 1)
770
+ for (const end of [0, 1]) {
771
+ const gX = end ? gR : gL, cX = end ? b : a, rX = end ? rR : rL, KX = end ? KR : KL, J = end ? Jr : Jl;
772
+ const dLX = end ? dLb : dLa, sgn = end ? 1 : -1;
773
+ for (let q = 0; q < m; q++) this._dc(gX, on[q], dcs.subarray(q * M, (q + 1) * M));
774
+ for (let t = 0; t <= n; t++) {
775
+ // the tangent of K̄ along slot t: through c at this end (its own ρ and c̄) and K there
776
+ for (let p = 0; p < m; p++) {
777
+ const tc = dcs[p * M + t], tcb = dLX[p] * tc;
778
+ tr[p] = 0.5 * rX[p] * (tcb / cb[p] - tc / cX[p]); // tρ at this end
779
+ W.tcbr[p] = tcb; // and c̄'s, which moves the other end's ρ too
780
+ }
781
+ for (let p = 0; p < m; p++) {
782
+ for (let q = 0; q < m; q++) {
783
+ const dk = this._dKslot(gX, on[p], on[q], t);
784
+ const rO = end ? rL : rR, KO = end ? KL : KR;
785
+ const trO_p = 0.5 * rO[p] * (W.tcbr[p] / cb[p]), trO_q = 0.5 * rO[q] * (W.tcbr[q] / cb[q]);
786
+ tK[p * m + q] =
787
+ 0.5 * ((tr[p] * rX[q] + rX[p] * tr[q]) * KX[p * m + q] + rX[p] * rX[q] * dk) +
788
+ 0.5 * (trO_p * rO[q] + rO[p] * trO_q) * KO[p * m + q];
789
+ }
790
+ }
791
+ for (let p = 0; p < m; p++) {
792
+ let x = 0, y = 0;
793
+ for (let q = 0; q < m; q++) {
794
+ x += tK[p * m + q] * de[q] + (t === 1 + on[q] ? sgn * Kb[p * m + q] : 0);
795
+ y += tK[p * m + q] * zc[q];
796
+ }
797
+ tg[p] = x;
798
+ tkz[p] = y;
799
+ }
800
+ let tS = 0, tQ = 0;
801
+ for (let p = 0; p < m; p++) {
802
+ tS += zc[p] * tkz[p];
803
+ tQ += zc[p] * tg[p];
804
+ }
805
+ for (let p = 0; p < m; p++) {
806
+ J[p * (n + 1) + t] = -(S > 0 ? tg[p] - (tkz[p] * Q + kz[p] * tQ) / S + (kz[p] * Q * tS) / (S * S) : tg[p]);
807
+ }
808
+ }
809
+ }
810
+ for (let p = 0; p < m; p++) {
811
+ const r = 1 + on[p], Ni = k0 * N[p];
812
+ res[R[bL * M + r]] += Ni;
813
+ res[R[bR * M + r]] -= Ni;
814
+ for (let t = 0; t <= n; t++) {
815
+ const vl = k0 * Jl[p * (n + 1) + t], vr = k0 * Jr[p * (n + 1) + t];
816
+ if (vl !== 0) {
817
+ this._j(bL, r, bL, t, vl);
818
+ this._j(bR, r, bL, t, -vl);
819
+ }
820
+ if (vr !== 0) {
821
+ this._j(bL, r, bR, t, vr);
822
+ this._j(bR, r, bR, t, -vr);
823
+ }
824
+ }
825
+ }
826
+ }
827
+
828
+ // ∂K_ij/∂(slot t) at node g: through ζ, slot 1 + r moving ζ_r and slot 0 (φ̂) every ζ_r by −z_r.
829
+ _dKslot(g, i, j, t) {
830
+ const { n, z } = this;
831
+ if (this.nodeIdeal[g]) {
832
+ if (i !== j) return 0;
833
+ const ci = this.c[g * n + i];
834
+ return t === 0 ? -z[i] * ci : t === 1 + i ? ci : 0;
835
+ }
836
+ const o = g * n * n * n + (i * n + j) * n;
837
+ if (t > 0) return this.dK[o + t - 1];
838
+ let v = 0;
839
+ for (let r = 0; r < n; r++) v -= this.dK[o + r] * z[r];
840
+ return v;
841
+ }
842
+
843
+ // _mixCrowded's flux per k0 on a segment, into W.N (indexed as on), and what its Jacobian
844
+ // needs. Also the solution's readout (segmentFluxes).
845
+ _mixBase(gL, gR, bL, bR, on) {
846
+ const { n, M, u, uLo, c, z } = this;
847
+ const m = on.length;
848
+ const W = this.mixCrowd ?? (this.mixCrowd = mixWork(n, M));
849
+ const { a, b, cb, dLa, dLb, rL, rR, KL, KR, Kb, de, zc, g, kz, N } = W;
850
+ for (let p = 0; p < m; p++) {
851
+ const k = on[p], oL = bL * M + 1 + k, oR = bR * M + 1 + k;
852
+ a[p] = c[gL * n + k];
853
+ b[p] = c[gR * n + k];
854
+ zc[p] = z[k];
855
+ logMean(a[p], b[p], Math.log(b[p] / a[p]), cb, dLa, dLb, p);
856
+ rL[p] = Math.sqrt(cb[p] / a[p]);
857
+ rR[p] = Math.sqrt(cb[p] / b[p]);
858
+ de[p] = u[oR] - u[oL] + (uLo[oR] - uLo[oL]);
859
+ }
860
+ for (let p = 0; p < m; p++) {
861
+ for (let q = 0; q < m; q++) {
862
+ KL[p * m + q] = this._Kat(gL, on[p], on[q]);
863
+ KR[p * m + q] = this._Kat(gR, on[p], on[q]);
864
+ Kb[p * m + q] = 0.5 * (rL[p] * rL[q] * KL[p * m + q] + rR[p] * rR[q] * KR[p * m + q]);
865
+ }
866
+ }
867
+ let S = 0, Q = 0;
868
+ for (let p = 0; p < m; p++) {
869
+ let x = 0, y = 0;
870
+ for (let q = 0; q < m; q++) {
871
+ x += Kb[p * m + q] * de[q];
872
+ y += Kb[p * m + q] * zc[q];
873
+ }
874
+ g[p] = x;
875
+ kz[p] = y;
876
+ }
877
+ for (let p = 0; p < m; p++) {
878
+ S += zc[p] * kz[p];
879
+ Q += zc[p] * g[p];
880
+ }
881
+ W.S = S;
882
+ W.Q = Q;
883
+ for (let p = 0; p < m; p++) N[p] = -(S > 0 ? g[p] - (kz[p] * Q) / S : g[p]);
884
+ return W;
885
+ }
886
+
887
+ // K_ij at node g.
888
+ _Kat(g, i, j) {
889
+ const { n } = this;
890
+ if (this.nodeIdeal[g]) return i === j ? this.c[g * n + i] : 0;
891
+ return this.K[g * n * n + i * n + j];
892
+ }
893
+
894
+ // ∂c_k/∂slot at node g into d (slot 0 = φ̂, 1 + j = η_j).
895
+ _dc(g, k, d) {
896
+ const { n } = this;
897
+ d.fill(0);
898
+ if (this.nodeIdeal[g]) {
899
+ const ck = this.c[g * n + k];
900
+ d[1 + k] = ck;
901
+ d[0] = -this.z[k] * ck;
902
+ return;
903
+ }
904
+ const o = g * n * n + k * n;
905
+ for (let j = 0; j < n; j++) d[1 + j] = this.K[o + j];
906
+ d[0] = -this._Kz(g, k);
907
+ }
908
+
909
+ // A bulk reaction's rate at node g (mol/(m³·s), forward), as assembled: for the solution.
910
+ bulkRate(rx, g) {
911
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
912
+ let aHi = rx.fixedA, aLo = 0;
913
+ for (let p = 0; p < rx.sp.length; p++) {
914
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
915
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
916
+ }
917
+ const law = rx.srh?.[this.nodeMaterial[g]];
918
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law)).rate;
919
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
920
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
921
+ return P * -Math.expm1(-(aHi + aLo));
922
+ }
923
+
924
+ // A bulk reaction's forward one-way rate at node g (mol/(m³·s)); its backward one is that
925
+ // times e^{−a}, so their difference is bulkRate's.
926
+ bulkForward(rx, g) {
927
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
928
+ const law = rx.srh?.[this.nodeMaterial[g]];
929
+ if (law) {
930
+ let a = rx.fixedA;
931
+ for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
932
+ return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).forward;
933
+ }
934
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
935
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
936
+ return P;
937
+ }
938
+
939
+ // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
940
+ // rate is read against (in equilibrium the net rate is their round-off).
941
+ bulkOneWay(rx, g) {
942
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
943
+ let a = rx.fixedA;
944
+ for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
945
+ const law = rx.srh?.[this.nodeMaterial[g]];
946
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).oneWay;
947
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
948
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
949
+ return P * Math.max(1, Math.exp(-a));
950
+ }
951
+
952
+ // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
953
+ // SRH kinetics (see srhLaw in device.js) at electron and hole concentrations cn, cp and affinity
954
+ // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}). n₁p₁ = n p e^{−a}
955
+ // (n_i² for Boltzmann carriers), and under degeneracy each carrier's emission scales with its own
956
+ // γ = e^{−ex} (xn, xp: the carriers' excess chemical potentials): n₁ = γ_n n₁₀ and p₁ = γ_p p₁₀,
957
+ // n₁₀ the law's n1, or n_i at midgap. (γ_n γ_p split evenly onto both, as it was, put a degenerate
958
+ // n⁺ region's γ_n on its holes' emission too.) The rate's partial derivatives in ln n, ln p, a,
959
+ // xn and xp, each with the others held (_srhInto takes them to the unknowns). For large negative
960
+ // a (strong extraction), np·(1 − e^{−a}) as −n p e^{−a}.
961
+ _srhRate(law, cn, cp, a, xn = 0, xp = 0) {
962
+ const { tn, tp } = law, lnnp = Math.log(cn) + Math.log(cp);
963
+ let lnn1, kN = 0, kP = 0, kA = 0, kX = 0; // ln n₁ = kN ln n + kP ln p + kA a + kX (xn − xp) + const
964
+ if (Number.isNaN(law.n1)) {
965
+ lnn1 = (lnnp - a - xn + xp) / 2; // γ_n n_i, with n_i² = n p e^{−a}/(γ_n γ_p)
966
+ kN = 0.5;
967
+ kP = 0.5;
968
+ kA = -0.5;
969
+ kX = -0.5;
970
+ } else {
971
+ lnn1 = Math.log(law.n1) - xn;
972
+ kX = -1; // (and nothing in xp)
973
+ }
974
+ const n1 = Math.exp(lnn1), p1 = Math.exp(lnnp - a - lnn1), np = cn * cp;
975
+ const den = tp * (cn + n1) + tn * (cp + p1);
976
+ // np(1 − e^{−a}), with e^{−a} folded into np where it would overflow
977
+ const f = -Math.expm1(-a), num = a > -700 ? np * f : np - Math.exp(lnnp - a);
978
+ const rate = num / den;
979
+ const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
980
+ const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
981
+ const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
982
+ const dDxn = (tp * n1 - tn * p1) * kX, dDxp = Number.isNaN(law.n1) ? -dDxn : 0;
983
+ return {
984
+ rate,
985
+ forward: np / den,
986
+ oneWay: a > -700 ? (np / den) * Math.max(1, Math.exp(-a)) : Math.exp(lnnp - a) / den,
987
+ Cn: rate * (1 - dDn / den),
988
+ Cp: rate * (1 - dDp / den),
989
+ Ca: (a > -700 ? (np / den) * (1 - f) : Math.exp(lnnp - a) / den) - (rate * dDa) / den,
990
+ Cxn: (-rate * dDxn) / den,
991
+ Cxp: (-rate * dDxp) / den,
992
+ };
993
+ }
994
+
995
+ // _srhRate's excess chemical potentials: the electrons' at node gn, the holes' at gq.
996
+ _srhDegen(gn, gq, law) {
997
+ return [this.nodeIdeal[gn] ? 0 : this.ex[gn * this.n + law.n.i], this.nodeIdeal[gq] ? 0 : this.ex[gq * this.n + law.p.i]];
998
+ }
999
+
1000
+ // Add C·∂(ln c_i) + Cx·∂(ex_i) at node g into d, over its slots: ex_i = ζ_i − ln c_i (0 where
1001
+ // the statistics are ideal), ζ_i = η_i − μ°_i/RT − z_i φ̂.
1002
+ _srhInto(g, i, C, Cx, d) {
1003
+ if (this.nodeIdeal[g] || Cx === 0) return this._dlnc(g, i, C, d);
1004
+ this._dlnc(g, i, C - Cx, d);
1005
+ d[1 + i] += Cx;
1006
+ d[0] -= this.z[i] * Cx;
1007
+ }
1008
+
1009
+ }