driftlet 0.7.0 → 0.9.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
@@ -195,13 +195,16 @@ export class Solver {
195
195
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
196
196
  // one per face reaction (its rate), as many as the busiest face has.
197
197
  const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
198
- const M = n + 1 + nRx;
198
+ // (and at an electrode port's nodes, one per surface species: its coverage's η)
199
+ const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
200
+ const M = n + 1 + Math.max(nRx, nSurf);
199
201
  const nNodes = grid.nNodes;
200
202
  const nFaces = regions.length - 1;
201
203
  const nB = nNodes + nFaces;
202
204
  this.n = n;
203
205
  this.M = M;
204
206
  this.nRx = nRx;
207
+ this.nSurf = nSurf;
205
208
  this.nNodes = nNodes;
206
209
  this.nFaces = nFaces;
207
210
  this.nB = nB;
@@ -214,6 +217,18 @@ export class Solver {
214
217
  this.blockOfFace = new Int32Array(nFaces);
215
218
  for (let f = 0; f < nFaces; f++) this.blockOfFace[f] = grid.regionEnd[f] + f + 1;
216
219
 
220
+ // Electrode ports' surfaces: each node's port (−1: none) and its index in the port's window;
221
+ // the coverages there, θ [g·nSurf + s], now and at the step's start.
222
+ this.surfPort = new Int32Array(nNodes).fill(-1);
223
+ this.surfW = new Int32Array(nNodes);
224
+ model.ports.forEach((port, k) => {
225
+ if (port.surface.length > 0) port.nodes.forEach((g, w) => ((this.surfPort[g] = k), (this.surfW[g] = w)));
226
+ });
227
+ this.th = new Float64Array(nNodes * nSurf);
228
+ 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);
231
+
217
232
  // Per-node material data, flattened [g·n + i].
218
233
  this.present = new Uint8Array(nNodes * n);
219
234
  this.cRef = new Float64Array(nNodes * n);
@@ -327,6 +342,12 @@ export class Solver {
327
342
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
328
343
  this.portFlux = model.ports.map(() => new Float64Array(n)); // into the device, mol/(m²·s)
329
344
  this.contactD = { left: 0, right: 0 };
345
+ // A capacitive port's charge (its side's, Σ vol·a·σ over its window), as the contacts' D: now,
346
+ // at the step's start and at the previous step's end.
347
+ this.portQ = new Float64Array(model.ports.length);
348
+ this.portQStart = new Float64Array(model.ports.length);
349
+ this.portQOld = new Float64Array(model.ports.length);
350
+ this.portQEnd = null;
330
351
  this.contactDOld = { left: 0, right: 0 };
331
352
  this.contactDStart = { left: 0, right: 0 };
332
353
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
@@ -374,6 +395,13 @@ export class Solver {
374
395
  nu: Float64Array.from([...rx.reactants.map((p) => p.nu), ...rx.products.map((p) => -p.nu)]),
375
396
  }));
376
397
 
398
+ // Each reacting port's electrode area per volume at its window's nodes (a profile's mean over
399
+ // each node's box, as for a rate constant), and its reactions' rates there, mol/(m²·s).
400
+ this.portArea = model.ports.map((port) =>
401
+ port.area === null ? null : Float64Array.from(port.nodes, (g) => port.area.value ?? profileMean(port.area, gx[g] - (g > 0 ? segLength[g - 1] : 0) / 2, gx[g] + (g < segLength.length ? segLength[g] : 0) / 2)),
402
+ );
403
+ this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
404
+
377
405
  // Whether any region is strictly neutral (ε = 0, not a conductor): see integrate().
