driftlet 1.2.1 → 1.2.3

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/laws.js ADDED
@@ -0,0 +1,693 @@
1
+ // The steady system's conservation laws: which amounts a steady solve keeps, and which of its
2
+ // equations each takes the place of.
3
+ //
4
+ // A steady state's balance rows are dependent wherever an amount is conserved: the weighted sum
5
+ // of a conserved combination's balances is zero for every state, so one of them says nothing,
6
+ // and the amount itself (which the state has, and a steady solve must not lose) is what goes in
7
+ // its place. Likewise a reaction whose rate a steady state sets to zero is better said by its
8
+ // affinity, a species nothing carries by its amount node by node, and a piece of a stretch fed
9
+ // only through its faces by its summed balance. This module finds them all, from the device's
10
+ // structure alone; src/lawRows.js evaluates them on the state and writes them into the Newton
11
+ // system.
12
+ //
13
+ // CONTRACT
14
+ //
15
+ // Input: a structure `s` (Solver._structure()), read and never written:
16
+ // model the normalised device (regions, materials, interfaces, ports, contacts,
17
+ // reactions, species, grid; ports[k] is terminal k + 2)
18
+ // n, M, z, nNodes species, slots per block (1 + n + the most extra slots), charges, nodes
19
+ // stretches the connected runs of regions where a species is present, each
20
+ // { species, regions: [r0, r1], nodes: [g0, g1], leftOpen, rightOpen,
21
+ // linked (ports holding or exchanging it), ports, reactive, spectator,
22
+ // contactFed, mobile, connected }
23
+ // stretchOf stretch index by region·n + species, −1 where absent
24
+ // flatStretches stretches a steady solve holds at a flat level (Solver._findStretches)
25
+ // rough each stretch's starting amount (Infinity where a level sets it)
26
+ // surfCols, surfColOf electrode surface species as columns: surfCols[j] = [port, slot];
27
+ // surfColOf[port] its first column (−1 for none)
28
+ // terms, floating terminals (0 left, 1 right, 2 + k port k) with their drives as they are
29
+ // now; the terminals whose voltage is an unknown
30
+ // blockOfNode, blockOfFace, rxsIn, nodeMaterial, phiUndefined
31
+ // the block layout (row o = block·M + slot), bulk reactions by material,
32
+ // each node's material, nodes with no φ
33
+ // (The small predicates below, moves … chargeOnly, read only the fields they destructure; the
34
+ // solver also calls them on itself.)
35
+ //
36
+ // Column spaces. A law's weights w are over C columns: stretches [0, S), electrode surfaces'
37
+ // species [S, X) (column k is surface column k − S), electrodes' charges [X, C) (column k is
38
+ // terminal carriers[k − X], counting its charge as Q/(zF), z = carrierZ). The rows a law can
39
+ // take, v, are over S + T: stretch k's balance row (at its first node) [0, S), then terminal t's
40
+ // circuit row, S + t.
41
+ //
42
+ // Output of findLaws(s): { constraints, affinities, localConstraints, islands, timeLaws,
43
+ // contactLaw, carriers, conserved, unkept }:
44
+ // constraints[] a kept amount, Σ w·n_stretch + Σ w·(surface's Γ a θ) + Σ w·Q_k/(z_k F) (ports
45
+ // with a capacitance) + Σ λ·D_t/F (contacts' charges), from
46
+ // { parts: [{stretch, w}], surface: [{col, w}], carriers: [{k, w}],
47
+ // contacts: [{t, lambda}] }. It replaces either one balance row, `row` (block·M
48
+ // + slot, circuit −1), or terminal `circuit`'s row, I = 0, where the amount
49
+ // changes by λ I/F (`lambda`). `terminals` are those driven by a current that
50
+ // feed it: with none it keeps its reference (the amounts at construction), with
51
+ // some what the state holds when a solve begins (captured under `key`). `col`,
52
+ // for a stretch held flat: the rows its pin moves together. idx, w, len, res, dV:
53
+ // workspace for the solver.
54
+ // affinities[] { rx, f, row }: face reaction rx at interface f, its affinity Σ ν η = 0 in place
55
+ // of balance row `row`; idx, w, len, res: workspace.
56
+ // localConstraints[] { parts: [{i, w}] or [{q, k, w}], row, nodes, reference }: at each node
57
+ // g of `nodes`, Σ w c_i + Σ w Γ a θ_q (a surface's only where port k is), as it
58
+ // was, in place of slot `row` of g's block. Only of still stretches (D = 0 in
59
+ // one region: nothing carries them between nodes) that no terminal feeds, and
60
+ // surfaces. One law can have several entries, a row per set of nodes.
61
+ // islands[] { stretch, row, flux, weight, outside }: a piece of a stretch between faces fed
62
+ // by nothing else, its balance summed over its nodes (Σ weight·u over the `flux`
63
+ // rows, plus the `outside` rows' balances) in place of balance row `row`.
64
+ // timeLaws[] time steps only: as constraints (no row, key or contacts), each for a terminal
65
+ // behind a resistance that alone feeds it.
66
+ // contactLaw[t] whether some constraint keeps contact t's charge.
67
+ // carriers the terminals with an electrode's-charge column, in column order.
68
+ // conserved the stretches some constraint or local constraint keeps (indices).
69
+ // unkept the conserved combinations (basis vectors) that no row here can keep. With
70
+ // any, or any stretch neither reached by a contact nor conserved, the solver
71
+ // doesn't solve the steady system directly but by time steps, which keep every
72
+ // amount themselves (Solver._directSteady).
73
+ //
74
+ // Invariants (each says what a reviewer should be able to check here):
75
+ // 1. conservedMoieties' basis spans the null space of its rows: every reaction's stoichiometry,
76
+ // and what each terminal feeds (freely, held at a voltage; in proportion to charge, driven
77
+ // by a current, with `drives`). Every basis vector w has w·row = 0 for every row.
78
+ // 2. No equation is replaced twice and none is lost: the rows the constraints and affinities
79
+ // take are chosen by elimination over v (one map from w, rowsOf), each law's v reduced by
80
+ // those already chosen, so their v are independent. The local constraints' rows are chosen
81
+ // the same way node by node, over the slots there. Islands take rows no other law has.
82
+ // 3. A balance row taken is a moving stretch's (a floating metal's own amount takes its
83
+ // carriers'). An affinity's is never a metal's carriers', nor a stretch's that a terminal
84
+ // feeds, nor a circuit row.
85
+ // 4. A charge is kept only in a circuit row (its pin there carries the voltage it depends on):
86
+ // an electrode's, at weight w/z on its carrier column, and a contact's (D_in, no column), at
87
+ // λ = w/z on what it passes. The first law through each takes a circuit row (the first
88
+ // terminal's in order whose v is left), and the laws after it have its multiple taken off.
89
+ // One with a charge left and no circuit row is unkept.
90
+ // 5. Every basis vector of conservedMoieties(s, { drives: true }) is accounted for: a
91
+ // spectator's own amount, a constraint, local constraints node by node, unkept, or dropped
92
+ // for one of the reasons marked DROP in findLaws. Each DROP states why nothing is lost.
93
+ // 6. The pivots and the affinities' rows don't depend on the order species, reactions or ports
94
+ // are listed in: they're chosen by the reactions' own terms and by name. A constraint's row
95
+ // can (the total iron in Fe²⁺'s row or Fe³⁺'s): the same law in another of its rows, with
96
+ // the same solution.
97
+ // 7. Nothing in `s` is written.
98
+ // Tests: test/laws.test.js (one device per law once dropped); tools/lawsweep.js (810 devices,
99
+ // solve() against a settled advance()).
100
+
101
+ // Whether a stretch's species moves through it (mobile in every region, or a metal's carrier),
102
+ // or stays still: D = 0 in its one region, so nothing carries it between nodes (trap states,
103
+ // conserved node by node). A stretch over two regions is joined by a face that passes it, which
104
+ // carries it between the face's nodes: still in neither sense. (Kept node by node, S⁺ linked
105
+ // across a face stayed at 1 and 0.1 on its two nodes, where the transient evens them to 0.4.)
106
+ export function moves({ model }, st) {
107
+ const { regions, materials } = model;
108
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
109
+ const mat = materials[regions[q].material];
110
+ if (!(mat.conductor || mat.D[st.species] > 0)) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ export function still({ model }, st) {
116
+ const { regions, materials } = model;
117
+ if (st.regions[0] !== st.regions[1]) return false;
118
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
119
+ const mat = materials[regions[q].material];
120
+ if (mat.conductor || mat.D[st.species] > 0) return false;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ // The charge number an electrode's carrier column counts its charge in, Q/(zF): its terminal
126
+ // species', or 1 for a port with none (a membrane's capacitance: Q/F).
127
+ export function carrierZ({ model, z }, k) {
128
+ const t = model.ports[k - 2].terminal;
129
+ return t === null || t === undefined ? 1 : z[t];
130
+ }
131
+
132
+ // Whether contact k (0, 1) holds a charge its current counts, D_in on its φ law (capacitive,
133
+ // pinned or bulk, where φ is defined at its end node).
134
+ export function contactCharges({ model, nNodes, phiUndefined }, k) {
135
+ const g = k === 0 ? 0 : nNodes - 1;
136
+ return ['capacitive', 'pinned', 'bulk'].includes(model.contacts[k === 0 ? 'left' : 'right'].phi.type) && !phiUndefined[g];
137
+ }
138
+
139
+ // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
140
+ export function feeds(k, st) {
141
+ return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
142
+ }
143
+
144
+ // Whether a terminal is driven by a current held at zero throughout (no resistance).
145
+ export function noCurrent(t) {
146
+ return t.drive.kind === 'I' && !(t.drive.R > 0) && t.drive.src.value === 0;
147
+ }
148
+
149
+ // Whether terminal k passes current only by charging a capacitance: no charged species through
150
+ // it (nor, a port, reactions), and a capacitance to charge (a gate's φ law, a port's own).
151
+ export function chargeOnly({ model, terms }, k) {
152
+ const t = terms[k], { species } = model;
153
+ const ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
154
+ if (t.kind === 'port') {
155
+ const p = model.ports[t.index];
156
+ return p.reactions.length === 0 && !ions(p.species) && !!p.capacitance;
157
+ }
158
+ const c = model.contacts[t.side];
159
+ return !ions(c.species) && (c.phi.type === 'capacitive' || c.phi.type === 'pinned');
160
+ }
161
+
162
+ // A basis of the conserved combinations of stretch amounts: the null space of the
163
+ // stoichiometry (a row per reaction, over the stretches it touches) together with what the
164
+ // terminals feed. A terminal held at a voltage (or behind a resistance, which holds it at
165
+ // steady state) feeds each stretch it links freely: a unit row. With `drives`, one driven by a
166
+ // current feeds its charged species only as that current allows, in proportion to their
167
+ // charges, so a combination weighted by z there is conserved while it passes none: a battery's
168
+ // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
169
+ // elimination will do. Returns the basis (each vector over the columns, w.own its free column),
170
+ // with basis.carriers (the terminals with a charge column) and basis.skipped.
171
+ export function conservedMoieties(s, { drives = false, alsoCurrent = [], skip = null } = {}) {
172
+ const { model, n, z, stretches, stretchOf, terms, surfCols, surfColOf, rough } = s, S = stretches.length;
173
+ // With `drives`, an electrode port driven by a current whose reactions take its carrier from
174
+ // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
175
+ // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
176
+ // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
177
+ const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
178
+ // (Or a port with a capacitance that passes ions, a membrane's: what it holds is Q/F. One that
179
+ // only charges keeps its charge alone, see lawRows.js, captureLaws.)
180
+ const carriers = drives ? terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && (model.ports[k - 2].reactions.length > 0 || (model.ports[k - 2].capacitance && !chargeOnly(s, k))) ? [k] : [])) : [];
181
+ const X = S + surfCols.length, C = X + carriers.length;
182
+ // (columns: stretches [0, S), surfaces' species [S, X), electrodes' charges [X, C), as in findLaws)
183
+ const isStretch = (k) => k < S, isSurface = (k) => k >= S && k < X;
184
+ // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
185
+ // its own rows, over the columns, are handed back as `basis.skipped`.)
186
+ const rows = [], skipped = [];
187
+ model.interfaces.forEach((itf, f) => {
188
+ for (const rx of itf.reactions) {
189
+ const row = new Float64Array(C);
190
+ for (const p of rx.part) row[stretchOf[(f + p.side) * n + p.i]] += p.nu;
191
+ (rx === skip ? skipped : rows).push(row);
192
+ }
193
+ });
194
+ // An electrode surface's species are columns too, after the stretches (S + j for surface
195
+ // column j), so a combination that passes through them (A⁺ + e⁻ = S, S + e⁻ = B⁻) is conserved
196
+ // with what the surface holds.
197
+ model.ports.forEach((port, k) => {
198
+ for (const rx of port.reactions) {
199
+ const row = new Float64Array(C); // (the electrode's carrier is outside the device)
200
+ if (rx === skip) skipped.push(row);
201
+ for (const p of rx.part) {
202
+ if (p.side === 0) row[stretchOf[port.region * n + p.i]] += p.nu;
203
+ else if (p.side === 2) row[S + surfColOf[k] + p.s] += p.nu;
204
+ else if (carriers.includes(k + 2)) row[X + carriers.indexOf(k + 2)] += p.nu;
205
+ }
206
+ if (rx !== skip) rows.push(row);
207
+ }
208
+ });
209
+ model.regions.forEach((reg, q) => {
210
+ for (const rx of model.reactions) {
211
+ if (!(rx.kf[reg.material] > 0)) continue;
212
+ const row = new Float64Array(C);
213
+ for (const p of rx.reactants) row[stretchOf[q * n + p.i]] -= p.nu;
214
+ for (const p of rx.products) row[stretchOf[q * n + p.i]] += p.nu;
215
+ (rx === skip ? skipped : rows).push(row);
216
+ }
217
+ });
218
+ const unit = (k) => {
219
+ const row = new Float64Array(C);
220
+ row[k] = 1;
221
+ rows.push(row);
222
+ };
223
+ terms.forEach((t, k) => {
224
+ const fed = stretches.flatMap((st, j) => (feeds(k, st) ? [j] : []));
225
+ if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
226
+ const charged = fed.filter((j) => z[stretches[j].species] !== 0).map((j) => [j, z[stretches[j].species]]);
227
+ if (carriers.includes(k)) charged.push([X + carriers.indexOf(k), carrierZ(s, k)]);
228
+ fed.filter((j) => z[stretches[j].species] === 0).forEach(unit);
229
+ for (let a = 1; a < charged.length; a++) {
230
+ const row = new Float64Array(C);
231
+ row[charged[0][0]] = 1 / charged[0][1];
232
+ row[charged[a][0]] = -1 / charged[a][1];
233
+ rows.push(row);
234
+ }
235
+ });
236
+ // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
237
+ // go first, and electrode surfaces' (as local), so that a combination of them alone (trap
238
+ // states, X⁰ + X⁻; adsorbates, A(ads) + B(ads)) comes out on its own, to be kept node by node,
239
+ // apart from any that moves (e⁻ − X⁰, the electrons the traps hold). (Pivoted after a closed
240
+ // Cl⁻ in A(ads) + Cl⁻ = B(ads) + e⁻, the surface's own came out mixed with it, and was lost.)
241
+ // Then the stretches by their starting amounts, least first, and by name: a trace species
242
+ // pivoted after an abundant one comes out added to it (A + B ⇌ C with B at 1 M and A at 1e-14
243
+ // gave B + C and A − B, where A + C was lost to round-off), and the basis followed the listing.
244
+ const isStill = (k) => ((isStretch(k) && still(s, stretches[k])) || isSurface(k) ? 1 : 0);
245
+ const name = (k) => model.species[stretches[k].species].name;
246
+ const order = [...Array(C).keys()].sort((a, b) => {
247
+ if (isStill(a) !== isStill(b)) return isStill(b) - isStill(a);
248
+ if (!isStretch(a) || !isStretch(b)) return a - b;
249
+ if (rough[a] !== rough[b]) return rough[a] - rough[b];
250
+ const na = name(a), nb = name(b);
251
+ return na < nb ? -1 : na > nb ? 1 : a - b;
252
+ });
253
+ const pivots = [];
254
+ let r = 0;
255
+ for (const col of order) {
256
+ if (r >= rows.length) break;
257
+ let best = r;
258
+ for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
259
+ if (Math.abs(rows[best][col]) < 1e-9) continue;
260
+ [rows[r], rows[best]] = [rows[best], rows[r]];
261
+ const pr = rows[r], pv = pr[col];
262
+ for (let j = 0; j < C; j++) pr[j] /= pv;
263
+ for (let i = 0; i < rows.length; i++) {
264
+ if (i === r || rows[i][col] === 0) continue;
265
+ const fct = rows[i][col];
266
+ for (let j = 0; j < C; j++) rows[i][j] -= fct * pr[j];
267
+ }
268
+ pivots.push(col);
269
+ r++;
270
+ }
271
+ const isPivot = new Uint8Array(C);
272
+ for (const col of pivots) isPivot[col] = 1;
273
+ const basis = [];
274
+ for (let free = 0; free < C; free++) {
275
+ if (isPivot[free]) continue;
276
+ const w = new Float64Array(C);
277
+ w[free] = 1;
278
+ pivots.forEach((col, i) => {
279
+ const v = -rows[i][free];
280
+ w[col] = Math.abs(v) < 1e-9 ? 0 : v;
281
+ });
282
+ w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
283
+ basis.push(w);
284
+ }
285
+ basis.skipped = skipped;
286
+ basis.carriers = carriers;
287
+ return basis;
288
+ }
289
+
290
+ // What a steady solve keeps (under the terminals' present drives, so again after a change of
291
+ // drive): each conserved amount, solved directly in place of one (redundant) balance row, and
292
+ // the islands' summed balances. See the contract above.
293
+ export function findLaws(s) {
294
+ const { model, n, M, z, nNodes, stretches, stretchOf, flatStretches, surfCols, terms, floating, blockOfNode, blockOfFace, rxsIn, nodeMaterial } = s;
295
+ const { regions, materials, grid } = model;
296
+ const laws = conservedMoieties(s, { drives: true }), carriersOf = laws.carriers;
297
+ const conserved = new Set();
298
+ // Conserved amounts solved directly, each in place of one (redundant) balance row: a
299
+ // spectator's own amount (a floating metal's: the charge on its faces), each conserved
300
+ // combination of reacting stretches (the total iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose
301
+ // weighted balances sum to zero at steady state, and what a terminal driven at no current
302
+ // keeps behind it. A combination may take in still stretches (the electrons trap states hold),
303
+ // their amounts summed like any other; one of still stretches alone conserves node by node
304
+ // instead (below), and one of a stretch still in part only is left to giant time steps.
305
+ const constraints = [];
306
+ const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
307
+ // The terminals driven by a current that feed a stretch: a combination through one keeps what
308
+ // the state holds when a solve begins (see lawRows.js, captureLaws), and has no steady state at
309
+ // any other current.
310
+ const driven = (st) => terms.flatMap((t, k) => (t.drive.kind === 'I' && feeds(k, st) ? [k] : []));
311
+ const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
312
+ const st = stretches[rowStretch];
313
+ // (the terminal's weight in it, w/z on what that feeds: the amount changes by λ I/F)
314
+ const own = carriers.find((p) => p.k === circuit);
315
+ const lambda = circuit < 0 ? 0 : ((own && own.w / carrierZ(s, circuit)) || parts.map((p) => (driven(stretches[p.stretch]).includes(circuit) ? p.w / z[stretches[p.stretch].species] : 0)).find((x) => x !== 0));
316
+ // In a circuit row, what passes through that terminal's capacitance is kept too, λ Q/F, as
317
+ // its current counts dQ/dt: a port or contact at no current that passes K⁺ and charges keeps
318
+ // n_K + Q/F. (Its K⁺ alone held, a membrane port's voltage settled 7 mV from the transient's.)
319
+ // A port's charge, as an electrode's carrier; a contact's, its displacement D_in, below.
320
+ if (circuit >= 2 && model.ports[circuit - 2].capacitance && !carriers.some((p) => p.k === circuit)) carriers = [...carriers, { k: circuit, w: lambda * carrierZ(s, circuit) }];
321
+ // Every contact driven by a current that feeds it and holds a charge, D_in, kept beside the
322
+ // ions it passes at its own λ (two contacts at no current passing K⁺, one a capacitor: its
323
+ // charge was left out where the law took the other's row).
324
+ const lam = (t) => parts.map((p) => (driven(stretches[p.stretch]).includes(t) && z[stretches[p.stretch].species] !== 0 ? p.w / z[stretches[p.stretch].species] : 0)).find((x) => x !== 0);
325
+ const contacts = circuit < 0 ? [] : [0, 1].filter((t) => lam(t) && contactCharges(s, t)).map((t) => ({ t, lambda: lam(t) }));
326
+ let nn = parts.reduce((m, p) => m + stretches[p.stretch].nodes[1] - stretches[p.stretch].nodes[0] + 1, 0);
327
+ for (const p of surface) nn += model.ports[surfCols[p.col][0]].nodes.length * model.ports[surfCols[p.col][0]].surface.length;
328
+ for (const p of carriers) nn += model.ports[p.k - 2].nodes.length;
329
+ for (const p of parts) conserved.add(p.stretch);
330
+ constraints.push({
331
+ parts,
332
+ surface,
333
+ carriers, // (what a port's electrode holds, Q/(zF) at weight w: see conservedMoieties)
334
+ contacts, // (contacts whose charges it keeps, λ D_in/F each)
335
+ terminals: [...new Set([...parts.flatMap((p) => driven(stretches[p.stretch])), ...carriers.map((p) => p.k)])],
336
+ key: parts.map((p) => `${p.stretch}:${p.w}`).join() + surface.map((p) => `|${p.col}:${p.w}`).join() + carriers.map((p) => `|T${p.k}:${p.w}`).join(),
337
+ circuit, // (the terminal whose circuit row it stands in for, or −1 for a balance row's, `row`)
338
+ lambda,
339
+ row: circuit >= 0 ? -1 : blockOfNode[st.nodes[0]] * M + 1 + st.species,
340
+ idx: new Int32Array(nn * M),
341
+ w: new Float64Array(nn * M),
342
+ len: 0,
343
+ res: 0,
344
+ });
345
+ };
346
+ stretches.forEach((st, k) => {
347
+ if (!st.spectator || !moves(s, st)) return;
348
+ add([{ stretch: k, w: 1 }], k);
349
+ // Held flat (see lawRows.js, flatRows), it moves as a whole: the pin's response is the whole
350
+ // stretch's shift, its amount saying how far.
351
+ if (flatStretches.includes(st)) constraints.at(-1).col = Array.from({ length: st.nodes[1] - st.nodes[0] + 1 }, (_, j) => blockOfNode[st.nodes[0] + j] * M + 1 + st.species);
352
+ });
353
+ const S = stretches.length, T = terms.length, X = S + surfCols.length;
354
+ // Two column spaces (see the contract). A law's weights w: stretches [0, S), electrode
355
+ // surfaces' species [S, X), electrodes' charges [X, …). The rows a law can take, v: stretches'
356
+ // balance rows [0, S), then terminals' circuit rows (terminal j − S).
357
+ const isStretch = (k) => k < S, isSurface = (k) => k >= S && k < X, isCarrier = (k) => k >= X;
358
+ const circuitOf = (j) => (j >= S ? j - S : -1);
359
+ const zc = (k) => carrierZ(s, k);
360
+ // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
361
+ // node, below), or the circuit row of a terminal driven at no current, which says nothing the
362
+ // combination's balance rows don't (their weighted sum is that terminal's current, weighted
363
+ // by w/z). Together they must be independent, each combination's weights over the rows
364
+ // replaced a nonsingular matrix, or one balance is lost and another said twice (A + B = AB and
365
+ // A + C = AC: −A + B + C in place of B's row, B + AB of AB's and A − B + AC of A's said B and
366
+ // A twice and C not at all, and the solve converged to a state that wasn't steady). So they're
367
+ // chosen by elimination, each combination's weights reduced by the rows already chosen: a
368
+ // circuit row where one is left, else the first stretch with weight 1, else the basis
369
+ // vector's own (its weight is 1 there, and no other vector's is), else the largest.
370
+ const rowOf = (k) => blockOfNode[stretches[k].nodes[0]] * M + 1 + stretches[k].species;
371
+ const chosen = []; // (a column and the reduced weights, per row replaced: S + t for terminal t's)
372
+ const spectators = new Set(constraints.map((cs) => cs.row));
373
+ // A law's weights over the rows it can take: a moving stretch's balance row (not a spectator's,
374
+ // taken above), and the circuit row of each terminal driven by a current that feeds it, at w/z
375
+ // (w/z of an electrode's charge too). The same map for every law, affinities and constraints
376
+ // alike, so that the elimination compares like with like.
377
+ const rowsOf = (w) => {
378
+ const v = new Float64Array(S + T);
379
+ for (let k = 0; k < S; k++) {
380
+ if (w[k] === 0) continue;
381
+ const st = stretches[k];
382
+ if (moves(s, st) && !spectators.has(rowOf(k))) v[k] = w[k];
383
+ if (z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / z[st.species];
384
+ }
385
+ carriersOf.forEach((k, j) => w[X + j] !== 0 && (v[S + k] = w[X + j] / zc(k)));
386
+ return v;
387
+ };
388
+ // What an electrode holds goes only in its terminal's circuit row (its charge depends on the
389
+ // terminal's voltage, which a balance row's pin can't carry): the combinations through it come
390
+ // first, so that the first takes that row, and the rest have its multiple taken off.
391
+ const through = (w) => carriersOf.some((k, j) => w[X + j] !== 0);
392
+ const held = new Map(); // (terminal → the combination in place of its circuit row)
393
+ // Reactions' equilibria first. A face reaction that alone changes some combination (without
394
+ // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
395
+ // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
396
+ // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
397
+ // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
398
+ // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
399
+ // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
400
+ // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
401
+ // form vanishes only there), so it stands in for one of those rows (see lawRows.js,
402
+ // applyAffinities), chosen by the same elimination, and a stretch's: never a circuit row.
403
+ // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
404
+ // own terms, never by where its species are listed: the same rows in any listing. (Picked by
405
+ // listing, an electrode's two couples took different rows with the species reversed, and
406
+ // Newton converged in one listing and crawled in the other.)
407
+ const affinities = [];
408
+ const faceRx = model.interfaces.flatMap((itf, f) => itf.reactions.map((rx) => ({ f, rx, key: rx.part.map((p) => `${model.species[p.i].name}:${p.side}:${p.nu}`).sort().join() })));
409
+ faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
410
+ for (const { f, rx } of faceRx) {
411
+ const nu = new Float64Array(S);
412
+ let inFlat = false;
413
+ for (const p of rx.part) {
414
+ const k = stretchOf[(f + p.side) * n + p.i];
415
+ if (k < 0 || flatStretches.includes(stretches[k])) inFlat = true;
416
+ else nu[k] += p.nu;
417
+ }
418
+ if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
419
+ // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
420
+ // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
421
+ // A current held at zero passes nothing: an electrode at open circuit feeding a closed
422
+ // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
423
+ const passes = (k) => !noCurrent(terms[k]);
424
+ const fed = (w) => w.some((x, k) => x !== 0 && ((isCarrier(k) && passes(carriersOf[k - X])) || (isStretch(k) && driven(stretches[k]).some(passes))));
425
+ // Any of the combinations it alone changes will do, with any conserved amount added (whose
426
+ // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
427
+ // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
428
+ const news = conservedMoieties(s, { drives: true, skip: rx }).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
429
+ // (One it changes only through immobile species has its rate alone in their balance at the
430
+ // face's node already, a row with no diffusion beside it to lose it to: left as it is. Stood
431
+ // in for by a mobile partner, Ag⁺ + Cl⁻ = S(immobile) beside a floating Ag electrode said its
432
+ // rate twice and lost the electrode's I = 0.)
433
+ if (news.some((w) => [...w.keys()].every((k) => !isStretch(k) || w[k] === 0 || still(s, stretches[k])))) continue;
434
+ const kept = laws.filter((l) => !fed(l));
435
+ const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
436
+ for (const w of tries) {
437
+ const v = rowsOf(w);
438
+ for (const [col, row] of chosen) {
439
+ const fc = v[col];
440
+ if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
441
+ }
442
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
443
+ if (!(scale > 1e-9)) continue;
444
+ // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
445
+ // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
446
+ // put the electrode at 602 V.)
447
+ const ok = (j) => j !== undefined && j >= 0 && j < S && Math.abs(v[j]) > 1e-9 * scale && !conductor(stretches[j]) && terms.every((_, t) => !feeds(t, stretches[j]));
448
+ // (One of its own species first, then weight 1, the largest, and by name.)
449
+ const own = (k) => rx.part.some((p) => p.i === stretches[k].species && stretchOf[(f + p.side) * n + p.i] === k);
450
+ const key = (k) => [own(k) ? 0 : 1, Math.abs(v[k]) === 1 ? 0 : 1, -Math.abs(v[k]), model.species[stretches[k].species].name, stretches[k].regions[0]];
451
+ const less = (a, b) => { const ka = key(a), kb = key(b); for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] < kb[j]; return false; };
452
+ const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
453
+ if (col < 0) continue;
454
+ chosen.push([col, v.map((x) => x / v[col])]);
455
+ affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
456
+ break;
457
+ }
458
+ }
459
+ // A contact's charge has no column: a law through what a contact driven by a current passes,
460
+ // where it holds a charge, counts λ D_in/F of it at λ = w/z on what it passes (see add). Kept
461
+ // once, in a circuit row, like an electrode's: the first law through it takes one, and the rest
462
+ // have its multiple taken off. (Two laws through K⁺ at a capacitor at I = 0, K⁺ − L⁻ and K⁺ + KL
463
+ // under K⁺ + L⁻ = KL: the second took KL's balance row without the charge, keeping n_K + n_KL,
464
+ // which the transient moves 15×, and the solve failed.)
465
+ const lamOf = (w, t) => (contactCharges(s, t) ? (stretches.map((st, k) => (w[k] !== 0 && z[st.species] !== 0 && driven(st).includes(t) ? w[k] / z[st.species] : 0)).find((x) => x !== 0) ?? 0) : 0);
466
+ const heldContact = new Map(); // (contact → the law that keeps its charge)
467
+ const unkept = [];
468
+ const reduce = (w, c, f) => {
469
+ const r = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
470
+ r.own = w.own;
471
+ return r;
472
+ };
473
+ for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
474
+ for (const [j, k] of carriersOf.entries()) {
475
+ const c = held.get(k);
476
+ if (w[X + j] === 0 || !c) continue;
477
+ w = reduce(w, c, w[X + j] / c[X + j]);
478
+ }
479
+ for (const [t, c] of heldContact) if (lamOf(w, t) !== 0) w = reduce(w, c, lamOf(w, t) / lamOf(c, t));
480
+ const parts = [...w.keys()].filter((k) => isStretch(k) && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
481
+ const surface = [...w.keys()].filter((k) => isSurface(k) && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
482
+ const carriers = carriersOf.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
483
+ // DROP: a surface's own is kept node by node, below. (One with an electrode's charge in it
484
+ // too is the circuit's, here: below skips it, and a floating electrode whose adsorbates gave
485
+ // it electrons settled 15 mV from where the transient ends.)
486
+ if (parts.length === 0 && carriers.length === 0) continue;
487
+ // UNKEPT: through a stretch still in part only, which no row here can carry (a known gap).
488
+ if (parts.some((p) => !moves(s, stretches[p.stretch]) && !still(s, stretches[p.stretch]))) {
489
+ unkept.push(w);
490
+ continue;
491
+ }
492
+ // DROP: a spectator's own amount: added above where it moves, node by node below where it's
493
+ // still.
494
+ if (parts.length === 1 && surface.length === 0 && stretches[parts[0].stretch].spectator) continue;
495
+ const v = rowsOf(w);
496
+ for (const [col, row] of chosen) {
497
+ const f = v[col];
498
+ if (f !== 0) for (let j = 0; j < S + T; j++) v[j] -= f * row[j];
499
+ }
500
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
501
+ // DROP: no row of its own left once those already chosen are taken off: of still stretches
502
+ // alone (kept node by node, below), or said by the laws already kept.
503
+ if (!(scale > 1e-9)) continue;
504
+ const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
505
+ const col = [...Array(T).keys()].map((t) => S + t).find(ok) ?? [parts.find((p) => w[p.stretch] === 1)?.stretch, w.own].find(ok) ?? v.findIndex((x) => Math.abs(x) === scale);
506
+ // UNKEPT: a charge (an electrode's on its capacitance, or a contact's) in a balance row,
507
+ // whose pin can't carry the terminal's voltage it depends on, nor (a contact's) hold it at
508
+ // all: none left to take off, no circuit row left to take.
509
+ if (circuitOf(col) < 0 && (carriers.some(({ k }) => model.ports[k - 2].capacitance) || [0, 1].some((t) => lamOf(w, t) !== 0))) {
510
+ unkept.push(w);
511
+ continue;
512
+ }
513
+ // (Other electrodes' charges in a circuit row not their own: their voltages enter it through
514
+ // termDX. Two floating electrodes sharing a closed Fe³⁺, their law dropped, settled at 1.3 V
515
+ // for −0.65.)
516
+ chosen.push([col, v.map((x) => x / v[col])]);
517
+ if (circuitOf(col) >= 0) held.set(circuitOf(col), w);
518
+ for (const t of [0, 1]) if (circuitOf(col) >= 0 && lamOf(w, t) !== 0 && !heldContact.has(t)) heldContact.set(t, w);
519
+ add(parts, circuitOf(col) < 0 ? col : parts[0]?.stretch, surface, circuitOf(col), carriers);
520
+ }
521
+ const contactLaw = [0, 1].map((k) => constraints.some((cs) => cs.contacts.some((c) => c.t === k)));
522
+ // A combination that nothing carries anywhere is conserved node by node: of still stretches
523
+ // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
524
+ // that only turn into each other). At each node its weighted sum stays what it was, a row local
525
+ // to that node's block, in place of one of their balances.
526
+ const localConstraints = [];
527
+ const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
528
+ const picked = new Map(); // (node → the rows chosen there: [slot, the reduced weights by slot])
529
+ for (const w of laws) {
530
+ const parts = [...w.keys()].filter((k) => w[k] !== 0);
531
+ if (parts.some(isCarrier)) continue; // (through an electrode: above)
532
+ // (Fed by a terminal driven by a current: in its circuit row, above. A port spread over
533
+ // several nodes carries it between them too. Kept here as well, S⁺ that a port at I = 0
534
+ // links was held twice, and the port's I = 0 lost: the solve settled at 0 V for −1.42 V.)
535
+ if (parts.some((k) => isStretch(k) && terms.some((_, t) => feeds(t, stretches[k])))) continue;
536
+ const sets = parts.map((k) => (isSurface(k) ? model.ports[surfCols[k - S][0]].nodes : still(s, stretches[k]) ? span(stretches[k].nodes) : null));
537
+ if (sets.includes(null)) continue; // (through a moving stretch: above)
538
+ // Its nodes: the immobile stretches', side by side, with any surface's window among them (a
539
+ // surface's term only where it is: an immobile S that adsorbs on an electrode spread over
540
+ // part of its region keeps S + Γaθ there and S alone elsewhere; required to match, that law
541
+ // was lost, and a floating electrode's coverage drifted). Or a surface's alone.
542
+ const own = parts.filter(isStretch), base = own.length > 0 ? sets[parts.indexOf(own[0])] : sets[0];
543
+ const same = (set) => set.length === base.length && set.every((g, j) => g === base[j]);
544
+ // UNKEPT: immobile stretches over different nodes, or a surface outside them: a face
545
+ // reaction joining the two nodes of a face, whose sum it keeps and neither alone.
546
+ if (!parts.every((k, j) => (isStretch(k) ? same(sets[j]) : sets[j].every((g) => base.includes(g))))) {
547
+ unkept.push(w);
548
+ continue;
549
+ }
550
+ for (const k of own) conserved.add(k);
551
+ const slot = (k) => (isStretch(k) ? 1 + stretches[k].species : 1 + n + surfCols[k - S][1]);
552
+ // Its row at each node, chosen by elimination as the constraints' are: its weights over the
553
+ // slots there (a surface's only where it is) reduced by the rows already chosen there, then
554
+ // the slot it prefers (a stretch's where it has one, present at every node, its own column's
555
+ // first; else the surface's) where that's left, else the first that is. One said already
556
+ // there, by those before it, is left out there. (Both given the same row, immobile S⁺ with
557
+ // S⁺ + A(ads) + e⁻ = B(ads): A − S⁺ and B + S⁺ took S⁺'s, one was lost, and θ_A settled at 1
558
+ // for the transient's 0.3.)
559
+ const rowPart = own.length > 0 ? (own.includes(w.own) ? w.own : own[0]) : parts.includes(w.own) ? w.own : parts[0];
560
+ const rowsAt = new Map(); // (slot → its nodes)
561
+ for (const g of base) {
562
+ const v = new Map();
563
+ parts.forEach((k, j) => sets[j].includes(g) && v.set(slot(k), w[k]));
564
+ for (const [sl, row] of picked.get(g) ?? []) {
565
+ const f = v.get(sl);
566
+ if (f) for (const [sx, x] of row) v.set(sx, (v.get(sx) ?? 0) - f * x);
567
+ }
568
+ const scale = [...v.values()].reduce((m, x) => Math.max(m, Math.abs(x)), 0);
569
+ if (!(scale > 1e-9)) continue;
570
+ const ok = (sl) => Math.abs(v.get(sl) ?? 0) > 1e-9 * scale;
571
+ const sl = ok(slot(rowPart)) ? slot(rowPart) : [...v.keys()].sort((a, b) => a - b).find(ok);
572
+ picked.set(g, [...(picked.get(g) ?? []), [sl, new Map([...v].map(([sx, x]) => [sx, x / v.get(sl)]))]]);
573
+ rowsAt.set(sl, [...(rowsAt.get(sl) ?? []), g]);
574
+ }
575
+ for (const [row, nodes] of rowsAt) {
576
+ localConstraints.push({
577
+ parts: parts.map((k) => (isStretch(k) ? { i: stretches[k].species, w: w[k] } : { q: surfCols[k - S][1], k: surfCols[k - S][0], w: w[k] })),
578
+ row,
579
+ nodes,
580
+ reference: new Float64Array(nodes.length),
581
+ });
582
+ }
583
+ }
584
+ // An immobile stretch at a node where nothing touches it (no reaction there, no port or contact
585
+ // linking it) keeps its concentration: its balance there is 0 = 0 in a steady state, which
586
+ // holds nothing, and a charged one's level stayed where it was while φ moved (S⁺ beside a face
587
+ // that makes it, the bath moved 0.2 V: S⁺ fell to e^{−0.2/V_T} of itself away from the face).
588
+ for (const st of stretches) {
589
+ if (!still(s, st)) continue;
590
+ const i = st.species, touched = new Set();
591
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) if (rxsIn[nodeMaterial[g]].some((rx) => rx.sp.includes(i))) touched.add(g);
592
+ model.interfaces.forEach((itf, f) => {
593
+ for (const rx of itf.reactions) for (const p of rx.part) if (p.i === i) touched.add(p.side === 0 ? grid.regionEnd[f] : grid.regionStart[f + 1]);
594
+ });
595
+ for (const port of model.ports) {
596
+ if (port.species[i].type !== 'blocked' || port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i))) for (const g of port.nodes) touched.add(g);
597
+ }
598
+ if (st.leftOpen) touched.add(0);
599
+ if (st.rightOpen) touched.add(nNodes - 1);
600
+ for (const lc of localConstraints) if (lc.row === 1 + i) for (const g of lc.nodes) touched.add(g);
601
+ const free = span(st.nodes).filter((g) => !touched.has(g));
602
+ if (free.length > 0) localConstraints.push({ parts: [{ i, w: 1 }], row: 1 + i, nodes: free, reference: new Float64Array(free.length) });
603
+ }
604
+ // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
605
+ // contact, port or reaction) holds its level only through its faces' fluxes, which can be
606
+ // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
607
+ // conducts): eliminated, the level is lost to round-off, and Newton settles at a wrong one with
608
+ // nothing to show for it. In steady solves the piece's balance summed over its nodes, where
609
+ // the internal fluxes cancel exactly and the faces' alone are left, replaces its first node's
610
+ // (see lawRows.js, applyIslands), once the plain solves converge (see Solver.newton).
611
+ const islands = [];
612
+ const used = new Set([...constraints.map((cs) => cs.row).filter((o) => o >= 0), ...affinities.map((af) => af.row)]);
613
+ for (const st of stretches) {
614
+ const i = st.species, [s0, s1] = st.regions;
615
+ if (!st.mobile || conductor(st)) continue;
616
+ for (let q0 = s0, q1 = s0; q1 <= s1; q1++) {
617
+ const p0 = q0;
618
+ q0 = q1 + 1;
619
+ if (p0 === s0 && q1 === s1) continue; // (uncut)
620
+ if ((p0 === s0 && st.leftOpen) || (q1 === s1 && st.rightOpen)) continue;
621
+ const inside = (q) => q >= p0 && q <= q1;
622
+ if (model.ports.some((port) => inside(port.region) && (port.species[i].type !== 'blocked' || port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i))))) continue;
623
+ let reactive = false;
624
+ for (let q = p0; q <= q1; q++) {
625
+ for (const rx of model.reactions) if (rx.kf[regions[q].material] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
626
+ }
627
+ for (let f = Math.max(0, p0 - 1); f <= Math.min(model.interfaces.length - 1, q1); f++) {
628
+ for (const rx of model.interfaces[f].reactions) if (rx.part.some((p) => p.i === i && inside(f + p.side))) reactive = true;
629
+ }
630
+ const row = blockOfNode[grid.regionStart[p0]] * M + 1 + i;
631
+ if (reactive || used.has(row)) continue;
632
+ used.add(row);
633
+ // The faces' fluxes into the piece: −A_f u at its left face, +A_f u at its right. Across
634
+ // a face that holds μ̄ level, u is set only by the edge balances, and eliminated, it can
635
+ // come out of the piece's own (G Δη, its two η all but equal: a neighbour 1e20 times
636
+ // less conductive, and the piece's level was seen through one face of two, and Newton
637
+ // swung it back and forth across both). There the outside edge node's balance is added:
638
+ // u cancels, and the outside's first segment carries the flux instead.
639
+ const faces = [], outside = [];
640
+ if (p0 > s0) faces.push([p0 - 1, -1, grid.regionEnd[p0 - 1]]);
641
+ if (q1 < s1) faces.push([q1, 1, grid.regionStart[q1 + 1]]);
642
+ for (const [f, , g] of faces) {
643
+ if (model.interfaces[f].links[i].type !== 'equilibrium' || g === 0 || g === nNodes - 1) continue;
644
+ if (model.ports.some((port) => port.nodes.includes(g))) continue;
645
+ outside.push(blockOfNode[g] * M + 1 + i);
646
+ }
647
+ islands.push({
648
+ stretch: st,
649
+ row,
650
+ flux: faces.map(([f]) => blockOfFace[f] * M + 1 + i),
651
+ weight: faces.map(([f, sg]) => sg * grid.area[grid.regionEnd[f]]),
652
+ outside,
653
+ idx: new Int32Array(faces.length + 3 * M * outside.length),
654
+ w: new Float64Array(faces.length + 3 * M * outside.length),
655
+ len: 0,
656
+ res: 0,
657
+ });
658
+ }
659
+ }
660
+ for (const isl of islands) isl.outside = isl.outside.filter((o) => !used.has(o));
661
+
662
+ // Time steps only: a terminal behind a resistance that alone feeds a combination reads its
663
+ // current by that combination's change too (see lawRows.js, circuitLaws), its combinations found
664
+ // with it counted as driven by a current. (Not in the steady system, where it holds its level.)
665
+ const timeLaws = [];
666
+ const behind = floating.filter((k) => terms[k].drive.kind !== 'I');
667
+ if (behind.length > 0) {
668
+ const more = conservedMoieties(s, { drives: true, alsoCurrent: behind }), X2 = S + surfCols.length;
669
+ for (const w of more) {
670
+ const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
671
+ if (parts.length === 0 || parts.some((p) => !moves(s, stretches[p.stretch]) && !still(s, stretches[p.stretch]))) continue;
672
+ const surface = [...w.keys()].filter((k) => k >= S && k < X2 && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
673
+ const carriers = more.carriers.flatMap((k, j) => (w[X2 + j] !== 0 ? [{ k, w: w[X2 + j] }] : []));
674
+ // Fed by one terminal behind a resistance and nothing else that passes current.
675
+ const fedBy = new Set([...carriers.map((p) => p.k), ...parts.flatMap((p) => floating.filter((k) => feeds(k, stretches[p.stretch])))]);
676
+ if (fedBy.size !== 1) continue;
677
+ const [k] = fedBy;
678
+ if (!behind.includes(k) || timeLaws.some((cs) => cs.circuit === k)) continue;
679
+ // (Nor a contact that holds a charge: its current is what it passes and dD_in/dt, which the
680
+ // change leaves out. Read by it, a capacitive contact behind a resistance passing a trace
681
+ // of K⁺ failed every step.)
682
+ if (k < 2 && contactCharges(s, k)) continue;
683
+ const zk = k >= 2 ? carrierZ(s, k) : 0;
684
+ const lambda = carriers.find((p) => p.k === k)?.w / zk || parts.map((p) => (feeds(k, stretches[p.stretch]) && z[stretches[p.stretch].species] !== 0 ? p.w / z[stretches[p.stretch].species] : 0)).find((x) => x !== 0);
685
+ if (!lambda) continue;
686
+ let nn = parts.reduce((m, p) => m + stretches[p.stretch].nodes[1] - stretches[p.stretch].nodes[0] + 1, 0);
687
+ for (const p of surface) nn += model.ports[surfCols[p.col][0]].nodes.length * model.ports[surfCols[p.col][0]].surface.length;
688
+ for (const p of carriers) nn += model.ports[p.k - 2].nodes.length;
689
+ timeLaws.push({ parts, surface, carriers, terminals: [k], circuit: k, lambda, idx: new Int32Array(nn * M), w: new Float64Array(nn * M), len: 0 });
690
+ }
691
+ }
692
+ return { constraints, affinities, localConstraints, islands, timeLaws, contactLaw, carriers: carriersOf, conserved, unkept };
693
+ }