driftlet 0.7.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/grid.js CHANGED
@@ -109,3 +109,76 @@ export function buildGrid(regions, opts = {}) {
109
109
  length: x0,
110
110
  };
111
111
  }
112
+
113
+ // The cross-section A(x) (m²), for a device that isn't planar: the conservation laws hold for
114
+ // the totals through it, flux × A, and a box holds its volume ∫A dx. A planar device has A = 1
115
+ // everywhere, and its currents read as densities.
116
+ // spherical: A = 4π r², cylindrical: A = 2π r (per metre of length), r = r0 + x;
117
+ // a profile: A(x) piecewise linear through the points given, constant beyond them.
118
+ function crossSection(geometry) {
119
+ const { type } = geometry;
120
+ if (type === 'spherical' || type === 'cylindrical') {
121
+ const r0 = geometry.r0, sph = type === 'spherical';
122
+ const sq = (x) => (r0 + x) * (r0 + x), cube = (x) => (r0 + x) * (r0 + x) * (r0 + x);
123
+ return {
124
+ at: (x) => (sph ? 4 * Math.PI * sq(x) : 2 * Math.PI * (r0 + x)),
125
+ // ∫A dx and ∫dx/A from a to b
126
+ integral: (a, b) => (sph ? (4 * Math.PI * (cube(b) - cube(a))) / 3 : Math.PI * (sq(b) - sq(a))),
127
+ resistance: (a, b) => (sph ? (1 / (r0 + a) - 1 / (r0 + b)) / (4 * Math.PI) : Math.log((r0 + b) / (r0 + a)) / (2 * Math.PI)),
128
+ };
129
+ }
130
+ const xs = geometry.x, vs = geometry.values, last = xs.length - 1;
131
+ const at = (x) => {
132
+ if (x <= xs[0]) return vs[0];
133
+ if (x >= xs[last]) return vs[last];
134
+ let k = 0;
135
+ while (xs[k + 1] < x) k++;
136
+ return vs[k] + ((vs[k + 1] - vs[k]) * (x - xs[k])) / (xs[k + 1] - xs[k]);
137
+ };
138
+ // Over [a, b], split at the profile's points, where A is linear on each piece.
139
+ const pieces = (a, b, f) => {
140
+ let s = 0, lo = a;
141
+ for (const xk of [...xs.filter((v) => v > a && v < b), b]) {
142
+ s += f(lo, xk, at(lo), at(xk));
143
+ lo = xk;
144
+ }
145
+ return s;
146
+ };
147
+ return {
148
+ at,
149
+ integral: (a, b) => pieces(a, b, (p, q, A1, A2) => ((A1 + A2) / 2) * (q - p)),
150
+ resistance: (a, b) =>
151
+ pieces(a, b, (p, q, A1, A2) => {
152
+ const d = A2 - A1, m = (A1 + A2) / 2;
153
+ return Math.abs(d) < 1e-6 * m ? (q - p) / m : ((q - p) * Math.log(A2 / A1)) / d;
154
+ }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Give a grid its cross-section: `area` (A at each node, m²), `segArea` (each segment's
160
+ * effective area, its length over ∫dx/A, so that a flux D·segArea·Δc/h is exact for steady
161
+ * diffusion between the nodes; A at its middle where A vanishes at an end, as at a sphere's
162
+ * centre) and `vol` (each node's box, ∫A dx). Planar (no geometry): A = 1, and nothing changes.
163
+ * @param {ReturnType<typeof buildGrid>} grid
164
+ * @param {{ type: 'planar' } | { type: 'spherical' | 'cylindrical', r0: number } | { type: 'profile', x: ArrayLike<number>, values: ArrayLike<number> }} geometry
165
+ */
166
+ export function applyGeometry(grid, geometry) {
167
+ const { nNodes, x, segLength } = grid;
168
+ grid.area = new Float64Array(nNodes).fill(1);
169
+ grid.segArea = new Float64Array(nNodes - 1).fill(1);
170
+ if (!geometry || geometry.type === 'planar') return grid;
171
+ const A = crossSection(geometry);
172
+ for (let g = 0; g < nNodes; g++) grid.area[g] = A.at(x[g]);
173
+ for (let s = 0; s < nNodes - 1; s++) {
174
+ const h = segLength[s];
175
+ if (h === 0) grid.segArea[s] = grid.area[s];
176
+ else if (grid.area[s] > 0 && grid.area[s + 1] > 0) grid.segArea[s] = h / A.resistance(x[s], x[s + 1]);
177
+ else grid.segArea[s] = A.at(x[s] + h / 2);
178
+ }
179
+ for (let g = 0; g < nNodes; g++) {
180
+ const lo = g > 0 ? x[g] - segLength[g - 1] / 2 : x[g], hi = g < nNodes - 1 ? x[g] + segLength[g] / 2 : x[g];
181
+ grid.vol[g] = hi > lo ? A.integral(lo, hi) : 0;
182
+ }
183
+ return grid;
184
+ }
package/src/kit.js CHANGED
@@ -14,6 +14,7 @@ export { describe, unitWarnings } from './describe.js';
14
14
  export { recorder } from './recorder.js';
15
15
  export { henderson, planck } from './junction.js';
16
16
  export { check } from './checks.js';
17
+ export { polarization } from './polarization.js';
17
18
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
18
19
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
19
20
 
@@ -0,0 +1,84 @@
1
+ // Polarization curves at an electrode, for driftlet/kit: each reaction's rate with the solution
2
+ // beside the electrode held as it is and the metal's level moved, which is what an Evans diagram
3
+ // plots. Where the net current crosses zero is the spot's open-circuit (mixed) potential.
4
+
5
+ import { DeviceError } from './errors.js';
6
+ import { FARADAY } from './constants.js';
7
+ import { powi, powr } from './pow.js';
8
+
9
+ const fail = (message) => {
10
+ throw new DeviceError(message);
11
+ };
12
+
13
+ /**
14
+ * Each electrode reaction's rate at a face (a metal's) or at one spot of an electrode port, with
15
+ * the composition beside it as in `sol` and the metal's carrier at each level in `V` (in the
16
+ * terminals' convention, V = μ̄/(zF); against SHE, subtract φ for the usual tables' ions). The
17
+ * rates are the solver's own law, so at the electrode's actual level they are
18
+ * `sol.interfaces[f].rates` (or `sol.ports[k].rates` at that node).
19
+ * @param {import('./index.js').Device} device
20
+ * @param {object} sol a solution of it
21
+ * @param {{ face: number } | { port: string | number, x?: number }} where a face's index, or a port
22
+ * (name or index) and the position in its window (m, the device's x; default its first node)
23
+ * @param {number | ArrayLike<number>} V the metal's level(s), V
24
+ * @returns {{ V: Float64Array, current: Float64Array, reactions: { equation: string, level: number,
25
+ * rate: Float64Array, current: Float64Array }[] }} per reaction: its equilibrium level here (where
26
+ * its rate is zero), its rate (mol/(m²·s) of electrode, forward) and its current (A/m², anodic
27
+ * positive: into the solution); and their sum
28
+ */
29
+ export function polarization(device, sol, where, V) {
30
+ const model = device.model, { species, grid, RT } = model, solver = device.solver;
31
+ const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
32
+ let reactions, nodeOf, carrier, names;
33
+ if (where?.face !== undefined) {
34
+ const f = where.face, itf = model.interfaces[f];
35
+ if (!itf) fail(`polarization: no face ${f} (the device has ${model.interfaces.length})`);
36
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
37
+ const metal = [gL, gR].findIndex((g) => solver.nodeConductor[g] >= 0);
38
+ if (metal < 0) fail(`polarization: face ${f} has no metal on either side`);
39
+ carrier = { side: metal, i: solver.nodeConductor[metal ? gR : gL] };
40
+ nodeOf = (p) => (p.side ? gR : gL);
41
+ reactions = itf.reactions;
42
+ names = device.def.interfaces?.[f]?.reactions;
43
+ } else if (where?.port !== undefined) {
44
+ const k = typeof where.port === 'number' ? where.port : model.ports.findIndex((p) => p.name === where.port);
45
+ const port = model.ports[k];
46
+ if (!port) fail(`polarization: no port ${JSON.stringify(where.port)}`);
47
+ if (port.reactions.length === 0) fail(`polarization: port ${port.name} has no reactions`);
48
+ let g = port.nodes[0];
49
+ if (where.x !== undefined) for (const h of port.nodes) if (Math.abs(grid.x[h] - where.x) < Math.abs(grid.x[g] - where.x)) g = h;
50
+ carrier = { side: 1, i: port.terminal };
51
+ nodeOf = () => g;
52
+ reactions = port.reactions;
53
+ names = device.def.ports?.[k]?.reactions;
54
+ } else fail('polarization: say where, { face: f } or { port: name, x }');
55
+ if (reactions.length === 0) fail('polarization: no reactions there');
56
+
57
+ const out = reactions.map((rx, k) => {
58
+ if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
59
+ // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
60
+ let a0 = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
61
+ rx.part.forEach((p, x) => {
62
+ if (p.side === carrier.side && p.i === carrier.i) {
63
+ s -= (p.nu * species[p.i].z * FARADAY) / RT;
64
+ return;
65
+ }
66
+ const g = nodeOf(p), name = species[p.i].name;
67
+ a0 -= (p.nu * sol.mu[name][g]) / RT;
68
+ q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
69
+ const c = sol.c[name][g];
70
+ if (rx.vmax) {
71
+ if (p.nu < 0) pref *= powi(c / (c + rx.K[x]), -p.nu);
72
+ } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
73
+ });
74
+ if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
75
+ const rate = Float64Array.from(Vs, (v) => {
76
+ const a = a0 + s * v;
77
+ return rx.vmax ? pref * -Math.expm1(-a) : pref * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
78
+ });
79
+ return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -a0 / s, rate, current: rate.map((r) => q * FARADAY * r) };
80
+ });
81
+ const current = new Float64Array(Vs.length);
82
+ for (const r of out) for (let j = 0; j < Vs.length; j++) current[j] += r.current[j];
83
+ return { V: Vs, current, reactions: out };
84
+ }
package/src/solution.js CHANGED
@@ -91,7 +91,8 @@ export function makeSolution(solver, result = {}) {
91
91
  const V = solver.termV[side === 'left' ? 0 : 1];
92
92
  sol.contacts[side] = { V, flux, D, current: conduction + displacement };
93
93
  if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
94
- // Charge per area on the gate (or metal) plate: +D at the left, −D at the right.
94
+ // Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
95
+ // at the right.
95
96
  sol.gates[side] = { V, D, charge: side === 'left' ? D : -D };
96
97
  }
97
98
  }
@@ -105,7 +106,14 @@ export function makeSolution(solver, result = {}) {
105
106
  flux[species[i].name] = solver.portFlux[k][i];
106
107
  current += F * species[i].z * solver.portFlux[k][i];
107
108
  }
108
- return { name: port.name, V: solver.termV[2 + k], flux, current };
109
+ const out = { name: port.name, V: solver.termV[2 + k], flux, current };
110
+ if (port.reactions.length > 0) {
111
+ // An electrode spread through the window: where it sits, and each reaction's rate there.
112
+ out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
113
+ out.area = Float64Array.from(solver.portArea[k]);
114
+ out.rates = solver.portRates[k].map((r) => Float64Array.from(r)); // mol/(m²·s), forward
115
+ }
116
+ return out;
109
117
  });