378
406
  this.strictlyNeutral = model.regions.some((reg) => {
379
407
  const mat = materials[reg.material];
@@ -395,8 +423,14 @@ export class Solver {
395
423
  for (const port of model.ports) for (const g of port.nodes) need[g] = 1;
396
424
  // Nodes of strictly neutral (ε = 0) regions that stay neutral, where φ is defined: interior
397
425
  // ones, and edges at neutral faces (a capacitive or pinned face's edge node holds the face's
398
- // charge), away from contacts and ports. On a transient step these are solved in better-
399
- // conditioned terms (see _chargeRows).
426
+ // charge), away from the contacts and from ports that hold a level (which replaces a balance
427
+ // row). On a transient step these are solved in better-conditioned terms (see _chargeRows).
428
+ // A port that only exchanges (an O₂ supply, a leak) is solved there too: left out, its window
429
+ // fell back on the ill-conditioned terms, and a closed neutral electrolyte whose potential is
430
+ // set only by a face reaction couldn't take a step.
431
+ const held = new Uint8Array(nNodes);
432
+ held[0] = held[nNodes - 1] = 1;
433
+ for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
400
434
  this.chargeNode = new Uint8Array(nNodes);
401
435
  const neutralFace = (f) => f >= 0 && f < model.interfaces.length && model.interfaces[f].phi.type === 'neutral';
402
436
  regions.forEach((reg, r) => {
@@ -405,7 +439,7 @@ export class Solver {
405
439
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
406
440
  for (let g = g0; g <= g1; g++) {
407
441
  const edgeOk = (g > g0 || neutralFace(r - 1)) && (g < g1 || neutralFace(r));
408
- if (edgeOk && !need[g] && !this.phiUndefined[g]) this.chargeNode[g] = 1;
442
+ if (edgeOk && !held[g] && !this.phiUndefined[g]) this.chargeNode[g] = 1;
409
443
  }
410
444
  });
411
445
  this.chargeNodes = Int32Array.from([...this.chargeNode.keys()].filter((g) => this.chargeNode[g]));
@@ -440,6 +474,8 @@ export class Solver {
440
474
  }
441
475
  active[b * M] = this.phiUndefined[g] ? 0 : 1;
442
476
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
477
+ const k = this.surfPort[g];
478
+ if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
443
479
  }
444
480
  model.interfaces.forEach((itf, f) => {
445
481
  const bf = this.blockOfFace[f];
@@ -502,10 +538,15 @@ export class Solver {
502
538
  if (rx.kf[m] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
503
539
  }
504
540
  }
505
- const ports = model.ports.flatMap((port, k) => (port.region >= r0 && port.region <= r && port.species[i].type !== 'blocked' ? [k] : []));
506
- const connected = leftOpen || rightOpen || ports.length > 0;
541
+ const inside = (port) => port.region >= r0 && port.region <= r;
542
+ const linked = model.ports.flatMap((port, k) => (inside(port) && port.species[i].type !== 'blocked' ? [k] : []));
543
+ // made or consumed by a port's reactions (an electrode spread through its window)
544
+ const reacting = model.ports.flatMap((port, k) => (inside(port) && port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i)) ? [k] : []));
545
+ if (reacting.length > 0) reactive = true;
546
+ const connected = leftOpen || rightOpen || linked.length > 0;
507
547
  this.stretches.push({
508
- ports,
548
+ linked, // ports that hold or exchange it
549
+ ports: [...new Set([...linked, ...reacting])], // ports that bring any in
509
550
  species: i,
510
551
  regions: [r0, r],
511
552
  nodes: [grid.regionStart[r0], grid.regionEnd[r]],
@@ -635,6 +676,13 @@ export class Solver {
635
676
  rows.push(row);
636
677
  }
637
678
  });
679
+ for (const port of model.ports) {
680
+ 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;
683
+ rows.push(row);
684
+ }
685
+ }
638
686
  model.regions.forEach((reg, q) => {
639
687
  for (const rx of model.reactions) {
640
688
  if (!(rx.kf[reg.material] > 0)) continue;
@@ -820,11 +868,11 @@ export class Solver {
820
868
  });
821
869
  }
822
870
 
823
- /** η_i (μ̄/RT) of a port's outside level for species i. */
871
+ /** η_i (μ̄/RT) of a port's outside level for species i (its electrode's carrier: at V itself). */
824
872
  portEta(port, i) {
825
873
  const link = port.species[i];
826
874
  const V = this.termV[2 + this.model.ports.indexOf(port)];
827
- return this.z[i] === 0 ? link.mu / this.model.RT : (this.z[i] * (V + link.offset)) / this.VT;
875
+ return this.z[i] === 0 ? link.mu / this.model.RT : (this.z[i] * (V + (link.offset ?? 0))) / this.VT;
828
876
  }
829
877
 
830
878
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
@@ -890,7 +938,7 @@ export class Solver {
890
938
  } else {
891
939
  const left = st.regions[0] === 0 ? this.contactEta('left', i) : NaN;
892
940
  eta[i] = Number.isFinite(left) ? left : this.contactEta('right', i);
893
- if (!Number.isFinite(eta[i]) && st.ports.length > 0) eta[i] = this.portEta(model.ports[st.ports[0]], i);
941
+ if (!Number.isFinite(eta[i]) && st.linked.length > 0) eta[i] = this.portEta(model.ports[st.linked[0]], i);
894
942
  mode[i] = 1;
895
943
  if (!Number.isFinite(eta[i])) {
896
944
  // Fed only through reactions (no level held at a contact): start from c0.
@@ -961,6 +1009,25 @@ export class Solver {
961
1009
  }
962
1010
  }
963
1011
  }
1012
+ const electrode = model.ports.find((port) => port.region === r && port.reactions.length > 0);
1013
+ if (!mat.phiFree && !responds && mat.ideal && electrode) {
1014
+ // Nor here, but an electrode spread through it does: start with the port's first reaction
1015
+ // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
1016
+ for (const rx of electrode.reactions) {
1017
+ let a = rx.fixedA, s = 0;
1018
+ for (const p of rx.part) {
1019
+ if (p.side === 1) a -= p.nu * this.portEta(electrode, p.i);
1020
+ else if (mode[p.i] === 2) {
1021
+ a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
1022
+ s += p.nu * z[p.i];
1023
+ } else s = NaN;
1024
+ }
1025
+ if (s !== 0 && Number.isFinite(s)) {
1026
+ phiHat = a / s;
1027
+ break;
1028
+ }
1029
+ }
1030
+ }
964
1031
  const neutralPhi = (ph) => {
965
1032
  let lo = ph - 1, hi = ph + 1;
966
1033
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
@@ -1011,7 +1078,60 @@ export class Solver {
1011
1078
  const shift = level(t.side) - level(this.terms[held].side);
1012
1079
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1013
1080
  }
1081
+ // Electrode surfaces at their starting coverages: η = μ°/RT + ln(θ/θ₀).
1082
+ for (let g = 0; g < this.nNodes; g++) {
1083
+ const k = this.surfPort[g];
1084
+ if (k < 0) continue;
1085
+ const surf = model.ports[k].surface, b = this.blockOfNode[g], bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1086
+ surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1087
+ }
1014
1088
  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.
1092
+ for (const k of this.floating) {
1093
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1094
+ if (!port || port.reactions.length === 0) continue;
1095
+ const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1096
+ const current = (V) => {
1097
+ this.termV[k] = V;
1098
+ let I = 0;
1099
+ port.reactions.forEach((rx) => {
1100
+ let q = 0;
1101
+ for (const p of rx.part) if (p.side === 0) q += p.nu * z[p.i];
1102
+ port.nodes.forEach((g, w) => (I += q * FARADAY * this.model.grid.vol[g] * this.portArea[t.index][w] * this._portRate(port, rx, g)));
1103
+ });
1104
+ return I;
1105
+ };
1106
+ // Current into the device rises with V (oxidation); bracket, then bisect.
1107
+ let lo = this.termV[k] - 1, hi = this.termV[k] + 1;
1108
+ while (current(lo) > want && lo > -1e3) lo -= 2 * (hi - lo);
1109
+ while (current(hi) < want && hi < 1e3) hi += 2 * (hi - lo);
1110
+ for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
1111
+ const m = 0.5 * (lo + hi);
1112
+ if (current(m) < want) lo = m;
1113
+ else hi = m;
1114
+ }
1115
+ this.termV[k] = 0.5 * (lo + hi);
1116
+ }
1117
+ }
1118
+
1119
+ // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
1120
+ _portRate(port, rx, g) {
1121
+ const { n, M, u, uLo, c, nSurf, th } = this, b = this.blockOfNode[g], RT = this.model.RT;
1122
+ let pref = rx.k0, a = rx.fixedA;
1123
+ for (const p of rx.part) {
1124
+ if (p.side === 1) {
1125
+ a -= p.nu * this.portEta(port, p.i);
1126
+ continue;
1127
+ }
1128
+ 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;
1129
+ a -= p.nu * (u[o] + uLo[o]);
1130
+ 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
+ }
1132
+ 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);
1015
1135
  }
1016
1136
 
1017
1137
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1040,6 +1160,23 @@ export class Solver {
1040
1160
  }
1041
1161
  }
