driftlet 0.13.0 → 0.14.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 +1 -1
- package/docs/device.md +10 -5
- package/docs/kit.md +57 -1
- package/docs/logo-dark.svg +3 -2
- package/docs/logo.svg +3 -2
- package/docs/reliability.md +1 -1
- package/llms.txt +293 -333
- package/package.json +1 -1
- package/src/checks.js +5 -1
- package/src/device.js +55 -0
- package/src/kit.js +1 -0
- package/src/particles.js +331 -0
- package/src/solution.js +26 -2
- package/src/solver.js +89 -2
- package/types/checks.d.ts +2 -1
- package/types/kit.d.ts +1 -0
- package/types/particles.d.ts +42 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +4 -0
package/package.json
CHANGED
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);
|
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) {
|
|
@@ -326,6 +369,18 @@ export function normalizeDevice(def) {
|
|
|
326
369
|
} catch (err) {
|
|
327
370
|
throw new DeviceError(`grid: ${err.message}`);
|
|
328
371
|
}
|
|
372
|
+
// A starting profile steeper than the cells under it starts as something else, silently: a
|
|
373
|
+
// liquid junction's potential then looks as if it grows while the junction forms.
|
|
374
|
+
regions.forEach((reg, r) => {
|
|
375
|
+
const missed = reg.c0Profile.map((p, i) => p && { i, ...unresolved(p, grid, r) }).filter((m) => m && m.off);
|
|
376
|
+
if (missed.length === 0) return;
|
|
377
|
+
const miss = missed.reduce((a, m) => (m.off > a.off ? m : a));
|
|
378
|
+
warnings.push(
|
|
379
|
+
`regions[${r}].c0 (${missed.map((m) => species[m.i].name).join(', ')}): the grid doesn't resolve the starting profile ` +
|
|
380
|
+
`(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), ` +
|
|
381
|
+
`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)`,
|
|
382
|
+
);
|
|
383
|
+
});
|
|
329
384
|
const geometry = normalizeGeometry(def.geometry);
|
|
330
385
|
if (geometry.type !== 'planar') {
|
|
331
386
|
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)`));
|
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';
|
package/src/particles.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Particles: a solution drawn as dots, each a fixed amount of one species, hopping between the
|
|
2
|
+
// cells of a display lattice. The hops' one-way rates are set from the solution so that, on
|
|
3
|
+
// average, the dots' density is its concentration and their net crossing rate is its flux: the
|
|
4
|
+
// dots are a stochastic sample of the drift–diffusion solution, not a separate model. Dots are
|
|
5
|
+
// made and unmade where the flux diverges (reactions, generation, ports), and they enter and
|
|
6
|
+
// leave through the contacts and any metal.
|
|
7
|
+
//
|
|
8
|
+
// Across each cell boundary, the one-way fluxes F⁺ (toward +x) and F⁻ are set by two numbers:
|
|
9
|
+
// their difference, the solution's net flux J there, and their product, F⁺F⁻ = F₀², the
|
|
10
|
+
// boundary's exchange. In equilibrium F⁺ = F⁻ = F₀, detailed balance, so the dots settle into
|
|
11
|
+
// the concentration profile whatever F₀ is; F₀ sets only how fast they jitter. Between cells it's
|
|
12
|
+
// Scharfetter–Gummel's (whose one-way fluxes over a uniform field have F⁺F⁻ fixed by the drift
|
|
13
|
+
// across the boundary): F₀ = (D/h)√(c_K c_K+1)·(d/2)/sinh(d/2), d the step in ψ = μ̄/RT − ln c,
|
|
14
|
+
// which gives a dot in a flat potential the diffusivity D. Across a face that a species crosses
|
|
15
|
+
// by permeability, the one-way fluxes are the face's own (Ussing's unidirectional fluxes), so the
|
|
16
|
+
// dots crossing each way are counted as a tracer experiment would count them.
|
|
17
|
+
//
|
|
18
|
+
// Where a species is dense, dots would crowd: a cell that would hold more than `cap` of them is
|
|
19
|
+
// drawn as a sea instead (its concentration, shaded), which holds no dots but exchanges them with
|
|
20
|
+
// its neighbours as a contact's reservoir does: a dot hopping in is absorbed, and dots come out at
|
|
21
|
+
// the one-way rate. Crossings between two seas through a face (an ion through a membrane between
|
|
22
|
+
// two baths) are events, at the same rates. So a junction shows its majority carriers as seas
|
|
23
|
+
// and its minority carriers as dots, each one injected, drifting, diffusing and recombining.
|
|
24
|
+
|
|
25
|
+
import { GAS_CONSTANT } from './constants.js';
|
|
26
|
+
|
|
27
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
// (d/2)/sinh(d/2) = √(B(d)B(−d)).
|
|
30
|
+
const shape = (d) => {
|
|
31
|
+
const a = Math.abs(d) / 2;
|
|
32
|
+
if (a < 1e-4) return 1 - (a * a) / 6;
|
|
33
|
+
if (a > 700) return 0;
|
|
34
|
+
return a / Math.sinh(a);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// What lies to one side of a cell boundary, for a species.
|
|
38
|
+
const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea, a contact's reservoir or a metal
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A swarm of dots sampling a solution's concentrations and fluxes. Amounts are per area (a planar
|
|
42
|
+
* device's). `step(dt)` moves them on in the device's time, at the rates of the solution last
|
|
43
|
+
* given; `update(sol)` gives a new one.
|
|
44
|
+
* @param {object} sol a solution (from solve(), advance(), live(), …)
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {string[]} [opts.species] which species (default: every species with a concentration)
|
|
47
|
+
* @param {number} [opts.dots] about how many dots per species (default 200), for the default weight
|
|
48
|
+
* @param {number|object} [opts.weight] amount per dot, mol/m²: one number for every species (so
|
|
49
|
+
* their dots compare), or { name: weight }; default, each species' total over `dots`
|
|
50
|
+
* @param {number} [opts.cap] the most dots a cell holds on average before it's a sea (default ∞)
|
|
51
|
+
* @param {number} [opts.cells] about how many cells across the device (default 120)
|
|
52
|
+
* @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
|
|
53
|
+
*/
|
|
54
|
+
export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
|
|
55
|
+
const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
|
|
56
|
+
const nn = sol.x.length, x = sol.x;
|
|
57
|
+
|
|
58
|
+
// The lattice: each region's nodes grouped into runs of node boxes about L/cells wide, so a
|
|
59
|
+
// cell boundary is a segment, where the solution's flux is. (A metal's nodes, with no
|
|
60
|
+
// concentration, are left out.)
|
|
61
|
+
const box = Float64Array.from(x, (_, g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? x[g] - x[g - 1] : 0) + (g < nn - 1 && sol.region[g + 1] === sol.region[g] ? x[g + 1] - x[g] : 0)) / 2);
|
|
62
|
+
const target = (x[nn - 1] - x[0]) / cells;
|
|
63
|
+
const lattice = [];
|
|
64
|
+
sol.regions.forEach((_, r) => {
|
|
65
|
+
const g0 = sol.region.indexOf(r), g1 = sol.region.lastIndexOf(r);
|
|
66
|
+
if (names.every((nm) => !(sol.c[nm][g0] >= 0))) return;
|
|
67
|
+
const start = lattice.length;
|
|
68
|
+
let a = g0, w = 0;
|
|
69
|
+
for (let g = g0; g <= g1; g++) {
|
|
70
|
+
w += box[g];
|
|
71
|
+
if (w >= target || g === g1) {
|
|
72
|
+
lattice.push({ a, b: g, region: r, h: w });
|
|
73
|
+
[a, w] = [g + 1, 0];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const last = lattice.length - 1;
|
|
77
|
+
if (last > start && lattice[last].h < target / 2) {
|
|
78
|
+
// a thin last cell joins the one before it
|
|
79
|
+
lattice[last - 1].b = lattice[last].b;
|
|
80
|
+
lattice[last - 1].h += lattice[last].h;
|
|
81
|
+
lattice.pop();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
for (const cl of lattice) {
|
|
85
|
+
cl.x0 = x[cl.a] - (cl.a > 0 && sol.region[cl.a - 1] === cl.region ? (x[cl.a] - x[cl.a - 1]) / 2 : 0);
|
|
86
|
+
cl.x1 = cl.x0 + cl.h;
|
|
87
|
+
let mid = cl.a;
|
|
88
|
+
for (let g = cl.a; g <= cl.b; g++) if (Math.abs(x[g] - (cl.x0 + cl.x1) / 2) < Math.abs(x[mid] - (cl.x0 + cl.x1) / 2)) mid = g;
|
|
89
|
+
cl.mid = mid;
|
|
90
|
+
}
|
|
91
|
+
const nc = lattice.length;
|
|
92
|
+
// where each boundary is: k between cells k − 1 and k
|
|
93
|
+
const at = Float64Array.from({ length: nc + 1 }, (_, k) => (k < nc ? lattice[k].x0 : lattice[nc - 1].x1));
|
|
94
|
+
|
|
95
|
+
const swarm = {
|
|
96
|
+
cells: lattice.map(({ x0, x1, region }) => ({ x0, x1, region })),
|
|
97
|
+
species: names,
|
|
98
|
+
weight: {}, // mol/m² per dot
|
|
99
|
+
sea: {}, // per species, per cell: 1 where it's a sea
|
|
100
|
+
dots: [], // { species, cell, x }
|
|
101
|
+
// what happened in the last step, beyond hops between cells of dots: { species, kind, x, dir }
|
|
102
|
+
// with kind 'in' (a dot from a sea, contact or metal), 'out' (into one), 'made', 'unmade',
|
|
103
|
+
// or 'cross' (from sea to sea through a face); dir ±1 for a crossing's direction
|
|
104
|
+
events: [],
|
|
105
|
+
crossed: {}, // per species, at each boundary (0 … cells): dots across it, { up, down } (toward +x, −x), counted since you last zeroed them
|
|
106
|
+
time: 0,
|
|
107
|
+
update,
|
|
108
|
+
step,
|
|
109
|
+
expected,
|
|
110
|
+
};
|
|
111
|
+
const S = {}; // per species: amounts, boundaries and rates, from the solution
|
|
112
|
+
|
|
113
|
+
function amounts(sol, nm) {
|
|
114
|
+
const c = sol.c[nm];
|
|
115
|
+
return Float64Array.from(lattice, (cl) => {
|
|
116
|
+
let n = 0;
|
|
117
|
+
for (let g = cl.a; g <= cl.b; g++) if (c[g] > 0) n += c[g] * box[g];
|
|
118
|
+
return n;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fields(sol, nm, prev) {
|
|
123
|
+
const c = sol.c[nm], mu = sol.mu[nm], D = sol.D[nm], J = sol.flux[nm], RT = GAS_CONSTANT * sol.T;
|
|
124
|
+
const N = amounts(sol, nm), w = swarm.weight[nm];
|
|
125
|
+
const on = Uint8Array.from(lattice, (cl) => (c[cl.mid] >= 0 ? 1 : 0));
|
|
126
|
+
const sea = Uint8Array.from(N, (n, K) => (on[K] && n / w > cap ? 1 : 0));
|
|
127
|
+
const psi = Float64Array.from(lattice, (cl, K) => mu[cl.mid] / RT - Math.log(N[K] / cl.h));
|
|
128
|
+
// Boundary k: what lies to each side, its one-way fluxes (mol/(m²·s)), and whether it's a face
|
|
129
|
+
// with one-way fluxes of its own (a membrane), whose crossings between seas are events.
|
|
130
|
+
const left = new Uint8Array(nc + 1), right = new Uint8Array(nc + 1), face = new Uint8Array(nc + 1);
|
|
131
|
+
const up = new Float64Array(nc + 1), down = new Float64Array(nc + 1);
|
|
132
|
+
const pair = (k, net, F0) => {
|
|
133
|
+
const r = Math.hypot(F0, net / 2);
|
|
134
|
+
up[k] = r + net / 2;
|
|
135
|
+
down[k] = r - net / 2;
|
|
136
|
+
};
|
|
137
|
+
const side = (K) => (sea[K] ? OUTSIDE : DOT);
|
|
138
|
+
for (let k = 0; k <= nc; k++) {
|
|
139
|
+
const A = k > 0 && on[k - 1] ? lattice[k - 1] : null, B = k < nc && on[k] ? lattice[k] : null;
|
|
140
|
+
if (A && B && A.b + 1 === B.a) {
|
|
141
|
+
// two cells: within a region, or across a face
|
|
142
|
+
if (A.region !== B.region) {
|
|
143
|
+
const itf = sol.interfaces[A.region];
|
|
144
|
+
if (!itf.links[nm]) continue;
|
|
145
|
+
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
146
|
+
if (itf.oneWay?.[nm]) {
|
|
147
|
+
face[k] = 1;
|
|
148
|
+
[up[k], down[k]] = itf.oneWay[nm];
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
153
|
+
const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
|
|
154
|
+
pair(k, J[A.b], (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD);
|
|
155
|
+
} else if (A || B) {
|
|
156
|
+
// out of the cells: through a contact, or a face to a metal
|
|
157
|
+
const cl = A ?? B, g = A ? A.b : B.a;
|
|
158
|
+
let net, open;
|
|
159
|
+
if (g === 0 || g === nn - 1) {
|
|
160
|
+
const ct = sol.contacts[g === 0 ? 'left' : 'right'];
|
|
161
|
+
open = ct.links[nm] !== undefined;
|
|
162
|
+
net = ct.flux[nm]; // toward +x at either end
|
|
163
|
+
} else {
|
|
164
|
+
open = sol.interfaces[A ? A.region : B.region - 1].links[nm] !== undefined;
|
|
165
|
+
net = J[A ? g : g - 1];
|
|
166
|
+
}
|
|
167
|
+
if (!open) continue;
|
|
168
|
+
[left[k], right[k]] = A ? [side(k - 1), OUTSIDE] : [OUTSIDE, side(k)];
|
|
169
|
+
pair(k, net, (2 * D[g] * Math.max(c[g], 0)) / cl.h);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Made and unmade in each cell (mol/(m²·s)): by the bulk reactions' one-way rates (so in
|
|
173
|
+
// equilibrium, generation and recombination both go on, in balance), and whatever else the
|
|
174
|
+
// fluxes don't account for, ∂N/∂t − (J_in − J_out) less the reactions' net (ports, face
|
|
175
|
+
// reactions), with ∂N/∂t from the last solution if time has passed since.
|
|
176
|
+
const made = new Float64Array(nc), unmade = new Float64Array(nc);
|
|
177
|
+
for (const rx of sol.bulkReactions ?? []) {
|
|
178
|
+
const nu = rx.nu?.[nm];
|
|
179
|
+
if (!nu) continue;
|
|
180
|
+
lattice.forEach((cl, K) => {
|
|
181
|
+
for (let g = cl.a; g <= cl.b; g++) {
|
|
182
|
+
if (!(rx.forward[g] >= 0)) continue;
|
|
183
|
+
const fwd = rx.forward[g] * box[g], bwd = (rx.forward[g] - rx.rate[g]) * box[g];
|
|
184
|
+
made[K] += nu > 0 ? nu * fwd : -nu * bwd;
|
|
185
|
+
unmade[K] += nu > 0 ? nu * bwd : -nu * fwd;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const dt = prev && sol.time > prev.time ? sol.time - prev.time : Infinity;
|
|
190
|
+
for (let K = 0; K < nc; K++) {
|
|
191
|
+
if (!on[K]) continue;
|
|
192
|
+
const rest = (Number.isFinite(dt) ? (N[K] - prev.N[K]) / dt : 0) + up[K + 1] - down[K + 1] - (up[K] - down[K]) - (made[K] - unmade[K]);
|
|
193
|
+
if (rest > 0) made[K] += rest;
|
|
194
|
+
else unmade[K] -= rest;
|
|
195
|
+
}
|
|
196
|
+
return { N, on, sea, left, right, face, up, down, made, unmade, time: sol.time };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A new solution: the dots keep their places and move on at its rates. A cell that has become a
|
|
201
|
+
* sea absorbs its dots, and one that's no longer a sea is filled. With resample (a jump between
|
|
202
|
+
* steady states, which has no path in time to follow), each cell's dots are thinned or added to
|
|
203
|
+
* by the change in what it's expected to hold, m → m′: each kept with probability m′/m if it
|
|
204
|
+
* falls, Poisson(m′ − m) more if it rises, which takes a Poisson sample of the one into a
|
|
205
|
+
* Poisson sample of the other, moving as few dots as can be.
|
|
206
|
+
*/
|
|
207
|
+
function update(next, { resample = false } = {}) {
|
|
208
|
+
sol = next;
|
|
209
|
+
for (const nm of names) {
|
|
210
|
+
const was = S[nm];
|
|
211
|
+
S[nm] = fields(sol, nm, was);
|
|
212
|
+
const F = S[nm], w = swarm.weight[nm];
|
|
213
|
+
swarm.sea[nm] = F.sea;
|
|
214
|
+
const before = (K) => (!was || was.sea[K] || !was.on[K] ? 0 : was.N[K] / w);
|
|
215
|
+
swarm.dots = swarm.dots.filter((d) => {
|
|
216
|
+
if (d.species !== nm) return true;
|
|
217
|
+
if (!F.on[d.cell] || F.sea[d.cell]) return false;
|
|
218
|
+
const m = before(d.cell);
|
|
219
|
+
return !(resample && m > 0) || random() * m < F.N[d.cell] / w;
|
|
220
|
+
});
|
|
221
|
+
F.N.forEach((n, K) => {
|
|
222
|
+
if (!F.on[K] || F.sea[K]) return;
|
|
223
|
+
const m = before(K);
|
|
224
|
+
if (m === 0 || resample) for (let j = poisson(Math.max(0, n / w - m)); j > 0; j--) swarm.dots.push(dot(nm, K));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return swarm;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The dots expected in each cell for a species, at the solution's concentrations. */
|
|
231
|
+
function expected(nm) {
|
|
232
|
+
return Float64Array.from(S[nm].N, (n) => n / swarm.weight[nm]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const dot = (nm, K) => ({ species: nm, cell: K, x: lattice[K].x0 + random() * lattice[K].h });
|
|
236
|
+
|
|
237
|
+
// A Poisson count of mean m (a normal one for large m).
|
|
238
|
+
const gauss = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
|
|
239
|
+
function poisson(m) {
|
|
240
|
+
if (m > 50) return Math.max(0, Math.round(m + Math.sqrt(m) * gauss()));
|
|
241
|
+
const e = Math.exp(-m);
|
|
242
|
+
let k = 0;
|
|
243
|
+
for (let p = random(); p > e; p *= random()) k++;
|
|
244
|
+
return k;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Move the dots on by dt (s, the device's time): each hops, and dots are made and unmade, at
|
|
249
|
+
* the solution's rates. A dot hops at most maxHops times in a step.
|
|
250
|
+
*/
|
|
251
|
+
function step(dt, { maxHops = 200 } = {}) {
|
|
252
|
+
swarm.events = [];
|
|
253
|
+
// Births first, each at a random moment of the step: made in a cell, or arriving from beyond
|
|
254
|
+
// (a sea, a contact, a metal). Each then moves for the rest of the step. And crossings
|
|
255
|
+
// between seas through a face.
|
|
256
|
+
const born = [];
|
|
257
|
+
for (const nm of names) {
|
|
258
|
+
const F = S[nm], w = swarm.weight[nm], cr = swarm.crossed[nm];
|
|
259
|
+
for (let K = 0; K < nc; K++) {
|
|
260
|
+
if (!F.on[K] || F.sea[K]) continue;
|
|
261
|
+
const made = F.made[K] / w;
|
|
262
|
+
const fromLeft = F.left[K] === OUTSIDE ? F.up[K] / w : 0, fromRight = F.right[K + 1] === OUTSIDE ? F.down[K + 1] / w : 0;
|
|
263
|
+
const rate = made + fromLeft + fromRight;
|
|
264
|
+
if (!(rate > 0)) continue;
|
|
265
|
+
for (let j = poisson(rate * dt); j > 0; j--) {
|
|
266
|
+
const u = random() * rate, d = dot(nm, K);
|
|
267
|
+
if (u < fromLeft) {
|
|
268
|
+
cr.up[K]++;
|
|
269
|
+
swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
|
|
270
|
+
} else if (u < fromLeft + fromRight) {
|
|
271
|
+
cr.down[K + 1]++;
|
|
272
|
+
swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
|
|
273
|
+
} else swarm.events.push({ species: nm, kind: 'made', x: d.x });
|
|
274
|
+
born.push([d, random() * dt]);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (let k = 0; k <= nc; k++) {
|
|
278
|
+
if (!F.face[k] || F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
|
|
279
|
+
for (const [dir, flux, count] of [[1, F.up[k], cr.up], [-1, F.down[k], cr.down]]) {
|
|
280
|
+
for (let j = poisson((flux / w) * dt); j > 0; j--) {
|
|
281
|
+
count[k]++;
|
|
282
|
+
swarm.events.push({ species: nm, kind: 'cross', x: at[k], dir });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const keep = [];
|
|
288
|
+
for (const d of swarm.dots) if (walk(d, dt, maxHops)) keep.push(d);
|
|
289
|
+
for (const [d, t] of born) if (walk(d, dt - t, maxHops)) keep.push(d);
|
|
290
|
+
swarm.dots = keep;
|
|
291
|
+
swarm.time += dt;
|
|
292
|
+
return swarm;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A dot's hops for a time dt (Gillespie's: exact for rates held fixed): whether it's still in.
|
|
296
|
+
function walk(d, dt, maxHops) {
|
|
297
|
+
const F = S[d.species], cr = swarm.crossed[d.species];
|
|
298
|
+
let t = 0;
|
|
299
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
300
|
+
const K = d.cell, N = F.N[K];
|
|
301
|
+
if (!(N > 0)) return true;
|
|
302
|
+
const r = F.right[K + 1] ? F.up[K + 1] / N : 0, l = F.left[K] ? F.down[K] / N : 0, die = F.unmade[K] / N;
|
|
303
|
+
const total = r + l + die;
|
|
304
|
+
if (!(total > 0)) return true;
|
|
305
|
+
t -= Math.log(1 - random()) / total;
|
|
306
|
+
if (t > dt) return true;
|
|
307
|
+
const u = random() * total;
|
|
308
|
+
if (u >= r + l) {
|
|
309
|
+
swarm.events.push({ species: d.species, kind: 'unmade', x: d.x });
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const k = u < r ? K + 1 : K, dir = u < r ? 1 : -1;
|
|
313
|
+
(dir > 0 ? cr.up : cr.down)[k]++;
|
|
314
|
+
if ((dir > 0 ? F.right[k] : F.left[k]) === OUTSIDE) {
|
|
315
|
+
swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
d.cell = K + dir;
|
|
319
|
+
d.x = lattice[d.cell].x0 + random() * lattice[d.cell].h;
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Weights, then the first dots, placed by the concentration.
|
|
325
|
+
for (const nm of names) {
|
|
326
|
+
const total = amounts(sol, nm).reduce((a, b) => a + b, 0);
|
|
327
|
+
swarm.weight[nm] = typeof weight === 'number' ? weight : isObject(weight) && weight[nm] > 0 ? weight[nm] : total / dots;
|
|
328
|
+
swarm.crossed[nm] = { up: new Float64Array(nc + 1), down: new Float64Array(nc + 1) };
|
|
329
|
+
}
|
|
330
|
+
return update(sol);
|
|
331
|
+
}
|
package/src/solution.js
CHANGED
|
@@ -26,6 +26,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
26
26
|
muStd: {},
|
|
27
27
|
V: {},
|
|
28
28
|
Vstd: {},
|
|
29
|
+
T: model.T,
|
|
29
30
|
time: solver.time,
|
|
30
31
|
steady: solver.atSteady,
|
|
31
32
|
converged: result.converged ?? true,
|
|
@@ -73,6 +74,17 @@ export function makeSolution(solver, result = {}) {
|
|
|
73
74
|
// Contacts: fluxes and displacement at the final state (re-evaluated from the balance rows of
|
|
74
75
|
// the boxes they're read from).
|
|
75
76
|
solver.computeConcentrations();
|
|
77
|
+
// Each species' flux toward +x across each segment (between nodes g and g + 1; a face's across
|
|
78
|
+
// its pair of nodes), and its diffusivity at each node (NaN where it's absent or in a metal).
|
|
79
|
+
sol.flux = {};
|
|
80
|
+
sol.D = {};
|
|
81
|
+
solver.segmentFluxes().forEach((J, i) => (sol.flux[species[i].name] = J));
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
sol.D[species[i].name] = Float64Array.from(grid.nodeRegion, (r, g) => {
|
|
84
|
+
const mat = model.materials[model.regions[r].material];
|
|
85
|
+
return mat.conductor || !solver.present[g * n + i] ? NaN : mat.D[i];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
76
88
|
solver._assembleBookkeeping(solver.lastDt);
|
|
77
89
|
sol.contacts = {};
|
|
78
90
|
sol.gates = {};
|
|
@@ -90,6 +102,8 @@ export function makeSolution(solver, result = {}) {
|
|
|
90
102
|
// Current toward +x through this contact; in steady state both contacts agree.
|
|
91
103
|
const V = solver.termV[side === 'left' ? 0 : 1];
|
|
92
104
|
sol.contacts[side] = { V, flux, D, current: conduction + displacement };
|
|
105
|
+
// How each species crosses it (a link type), for those that do.
|
|
106
|
+
sol.contacts[side].links = Object.fromEntries(ct.species.flatMap((l, i) => (l.type === 'blocked' ? [] : [[species[i].name, l.type]])));
|
|
93
107
|
if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
|
|
94
108
|
// Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
|
|
95
109
|
// at the right.
|
|
@@ -154,6 +168,11 @@ export function makeSolution(solver, result = {}) {
|
|
|
154
168
|
for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
|
|
155
169
|
const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
|
|
156
170
|
const out = { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
|
|
171
|
+
// How each species crosses it (a link type), for those that do; and for those crossing by
|
|
172
|
+
// permeability, the one-way fluxes [toward +x, toward −x], per area.
|
|
173
|
+
out.links = Object.fromEntries(itf.links.flatMap((l, i) => (l.type === 'blocked' ? [] : [[species[i].name, l.type]])));
|
|
174
|
+
const oneWay = solver.faceOneWay(f);
|
|
175
|
+
if (oneWay.some((w) => w)) out.oneWay = Object.fromEntries(oneWay.flatMap((w, i) => (w ? [[species[i].name, w]] : [])));
|
|
157
176
|
if (itf.gates.length > 0) {
|
|
158
177
|
// Each gate's fraction open, and the voltage across the face that drives them.
|
|
159
178
|
out.gates = Object.fromEntries(itf.gates.map((gate, q) => [gate.name, u[solver._gateSlot(f, q)]]));
|
|
@@ -166,17 +185,22 @@ export function makeSolution(solver, result = {}) {
|
|
|
166
185
|
// and integrated over each region and the device (mol/(m²·s)), by the nodes' control volumes,
|
|
167
186
|
// just as the balances count it.
|
|
168
187
|
sol.bulkReactions = solver.rxs.map((rx) => {
|
|
169
|
-
const rate = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
|
|
188
|
+
const rate = new Float64Array(nNodes).fill(NaN), forward = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
|
|
170
189
|
let total = 0;
|
|
171
190
|
for (let g = 0; g < nNodes; g++) {
|
|
172
191
|
const m = solver.nodeMaterial[g];
|
|
173
192
|
if (!(rx.kf[m] > 0)) continue;
|
|
174
193
|
rate[g] = solver.bulkRate(rx, g);
|
|
194
|
+
forward[g] = solver.bulkForward(rx, g);
|
|
175
195
|
const amount = grid.vol[g] * rate[g];
|
|
176
196
|
regions[grid.nodeRegion[g]] += amount;
|
|
177
197
|
total += amount;
|
|
178
198
|
}
|
|
179
|
-
|
|
199
|
+
// Its two one-way rates are forward and forward − rate; what each makes of each species, per
|
|
200
|
+
// forward reaction: ν, negative for what it consumes.
|
|
201
|
+
const nu = {};
|
|
202
|
+
rx.sp.forEach((i, p) => (nu[species[i].name] = (nu[species[i].name] ?? 0) - rx.nu[p]));
|
|
203
|
+
return { rate, forward, nu, regions, total };
|
|
180
204
|
});
|
|
181
205
|
|
|
182
206
|
// Total charge in the device (space charge plus sheet charges), per area if it's planar.
|
package/src/solver.js
CHANGED
|
@@ -2347,6 +2347,21 @@ export class Solver {
|
|
|
2347
2347
|
return P * -Math.expm1(-(aHi + aLo));
|
|
2348
2348
|
}
|
|
2349
2349
|
|
|
2350
|
+
// A bulk reaction's forward one-way rate at node g (mol/(m³·s)); its backward one is that
|
|
2351
|
+
// times e^{−a}, so their difference is bulkRate's.
|
|
2352
|
+
bulkForward(rx, g) {
|
|
2353
|
+
const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
|
|
2354
|
+
const law = rx.srh?.[this.nodeMaterial[g]];
|
|
2355
|
+
if (law) {
|
|
2356
|
+
let a = rx.fixedA;
|
|
2357
|
+
for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
|
|
2358
|
+
return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).forward;
|
|
2359
|
+
}
|
|
2360
|
+
let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
|
|
2361
|
+
for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
|
|
2362
|
+
return P;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2350
2365
|
// The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
|
|
2351
2366
|
// rate is read against (in equilibrium the net rate is their round-off).
|
|
2352
2367
|
bulkOneWay(rx, g) {
|
|
@@ -2376,7 +2391,7 @@ export class Solver {
|
|
|
2376
2391
|
const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
|
|
2377
2392
|
const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
|
|
2378
2393
|
const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
|
|
2379
|
-
return { rate, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
|
|
2394
|
+
return { rate, forward: np / den, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
|
|
2380
2395
|
}
|
|
2381
2396
|
|
|
2382
2397
|
// A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
|
|
@@ -3152,7 +3167,13 @@ export class Solver {
|
|
|
3152
3167
|
const alpha = own > maxStep ? maxStep / own : 1;
|
|
3153
3168
|
this._addToState(delta, -alpha);
|
|
3154
3169
|
this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
|
|
3155
|
-
|
|
3170
|
+
// A time step's Newton contracts fast from its predictor (1e-2, 1e-9, 1e-15 thermal
|
|
3171
|
+
// units). The next update would be about θ = step/(the last one) times this one, so where
|
|
3172
|
+
// θ/(1 − θ) of it (Hairer and Wanner's estimate) is under tol, this update converged it:
|
|
3173
|
+
// the iteration that would only show that is skipped, a third of a typical step's work.
|
|
3174
|
+
const last = history[history.length - 2], theta = step / last;
|
|
3175
|
+
const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
|
|
3176
|
+
if (alpha === 1 && (step < tol || contracted)) {
|
|
3156
3177
|
const done = { converged: true, iterations: it, history, residual: rmax };
|
|
3157
3178
|
if (islandsOff(it, done)) continue;
|
|
3158
3179
|
this.computeConcentrations();
|
|
@@ -4120,6 +4141,72 @@ export class Solver {
|
|
|
4120
4141
|
return cuts;
|
|
4121
4142
|
}
|
|
4122
4143
|
|
|
4144
|
+
// Each species' flux toward +x across every segment, at the current state (concentrations
|
|
4145
|
+
// computed): totals through the cross-section, mol/s (mol/(m²·s) for a planar device). A face's
|
|
4146
|
+
// segment, between its pair of nodes, carries the face's flux; a conductor's, its carrier's.
|
|
4147
|
+
// These are the fluxes the balances add up (displacement aside), from the same formulas.
|
|
4148
|
+
segmentFluxes() {
|
|
4149
|
+
const { model, n, M, u, uLo, c, z, ex } = this;
|
|
4150
|
+
const { grid, materials, regions, interfaces } = model;
|
|
4151
|
+
const out = Array.from({ length: n }, () => new Float64Array(grid.nNodes - 1));
|
|
4152
|
+
for (let r = 0; r < regions.length; r++) {
|
|
4153
|
+
const reg = regions[r], mat = materials[reg.material], vel = reg.velocity;
|
|
4154
|
+
for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
|
|
4155
|
+
const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
|
|
4156
|
+
if (mat.conductor) {
|
|
4157
|
+
out[mat.conductor.i][s] = u[bL * M];
|
|
4158
|
+
continue;
|
|
4159
|
+
}
|
|
4160
|
+
for (let i = 0; i < n; i++) {
|
|
4161
|
+
if (!mat.present[i] || mat.D[i] === 0) continue;
|
|
4162
|
+
const pe = (vel * h) / mat.D[i];
|
|
4163
|
+
const d = z[i] * (u[bR * M] - u[bL * M]) + ex[(s + 1) * n + i] - ex[s * n + i] - pe;
|
|
4164
|
+
const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]) - pe;
|
|
4165
|
+
out[i][s] = -(mat.D[i] / h) * bernoulli(d) * c[s * n + i] * Math.expm1(deta);
|
|
4166
|
+
}
|
|
4167
|
+
if (reg.mixing > 0) {
|
|
4168
|
+
// Eddy mixing (see _segmentMixing): −(D_mix/h) c̄_i (Δη_i − z_i q), c̄ the logarithmic mean.
|
|
4169
|
+
const on = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0);
|
|
4170
|
+
const cb = [], de = [];
|
|
4171
|
+
let S = 0, Q = 0;
|
|
4172
|
+
for (const k of on) {
|
|
4173
|
+
const a = c[s * n + k], b = c[(s + 1) * n + k];
|
|
4174
|
+
cb[k] = a === b ? a : (b - a) / Math.log(b / a);
|
|
4175
|
+
de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
|
|
4176
|
+
S += z[k] * z[k] * cb[k];
|
|
4177
|
+
Q += z[k] * cb[k] * de[k];
|
|
4178
|
+
}
|
|
4179
|
+
const q = S > 0 ? Q / S : 0;
|
|
4180
|
+
for (const i of on) out[i][s] -= (reg.mixing / h) * cb[i] * (de[i] - z[i] * q);
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
interfaces.forEach((itf, f) => {
|
|
4185
|
+
const gL = grid.regionEnd[f], b = this.blockOfFace[f];
|
|
4186
|
+
for (let i = 0; i < n; i++) if (itf.links[i].type !== 'blocked') out[i][gL] = grid.area[gL] * u[b * M + 1 + i];
|
|
4187
|
+
});
|
|
4188
|
+
return out;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
// A face's one-way fluxes for each species crossing it by permeability, per area: toward +x
|
|
4192
|
+
// and toward −x, whose difference is its net flux (Ussing's unidirectional fluxes; GHK's two
|
|
4193
|
+
// terms between like solutions). null for a species crossing any other way.
|
|
4194
|
+
faceOneWay(f) {
|
|
4195
|
+
const { model, n, M, u, uLo, z } = this, itf = model.interfaces[f], grid = model.grid;
|
|
4196
|
+
const bL = this.blockOfFace[f] - 1, bR = bL + 2, gl = grid.regionEnd[f], gr = gl + 1;
|
|
4197
|
+
return itf.links.map((link, i) => {
|
|
4198
|
+
if (link.type !== 'permeability') return null;
|
|
4199
|
+
const kl = gl * n + i, kr = gr * n + i;
|
|
4200
|
+
const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
|
|
4201
|
+
const d = z[i] * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
|
|
4202
|
+
const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]);
|
|
4203
|
+
const gated = link.gates ?? [];
|
|
4204
|
+
const P = link.P * (gated.length ? this._open(gated, (q) => u[this._gateSlot(f, q)]) : 1);
|
|
4205
|
+
const right = P * bernoulli(d) * this.c[kl];
|
|
4206
|
+
return [right, right * Math.exp(deta)];
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4123
4210
|
// Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
|
|
4124
4211
|
_smallSignalProfiles(xr, xi) {
|
|
4125
4212
|
const { n, M, z, VT, model } = this;
|
package/types/checks.d.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* zero; under light it's J = F(G − R), and it says where every carrier went.
|
|
19
19
|
* - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
|
|
20
20
|
* what came in.
|
|
21
|
-
* - **grid** (
|
|
21
|
+
* - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
|
|
22
|
+
* answer depends on the whole run): in a steady state, the device solved again on a grid
|
|
22
23
|
* twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
|
|
23
24
|
* region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
|
|
24
25
|
* wants less). The discretisation is second order, so the change estimates this grid's error
|
package/types/kit.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export function vacuumZeroCharge(def: object, workFunction: number, inside: Vacu
|
|
|
36
36
|
export { live } from "./live.js";
|
|
37
37
|
export { recorder } from "./recorder.js";
|
|
38
38
|
export { check } from "./checks.js";
|
|
39
|
+
export { particles } from "./particles.js";
|
|
39
40
|
export { polarization } from "./polarization.js";
|
|
40
41
|
/**
|
|
41
42
|
* One material seen from vacuum: a level inside it (the anchor) and how far above that level,
|