driftlet 0.6.0 → 0.8.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
@@ -374,6 +374,13 @@ export class Solver {
374
374
  nu: Float64Array.from([...rx.reactants.map((p) => p.nu), ...rx.products.map((p) => -p.nu)]),
375
375
  }));
376
376
 
377
+ // Each reacting port's electrode area per volume at its window's nodes (a profile's mean over
378
+ // each node's box, as for a rate constant), and its reactions' rates there, mol/(m²·s).
379
+ this.portArea = model.ports.map((port) =>
380
+ 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)),
381
+ );
382
+ this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
383
+
377
384
  // Whether any region is strictly neutral (ε = 0, not a conductor): see integrate().
378
385
  this.strictlyNeutral = model.regions.some((reg) => {
379
386
  const mat = materials[reg.material];
@@ -395,8 +402,14 @@ export class Solver {
395
402
  for (const port of model.ports) for (const g of port.nodes) need[g] = 1;
396
403
  // Nodes of strictly neutral (ε = 0) regions that stay neutral, where φ is defined: interior
397
404
  // 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).
405
+ // charge), away from the contacts and from ports that hold a level (which replaces a balance
406
+ // row). On a transient step these are solved in better-conditioned terms (see _chargeRows).
407
+ // A port that only exchanges (an O₂ supply, a leak) is solved there too: left out, its window
408
+ // fell back on the ill-conditioned terms, and a closed neutral electrolyte whose potential is
409
+ // set only by a face reaction couldn't take a step.
410
+ const held = new Uint8Array(nNodes);
411
+ held[0] = held[nNodes - 1] = 1;
412
+ for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
400
413
  this.chargeNode = new Uint8Array(nNodes);
401
414
  const neutralFace = (f) => f >= 0 && f < model.interfaces.length && model.interfaces[f].phi.type === 'neutral';
402
415
  regions.forEach((reg, r) => {
@@ -405,7 +418,7 @@ export class Solver {
405
418
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
406
419
  for (let g = g0; g <= g1; g++) {
407
420
  const edgeOk = (g > g0 || neutralFace(r - 1)) && (g < g1 || neutralFace(r));
408
- if (edgeOk && !need[g] && !this.phiUndefined[g]) this.chargeNode[g] = 1;
421
+ if (edgeOk && !held[g] && !this.phiUndefined[g]) this.chargeNode[g] = 1;
409
422
  }
410
423
  });
411
424
  this.chargeNodes = Int32Array.from([...this.chargeNode.keys()].filter((g) => this.chargeNode[g]));
@@ -502,10 +515,15 @@ export class Solver {
502
515
  if (rx.kf[m] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
503
516
  }
504
517
  }
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;
518
+ const inside = (port) => port.region >= r0 && port.region <= r;
519
+ const linked = model.ports.flatMap((port, k) => (inside(port) && port.species[i].type !== 'blocked' ? [k] : []));
520
+ // made or consumed by a port's reactions (an electrode spread through its window)
521
+ const reacting = model.ports.flatMap((port, k) => (inside(port) && port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i)) ? [k] : []));
522
+ if (reacting.length > 0) reactive = true;
523
+ const connected = leftOpen || rightOpen || linked.length > 0;
507
524
  this.stretches.push({
508
- ports,
525
+ linked, // ports that hold or exchange it
526
+ ports: [...new Set([...linked, ...reacting])], // ports that bring any in
509
527
  species: i,
510
528
  regions: [r0, r],
511
529
  nodes: [grid.regionStart[r0], grid.regionEnd[r]],
@@ -635,6 +653,13 @@ export class Solver {
635
653
  rows.push(row);
636
654
  }
637
655
  });
656
+ for (const port of model.ports) {
657
+ 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;
660
+ rows.push(row);
661
+ }
662
+ }
638
663
  model.regions.forEach((reg, q) => {
639
664
  for (const rx of model.reactions) {
640
665
  if (!(rx.kf[reg.material] > 0)) continue;
@@ -820,11 +845,11 @@ export class Solver {
820
845
  });
821
846
  }
822
847
 
823
- /** η_i (μ̄/RT) of a port's outside level for species i. */
848
+ /** η_i (μ̄/RT) of a port's outside level for species i (its electrode's carrier: at V itself). */
824
849
  portEta(port, i) {
825
850
  const link = port.species[i];
826
851
  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;
852
+ return this.z[i] === 0 ? link.mu / this.model.RT : (this.z[i] * (V + (link.offset ?? 0))) / this.VT;
828
853
  }
829
854
 
830
855
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
@@ -890,7 +915,7 @@ export class Solver {
890
915
  } else {
891
916
  const left = st.regions[0] === 0 ? this.contactEta('left', i) : NaN;
892
917
  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);
918
+ if (!Number.isFinite(eta[i]) && st.linked.length > 0) eta[i] = this.portEta(model.ports[st.linked[0]], i);
894
919
  mode[i] = 1;
895
920
  if (!Number.isFinite(eta[i])) {
896
921
  // Fed only through reactions (no level held at a contact): start from c0.
@@ -961,6 +986,25 @@ export class Solver {
961
986
  }
962
987
  }
963
988
  }
989
+ const electrode = model.ports.find((port) => port.region === r && port.reactions.length > 0);
990
+ if (!mat.phiFree && !responds && mat.ideal && electrode) {
991
+ // Nor here, but an electrode spread through it does: start with the port's first reaction
992
+ // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
993
+ for (const rx of electrode.reactions) {
994
+ let a = rx.fixedA, s = 0;
995
+ for (const p of rx.part) {
996
+ if (p.side === 1) a -= p.nu * this.portEta(electrode, p.i);
997
+ else if (mode[p.i] === 2) {
998
+ a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
999
+ s += p.nu * z[p.i];
1000
+ } else s = NaN;
1001
+ }
1002
+ if (s !== 0 && Number.isFinite(s)) {
1003
+ phiHat = a / s;
1004
+ break;
1005
+ }
1006
+ }
1007
+ }
964
1008
  const neutralPhi = (ph) => {
965
1009
  let lo = ph - 1, hi = ph + 1;
966
1010
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
@@ -995,7 +1039,67 @@ export class Solver {
995
1039
  }
996
1040
  }
997
1041
  }