110
118
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
111
119
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
@@ -142,14 +150,14 @@ export function makeSolution(solver, result = {}) {
142
150
  return { rate, regions, total };
143
151
  });
144
152
 
145
- // Total charge per area in the device (space charge plus sheet charges).
153
+ // Total charge in the device (space charge plus sheet charges), per area if it's planar.
146
154
  let q = 0;
147
155
  for (let g = 0; g < nNodes; g++) {
148
156
  let rho = solver.rhoFixed[g];
149
157
  for (let i = 0; i < n; i++) rho += F * species[i].z * c[g * n + i];
150
158
  q += grid.vol[g] * rho;
151
159
  }
152
- for (const itf of interfaces) q += itf.sheetCharge;
160
+ interfaces.forEach((itf, f) => (q += itf.sheetCharge * grid.area[grid.regionEnd[f]]));
153
161
  sol.charge = q;
154
162
 
155
163
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
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);
@@ -1012,6 +1056,50 @@ export class Solver {
1012
1056
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1013
1057
  }
1014
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);
1015
1103
  }
1016
1104
 
1017
1105
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1054,7 +1142,7 @@ export class Solver {
1054
1142
  // Excess carriers: zero in the bulk, the surface sheet at a charged face.
1055
1143
  for (let i = 0; i < n; i++) c[g * n + i] = 0;
1056
1144
  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]);
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]);
1058
1146
  continue;
