driftlet 0.9.0 → 0.10.0

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/solver.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // and its derivative g′(a) = α e^{αa} + (1−α) e^{−(1−α)a}.
3
3
  function bvFactor(a, alpha) {
4
4
  const e = Math.exp(-(1 - alpha) * a);
5
- return { g: e * Math.expm1(a), gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
5
+ // (far from equilibrium, the plain difference: the product would give 0·∞ there)
6
+ const g = Math.abs(a) > 50 ? Math.exp(alpha * a) - e : e * Math.expm1(a);
7
+ return { g, gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
6
8
  }
7
9
 
8
10
  // Solve the K×K complex system (Ar + i Ai) x = (last column), partial pivoting by modulus.
@@ -224,10 +226,14 @@ export class Solver {
224
226
  model.ports.forEach((port, k) => {
225
227
  if (port.surface.length > 0) port.nodes.forEach((g, w) => ((this.surfPort[g] = k), (this.surfW[g] = w)));
226
228
  });
229
+ // Each surface species of each port, as a column of what's conserved: [port, species].
230
+ this.surfCols = model.ports.flatMap((port, k) => port.surface.map((_, q) => [k, q]));
231
+ this.surfColOf = model.ports.map((port, k) => this.surfCols.findIndex(([kk]) => kk === k));
227
232
  this.th = new Float64Array(nNodes * nSurf);
228
233
  this.thOld = new Float64Array(nNodes * nSurf);
229
- this.th0 = new Float64Array(nNodes); // the bare fraction θ₀, directly (1 − Σθ cancels as θ → 1)
230
- this.th0Old = new Float64Array(nNodes);
234
+ // The bare fraction θ₀, directly (1 − Σθ cancels as θ → 1); 1 where there's no surface.
235
+ this.th0 = new Float64Array(nNodes).fill(1);
236
+ this.th0Old = new Float64Array(nNodes).fill(1);
231
237
 
232
238
  // Per-node material data, flattened [g·n + i].
233
239
  this.present = new Uint8Array(nNodes * n);
@@ -348,6 +354,13 @@ export class Solver {
348
354
  this.portQStart = new Float64Array(model.ports.length);
349
355
  this.portQOld = new Float64Array(model.ports.length);
350
356
  this.portQEnd = null;
357
+ // And per node, the charge per volume the capacitances hold against the window (Σ aσ, over
358
+ // every port with one there: two gates on one channel, say; the ions hold −Σ aσ), its slope
359
+ // Σ a·C per volt, and the ports [terminal, index in its window].
360
+ this.qg = new Float64Array(grid.nNodes);
361
+ this.qgSlope = new Float64Array(grid.nNodes);
362
+ this.qgTerms = Array.from({ length: grid.nNodes }, () => []);
363
+ model.ports.forEach((port, k) => port.capacitance && port.nodes.forEach((g, w) => this.qgTerms[g].push([2 + k, w])));
351
364
  this.contactDOld = { left: 0, right: 0 };
352
365
  this.contactDStart = { left: 0, right: 0 };
353
366
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
@@ -428,11 +441,15 @@ export class Solver {
428
441
  // A port that only exchanges (an O₂ supply, a leak) is solved there too: left out, its window
429
442
  // fell back on the ill-conditioned terms, and a closed neutral electrolyte whose potential is
430
443
  // set only by a face reaction couldn't take a step.
444
+ // An end node is solved there too if its contact is closed to every ion and leaves φ alone.
431
445
  const held = new Uint8Array(nNodes);
432
- held[0] = held[nNodes - 1] = 1;
446
+ const closed = (side) => model.contacts[side].phi.type === 'neutral' && model.contacts[side].species.every((l, i) => species[i].z === 0 ? l.type !== 'equilibrium' : l.type === 'blocked');
447
+ this.closedEnd = { left: closed('left'), right: closed('right') };
448
+ held[0] = this.closedEnd.left ? 0 : 1;
449
+ held[nNodes - 1] = this.closedEnd.right ? 0 : 1;
433
450
  for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
434
451
  this.chargeNode = new Uint8Array(nNodes);
435
- const neutralFace = (f) => f >= 0 && f < model.interfaces.length && model.interfaces[f].phi.type === 'neutral';
452
+ const neutralFace = (f) => (f === -1 ? this.closedEnd.left : f === model.interfaces.length ? this.closedEnd.right : model.interfaces[f].phi.type === 'neutral');
436
453
  regions.forEach((reg, r) => {
437
454
  const mat = materials[reg.material];
438
455
  if (mat.conductor || mat.epsr !== 0) return;
@@ -456,6 +473,8 @@ export class Solver {
456
473
  this._findStretches();
457
474
  this.initFromComposition();
458
475
  this.referenceAmounts = this.stretches.map((st) => this.amount(st));
476
+ // And what each surface species held then (a conserved combination through one counts it).
477
+ this.surfaceRef = Float64Array.from(this.surfCols, (_, j) => this.surfaceAmount(j));
459
478
  // ∫ (flux in − flux out) dt through the contacts, per stretch, since the reference.
460
479
  this.boundaryIntake = new Float64Array(this.stretches.length);
461
480
  }
@@ -582,12 +601,14 @@ export class Solver {
582
601
  // its faces; both are left to giant time steps.
583
602
  this.constraints = [];
584
603
  const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
585
- const add = (parts, rowStretch) => {
604
+ const add = (parts, rowStretch, surface = []) => {
586
605
  const st = this.stretches[rowStretch];
587
- const nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
606
+ let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
607
+ for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
588
608
  for (const p of parts) this.stretches[p.stretch].conserved = true;
589
609
  this.constraints.push({
590
610
  parts,
611
+ surface,
591
612
  row: this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
592
613
  idx: new Int32Array(nNodes * this.M),
593
614
  w: new Float64Array(nNodes * this.M),
@@ -599,12 +620,15 @@ export class Solver {
599
620
  this.stretches.forEach((st, k) => {
600
621
  if (st.spectator && st.mobile) add([{ stretch: k, w: 1 }], k);
601
622
  });
623
+ const S = this.stretches.length;
602
624
  for (const w of this.moieties) {
603
- const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
604
- if (parts.length === 1 && this.stretches[parts[0].stretch].spectator) continue; // (above)
625
+ const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
626
+ const surface = [...w.keys()].filter((k) => k >= S && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
627
+ if (parts.length === 0) continue; // (a surface's own: left to giant time steps)
628
+ if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
605
629
  if (parts.some((p) => !this.stretches[p.stretch].mobile || conductor(this.stretches[p.stretch]))) continue;
606
630
  // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
607
- add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
631
+ add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch, surface);
608
632
  }
609
633
  // A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
610
634
  // node by node, since nothing carries it anywhere: at each node its weighted sum stays what
@@ -667,26 +691,32 @@ export class Solver {
667
691
  // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
668
692
  // for each stretch fed from outside. Exact integer data, so plain elimination will do.
669
693
  _conservedMoieties() {
670
- const { model, n } = this, S = this.stretches.length;
694
+ const { model, n } = this, S = this.stretches.length, C = S + this.surfCols.length;
671
695
  const rows = [];
672
696
  model.interfaces.forEach((itf, f) => {
673
697
  for (const rx of itf.reactions) {
674
- const row = new Float64Array(S);
698
+ const row = new Float64Array(C);
675
699
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
676
700
  rows.push(row);
677
701
  }
678
702
  });
679
- for (const port of model.ports) {
703
+ // An electrode surface's species are columns too, after the stretches (S + j for surface
704
+ // column j), so a combination that passes through them (A⁺ + e⁻ = S, S + e⁻ = B⁻) is conserved
705
+ // with what the surface holds.
706
+ model.ports.forEach((port, k) => {
680
707
  for (const rx of port.reactions) {
681
- const row = new Float64Array(S); // (the electrode's carrier is outside the device)
682
- for (const p of rx.part) if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
708
+ const row = new Float64Array(C); // (the electrode's carrier is outside the device)
709
+ for (const p of rx.part) {
710
+ if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
711
+ else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
712
+ }
683
713
  rows.push(row);
684
714
  }
685
- }
715
+ });
686
716
  model.regions.forEach((reg, q) => {
687
717
  for (const rx of model.reactions) {
688
718
  if (!(rx.kf[reg.material] > 0)) continue;
689
- const row = new Float64Array(S);
719
+ const row = new Float64Array(C);
690
720
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
691
721
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
692
722
  rows.push(row);
@@ -694,34 +724,34 @@ export class Solver {
694
724
  });
695
725
  this.stretches.forEach((st, k) => {
696
726
  if (!st.contactFed) return;
697
- const row = new Float64Array(S);
727
+ const row = new Float64Array(C);
698
728
  row[k] = 1;
699
729
  rows.push(row);
700
730
  });
701
731
  // Reduced row echelon form; the free columns give the null space.
702
732
  const pivots = [];
703
733
  let r = 0;
704
- for (let col = 0; col < S && r < rows.length; col++) {
734
+ for (let col = 0; col < C && r < rows.length; col++) {
705
735
  let best = r;
706
736
  for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
707
737
  if (Math.abs(rows[best][col]) < 1e-9) continue;
708
738
  [rows[r], rows[best]] = [rows[best], rows[r]];
709
739
  const pr = rows[r], pv = pr[col];
710
- for (let j = 0; j < S; j++) pr[j] /= pv;
740
+ for (let j = 0; j < C; j++) pr[j] /= pv;
711
741
  for (let i = 0; i < rows.length; i++) {
712
742
  if (i === r || rows[i][col] === 0) continue;
713
743
  const fct = rows[i][col];
714
- for (let j = 0; j < S; j++) rows[i][j] -= fct * pr[j];
744
+ for (let j = 0; j < C; j++) rows[i][j] -= fct * pr[j];
715
745
  }
716
746
  pivots.push(col);
717
747
  r++;
718
748
  }
719
- const isPivot = new Uint8Array(S);
749
+ const isPivot = new Uint8Array(C);
720
750
  for (const col of pivots) isPivot[col] = 1;
721
751
  const basis = [];
722
- for (let free = 0; free < S; free++) {
752
+ for (let free = 0; free < C; free++) {
723
753
  if (isPivot[free]) continue;
724
- const w = new Float64Array(S);
754
+ const w = new Float64Array(C);
725
755
  w[free] = 1;
726
756
  pivots.forEach((col, i) => {
727
757
  const v = -rows[i][free];
@@ -761,6 +791,19 @@ export class Solver {
761
791
  }
762
792
  }
763
793
  }
794
+ // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
795
+ for (const { col, w } of cs.surface) {
796
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
797
+ reference += w * this.surfaceRef[col];
798
+ port.nodes.forEach((g, j) => {
799
+ const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
800
+ amount += f * tq;
801
+ for (let x = 0; x < ns; x++) {
802
+ cs.idx[len] = this.rix[b * M + 1 + n + x];
803
+ cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
804
+ }
805
+ });
806
+ }
764
807
  cs.len = len;
765
808
  cs.res = amount - reference;
766
809
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
@@ -839,6 +882,14 @@ export class Solver {
839
882
  }
840
883
 
841
884
 
885
+ /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
886
+ surfaceAmount(j) {
887
+ const [k, q] = this.surfCols[j], port = this.model.ports[k], vol = this.model.grid.vol;
888
+ let s = 0;
889
+ port.nodes.forEach((g, w) => (s += vol[g] * this.portArea[k][w] * this.th[g * this.nSurf + q]));
890
+ return port.surface[0].capacity * s;
891
+ }
892
+
842
893
  /** Total amount (mol per unit area) of a stretch's species in the current state. */
843
894
  amount(stretch) {
844
895
  const { n, c, model } = this;
@@ -1086,12 +1137,28 @@ export class Solver {
1086
1137
  surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1087
1138
  }
1088
1139
  this.computeConcentrations();
1089
- // A floating electrode spread through a port starts where its reactions pass the current it's
1090
- // set (none, behind a resistance): at its mixed potential in the start's composition, not
1091
- // level with a held terminal, which can be volts away and pass an absurd current.
1140
+ // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
1141
+ // composition, neutral without it, assumes; with reactions too, its double layer then charges
1142
+ // toward their mixed potential.
1092
1143
  for (const k of this.floating) {
1093
1144
  const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1094
- if (!port || port.reactions.length === 0) continue;
1145
+ if (!port?.capacitance) continue;
1146
+ let w = 0, sum = 0;
1147
+ port.nodes.forEach((g, j) => {
1148
+ if (this.phiUndefined[g]) return;
1149
+ const a = this.model.grid.vol[g] * this.portArea[t.index][j];
1150
+ w += a;
1151
+ sum += a * this.VT * u[this.blockOfNode[g] * M];
1152
+ });
1153
+ if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1154
+ }
1155
+ // A floating electrode spread through a port (without a double layer) starts where its
1156
+ // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1157
+ // the start's composition, not level with a held terminal, which can be volts away and pass
1158
+ // an absurd current.
1159
+ for (const k of this.floating) {
1160
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1161
+ if (!port || port.reactions.length === 0 || port.capacitance) continue;
1095
1162
  const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1096
1163
  const current = (V) => {
1097
1164
  this.termV[k] = V;
@@ -1130,8 +1197,7 @@ export class Solver {
1130
1197
  pref *= p.side === 2 ? Math.exp(e * (u[o] + uLo[o] - port.surface[p.s].mu0 / RT)) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1131
1198
  }
1132
1199
  if (rx.bare) pref *= this.th0[g];
1133
- // (far from equilibrium, the plain difference: bvFactor's product would give 0·∞ there)
1134
- return pref * (Math.abs(a) > 50 ? Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a) : bvFactor(a, rx.alpha).g);
1200
+ return pref * bvFactor(a, rx.alpha).g;
1135
1201
  }
1136
1202
 
1137
1203
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1494,6 +1560,18 @@ export class Solver {
1494
1560
  }
1495
1561
  }
1496
1562
  res[R[lb]] -= v * q;
1563
+ // The pivot's row (charge continuity) left storage out, the ions' net charge being constant
1564
+ // under neutrality. Against a capacitance it isn't: neutrality makes it −(aσ + ρ_fixed)/F,
1565
+ // so their storage is that less what the step started with, Σ z c_old, over dt (in units
1566
+ // of the pivot's balance).
1567
+ if (this.qgTerms[g].length > 0) {
1568
+ const f = -v / (FARADAY * z[k] * dt);
1569
+ let qOld = this.rhoFixed[g];
1570
+ for (let i = 0; i < n; i++) if (present[gn + i]) qOld += F * z[i] * cOld[gn + i];
1571
+ res[rk] += f * (this.qg[g] + qOld);
1572
+ B[offB[b] + lk * m + p] += -f * this.qgSlope[g] * this.VT;
1573
+ for (const [kq, w] of this.qgTerms[g]) termB[kq][rk] += f * this.portArea[kq - 2][w] * this.model.ports[kq - 2].capacitance.C;
1574
+ }
1497
1575
  }
1498
1576
  }
1499
1577
 
@@ -2019,7 +2097,10 @@ export class Solver {
2019
2097
  this.termDI[k] = 0;
2020
2098
  this.termI[k] = 0;
2021
2099
  }
2022
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt));
2100
+ this.qg.fill(0);
2101
+ this.qgSlope.fill(0);
2102
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
2103
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, true));
2023
2104
  this._contact('left', dt);
2024
2105
  this._contact('right', dt);
2025
2106
  for (const k of this.floating) this._circuit(k);
@@ -2051,16 +2132,18 @@ export class Solver {
2051
2132
 
2052
2133
  // An internal port (terminal k): its exchange with each node of its window, as a source per
2053
2134
  // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
2054
- // row's residual, read just before. Its current into the device is Σ z F × the sources.
2055
- _port(port, flux, k, dt) {
2135
+ // row's residual, read just before. Its current into the device is Σ z F × the sources. In two
2136
+ // passes over the ports: everything else, then the held levels (`holds`), so that a level reads
2137
+ // every other port's terms at its nodes, and none is added into its row after.
2138
+ _port(port, flux, k, dt, holds) {
2056
2139
  const R = this.rix;
2057
2140
  const { n, M, u, uLo, res, z, VT } = this;
2058
2141
  const F = FARADAY, vol = this.model.grid.vol;
2059
2142
  const B = this.termB[k], C = this.termC[k];
2060
- flux.fill(0);
2143
+ if (!holds) flux.fill(0);
2061
2144
  for (let i = 0; i < n; i++) {
2062
2145
  const link = port.species[i];
2063
- if (link.type === 'blocked') continue;
2146
+ if (link.type === 'blocked' || (link.type === 'equilibrium') !== holds) continue;
2064
2147
  const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
2065
2148
  for (const g of port.nodes) {
2066
2149
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
@@ -2087,10 +2170,13 @@ export class Solver {
2087
2170
  }
2088
2171
  }
2089
2172
  }
2173
+ if (holds) {
2174
+ for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2175
+ return;
2176
+ }
2090
2177
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2091
2178
  if (port.capacitance) this._portCapacitance(port, k, dt);
2092
2179
  if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2093
- for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2094
2180
  }
2095
2181
 
2096
2182
  // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
@@ -2167,6 +2253,8 @@ export class Solver {
2167
2253
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2168
2254
  const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
2169
2255
  const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2256
+ this.qg[g] += area[w] * Cs * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2257
+ this.qgSlope[g] += area[w] * Cs;
2170
2258
  Q += q;
2171
2259
  dQdV += s;
2172
2260
  res[R[b * M]] -= q;
@@ -2221,10 +2309,13 @@ export class Solver {
2221
2309
  const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
2222
2310
  const dyn = Number.isFinite(dt);
2223
2311
 
2224
- // Before anything is added, each balance residual is the flux into the device here.
2312
+ // Before anything is added, each balance residual is the flux into the device here. (A
2313
+ // closed end solved in charge rows has its ions' storage left out until later: none of them
2314
+ // passes, so their flux is zero.)
2315
+ const closedIons = this.combining && dyn && this.chargeNode[g] === 1;
2225
2316
  let I = 0;
2226
2317
  for (let i = 0; i < n; i++) {
2227
- const active = this.loc[b * M + 1 + i] >= 0;
2318
+ const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0);
2228
2319
  const nIn = active ? res[R[b * M + 1 + i]] : 0;
2229
2320
  flux[i] = sgn * nIn; // (toward +x)
2230
2321
  if (active && z[i] !== 0) {
@@ -2583,7 +2674,7 @@ export class Solver {
2583
2674
  /**
2584
2675
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
2585
2676
  * estimated against an explicit predictor through the previous states, and controlled to
2586
- * `tol` thermal units per step in every potential (φ̂, each η, a floating terminal). The first
2677
+ * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
2587
2678
  * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
2588
2679
  * animation can call it once per frame; the step size carries over between calls.
2589
2680
  */
@@ -2663,7 +2754,8 @@ export class Solver {
2663
2754
  continue;
2664
2755
  }
2665
2756
  } else {
2666
- const r = this.step(h, { method, guess: this._extrapolate(guess, this.time + h) });
2757
+ // (Order 2 once three consistent states can check it.)
2758
+ const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, this.time + h) });
2667
2759
  iterations += r.iterations;
2668
2760
  if (!r.converged) {
2669
2761
  rejected++;
@@ -2683,6 +2775,11 @@ export class Solver {
2683
2775
  }
2684
2776
  steps++;
2685
2777
  grow = 0;
2778
+ // The state the first step started from needn't satisfy the algebraic equations (φ in a
2779
+ // strictly neutral region, an interface's unknowns, after a start or a jump), and they
2780
+ // jump in the first instant; a predictor through it would see that jump as error on every
2781
+ // step after. The history starts from the half step.
2782
+ if (half) this.history.length = 1;
2686
2783
  dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
2687
2784
  if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
2688
2785
  if (half) {
@@ -2809,6 +2906,8 @@ export class Solver {
2809
2906
  for (let i = 0; i < n; i++) {
2810
2907
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
2811
2908
  }
2909
+ const k = this.surfPort[g]; // an electrode surface's coverages, as η
2910
+ if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(u[b * M + 1 + n + q] - ref[b * M + 1 + n + q]));
2812
2911
  }
2813
2912
  return mx;
2814
2913
  }
@@ -3263,6 +3362,18 @@ export class Solver {
3263
3362
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
3264
3363
  // a change of drive).
3265
3364
  solveSteady(opts = {}) {
3365
+ // A terminal that passes current only by charging (a gate, a capacitance with no species
3366
+ // through it) has no steady state under a current drive: it charges for ever, or at I = 0
3367
+ // keeps whatever charge it started with, which a steady solve doesn't know.
3368
+ const { species } = this.model, ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
3369
+ for (const k of this.floating) {
3370
+ const t = this.terms[k];
3371
+ if (t.drive.kind !== 'I') continue;
3372
+ const only = t.kind === 'port' ? (p) => p.reactions.length === 0 && !ions(p.species) : (c) => !ions(c.species);
3373
+ if (only(t.kind === 'port' ? this.model.ports[t.index] : this.model.contacts[t.side])) {
3374
+ throw new DeviceError(`${t.kind === 'port' ? `ports[${t.index}]` : `contacts.${t.side}`} passes current only by charging, so driven by a current it has no steady state; hold it at V (or a source behind R), or advance() in time`);
3375
+ }
3376
+ }
3266
3377
  const r = this._steadyFromHere(opts);
3267
3378
  this.atSteady = r.converged;
3268
3379
  return r;
package/src/types.js CHANGED
@@ -197,6 +197,10 @@
197
197
  */
198
198
 
199
199
  /**
200
+ * The device's totals through its cross-section (currents, fluxes through contacts and ports,
201
+ * charges, amounts, reaction totals, impedance) are per m² of a planar device, as below, and
202
+ * whole (A, mol/s, C, mol, Ω; per metre of a cylinder) for one with a `geometry`. Rates at a
203
+ * face or an electrode are always per m² of it.
200
204
  * @typedef {object} Solution
201
205
  * @property {Float64Array} x node positions, m (interface positions appear twice)
202
206
  * @property {Int32Array} region region index per node
package/types/checks.d.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
14
14
  * parts) and the definition's likely unit slips.
15
15
  * - **balance** (a steady state): each species' ledger, what comes in through each terminal and
16
- * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
17
- * species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
18
- * and it says where every carrier went.
16
+ * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) (mol/s if the device
17
+ * isn't planar) and, for a charged species, as a current F·|z|·rate. The terms must sum to
18
+ * zero; under light it's J = F(G − R), and it says where every carrier went.
19
19
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
20
20
  * what came in.
21
21
  * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
package/types/index.d.ts CHANGED
@@ -93,6 +93,7 @@ export class Device {
93
93
  snap: any;
94
94
  solvedV: any;
95
95
  referenceAmounts: any;
96
+ surfaceRef: any;
96
97
  };
97
98
  /**
98
99
  * Back to a checkpoint taken on this device.
package/types/live.d.ts CHANGED
@@ -48,6 +48,7 @@ export class Session {
48
48
  snap: any;
49
49
  solvedV: any;
50
50
  referenceAmounts: any;
51
+ surfaceRef: any;
51
52
  };
52
53
  sol: import("./types.js").Solution;
53
54
  };
@@ -9,7 +9,10 @@
9
9
  * @param {{ face: number } | { port: string | number, x?: number }} where a face's index, or a port
10
10
  * (name or index) and the position in its window (m, the device's x; default its first node)
11
11
  * @param {number | ArrayLike<number>} V the metal's level(s), V
12
- * @returns {{ V: Float64Array, current: Float64Array, reactions: { equation: string, level: number,
12
+ * @param {{ surface?: 'held' | 'equilibrium' }} [opts] an electrode port's surface coverages: held as
13
+ * in `sol` (the default: this instant's curves), or in equilibrium at each V (the steady-state
14
+ * curve, with a passive film's active–passive peak), each species by the reaction that makes it
15
+ * @returns {{ V: Float64Array, current: Float64Array, bare?: Float64Array, reactions: { equation: string, level: number,
13
16
  * rate: Float64Array, current: Float64Array }[] }} per reaction: its equilibrium level here (where
14
17
  * its rate is zero), its rate (mol/(m²·s) of electrode, forward) and its current (A/m², anodic
15
18
  * positive: into the solution); and their sum
@@ -19,9 +22,12 @@ export function polarization(device: import("./index.js").Device, sol: object, w
19
22
  } | {
20
23
  port: string | number;
21
24
  x?: number;
22
- }, V: number | ArrayLike<number>): {
25
+ }, V: number | ArrayLike<number>, opts?: {
26
+ surface?: "held" | "equilibrium";
27
+ }): {
23
28
  V: Float64Array;
24
29
  current: Float64Array;
30
+ bare?: Float64Array;
25
31
  reactions: {
26
32
  equation: string;
27
33
  level: number;
package/types/solver.d.ts CHANGED
@@ -17,6 +17,8 @@ export class Solver {
17
17
  blockOfFace: Int32Array<ArrayBuffer>;
18
18
  surfPort: Int32Array<any>;
19
19
  surfW: Int32Array<any>;
20
+ surfCols: any;
21
+ surfColOf: any;
20
22
  th: Float64Array<ArrayBuffer>;
21
23
  thOld: Float64Array<ArrayBuffer>;
22
24
  th0: Float64Array<any>;
@@ -65,6 +67,9 @@ export class Solver {
65
67
  portQStart: Float64Array<any>;
66
68
  portQOld: Float64Array<any>;
67
69
  portQEnd: any;
70
+ qg: Float64Array<any>;
71
+ qgSlope: Float64Array<any>;
72
+ qgTerms: any[][];
68
73
  contactDOld: {
69
74
  left: number;
70
75
  right: number;
@@ -94,12 +99,17 @@ export class Solver {
94
99
  generationScale: number;
95
100
  hasGeneration: any;
96
101
  rxsIn: any;
102
+ closedEnd: {
103
+ left: any;
104
+ right: any;
105
+ };
97
106
  chargeNode: Uint8Array<any>;
98
107
  chargeNodes: Int32Array<ArrayBuffer>;
99
108
  combining: boolean;
100
109
  bookkeeping: any;
101
110
  bookkeepingFaces: number[];
102
111
  referenceAmounts: number[];
112
+ surfaceRef: Float64Array<ArrayBuffer>;
103
113
  boundaryIntake: Float64Array<ArrayBuffer>;
104
114
  _activeSlots(): void;
105
115
  active: Uint8Array<ArrayBuffer>;
@@ -115,18 +125,20 @@ export class Solver {
115
125
  _findStretches(): void;
116
126
  stretches: any[];
117
127
  stretchOf: Int32Array<ArrayBuffer>;
118
- moieties: Float64Array<ArrayBuffer>[];
128
+ moieties: Float64Array<any>[];
119
129
  constraints: any[];
120
130
  localConstraints: any[];
121
131
  constrained: boolean;
122
132
  _captureLocal(): void;
123
133
  _applyLocalConstraints(): void;
124
- _conservedMoieties(): Float64Array<ArrayBuffer>[];
134
+ _conservedMoieties(): Float64Array<any>[];
125
135
  _directSteady(): boolean;
126
136
  _applyConstraints(): void;
127
137
  _solveBordered(rhs: any, delta: any, deltaV: any, pins: any): void;
128
138
  dWork: Float64Array<ArrayBuffer>;
129
139
  borderCols: any[];
140
+ /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
141
+ surfaceAmount(j: any): number;
130
142
  /** Total amount (mol per unit area) of a stretch's species in the current state. */
131
143
  amount(stretch: any): number;
132
144
  redrive(): void;
@@ -184,7 +196,7 @@ export class Solver {
184
196
  _terminals(dt: any): void;
185
197
  _circuit(k: any): void;
186
198
  _captureRow(b: any, s: any, w: any, into: any): number;
187
- _port(port: any, flux: any, k: any, dt: any): void;
199
+ _port(port: any, flux: any, k: any, dt: any, holds: any): void;
188
200
  _portReaction(port: any, rx: any, area: any, rates: any, flux: any, k: any): void;
189
201
  _portCapacitance(port: any, k: any, dt: any): void;
190
202
  _surfaceStorage(port: any, dt: any): void;
@@ -236,7 +248,7 @@ export class Solver {
236
248
  /**
237
249
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
238
250
  * estimated against an explicit predictor through the previous states, and controlled to
239
- * `tol` thermal units per step in every potential (φ̂, each η, a floating terminal). The first
251
+ * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
240
252
  * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
241
253
  * animation can call it once per frame; the step size carries over between calls.
242
254
  */
package/types/types.d.ts CHANGED
@@ -471,6 +471,12 @@ export type ContactResult = {
471
471
  */
472
472
  current: number;
473
473
  };
474
+ /**
475
+ * The device's totals through its cross-section (currents, fluxes through contacts and ports,
476
+ * charges, amounts, reaction totals, impedance) are per m² of a planar device, as below, and
477
+ * whole (A, mol/s, C, mol, Ω; per metre of a cylinder) for one with a `geometry`. Rates at a
478
+ * face or an electrode are always per m² of it.
479
+ */
474
480
  export type Solution = {
475
481
  /**
476
482
  * node positions, m (interface positions appear twice)