driftlet 0.8.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.
@@ -195,13 +197,16 @@ export class Solver {
195
197
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
196
198
  // one per face reaction (its rate), as many as the busiest face has.
197
199
  const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
198
- const M = n + 1 + nRx;
200
+ // (and at an electrode port's nodes, one per surface species: its coverage's η)
201
+ const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
202
+ const M = n + 1 + Math.max(nRx, nSurf);
199
203
  const nNodes = grid.nNodes;
200
204
  const nFaces = regions.length - 1;
201
205
  const nB = nNodes + nFaces;
202
206
  this.n = n;
203
207
  this.M = M;
204
208
  this.nRx = nRx;
209
+ this.nSurf = nSurf;
205
210
  this.nNodes = nNodes;
206
211
  this.nFaces = nFaces;
207
212
  this.nB = nB;
@@ -214,6 +219,22 @@ export class Solver {
214
219
  this.blockOfFace = new Int32Array(nFaces);
215
220
  for (let f = 0; f < nFaces; f++) this.blockOfFace[f] = grid.regionEnd[f] + f + 1;
216
221
 
222
+ // Electrode ports' surfaces: each node's port (−1: none) and its index in the port's window;
223
+ // the coverages there, θ [g·nSurf + s], now and at the step's start.
224
+ this.surfPort = new Int32Array(nNodes).fill(-1);
225
+ this.surfW = new Int32Array(nNodes);
226
+ model.ports.forEach((port, k) => {
227
+ if (port.surface.length > 0) port.nodes.forEach((g, w) => ((this.surfPort[g] = k), (this.surfW[g] = w)));
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));
232
+ this.th = new Float64Array(nNodes * nSurf);
233
+ this.thOld = new Float64Array(nNodes * nSurf);
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);
237
+
217
238
  // Per-node material data, flattened [g·n + i].
218
239
  this.present = new Uint8Array(nNodes * n);
219
240
  this.cRef = new Float64Array(nNodes * n);
@@ -327,6 +348,19 @@ export class Solver {
327
348
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
328
349
  this.portFlux = model.ports.map(() => new Float64Array(n)); // into the device, mol/(m²·s)
329
350
  this.contactD = { left: 0, right: 0 };
351
+ // A capacitive port's charge (its side's, Σ vol·a·σ over its window), as the contacts' D: now,
352
+ // at the step's start and at the previous step's end.
353
+ this.portQ = new Float64Array(model.ports.length);
354
+ this.portQStart = new Float64Array(model.ports.length);
355
+ this.portQOld = new Float64Array(model.ports.length);
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])));
330
364
  this.contactDOld = { left: 0, right: 0 };
331
365
  this.contactDStart = { left: 0, right: 0 };
332
366
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
@@ -407,11 +441,15 @@ export class Solver {
407
441
  // A port that only exchanges (an O₂ supply, a leak) is solved there too: left out, its window
408
442
  // fell back on the ill-conditioned terms, and a closed neutral electrolyte whose potential is
409
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.
410
445
  const held = new Uint8Array(nNodes);
411
- 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;
412
450
  for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
413
451
  this.chargeNode = new Uint8Array(nNodes);
414
- 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');
415
453
  regions.forEach((reg, r) => {
416
454
  const mat = materials[reg.material];
417
455
  if (mat.conductor || mat.epsr !== 0) return;
@@ -435,6 +473,8 @@ export class Solver {
435
473
  this._findStretches();
436
474
  this.initFromComposition();
437
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));
438
478
  // ∫ (flux in − flux out) dt through the contacts, per stretch, since the reference.
439
479
  this.boundaryIntake = new Float64Array(this.stretches.length);
440
480
  }
@@ -453,6 +493,8 @@ export class Solver {
453
493
  }
454
494
  active[b * M] = this.phiUndefined[g] ? 0 : 1;
455
495
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
496
+ const k = this.surfPort[g];
497
+ if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
456
498
  }