1042
1162
 
1163
+ // Each electrode surface's coverages from its η: Langmuir on shared sites, θ_s = e^{ζ_s}/(1 + Σ e^{ζ}),
1164
+ // ζ = η − μ°/RT (so θ_s/θ₀ = e^{ζ_s}), at every node of a surfaced window.
1165
+ _coverages() {
1166
+ const { n, M, u, uLo, nSurf, th } = this, RT = this.model.RT;
1167
+ for (let g = 0; g < this.nNodes; g++) {
1168
+ const k = this.surfPort[g];
1169
+ if (k < 0) continue;
1170
+ const surf = this.model.ports[k].surface, b = this.blockOfNode[g];
1171
+ let m = 0;
1172
+ 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);
1173
+ let sum = Math.exp(-m);
1174
+ 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));
1175
+ for (let q = 0; q < surf.length; q++) th[g * nSurf + q] /= sum;
1176
+ this.th0[g] = Math.exp(-m) / sum;
1177
+ }
1178
+ }
1179
+
1043
1180
  computeConcentrations() {
1044
1181
  const { n, M, u, uLo, c, z } = this;
1045
1182
  for (let g = 0; g < this.nNodes; g++) {
@@ -1054,7 +1191,7 @@ export class Solver {
1054
1191
  // Excess carriers: zero in the bulk, the surface sheet at a charged face.
1055
1192
  for (let i = 0; i < n; i++) c[g * n + i] = 0;
1056
1193
  const f = this.sheetFace[g];
1057
- if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M]) / (z[im] * FARADAY * this.model.grid.vol[g]);
1194
+ if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M] * this.model.grid.area[g]) / (z[im] * FARADAY * this.model.grid.vol[g]);
1058
1195
  continue;
