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/README.md +11 -5
- package/docs/device.md +163 -3
- package/docs/kit.md +27 -3
- package/docs/numerics.md +16 -0
- package/llms.txt +14 -9
- package/package.json +1 -1
- package/src/checks.js +10 -9
- package/src/describe.js +14 -1
- package/src/device.js +153 -13
- package/src/grid.js +73 -0
- package/src/index.js +1 -0
- package/src/kit.js +1 -0
- package/src/polarization.js +96 -0
- package/src/solution.js +30 -4
- package/src/solver.js +325 -48
- package/src/stack.js +3 -2
- package/src/types.js +22 -4
- package/types/grid.d.ts +29 -0
- package/types/kit.d.ts +1 -0
- package/types/polarization.d.ts +31 -0
- package/types/solver.d.ts +26 -3
- package/types/stack.d.ts +1 -1
- package/types/types.d.ts +65 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|