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/device.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // defaults for physically meaningful choices: interface alignments, reference concentrations
7
7
  // and standard potentials must all be given.
8
8
 
9
- import { buildGrid } from './grid.js';
9
+ import { buildGrid, applyGeometry } from './grid.js';
10
10
  import { FARADAY, GAS_CONSTANT } from './constants.js';
11
11
  import { normalizeStatistics } from './statistics.js';
12
12
  import { DeviceError } from './errors.js';
@@ -105,7 +105,7 @@ function nonNegative(v, path) {
105
105
  */
106
106
  export function normalizeDevice(def) {
107
107
  need(isObject(def), 'device definition must be an object');
108
- fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid']);
108
+ fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid', 'geometry']);
109
109
 
110
110
  const T = def.T === undefined ? 298.15 : positive(def.T, 'T');
111
111
  const RT = GAS_CONSTANT * T;
@@ -171,8 +171,14 @@ export function normalizeDevice(def) {
171
171
  materialIndex.set(mname, materials.length);
172
172
  materials.push(m);
173
173
  }
174
+ // (an electrode's carrier can live outside the device: a reacting port's terminal species)
175
+ const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
174
176
  species.forEach((sp, i) => {
175
- need(materials.some((m) => m.present[i]), `species '${sp.name}' is not present in any material`);
177
+ const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
178
+ need(
179
+ materials.some((m) => m.present[i]) || outside(sp),
180
+ `species '${sp.name}' is not present in any material` + (reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
181
+ );
176
182
  });
177
183
 
178
184
  // --- regions
@@ -280,7 +286,7 @@ export function normalizeDevice(def) {
280
286
 
281
287
  // --- internal ports (outside phases attached over windows of interior nodes)
282
288
  need(def.ports === undefined || Array.isArray(def.ports), 'ports must be an array');
283
- const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex));
289
+ const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex, RT));
284
290
 
285
291
  // Every electrostatically coupled cluster of regions needs an anchor, or its φ (and every
286
292
  // charged level with it) floats: shifting φ by s and each η_i by z_i s changes nothing.
@@ -322,6 +328,17 @@ export function normalizeDevice(def) {
322
328
  } catch (err) {
323
329
  throw new DeviceError(`grid: ${err.message}`);
324
330
  }
331
+ const geometry = normalizeGeometry(def.geometry);
332
+ if (geometry.type !== 'planar') {
333
+ regions.forEach((reg, r) => need(reg.velocity === 0, `regions[${r}].velocity: flow is for a planar device (a uniform velocity through a varying cross-section wouldn't conserve the liquid)`));
334
+ }
335
+ applyGeometry(grid, geometry);
336
+ // (zero only at an end, as at a sphere's centre: nothing passes there)
337
+ const inside = (x) => x > 0 && x < grid.length;
338
+ need(
339
+ grid.area.every((a, g) => a > 0 || g === 0 || g === grid.nNodes - 1) && (geometry.type !== 'profile' || geometry.values.every((a, k) => a > 0 || !inside(geometry.x[k]))),
340
+ 'geometry: the cross-section vanishes inside the device; only an end may have A = 0',
341
+ );
325
342
 
326
343
  // Port windows: the nodes of the port's region within [from, to] of its left end.
327
344
  ports.forEach((port, k) => {
@@ -334,9 +351,18 @@ export function normalizeDevice(def) {
334
351
  need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
335
352
  port.nodes = Int32Array.from(nodes);
336
353
  });
354
+ // A node's surface belongs to one electrode.
355
+ const surfaced = new Int32Array(grid.nNodes).fill(-1);
356
+ ports.forEach((port, k) => {
357
+ if (port.surface.length === 0) return;
358
+ for (const g of port.nodes) {
359
+ need(surfaced[g] < 0, `ports[${k}]: its window overlaps ports[${surfaced[g]}]'s, and both have a surface; a spot of metal has one surface`);
360
+ surfaced[g] = k;
361
+ }
362
+ });
337
363
 
338
364
  return {
339
- T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports,
365
+ T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
340
366
  };
341
367
  }
342
368
 