457
499
  model.interfaces.forEach((itf, f) => {
458
500
  const bf = this.blockOfFace[f];
@@ -559,12 +601,14 @@ export class Solver {
559
601
  // its faces; both are left to giant time steps.
560
602
  this.constraints = [];
561
603
  const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
562
- const add = (parts, rowStretch) => {
604
+ const add = (parts, rowStretch, surface = []) => {
563
605
  const st = this.stretches[rowStretch];
564
- 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;
565
608
  for (const p of parts) this.stretches[p.stretch].conserved = true;
566
609
  this.constraints.push({
567
610
  parts,
611
+ surface,
568
612
  row: this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
569
613
  idx: new Int32Array(nNodes * this.M),
570
614
  w: new Float64Array(nNodes * this.M),
@@ -576,12 +620,15 @@ export class Solver {
576
620
  this.stretches.forEach((st, k) => {
577
621
  if (st.spectator && st.mobile) add([{ stretch: k, w: 1 }], k);
578
622
  });
623
+ const S = this.stretches.length;
579
624
  for (const w of this.moieties) {
580
- const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
581
- 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)
582
629
  if (parts.some((p) => !this.stretches[p.stretch].mobile || conductor(this.stretches[p.stretch]))) continue;
583
630
  // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
584
- 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);
585
632
  }
586
633
  // A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
587
634
  // node by node, since nothing carries it anywhere: at each node its weighted sum stays what
@@ -644,26 +691,32 @@ export class Solver {
644
691
  // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
645
692
  // for each stretch fed from outside. Exact integer data, so plain elimination will do.
646
693
  _conservedMoieties() {
647
- const { model, n } = this, S = this.stretches.length;
694
+ const { model, n } = this, S = this.stretches.length, C = S + this.surfCols.length;
648
695
  const rows = [];
649
696
  model.interfaces.forEach((itf, f) => {
650
697
  for (const rx of itf.reactions) {
651
- const row = new Float64Array(S);
698
+ const row = new Float64Array(C);
652
699
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
653
700
  rows.push(row);
654
701
  }
655
702
  });
656
- 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) => {
657
707
  for (const rx of port.reactions) {
658
- const row = new Float64Array(S); // (the electrode's carrier is outside the device)
659
- 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
+ }
660
713
  rows.push(row);
661
714
  }
662
- }
715
+ });
663
716
  model.regions.forEach((reg, q) => {
664
717
  for (const rx of model.reactions) {
665
718
  if (!(rx.kf[reg.material] > 0)) continue;
666
- const row = new Float64Array(S);
719
+ const row = new Float64Array(C);
667
720
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
668
721
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
669
722
  rows.push(row);
@@ -671,34 +724,34 @@ export class Solver {
671
724
  });
672
725
  this.stretches.forEach((st, k) => {
673
726
  if (!st.contactFed) return;
674
- const row = new Float64Array(S);
727
+ const row = new Float64Array(C);
675
728
  row[k] = 1;
676
729
  rows.push(row);
677
730
  });
678
731
  // Reduced row echelon form; the free columns give the null space.
679
732
  const pivots = [];
680
733
  let r = 0;
681
- for (let col = 0; col < S && r < rows.length; col++) {
734
+ for (let col = 0; col < C && r < rows.length; col++) {
682
735
  let best = r;
683
736
  for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
684
737
  if (Math.abs(rows[best][col]) < 1e-9) continue;
685
738
  [rows[r], rows[best]] = [rows[best], rows[r]];
686
739
  const pr = rows[r], pv = pr[col];
687
- for (let j = 0; j < S; j++) pr[j] /= pv;
740
+ for (let j = 0; j < C; j++) pr[j] /= pv;
688
741
  for (let i = 0; i < rows.length; i++) {
689
742
  if (i === r || rows[i][col] === 0) continue;
690
743
  const fct = rows[i][col];
691
- 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];
692
745
  }
693
746
  pivots.push(col);
694
747
  r++;
695
748
  }
696
- const isPivot = new Uint8Array(S);
749
+ const isPivot = new Uint8Array(C);
697
750
  for (const col of pivots) isPivot[col] = 1;
698
751
  const basis = [];
699
- for (let free = 0; free < S; free++) {
752
+ for (let free = 0; free < C; free++) {
700
753
  if (isPivot[free]) continue;
701
- const w = new Float64Array(S);
754
+ const w = new Float64Array(C);
702
755
  w[free] = 1;
703
756
  pivots.forEach((col, i) => {
704
757
  const v = -rows[i][free];
@@ -738,6 +791,19 @@ export class Solver {
738
791
  }
739
792
  }
740
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
+ }
741
807
  cs.len = len;
742
808
  cs.res = amount - reference;
743
809
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
@@ -816,6 +882,14 @@ export class Solver {
816
882
  }
817
883
 
818
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
+
819
893
  /** Total amount (mol per unit area) of a stretch's species in the current state. */
820
894
  amount(stretch) {
821
895
  const { n, c, model } = this;
@@ -1055,13 +1129,36 @@ export class Solver {
1055
1129
  const shift = level(t.side) - level(this.terms[held].side);
1056
1130
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1057
1131
  }
1132
+ // Electrode surfaces at their starting coverages: η = μ°/RT + ln(θ/θ₀).
1133
+ for (let g = 0; g < this.nNodes; g++) {
1134
+ const k = this.surfPort[g];
1135
+ if (k < 0) continue;
1136
+ const surf = model.ports[k].surface, b = this.blockOfNode[g], bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1137
+ surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1138
+ }
1058
1139
  this.computeConcentrations();
1059
- // A floating electrode spread through a port starts where its reactions pass the current it's
1060
- // set (none, behind a resistance): at its mixed potential in the start's composition, not
1061
- // 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.
1062
1143
  for (const k of this.floating) {
1063
1144
  const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1064
- 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;
1065
1162
  const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1066
1163
  const current = (V) => {
1067
1164
  this.termV[k] = V;
@@ -1088,18 +1185,19 @@ export class Solver {
1088
1185
 
1089
1186
  // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
1090
1187
  _portRate(port, rx, g) {
1091
- const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1188
+ const { n, M, u, uLo, c, nSurf, th } = this, b = this.blockOfNode[g], RT = this.model.RT;
1092
1189
  let pref = rx.k0, a = rx.fixedA;
1093
1190
  for (const p of rx.part) {
1094
1191
  if (p.side === 1) {
1095
1192
  a -= p.nu * this.portEta(port, p.i);
1096
1193
  continue;
1097
1194
  }
1098
- a -= p.nu * (u[b * M + 1 + p.i] + uLo[b * M + 1 + p.i]);
1099
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
1195
+ const o = b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
1196
+ a -= p.nu * (u[o] + uLo[o]);
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);
1100
1198
  }
1101
- // (far from equilibrium, the plain difference: bvFactor's product would give 0·∞ there)
1102
- return pref * (Math.abs(a) > 50 ? Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a) : bvFactor(a, rx.alpha).g);
1199
+ if (rx.bare) pref *= this.th0[g];
1200
+ return pref * bvFactor(a, rx.alpha).g;
1103
1201
  }
1104
1202
 
1105
1203
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1128,6 +1226,23 @@ export class Solver {
1128
1226
  }
1129
1227
  }
1130
1228
 
1229
+ // Each electrode surface's coverages from its η: Langmuir on shared sites, θ_s = e^{ζ_s}/(1 + Σ e^{ζ}),
1230
+ // ζ = η − μ°/RT (so θ_s/θ₀ = e^{ζ_s}), at every node of a surfaced window.
1231
+ _coverages() {
1232
+ const { n, M, u, uLo, nSurf, th } = this, RT = this.model.RT;
1233
+ for (let g = 0; g < this.nNodes; g++) {
1234
+ const k = this.surfPort[g];
1235
+ if (k < 0) continue;
1236
+ const surf = this.model.ports[k].surface, b = this.blockOfNode[g];
1237
+ let m = 0;
1238
+ for (let q = 0; q < surf.length; q++) m = Math.max(m, u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT);
1239
+ let sum = Math.exp(-m);
1240
+ for (let q = 0; q < surf.length; q++) sum += (th[g * nSurf + q] = Math.exp(u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT - m));
1241
+ for (let q = 0; q < surf.length; q++) th[g * nSurf + q] /= sum;
1242
+ this.th0[g] = Math.exp(-m) / sum;
1243
+ }
1244
+ }
1245
+
1131
1246
  computeConcentrations() {
1132
1247
  const { n, M, u, uLo, c, z } = this;
1133
1248
  for (let g = 0; g < this.nNodes; g++) {
@@ -1150,6 +1265,7 @@ export class Solver {
1150
1265
  c[k] = this.present[k] ? this.cRef[k] * Math.exp(u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat) : 0;
1151
1266
  }
1152
1267
  }
1268
+ this._coverages();
1153
1269
  }
1154
1270
 
1155
1271
  _work(md) {
@@ -1444,6 +1560,18 @@ export class Solver {
1444
1560
  }
1445
1561
  }
1446
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
+ }
1447
1575
  }
1448
1576
  }
1449
1577
 
@@ -1969,7 +2097,10 @@ export class Solver {
1969
2097
  this.termDI[k] = 0;
1970
2098
  this.termI[k] = 0;
1971
2099
  }
1972
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k));
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));
1973
2104
  this._contact('left', dt);
1974
2105
  this._contact('right', dt);
1975
2106
  for (const k of this.floating) this._circuit(k);
@@ -2001,16 +2132,18 @@ export class Solver {
2001
2132
 
2002
2133
  // An internal port (terminal k): its exchange with each node of its window, as a source per
2003
2134
  // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
2004
- // row's residual, read just before. Its current into the device is Σ z F × the sources.
2005
- _port(port, flux, k) {
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) {
2006
2139
  const R = this.rix;
2007
2140
  const { n, M, u, uLo, res, z, VT } = this;
2008
2141
  const F = FARADAY, vol = this.model.grid.vol;
2009
2142
  const B = this.termB[k], C = this.termC[k];
2010
- flux.fill(0);
2143
+ if (!holds) flux.fill(0);
2011
2144
  for (let i = 0; i < n; i++) {
2012
2145
  const link = port.species[i];
2013
- if (link.type === 'blocked') continue;
2146
+ if (link.type === 'blocked' || (link.type === 'equilibrium') !== holds) continue;
2014
2147
  const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
2015
2148
  for (const g of port.nodes) {
2016
2149
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
@@ -2037,8 +2170,13 @@ export class Solver {
2037
2170
  }
2038
2171
  }
2039
2172
  }
2173
+ if (holds) {
2174
+ for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2175
+ return;
2176
+ }
2040
2177
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2041
- for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2178
+ if (port.capacitance) this._portCapacitance(port, k, dt);
2179
+ if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2042
2180
  }
2043
2181
 
2044
2182
  // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
@@ -2046,49 +2184,113 @@ export class Solver {
2046
2184
  // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
2047
2185
  // species it makes and consumes, and the port's current.
2048
2186
  _portReaction(port, rx, area, rates, flux, k) {
2049
- const { n, M, u, uLo, c, res, z, VT } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol;
2050
- const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA;
2187
+ const { n, M, u, uLo, c, res, z, VT, nSurf, th } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol, RT = this.model.RT;
2188
+ const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA, surf = port.surface;
2051
2189
  // The carrier's part of the affinity, and how it moves with the port's voltage.
2052
2190
  let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
2053
2191
  for (const p of rx.part) {
2054
2192
  if (p.side === 1) {
2055
2193
  aV -= p.nu * this.portEta(port, p.i);
2056
2194
  daV -= (p.nu * z[p.i]) / VT;
2057
- } else q += p.nu * z[p.i];
2195
+ } else if (p.side === 0) q += p.nu * z[p.i];
2058
2196
  }
2197
+ const slot = (p) => (p.side === 2 ? 1 + n + p.s : 1 + p.i);
2059
2198
  port.nodes.forEach((g, w) => {
2060
2199
  const b = this.blockOfNode[g], s = vol[g] * area[w];
2061
2200
  d.fill(0);
2062
2201
  let pref = rx.k0, aHi = aV, aLo = 0;
2063
2202
  for (const p of rx.part) {
2064
2203
  if (p.side === 1) continue;
2065
- const o = b * M + 1 + p.i;
2204
+ const o = b * M + slot(p);
2066
2205
  aHi -= p.nu * u[o];
2067
2206
  aLo -= p.nu * uLo[o];
2068
2207
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
2069
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2070
- this._dlnc(g, p.i, e, d);
2208
+ if (p.side === 2) {
2209
+ // a surface species at activity θ/θ₀ = e^ζ
2210
+ pref *= Math.exp(e * (u[o] + uLo[o] - surf[p.s].mu0 / RT));
2211
+ d[1 + n + p.s] += e;
2212
+ } else {
2213
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2214
+ this._dlnc(g, p.i, e, d);
2215
+ }
2216
+ }
2217
+ if (rx.bare) {
2218
+ // on bare metal only: the free fraction θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
2219
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= th[g * nSurf + x];
2220
+ pref *= this.th0[g];
2071
2221
  }
2072
2222
  const { g: bv, gp } = bvFactor(aHi + aLo, al);
2073
2223
  const rate = pref * bv;
2074
2224
  rates[w] = rate;
2075
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
2225
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η = −ν
2076
2226
  for (let t = 0; t < M; t++) d[t] *= rate;
2077
- for (const p of rx.part) if (p.side === 0) d[1 + p.i] -= pref * gp * p.nu;
2227
+ for (const p of rx.part) if (p.side !== 1) d[slot(p)] -= pref * gp * p.nu;
2078
2228
  const dV = pref * gp * daV;
2079
2229
  for (const p of rx.part) {
2080
2230
  if (p.side === 1) continue;
2081
- const row = 1 + p.i, o = b * M + row;
2082
- res[R[o]] -= s * p.nu * rate;
2083
- flux[p.i] += s * p.nu * rate;
2084
- for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -s * p.nu * d[t]);
2085
- B[R[o]] -= s * p.nu * dV;
2231
+ // the region's species per volume of the window; a surface's per area of electrode
2232
+ const row = slot(p), o = b * M + row, f = p.side === 2 ? 1 : s;
2233
+ res[R[o]] -= f * p.nu * rate;
2234
+ if (p.side === 0) flux[p.i] += s * p.nu * rate;
2235
+ for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -f * p.nu * d[t]);
2236
+ B[R[o]] -= f * p.nu * dV;
2086
2237
  }
2087
2238
  for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
2088
2239
  this.termDI[k] += q * F * s * dV;
2089
2240
  });
2090
2241
  }
2091
2242
 
2243
+ // A capacitance spread through a port's window: per area of electrode, σ = C (V − zeroCharge − φ)
2244
+ // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2245
+ // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2246
+ // contact sets is left to the contact.
2247
+ _portCapacitance(port, k, dt) {
2248
+ const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2249
+ const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2250
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
2251
+ let Q = 0, dQdV = 0;
2252
+ port.nodes.forEach((g, w) => {
2253
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2254
+ const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
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;
2258
+ Q += q;
2259
+ dQdV += s;
2260
+ res[R[b * M]] -= q;
2261
+ this._j(b, 0, b, 0, s * VT);
2262
+ B[R[b * M]] -= s;
2263
+ if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
2264
+ });
2265
+ this.portQ[k - 2] = Q;
2266
+ if (Number.isFinite(dt)) {
2267
+ this.termI[k] += (Q - this.portQStart[k - 2]) / dt;
2268
+ this.termDI[k] += dQdV / dt;
2269
+ }
2270
+ }
2271
+
2272
+ // An electrode surface's storage, per area of electrode: Γ (θ_s − θ_s,old)/dt in each surface
2273
+ // species' row, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
2274
+ _surfaceStorage(port, dt) {
2275
+ const { n, M, res, nSurf, th, thOld } = this, R = this.rix, surf = port.surface, G = surf[0].capacity;
2276
+ for (const g of port.nodes) {
2277
+ const b = this.blockOfNode[g];
2278
+ // Near full coverage θ − θ_old cancels (both ~1, the change ~θ₀): the dominant species'
2279
+ // change comes from the small ones instead, Δθ = −Δθ₀ − Σ_others Δθ.
2280
+ let top = 0;
2281
+ for (let q = 1; q < surf.length; q++) if (th[g * nSurf + q] > th[g * nSurf + top]) top = q;
2282
+ for (let q = 0; q < surf.length; q++) {
2283
+ const tq = th[g * nSurf + q];
2284
+ let change = tq - thOld[g * nSurf + q];
2285
+ if (q === top && tq > 0.5) {
2286
+ change = -(this.th0[g] - this.th0Old[g]);
2287
+ for (let x = 0; x < surf.length; x++) if (x !== q) change -= th[g * nSurf + x] - thOld[g * nSurf + x];
2288
+ }
2289
+ res[R[b * M + 1 + n + q]] += (G * change) / dt;
2290
+ for (let x = 0; x < surf.length; x++) this._j(b, 1 + n + q, b, 1 + n + x, ((G * tq) / dt) * ((q === x ? 1 : 0) - th[g * nSurf + x]));
2291
+ }
2292
+ }
2293
+ }
2092
2294
 
2093
2295
  // One contact (terminal 0 or 1): record the flux through its outer face and the current into
2094
2296
  // the device (read from the end box before anything here is added: whatever the box needs
@@ -2107,10 +2309,13 @@ export class Solver {
2107
2309
  const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
2108
2310
  const dyn = Number.isFinite(dt);
2109
2311
 
2110
- // 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;
2111
2316
  let I = 0;
2112
2317
  for (let i = 0; i < n; i++) {
2113
- const active = this.loc[b * M + 1 + i] >= 0;
2318
+ const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0);
2114
2319
  const nIn = active ? res[R[b * M + 1 + i]] : 0;
2115
2320
  flux[i] = sgn * nIn; // (toward +x)
2116
2321
  if (active && z[i] !== 0) {
@@ -2245,6 +2450,8 @@ export class Solver {
2245
2450
  for (let i = 0; i < n; i++) {
2246
2451
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + i]]));
2247
2452
  }