1059
1147
  }
1060
1148
  for (let i = 0; i < n; i++) {
@@ -1165,7 +1253,7 @@ export class Solver {
1165
1253
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
1166
1254
  if (mat.conductor) {
1167
1255
  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]);
1256
+ for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
1169
1257
  continue;
1170
1258
  }
1171
1259
  if (mat.ideal) this._nodesDilute(g0, g1, dt);
@@ -1173,7 +1261,7 @@ export class Solver {
1173
1261
  const rxs = this.rxsIn[reg.material];
1174
1262
  if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
1175
1263
  for (let s = g0; s < g1; s++) {
1176
- 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)
1177
1265
  this._segmentDisplacement(s, bL, bR, mat, h);
1178
1266
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
1179
1267
  if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
@@ -1201,7 +1289,7 @@ export class Solver {
1201
1289
  const r = 1 + im, k = g * n + im;
1202
1290
  res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1203
1291
  const f = this.sheetFace[g];
1204
- 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));
1205
1293
  }
1206
1294
 
1207
1295
  // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
@@ -1450,9 +1538,9 @@ export class Solver {
1450
1538
  _segmentsDilute(g0, g1, region, mat, vel) {
1451
1539
  const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1452
1540
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1453
- const segLength = this.model.grid.segLength;
1541
+ const { segLength, segArea } = this.model.grid;
1454
1542
  for (let s = g0; s < g1; s++) {
1455
- 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)
1456
1544
  const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1457
1545
  const phiL = u[bL * M], phiR = u[bR * M];
1458
1546
  for (let i = 0; i < n; i++) {
@@ -1535,15 +1623,16 @@ export class Solver {
1535
1623
  this._j(bf, 0, bL, 0, -kC);
1536
1624
  }
1537
1625
  }
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;
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];
1540
1629
  if (this.nodeConductor[gL] < 0) {
1541
- res[R[bL * M]] += u[bf * M];
1542
- this._j(bL, 0, bf, 0, 1);
1630
+ res[R[bL * M]] += Af * u[bf * M];
1631
+ this._j(bL, 0, bf, 0, Af);
1543
1632
  }
1544
1633
  if (this.nodeConductor[gR] < 0) {
1545
- res[R[bR * M]] -= u[bf * M] + itf.sheetCharge;
1546
- 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);
1547
1636
  }