@@ -555,6 +581,7 @@ export function normalizeDrives(def, model) {
555
581
  for (const [k, side] of [[0, 'left'], [1, 'right']]) {
556
582
  need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
557
583
  }
584
+ model.ports.forEach((port, k) => need(port.passes || drives[2 + k].kind === 'V', `ports[${k}].I: this port passes no current (it exchanges only neutral species), so give it no drive`));
558
585
  need(drives.some((d) => d.kind === 'V'), "every terminal is driven by a current, so the device's overall level floats: hold at least one at a voltage V");
559
586
  return drives;
560
587
  }
@@ -601,15 +628,37 @@ export function nextBreakpoint(src, t) {
601
628
  return Infinity;
602
629
  }
603
630
 
631
+ // The device's cross-section A(x): planar (the default, A = 1 m², so currents read as densities),
632
+ // spherical or cylindrical about a centre r0 to the left of x = 0 (r = r0 + x), or a profile
633
+ // { x, values } of A in m². Every flux, current and amount is then a total through A.
634
+ function normalizeGeometry(g) {
635
+ if (g === undefined || g === 'planar') return { type: 'planar' };
636
+ if (g === 'spherical' || g === 'cylindrical') g = { type: g };
637
+ need(isObject(g), "geometry must be 'planar', 'spherical', 'cylindrical', { type, r0 } or { area: { x, values } }");
638
+ if (g.area !== undefined) {
639
+ fields(g, 'geometry', ['area']);
640
+ const p = profile(g.area, 'geometry.area', 'cross-sections (m²)', (a) => a >= 0, 'a cross-section ≥ 0 (m²)');
641
+ need([...p.values].some((a) => a > 0), 'geometry.area: the cross-section is zero everywhere');
642
+ return { type: 'profile', x: p.x, values: p.values };
643
+ }
644
+ fields(g, 'geometry', ['type', 'r0']);
645
+ need(['planar', 'spherical', 'cylindrical'].includes(g.type), "geometry.type must be 'planar', 'spherical' or 'cylindrical'");
646
+ if (g.type === 'planar') return { type: 'planar' };
647
+ const r0 = g.r0 === undefined ? 0 : nonNegative(g.r0, 'geometry.r0 (m, the radius at x = 0)');
648
+ return { type: g.type, r0 };
649
+ }
650
+
604
651
  // An internal port: an outside phase with known levels (V_i = V + offset_i, or μ for neutral
605
652
  // species, as at a contact), exchanging with every node in a window of one region.
606
653
  // Links per species: 'equilibrium' (μ̄ held at the outside level throughout the window),
607
654
  // { type: 'conductance', G } for charged species (G in S/m³: a source G (V_out − V_i)/(zF) per
608
655
  // volume), { type: 'exchange', k, mu } for neutral ones (k in mol/(m³·s): a source
609
- // k (μ_out − μ)/RT per volume), or 'blocked' (the default).
610
- function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
656
+ // k (μ_out − μ)/RT per volume), or 'blocked' (the default). And reactions with the port's
657
+ // terminal species, a metal's carrier at the port's level: an electrode surface spread through
658
+ // the window, `area` (m²/m³) of it per volume.
659
+ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
611
660
  need(isObject(pdef), `${path} must be an object`);
612
- fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species']);
661
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance']);
613
662
  let r;
614
663
  if (Number.isInteger(pdef.region)) r = pdef.region;
615
664
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -626,9 +675,13 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
626
675
  need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
627
676
  terminal = speciesIndex.get(pdef.terminal);
628
677
  }
629
- need(isObject(pdef.species), `${path}.species must map species names to port links`);
678
+ const surface = portSurface(pdef, path, speciesIndex);
679
+ const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
680
+ surface.forEach((sp, s) => need(reactions.some((rx) => rx.part.some((p) => p.side === 2 && p.s === s)), `${path}.surface.${sp.name}: no reaction of the port makes or uses it`));
681
+ const capacitance = portCapacitance(pdef, path, mat);
682
+ need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
630
683
  const links = species.map(() => ({ type: 'blocked' }));