2453
+ const k = this.surfPort[g]; // an electrode surface's coverages, as η
2454
+ if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2248
2455
  }
2249
2456
  return mx;
2250
2457
  }
@@ -2401,14 +2608,15 @@ export class Solver {
2401
2608
  this.uPrevLo.set(this.uLo);
2402
2609
  this.termVPrev.set(this.termV);
2403
2610
  this.computeConcentrations();
2404
- const cN = Float64Array.from(this.c);
2611
+ const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
2405
2612
  // Contact displacement before the step: as it was at the end of the previous step (under
2406
2613
  // the parameters then), so a gate-voltage change shows up as displacement current.
2407
- if (!this.contactDEnd) {
2614
+ if (!this.contactDEnd || !this.portQEnd) {
2408
2615
  this._assembleBookkeeping(dt);
2409
2616
  this.contactDEnd = { ...this.contactD };
2617
+ this.portQEnd = Float64Array.from(this.portQ);
2410
2618
  }
2411
- const DN = { ...this.contactDEnd };
2619
+ const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
2412
2620
  if (opts.guess) {
2413
2621
  // Start Newton from a predicted state (e.g. extrapolated from the history).
2414
2622
  this.u.set(opts.guess);
@@ -2419,11 +2627,17 @@ export class Solver {
2419
2627
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
2420
2628
  const { cOld } = this;
2421
2629
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
2630
+ for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
2631
+ for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
2422
2632
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
2633
+ this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
2423
2634
  dtEff = dt / a0;
2424
2635
  } else {
2425
2636
  this.cOld.set(cN);
2637
+ this.thOld.set(thN);
2638
+ this.th0Old.set(th0N);
2426
2639
  this.contactDStart = DN;
2640
+ this.portQStart = QN;
2427
2641
  }
2428
2642
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
2429
2643
  // A step landing on a breakpoint ends on it exactly.
@@ -2439,11 +2653,12 @@ export class Solver {
2439
2653
  }
2440
2654
  result.bdf = bdf;
2441
2655
  if (result.converged) {
2442
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, D: DN });
2656
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
2443
2657
  if (this.history.length > 3) this.history.length = 3;
2444
2658
  this.time = tEnd;
2445
2659
  this.lastDt = dtEff;
2446
2660
  this.contactDOld = this.contactDStart;
2661
+ this.portQOld = this.portQStart;
2447
2662
  this._accumulateBoundaryIntake(dtEff, cN);
2448
2663
  } else {
2449
2664
  this.u.set(this.uPrev);
@@ -2459,7 +2674,7 @@ export class Solver {
2459
2674
  /**
2460
2675
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
2461
2676
  * estimated against an explicit predictor through the previous states, and controlled to
2462
- * `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
2463
2678
  * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
2464
2679
  * animation can call it once per frame; the step size carries over between calls.
2465
2680
  */
@@ -2539,7 +2754,8 @@ export class Solver {
2539
2754
  continue;
2540
2755
  }
2541
2756
  } else {
2542
- 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) });
2543
2759
  iterations += r.iterations;