1059
1196
  }
1060
1197
  for (let i = 0; i < n; i++) {
@@ -1062,6 +1199,7 @@ export class Solver {
1062
1199
  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;
1063
1200
  }
1064
1201
  }
1202
+ this._coverages();
1065
1203
  }
1066
1204
 
1067
1205
  _work(md) {
@@ -1165,7 +1303,7 @@ export class Solver {
1165
1303
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
1166
1304
  if (mat.conductor) {
1167
1305
  for (let g = g0; g <= g1; g++) this._nodeConductor(g, dt);
1168
- for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
1306
+ for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
1169
1307
  continue;
1170
1308
  }
1171
1309
  if (mat.ideal) this._nodesDilute(g0, g1, dt);
@@ -1173,7 +1311,7 @@ export class Solver {
1173
1311
  const rxs = this.rxsIn[reg.material];
1174
1312
  if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
1175
1313
  for (let s = g0; s < g1; s++) {
1176
- const bL = s + r, bR = bL + 1, h = grid.segLength[s];
1314
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
1177
1315
  this._segmentDisplacement(s, bL, bR, mat, h);
1178
1316
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
1179
1317
  if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
@@ -1201,7 +1339,7 @@ export class Solver {
1201
1339
  const r = 1 + im, k = g * n + im;
1202
1340
  res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1203
1341
  const f = this.sheetFace[g];
1204
- if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, this.sheetSign[g] / (z[im] * FARADAY * dt));
1342
+ if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, (this.sheetSign[g] * this.model.grid.area[g]) / (z[im] * FARADAY * dt));
1205
1343
  }
1206
1344
 
1207
1345
  // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
@@ -1450,9 +1588,9 @@ export class Solver {
1450
1588
  _segmentsDilute(g0, g1, region, mat, vel) {
1451
1589
  const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1452
1590
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1453
- const segLength = this.model.grid.segLength;
1591
+ const { segLength, segArea } = this.model.grid;
1454
1592
  for (let s = g0; s < g1; s++) {
1455
- const bL = s + region, bR = bL + 1, h = segLength[s];
1593
+ const bL = s + region, bR = bL + 1, h = segLength[s] / segArea[s]; // (fluxes are totals through the cross-section)
1456
1594
  const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1457
1595
  const phiL = u[bL * M], phiR = u[bR * M];
1458
1596
  for (let i = 0; i < n; i++) {
@@ -1535,15 +1673,16 @@ export class Solver {
1535
1673
  this._j(bf, 0, bL, 0, -kC);
1536
1674
  }
1537
1675
  }
1538
- // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead.
1539
- const gL = grid.regionEnd[f], gR = gL + 1;
1676
+ // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead. The
1677
+ // flux node's unknowns are per area, so what they carry is that times the face's area.
1678
+ const gL = grid.regionEnd[f], gR = gL + 1, Af = grid.area[gL];
1540
1679
  if (this.nodeConductor[gL] < 0) {
1541
- res[R[bL * M]] += u[bf * M];
1542
- this._j(bL, 0, bf, 0, 1);
1680
+ res[R[bL * M]] += Af * u[bf * M];
1681
+ this._j(bL, 0, bf, 0, Af);
1543
1682
  }
1544
1683
  if (this.nodeConductor[gR] < 0) {
1545
- res[R[bR * M]] -= u[bf * M] + itf.sheetCharge;
1546
- this._j(bR, 0, bf, 0, -1);
1684
+ res[R[bR * M]] -= Af * (u[bf * M] + itf.sheetCharge);
1685
+ this._j(bR, 0, bf, 0, -Af);
1547
1686
  }
1548
1687
  for (let i = 0; i < n; i++) {
1549
1688
  const r = 1 + i, o = bf * M + r;
@@ -1583,12 +1722,12 @@ export class Solver {
1583
1722
  this._j(bf, r, bL, r, -gG);
1584
1723
  this._j(bf, r, bR, r, gG);
1585
1724
  }
1586
- res[R[bL * M + r]] += u[o];
1587
- this._j(bL, r, bf, r, 1);
1588
- res[R[bR * M + r]] -= u[o];
1589
- this._j(bR, r, bf, r, -1);
1725
+ res[R[bL * M + r]] += Af * u[o];
1726
+ this._j(bL, r, bf, r, Af);
1727
+ res[R[bR * M + r]] -= Af * u[o];
1728
+ this._j(bR, r, bf, r, -Af);
1590
1729
  }
1591
- itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR));
1730
+ itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
1592
1731
  }
1593
1732
 
1594
1733
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -1805,7 +1944,7 @@ export class Solver {
1805
1944
  // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1806
1945
  // there when ν > 0). The rate couples the two edge nodes only through the face block between
1807
1946
  // them, which keeps the system block-tridiagonal.
1808
- _faceReaction(rx, k, f, bf, bL, bR) {
1947
+ _faceReaction(rx, k, f, bf, bL, bR, Af) {
1809
1948
  const { n, M, u, uLo, c, res } = this;
1810
1949
  const R = this.rix;
1811
1950
  const grid = this.model.grid;
@@ -1865,8 +2004,8 @@ export class Solver {
1865
2004
  }
1866
2005
  for (const p of rx.part) {
1867
2006
  const b = p.side ? bR : bL;
1868
- res[R[b * M + 1 + p.i]] -= p.nu * u[o];
1869
- this._j(b, 1 + p.i, bf, slot, -p.nu);
2007
+ res[R[b * M + 1 + p.i]] -= Af * p.nu * u[o];
2008
+ this._j(b, 1 + p.i, bf, slot, -Af * p.nu);
1870
2009
  }
1871
2010
  }
1872
2011
 
@@ -1880,7 +2019,7 @@ export class Solver {
1880
2019
  this.termDI[k] = 0;
1881
2020
  this.termI[k] = 0;
1882
2021
  }
1883
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k));
2022
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt));
1884
2023
  this._contact('left', dt);
