driftlet 0.13.0 → 0.16.2
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 +1 -1
- package/docs/alignment.md +6 -3
- package/docs/data.md +3 -1
- package/docs/device.md +97 -33
- package/docs/kit.md +95 -8
- package/docs/logo-dark.svg +3 -2
- package/docs/logo.svg +3 -2
- package/docs/numerics.md +193 -114
- package/docs/reliability.md +16 -10
- package/docs/statistics.md +28 -4
- package/llms.txt +325 -335
- package/package.json +1 -1
- package/src/blockTridiagonal.js +12 -6
- package/src/checks.js +16 -5
- package/src/describe.js +46 -5
- package/src/device.js +88 -10
- package/src/index.js +19 -7
- package/src/kit.js +1 -0
- package/src/particles.js +407 -0
- package/src/solution.js +171 -10
- package/src/solver.js +797 -262
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/checks.d.ts +2 -1
- package/types/kit.d.ts +1 -0
- package/types/particles.d.ts +39 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +47 -29
- package/types/stack.d.ts +6 -4
package/package.json
CHANGED
package/src/blockTridiagonal.js
CHANGED
|
@@ -110,12 +110,17 @@ export class BlockTridiagonal {
|
|
|
110
110
|
if (i === 0 || sizes[i - 1] === 0) {
|
|
111
111
|
for (let k = 0; k < m * m; k++) lu[o + k] = B[o + k];
|
|
112
112
|
} else {
|
|
113
|
+
// Row by row, skipping A's zeros (a gate or a local unknown has none to its neighbours):
|
|
114
|
+
// the same sums, subtracted in the same order.
|
|
113
115
|
const mp = sizes[i - 1], oa = offA[i], op = offC[i - 1]; // C'_{i−1}: mp × m
|
|
114
116
|
for (let r = 0; r < m; r++) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const or = o + r * m;
|
|
118
|
+
for (let c = 0; c < m; c++) lu[or + c] = B[or + c];
|
|
119
|
+
for (let k = 0; k < mp; k++) {
|
|
120
|
+
const a = A[oa + r * mp + k];
|
|
121
|
+
if (a === 0) continue;
|
|
122
|
+
const ok = op + k * m;
|
|
123
|
+
for (let c = 0; c < m; c++) lu[or + c] -= a * cp[ok + c];
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -130,8 +135,9 @@ export class BlockTridiagonal {
|
|
|
130
135
|
if (i < n - 1) {
|
|
131
136
|
const mn = sizes[i + 1], oc = offC[i];
|
|
132
137
|
for (let c = 0; c < mn; c++) {
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
let any = false;
|
|
139
|
+
for (let r = 0; r < m; r++) if ((tmp[r] = C[oc + r * mn + c]) !== 0) any = true;
|
|
140
|
+
if (any) luSolve(lu, o, piv, offX[i], m, tmp); // (a zero column stays zero)
|
|
135
141
|
for (let r = 0; r < m; r++) cp[oc + r * mn + c] = tmp[r];
|
|
136
142
|
}
|
|
137
143
|
}
|
package/src/checks.js
CHANGED
|
@@ -29,7 +29,8 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
|
|
|
29
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
|
-
* - **grid** (
|
|
32
|
+
* - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
|
|
33
|
+
* answer depends on the whole run): in a steady state, the device solved again on a grid
|
|
33
34
|
* twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
|
|
34
35
|
* region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
|
|
35
36
|
* wants less). The discretisation is second order, so the change estimates this grid's error
|
|
@@ -67,6 +68,9 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
|
|
|
67
68
|
const ledger = steady ? balance(device, sol) : null;
|
|
68
69
|
items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
|
|
69
70
|
if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
|
|
71
|
+
// A transient's answer depends on its whole history (and on drives set along the way), so
|
|
72
|
+
// only a run of its own on a finer grid can check it.
|
|
73
|
+
else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const ok = sol.converged && items.every((it) => it.ok !== false);
|
|
@@ -106,9 +110,12 @@ function balance(device, sol) {
|
|
|
106
110
|
else ledgers[l].terms.push({ what, rate });
|
|
107
111
|
};
|
|
108
112
|
const last = regions.length - 1;
|
|
113
|
+
// (A contact that blocks a species is no source of it: what its flux reads there is the end
|
|
114
|
+
// box's own imbalance, which the ledger must show, not balance.)
|
|
115
|
+
const through = (side, i) => model.contacts[side].species[i].type !== 'blocked';
|
|
109
116
|
species.forEach((sp, i) => {
|
|
110
|
-
add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
111
|
-
add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
117
|
+
if (through('left', i)) add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
118
|
+
if (through('right', i)) add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
112
119
|
model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
|
|
113
120
|
});
|
|
114
121
|
model.reactions.forEach((rx, k) => {
|
|
@@ -255,8 +262,12 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
255
262
|
const diffs = [];
|
|
256
263
|
// The current, against the largest current any species carries (so at open circuit, or in
|
|
257
264
|
// equilibrium, round-off isn't read as a change).
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
// A current too small for the ledgers to call moving (a diode at low bias: 1e-4 A/m² against
|
|
266
|
+
// majority carriers that could carry 6e8) is compared still, above a floor at 1e-16 of what the
|
|
267
|
+
// species could carry, above what solves resolve (~1e-19 of it in a GaAs junction).
|
|
268
|
+
const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
|
|
269
|
+
const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross, floorI);
|
|
270
|
+
if (gross > 0 || Math.max(Math.abs(sol.current), Math.abs(other.current)) > floorI) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
|
|
260
271
|
// Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
|
|
261
272
|
const VT = model.RT / FARADAY;
|
|
262
273
|
model.terminals.forEach((t, k) => {
|
package/src/describe.js
CHANGED
|
@@ -49,6 +49,10 @@ export function unitWarnings(def) {
|
|
|
49
49
|
if (c > 1e6) warn(path, `${num(c)} mol/m³ is over 1000 M; concentrations are mol/m³ (units.molar, and units.perCm3 for carriers)`);
|
|
50
50
|
};
|
|
51
51
|
(def.species ?? []).forEach((sp, i) => sp?.cRef !== undefined && conc(`species[${i}].cRef`, sp.cRef));
|
|
52
|
+
// SRH's n₁ is a concentration too (a value per cm³ typed in is 6e5 times too large)
|
|
53
|
+
(def.bulkReactions ?? []).forEach((rx, k) => {
|
|
54
|
+
for (const [m, law] of Object.entries(isObject(rx?.srh) ? rx.srh : {})) if (isObject(law) && law.n1 !== undefined) conc(`bulkReactions[${k}].srh.${m}.n1`, law.n1);
|
|
55
|
+
});
|
|
52
56
|
for (const [mname, mat] of Object.entries(def.materials ?? {})) {
|
|
53
57
|
if (!isObject(mat)) continue;
|
|
54
58
|
const path = `materials.${mname}`;
|
|
@@ -92,13 +96,19 @@ export function unitWarnings(def) {
|
|
|
92
96
|
};
|
|
93
97
|
const kinetics = (path, reactions) => {
|
|
94
98
|
(reactions ?? []).forEach((rx, k) => {
|
|
95
|
-
|
|
99
|
+
// (an exchange current only where electrons take part: an ion transfer's k0 can be large)
|
|
100
|
+
const said = rx?.equation ?? (rx?.left || rx?.right ? JSON.stringify([rx.left, rx.right]) : null);
|
|
101
|
+
const electrons = said === null || /(^|[^\w])e-($|[^\w])/.test(said);
|
|
102
|
+
if (rx?.k0 > 10 && electrons) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
|
|
96
103
|
});
|
|
97
104
|
};
|
|
98
105
|
(def.interfaces ?? []).forEach((f, k) => {
|
|
99
106
|
if (!isObject(f)) return;
|
|
100
107
|
capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
|
|
101
|
-
|
|
108
|
+
// (an exchange current is an electrode's: a metal on one side, or sides not yet known; a
|
|
109
|
+
// splitting exciton's k0, between two semiconductors, is a velocity times cRef)
|
|
110
|
+
const sides = [def.regions?.[k], def.regions?.[k + 1]].map((r) => (isObject(r) ? def.materials?.[r.material] : undefined));
|
|
111
|
+
if (sides.some((m) => m === undefined || isObject(m?.conductor))) kinetics(`interfaces[${k}]`, f.reactions);
|
|
102
112
|
});
|
|
103
113
|
(def.ports ?? []).forEach((p, k) => {
|
|
104
114
|
if (!isObject(p)) return;
|
|
@@ -149,7 +159,12 @@ export function describe(def) {
|
|
|
149
159
|
return;
|
|
150
160
|
}
|
|
151
161
|
const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
|
|
152
|
-
|
|
162
|
+
// (donors and acceptors where there are electrons; an ion exchanger's charge in mol/m³)
|
|
163
|
+
const doped = species.some((sp, i) => mat.present[i] && (sp.name === 'e-' || sp.name === 'h+'));
|
|
164
|
+
if (reg.fixedCharge !== 0) {
|
|
165
|
+
const X = Math.abs(reg.fixedCharge) / FARADAY;
|
|
166
|
+
parts.push(doped ? `fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(X)}` : `fixed charge ${reg.fixedCharge > 0 ? '+' : '−'}${num(X)} mol/m³`);
|
|
167
|
+
}
|
|
153
168
|
// The screening concentration: Σ z²c from the initial composition, plus the carriers that
|
|
154
169
|
// balance the fixed charge (doping), which c0 doesn't list.
|
|
155
170
|
let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
|
|
@@ -216,8 +231,34 @@ export function describe(def) {
|
|
|
216
231
|
const cdef = def.contacts?.[side];
|
|
217
232
|
const ct = model.contacts[side];
|
|
218
233
|
const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
|
|
219
|
-
|
|
220
|
-
|
|
234
|
+
// (a bath without a reference species: its V is its φ)
|
|
235
|
+
const term = ct.she && !ct.she.reference ? ' (terminal: the bath φ)' : ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
|
|
236
|
+
// A bath read through a reference species: where the SHE level sits against its terminal,
|
|
237
|
+
// so a held electrode's V reads on the familiar scale (a Cl⁻ reference on table μ° puts it
|
|
238
|
+
// 1.37 V below, a surprise to anyone expecting 0 V to mean SHE).
|
|
239
|
+
let she = '';
|
|
240
|
+
if (ct.she?.reference) {
|
|
241
|
+
const d = ct.she.shift, at = Math.abs(d) < 5e-4 ? 'V' : `V ${d < 0 ? '−' : '+'} ${Math.abs(d).toFixed(3)} V`;
|
|
242
|
+
she = ct.she.exact ? `; the bath's SHE level is at ${at}` : `; the bath's φ is at ${at} (on the usual tables' μ°, where μ°_H⁺ = 0, that's its SHE level)`;
|
|
243
|
+
}
|
|
244
|
+
lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}${she}`);
|
|
245
|
+
// An outside phase out of equilibrium with a reaction that runs at the contact: the reaction
|
|
246
|
+
// then runs there for ever, a current at zero bias (H⁺ and OH⁻ given off Kw in a bath, say).
|
|
247
|
+
const end = regions[side === 'left' ? 0 : regions.length - 1].material;
|
|
248
|
+
model.reactions.forEach((rx, k) => {
|
|
249
|
+
if (!(rx.kf[end] > 0) || rx.kfProfile[end]) return; // (light, a profile, is meant to drive)
|
|
250
|
+
const parts = [...rx.reactants.map(({ i, nu }) => [i, -nu]), ...rx.products.map(({ i, nu }) => [i, nu])];
|
|
251
|
+
const level = (i) => {
|
|
252
|
+
const l = ct.species[i];
|
|
253
|
+
return l.type !== 'equilibrium' ? NaN : species[i].z === 0 ? l.mu : l.offset === undefined ? NaN : species[i].z * FARADAY * l.offset;
|
|
254
|
+
};
|
|
255
|
+
// A/RT from the outside's levels (the terminal voltage cancels: Σ ν z = 0)
|
|
256
|
+
const a = rx.fixedA - parts.reduce((sum, [i, nu]) => sum + nu * level(i), 0) / RT;
|
|
257
|
+
if (Math.abs(a) > 1e-3) {
|
|
258
|
+
const rdef = def.bulkReactions[k];
|
|
259
|
+
warnings.push(`contacts.${side}: the outside isn't in equilibrium with bulkReactions[${k}] (${rdef.equation ?? equation(rdef.nu)}; A = ${num(a)} RT there), so it runs at the contact for ever, a current even at zero bias. Unless that's meant (a source), give the outside a composition in equilibrium with it (H⁺ and OH⁻ at √K_w, say)`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
221
262
|
}
|
|
222
263
|
(def.ports ?? []).forEach((p, k) => {
|
|
223
264
|
const port = model.ports[k];
|
package/src/device.js
CHANGED
|
@@ -56,6 +56,49 @@ function positive(v, path) {
|
|
|
56
56
|
return v;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Where a region's nodes miss a starting profile worst: the profile at each of its points
|
|
60
|
+
// against the line between the nodes either side, as a fraction of its range over the region
|
|
61
|
+
// (the start is the profile sampled at the nodes). Empty where they miss it by under 2%.
|
|
62
|
+
function unresolved(p, grid, r) {
|
|
63
|
+
const at = (t) => {
|
|
64
|
+
const { x, values: v } = p, last = x.length - 1;
|
|
65
|
+
if (t <= x[0]) return v[0];
|
|
66
|
+
if (t >= x[last]) return v[last];
|
|
67
|
+
let k = 0;
|
|
68
|
+
while (x[k + 1] < t) k++;
|
|
69
|
+
return v[k] + ((v[k + 1] - v[k]) * (t - x[k])) / (x[k + 1] - x[k]);
|
|
70
|
+
};
|
|
71
|
+
const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], a = grid.x[g0], b = grid.x[g1];
|
|
72
|
+
let lo = Math.min(at(a), at(b)), hi = Math.max(at(a), at(b));
|
|
73
|
+
p.x.forEach((t, k) => {
|
|
74
|
+
if (t > a && t < b) (lo = Math.min(lo, p.values[k])), (hi = Math.max(hi, p.values[k]));
|
|
75
|
+
});
|
|
76
|
+
if (!(hi > lo)) return {};
|
|
77
|
+
const missAt = (t, xl, xr) => {
|
|
78
|
+
const cl = at(xl), cr = at(xr);
|
|
79
|
+
return Math.abs(at(t) - (cl + ((cr - cl) * (t - xl)) / (xr - xl))) / (hi - lo);
|
|
80
|
+
};
|
|
81
|
+
let worst = {}, g = g0;
|
|
82
|
+
p.x.forEach((t) => {
|
|
83
|
+
if (!(t > a && t < b)) return;
|
|
84
|
+
while (grid.x[g + 1] < t) g++;
|
|
85
|
+
const off = missAt(t, grid.x[g], grid.x[g + 1]);
|
|
86
|
+
if (off > 0.02 && !(off <= worst.off)) worst = { x: t, h: grid.x[g + 1] - grid.x[g], off };
|
|
87
|
+
});
|
|
88
|
+
if (!worst.off) return worst;
|
|
89
|
+
// The cells that would resolve it there: uniform ones, finer until they miss it by under 2%
|
|
90
|
+
// however they fall on it. (Cells wider than a step miss it by about as much at any size, so
|
|
91
|
+
// the miss isn't extrapolated.)
|
|
92
|
+
const near = Array.from(p.x).filter((t) => Math.abs(t - worst.x) <= 5 * worst.h);
|
|
93
|
+
const misses = (h) => Math.max(...[0, 0.25, 0.5, 0.75].flatMap((f) => near.map((t) => {
|
|
94
|
+
const xl = worst.x + h * (Math.floor((t - worst.x) / h - f) + f);
|
|
95
|
+
return missAt(t, xl, xl + h);
|
|
96
|
+
})));
|
|
97
|
+
worst.want = worst.h / 2;
|
|
98
|
+
while (misses(worst.want) > 0.02 && worst.want > worst.h * 1e-3) worst.want /= Math.SQRT2;
|
|
99
|
+
return worst;
|
|
100
|
+
}
|
|
101
|
+
|
|
59
102
|
// A profile against the device's x: { x, values }, piecewise linear between its points and
|
|
60
103
|
// constant beyond its ends (read with profileAt).
|
|
61
104
|
function profile(v, path, what, ok, okText) {
|
|
@@ -176,9 +219,12 @@ export function normalizeDevice(def) {
|
|
|
176
219
|
const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
|
|
177
220
|
species.forEach((sp, i) => {
|
|
178
221
|
const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
|
|
222
|
+
// (a port naming it as its terminal without reactions: a capacitance alone needs none)
|
|
223
|
+
const named = (def.ports ?? []).find((p) => p?.terminal === sp.name);
|
|
179
224
|
need(
|
|
180
225
|
materials.some((m) => m.present[i]) || outside(sp),
|
|
181
|
-
`species '${sp.name}' is not present in any material` +
|
|
226
|
+
`species '${sp.name}' is not present in any material` +
|
|
227
|
+
(named ? `: it is the terminal of port ${JSON.stringify(named.name ?? '')}, which can hold a carrier from outside the device only with reactions. A port with only a capacitance needs no terminal: drop it (and the species)` : reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
|
|
182
228
|
);
|
|
183
229
|
});
|
|
184
230
|
|
|
@@ -326,6 +372,18 @@ export function normalizeDevice(def) {
|
|
|
326
372
|
} catch (err) {
|
|
327
373
|
throw new DeviceError(`grid: ${err.message}`);
|
|
328
374
|
}
|
|
375
|
+
// A starting profile steeper than the cells under it starts as something else, silently: a
|
|
376
|
+
// liquid junction's potential then looks as if it grows while the junction forms.
|
|
377
|
+
regions.forEach((reg, r) => {
|
|
378
|
+
const missed = reg.c0Profile.map((p, i) => p && { i, ...unresolved(p, grid, r) }).filter((m) => m && m.off);
|
|
379
|
+
if (missed.length === 0) return;
|
|
380
|
+
const miss = missed.reduce((a, m) => (m.off > a.off ? m : a));
|
|
381
|
+
warnings.push(
|
|
382
|
+
`regions[${r}].c0 (${missed.map((m) => species[m.i].name).join(', ')}): the grid doesn't resolve the starting profile ` +
|
|
383
|
+
`(at x = ${miss.x.toExponential(2)} m, nodes ${miss.h.toExponential(1)} m apart miss it by ${Math.round(100 * miss.off)}% of its range), ` +
|
|
384
|
+
`so the start isn't the one given; cells of about ${miss.want.toExponential(0)} m there would resolve it (hmax, or a region boundary there to grade toward)`,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
329
387
|
const geometry = normalizeGeometry(def.geometry);
|
|
330
388
|
if (geometry.type !== 'planar') {
|
|
331
389
|
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)`));
|
|
@@ -1103,8 +1161,10 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
|
|
|
1103
1161
|
return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
|
|
1104
1162
|
}
|
|
1105
1163
|
|
|
1164
|
+
// A species present on both sides crosses freely; one that can't move on either (D = 0, trap
|
|
1165
|
+
// states, say) is blocked: held level, its two edge boxes would pass whatever flux that takes.
|
|
1106
1166
|
function defaultInterfaceLinks(matL, matR, species) {
|
|
1107
|
-
return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] ? 'equilibrium' : 'blocked' }));
|
|
1167
|
+
return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] && (matL.D[i] > 0 || matR.D[i] > 0) ? 'equilibrium' : 'blocked' }));
|
|
1108
1168
|
}
|
|
1109
1169
|
|
|
1110
1170
|
function transferCoefficient(v, path) {
|
|
@@ -1142,6 +1202,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1142
1202
|
const links = species.map(() => ({ type: 'blocked' }));
|
|
1143
1203
|
let phi = { type: 'neutral' };
|
|
1144
1204
|
let terminal = null;
|
|
1205
|
+
let she = null; // a bath's SHE level against V
|
|
1145
1206
|
if (cdef === undefined || cdef === null) return { drive: normalizeDrive({}, path), species: links, phi, terminal };
|
|
1146
1207
|
need(isObject(cdef), `${path} must be an object`);
|
|
1147
1208
|
fields(cdef, path, ['V', 'I', 'R', 'terminal', 'species', 'bath', 'phi', 'zeroCharge']);
|
|
@@ -1219,7 +1280,13 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1219
1280
|
need(cdef.species === undefined, `${path}: give either bath or species links, not both`);
|
|
1220
1281
|
const bath = cdef.bath;
|
|
1221
1282
|
need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
|
|
1222
|
-
fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
|
|
1283
|
+
fields(bath, `${path}.bath`, ['c', 'reference', 'offset', 'material']);
|
|
1284
|
+
// The bath is the end material at that composition (balancing any fixed charge there), unless
|
|
1285
|
+
// it names a material of its own: a solution outside an ion exchanger, a doped or host region,
|
|
1286
|
+
// whose levels it sets there through a Donnan step at the end node.
|
|
1287
|
+
const own = bath.material !== undefined;
|
|
1288
|
+
const bm = own ? materials.find((m) => m.name === bath.material) : mat;
|
|
1289
|
+
need(bm && !bm.conductor && !bm.phiFree, `${path}.bath.material: ${JSON.stringify(bath.material)} is not a material with ions in it (one of ${materials.filter((m) => !m.conductor).map((m) => m.name).join(', ')})`);
|
|
1223
1290
|
// Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
|
|
1224
1291
|
// convention), and the terminal is the first charged species in it.
|
|
1225
1292
|
const byPhi = bath.reference === undefined;
|
|
@@ -1233,24 +1300,35 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1233
1300
|
need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
|
|
1234
1301
|
need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
|
|
1235
1302
|
terminal = byPhi && terminal !== null ? terminal : r;
|
|
1236
|
-
|
|
1303
|
+
const fixed = own ? 0 : region.fixedCharge / FARADAY;
|
|
1304
|
+
let charge = fixed, scale = Math.abs(charge);
|
|
1237
1305
|
const cb = new Float64Array(species.length);
|
|
1238
1306
|
for (const [sname, v] of Object.entries(bath.c)) {
|
|
1239
1307
|
need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
1240
1308
|
const i = speciesIndex.get(sname);
|
|
1241
|
-
need(
|
|
1309
|
+
need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
|
|
1242
1310
|
cb[i] = positive(v, `${path}.bath.c.${sname}`);
|
|
1243
1311
|
charge += species[i].z * cb[i];
|
|
1244
1312
|
scale += Math.abs(species[i].z) * cb[i];
|
|
1245
1313
|
}
|
|
1246
|
-
need(
|
|
1314
|
+
need(
|
|
1315
|
+
Math.abs(charge) <= 1e-9 * scale,
|
|
1316
|
+
fixed !== 0
|
|
1317
|
+
? `${path}.bath: the bath is the end material '${mat.name}' at this composition, and with its fixed charge (${fixed} mol/m³ of charge) it is not neutral (net ${charge}). ` +
|
|
1318
|
+
`For a solution outside a charged region, give the solution's material (bath.material), whose composition is then neutral on its own`
|
|
1319
|
+
: `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`,
|
|
1320
|
+
);
|
|
1247
1321
|
// The reference species pins the bath's φ; every other species follows from composition.
|
|
1248
|
-
const zb = bathZeta(
|
|
1249
|
-
const level = (i) =>
|
|
1322
|
+
const zb = bathZeta(bm, cb, own ? 0 : region.background);
|
|
1323
|
+
const level = (i) => bm.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
|
|
1250
1324
|
const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
|
|
1251
1325
|
const beta = byPhi ? 0 : refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
|
|
1326
|
+
// Where the bath's SHE level sits against V (for describe()): φ + μ°_H⁺/F with H⁺ in the
|
|
1327
|
+
// bath, else φ on the usual tables' convention (μ°_H⁺ = 0), which then is only a reading.
|
|
1328
|
+
const h = speciesIndex.get('H+');
|
|
1329
|
+
she = { shift: beta + (h !== undefined && bm.present[h] ? bm.mu0[h] / FARADAY : 0), exact: h !== undefined && bm.present[h], reference: !byPhi };
|
|
1252
1330
|
for (let i = 0; i < species.length; i++) {
|
|
1253
|
-
if (!(cb[i] > 0)) continue;
|
|
1331
|
+
if (!(cb[i] > 0) || !mat.present[i]) continue; // (a bath of its own may hold species the end lacks)
|
|
1254
1332
|
links[i] =
|
|
1255
1333
|
species[i].z === 0
|
|
1256
1334
|
? { type: 'equilibrium', mu: level(i) }
|
|
@@ -1288,5 +1366,5 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1288
1366
|
if (terminal !== null) {
|
|
1289
1367
|
need(links[terminal].type !== 'blocked', `${path}.terminal: '${species[terminal].name}' is blocked at this contact`);
|
|
1290
1368
|
}
|
|
1291
|
-
return { drive, species: links, phi, terminal };
|
|
1369
|
+
return { drive, species: links, phi, terminal, ...(she ? { she } : {}) };
|
|
1292
1370
|
}
|
package/src/index.js
CHANGED
|
@@ -106,15 +106,27 @@ export class Device {
|
|
|
106
106
|
this.def = def;
|
|
107
107
|
this.model = model;
|
|
108
108
|
this._solver = solver;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
// The state carries over where the unknowns line up; on a new grid (the same regions, faces
|
|
110
|
+
// and terminals), interpolated onto it. (Started cold instead, a floating gate lost its
|
|
111
|
+
// charge to a refined grid.)
|
|
112
|
+
const sameUnknowns = old && old.u.length === solver.u.length && old.n === solver.n;
|
|
113
|
+
const regridded = old && !sameUnknowns && old.M === solver.M && old.n === solver.n && old.nFaces === solver.nFaces && old.terms.length === solver.terms.length && old.model.grid.regionStart.length === model.grid.regionStart.length;
|
|
114
|
+
if (sameUnknowns || regridded) {
|
|
115
|
+
if (sameUnknowns) {
|
|
116
|
+
solver.u.set(old.u);
|
|
117
|
+
solver.uLo.set(old.uLo);
|
|
118
|
+
// A node newly on an electrode's surface (a window moved, a surface added) starts at its
|
|
119
|
+
// starting coverages, not at whatever its slots held.
|
|
120
|
+
const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
|
|
121
|
+
for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
|
|
122
|
+
} else solver._warmFrom(old);
|
|
116
123
|
solver.computeConcentrations();
|
|
117
124
|
solver.time = old.time;
|
|
125
|
+
// Held sources read at the same time as before, and an adaptive transient goes on at the
|
|
126
|
+
// step size it had reached (its order restarts, as at any change of the equations).
|
|
127
|
+
solver.sourceTime = old.sourceTime;
|
|
128
|
+
solver._refreshSources();
|
|
129
|
+
solver.dtNext = old.dtNext;
|
|
118
130
|
solver.contactDEnd = old.contactDEnd;
|
|
119
131
|
if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
|
|
120
132
|
solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
|
package/src/kit.js
CHANGED
|
@@ -15,6 +15,7 @@ export { describe, unitWarnings } from './describe.js';
|
|
|
15
15
|
export { recorder } from './recorder.js';
|
|
16
16
|
export { henderson, planck } from './junction.js';
|
|
17
17
|
export { check } from './checks.js';
|
|
18
|
+
export { particles } from './particles.js';
|
|
18
19
|
export { polarization } from './polarization.js';
|
|
19
20
|
export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
|
|
20
21
|
export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
|