driftlet 1.1.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/README.md +2 -2
- package/docs/conventions.md +2 -2
- package/docs/data.md +4 -1
- package/docs/device.md +6 -5
- package/docs/internals.md +86 -0
- package/docs/numerics.md +24 -5
- package/docs/reliability.md +34 -0
- package/docs/statistics.md +1 -0
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/channels.js +12 -7
- package/src/checks.js +6 -4
- package/src/data.js +1 -1
- package/src/describe.js +1 -1
- package/src/device.js +3 -4
- package/src/devices.js +9 -1
- package/src/equation.js +14 -3
- package/src/index.js +6 -3
- package/src/junction.js +9 -3
- package/src/kit.js +1 -1
- package/src/lawRows.js +562 -0
- package/src/laws.js +693 -0
- package/src/live.js +14 -9
- package/src/particles.js +4 -1
- package/src/plot.js +8 -4
- package/src/solution.js +1 -0
- package/src/solver.js +82 -902
- package/src/stack.js +5 -1
- package/src/statistics.js +31 -10
- package/src/traces.js +10 -7
- package/src/types.js +96 -29
- package/types/index.d.ts +14 -5
- package/types/kit.d.ts +1 -1
- package/types/lawRows.d.ts +5 -0
- package/types/laws.d.ts +86 -0
- package/types/plot.d.ts +2 -2
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +80 -39
- package/types/types.d.ts +179 -26
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
|
+
}
|