1042
+ // A floating contact facing a held one starts where the start's own composition puts it: the
1043
+ // held voltage plus the difference between the two terminal species' levels beside each
1044
+ // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
1045
+ // that a transient's first reading means something.
1046
+ const level = (side) => {
1047
+ const ct = model.contacts[side], i = ct.terminal;
1048
+ if (i === null || ct.species[i].type !== 'equilibrium' || z[i] === 0) return NaN;
1049
+ const b = this.blockOfNode[side === 'left' ? 0 : grid.nNodes - 1];
1050
+ return (this.VT * u[b * M + 1 + i]) / z[i] - ct.species[i].offset;
1051
+ };
1052
+ for (const k of this.floating) {
1053
+ const t = this.terms[k], held = k === 0 ? 1 : 0;
1054
+ if (t.kind === 'port' || held >= this.terms.length || this.floating.includes(held)) continue;
1055
+ const shift = level(t.side) - level(this.terms[held].side);
1056
+ if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1057
+ }
998
1058
  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.
1062
+ for (const k of this.floating) {
1063
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1064
+ if (!port || port.reactions.length === 0) continue;
1065
+ const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1066
+ const current = (V) => {
1067
+ this.termV[k] = V;
1068
+ let I = 0;
1069
+ port.reactions.forEach((rx) => {
1070
+ let q = 0;
1071
+ for (const p of rx.part) if (p.side === 0) q += p.nu * z[p.i];
1072
+ port.nodes.forEach((g, w) => (I += q * FARADAY * this.model.grid.vol[g] * this.portArea[t.index][w] * this._portRate(port, rx, g)));
1073
+ });
1074
+ return I;
1075
+ };
1076
+ // Current into the device rises with V (oxidation); bracket, then bisect.
1077
+ let lo = this.termV[k] - 1, hi = this.termV[k] + 1;
1078
+ while (current(lo) > want && lo > -1e3) lo -= 2 * (hi - lo);
1079
+ while (current(hi) < want && hi < 1e3) hi += 2 * (hi - lo);
1080
+ for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
1081
+ const m = 0.5 * (lo + hi);
1082
+ if (current(m) < want) lo = m;
1083
+ else hi = m;
1084
+ }
1085
+ this.termV[k] = 0.5 * (lo + hi);
1086
+ }
1087
+ }
1088
+
1089
+ // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
1090
+ _portRate(port, rx, g) {
1091
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1092
+ let pref = rx.k0, a = rx.fixedA;
1093
+ for (const p of rx.part) {
1094
+ if (p.side === 1) {
1095
+ a -= p.nu * this.portEta(port, p.i);
1096
+ continue;
1097
+ }
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);
1100
+ }
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);
999
1103
  }
