driftlet 1.2.4 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/start.js ADDED
@@ -0,0 +1,508 @@
1
+ // The cold start: the state a device starts from, built from its definition (each region's c0,
2
+ // the contacts' and ports' levels) before any solve or step.
3
+ //
4
+ // CONTRACT
5
+ //
6
+ // initFromComposition(s) writes a Solver `s` (src/solver.js; its layout is in docs/internals.md)
7
+ // once constructed: its unknowns u (every slot: φ̂, each present species' η, an electrode
8
+ // surface's coverages as η, gates' fractions) with uLo = 0, the voltages termV of its floating
9
+ // terminals, and, through s.computeConcentrations(), its concentrations and coverages. It reads
10
+ // the model (regions' c0 and c0Profile, materials, interfaces, contacts, ports), the stretches
11
+ // (s.stretches, s.stretchOf), the terminals (s.terms, s.floating), every terminal's voltage as
12
+ // it stands (held or floating: a floating one's sets the levels it feeds) and s.time (the
13
+ // drives' sources), and calls s.contactEta, s.portEta, s._surfaceStart, s._portRate, s._gateVoltage.
14
+ //
15
+ // In order:
16
+ // 1. Each region, left to right, each species present in it:
17
+ // - its c0 (or c0Profile) where given: an amount, mode 2, the starting state even where a
18
+ // contact feeds the species;
19
+ // - else a conductor's carrier away from the contacts: at equilibrium with the first
20
+ // reaction on its left face that takes it, else at the running φ̂ (level, mode 1);
21
+ // - else not fed by a contact: c0 is required (an error without it);
22
+ // - else at the level of what feeds it: the left contact's where its stretch reaches the
23
+ // left end, else the right's (whether its stretch reaches it or not: see Known), else its
24
+ // first linked port's; a contact's link held or exchanged at a rate (conductance,
25
+ // exchange) alike; fed by none of them (reactions only), from c0, required.
26
+ // φ̂ runs from the left: a pinned or capacitive left contact's (V − zeroCharge)/V_T, else 0,
27
+ // plus each face's dipole. In a region where a level-fixed charged species responds to φ̂,
28
+ // φ̂ is chosen for neutrality (a bisection; node by node where a profile varies);
29
+ // otherwise it's the running φ̂, or where a conductor on the left or an electrode spread
30
+ // through the region has a reaction that can balance, the φ̂ that balances it (by bisection;
31
+ // a trial a non-ideal model refuses counts its ideal charge, which has the sign there). Where
32
+ // neutrality is out of reach (c0's amounts leave a charge no held level balances), the
33
+ // running φ̂, and a strictly neutral region is noted in s.unbalanced (for a transient that
34
+ // fails from it to say why). A strictly
35
+ // neutral region (ε = 0) with every charged species' amount given must have a neutral c0:
36
+ // an error otherwise. A non-ideal material's composition comes from its statistics
37
+ // (materialAt).
38
+ // 2. Marks the state cold (s.coldStart). equilibrateFaces(s), which the first time step from it
39
+ // calls (src/timeSteps.js) and a steady solve doesn't: a face between two strictly neutral
40
+ // ideal regions, its φ type neutral, whose links hold some charged species in equilibrium,
41
+ // its two nodes set to that equilibrium, its t = 0⁺ (each passing species' μ̄ equal across
42
+ // it, the left node neutral, each passing species' amount over the two nodes kept; the jump
43
+ // in φ̂ between them the one unknown), everything else at them unchanged in concentration;
44
+ // s.coldStart false. A solve that converges, a warm start and a carried state set it as
45
+ // their state is.
46
+ // 3. An insertion host (a φ-free material, not a conductor): its φ̂ and every η shifted by z·Δ so that its first
47
+ // charged species shared with a neighbour across a face starts at that neighbour's level.
48
+ // 4. A floating contact facing a held one: V = the held V + the difference between the two
49
+ // terminal species' levels beside each (where both are equilibrium links of charged
50
+ // species).
51
+ // 5. Electrode surfaces at their θ₀ (s._surfaceStart); concentrations computed.
52
+ // 6. Floating double layers start uncharged: a capacitive contact at zeroCharge + V_T·φ̂
53
+ // beside it; a port with a capacitance at zeroCharge + its window's mean V_T·φ̂ (by
54
+ // volume·area, where φ is defined).
55
+ // 7. A floating port linked to its carrier, with no reactions or capacitance: at the carrier's
56
+ // level across its window, relative to a held contact's (where there is one).
57
+ // 8. Gates at α/(α + β) at the starting voltage across them.
58
+ // 9. A floating port with reactions and no capacitance: at the voltage where its reactions
59
+ // pass the current it's driven at, or behind a resistance (V_src − V)/R, by bisection.
60
+ //
61
+ // Invariants (each says what a reviewer should be able to check here):
62
+ // 1. A species given by its amount starts at c0 (or its profile) at every node; at a face
63
+ // equilibrated by step 2 (a transient's first step), its amount over the two nodes is kept.
64
+ // 2. A species given by its level starts at the level of what feeds it, as step 1 orders them.
65
+ // 3. Where a level-fixed charged species responds, each node starts neutral (Σ z c + the fixed
66
+ // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1); a
67
+ // strictly neutral region given by amounts is neutral by its c0, checked.
68
+ // 4. Steps 2 and 3 change no concentration but as they say: step 3 is a gauge (φ̂ and each η by
69
+ // z·Δ), step 2 moves the passing species between a face's two nodes only.
70
+ // 5. Each floating terminal's V is set by the last of steps 4, 6, 7, 9 that applies to it;
71
+ // held terminals' V are untouched.
72
+ // 6. Only u, uLo, termV, s.unbalanced, s.coldStart and what computeConcentrations derives are written; the
73
+ // result depends on the model, every terminal's voltage and s.time, deterministically.
74
+ // Known, and not an invariant (a cold review; see ROADMAP): the gauge (φ̂ and each η by z·Δ)
75
+ // runs from the left, so behind a closed or floating left end the start sits away from a right
76
+ // contact's level (55 RT for a bath referenced to Cl⁻), as does a c0 given for a contact-fed
77
+ // carrier (φ̂ then from the left's own start); an insertion host's shift moves contact-held
78
+ // levels in it too; a species whose stretch doesn't reach the right contact can still take its
79
+ // level; a terminal behind a resistance starts at 0 V; and the φ̂ beside an electrode passes
80
+ // over a reaction with a neutral level-held participant. Each the first step absorbs.
81
+ // Tests: every test (each device starts here); test/transient.test.js on what a start must be.
82
+
83
+ import { gateRate } from './boundaries.js';
84
+ import { FARADAY } from './constants.js';
85
+ import { sourceAt } from './device.js';
86
+ import { profileAt, SolverError } from './solver.js';
87
+
88
+ /**
89
+ * Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
90
+ * the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
91
+ * profile varies), or continued across the interface dipole if nothing there responds.
92
+ */
93
+ export function initFromComposition(s) {
94
+ const { model, n, M, u, z } = s;
95
+ const { regions, species, interfaces, materials } = model;
96
+ const grid = model.grid;
97
+ u.fill(0);
98
+ // The outside level a contact's link offers species i: held there, or exchanged with at a rate
99
+ // (conductance, exchange), whose level a start can take as well (a link of either kind had
100
+ // asked for c0 as if it fed the species through reactions alone).
101
+ const held = (side, i) => {
102
+ const l = model.contacts[side].species[i];
103
+ if (l.type !== 'conductance' && l.type !== 'exchange') return s.contactEta(side, i);
104
+ return z[i] === 0 ? l.mu / model.RT : (z[i] * (s.termV[side === 'left' ? 0 : 1] + l.offset)) / s.VT;
105
+ };
106
+ s.unbalanced = []; // (strictly neutral regions whose start can't be neutral: see neutralPhi)
107
+ s.uLo.fill(0);
108
+ const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
109
+ // φ̂ runs from the left: from a pinned (or gated) left contact's φ at its zero charge, else 0.
110
+ const leftPhi = model.contacts.left.phi;
111
+ let phiHat = leftPhi.type === 'pinned' || leftPhi.type === 'capacitive' ? (s.termV[0] - leftPhi.zeroCharge) / s.VT : 0;
112
+ for (let r = 0; r < regions.length; r++) {
113
+ if (r > 0) phiHat += interfaces[r - 1].dipole / s.VT;
114
+ const reg = regions[r], mat = materials[reg.material];
115
+ mode.fill(0);
116
+ const profiled = reg.c0Profile.some((p) => p !== null);
117
+ for (let i = 0; i < n; i++) {
118
+ if (!mat.present[i]) continue;
119
+ const st = s.stretches[s.stretchOf[r * n + i]];
120
+ if (reg.c0Profile[i] || reg.c0[i] > 0) {
121
+ // A given c0 is the starting state, even where a contact feeds the species.
122
+ mode[i] = 2;
123
+ cFix[i] = reg.c0Profile[i] ? profileAt(reg.c0Profile[i], grid.x[grid.regionStart[r]]) : reg.c0[i];
124
+ } else if (!st.contactFed && mat.conductor) {
125
+ // A conductor away from the contacts: start uncharged, with its carrier's level in
126
+ // equilibrium with the first reaction on its left face that takes it, else at the
127
+ // running φ.
128
+ eta[i] = z[i] * phiHat;
129
+ const rx = r > 0 ? interfaces[r - 1].reactions.find((x) => x.part.some((p) => p.side === 1 && p.i === i)) : undefined;
130
+ if (rx) {
131
+ const bn = s.blockOfNode[grid.regionEnd[r - 1]];
132
+ let a = rx.fixedA, nu = 0; // a = fixedA − Σ ν η = 0, solved for the carrier's η
133
+ for (const p of rx.part) {
134
+ if (p.side === 1) nu += p.nu;
135
+ else a -= p.nu * u[bn * M + 1 + p.i];
136
+ }
137
+ eta[i] = a / nu;
138
+ }
139
+ mode[i] = 1;
140
+ } else if (!st.contactFed) {
141
+ if (!(reg.c0[i] > 0)) {
142
+ throw new SolverError(
143
+ `regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration` +
144
+ (st.connected ? ' (reactions make and consume it, so this is only where the solve starts, not an amount it keeps: a tiny value will do)' : ', which fixes the amount it conserves'),
145
+ );
146
+ }
147
+ mode[i] = 2;
148
+ cFix[i] = reg.c0[i];
149
+ } else {
150
+ const left = st.regions[0] === 0 ? held('left', i) : NaN;
151
+ eta[i] = Number.isFinite(left) ? left : held('right', i);
152
+ if (!Number.isFinite(eta[i]) && st.linked.length > 0) eta[i] = s.portEta(model.ports[st.linked[0]], i);
153
+ mode[i] = 1;
154
+ if (!Number.isFinite(eta[i])) {
155
+ // Fed only through reactions (no level held at a contact): start from c0.
156
+ if (!(reg.c0[i] > 0)) {
157
+ throw new SolverError(
158
+ `regions[${r}].c0.${species[i].name}: a species fed only through reactions needs its initial concentration`,
159
+ );
160
+ }
161
+ mode[i] = 2;
162
+ cFix[i] = reg.c0[i];
163
+ }
164
+ }
165
+ }
166
+ // Net charge (mol/m³) at trial φ̂; decreasing in φ̂ wherever a level-fixed ion responds.
167
+ const zeta = new Float64Array(n), cc = new Float64Array(n);
168
+ const charge = (ph) => {
169
+ if (!mat.ideal) {
170
+ // A model can refuse a trial φ̂ far out, where one ion is many orders denser than the rest
171
+ // (Debye–Hückel's limiting law threw out of the start, for KCl at 10 mM, its neutral φ̂
172
+ // 57 units from where the search began): there the ideal charge has the sign, and the
173
+ // search goes on by it.
174
+ let refused = false;
175
+ try {
176
+ materialAt(s, mat, reg.background, eta, mode, cFix, ph, zeta, cc);
177
+ } catch (err) {
178
+ if (!(err instanceof SolverError)) throw err;
179
+ refused = true;
180
+ }
181
+ if (!refused) {
182
+ let q = reg.fixedCharge / FARADAY;
183
+ for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
184
+ return q;
185
+ }
186
+ }
187
+ let q = reg.fixedCharge / FARADAY;
188
+ for (let i = 0; i < n; i++) {
189
+ if (mode[i] === 2) q += z[i] * cFix[i];
190
+ else if (mode[i] === 1 && z[i] !== 0) {
191
+ const ex = Math.min(700, Math.max(-700, eta[i] - mat.mu0[i] / model.RT - z[i] * ph));
192
+ q += z[i] * mat.cRef[i] * Math.exp(ex);
193
+ }
194
+ }
195
+ return q;
196
+ };
197
+ const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
198
+ const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
199
+ const checkNeutral = (where) => {
200
+ // Strictly neutral, with every charged species' amount given: its c0 must be neutral.
201
+ let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
202
+ for (let i = 0; i < n; i++) {
203
+ if (mode[i] !== 2 || z[i] === 0) continue;
204
+ q += z[i] * cFix[i];
205
+ scale += Math.abs(z[i] * cFix[i]);
206
+ }
207
+ if (Math.abs(q) > 1e-9 * scale) {
208
+ throw new SolverError(
209
+ `regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³${where} (Σ z·c0, with any fixed charge), ` +
210
+ 'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
211
+ );
212
+ }
213
+ };
214
+ if (mustBalance && !profiled) checkNeutral('');
215
+ if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
216
+ // Nothing here fixes φ, but an electrode on the left does: start with its first reaction
217
+ // that takes a species from this side at equilibrium (the electrode at its open-circuit
218
+ // level), rather than φ carried over from the metal, which can be volts away.
219
+ const bn = s.blockOfNode[grid.regionEnd[r - 1]];
220
+ for (const rx of interfaces[r - 1].reactions) {
221
+ let a = rx.fixedA, slope = 0;
222
+ for (const p of rx.part) {
223
+ if (p.side === 0) a -= p.nu * u[bn * M + 1 + p.i];
224
+ else if (mode[p.i] === 2) {
225
+ a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
226
+ slope += p.nu * z[p.i];
227
+ } else slope = NaN;
228
+ }
229
+ if (slope !== 0 && Number.isFinite(slope)) {
230
+ phiHat = a / slope; // a − slope·φ̂ = 0
231
+ break;
232
+ }
233
+ }
234
+ }
235
+ const electrode = model.ports.find((port) => port.region === r && port.reactions.length > 0);
236
+ if (!mat.phiFree && !responds && mat.ideal && electrode) {
237
+ // Nor here, but an electrode spread through it does: start with the port's first reaction
238
+ // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
239
+ const bare = 1 - electrode.surface.reduce((t, sp) => t + sp.theta0, 0);
240
+ for (const rx of electrode.reactions) {
241
+ let a = rx.fixedA, slope = 0;
242
+ for (const p of rx.part) {
243
+ if (p.side === 1) a -= p.nu * s.portEta(electrode, p.i);
244
+ else if (p.side === 2) {
245
+ const sp = electrode.surface[p.s]; // (at its starting coverage)
246
+ a -= p.nu * (sp.mu0 / model.RT + Math.log(sp.theta0 / bare));
247
+ } else if (mode[p.i] === 2) {
248
+ a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
249
+ slope += p.nu * z[p.i];
250
+ } else slope = NaN;
251
+ }
252
+ if (slope !== 0 && Number.isFinite(slope)) {
253
+ phiHat = a / slope;
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ const neutralPhi = (ph) => {
259
+ let lo = ph - 1, hi = ph + 1;
260
+ while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
261
+ while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
262
+ // Out of reach (every responding carrier has one sign and nothing balances it, as in an
263
+ // undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
264
+ if (charge(lo) < 0 || charge(hi) > 0) {
265
+ // In a strictly neutral region that's a start that can't be: the amounts c0 gives leave
266
+ // a charge no level-held species can balance. Noted, for a transient that fails from it
267
+ // to say so (a steady solve, its levels from the contacts, can still find the state).
268
+ if (mat.epsr === 0) s.unbalanced.push(r);
269
+ return ph;
270
+ }
271
+ for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
272
+ const m = 0.5 * (lo + hi);
273
+ if (charge(m) > 0) lo = m;
274
+ else hi = m;
275
+ }
276
+ return 0.5 * (lo + hi);
277
+ };
278
+ if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
279
+ if (!mat.ideal) materialAt(s, mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
280
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
281
+ if (profiled) {
282
+ for (let i = 0; i < n; i++) if (reg.c0Profile[i]) cFix[i] = profileAt(reg.c0Profile[i], grid.x[g]);
283
+ if (mustBalance) checkNeutral(` at x = ${grid.x[g].toPrecision(4)} m`);
284
+ if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
285
+ if (!mat.ideal) materialAt(s, mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
286
+ }
287
+ const b = s.blockOfNode[g];
288
+ u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
289
+ for (let i = 0; i < n; i++) {
290
+ if (mode[i] === 1) u[b * M + 1 + i] = eta[i];
291
+ else if (mode[i] === 2) {
292
+ const zt = mat.ideal ? Math.log(cFix[i] / mat.cRef[i]) : zeta[i];
293
+ u[b * M + 1 + i] = zt + mat.mu0[i] / model.RT + z[i] * phiHat;
294
+ }
295
+ }
296
+ }
297
+ }
298
+ // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
299
+ // splits between its ion and its electrons. Start its ion level with the same ion's beside it
300
+ // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
301
+ // and a floating contact on them, start where the cell's open-circuit voltage puts them, not
302
+ // volts away. (A shift of φ̂ and each η by z·Δ: the concentrations don't change.) Not through
303
+ // a metal collector between the host and the contact: the contact then starts at 0 V, and the
304
+ // first step or the steady solve brings it to the open circuit (a cold review's p9: 4.09 V
305
+ // either way, in the same 14 steps).
306
+ regions.forEach((reg, r) => {
307
+ const mat = materials[reg.material];
308
+ if (!mat.phiFree || mat.conductor) return; // (a metal is φ-free too, but no host: its level is its contact's or its faces')
309
+ const sides = [[r - 1, grid.regionStart[r], r > 0 ? grid.regionEnd[r - 1] : -1], [r + 1, grid.regionEnd[r], r + 1 < regions.length ? grid.regionStart[r + 1] : -1]];
310
+ for (const [nr, gIn, gOut] of sides) {
311
+ if (gOut < 0) continue;
312
+ const other = materials[regions[nr].material];
313
+ if (other.phiFree || other.conductor) continue;
314
+ const i = z.findIndex((zi, j) => zi !== 0 && mat.present[j] && other.present[j]);
315
+ if (i < 0) continue;
316
+ const d = (u[s.blockOfNode[gOut] * M + 1 + i] - u[s.blockOfNode[gIn] * M + 1 + i]) / z[i];
317
+ if (!Number.isFinite(d)) continue;
318
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
319
+ const b = s.blockOfNode[g];
320
+ u[b * M] += d;
321
+ for (let j = 0; j < n; j++) if (mat.present[j]) u[b * M + 1 + j] += z[j] * d;
322
+ }
323
+ return;
324
+ }
325
+ });
326
+ // A floating contact facing a held one starts where the start's own composition puts it: the
327
+ // held voltage plus the difference between the two terminal species' levels beside each
328
+ // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
329
+ // that a transient's first reading means something.
330
+ const level = (side) => {
331
+ const ct = model.contacts[side], i = ct.terminal;
332
+ if (i === null || ct.species[i].type !== 'equilibrium' || z[i] === 0) return NaN;
333
+ const b = s.blockOfNode[side === 'left' ? 0 : grid.nNodes - 1];
334
+ return (s.VT * u[b * M + 1 + i]) / z[i] - ct.species[i].offset;
335
+ };
336
+ for (const k of s.floating) {
337
+ const t = s.terms[k], held = k === 0 ? 1 : 0;
338
+ if (t.kind === 'port' || held >= s.terms.length || s.floating.includes(held)) continue;
339
+ const shift = level(t.side) - level(s.terms[held].side);
340
+ if (Number.isFinite(shift)) s.termV[k] = s.termV[held] + shift;
341
+ }
342
+ for (let g = 0; g < s.nNodes; g++) if (s.surfPort[g] >= 0) s._surfaceStart(g);
343
+ s.computeConcentrations();
344
+ // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
345
+ // composition, neutral without it, assumes; with reactions too, its double layer then charges
346
+ // toward their mixed potential.
347
+ for (const k of s.floating) {
348
+ const t = s.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
349
+ const gate = t.kind === 'contact' ? model.contacts[t.side].phi : null;
350
+ if (gate?.type === 'capacitive') {
351
+ // A gate contact likewise: at its zero-charge level from the φ beside it (a pinned
352
+ // one's charge is the field's, already zero against a neutral start).
353
+ const g = t.side === 'left' ? 0 : s.nNodes - 1;
354
+ if (!s.phiUndefined[g]) s.termV[k] = gate.zeroCharge + s.VT * u[s.blockOfNode[g] * M];
355
+ continue;
356
+ }
357
+ if (!port?.capacitance) continue;
358
+ let w = 0, sum = 0;
359
+ port.nodes.forEach((g, j) => {
360
+ if (s.phiUndefined[g]) return;
361
+ const a = s.model.grid.vol[g] * s.portArea[t.index][j];
362
+ w += a;
363
+ sum += a * s.VT * u[s.blockOfNode[g] * M];
364
+ });
365
+ if (w > 0) s.termV[k] = port.capacitance.zeroCharge + sum / w;
366
+ }
367
+ // A floating port linked to its carrier (no reactions, no double layer: a wire into an
368
+ // insertion host) starts at that carrier's level across its window, not at 0 V.
369
+ for (const k of s.floating) {
370
+ const t = s.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
371
+ const i = port?.terminal, link = i === null || i === undefined ? null : port.species[i];
372
+ if (!port || port.reactions.length > 0 || port.capacitance || !link || link.type === 'blocked' || z[i] === 0) continue;
373
+ let w = 0, sum = 0;
374
+ port.nodes.forEach((g, j) => {
375
+ const a = s.model.grid.vol[g] * (s.portArea[t.index]?.[j] ?? port.fill[j]); // (a window with no area: by volume)
376
+ w += a;
377
+ sum += (a * s.VT * u[s.blockOfNode[g] * M + 1 + i]) / z[i];
378
+ });
379
+ // (Levels at the start mean something only against a held terminal's, as for a contact.)
380
+ const held = [0, 1].find((h) => !s.floating.includes(h) && Number.isFinite(level(s.terms[h].side)));
381
+ if (w > 0 && held !== undefined) s.termV[k] = s.termV[held] + sum / w - (link.offset ?? 0) - level(s.terms[held].side);
382
+ }
383
+ // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
384
+ for (const gt of s.gateList) {
385
+ const { gate, o } = gt, V = s._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
386
+ u[o] = a + b > 0 ? a / (a + b) : 0;
387
+ }
388
+ // A floating electrode spread through a port (without a double layer) starts where its
389
+ // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
390
+ // the start's composition, not level with a held terminal, which can be volts away and pass
391
+ // an absurd current.
392
+ for (const k of s.floating) {
393
+ const t = s.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
394
+ if (!port || port.reactions.length === 0 || port.capacitance) continue;
395
+ // What it must pass: its driven current, or behind a resistance what the circuit says,
396
+ // (V_src − V)/R (taken as 0 there, both solves failed for R ≤ 10 Ω·m²: a cold review).
397
+ const src = sourceAt(t.drive.src, s.time), want = (V) => (t.drive.kind === 'I' ? src : (src - V) / t.drive.R);
398
+ const current = (V) => {
399
+ s.termV[k] = V;
400
+ let I = 0;
401
+ port.reactions.forEach((rx) => {
402
+ let q = 0;
403
+ for (const p of rx.part) if (p.side === 0) q += p.nu * z[p.i];
404
+ port.nodes.forEach((g, w) => (I += q * FARADAY * s.model.grid.vol[g] * s.portArea[t.index][w] * s._portRate(port, rx, g)));
405
+ });
406
+ return I;
407
+ };
408
+ // Current into the device rises with V (oxidation); bracket, then bisect.
409
+ let lo = s.termV[k] - 1, hi = s.termV[k] + 1;
410
+ while (current(lo) > want(lo) && lo > -1e3) lo -= 2 * (hi - lo);
411
+ while (current(hi) < want(hi) && hi < 1e3) hi += 2 * (hi - lo);
412
+ for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
413
+ const m = 0.5 * (lo + hi);
414
+ if (current(m) < want(m)) lo = m;
415
+ else hi = m;
416
+ }
417
+ s.termV[k] = 0.5 * (lo + hi);
418
+ }
419
+ s.coldStart = true; // (until a solve or a step moves it: see equilibrateFaces)
420
+ }
421
+
422
+ /**
423
+ * The t = 0⁺ state at the faces between strictly neutral regions, taken by the first time step
424
+ * from a cold start (src/timeSteps.js) and not by a steady solve, which needs no t = 0⁺: there it
425
+ * was the worse start, its face nodes moved while the rest of a region stayed (a stack whose
426
+ * middle region started 2.2 V from its neighbour's level failed every steady solve, where the
427
+ * cold start converged in one).
428
+ */
429
+ export function equilibrateFaces(s) {
430
+ const { model, n, M, u, z } = s;
431
+ const { regions, interfaces, materials } = model;
432
+ const grid = model.grid;
433
+ s.coldStart = false;
434
+ // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
435
+ // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
436
+ // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
437
+ // first step made to take that jump met an exactly singular pivot. Start there: the ions the
438
+ // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
439
+ // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
440
+ // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
441
+ // the two nodes' charge summing to what it was, zero.
442
+ for (let f = 0; f < interfaces.length; f++) {
443
+ const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
444
+ const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
445
+ if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
446
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = s.blockOfNode[gL] * M, bR = s.blockOfNode[gR] * M;
447
+ const pass = [];
448
+ for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
449
+ if (!pass.some((i) => z[i] !== 0)) continue;
450
+ const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
451
+ const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
452
+ const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
453
+ let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
454
+ for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
455
+ // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
456
+ const split = (D) =>
457
+ pass.map((i, j) => {
458
+ const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
459
+ return [K * cR, cR];
460
+ });
461
+ const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
462
+ const D0 = u[bR] - u[bL];
463
+ let lo = D0 - 1, hi = D0 + 1;
464
+ while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
465
+ while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
466
+ if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
467
+ for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
468
+ const m = 0.5 * (lo + hi);
469
+ if (qL(m) < 0) lo = m;
470
+ else hi = m;
471
+ }
472
+ const D = 0.5 * (lo + hi), cs = split(D);
473
+ const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
474
+ u[bR] = u[bL] + D;
475
+ for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
476
+ pass.forEach((i, j) => {
477
+ u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
478
+ u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
479
+ });
480
+ }
481
+ s.computeConcentrations();
482
+ }
483
+
484
+ // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
485
+ // amount-fixed ones (mode 2) at cFix. Fills ζ and c for every present species.
486
+ function materialAt(s, mat, background, eta, mode, cFix, ph, zeta, cc) {
487
+ const { n, z } = s;
488
+ const RT = s.model.RT;
489
+ for (let i = 0; i < n; i++) {
490
+ if (!mat.present[i]) continue;
491
+ zeta[i] = mode[i] === 2 ? Math.log(cFix[i] / mat.cRef[i]) : eta[i] - mat.mu0[i] / RT - z[i] * ph;
492
+ if (mat.modelOf[i] < 0) cc[i] = mode[i] === 2 ? cFix[i] : mat.cRef[i] * Math.exp(Math.min(700, zeta[i]));
493
+ }
494
+ for (const md of mat.models) {
495
+ const w = s._work(md), idx = md.idx, k = idx.length;
496
+ for (let a = 0; a < k; a++) {
497
+ w.z[a] = zeta[idx[a]];
498
+ w.fixed[a] = mode[idx[a]] === 2 ? 1 : 0;
499
+ w.t[a] = cFix[idx[a]];
500
+ }
501
+ md.invert(w.z, w.fixed, w.t, background);
502
+ md.evaluate(w.z, w.c, w.K, background);
503
+ for (let a = 0; a < k; a++) {
504
+ zeta[idx[a]] = w.z[a];
505
+ cc[idx[a]] = w.c[a];
506
+ }
507
+ }
508
+ }