631
- for (const [sname, raw] of Object.entries(pdef.species)) {
684
+ for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
632
685
  const lpath = `${path}.species.${sname}`;
633
686
  need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
634
687
  const i = speciesIndex.get(sname);
@@ -656,8 +709,95 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
656
709
  if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
657
710
  links[i] = { type: link.type, ...level };
658
711
  }
659
- need(links.some((l) => l.type !== 'blocked'), `${path}.species: the port exchanges no species`);
660
- return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links };
712
+ need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
713
+ // One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
714
+ // nobody's business: it can't be driven by one.
715
+ const passes = reactions.length > 0 || capacitance !== null || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
716
+ need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
717
+ let area = null;
718
+ if (reactions.length > 0 || capacitance !== null) {
719
+ need(pdef.area !== undefined, `${path}.area: give the electrode's area per volume of the window (m²/m³, e.g. 1/h for a film of thickness h on it), a number or a profile`);
720
+ area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
721
+ } else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
722
+ need(surface.length === 0 || reactions.length > 0, `${path}.surface: only an electrode (a port with reactions) has a surface`);
723
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance };
724
+ }
725
+
726
+ // A capacitance spread through the window: a gate along a channel, an electrode's double layer.
727
+ // Per area of electrode it holds σ = C (V − zeroCharge − φ) on the port's side, and the window
728
+ // the opposite: the region's charge balance (Gauss's law, or neutrality at ε = 0) gains aσ.
729
+ function portCapacitance(pdef, path, mat) {
730
+ if (pdef.capacitance === undefined) return null;
731
+ const cpath = `${path}.capacitance`;
732
+ need(isObject(pdef.capacitance), `${cpath} must be { C, zeroCharge }`);
733
+ fields(pdef.capacitance, cpath, ['C', 'zeroCharge']);
734
+ need(!mat.conductor && !mat.phiFree, `${cpath}: a capacitance couples to φ, which a ${mat.conductor ? 'conductor' : 'region with no free charge'} doesn't have`);
735
+ return { C: positive(pdef.capacitance.C, `${cpath}.C (F/m² of electrode)`), zeroCharge: pdef.capacitance.zeroCharge === undefined ? 0 : finite(pdef.capacitance.zeroCharge, `${cpath}.zeroCharge (V)`) };
736
+ }
737
+
738
+ // An electrode port's surface species: coverages θ of its sites (Langmuir: each takes one site,
739
+ // μ = μ° + RT ln(θ/θ₀) with θ₀ = 1 − Σθ the bare fraction), Γ mol of sites per m² of electrode.
740
+ function portSurface(pdef, path, speciesIndex) {
741
+ if (pdef.surface === undefined) return [];
742
+ need(isObject(pdef.surface), `${path}.surface must map surface species to { mu0, capacity, theta0 }`);
743
+ const list = Object.entries(pdef.surface).map(([name, d]) => {
744
+ const spath = `${path}.surface.${name}`;
745
+ need(!speciesIndex.has(name), `${spath}: '${name}' is a species of the device; a surface species is the port's own, with its own name`);
746
+ need(isObject(d), `${spath} must be { mu0, capacity, theta0 }`);
747
+ fields(d, spath, ['mu0', 'capacity', 'theta0']);
748
+ const theta0 = d.theta0 === undefined ? 1e-6 : positive(d.theta0, `${spath}.theta0`);
749
+ return { name, mu0: finite(d.mu0, `${spath}.mu0 (J/mol)`), capacity: positive(d.capacity, `${spath}.capacity (mol of sites per m² of electrode)`), theta0 };
750
+ });
751
+ need(list.length > 0, `${path}.surface: give at least one surface species, or leave it out`);
752
+ const caps = new Set(list.map((sp) => sp.capacity));
753
+ need(caps.size === 1, `${path}.surface: the species share the electrode's sites, so give them one capacity`);
754
+ need(list.reduce((t, sp) => t + sp.theta0, 0) < 1, `${path}.surface: the starting coverages (theta0) must sum to less than 1`);
755
+ return list;
756
+ }
757
+
758
+ // A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
759
+ // port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
760
+ // electrode: part side 0 for the region's species, side 1 for the carrier.
761
+ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface = []) {
762
+ need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
763
+ const list = pdef.reactions ?? [];
764
+ if (list.length === 0) return [];
765
+ need(!mat.conductor, `${path}.reactions: a port on a conductor is a wire; reactions belong on a face, or on a port in the solution`);
766
+ // The metal's countercharge (each spot's double layer) is below the grid, so the solution
767
+ // beside it must be neutral, as in porous-electrode theory; with ε > 0 the reactions would
768
+ // leave a net charge with nothing to balance it.
769
+ need(mat.epsr === 0 || mat.phiFree, `${path}.reactions: an electrode spread through a window needs its region strictly neutral (ε = 0): its double layers are below the grid`);
770
+ need(terminal !== null && species[terminal].z !== 0, `${path}.terminal: a port with reactions needs its electrode's carrier as its terminal species (e.g. 'e-')`);
771
+ need(!mat.present[terminal], `${path}.terminal: '${species[terminal].name}' is in the port's region too, so a reaction can't tell the electrode's from the region's`);
772
+ const carrier = species[terminal].name;
773
+ return list.map((rdef, k) => {
774
+ const rpath = `${path}.reactions[${k}]`;
775
+ need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
776
+ fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha', 'bare']);
777
+ const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
778
+ const onSurface = (name) => surface.findIndex((sp) => sp.name === name);
779
+ const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier || onSurface(name) >= 0, conductor: true }, rdef.fixed, `${rpath}.equation`);
780
+ const part = [];
781
+ let fixedA = 0, charge = 0;
782
+ // The electrode's side: its carrier, and its surface species (side 2, neutral, by index s).
783
+ const electrode = {};
784
+ for (const [name, nu] of Object.entries(sides.right)) {
785
+ const s = onSurface(name);
786
+ if (s >= 0) part.push({ s, nu, side: 2 });
787
+ else electrode[name] = nu;
788
+ }
789
+ for (const [map, side] of [[sides.left, 0], [electrode, 1]]) {
790
+ const st = stoichiometry(map, `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
791
+ for (const { i, nu } of st.list) part.push({ i, nu, side });
792
+ fixedA += st.fixedA;
793
+ charge += st.charge;
794
+ }
795
+ need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
796
+ need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
797
+ need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
798
+ need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
799
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
800
+ });
661
801
  }
662
802
 
663
803
  function checkAnchors(regions, materials, interfaces, contacts, species, ports = []) {
@@ -680,7 +820,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
680
820
  const anchored = new Set();
681
821
  if (anchors(contacts.left)) anchored.add(find(0));
682
822
  if (anchors(contacts.right)) anchored.add(find(nR - 1));
683
- for (const port of ports) if (port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
823
+ for (const port of ports) if (port.reactions.length > 0 || port.capacitance || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
684
824
  for (let r = 0; r < nR; r++) {
685
825
  const mat = materials[regions[r].material];
686
826
  const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
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/index.js CHANGED
@@ -100,6 +100,7 @@ export class Device {
100
100
  solver.computeConcentrations();
101
101
  solver.time = old.time;
102
102
  solver.contactDEnd = old.contactDEnd;
103
+ if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
103
104
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
104
105
  // Floating terminals keep their voltages (a good start), where the terminals match.
105
106
  if (old.terms.length === solver.terms.length) {
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,96 @@
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, surface = null; // (an electrode port's coverages at the spot)
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
+ if (port.surface.length > 0) {
53
+ const w = port.nodes.indexOf(g), theta = port.surface.map((sp) => sol.ports[k].coverage[sp.name][w]);
54
+ surface = { sp: port.surface, theta, bare: sol.ports[k].bare[w] };
55
+ }
56
+ reactions = port.reactions;
57
+ names = device.def.ports?.[k]?.reactions;
58
+ } else fail('polarization: say where, { face: f } or { port: name, x }');
59
+ if (reactions.length === 0) fail('polarization: no reactions there');
60
+
61
+ const out = reactions.map((rx, k) => {
62
+ if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
63
+ // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
64
+ let a0 = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
65
+ rx.part.forEach((p, x) => {
66
+ if (p.side === 2) {
67
+ // a surface species, at activity θ/θ₀ (μ = μ° + RT ln(θ/θ₀))
68
+ const ln = Math.log(surface.theta[p.s] / surface.bare), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
69
+ a0 -= (p.nu * (surface.sp[p.s].mu0 / RT + ln));
70
+ pref *= Math.exp(e * ln);
71
+ return;
72
+ }
73
+ if (p.side === carrier.side && p.i === carrier.i) {
74
+ s -= (p.nu * species[p.i].z * FARADAY) / RT;
75
+ return;
76
+ }
77
+ const g = nodeOf(p), name = species[p.i].name;
78
+ a0 -= (p.nu * sol.mu[name][g]) / RT;
79
+ q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
80
+ const c = sol.c[name][g];
81
+ if (rx.vmax) {
82
+ if (p.nu < 0) pref *= powi(c / (c + rx.K[x]), -p.nu);
83
+ } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
84
+ });
85
+ if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
86
+ if (rx.bare) pref *= surface ? surface.bare : 1; // on bare metal only
87
+ const rate = Float64Array.from(Vs, (v) => {
88
+ const a = a0 + s * v;
89
+ return rx.vmax ? pref * -Math.expm1(-a) : pref * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
90
+ });
91
+ return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -a0 / s, rate, current: rate.map((r) => q * FARADAY * r) };
92
+ });
93
+ const current = new Float64Array(Vs.length);
94
+ for (const r of out) for (let j = 0; j < Vs.length; j++) current[j] += r.current[j];
95
+ return { V: Vs, current, reactions: out };
96
+ }
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,32 @@ 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
+ if (port.capacitance) {
110
+ // a capacitance's charging current, and its charge (the port's side), like a gate's
111
+ const dt = solver.lastDt;
112
+ current += Number.isFinite(dt) ? (solver.portQ[k] - solver.portQOld[k]) / dt : 0;
113
+ }
114
+ const out = { name: port.name, V: solver.termV[2 + k], flux, current };
115
+ if (port.capacitance) out.charge = solver.portQ[k];
116
+ if (port.capacitance) {
117
+ // its charge per area of electrode at each node, σ = C (V − zeroCharge − φ)
118
+ const { C: Cs, zeroCharge } = port.capacitance;
119
+ out.sigma = Float64Array.from(port.nodes, (g) => Cs * (solver.termV[2 + k] - zeroCharge - phi[g]));
120
+ }
121
+ if (port.area !== null) {
122
+ out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
123
+ out.area = Float64Array.from(solver.portArea[k]);
124
+ }
125
+ if (port.reactions.length > 0) {
126
+ // An electrode spread through the window: each reaction's rate at its nodes.
127
+ out.rates = solver.portRates[k].map((r) => Float64Array.from(r)); // mol/(m²·s), forward
128
+ // its surface: each species' coverage θ at each node of the window
129
+ if (port.surface.length > 0) {
130
+ out.coverage = Object.fromEntries(port.surface.map((sp, q) => [sp.name, Float64Array.from(port.nodes, (g) => solver.th[g * solver.nSurf + q])]));
131
+ out.bare = Float64Array.from(port.nodes, (g) => solver.th0[g]); // θ₀, to full precision however covered
132
+ }
133
+ }
134
+ return out;
109
135
  });
110
136
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
111
137
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
@@ -142,14 +168,14 @@ export function makeSolution(solver, result = {}) {
142
168
  return { rate, regions, total };
143
169
  });
144
170
 
145
- // Total charge per area in the device (space charge plus sheet charges).
171
+ // Total charge in the device (space charge plus sheet charges), per area if it's planar.
146
172
  let q = 0;
147
173
  for (let g = 0; g < nNodes; g++) {
148
174
  let rho = solver.rhoFixed[g];
149
175
  for (let i = 0; i < n; i++) rho += F * species[i].z * c[g * n + i];
150
176
  q += grid.vol[g] * rho;
151
177
  }
152
- for (const itf of interfaces) q += itf.sheetCharge;
178
+ interfaces.forEach((itf, f) => (q += itf.sheetCharge * grid.area[grid.regionEnd[f]]));
153
179
  sol.charge = q;
154
180
 
155
181
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,