1548
1637
  for (let i = 0; i < n; i++) {
1549
1638
  const r = 1 + i, o = bf * M + r;
@@ -1583,12 +1672,12 @@ export class Solver {
1583
1672
  this._j(bf, r, bL, r, -gG);
1584
1673
  this._j(bf, r, bR, r, gG);
1585
1674
  }
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);
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);
1590
1679
  }
1591
- 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));
1592
1681
  }
1593
1682
 
1594
1683
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -1805,7 +1894,7 @@ export class Solver {
1805
1894
  // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1806
1895
  // there when ν > 0). The rate couples the two edge nodes only through the face block between
1807
1896
  // them, which keeps the system block-tridiagonal.
1808
- _faceReaction(rx, k, f, bf, bL, bR) {
1897
+ _faceReaction(rx, k, f, bf, bL, bR, Af) {
1809
1898
  const { n, M, u, uLo, c, res } = this;
1810
1899
  const R = this.rix;
1811
1900
  const grid = this.model.grid;
@@ -1865,8 +1954,8 @@ export class Solver {
1865
1954
  }
1866
1955
  for (const p of rx.part) {
1867
1956
  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);
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);
1870
1959
  }
1871
1960
  }
1872
1961
 
@@ -1948,9 +2037,58 @@ export class Solver {
1948
2037
  }
