driftlet 0.8.0 → 0.10.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 +5 -3
- package/docs/device.md +169 -57
- package/docs/kit.md +7 -2
- package/docs/numerics.md +17 -5
- package/llms.txt +106 -7
- package/package.json +2 -1
- package/src/checks.js +27 -23
- package/src/describe.js +18 -10
- package/src/device.js +90 -21
- package/src/index.js +18 -4
- package/src/polarization.js +59 -12
- package/src/solution.js +32 -2
- package/src/solver.js +308 -59
- package/src/stack.js +3 -2
- package/src/types.js +14 -3
- package/types/checks.d.ts +3 -3
- package/types/index.d.ts +1 -0
- package/types/live.d.ts +1 -0
- package/types/polarization.d.ts +8 -2
- package/types/solver.d.ts +35 -4
- package/types/stack.d.ts +1 -1
- package/types/types.d.ts +31 -1
package/src/checks.js
CHANGED
|
@@ -24,9 +24,9 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
|
|
|
24
24
|
* - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
|
|
25
25
|
* parts) and the definition's likely unit slips.
|
|
26
26
|
* - **balance** (a steady state): each species' ledger, what comes in through each terminal and
|
|
27
|
-
* what each reaction (bulk or at a face) makes or uses, in mol/(m²·s)
|
|
28
|
-
* species, as a current F·|z|·rate. The terms must sum to
|
|
29
|
-
* and it says where every carrier went.
|
|
27
|
+
* what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) (mol/s if the device
|
|
28
|
+
* isn't planar) and, for a charged species, as a current F·|z|·rate. The terms must sum to
|
|
29
|
+
* zero; under light it's J = F(G − R), and it says where every carrier went.
|
|
30
30
|
* - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
|
|
31
31
|
* what came in.
|
|
32
32
|
* - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
|
|
@@ -75,10 +75,13 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
|
|
|
75
75
|
return { ok, items, text };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// The units of totals through the cross-section: per m² of a planar device, or whole.
|
|
79
|
+
const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²' } : { rate: 'mol/s', current: 'A', charge: 'C' });
|
|
80
|
+
|
|
78
81
|
// Each species' steady ledger: in through the terminals, made by the reactions. Rates in
|
|
79
|
-
// mol/(m²·s), positive into the device or made.
|
|
82
|
+
// mol/(m²·s) (mol/s if not planar), positive into the device or made.
|
|
80
83
|
function balance(device, sol) {
|
|
81
|
-
const { model, def } = device, { species, regions, interfaces } = model;
|
|
84
|
+
const { model, def } = device, { species, regions, interfaces } = model, units = unitsOf(model);
|
|
82
85
|
const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
|
|
83
86
|
// Each species' compartments: runs of regions it's present in, joined by faces it crosses
|
|
84
87
|
// freely (in equilibrium). A face it crosses only by a law (a permeability, a conductance) or
|
|
@@ -114,16 +117,18 @@ function balance(device, sol) {
|
|
|
114
117
|
});
|
|
115
118
|
});
|
|
116
119
|
interfaces.forEach((itf, f) => {
|
|
120
|
+
// A face's flux and rates are per area; everything else here is a total, through A there.
|
|
121
|
+
const Af = model.grid.area[model.grid.regionEnd[f]];
|
|
117
122
|
// Across the face, where it divides a species' compartments.
|
|
118
123
|
species.forEach((sp, i) => {
|
|
119
124
|
const type = itf.links[i].type;
|
|
120
125
|
if (type === 'equilibrium' || type === 'blocked') return;
|
|
121
|
-
const N = sol.interfaces[f].N[sp.name];
|
|
126
|
+
const N = Af * sol.interfaces[f].N[sp.name];
|
|
122
127
|
add(i, f, `across face ${f} (${type})`, -N);
|
|
123
128
|
add(i, f + 1, `across face ${f} (${type})`, N);
|
|
124
129
|
});
|
|
125
130
|
itf.reactions.forEach((rx, k) => {
|
|
126
|
-
const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
|
|
131
|
+
const rate = Af * sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
|
|
127
132
|
for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
|
|
128
133
|
});
|
|
129
134
|
});
|
|
@@ -147,14 +152,14 @@ function balance(device, sol) {
|
|
|
147
152
|
worst = Math.max(worst, Math.abs(l.residual));
|
|
148
153
|
// Terms that matter, largest first; a charged species' as a current too.
|
|
149
154
|
const shown = l.terms.filter((t) => Math.abs(t.rate) > 1e-6 * size(l)).sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate));
|
|
150
|
-
const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)}
|
|
155
|
+
const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} ${units.current})` : '');
|
|
151
156
|
lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
|
|
152
157
|
}
|
|
153
158
|
const ok = worst < 1e-6;
|
|
154
159
|
const summary =
|
|
155
160
|
moving.length === 0
|
|
156
161
|
? 'nothing flows or reacts (equilibrium)'
|
|
157
|
-
: `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks,
|
|
162
|
+
: `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, ${units.rate}, + in or made`;
|
|
158
163
|
// The largest current any charged species carries anywhere: the scale a net current is read against.
|
|
159
164
|
const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
|
|
160
165
|
return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
|
|
@@ -223,15 +228,17 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
223
228
|
}),
|
|
224
229
|
};
|
|
225
230
|
// Started from this solution, interpolated onto the finer grid; failing that, from cold.
|
|
226
|
-
let other;
|
|
231
|
+
let other, otherGrid;
|
|
227
232
|
try {
|
|
228
233
|
const dev = new Device(fine);
|
|
229
234
|
dev.solver._warmFrom(device.solver);
|
|
230
235
|
other = dev.solve();
|
|
236
|
+
otherGrid = dev.model.grid;
|
|
231
237
|
if (!other.converged) {
|
|
232
238
|
const cold = new Device(fine);
|
|
233
239
|
if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
|
|
234
240
|
other = cold.solve();
|
|
241
|
+
otherGrid = cold.model.grid;
|
|
235
242
|
}
|
|
236
243
|
} catch (e) {
|
|
237
244
|
return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
|
|
@@ -242,7 +249,7 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
242
249
|
// The current, against the largest current any species carries (so at open circuit, or in
|
|
243
250
|
// equilibrium, round-off isn't read as a change).
|
|
244
251
|
const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
|
|
245
|
-
if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit:
|
|
252
|
+
if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
|
|
246
253
|
// Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
|
|
247
254
|
const VT = model.RT / FARADAY;
|
|
248
255
|
model.terminals.forEach((t, k) => {
|
|
@@ -251,33 +258,30 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
251
258
|
diffs.push({ what: `${t.name} voltage`, a, b, rel: Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b), VT), unit: 'V' });
|
|
252
259
|
}
|
|
253
260
|
});
|
|
254
|
-
// Each region's charge
|
|
255
|
-
//
|
|
256
|
-
const charges = (s) => s.regions.map((_, r) => {
|
|
261
|
+
// Each region's charge, against the largest: summed over the nodes' boxes, as the solver's
|
|
262
|
+
// Gauss law counts it.
|
|
263
|
+
const charges = (s, grid) => s.regions.map((_, r) => {
|
|
257
264
|
let q = 0;
|
|
258
|
-
for (let g = 0; g < s.x.length; g++)
|
|
259
|
-
if (s.region[g] !== r) continue;
|
|
260
|
-
const left = g > 0 && s.region[g - 1] === r ? s.x[g] - s.x[g - 1] : 0, right = g + 1 < s.x.length && s.region[g + 1] === r ? s.x[g + 1] - s.x[g] : 0;
|
|
261
|
-
q += rho(s, g, model, r) * ((left + right) / 2);
|
|
262
|
-
}
|
|
265
|
+
for (let g = 0; g < s.x.length; g++) if (s.region[g] === r) q += rho(s, g, model, r) * grid.vol[g];
|
|
263
266
|
return q;
|
|
264
267
|
});
|
|
265
268
|
// Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
|
|
266
269
|
// the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
|
|
267
270
|
const content = Math.max(...sol.regions.map((reg, r) => {
|
|
268
|
-
let q = 0;
|
|
271
|
+
let q = 0, v = 0;
|
|
269
272
|
for (let g = 0; g < sol.x.length; g++) {
|
|
270
273
|
if (sol.region[g] !== r) continue;
|
|
271
274
|
let a = Math.abs(model.regions[r].fixedCharge ?? 0);
|
|
272
275
|
for (const sp of model.species) if (Number.isFinite(sol.c[sp.name][g])) a += FARADAY * Math.abs(sp.z * sol.c[sp.name][g]);
|
|
273
276
|
q = Math.max(q, a);
|
|
277
|
+
v += model.grid.vol[g];
|
|
274
278
|
}
|
|
275
|
-
return q *
|
|
279
|
+
return q * v;
|
|
276
280
|
}));
|
|
277
|
-
const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
|
|
281
|
+
const qa = charges(sol, model.grid), qb = charges(other, otherGrid), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
|
|
278
282
|
if (scaleQ > 1e-6 * content) {
|
|
279
283
|
const named = (reg) => (/^region \d+$/.test(reg.name) ? `${reg.name} (${reg.material})` : reg.name);
|
|
280
|
-
qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit:
|
|
284
|
+
qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit: unitsOf(model).charge }));
|
|
281
285
|
}
|
|
282
286
|
const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
|
|
283
287
|
if (!worst) return { name: 'grid', ok: true, summary: 'nothing to compare (no current, charge or floating voltage)' };
|
package/src/describe.js
CHANGED
|
@@ -27,10 +27,11 @@ function equation(...maps) {
|
|
|
27
27
|
return `${lhs.join(' + ') || '0'} = ${rhs.join(' + ') || '0'}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// (Per m² of a planar device; a whole one's current and resistance otherwise.)
|
|
31
|
+
function drive(c, planar) {
|
|
31
32
|
const v = (x) => (isObject(x) ? 'a waveform' : `${num(x)}`);
|
|
32
|
-
if (c?.I !== undefined) return `driven at I = ${v(c.I)} A/m
|
|
33
|
-
if (c?.R !== undefined) return `a source of ${v(c.V ?? 0)} V behind R = ${num(c.R)} Ω·m
|
|
33
|
+
if (c?.I !== undefined) return `driven at I = ${v(c.I)} ${planar ? 'A/m²' : 'A'}`;
|
|
34
|
+
if (c?.R !== undefined) return `a source of ${v(c.V ?? 0)} V behind R = ${num(c.R)} ${planar ? 'Ω·m²' : 'Ω'}`;
|
|
34
35
|
return `held at ${v(c?.V ?? 0)} V`;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -86,8 +87,8 @@ export function unitWarnings(def) {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
});
|
|
89
|
-
const capacitance = (path,
|
|
90
|
-
if (
|
|
90
|
+
const capacitance = (path, C) => {
|
|
91
|
+
if (C > 10) warn(path, `${num(C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
|
|
91
92
|
};
|
|
92
93
|
const kinetics = (path, reactions) => {
|
|
93
94
|
(reactions ?? []).forEach((rx, k) => {
|
|
@@ -96,13 +97,18 @@ export function unitWarnings(def) {
|
|
|
96
97
|
};
|
|
97
98
|
(def.interfaces ?? []).forEach((f, k) => {
|
|
98
99
|
if (!isObject(f)) return;
|
|
99
|
-
capacitance(`interfaces[${k}]`, f.phi);
|
|
100
|
+
capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
|
|
100
101
|
kinetics(`interfaces[${k}]`, f.reactions);
|
|
101
102
|
});
|
|
103
|
+
(def.ports ?? []).forEach((p, k) => {
|
|
104
|
+
if (!isObject(p)) return;
|
|
105
|
+
capacitance(`ports[${k}].capacitance.C`, p.capacitance?.C);
|
|
106
|
+
kinetics(`ports[${k}]`, p.reactions);
|
|
107
|
+
});
|
|
102
108
|
for (const side of ['left', 'right']) {
|
|
103
109
|
const c = def.contacts?.[side];
|
|
104
110
|
if (!isObject(c)) continue;
|
|
105
|
-
capacitance(`contacts.${side}`, c.phi);
|
|
111
|
+
capacitance(`contacts.${side}.phi.C`, c.phi?.C);
|
|
106
112
|
for (const [sname, v] of Object.entries(c.bath?.c ?? {})) conc(`contacts.${side}.bath.c.${sname}`, v);
|
|
107
113
|
if (typeof c.V === 'number' && Math.abs(c.V) > 50) warn(`contacts.${side}.V`, `${c.V} V is a very large bias; voltages are V`);
|
|
108
114
|
}
|
|
@@ -209,14 +215,16 @@ export function describe(def) {
|
|
|
209
215
|
const ct = model.contacts[side];
|
|
210
216
|
const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
|
|
211
217
|
const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
|
|
212
|
-
lines.push(` ${side}: ${drive(cdef)}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
|
|
218
|
+
lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
|
|
213
219
|
}
|
|
214
220
|
(def.ports ?? []).forEach((p, k) => {
|
|
215
221
|
const port = model.ports[k];
|
|
216
|
-
lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
|
|
222
|
+
lines.push(` port ${port.name}: ${drive(p, model.geometry.type === 'planar')}, in ${regions[port.region].name}`);
|
|
217
223
|
// an electrode spread through the window
|
|
218
224
|
if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
|
|
219
|
-
for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}`);
|
|
225
|
+
for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
|
|
226
|
+
if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
|
|
227
|
+
if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
|
|
220
228
|
});
|
|
221
229
|
if ((def.bulkReactions ?? []).length) {
|
|
222
230
|
lines.push('bulk reactions:');
|
package/src/device.js
CHANGED
|
@@ -312,10 +312,7 @@ export function normalizeDevice(def) {
|
|
|
312
312
|
need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
|
|
313
313
|
ct.passes = passes;
|
|
314
314
|
}
|
|
315
|
-
|
|
316
|
-
terminals.some((t) => t.drive.kind === 'V'),
|
|
317
|
-
'every terminal is driven by a current, so the device\'s overall level floats: hold at least one at a voltage V',
|
|
318
|
-
);
|
|
315
|
+
checkLevel(terminals.map((t) => t.drive), terminals.map((t) => (t.kind === 'port' ? ports[t.index] : contacts[t.side])));
|
|
319
316
|
|
|
320
317
|
// --- grid
|
|
321
318
|
if (def.grid !== undefined) {
|
|
@@ -351,6 +348,15 @@ export function normalizeDevice(def) {
|
|
|
351
348
|
need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
|
|
352
349
|
port.nodes = Int32Array.from(nodes);
|
|
353
350
|
});
|
|
351
|
+
// A node's surface belongs to one electrode.
|
|
352
|
+
const surfaced = new Int32Array(grid.nNodes).fill(-1);
|
|
353
|
+
ports.forEach((port, k) => {
|
|
354
|
+
if (port.surface.length === 0) return;
|
|
355
|
+
for (const g of port.nodes) {
|
|
356
|
+
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`);
|
|
357
|
+
surfaced[g] = k;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
354
360
|
|
|
355
361
|
return {
|
|
356
362
|
T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
|
|
@@ -559,6 +565,20 @@ function normalizeSource(v, path) {
|
|
|
559
565
|
return { t: Float64Array.from(v.t), values: Float64Array.from(v.values), repeat: v.repeat === true };
|
|
560
566
|
}
|
|
561
567
|
|
|
568
|
+
// Something must hold the device's overall level: a terminal held at a voltage, and if any is
|
|
569
|
+
// driven by a current, one held that passes current (one that passes none, a closed end, ties the
|
|
570
|
+
// level of nothing, and the driven terminal's voltage would float with φ).
|
|
571
|
+
function checkLevel(drives, owners) {
|
|
572
|
+
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");
|
|
573
|
+
const driven = drives.findIndex((d) => d.kind === 'I');
|
|
574
|
+
if (driven < 0) return;
|
|
575
|
+
const name = (k) => (k < 2 ? `contacts.${k === 0 ? 'left' : 'right'}` : `ports[${k - 2}]`);
|
|
576
|
+
need(
|
|
577
|
+
drives.some((d, k) => d.kind === 'V' && owners[k].passes),
|
|
578
|
+
`${name(driven)} is driven by a current, but no terminal held at a voltage passes any, so nothing fixes the level its voltage is read against: hold one that does (the port of an electrode at V: 0, say: at the only electrode of a device, the same as I: 0)`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
562
582
|
/**
|
|
563
583
|
* The terminals' drives alone (in terminal order: left, right, then the ports), from a definition
|
|
564
584
|
* whose structure is unchanged: for a fast update of sources.
|
|
@@ -573,7 +593,7 @@ export function normalizeDrives(def, model) {
|
|
|
573
593
|
need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
|
|
574
594
|
}
|
|
575
595
|
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`));
|
|
576
|
-
|
|
596
|
+
checkLevel(drives, [model.contacts.left, model.contacts.right, ...model.ports]);
|
|
577
597
|
return drives;
|
|
578
598
|
}
|
|
579
599
|
|
|
@@ -649,7 +669,7 @@ function normalizeGeometry(g) {
|
|
|
649
669
|
// the window, `area` (m²/m³) of it per volume.
|
|
650
670
|
function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
|
|
651
671
|
need(isObject(pdef), `${path} must be an object`);
|
|
652
|
-
fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area']);
|
|
672
|
+
fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance']);
|
|
653
673
|
let r;
|
|
654
674
|
if (Number.isInteger(pdef.region)) r = pdef.region;
|
|
655
675
|
else r = regions.findIndex((reg) => reg.name === pdef.region);
|
|
@@ -666,8 +686,11 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
666
686
|
need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
|
|
667
687
|
terminal = speciesIndex.get(pdef.terminal);
|
|
668
688
|
}
|
|
669
|
-
const
|
|
670
|
-
|
|
689
|
+
const surface = portSurface(pdef, path, speciesIndex);
|
|
690
|
+
const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
|
|
691
|
+
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`));
|
|
692
|
+
const capacitance = portCapacitance(pdef, path, mat);
|
|
693
|
+
need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
|
|
671
694
|
const links = species.map(() => ({ type: 'blocked' }));
|
|
672
695
|
for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
|
|
673
696
|
const lpath = `${path}.species.${sname}`;
|
|
@@ -697,48 +720,94 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
697
720
|
if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
|
|
698
721
|
links[i] = { type: link.type, ...level };
|
|
699
722
|
}
|
|
700
|
-
need(links.some((l) => l.type !== 'blocked') || reactions.length > 0, `${path}.species: the port exchanges no species`);
|
|
723
|
+
need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
|
|
701
724
|
// One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
|
|
702
725
|
// nobody's business: it can't be driven by one.
|
|
703
|
-
const passes = reactions.length > 0 || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
|
|
726
|
+
const passes = reactions.length > 0 || capacitance !== null || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
|
|
704
727
|
need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
|
|
705
728
|
let area = null;
|
|
706
|
-
if (reactions.length > 0) {
|
|
729
|
+
if (reactions.length > 0 || capacitance !== null) {
|
|
707
730
|
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`);
|
|
708
731
|
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');
|
|
709
|
-
} else need(pdef.area === undefined, `${path}.area: only a port with reactions has an
|
|
710
|
-
|
|
732
|
+
} else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
|
|
733
|
+
need(surface.length === 0 || reactions.length > 0, `${path}.surface: only an electrode (a port with reactions) has a surface`);
|
|
734
|
+
return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// A capacitance spread through the window: a gate along a channel, an electrode's double layer.
|
|
738
|
+
// Per area of electrode it holds σ = C (V − zeroCharge − φ) on the port's side, and the window
|
|
739
|
+
// the opposite: the region's charge balance (Gauss's law, or neutrality at ε = 0) gains aσ.
|
|
740
|
+
function portCapacitance(pdef, path, mat) {
|
|
741
|
+
if (pdef.capacitance === undefined) return null;
|
|
742
|
+
const cpath = `${path}.capacitance`;
|
|
743
|
+
need(isObject(pdef.capacitance), `${cpath} must be { C, zeroCharge }`);
|
|
744
|
+
fields(pdef.capacitance, cpath, ['C', 'zeroCharge']);
|
|
745
|
+
need(!mat.conductor && !mat.phiFree, `${cpath}: a capacitance couples to φ, which a ${mat.conductor ? 'conductor' : 'region with no free charge'} doesn't have`);
|
|
746
|
+
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)`) };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// An electrode port's surface species: coverages θ of its sites (Langmuir: each takes one site,
|
|
750
|
+
// μ = μ° + RT ln(θ/θ₀) with θ₀ = 1 − Σθ the bare fraction), Γ mol of sites per m² of electrode.
|
|
751
|
+
function portSurface(pdef, path, speciesIndex) {
|
|
752
|
+
if (pdef.surface === undefined) return [];
|
|
753
|
+
need(isObject(pdef.surface), `${path}.surface must map surface species to { mu0, capacity, theta0 }`);
|
|
754
|
+
const list = Object.entries(pdef.surface).map(([name, d]) => {
|
|
755
|
+
const spath = `${path}.surface.${name}`;
|
|
756
|
+
need(!speciesIndex.has(name), `${spath}: '${name}' is a species of the device; a surface species is the port's own, with its own name`);
|
|
757
|
+
need(isObject(d), `${spath} must be { mu0, capacity, theta0 }`);
|
|
758
|
+
fields(d, spath, ['mu0', 'capacity', 'theta0']);
|
|
759
|
+
const theta0 = d.theta0 === undefined ? 1e-6 : positive(d.theta0, `${spath}.theta0`);
|
|
760
|
+
return { name, mu0: finite(d.mu0, `${spath}.mu0 (J/mol)`), capacity: positive(d.capacity, `${spath}.capacity (mol of sites per m² of electrode)`), theta0 };
|
|
761
|
+
});
|
|
762
|
+
need(list.length > 0, `${path}.surface: give at least one surface species, or leave it out`);
|
|
763
|
+
const caps = new Set(list.map((sp) => sp.capacity));
|
|
764
|
+
need(caps.size === 1, `${path}.surface: the species share the electrode's sites, so give them one capacity`);
|
|
765
|
+
need(list.reduce((t, sp) => t + sp.theta0, 0) < 1, `${path}.surface: the starting coverages (theta0) must sum to less than 1`);
|
|
766
|
+
return list;
|
|
711
767
|
}
|
|
712
768
|
|
|
713
769
|
// A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
|
|
714
770
|
// port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
|
|
715
771
|
// electrode: part side 0 for the region's species, side 1 for the carrier.
|
|
716
|
-
function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT) {
|
|
772
|
+
function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface = []) {
|
|
717
773
|
need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
|
|
718
774
|
const list = pdef.reactions ?? [];
|
|
719
775
|
if (list.length === 0) return [];
|
|
720
776
|
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`);
|
|
777
|
+
// The metal's countercharge (each spot's double layer) is below the grid, so the solution
|
|
778
|
+
// beside it must be neutral, as in porous-electrode theory; with ε > 0 the reactions would
|
|
779
|
+
// leave a net charge with nothing to balance it.
|
|
780
|
+
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`);
|
|
721
781
|
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-')`);
|
|
722
782
|
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`);
|
|
723
783
|
const carrier = species[terminal].name;
|
|
724
784
|
return list.map((rdef, k) => {
|
|
725
785
|
const rpath = `${path}.reactions[${k}]`;
|
|
726
786
|
need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
|
|
727
|
-
fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha']);
|
|
787
|
+
fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha', 'bare']);
|
|
728
788
|
const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
|
|
729
|
-
const
|
|
789
|
+
const onSurface = (name) => surface.findIndex((sp) => sp.name === name);
|
|
790
|
+
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`);
|
|
730
791
|
const part = [];
|
|
731
792
|
let fixedA = 0, charge = 0;
|
|
732
|
-
|
|
733
|
-
|
|
793
|
+
// The electrode's side: its carrier, and its surface species (side 2, neutral, by index s).
|
|
794
|
+
const electrode = {};
|
|
795
|
+
for (const [name, nu] of Object.entries(sides.right)) {
|
|
796
|
+
const s = onSurface(name);
|
|
797
|
+
if (s >= 0) part.push({ s, nu, side: 2 });
|
|
798
|
+
else electrode[name] = nu;
|
|
799
|
+
}
|
|
800
|
+
for (const [map, side] of [[sides.left, 0], [electrode, 1]]) {
|
|
801
|
+
const st = stoichiometry(map, `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
|
|
734
802
|
for (const { i, nu } of st.list) part.push({ i, nu, side });
|
|
735
803
|
fixedA += st.fixedA;
|
|
736
804
|
charge += st.charge;
|
|
737
805
|
}
|
|
738
806
|
need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
|
|
739
|
-
need(part.some((p) => p.side === 0), `${rpath}: no species of the port's region takes part`);
|
|
807
|
+
need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
|
|
740
808
|
need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
|
|
741
|
-
|
|
809
|
+
need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
|
|
810
|
+
return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
|
|
742
811
|
});
|
|
743
812
|
}
|
|
744
813
|
|
|
@@ -762,7 +831,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
|
|
|
762
831
|
const anchored = new Set();
|
|
763
832
|
if (anchors(contacts.left)) anchored.add(find(0));
|
|
764
833
|
if (anchors(contacts.right)) anchored.add(find(nR - 1));
|
|
765
|
-
for (const port of ports) if (port.reactions.length > 0 || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
|
|
834
|
+
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));
|
|
766
835
|
for (let r = 0; r < nR; r++) {
|
|
767
836
|
const mat = materials[regions[r].material];
|
|
768
837
|
const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
|
package/src/index.js
CHANGED
|
@@ -100,21 +100,34 @@ 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) {
|
|
106
107
|
for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
|
|
107
108
|
}
|
|
108
109
|
// What each stretch conserves from here: a stretch that was closed and still is (same
|
|
109
|
-
// species, same regions) keeps its amount; any other (newly closed, split,
|
|
110
|
-
// starts from what the
|
|
110
|
+
// species, same regions, same boxes) keeps its amount; any other (newly closed, split,
|
|
111
|
+
// merged, open, or with its boxes resized by a new grid or geometry) starts from what the
|
|
112
|
+
// carried state holds.
|
|
111
113
|
const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
|
|
112
114
|
const before = new Map(old.stretches.map((st, k) => [key(st), k]));
|
|
115
|
+
const va = old.model.grid.vol, vb = model.grid.vol, sameBoxes = va.length === vb.length && va.every((v, g) => v === vb[g]);
|
|
113
116
|
solver.referenceAmounts = solver.stretches.map((st) => {
|
|
114
117
|
const k = before.get(key(st));
|
|
115
|
-
return k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
|
|
118
|
+
return sameBoxes && k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
|
|
116
119
|
});
|
|
117
120
|
solver.boundaryIntake.fill(0);
|
|
121
|
+
// Each surface's reference likewise: kept where its window and the boxes are unchanged.
|
|
122
|
+
const sameSurface = (j) => {
|
|
123
|
+
const [k, q] = solver.surfCols[j], o = old.surfCols.findIndex(([kk, qq]) => old.model.ports[kk].name === model.ports[k].name && qq === q);
|
|
124
|
+
const a = old.model.ports[old.surfCols[o]?.[0]], b = model.ports[k];
|
|
125
|
+
return o >= 0 && a.surface[q].name === b.surface[q].name && a.nodes.length === b.nodes.length && a.nodes.every((g, w) => g === b.nodes[w]) ? o : -1;
|
|
126
|
+
};
|
|
127
|
+
solver.surfaceRef = Float64Array.from(solver.surfCols, (_, j) => {
|
|
128
|
+
const o = sameBoxes ? sameSurface(j) : -1;
|
|
129
|
+
return o >= 0 ? old.surfaceRef[o] : solver.surfaceAmount(j);
|
|
130
|
+
});
|
|
118
131
|
}
|
|
119
132
|
return this;
|
|
120
133
|
}
|
|
@@ -189,7 +202,7 @@ export class Device {
|
|
|
189
202
|
*/
|
|
190
203
|
_checkpoint() {
|
|
191
204
|
const solver = this.solver;
|
|
192
|
-
return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice() };
|
|
205
|
+
return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice(), surfaceRef: solver.surfaceRef.slice() };
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
/**
|
|
@@ -207,6 +220,7 @@ export class Device {
|
|
|
207
220
|
cp.solver._restore(cp.snap);
|
|
208
221
|
cp.solver.solvedV = cp.solvedV;
|
|
209
222
|
cp.solver.referenceAmounts = cp.referenceAmounts.slice();
|
|
223
|
+
cp.solver.surfaceRef = cp.surfaceRef.slice();
|
|
210
224
|
}
|
|
211
225
|
|
|
212
226
|
/**
|
package/src/polarization.js
CHANGED
|
@@ -21,15 +21,18 @@ const fail = (message) => {
|
|
|
21
21
|
* @param {{ face: number } | { port: string | number, x?: number }} where a face's index, or a port
|
|
22
22
|
* (name or index) and the position in its window (m, the device's x; default its first node)
|
|
23
23
|
* @param {number | ArrayLike<number>} V the metal's level(s), V
|
|
24
|
-
* @
|
|
24
|
+
* @param {{ surface?: 'held' | 'equilibrium' }} [opts] an electrode port's surface coverages: held as
|
|
25
|
+
* in `sol` (the default: this instant's curves), or in equilibrium at each V (the steady-state
|
|
26
|
+
* curve, with a passive film's active–passive peak), each species by the reaction that makes it
|
|
27
|
+
* @returns {{ V: Float64Array, current: Float64Array, bare?: Float64Array, reactions: { equation: string, level: number,
|
|
25
28
|
* rate: Float64Array, current: Float64Array }[] }} per reaction: its equilibrium level here (where
|
|
26
29
|
* its rate is zero), its rate (mol/(m²·s) of electrode, forward) and its current (A/m², anodic
|
|
27
30
|
* positive: into the solution); and their sum
|
|
28
31
|
*/
|
|
29
|
-
export function polarization(device, sol, where, V) {
|
|
32
|
+
export function polarization(device, sol, where, V, opts = {}) {
|
|
30
33
|
const model = device.model, { species, grid, RT } = model, solver = device.solver;
|
|
31
34
|
const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
|
|
32
|
-
let reactions, nodeOf, carrier, names;
|
|
35
|
+
let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
|
|
33
36
|
if (where?.face !== undefined) {
|
|
34
37
|
const f = where.face, itf = model.interfaces[f];
|
|
35
38
|
if (!itf) fail(`polarization: no face ${f} (the device has ${model.interfaces.length})`);
|
|
@@ -49,22 +52,34 @@ export function polarization(device, sol, where, V) {
|
|
|
49
52
|
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
53
|
carrier = { side: 1, i: port.terminal };
|
|
51
54
|
nodeOf = () => g;
|
|
55
|
+
if (port.surface.length > 0) {
|
|
56
|
+
const w = port.nodes.indexOf(g), theta = port.surface.map((sp) => sol.ports[k].coverage[sp.name][w]);
|
|
57
|
+
surface = { sp: port.surface, theta, bare: sol.ports[k].bare[w] };
|
|
58
|
+
}
|
|
52
59
|
reactions = port.reactions;
|
|
53
60
|
names = device.def.ports?.[k]?.reactions;
|
|
54
61
|
} else fail('polarization: say where, { face: f } or { port: name, x }');
|
|
55
62
|
if (reactions.length === 0) fail('polarization: no reactions there');
|
|
56
63
|
|
|
57
|
-
|
|
64
|
+
// Each reaction as a = base + s·V − Σ ν_s ln a_s (the surface species' activities apart), with
|
|
65
|
+
// its prefactor apart from them too, so a surface can be held or re-equilibrated at each V.
|
|
66
|
+
const parts = reactions.map((rx, k) => {
|
|
58
67
|
if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
let base = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
|
|
69
|
+
const surf = [];
|
|
61
70
|
rx.part.forEach((p, x) => {
|
|
71
|
+
if (p.side === 2) {
|
|
72
|
+
// a surface species: μ = μ° + RT ln a, a = θ/θ₀
|
|
73
|
+
base -= (p.nu * surface.sp[p.s].mu0) / RT;
|
|
74
|
+
surf.push({ s: p.s, nu: p.nu, e: p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
62
77
|
if (p.side === carrier.side && p.i === carrier.i) {
|
|
63
78
|
s -= (p.nu * species[p.i].z * FARADAY) / RT;
|
|
64
79
|
return;
|
|
65
80
|
}
|
|
66
81
|
const g = nodeOf(p), name = species[p.i].name;
|
|
67
|
-
|
|
82
|
+
base -= (p.nu * sol.mu[name][g]) / RT;
|
|
68
83
|
q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
|
|
69
84
|
const c = sol.c[name][g];
|
|
70
85
|
if (rx.vmax) {
|
|
@@ -72,13 +87,45 @@ export function polarization(device, sol, where, V) {
|
|
|
72
87
|
} else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
|
|
73
88
|
});
|
|
74
89
|
if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
return { rx, k, base, s, q, pref, surf };
|
|
91
|
+
});
|
|
92
|
+
// The surface's activities (ln a) and bare fraction at a level: held as `sol` has them, or in
|
|
93
|
+
// equilibrium there, each species by the first reaction that makes it from the solution alone.
|
|
94
|
+
const held = surface && surface.theta.map((t) => Math.log(t / surface.bare));
|
|
95
|
+
let former = null;
|
|
96
|
+
if (surface && opts.surface === 'equilibrium') {
|
|
97
|
+
former = surface.sp.map((sp, x) => {
|
|
98
|
+
const f = parts.find((pt) => pt.surf.length === 1 && pt.surf[0].s === x);
|
|
99
|
+
if (!f) fail(`polarization: no reaction makes ${sp.name} from the solution alone, so its equilibrium coverage isn't defined`);
|
|
100
|
+
return f;
|
|
101
|
+
});
|
|
102
|
+
} else if (opts.surface !== undefined && opts.surface !== 'held') fail("polarization: surface must be 'held' (the default) or 'equilibrium'");
|
|
103
|
+
const surfaceAt = (v) => {
|
|
104
|
+
if (!surface) return { lnA: [], bare: 1 };
|
|
105
|
+
// base + s v − ν ln a = 0 for each species' former
|
|
106
|
+
const lnA = former ? former.map((f) => (f.base + f.s * v) / f.surf[0].nu) : held;
|
|
107
|
+
if (!former) return { lnA, bare: surface.bare };
|
|
108
|
+
const m = Math.max(0, ...lnA);
|
|
109
|
+
let sum = Math.exp(-m);
|
|
110
|
+
for (const l of lnA) sum += Math.exp(l - m);
|
|
111
|
+
return { lnA, bare: Math.exp(-m) / sum };
|
|
112
|
+
};
|
|
113
|
+
const at = Array.from(Vs, (v) => surfaceAt(v));
|
|
114
|
+
const out = parts.map(({ rx, k, base, s, q, pref, surf }) => {
|
|
115
|
+
const rate = Float64Array.from(Vs, (v, j) => {
|
|
116
|
+
const { lnA, bare } = at[j];
|
|
117
|
+
let a = base + s * v, p = pref * (rx.bare ? bare : 1);
|
|
118
|
+
for (const u of surf) {
|
|
119
|
+
a -= u.nu * lnA[u.s];
|
|
120
|
+
p *= Math.exp(u.e * lnA[u.s]);
|
|
121
|
+
}
|
|
122
|
+
return rx.vmax ? p * -Math.expm1(-a) : p * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
|
|
78
123
|
});
|
|
79
|
-
|
|
124
|
+
// (its level with the surface as held)
|
|
125
|
+
const aHeld = surf.reduce((t, u) => t - u.nu * (held ? held[u.s] : 0), base);
|
|
126
|
+
return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -aHeld / s, rate, current: rate.map((r) => q * FARADAY * r) };
|
|
80
127
|
});
|
|
81
128
|
const current = new Float64Array(Vs.length);
|
|
82
129
|
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 };
|
|
130
|
+
return { V: Vs, current, ...(surface ? { bare: Float64Array.from(at, (x) => x.bare) } : {}), reactions: out };
|
|
84
131
|
}
|
package/src/solution.js
CHANGED
|
@@ -107,11 +107,29 @@ export function makeSolution(solver, result = {}) {
|
|
|
107
107
|
current += F * species[i].z * solver.portFlux[k][i];
|
|
108
108
|
}
|
|
109
109
|
const out = { name: port.name, V: solver.termV[2 + k], flux, current };
|
|
110
|
-
if (port.
|
|
111
|
-
//
|
|
110
|
+
if (port.capacitance) {
|
|
111
|
+
// A capacitance's charging current, and its charge (the port's side), like a gate's; and
|
|
112
|
+
// its charge per area of electrode at each node, σ = C (V − zeroCharge − φ) (none at an
|
|
113
|
+
// end whose φ its contact sets: the contact holds that charge).
|
|
114
|
+
const dt = solver.lastDt;
|
|
115
|
+
out.current += Number.isFinite(dt) ? (solver.portQ[k] - solver.portQOld[k]) / dt : 0;
|
|
116
|
+
out.charge = solver.portQ[k];
|
|
117
|
+
const { C: Cs, zeroCharge } = port.capacitance, last = model.grid.nNodes - 1;
|
|
118
|
+
const sets = (side) => ['pinned', 'bulk'].includes(model.contacts[side].phi.type);
|
|
119
|
+
out.sigma = Float64Array.from(port.nodes, (g) => ((g === 0 && sets('left')) || (g === last && sets('right')) ? 0 : Cs * (solver.termV[2 + k] - zeroCharge - phi[g])));
|
|
120
|
+
}
|
|
121
|
+
if (port.area !== null) {
|
|
112
122
|
out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
|
|
113
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.
|
|
114
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
|
+
}
|
|
115
133
|
}
|
|
116
134
|
return out;
|
|
117
135
|
});
|
|
@@ -163,6 +181,18 @@ export function makeSolution(solver, result = {}) {
|
|
|
163
181
|
// Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
|
|
164
182
|
// gate contacts), check the local Debye length against the adjacent cell.
|
|
165
183
|
sol.warnings = [...(model.warnings ?? [])];
|
|
184
|
+
// A surface covered almost completely: its coverages' η barely move anything below ~1e-10 bare,
|
|
185
|
+
// and Newton loses them (a transient can stall there).
|
|
186
|
+
sol.ports.forEach((p) => {
|
|
187
|
+
if (!p.bare) return;
|
|
188
|
+
const min = Math.min(...p.bare);
|
|
189
|
+
if (min < 1e-10) {
|
|
190
|
+
sol.warnings.push(
|
|
191
|
+
`ports ${p.name}: its surface is covered to a bare fraction of ${min.toExponential(1)} at x = ${p.x[p.bare.indexOf(min)].toExponential(3)} m. ` +
|
|
192
|
+
'Below ~1e-10 the coverage barely affects anything and the solver struggles with it; a passive metal blocks to 1e-3–1e-6, so make the film less stable (raise its μ°).',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
166
196
|
const cond = solver.conditioning;
|
|
167
197
|
if (result.converged === false && cond && cond.digits > 12) {
|
|
168
198
|
const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
|