1885
2024
  this._contact('right', dt);
1886
2025
  for (const k of this.floating) this._circuit(k);
@@ -1913,7 +2052,7 @@ export class Solver {
1913
2052
  // An internal port (terminal k): its exchange with each node of its window, as a source per
1914
2053
  // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
1915
2054
  // row's residual, read just before. Its current into the device is Σ z F × the sources.
1916
- _port(port, flux, k) {
2055
+ _port(port, flux, k, dt) {
1917
2056
  const R = this.rix;
1918
2057
  const { n, M, u, uLo, res, z, VT } = this;
1919
2058
  const F = FARADAY, vol = this.model.grid.vol;
@@ -1948,9 +2087,122 @@ export class Solver {
1948
2087
  }
1949
2088
  }
1950
2089
  }
2090
+ port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2091
+ if (port.capacitance) this._portCapacitance(port, k, dt);
2092
+ if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
1951
2093
  for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
1952
2094
  }
1953
2095
 
2096
+ // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
2097
+ // the electrode's carrier at the port's level (activity 1), times the electrode's area per
2098
+ // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
2099
+ // species it makes and consumes, and the port's current.
2100
+ _portReaction(port, rx, area, rates, flux, k) {
2101
+ 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;
2102
+ const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA, surf = port.surface;
2103
+ // The carrier's part of the affinity, and how it moves with the port's voltage.
2104
+ let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
2105
+ for (const p of rx.part) {
2106
+ if (p.side === 1) {
2107
+ aV -= p.nu * this.portEta(port, p.i);
2108
+ daV -= (p.nu * z[p.i]) / VT;
2109
+ } else if (p.side === 0) q += p.nu * z[p.i];
2110
+ }
2111
+ const slot = (p) => (p.side === 2 ? 1 + n + p.s : 1 + p.i);
2112
+ port.nodes.forEach((g, w) => {
2113
+ const b = this.blockOfNode[g], s = vol[g] * area[w];
2114
+ d.fill(0);
2115
+ let pref = rx.k0, aHi = aV, aLo = 0;
2116
+ for (const p of rx.part) {
2117
+ if (p.side === 1) continue;
2118
+ const o = b * M + slot(p);
2119
+ aHi -= p.nu * u[o];
2120
+ aLo -= p.nu * uLo[o];
2121
+ const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
2122
+ if (p.side === 2) {
2123
+ // a surface species at activity θ/θ₀ = e^ζ
2124
+ pref *= Math.exp(e * (u[o] + uLo[o] - surf[p.s].mu0 / RT));
2125
+ d[1 + n + p.s] += e;
2126
+ } else {
2127
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2128
+ this._dlnc(g, p.i, e, d);
2129
+ }
2130
+ }
2131
+ if (rx.bare) {
2132
+ // on bare metal only: the free fraction θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
2133
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= th[g * nSurf + x];
2134
+ pref *= this.th0[g];
2135
+ }
2136
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
2137
+ const rate = pref * bv;
2138
+ rates[w] = rate;
2139
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η = −ν
2140
+ for (let t = 0; t < M; t++) d[t] *= rate;
2141
+ for (const p of rx.part) if (p.side !== 1) d[slot(p)] -= pref * gp * p.nu;
2142
+ const dV = pref * gp * daV;
2143
+ for (const p of rx.part) {
2144
+ if (p.side === 1) continue;
2145
+ // the region's species per volume of the window; a surface's per area of electrode
2146
+ const row = slot(p), o = b * M + row, f = p.side === 2 ? 1 : s;
2147
+ res[R[o]] -= f * p.nu * rate;
2148
+ if (p.side === 0) flux[p.i] += s * p.nu * rate;
2149
+ for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -f * p.nu * d[t]);
2150
+ B[R[o]] -= f * p.nu * dV;
2151
+ }
2152
+ for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
2153
+ this.termDI[k] += q * F * s * dV;
2154
+ });
2155
+ }
2156
+
2157
+ // A capacitance spread through a port's window: per area of electrode, σ = C (V − zeroCharge − φ)
2158
+ // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2159
+ // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2160
+ // contact sets is left to the contact.
2161
+ _portCapacitance(port, k, dt) {
2162
+ const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2163
+ const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2164
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
2165
+ let Q = 0, dQdV = 0;
2166
+ port.nodes.forEach((g, w) => {
2167
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2168
+ const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
2169
+ const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2170
+ Q += q;
2171
+ dQdV += s;
2172
+ res[R[b * M]] -= q;
2173
+ this._j(b, 0, b, 0, s * VT);
2174
+ B[R[b * M]] -= s;
2175
+ if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
2176
+ });
2177
+ this.portQ[k - 2] = Q;
2178
+ if (Number.isFinite(dt)) {
2179
+ this.termI[k] += (Q - this.portQStart[k - 2]) / dt;
2180
+ this.termDI[k] += dQdV / dt;
2181
+ }
2182
+ }
2183
+
2184
+ // An electrode surface's storage, per area of electrode: Γ (θ_s − θ_s,old)/dt in each surface
2185
+ // species' row, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
2186
+ _surfaceStorage(port, dt) {
2187
+ const { n, M, res, nSurf, th, thOld } = this, R = this.rix, surf = port.surface, G = surf[0].capacity;
2188
+ for (const g of port.nodes) {
2189
+ const b = this.blockOfNode[g];
2190
+ // Near full coverage θ − θ_old cancels (both ~1, the change ~θ₀): the dominant species'
2191
+ // change comes from the small ones instead, Δθ = −Δθ₀ − Σ_others Δθ.
2192
+ let top = 0;
2193
+ for (let q = 1; q < surf.length; q++) if (th[g * nSurf + q] > th[g * nSurf + top]) top = q;
2194
+ for (let q = 0; q < surf.length; q++) {
2195
+ const tq = th[g * nSurf + q];
2196
+ let change = tq - thOld[g * nSurf + q];
2197
+ if (q === top && tq > 0.5) {
2198
+ change = -(this.th0[g] - this.th0Old[g]);
2199
+ for (let x = 0; x < surf.length; x++) if (x !== q) change -= th[g * nSurf + x] - thOld[g * nSurf + x];
2200
+ }
2201
+ res[R[b * M + 1 + n + q]] += (G * change) / dt;
2202
+ 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]));
2203
+ }
2204
+ }
2205
+ }
1954
2206
 