1000
1104
 
1001
1105
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1038,7 +1142,7 @@ export class Solver {
1038
1142
  // Excess carriers: zero in the bulk, the surface sheet at a charged face.
1039
1143
  for (let i = 0; i < n; i++) c[g * n + i] = 0;
1040
1144
  const f = this.sheetFace[g];
1041
- if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M]) / (z[im] * FARADAY * this.model.grid.vol[g]);
1145
+ 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]);
1042
1146
  continue;
1043
1147
  }
1044
1148
  for (let i = 0; i < n; i++) {
@@ -1149,7 +1253,7 @@ export class Solver {
1149
1253
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
1150
1254
  if (mat.conductor) {
1151
1255
  for (let g = g0; g <= g1; g++) this._nodeConductor(g, dt);
1152
- for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
1256
+ for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
1153
1257
  continue;
1154
1258
  }
1155
1259
  if (mat.ideal) this._nodesDilute(g0, g1, dt);
@@ -1157,7 +1261,7 @@ export class Solver {
1157
1261
  const rxs = this.rxsIn[reg.material];
1158
1262
  if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
1159
1263
  for (let s = g0; s < g1; s++) {
1160
- const bL = s + r, bR = bL + 1, h = grid.segLength[s];
1264
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
1161
1265
  this._segmentDisplacement(s, bL, bR, mat, h);
1162
1266
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
1163
1267
  if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
@@ -1185,7 +1289,7 @@ export class Solver {
1185
1289
  const r = 1 + im, k = g * n + im;
1186
1290
  res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1187
1291
  const f = this.sheetFace[g];
1188
- if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, this.sheetSign[g] / (z[im] * FARADAY * dt));
1292
+ if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, (this.sheetSign[g] * this.model.grid.area[g]) / (z[im] * FARADAY * dt));
1189
1293
  }
1190
1294
 
1191
1295
  // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
@@ -1434,9 +1538,9 @@ export class Solver {
1434
1538
  _segmentsDilute(g0, g1, region, mat, vel) {
1435
1539
  const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1436
1540
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1437
- const segLength = this.model.grid.segLength;
1541
+ const { segLength, segArea } = this.model.grid;
1438
1542
  for (let s = g0; s < g1; s++) {
1439
- const bL = s + region, bR = bL + 1, h = segLength[s];
1543
+ const bL = s + region, bR = bL + 1, h = segLength[s] / segArea[s]; // (fluxes are totals through the cross-section)
1440
1544
  const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1441
1545
  const phiL = u[bL * M], phiR = u[bR * M];
1442
1546
  for (let i = 0; i < n; i++) {
@@ -1519,15 +1623,16 @@ export class Solver {
1519
1623
  this._j(bf, 0, bL, 0, -kC);
1520
1624
  }
1521
1625
  }
1522
- // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead.
1523
- const gL = grid.regionEnd[f], gR = gL + 1;
1626
+ // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead. The
1627
+ // flux node's unknowns are per area, so what they carry is that times the face's area.
1628
+ const gL = grid.regionEnd[f], gR = gL + 1, Af = grid.area[gL];
1524
1629
  if (this.nodeConductor[gL] < 0) {
1525
- res[R[bL * M]] += u[bf * M];
1526
- this._j(bL, 0, bf, 0, 1);
1630
+ res[R[bL * M]] += Af * u[bf * M];
1631
+ this._j(bL, 0, bf, 0, Af);
1527
1632
  }
1528
1633
  if (this.nodeConductor[gR] < 0) {
1529
- res[R[bR * M]] -= u[bf * M] + itf.sheetCharge;
1530
- this._j(bR, 0, bf, 0, -1);
1634
+ res[R[bR * M]] -= Af * (u[bf * M] + itf.sheetCharge);
1635
+ this._j(bR, 0, bf, 0, -Af);
1531
1636
  }
1532
1637
  for (let i = 0; i < n; i++) {
1533
1638
  const r = 1 + i, o = bf * M + r;
@@ -1538,6 +1643,27 @@ export class Solver {
1538
1643
  res[R[o]] = -deta; // μ̄ continuous
1539
1644
  this._j(bf, r, bR, r, 1);
1540
1645
  this._j(bf, r, bL, r, -1);
1646
+ } else if (type === 'permeability') {
1647
+ // Electrodiffusion through a thin membrane in a constant field: Scharfetter–Gummel across
1648
+ // the face with D/h → P, its drift taken over the face's whole jump in the standard level
1649
+ // zφ̂ + μ°/RT − ln c_ref (the φ jump of a capacitive face, and any step between the two
1650
+ // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
1651
+ // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
1652
+ // and it's exactly zero where μ̄ is level.
1653
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
1654
+ const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
1655
+ const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
1656
+ const E = Math.expm1(-deta); // η_R − η_L
1657
+ const gBc = P * bernoulli(d) * this.c[kl];
1658
+ const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
1659
+ res[R[o]] = u[o] + gBc * E;
1660
+ this._j(bf, r, bf, r, 1);
1661
+ this._j(bf, r, bL, r, -gBc);
1662
+ this._j(bf, r, bR, r, gBc * (E + 1));
1663
+ if (zi !== 0) {
1664
+ this._j(bf, r, bL, 0, zi * dNdd - zi * gBc * E);
1665
+ this._j(bf, r, bR, 0, -zi * dNdd);
1666
+ }
1541
1667
  } else {
1542
1668
  // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
1543
1669
  const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
@@ -1546,12 +1672,12 @@ export class Solver {
1546
1672
  this._j(bf, r, bL, r, -gG);
1547
1673
  this._j(bf, r, bR, r, gG);
1548
1674
  }
1549
- res[R[bL * M + r]] += u[o];
1550
- this._j(bL, r, bf, r, 1);
1551
- res[R[bR * M + r]] -= u[o];
1552
- this._j(bR, r, bf, r, -1);
1675
+ res[R[bL * M + r]] += Af * u[o];
1676
+ this._j(bL, r, bf, r, Af);
1677
+ res[R[bR * M + r]] -= Af * u[o];
1678
+ this._j(bR, r, bf, r, -Af);
1553
1679
  }
1554
- itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR));
1680
+ itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
1555
1681
  }
1556
1682
 
1557
1683
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -1768,7 +1894,7 @@ export class Solver {
1768
1894
  // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1769
1895
  // there when ν > 0). The rate couples the two edge nodes only through the face block between
1770
1896
  // them, which keeps the system block-tridiagonal.
1771
- _faceReaction(rx, k, f, bf, bL, bR) {
1897
+ _faceReaction(rx, k, f, bf, bL, bR, Af) {
1772
1898
  const { n, M, u, uLo, c, res } = this;
1773
1899
  const R = this.rix;
1774
1900
  const grid = this.model.grid;
@@ -1780,12 +1906,28 @@ export class Solver {
1780
1906
  const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
1781
1907
  aHi -= p.nu * u[o];
1782
1908
  aLo -= p.nu * uLo[o];
1783
- if (rx.srh || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1909
+ if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1784
1910
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1785
1911
  pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1786
1912
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1787
1913
  }
1788
- if (rx.srh) {
1914
+ if (rx.vmax) {
1915
+ // Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
1916
+ let S = 1;
1917
+ rx.part.forEach((p, q) => {
1918
+ if (p.nu >= 0) return;
1919
+ const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
1920
+ S *= powi(cc / (cc + K), -p.nu);
1921
+ this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
1922
+ });
1923
+ const a = aHi + aLo, back = Math.exp(-a);
1924
+ rate = rx.vmax * S * -Math.expm1(-a);
1925
+ for (let s = 0; s < M; s++) {
1926
+ dL[s] *= rate;
1927
+ dR[s] *= rate;
1928
+ }
1929
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
1930
+ } else if (rx.srh) {
1789
1931
  // SRH, with n and p each at its own side's edge node.
1790
1932
  const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
1791
1933
  const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
@@ -1812,8 +1954,8 @@ export class Solver {
1812
1954
  }
1813
1955
  for (const p of rx.part) {
1814
1956
  const b = p.side ? bR : bL;
1815
- res[R[b * M + 1 + p.i]] -= p.nu * u[o];
1816
- this._j(b, 1 + p.i, bf, slot, -p.nu);
1957
+ res[R[b * M + 1 + p.i]] -= Af * p.nu * u[o];
1958
+ this._j(b, 1 + p.i, bf, slot, -Af * p.nu);
1817
1959
  }
1818
1960
  }
1819
1961
 
@@ -1895,9 +2037,58 @@ export class Solver {
1895
2037
  }
1896
2038
  }
1897
2039
  }
2040
+ port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
1898
2041
  for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
1899
2042
  }
1900
2043
 
2044
+ // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
2045
+ // the electrode's carrier at the port's level (activity 1), times the electrode's area per
2046
+ // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
2047
+ // species it makes and consumes, and the port's current.
2048
+ _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;
2051
+ // The carrier's part of the affinity, and how it moves with the port's voltage.
2052
+ let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
2053
+ for (const p of rx.part) {
2054
+ if (p.side === 1) {
2055
+ aV -= p.nu * this.portEta(port, p.i);
2056
+ daV -= (p.nu * z[p.i]) / VT;
2057
+ } else q += p.nu * z[p.i];
2058
+ }
2059
+ port.nodes.forEach((g, w) => {
2060
+ const b = this.blockOfNode[g], s = vol[g] * area[w];
2061
+ d.fill(0);
2062
+ let pref = rx.k0, aHi = aV, aLo = 0;
2063
+ for (const p of rx.part) {
2064
+ if (p.side === 1) continue;
2065
+ const o = b * M + 1 + p.i;
2066
+ aHi -= p.nu * u[o];
2067
+ aLo -= p.nu * uLo[o];
2068
+ 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);
2071
+ }
2072
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
2073
+ const rate = pref * bv;
2074
+ rates[w] = rate;
2075
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
2076
+ 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;
2078
+ const dV = pref * gp * daV;
2079
+ for (const p of rx.part) {
2080
+ 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;
2086
+ }
2087
+ for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
2088
+ this.termDI[k] += q * F * s * dV;
2089
+ });
2090
+ }
2091
+
1901
2092
 
1902
2093
  // One contact (terminal 0 or 1): record the flux through its outer face and the current into
1903
2094
  // the device (read from the end box before anything here is added: whatever the box needs
@@ -1913,6 +2104,7 @@ export class Solver {
1913
2104
  const b = this.blockOfNode[g];
1914
2105
  const sgn = side === 'left' ? 1 : -1;
1915
2106
  const flux = this.contactFlux[side];
2107
+ const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
1916
2108
  const dyn = Number.isFinite(dt);
1917
2109
 
1918
2110
  // Before anything is added, each balance residual is the flux into the device here.
@@ -1933,13 +2125,13 @@ export class Solver {
1933
2125
  const link = ct.species[i];
1934
2126
  const o = b * M + 1 + i;
1935
2127
  if (link.type === 'exchange') {
1936
- res[R[o]] -= link.k * (link.mu / model.RT - (u[o] + uLo[o]));
1937
- this._j(b, 1 + i, b, 1 + i, link.k);
2128
+ res[R[o]] -= Ac * link.k * (link.mu / model.RT - (u[o] + uLo[o]));
2129
+ this._j(b, 1 + i, b, 1 + i, Ac * link.k);
1938
2130
  } else if (link.type === 'conductance') {
1939
2131
  const Vi = (VT * (u[o] + uLo[o])) / z[i];
1940
- res[R[o]] -= (link.G * (Vt + link.offset - Vi)) / (z[i] * F);
1941
- this._j(b, 1 + i, b, 1 + i, (link.G * VT) / (z[i] * z[i] * F));
1942
- B[R[o]] += -link.G / (z[i] * F);
2132
+ res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2133
+ this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2134
+ B[R[o]] += (-Ac * link.G) / (z[i] * F);
1943
2135
  }
1944
2136
  }
1945
2137
 
@@ -1961,15 +2153,15 @@ export class Solver {
1961
2153
  this.contactD[side] = 0;
1962
2154
  } else if (link.type === 'capacitive') {
1963
2155
  // Gate, or metal across a Stern layer, at φ_g = V_t − zeroCharge. D toward +x.
1964
- const Din = link.C * (Vt - link.zeroCharge - VT * u[b * M]);
2156
+ const Din = Ac * link.C * (Vt - link.zeroCharge - VT * u[b * M]);
1965
2157
  res[R[b * M]] -= Din;
1966
- this._j(b, 0, b, 0, link.C * VT);
1967
- B[R[b * M]] += -link.C;
2158
+ this._j(b, 0, b, 0, Ac * link.C * VT);
2159
+ B[R[b * M]] += -Ac * link.C;
1968
2160
  this.contactD[side] = sgn * Din;
1969
2161
  if (dyn) {
1970
2162
  I += (Din - Dstart) / dt;
1971
- C[R[b * M]] += (-link.C * VT) / dt;
1972
- this.termDI[k] += link.C / dt;
2163
+ C[R[b * M]] += (-Ac * link.C * VT) / dt;
2164
+ this.termDI[k] += (Ac * link.C) / dt;
1973
2165
  }
1974
2166
  } else if (link.type === 'pinned' || link.type === 'bulk') {
1975
2167
  // The Poisson residual is the outside's charge, D_in.
@@ -2447,10 +2639,10 @@ export class Solver {
2447
2639
  if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
2448
2640
  const plan = probes.map((p, k) => {
2449
2641
  const path = `advance: probes[${k}]`;
2450
- const i = species.findIndex((sp) => sp.name === p?.species);
2451
- if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2452
- const quantity = p.quantity ?? 'c';
2453
- if (quantity !== 'c' && quantity !== 'V') fail(`${path}.quantity must be 'c' (mol/m³) or 'V' (the species voltage)`);
2642
+ const quantity = p?.quantity ?? 'c';
2643
+ if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
2644
+ const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
2645
+ if (quantity !== 'phi' && i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2454
2646
  if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
2455
2647
  const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
2456
2648
  let r;
@@ -2470,6 +2662,7 @@ export class Solver {
2470
2662
  const out = plan.map(() => []);
2471
2663
  const { n, M } = this;
2472
2664
  const at = (q, g) => {
2665
+ if (q.quantity === 'phi') return this.phiUndefined[g] ? NaN : this.VT * (this.u[this.blockOfNode[g] * M] + this.uLo[this.blockOfNode[g] * M]);
2473
2666
  if (!this.present[g * n + q.i]) return NaN;
2474
2667
  if (q.quantity === 'c') return this.c[g * n + q.i];
2475
2668
  const o = this.blockOfNode[g] * M + 1 + q.i;
@@ -2856,7 +3049,7 @@ export class Solver {
2856
3049
  const reg = regions[r], mat = materials[reg.material];
2857
3050
  if (mat.conductor) {
2858
3051
  for (const g of nodes) this._nodeConductor(g, dt);
2859
- for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
3052
+ for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
2860
3053
  continue;
2861
3054
  }
2862
3055
  for (const g of nodes) {
@@ -2866,7 +3059,7 @@ export class Solver {
2866
3059
  const rxs = this.rxsIn[reg.material];
2867
3060
  if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
2868
3061
  for (const s of segs) {
2869
- const bL = s + r, bR = bL + 1, h = grid.segLength[s];
3062
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
2870
3063
  this._segmentDisplacement(s, bL, bR, mat, h);
2871
3064
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
2872
3065
  if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
package/src/types.js CHANGED
@@ -154,9 +154,14 @@
154
154
  * @property {Source} [V] held terminal voltage, V (default 0, if no I)
155
155
  * @property {Source} [I] or: the current driven into the device, A/m² (e.g. 0 for a reference electrode)
156
156
  * @property {number} [R] with V: a series resistance, Ω·m²
157
- * @property {string} [terminal] species whose offset defaults to 0
157
+ * @property {string} [terminal] species whose offset defaults to 0; with reactions, the electrode's carrier
158
158
  * @property {Record<string, 'blocked' | 'equilibrium' | { type: 'equilibrium', offset?: number, mu?: number }
159
- * | { type: 'conductance', G: number, offset?: number } | { type: 'exchange', k: number, mu: number }>} species
159
+ * | { type: 'conductance', G: number, offset?: number } | { type: 'exchange', k: number, mu: number }>} [species]
160
+ * @property {{ equation: string, fixed?: Record<string, number>, k0: number, alpha: number }[]} [reactions] an
161
+ * electrode spread through the window: Butler–Volmer per area (k0 in mol/(m²·s)) between the region's
162
+ * species and the terminal species at the port's level
163
+ * @property {number | { x: number[], values: number[] }} [area] with reactions: electrode area per volume,
164
+ * m²/m³ (1/h for a film of thickness h), or a profile against the device's x
160
165
  */
161
166
 
162
167
  /**
@@ -170,6 +175,10 @@
170
175
  * @property {BulkReactionDef[]} [bulkReactions]
171
176
  * @property {{ left?: ContactDef, right?: ContactDef }} [contacts]
172
177
  * @property {PortDef[]} [ports] internal ports
178
+ * @property {'planar' | 'spherical' | 'cylindrical' | { type: 'planar' | 'spherical' | 'cylindrical', r0?: number }
179
+ * | { area: { x: number[], values: number[] } }} [geometry] the cross-section A(x): planar (A = 1 m², the
180
+ * default: currents read per m²), shells about a centre at r = r0 (m) left of x = 0 (cylindrical: per
181
+ * metre of length), or A in m² against x. Currents, fluxes, amounts and charges are then totals through A.
173
182
  * @property {GridOptions} [grid]
174
183
  */
175
184
 
@@ -199,8 +208,10 @@
199
208
  * then the ports by name): its voltage, and its current into the device (A/m²)
200
209
  * @property {{ left: ContactResult, right: ContactResult }} contacts
201
210
  * @property {{ left?: { V: number, D: number, charge: number }, right?: { V: number, D: number, charge: number } }} gates
202
- * @property {{ name: string, V: number, flux: Record<string, number>, current: number }[]} ports what each
203
- * internal port brings into the device
211
+ * @property {{ name: string, V: number, flux: Record<string, number>, current: number, x?: Float64Array,
212
+ * area?: Float64Array, rates?: Float64Array[] }[]} ports what each internal port brings into the device;
213
+ * with reactions, also its window's nodes (x, m), area per volume there, and each reaction's rate per area
214
+ * at each node (mol/(m²·s), forward)
204
215
  * @property {{ left: string, right: string, dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
205
216
  * @property {{ rate: Float64Array, regions: number[], total: number }[]} bulkReactions each bulk reaction's
206
217
  * forward rate per node (mol/(m³·s), NaN where it doesn't run), and per region and in all (mol/(m²·s))
package/types/grid.d.ts CHANGED
@@ -26,3 +26,32 @@ export function buildGrid(regions: {
26
26
  regionEnd: Int32Array<ArrayBuffer>;
27
27
  length: number;
28
28
  };
29
+ /**
30
+ * Give a grid its cross-section: `area` (A at each node, m²), `segArea` (each segment's
31
+ * effective area, its length over ∫dx/A, so that a flux D·segArea·Δc/h is exact for steady
32
+ * diffusion between the nodes; A at its middle where A vanishes at an end, as at a sphere's
33
+ * centre) and `vol` (each node's box, ∫A dx). Planar (no geometry): A = 1, and nothing changes.
34
+ * @param {ReturnType<typeof buildGrid>} grid
35
+ * @param {{ type: 'planar' } | { type: 'spherical' | 'cylindrical', r0: number } | { type: 'profile', x: ArrayLike<number>, values: ArrayLike<number> }} geometry
36
+ */
37
+ export function applyGeometry(grid: ReturnType<typeof buildGrid>, geometry: {
38
+ type: "planar";
39
+ } | {
40
+ type: "spherical" | "cylindrical";
41
+ r0: number;
42
+ } | {
43
+ type: "profile";
44
+ x: ArrayLike<number>;
45
+ values: ArrayLike<number>;
46
+ }): {
47
+ nNodes: number;
48
+ x: Float64Array<ArrayBuffer>;
49
+ nodeRegion: Int32Array<ArrayBuffer>;
50
+ segLength: Float64Array<ArrayBuffer>;
51
+ segRegion: Int32Array<ArrayBuffer>;
52
+ segFace: Int32Array<ArrayBuffer>;
53
+ vol: Float64Array<ArrayBuffer>;
54
+ regionStart: Int32Array<ArrayBuffer>;
55
+ regionEnd: Int32Array<ArrayBuffer>;
56
+ length: number;
57
+ };
package/types/index.d.ts CHANGED
@@ -51,7 +51,7 @@ export class Device {
51
51
  * (one array per probe, alongside `trace.t`): what a detector at x sees.
52
52
  * @param {number} tEnd s
53
53
  * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
54
- * probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
54
+ * probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
55
55
  * @returns {Solution}
56
56
  */
57
57
  advance(tEnd: number, opts?: {
@@ -63,8 +63,8 @@ export class Device {
63
63
  method?: "bdf2" | "be";
64
64
  probes?: {
65
65
  x: number;
66
- species: string;
67
- quantity?: "c" | "V";
66
+ species?: string;
67
+ quantity?: "c" | "V" | "phi";
68
68
  region?: string | number;
69
69
  }[];
70
70
  }): Solution;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
3
+ * mixed in one proportion at every point (every ion's mixing fraction the same curve). Exact for a single binary salt, a close estimate otherwise (it's what
4
+ * JPCalc and most electrophysiology corrections use).
5
+ * @param {Record<string, number>} left mol/m³ by ion
6
+ * @param {Record<string, number>} right mol/m³ by ion
7
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
8
+ * @param {{ T?: number }} [opts] K (default 298.15)
9
+ */
10
+ export function henderson(left: Record<string, number>, right: Record<string, number>, ions: Record<string, {
11
+ z: number;
12
+ D: number;
13
+ }>, { T }?: {
14
+ T?: number;
15
+ }): number;
16
+ /**
17
+ * Planck's liquid-junction potential, φ_R − φ_L (V): the steady state of constrained diffusion,
18
+ * a junction zone held between the two solutions, electroneutral, carrying no current. Solved by
19
+ * shooting on the Nernst–Planck equations across it (its length drops out). Exact for that
20
+ * junction; equal to Henderson's for a single binary salt.
21
+ * @param {Record<string, number>} left mol/m³ by ion
22
+ * @param {Record<string, number>} right mol/m³ by ion
23
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
24
+ * @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
25
+ */
26
+ export function planck(left: Record<string, number>, right: Record<string, number>, ions: Record<string, {
27
+ z: number;
28
+ D: number;
29
+ }>, { T, steps }?: {
30
+ T?: number;
31
+ steps?: number;
32
+ }): number;
package/types/kit.d.ts CHANGED
@@ -36,6 +36,7 @@ export function vacuumZeroCharge(def: object, workFunction: number, inside: Vacu
36
36
  export { live } from "./live.js";
37
37
  export { recorder } from "./recorder.js";
38
38
  export { check } from "./checks.js";
39
+ export { polarization } from "./polarization.js";
39
40
  /**
40
41
  * One material seen from vacuum: a level inside it (the anchor) and how far above that level,
41
42
  * in electron energy, the vacuum level just outside lies (the offset, V). So
@@ -61,5 +62,6 @@ export { parseEquation, stoichiometry, half, SHE, level } from "./equation.js";
61
62
  export { traces, speciesRole, typeset } from "./traces.js";
62
63
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from "./sources.js";
63
64
  export { describe, unitWarnings } from "./describe.js";
65
+ export { henderson, planck } from "./junction.js";
64
66
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from "./devices.js";
65
67
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from "./data.js";