driftlet 0.9.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 +3 -3
- package/docs/device.md +118 -90
- package/docs/kit.md +5 -0
- 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 +15 -9
- package/src/device.js +16 -5
- package/src/index.js +17 -4
- package/src/polarization.js +51 -16
- package/src/solution.js +21 -9
- package/src/solver.js +151 -40
- package/src/types.js +4 -0
- 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 +16 -4
- package/types/types.d.ts +6 -0
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,11 +215,11 @@ 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
225
|
for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
|
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) {
|
|
@@ -568,6 +565,20 @@ function normalizeSource(v, path) {
|
|
|
568
565
|
return { t: Float64Array.from(v.t), values: Float64Array.from(v.values), repeat: v.repeat === true };
|
|
569
566
|
}
|
|
570
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
|
+
|
|
571
582
|
/**
|
|
572
583
|
* The terminals' drives alone (in terminal order: left, right, then the ports), from a definition
|
|
573
584
|
* whose structure is unchanged: for a fast update of sources.
|
|
@@ -582,7 +593,7 @@ export function normalizeDrives(def, model) {
|
|
|
582
593
|
need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
|
|
583
594
|
}
|
|
584
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`));
|
|
585
|
-
|
|
596
|
+
checkLevel(drives, [model.contacts.left, model.contacts.right, ...model.ports]);
|
|
586
597
|
return drives;
|
|
587
598
|
}
|
|
588
599
|
|
package/src/index.js
CHANGED
|
@@ -107,15 +107,27 @@ export class Device {
|
|
|
107
107
|
for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
|
|
108
108
|
}
|
|
109
109
|
// What each stretch conserves from here: a stretch that was closed and still is (same
|
|
110
|
-
// species, same regions) keeps its amount; any other (newly closed, split,
|
|
111
|
-
// 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.
|
|
112
113
|
const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
|
|
113
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]);
|
|
114
116
|
solver.referenceAmounts = solver.stretches.map((st) => {
|
|
115
117
|
const k = before.get(key(st));
|
|
116
|
-
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);
|
|
117
119
|
});
|
|
118
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
|
+
});
|
|
119
131
|
}
|
|
120
132
|
return this;
|
|
121
133
|
}
|
|
@@ -190,7 +202,7 @@ export class Device {
|
|
|
190
202
|
*/
|
|
191
203
|
_checkpoint() {
|
|
192
204
|
const solver = this.solver;
|
|
193
|
-
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() };
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
/**
|
|
@@ -208,6 +220,7 @@ export class Device {
|
|
|
208
220
|
cp.solver._restore(cp.snap);
|
|
209
221
|
cp.solver.solvedV = cp.solvedV;
|
|
210
222
|
cp.solver.referenceAmounts = cp.referenceAmounts.slice();
|
|
223
|
+
cp.solver.surfaceRef = cp.surfaceRef.slice();
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
/**
|
package/src/polarization.js
CHANGED
|
@@ -21,12 +21,15 @@ 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
35
|
let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
|
|
@@ -58,16 +61,17 @@ export function polarization(device, sol, where, V) {
|
|
|
58
61
|
} else fail('polarization: say where, { face: f } or { port: name, x }');
|
|
59
62
|
if (reactions.length === 0) fail('polarization: no reactions there');
|
|
60
63
|
|
|
61
|
-
|
|
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) => {
|
|
62
67
|
if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
let base = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
|
|
69
|
+
const surf = [];
|
|
65
70
|
rx.part.forEach((p, x) => {
|
|
66
71
|
if (p.side === 2) {
|
|
67
|
-
// a surface species
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pref *= Math.exp(e * ln);
|
|
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 });
|
|
71
75
|
return;
|
|
72
76
|
}
|
|
73
77
|
if (p.side === carrier.side && p.i === carrier.i) {
|
|
@@ -75,7 +79,7 @@ export function polarization(device, sol, where, V) {
|
|
|
75
79
|
return;
|
|
76
80
|
}
|
|
77
81
|
const g = nodeOf(p), name = species[p.i].name;
|
|
78
|
-
|
|
82
|
+
base -= (p.nu * sol.mu[name][g]) / RT;
|
|
79
83
|
q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
|
|
80
84
|
const c = sol.c[name][g];
|
|
81
85
|
if (rx.vmax) {
|
|
@@ -83,14 +87,45 @@ export function polarization(device, sol, where, V) {
|
|
|
83
87
|
} else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
|
|
84
88
|
});
|
|
85
89
|
if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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));
|
|
90
123
|
});
|
|
91
|
-
|
|
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) };
|
|
92
127
|
});
|
|
93
128
|
const current = new Float64Array(Vs.length);
|
|
94
129
|
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 };
|
|
130
|
+
return { V: Vs, current, ...(surface ? { bare: Float64Array.from(at, (x) => x.bare) } : {}), reactions: out };
|
|
96
131
|
}
|
package/src/solution.js
CHANGED
|
@@ -106,17 +106,17 @@ export function makeSolution(solver, result = {}) {
|
|
|
106
106
|
flux[species[i].name] = solver.portFlux[k][i];
|
|
107
107
|
current += F * species[i].z * solver.portFlux[k][i];
|
|
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
109
|
const out = { name: port.name, V: solver.termV[2 + k], flux, current };
|
|
115
|
-
if (port.capacitance) out.charge = solver.portQ[k];
|
|
116
110
|
if (port.capacitance) {
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
120
|
}
|
|
121
121
|
if (port.area !== null) {
|
|
122
122
|
out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
|
|
@@ -181,6 +181,18 @@ export function makeSolution(solver, result = {}) {
|
|
|
181
181
|
// Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
|
|
182
182
|
// gate contacts), check the local Debye length against the adjacent cell.
|
|
183
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
|
+
});
|
|
184
196
|
const cond = solver.conditioning;
|
|
185
197
|
if (result.converged === false && cond && cond.digits > 12) {
|
|
186
198
|
const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
|