1955
2207
  // One contact (terminal 0 or 1): record the flux through its outer face and the current into
1956
2208
  // the device (read from the end box before anything here is added: whatever the box needs
@@ -1966,6 +2218,7 @@ export class Solver {
1966
2218
  const b = this.blockOfNode[g];
1967
2219
  const sgn = side === 'left' ? 1 : -1;
1968
2220
  const flux = this.contactFlux[side];
2221
+ const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
1969
2222
  const dyn = Number.isFinite(dt);
1970
2223
 
1971
2224
  // Before anything is added, each balance residual is the flux into the device here.
@@ -1986,13 +2239,13 @@ export class Solver {
1986
2239
  const link = ct.species[i];
1987
2240
  const o = b * M + 1 + i;
1988
2241
  if (link.type === 'exchange') {
1989
- res[R[o]] -= link.k * (link.mu / model.RT - (u[o] + uLo[o]));
1990
- this._j(b, 1 + i, b, 1 + i, link.k);
2242
+ res[R[o]] -= Ac * link.k * (link.mu / model.RT - (u[o] + uLo[o]));
2243
+ this._j(b, 1 + i, b, 1 + i, Ac * link.k);
1991
2244
  } else if (link.type === 'conductance') {
1992
2245
  const Vi = (VT * (u[o] + uLo[o])) / z[i];
1993
- res[R[o]] -= (link.G * (Vt + link.offset - Vi)) / (z[i] * F);
1994
- this._j(b, 1 + i, b, 1 + i, (link.G * VT) / (z[i] * z[i] * F));
1995
- B[R[o]] += -link.G / (z[i] * F);
2246
+ res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2247
+ this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2248
+ B[R[o]] += (-Ac * link.G) / (z[i] * F);
1996
2249
  }
1997
2250
  }
1998
2251
 
@@ -2014,15 +2267,15 @@ export class Solver {
2014
2267
  this.contactD[side] = 0;
2015
2268
  } else if (link.type === 'capacitive') {
2016
2269
  // Gate, or metal across a Stern layer, at φ_g = V_t − zeroCharge. D toward +x.
2017
- const Din = link.C * (Vt - link.zeroCharge - VT * u[b * M]);
2270
+ const Din = Ac * link.C * (Vt - link.zeroCharge - VT * u[b * M]);
2018
2271
  res[R[b * M]] -= Din;
2019
- this._j(b, 0, b, 0, link.C * VT);
2020
- B[R[b * M]] += -link.C;
2272
+ this._j(b, 0, b, 0, Ac * link.C * VT);
2273
+ B[R[b * M]] += -Ac * link.C;
2021
2274
  this.contactD[side] = sgn * Din;
2022
2275
  if (dyn) {
2023
2276
  I += (Din - Dstart) / dt;
2024
- C[R[b * M]] += (-link.C * VT) / dt;
2025
- this.termDI[k] += link.C / dt;
2277
+ C[R[b * M]] += (-Ac * link.C * VT) / dt;
2278
+ this.termDI[k] += (Ac * link.C) / dt;
2026
2279
  }
2027
2280
  } else if (link.type === 'pinned' || link.type === 'bulk') {
2028
2281
  // The Poisson residual is the outside's charge, D_in.
@@ -2106,6 +2359,8 @@ export class Solver {
2106
2359
  for (let i = 0; i < n; i++) {
2107
2360
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + i]]));
2108
2361
  }