1949
2038
  }
1950
2039
  }
2040
+ port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
1951
2041
  for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
1952
2042
  }
1953
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
+
1954
2092
 
1955
2093
  // One contact (terminal 0 or 1): record the flux through its outer face and the current into
1956
2094
  // the device (read from the end box before anything here is added: whatever the box needs
@@ -1966,6 +2104,7 @@ export class Solver {
1966
2104
  const b = this.blockOfNode[g];
1967
2105
  const sgn = side === 'left' ? 1 : -1;
1968
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
1969
2108
  const dyn = Number.isFinite(dt);
1970
2109
 
1971
2110
  // Before anything is added, each balance residual is the flux into the device here.
@@ -1986,13 +2125,13 @@ export class Solver {
1986
2125
  const link = ct.species[i];
1987
2126
  const o = b * M + 1 + i;
1988
2127
  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);
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);
1991
2130
  } else if (link.type === 'conductance') {
1992
2131
  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);
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);
1996
2135
  }
1997
2136
  }
1998
2137
 
@@ -2014,15 +2153,15 @@ export class Solver {
2014
2153
  this.contactD[side] = 0;
2015
2154
  } else if (link.type === 'capacitive') {
2016
2155
  // 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]);
2156
+ const Din = Ac * link.C * (Vt - link.zeroCharge - VT * u[b * M]);
2018
2157
  res[R[b * M]] -= Din;
2019
- this._j(b, 0, b, 0, link.C * VT);
2020
- 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;
2021
2160
  this.contactD[side] = sgn * Din;
2022
2161
  if (dyn) {
2023
2162
  I += (Din - Dstart) / dt;
2024
- C[R[b * M]] += (-link.C * VT) / dt;
2025
- this.termDI[k] += link.C / dt;
2163
+ C[R[b * M]] += (-Ac * link.C * VT) / dt;
2164
+ this.termDI[k] += (Ac * link.C) / dt;
2026
2165
  }
2027
2166
  } else if (link.type === 'pinned' || link.type === 'bulk') {
2028
2167
  // The Poisson residual is the outside's charge, D_in.
@@ -2910,7 +3049,7 @@ export class Solver {
2910
3049
  const reg = regions[r], mat = materials[reg.material];
2911
3050
  if (mat.conductor) {
2912
3051
  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]);
3052
+ for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
2914
3053
  continue;
2915
3054
  }
2916
3055
  for (const g of nodes) {
@@ -2920,7 +3059,7 @@ export class Solver {
2920
3059
  const rxs = this.rxsIn[reg.material];
2921
3060
  if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
2922
3061
  for (const s of segs) {
2923
- 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)
2924
3063
  this._segmentDisplacement(s, bL, bR, mat, h);
2925
3064
  if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
2926
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))