driftlet 1.2.1 → 1.2.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/lawRows.js ADDED
@@ -0,0 +1,562 @@
1
+ // The laws in the Newton system: each law src/laws.js finds, evaluated on the state and written
2
+ // into the equations in place of the row it takes, and the linear solve that holds the rows too
3
+ // dense for the block-tridiagonal matrix aside.
4
+ //
5
+ // CONTRACT
6
+ //
7
+ // These are functions of a Solver `s` (src/solver.js), which they read and write; its layout
8
+ // (blocks, slots, row o = block·M + slot, its compact index R = s.rix[o], loc[o] < 0 for an
9
+ // inactive slot, u + uLo, terminals 0, 1, 2 + k) is in docs/internals.md. The laws are as
10
+ // Solver._laws() set them from findLaws: s.constraints, affinities, localConstraints, islands,
11
+ // timeLaws, contactLaw, and s.flatStretches, each meaning what src/laws.js's contract says. The
12
+ // solver's own helpers they call: s._j(rb, r, cb, c, v) adds v at row (rb, r), column (cb, c) of
13
+ // the block matrix s.sys; s._replaceRow(b, r) zeroes that row (all three blocks, and its terms in
14
+ // difference form, s.lin); s._dc(g, i, d) puts ∂c_i/∂(slot) at node g into d; s._solveLinear(rhs,
15
+ // out) solves the factorised s.sys; s._assembleBookkeeping(dt) sets the terminals' charges and
16
+ // currents (portQ, contactD, termI); s.amount(st), s.surfaceAmount(col) a stretch's or surface
17
+ // column's amount.
18
+ //
19
+ // The Newton system (see Solver.newton): J δ = res, the update u −= δ (and termV[k] −= δV for a
20
+ // floating k). It is s.sys (the blocks, residual s.res), and beside it, for each floating
21
+ // terminal k, its column termB[k] (∂ of every row by V_k), its circuit row termC[k], termRes[k]
22
+ // and its ∂/∂V's, termDI[k] (own) and termDX[k][j] (terminal j's); and s.pins.
23
+ //
24
+ // A pin: a law too dense for the block matrix, { row, idx, w, len, res } (and `col`, see below).
25
+ // Its row in s.sys is the identity with residual 0, and the law, Σ_j w_j δ[idx_j] = res, is
26
+ // solved beside it by solveBordered: the pin's multiplier is free, so whatever else that row
27
+ // holds (a terminal's termB entry, say) is absorbed by it. Not scaled by Solver._equilibrate,
28
+ // which scales s.sys's rows, s.res and termB, and runs between assembly and the solve.
29
+ //
30
+ // The functions, and when each runs:
31
+ // captureLaws(s) when a steady solve begins (Solver.solveSteady): s.lawRefs, the amount each
32
+ // constraint fed by a terminal driven by a current holds now (by its key);
33
+ // s.chargeRefs, the charge (portQ, or the contact's ±D) of each terminal
34
+ // driven by a current that passes current only by charging. (A steady solve
35
+ // has such a terminal only at I = 0: Solver._solveSteadyAll stops at any
36
+ // other.) Calls _assembleBookkeeping where it needs the charges.
37
+ // captureLocal(s) when a steady solve's steady system begins (Solver._steadyFromHere): each
38
+ // local constraint's reference[j], its sum at node nodes[j] now.
39
+ // snapFlat(s) in a steady assembly (dt = ∞), before the concentrations: each flat
40
+ // stretch's level (u and uLo) set at every node to its first node's.
41
+ // applyLaws(s, dt) at the end of Solver.assemble(dt), after every kernel and the terminals:
42
+ // dt finite: each circuit row a law of the constraints or timeLaws may read by
43
+ // the change of its amount (circuitLaws). Steady (s.steady and dt = ∞): the
44
+ // rows of the flat stretches, affinities, constraints, local constraints and,
45
+ // once Newton turns them on (s.islandsOn), the islands, in that order; and
46
+ // s.pins, those of them held aside (constraints in a balance row, affinities,
47
+ // islands). Otherwise s.pins is empty.
48
+ // solveBordered(s, rhs, delta, deltaV, pins) each Newton solve: δ (delta, compact) and the
49
+ // floating terminals' δV (deltaV, by s.floating's order) with s.sys
50
+ // factorised.
51
+ //
52
+ // What each row says, as written (the steady rows replace a row whole, with _replaceRow first):
53
+ // flat stretch at each of its nodes, u_o − level = 0, Jacobian 1 on itself (the level:
54
+ // its contact's, the terminal's ∂/∂V in termB; reaching none, its first
55
+ // node's own u, so that row is 0 = 0, and the stretch's amount, a constraint
56
+ // with `col` = its nodes' rows, moves it whole: a unit pin there shifts every
57
+ // row of `col`). A face inside it whose link is an equilibrium: its flux
58
+ // unknown = 0.
59
+ // affinity pin: fixedA − Σ ν η at the face's two nodes = 0, ∂ = −ν.
60
+ // constraint in a balance row, a pin: amount − ref = 0 (ref: its reference without
61
+ // terminals, else lawRefs; with no reference captured there, none, the amount
62
+ // held where it is), its ∂ from lawAmount. In terminal k's circuit row
63
+ // (in place of what Solver._terminals wrote): the same, its contacts' charges
64
+ // λ·D_in/F added from s.contactQ[t] (as Solver._contact captured them in this
65
+ // assembly), termDI and termDX from the electrodes' charges' ∂/∂V.
66
+ // local constraint at each node of `nodes`, Σ w c_i (+ Σ w Γ a θ_q where port k's surface is)
67
+ // − reference = 0, its ∂ in the node's block (a coverage's,
68
+ // θ_q (δ_qx − θ_x) over its surface's species).
69
+ // island pin: Σ weight·u over the `flux` rows + Σ the `outside` rows' residuals = 0,
70
+ // its ∂ the same sums of their Jacobian rows (rowInto), gathered by column.
71
+ // circuit law (time steps) terminal k's circuit row, where it reads by the change: F (S −
72
+ // S*)/(λ dt) − (its drive's current: the source, or (V_src − V)/R), with S*
73
+ // the amount at the step's start (lawStart), chosen once per step per law
74
+ // (s.lawForm) by comparing the round-off of the two readings.
75
+ //
76
+ // Invariants (each says what a reviewer should be able to check here):
77
+ // 1. A row is replaced only where its slot is active (loc ≥ 0), and every entry a row held
78
+ // before is gone: _replaceRow clears the three blocks and s.lin's; a row that isn't a pin
79
+ // (a flat stretch's, a local constraint's) also has its termB entries cleared, where a
80
+ // terminal's ∂/∂V was in it (a port reaction's, in the slots it touches).
81
+ // 2. Each law's residual and its derivatives are of the same function of the state: every
82
+ // term in amount (lawAmount, and the rows above) has its ∂ in idx/w, dV or the block row,
83
+ // and none is counted twice (a metal's carriers by its charged face's D, not its η).
84
+ // 3. lawAmount at a stretch, surface or electrode sums the same quantity lawStart sums at the
85
+ // step's start, and captureLaws at a solve's start: what a step's balance rows change is
86
+ // what a circuit law reads.
87
+ // 4. solveBordered solves the full system: the block rows, each pin's law, and each floating
88
+ // terminal's circuit row, with δV, by the Woodbury identity written at it, exact given the
89
+ // block solves (T⁻¹ applied by the factorised s.sys): its accuracy is theirs.
90
+ // 5. Nothing here writes the laws' structure (their parts, rows, nodes): only the workspace
91
+ // fields src/laws.js leaves to the solver (idx, w, len, res, dV, reference), the solver's
92
+ // maps lawRefs, chargeRefs, lawForm, s.pins and s.pinnedIslands, scratch arrays kept on s
93
+ // (islandRow, linRow, dWork, borderCols), and, in snapFlat alone, the state (u, uLo).
94
+ // Tests: test/laws.test.js, test/steady.test.js and every test that solves a steady state;
95
+ // tools/lawsweep.js (solve() against a settled advance()).
96
+
97
+ import { FARADAY } from './constants.js';
98
+ import { sourceAt } from './device.js';
99
+ import { carrierZ, chargeOnly } from './laws.js';
100
+ import { SolverError } from './solver.js';
101
+
102
+ // The laws' rows at the end of an assembly (see the contract above).
103
+ export function applyLaws(s, dt) {
104
+ if (Number.isFinite(dt) && (s.constraints.length > 0 || s.timeLaws.length > 0)) circuitLaws(s, dt);
105
+ const steady = s.steady && dt === Infinity;
106
+ if (steady) flatRows(s);
107
+ if (steady && s.affinities.length > 0) applyAffinities(s);
108
+ if (steady && s.constraints.length > 0) applyConstraints(s);
109
+ if (steady && s.localConstraints.length > 0) applyLocalConstraints(s);
110
+ s.pinnedIslands = steady && s.islandsOn ? applyIslands(s) : [];
111
+ s.pins = steady ? [...s.constraints.filter((cs) => cs.circuit < 0), ...s.affinities, ...s.pinnedIslands] : [];
112
+ }
113
+
114
+ // What each combination fed by a terminal driven at no current holds now, which a steady solve
115
+ // keeps: a closed battery at open circuit stays at the charge it had.
116
+ export function captureLaws(s) {
117
+ // A terminal at no current that passes current only by charging a capacitance keeps its charge.
118
+ s.chargeRefs.clear();
119
+ const charged = s.floating.filter((k) => s.terms[k].drive.kind === 'I' && chargeOnly(s, k));
120
+ if (charged.length > 0) {
121
+ s._assembleBookkeeping(Infinity);
122
+ for (const k of charged) s.chargeRefs.set(k, k >= 2 ? s.portQ[k - 2] : k === 0 ? s.contactD.left : -s.contactD.right);
123
+ }
124
+ s.lawRefs.clear();
125
+ if (s.constraints.some((cs) => cs.contacts.length > 0 || cs.carriers.some(({ k }) => s.model.ports[k - 2].capacitance)) && charged.length === 0) s._assembleBookkeeping(Infinity);
126
+ for (const cs of s.constraints) {
127
+ if (cs.terminals.length === 0) continue;
128
+ let a = 0;
129
+ for (const { stretch, w } of cs.parts) a += w * s.amount(s.stretches[stretch]);
130
+ for (const { col, w } of cs.surface) a += w * s.surfaceAmount(col);
131
+ for (const { k, w } of cs.carriers) if (s.model.ports[k - 2].capacitance) a += (w * s.portQ[k - 2]) / (carrierZ(s, k) * FARADAY);
132
+ for (const { t, lambda } of cs.contacts) a += (lambda * (t === 0 ? s.contactD.left : -s.contactD.right)) / FARADAY;
133
+ s.lawRefs.set(cs.key, a);
134
+ }
135
+ }
136
+
137
+ // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
138
+ // solve keeps.
139
+ export function captureLocal(s) {
140
+ const { n, c, th, nSurf } = s;
141
+ for (const lc of s.localConstraints) {
142
+ lc.nodes.forEach((g, j) => {
143
+ let t = 0;
144
+ for (const { i, q, k, w } of lc.parts) {
145
+ if (i !== undefined) t += w * c[g * n + i];
146
+ else if (s.surfPort[g] === k) t += w * sites(s, g) * th[g * nSurf + q];
147
+ }
148
+ lc.reference[j] = t;
149
+ });
150
+ }
151
+ }
152
+
153
+ // The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
154
+ // first node's row pinned in the factorised matrix and the sum bordered (solveBordered) like a
155
+ // conserved amount's. The pieces of a flat stretch are left as they are (its rows are flatRows').
156
+ function applyIslands(s) {
157
+ const { u, res } = s, R = s.rix, out = [];
158
+ for (const isl of s.islands) {
159
+ if (s.flatStretches.includes(isl.stretch)) continue;
160
+ const b0 = Math.floor(isl.row / s.M), r0 = isl.row % s.M;
161
+ if (s.loc[isl.row] < 0) continue;
162
+ // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
163
+ // exactly where they meet), then the nonzeros kept.
164
+ const acc = s.islandRow ?? (s.islandRow = new Float64Array(s.sys.size + 1));
165
+ const cols = [];
166
+ const add = (col, v) => {
167
+ if (acc[col] === 0) cols.push(col);
168
+ acc[col] += v;
169
+ };
170
+ let t = 0;
171
+ isl.flux.forEach((o, j) => {
172
+ if (s.loc[o] < 0) return;
173
+ t += isl.weight[j] * u[o];
174
+ add(R[o], isl.weight[j]);
175
+ });
176
+ for (const o of isl.outside) {
177
+ if (s.loc[o] < 0) continue;
178
+ t += res[R[o]];
179
+ rowInto(s, o, add);
180
+ }
181
+ let len = 0;
182
+ for (const col of cols) {
183
+ if (col < s.sys.size && acc[col] !== 0) {
184
+ isl.idx[len] = col;
185
+ isl.w[len++] = acc[col];
186
+ }
187
+ acc[col] = 0;
188
+ }
189
+ isl.len = len;
190
+ isl.res = t;
191
+ s._replaceRow(b0, r0);
192
+ for (const B of s.termB) B[R[isl.row]] = 0;
193
+ s._j(b0, r0, b0, r0, 1);
194
+ res[R[isl.row]] = 0;
195
+ out.push(isl);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
201
+ // kernels' kept aside in difference form (see Solver._assembleDifference) when they are.
202
+ function rowInto(s, o, add) {
203
+ const { sys, M, nB } = s, b = Math.floor(o / M), l = s.loc[o];
204
+ const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
205
+ for (let k = 0; k < m; k++) if (B[offB[b] + l * m + k] !== 0) add(offX[b] + k, B[offB[b] + l * m + k]);
206
+ if (b > 0) for (let k = 0, mp = sizes[b - 1]; k < mp; k++) if (A[offA[b] + l * mp + k] !== 0) add(offX[b - 1] + k, A[offA[b] + l * mp + k]);
207
+ if (b < nB - 1) for (let k = 0, mn = sizes[b + 1]; k < mn; k++) if (C[offC[b] + l * mn + k] !== 0) add(offX[b + 1] + k, C[offC[b] + l * mn + k]);
208
+ if (s.lin) {
209
+ const into = s.linRow ?? (s.linRow = new Float64Array(sys.size + 1));
210
+ s.lin.capture(s.rix[o], 1, 0, into);
211
+ for (let k = 0; k < sys.size; k++) {
212
+ if (into[k] !== 0) add(k, into[k]);
213
+ into[k] = 0;
214
+ }
215
+ }
216
+ }
217
+
218
+ // An electrode surface's sites per volume at node g, Γ a (mol/m³): what a coverage of 1 holds,
219
+ // beside the concentrations of a local constraint. (Summed as θ alone, an immobile S that adsorbs
220
+ // as A(ads) kept S + θ_A, mol/m³ and a fraction.)
221
+ function sites(s, g) {
222
+ const k = s.surfPort[g];
223
+ return s.model.ports[k].surface[0].capacity * s.portArea[k][s.surfW[g]];
224
+ }
225
+
226
+ // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
227
+ function applyLocalConstraints(s) {
228
+ const { n, M, c, th, nSurf, res, dA: d } = s;
229
+ for (const lc of s.localConstraints) {
230
+ lc.nodes.forEach((g, j) => {
231
+ const b = s.blockOfNode[g];
232
+ if (s.loc[b * M + lc.row] < 0) return;
233
+ s._replaceRow(b, lc.row);
234
+ for (const B of s.termB) B[s.rix[b * M + lc.row]] = 0; // (a port reaction's ∂/∂V, where one touched this slot)
235
+ let t = 0;
236
+ for (const { i, q, k, w } of lc.parts) {
237
+ if (i !== undefined) {
238
+ t += w * c[g * n + i];
239
+ s._dc(g, i, d);
240
+ for (let r = 0; r < M; r++) if (d[r] !== 0) s._j(b, lc.row, b, r, w * d[r]);
241
+ continue;
242
+ }
243
+ // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species (where it is).
244
+ if (s.surfPort[g] !== k) continue;
245
+ const tq = th[g * nSurf + q], ns = s.model.ports[s.surfPort[g]].surface.length, ws = w * sites(s, g);
246
+ t += ws * tq;
247
+ for (let x = 0; x < ns; x++) s._j(b, lc.row, b, 1 + n + x, ws * tq * ((q === x ? 1 : 0) - th[g * nSurf + x]));
248
+ }
249
+ res[s.rix[b * M + lc.row]] = t - lc.reference[j];
250
+ });
251
+ }
252
+ }
253
+
254
+ // Conservation rows for the spectators (steady solves only): Σ v c_i = amount over the
255
+ // stretch replaces the balance row of its first node, which in steady state is the negative
256
+ // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
257
+ // (identity row) there instead, and solveBordered restores the constraint.
258
+ function applyConstraints(s) {
259
+ const { M, res } = s;
260
+ for (const cs of s.constraints) {
261
+ const { amount, reference, dV } = lawAmount(s, cs);
262
+ cs.res = amount - (cs.terminals.length > 0 ? (s.lawRefs.get(cs.key) ?? amount) : reference);
263
+ if (cs.circuit >= 0) {
264
+ // In place of the terminal's circuit row (its I = 0).
265
+ const C = s.termC[cs.circuit];
266
+ C.fill(0);
267
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
268
+ for (const { t, lambda } of cs.contacts) {
269
+ // A contact's charge, D_in, as Solver._contact had it, with its derivatives.
270
+ const q = s.contactQ[t], f = lambda / FARADAY;
271
+ cs.res += f * q.D;
272
+ for (let j = 0; j < C.length; j++) C[j] += f * q.row[j];
273
+ dV[t] += f * q.dV;
274
+ }
275
+ s.termRes[cs.circuit] = cs.res;
276
+ s.termDI[cs.circuit] = dV[cs.circuit];
277
+ dV.forEach((x, k) => k !== cs.circuit && (s.termDX[cs.circuit][k] = x));
278
+ continue;
279
+ }
280
+ const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
281
+ s._replaceRow(b0, r0);
282
+ s._j(b0, r0, b0, r0, 1);
283
+ res[s.rix[cs.row]] = 0;
284
+ }
285
+ }
286
+
287
+ // A conserved combination's amount as the state holds it (and its reference, closed), with its
288
+ // derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
289
+ // counts).
290
+ function lawAmount(s, cs) {
291
+ const { n, M, c, dA: d } = s;
292
+ const vol = s.model.grid.vol;
293
+ let amount = 0, reference = 0, len = 0, gross = 0;
294
+ for (const { stretch, w } of cs.parts) {
295
+ const st = s.stretches[stretch], i = st.species;
296
+ if (cs.terminals.length === 0) reference += w * s.referenceAmounts[stretch];
297
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
298
+ amount += w * vol[g] * c[g * n + i];
299
+ gross += Math.abs(w * vol[g] * c[g * n + i]);
300
+ if (s.nodeConductor[g] >= 0) {
301
+ // A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
302
+ // face's displacement.
303
+ const f = s.sheetFace[g];
304
+ if (f >= 0 && s.loc[s.blockOfFace[f] * M] >= 0) {
305
+ cs.idx[len] = s.rix[s.blockOfFace[f] * M];
306
+ cs.w[len++] = (w * s.sheetSign[g] * s.model.grid.area[g]) / (s.z[i] * FARADAY);
307
+ }
308
+ continue;
309
+ }
310
+ s._dc(g, i, d);
311
+ const b = s.blockOfNode[g];
312
+ for (let r = 0; r < M; r++) {
313
+ if (d[r] === 0 || s.loc[b * M + r] < 0) continue;
314
+ cs.idx[len] = s.rix[b * M + r];
315
+ cs.w[len++] = w * vol[g] * d[r];
316
+ }
317
+ }
318
+ }
319
+ // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
320
+ for (const { col, w } of cs.surface) {
321
+ const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
322
+ if (cs.terminals.length === 0) reference += w * s.surfaceRef[col];
323
+ port.nodes.forEach((g, j) => {
324
+ const f = w * G * vol[g] * s.portArea[k][j], b = s.blockOfNode[g], tq = s.th[g * s.nSurf + q];
325
+ amount += f * tq;
326
+ gross += Math.abs(f * tq);
327
+ for (let x = 0; x < ns; x++) {
328
+ cs.idx[len] = s.rix[b * M + 1 + n + x];
329
+ cs.w[len++] = f * tq * ((q === x ? 1 : 0) - s.th[g * s.nSurf + x]);
330
+ }
331
+ });
332
+ }
333
+ // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
334
+ // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as Solver._portCapacitance has it).
335
+ const dV = cs.dV ?? (cs.dV = new Float64Array(s.terms.length));
336
+ dV.fill(0);
337
+ for (const { k, w } of cs.carriers) {
338
+ const port = s.model.ports[k - 2];
339
+ if (!port.capacitance) continue;
340
+ const f = w / (carrierZ(s, k) * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = s.termV[k];
341
+ const sets = (side) => ['pinned', 'bulk'].includes(s.model.contacts[side].phi.type);
342
+ port.nodes.forEach((g, j) => {
343
+ if ((g === 0 && sets('left')) || (g === s.nNodes - 1 && sets('right')) || s.phiUndefined[g]) return;
344
+ const b = s.blockOfNode[g], cap = vol[g] * s.portArea[k - 2][j] * Cs;
345
+ amount += f * cap * (V - zeroCharge - s.VT * (s.u[b * M] + s.uLo[b * M]));
346
+ gross += Math.abs(f * cap * (V - zeroCharge - s.VT * (s.u[b * M] + s.uLo[b * M])));
347
+ dV[k] += f * cap;
348
+ cs.idx[len] = s.rix[b * M];
349
+ cs.w[len++] = -f * cap * s.VT;
350
+ });
351
+ }
352
+ cs.len = len;
353
+ return { amount, reference, dV, gross }; // (dV by terminal)
354
+ }
355
+
356
+ // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
357
+ function lawStart(s, cs) {
358
+ const { n, nSurf } = s, vol = s.model.grid.vol;
359
+ let a = 0;
360
+ for (const { stretch, w } of cs.parts) {
361
+ const st = s.stretches[stretch], i = st.species;
362
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * s.cOld[g * n + i];
363
+ }
364
+ for (const { col, w } of cs.surface) {
365
+ const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity;
366
+ port.nodes.forEach((g, j) => (a += w * G * vol[g] * s.portArea[k][j] * s.thOld[g * nSurf + q]));
367
+ }
368
+ for (const { k, w } of cs.carriers) if (s.model.ports[k - 2].capacitance) a += (w * s.portQStart[k - 2]) / (carrierZ(s, k) * FARADAY);
369
+ return a;
370
+ }
371
+
372
+ // Time steps: a terminal driven by a current that alone feeds a conserved combination can read
373
+ // its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
374
+ // to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
375
+ // in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
376
+ // gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
377
+ // Over long steps the change is the better kept (a closed host at open circuit, read by its
378
+ // flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
379
+ // short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
380
+ // electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
381
+ // its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
382
+ // where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
383
+ // throughout, and where a step read by the flux fails, it's tried once more read by the change
384
+ // (see step).
385
+ function circuitLaws(s, dt) {
386
+ for (const cs of [...s.constraints, ...s.timeLaws]) {
387
+ const k = cs.circuit;
388
+ if (k < 0 || !s.floating.includes(k) || cs.contacts?.length > 0) continue; // (a contact's charge: its own rows)
389
+ const { amount, dV, gross } = lawAmount(s, cs), f = FARADAY / (cs.lambda * dt), C = s.termC[k];
390
+ if (!s.lawForm.has(cs)) {
391
+ let G = 0;
392
+ for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
393
+ s.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
394
+ }
395
+ if (!s.lawForm.get(cs)) continue;
396
+ C.fill(0);
397
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
398
+ const d = s.terms[k].drive, src = sourceAt(d.src, s.sourceTime, s.sourceBefore);
399
+ s.termRes[k] = f * (amount - lawStart(s, cs)) - (d.kind === 'I' ? src : (src - s.termV[k]) / d.R);
400
+ s.termDI[k] = f * dV[k] + (d.kind === 'I' ? 0 : 1 / d.R);
401
+ dV.forEach((x, j) => j !== k && (s.termDX[k][j] = f * x));
402
+ }
403
+ }
404
+
405
+ // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
406
+ // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
407
+ // the rows pinned in T: the spectators' conserved amounts (see applyConstraints) and the
408
+ // islands' summed balances (see applyIslands). By low-rank
409
+ // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
410
+ // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
411
+ // system for the μ (pins) and δV (terminals):
412
+ // pin q: Σ (W_q·Q_q′) μ_q′ − Σ (W_q·X_k) δV_k = res_q − W_q·y
413
+ // terminal k: Σ (C_k·Q_q) μ_q + Σ (δ_kk′ ∂I_k/∂V_k − C_k·X_k′) δV_k′ = res_k − C_k·y
414
+ export function solveBordered(s, rhs, delta, deltaV, pins) {
415
+ const N = s.sys.size, fl = s.floating, P = pins.length, K = fl.length, S = P + K;
416
+ s._solveLinear(rhs, delta);
417
+ if (S === 0) return;
418
+ const e = s.dWork ?? (s.dWork = new Float64Array(N + 1));
419
+ const cols = s.borderCols ?? (s.borderCols = []);
420
+ for (let a = 0; a < S; a++) {
421
+ if (!cols[a]) cols[a] = new Float64Array(N + 1);
422
+ if (a < P) {
423
+ // The response to a unit pin, or where the pin moves a flat stretch, to its shift.
424
+ const col = pins[a].col ?? [pins[a].row];
425
+ for (const o of col) e[s.rix[o]] = 1;
426
+ s._solveLinear(e, cols[a]);
427
+ for (const o of col) e[s.rix[o]] = 0;
428
+ } else s._solveLinear(s.termB[fl[a - P]], cols[a]);
429
+ }
430
+ // Row a of the small system, as a dot product with a compact vector (sparse for pins).
431
+ const dot = (a, v) => {
432
+ let t = 0;
433
+ if (a < P) {
434
+ const p = pins[a];
435
+ for (let j = 0; j < p.len; j++) t += p.w[j] * v[p.idx[j]];
436
+ } else {
437
+ const C = s.termC[fl[a - P]];
438
+ for (let j = 0; j < N; j++) t += C[j] * v[j];
439
+ }
440
+ return t;
441
+ };
442
+ const A = Array.from({ length: S }, () => new Float64Array(S + 1));
443
+ for (let a = 0; a < S; a++) {
444
+ for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
445
+ if (a >= P) {
446
+ A[a][a] += s.termDI[fl[a - P]];
447
+ for (let b = P; b < S; b++) if (b !== a) A[a][b] += s.termDX[fl[a - P]][fl[b - P]];
448
+ }
449
+ A[a][S] = (a < P ? pins[a].res : s.termRes[fl[a - P]]) - dot(a, delta);
450
+ let mx = 0;
451
+ for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
452
+ if (mx > 0) for (let b = 0; b <= S; b++) A[a][b] /= mx;
453
+ }
454
+ // Small dense solve with partial pivoting.
455
+ for (let col = 0; col < S; col++) {
456
+ let p = col;
457
+ for (let r = col + 1; r < S; r++) if (Math.abs(A[r][col]) > Math.abs(A[p][col])) p = r;
458
+ [A[col], A[p]] = [A[p], A[col]];
459
+ if (A[col][col] === 0) throw new SolverError('the bordered system is singular: a conserved amount or a floating terminal is not determined');
460
+ for (let r = col + 1; r < S; r++) {
461
+ const f = A[r][col] / A[col][col];
462
+ for (let k = col; k <= S; k++) A[r][k] -= f * A[col][k];
463
+ }
464
+ }
465
+ const x = new Float64Array(S);
466
+ for (let r = S - 1; r >= 0; r--) {
467
+ let v = A[r][S];
468
+ for (let k = r + 1; k < S; k++) v -= A[r][k] * x[k];
469
+ x[r] = v / A[r][r];
470
+ }
471
+ for (let a = 0; a < S; a++) {
472
+ const sa = a < P ? x[a] : -x[a], col = cols[a];
473
+ for (let j = 0; j < N; j++) delta[j] += sa * col[j];
474
+ if (a >= P) deltaV[a - P] = x[a];
475
+ }
476
+ }
477
+
478
+ // A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
479
+ // none) its first node's, which its amount fixes (that row is the amount's, a pin whose
480
+ // response moves the whole stretch: see applyConstraints).
481
+ function flatLevel(s, st) {
482
+ const { M, u, uLo, z, VT, model } = s, i = st.species;
483
+ if (st.leftOpen || st.rightOpen) {
484
+ const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
485
+ return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (s.termV[k] + link.offset)) / VT, lo: 0 };
486
+ }
487
+ const o = s.blockOfNode[st.nodes[0]] * M + 1 + i;
488
+ return { k: -1, hi: u[o], lo: uLo[o] };
489
+ }
490
+
491
+ // Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
492
+ // updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
493
+ // expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
494
+ // read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
495
+ // 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
496
+ export function snapFlat(s) {
497
+ const { M, u, uLo } = s;
498
+ for (const st of s.flatStretches) {
499
+ const f = s.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
500
+ for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
501
+ const o = s.blockOfNode[g] * M + 1 + st.species;
502
+ if (s.loc[o] < 0) continue;
503
+ u[o] = hi;
504
+ uLo[o] = lo;
505
+ }
506
+ }
507
+ }
508
+
509
+ // Steady solves: each flat stretch's level held at its contact's throughout (see
510
+ // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
511
+ function flatRows(s) {
512
+ const { M, u, uLo, res, z, VT, model } = s, R = s.rix;
513
+ for (const st of s.flatStretches) {
514
+ const i = st.species, open = st.leftOpen || st.rightOpen;
515
+ const { k, hi, lo } = flatLevel(s, st);
516
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
517
+ const b = s.blockOfNode[g], o = b * M + 1 + i;
518
+ if (s.loc[o] < 0) continue;
519
+ s._replaceRow(b, 1 + i);
520
+ for (const B of s.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
521
+ s._j(b, 1 + i, b, 1 + i, 1);
522
+ res[R[o]] = u[o] - hi + (uLo[o] - lo); // (the big terms first: hi + lo rounds to hi)
523
+ if (open && z[i] !== 0) s.termB[k][R[o]] += -z[i] / VT;
524
+ }
525
+ // A face inside the stretch whose link holds the level continuous carries the species' flux
526
+ // as an unknown that only the edge balances, now replaced, determined: it's zero.
527
+ for (let f = st.regions[0]; f < st.regions[1]; f++) {
528
+ if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
529
+ const b = s.blockOfFace[f], o = b * M + 1 + i;
530
+ if (s.loc[o] < 0) continue;
531
+ s._replaceRow(b, 1 + i);
532
+ for (const B of s.termB) B[R[o]] = 0;
533
+ s._j(b, 1 + i, b, 1 + i, 1);
534
+ res[R[o]] = u[o] + uLo[o];
535
+ }
536
+ }
537
+ }
538
+
539
+ // Steady solves: each reaction that alone changes a combination (see src/laws.js) held at its
540
+ // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
541
+ // like an amount's).
542
+ function applyAffinities(s) {
543
+ const { M, u, uLo, res } = s, grid = s.model.grid;
544
+ for (const af of s.affinities) {
545
+ const b0 = Math.floor(af.row / M), r0 = af.row % M;
546
+ s._replaceRow(b0, r0);
547
+ s._j(b0, r0, b0, r0, 1);
548
+ res[s.rix[af.row]] = 0;
549
+ const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
550
+ let hi = af.rx.fixedA, lo = 0, len = 0;
551
+ for (const p of af.rx.part) {
552
+ const o = s.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
553
+ hi -= p.nu * u[o];
554
+ lo -= p.nu * uLo[o];
555
+ if (s.loc[o] < 0) continue;
556
+ af.idx[len] = s.rix[o];
557
+ af.w[len++] = -p.nu;
558
+ }
559
+ af.len = len;
560
+ af.res = hi + lo;
561
+ }
562
+ }