2362
+ const k = this.surfPort[g]; // an electrode surface's coverages, as η
2363
+ 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]]));
2109
2364
  }
2110
2365
  return mx;
2111
2366
  }
@@ -2262,14 +2517,15 @@ export class Solver {
2262
2517
  this.uPrevLo.set(this.uLo);
2263
2518
  this.termVPrev.set(this.termV);
2264
2519
  this.computeConcentrations();
2265
- const cN = Float64Array.from(this.c);
2520
+ const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
2266
2521
  // Contact displacement before the step: as it was at the end of the previous step (under
2267
2522
  // the parameters then), so a gate-voltage change shows up as displacement current.
2268
- if (!this.contactDEnd) {
2523
+ if (!this.contactDEnd || !this.portQEnd) {
2269
2524
  this._assembleBookkeeping(dt);
2270
2525
  this.contactDEnd = { ...this.contactD };
2526
+ this.portQEnd = Float64Array.from(this.portQ);
2271
2527
  }
2272
- const DN = { ...this.contactDEnd };
2528
+ const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
2273
2529
  if (opts.guess) {
2274
2530
  // Start Newton from a predicted state (e.g. extrapolated from the history).
2275
2531
  this.u.set(opts.guess);
@@ -2280,11 +2536,17 @@ export class Solver {
2280
2536
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
2281
2537
  const { cOld } = this;
2282
2538
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
2539
+ for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
2540
+ for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
2283
2541
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
2542
+ this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
2284
2543
  dtEff = dt / a0;
2285
2544
  } else {
2286
2545
  this.cOld.set(cN);
2546
+ this.thOld.set(thN);
2547
+ this.th0Old.set(th0N);
2287
2548
  this.contactDStart = DN;
2549
+ this.portQStart = QN;
2288
2550
  }
2289
2551
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
2290
2552
  // A step landing on a breakpoint ends on it exactly.
@@ -2300,11 +2562,12 @@ export class Solver {
2300
2562
  }
2301
2563
  result.bdf = bdf;
2302
2564
  if (result.converged) {
2303
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, D: DN });
2565
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
2304
2566
  if (this.history.length > 3) this.history.length = 3;
2305
2567
  this.time = tEnd;
2306
2568
  this.lastDt = dtEff;
2307
2569
  this.contactDOld = this.contactDStart;
2570
+ this.portQOld = this.portQStart;
2308
2571
  this._accumulateBoundaryIntake(dtEff, cN);
2309
2572
  } else {
2310
2573
  this.u.set(this.uPrev);
@@ -2588,12 +2851,17 @@ export class Solver {
2588
2851
  u: Float64Array.from(this.u),
2589
2852
  uLo: Float64Array.from(this.uLo),
2590
2853
  cOld: Float64Array.from(this.cOld),
2854
+ thOld: Float64Array.from(this.thOld),
2855
+ th0Old: Float64Array.from(this.th0Old),
2591
2856
  time: this.time,
2592
2857
  lastDt: this.lastDt,
2593
2858
  atSteady: this.atSteady,
2594
2859
  contactDStart: this.contactDStart,
2595
2860
  contactDOld: this.contactDOld,
2596
2861
  contactDEnd: this.contactDEnd,
2862
+ portQStart: this.portQStart,
2863
+ portQOld: this.portQOld,
2864
+ portQEnd: this.portQEnd,
2597
2865
  boundaryIntake: Float64Array.from(this.boundaryIntake),
2598
2866
  history: this.history.slice(),
2599
2867
  termV: Float64Array.from(this.termV),
@@ -2605,12 +2873,17 @@ export class Solver {
2605
2873
  this.u.set(s.u);
2606
2874
  this.uLo.set(s.uLo);
2607
2875
  this.cOld.set(s.cOld);
2876
+ this.thOld.set(s.thOld);
2877
+ this.th0Old.set(s.th0Old);
2608
2878
  this.time = s.time;
2609
2879
  this.lastDt = s.lastDt;
2610
2880
  this.atSteady = s.atSteady;
2611
2881
  this.contactDStart = s.contactDStart;
2612
2882
  this.contactDOld = s.contactDOld;
2613
2883
  this.contactDEnd = s.contactDEnd;
2884
+ this.portQStart = s.portQStart;
2885
+ this.portQOld = s.portQOld;
2886
+ this.portQEnd = s.portQEnd;
2614
2887
  this.boundaryIntake.set(s.boundaryIntake);
2615
2888
  this.history = s.history.slice();
2616
2889
  this.termV.set(s.termV);
@@ -2647,8 +2920,11 @@ export class Solver {
2647
2920
  this.sourceTime = this.time;
2648
2921
  this.computeConcentrations();
2649
2922
  this.cOld.set(this.c);
2923
+ this.thOld.set(this.th);
2924
+ this.th0Old.set(this.th0);
2650
2925
  this.assemble(Infinity);
2651
2926
  this.contactDStart = { ...this.contactD };
2927
+ this.portQStart = Float64Array.from(this.portQ);
2652
2928
  this.assemble(Infinity);
2653
2929
  // The steady parts, then the storage parts from an assembly at a tiny dt.
2654
2930
  const J = { A: sys.A.slice(), B: sys.B.slice(), C: sys.C.slice() };
@@ -2910,7 +3186,7 @@ export class Solver {
2910
3186
  const reg = regions[r], mat = materials[reg.material];
2911
3187
  if (mat.conductor) {
2912
3188
  for (const g of nodes) this._nodeConductor(g, dt);
2913
- for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
3189
+ for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
2914
3190
  continue;
2915
3191
  }
2916
3192
  for (const g of nodes) {
@@ -2920,7 +3196,7 @@ export class Solver {
2920
3196
  const rxs = this.rxsIn[reg.material];
2921
3197
  if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
2922
3198
  for (const s of segs) {
2923
- const bL = s + r, bR = bL + 1, h = grid.segLength[s];
3199
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
2924
3200
  this._segmentDisplacement(s, bL, bR, mat, h);
2925
3201
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
2926
3202
  if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
@@ -2936,6 +3212,7 @@ export class Solver {
2936
3212
  _accumulateBoundaryIntake(dt, cN) {
2937
3213
  this._assembleBookkeeping(dt);
2938
3214
  this.contactDEnd = { ...this.contactD };
3215
+ this.portQEnd = Float64Array.from(this.portQ);
2939
3216
  if (!Number.isFinite(dt)) return;
2940
3217
  const last = this.model.regions.length - 1;
2941
3218
  const { n, cOld } = this, vol = this.model.grid.vol;