2544
2760
  if (!r.converged) {
2545
2761
  rejected++;
@@ -2559,6 +2775,11 @@ export class Solver {
2559
2775
  }
2560
2776
  steps++;
2561
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;
2562
2783
  dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
2563
2784
  if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
2564
2785
  if (half) {
@@ -2685,6 +2906,8 @@ export class Solver {
2685
2906
  for (let i = 0; i < n; i++) {
2686
2907
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
2687
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]));
2688
2911
  }
2689
2912
  return mx;
2690
2913
  }
@@ -2727,12 +2950,17 @@ export class Solver {
2727
2950
  u: Float64Array.from(this.u),
2728
2951
  uLo: Float64Array.from(this.uLo),
2729
2952
  cOld: Float64Array.from(this.cOld),
2953
+ thOld: Float64Array.from(this.thOld),
2954
+ th0Old: Float64Array.from(this.th0Old),
2730
2955
  time: this.time,
2731
2956
  lastDt: this.lastDt,
2732
2957
  atSteady: this.atSteady,
2733
2958
  contactDStart: this.contactDStart,
2734
2959
  contactDOld: this.contactDOld,
2735
2960
  contactDEnd: this.contactDEnd,
2961
+ portQStart: this.portQStart,
2962
+ portQOld: this.portQOld,
2963
+ portQEnd: this.portQEnd,
2736
2964
  boundaryIntake: Float64Array.from(this.boundaryIntake),
2737
2965
  history: this.history.slice(),
2738
2966
  termV: Float64Array.from(this.termV),
@@ -2744,12 +2972,17 @@ export class Solver {
2744
2972
  this.u.set(s.u);
2745
2973
  this.uLo.set(s.uLo);
2746
2974
  this.cOld.set(s.cOld);
2975
+ this.thOld.set(s.thOld);
2976
+ this.th0Old.set(s.th0Old);
2747
2977
  this.time = s.time;
2748
2978
  this.lastDt = s.lastDt;
2749
2979
  this.atSteady = s.atSteady;
2750
2980
  this.contactDStart = s.contactDStart;
2751
2981
  this.contactDOld = s.contactDOld;
2752
2982
  this.contactDEnd = s.contactDEnd;
2983
+ this.portQStart = s.portQStart;
2984
+ this.portQOld = s.portQOld;
2985
+ this.portQEnd = s.portQEnd;
2753
2986
  this.boundaryIntake.set(s.boundaryIntake);
2754
2987
  this.history = s.history.slice();
2755
2988
  this.termV.set(s.termV);
@@ -2786,8 +3019,11 @@ export class Solver {
2786
3019
  this.sourceTime = this.time;
2787
3020
  this.computeConcentrations();
2788
3021
  this.cOld.set(this.c);
3022
+ this.thOld.set(this.th);
3023
+ this.th0Old.set(this.th0);
2789
3024
  this.assemble(Infinity);
2790
3025
  this.contactDStart = { ...this.contactD };
3026
+ this.portQStart = Float64Array.from(this.portQ);
2791
3027
  this.assemble(Infinity);
2792
3028
  // The steady parts, then the storage parts from an assembly at a tiny dt.
2793
3029
  const J = { A: sys.A.slice(), B: sys.B.slice(), C: sys.C.slice() };
@@ -3075,6 +3311,7 @@ export class Solver {
3075
3311
  _accumulateBoundaryIntake(dt, cN) {
3076
3312
  this._assembleBookkeeping(dt);
3077
3313
  this.contactDEnd = { ...this.contactD };
3314
+ this.portQEnd = Float64Array.from(this.portQ);
3078
3315
  if (!Number.isFinite(dt)) return;
3079
3316
  const last = this.model.regions.length - 1;
3080
3317
  const { n, cOld } = this, vol = this.model.grid.vol;
@@ -3125,6 +3362,18 @@ export class Solver {
3125
3362
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
3126
3363
  // a change of drive).
3127
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
+ }
3128
3377
  const r = this._steadyFromHere(opts);
3129
3378
  this.atSteady = r.converged;
3130
3379
  return r;