driftlet 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/docs/device.md +15 -3
- package/llms.txt +15 -6
- package/package.json +3 -1
- package/src/describe.js +17 -2
- package/src/device.js +23 -8
- package/src/fermi.js +5 -3
- package/src/plot.js +2 -1
- package/src/pow.js +24 -0
- package/src/solver.js +44 -15
- package/src/statistics.js +4 -3
- package/src/types.js +3 -2
- package/types/pow.d.ts +4 -0
- package/types/solver.d.ts +3 -3
- package/types/types.d.ts +7 -3
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Or straight from a CDN in a page, pinned to a version:
|
|
58
|
-
`https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
58
|
+
`https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
|
|
59
59
|
TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
|
|
60
60
|
[`llms.txt`](llms.txt).
|
|
61
61
|
|
|
@@ -231,9 +231,11 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
231
231
|
| pn junction | exact built-in potential; depletion charge; short-diode J–V | 1e-12; 1%; 2e-3 |
|
|
232
232
|
| Long pn diode with recombination | Shockley J–V incl. depletion recombination | 2e-3 |
|
|
233
233
|
| Haynes–Shockley pulse | an injected hole packet's drift, spread and decay at the ambipolar μ*, D*, 1/τ*; nothing lost while it goes in | 2e-4; 1e-2; 2e-3 |
|
|
234
|
+
| A packet laid down as a `c0` profile | the same rates from a Gaussian at t = 0: μ*E t, σ₀² + 2D*t, e^(−t/τ*) | 3e-4; 1e-2; 1e-3 |
|
|
234
235
|
| Illuminated long pn diode | `J_sc = qG(L_n + L_p + W)` from a cold start; superposition at low injection | 5e-3; 1e-2 |
|
|
235
236
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
236
237
|
| Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
|
|
238
|
+
| Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
|
|
237
239
|
| Concentration polarization | `i = i_lim tanh(V/4V_T)`, incl. galvanostatic and load modes | 2e-4 |
|
|
238
240
|
| Butler–Volmer electrode | Nernst equilibrium; mixed kinetic/diffusion closed form | 5e-4 |
|
|
239
241
|
| Redox couple between inert electrodes | Nernst level of Fe³⁺/Fe²⁺; the couple's total conserved at equilibrium and under current | 1e-9; 1e-10 |
|
|
@@ -288,6 +290,8 @@ No dependencies. Tests use node's built-in runner (Node ≥ 22):
|
|
|
288
290
|
npm test # the validation suite
|
|
289
291
|
npm run bench # typical workloads against bench/baseline.json (--save to update it)
|
|
290
292
|
npm run types # TypeScript declarations from the JSDoc, into types/ (fetches TypeScript via npx)
|
|
293
|
+
npm run smoke # every demo in headless Chrome, every control to its extremes (CHROME=… to choose)
|
|
294
|
+
npm run shots # retake the demos' screenshots, each caught at a set state (tools/shots.js)
|
|
291
295
|
```
|
|
292
296
|
|
|
293
297
|
Design notes and plans: [numerics](docs/numerics.md), [conventions](docs/conventions.md),
|
package/docs/device.md
CHANGED
|
@@ -97,11 +97,23 @@ regions: [
|
|
|
97
97
|
|
|
98
98
|
- `fixedCharge`: immobile charge density (doping, ionomer), C/m³. Default 0. In an insertion
|
|
99
99
|
host it's balanced by background electronic carriers.
|
|
100
|
-
- `c0`: initial concentrations, mol/m
|
|
101
|
-
|
|
100
|
+
- `c0`: initial concentrations, mol/m³: the starting state of each species given. Species
|
|
101
|
+
connected to a contact don't need it (without it, they start from that contact's level). Any
|
|
102
|
+
other species (blocked everywhere, or only made and consumed by
|
|
102
103
|
reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
|
|
103
104
|
logarithmic in them); to have none of a species in a region, leave it out of that region's
|
|
104
|
-
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
105
|
+
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
106
|
+
An entry can instead be a profile, `{ x: [...], c: [...] }`, against the device's x (m, not
|
|
107
|
+
the region's own), piecewise linear between its points and constant beyond its ends, so a
|
|
108
|
+
transient can start from a state it didn't simulate reaching: a packet already injected, a
|
|
109
|
+
gradient laid down. Skip `solve()`, which goes straight to the steady state, and `advance()`
|
|
110
|
+
from it. Species without a `c0` start from their contact's level, and $`\phi`$ is chosen node
|
|
111
|
+
by node for local neutrality, so a mobile counter-ion follows the profile (electrons follow an
|
|
112
|
+
injected hole packet); what the cold start leaves unbalanced settles within the first steps.
|
|
113
|
+
That fill-in is right in a quasi-neutral bulk (it's what dielectric relaxation would do), not
|
|
114
|
+
where the rest state carries charge: a packet laid into a junction starts with the junction
|
|
115
|
+
itself unformed.
|
|
116
|
+
For a spectator, the profile's integral is the amount it conserves. Where nothing else sets
|
|
105
117
|
$`\phi`$ at the start, a region beside an electrode starts with the electrode's first reaction at
|
|
106
118
|
equilibrium (the electrode at its open-circuit level); otherwise $`\phi`$ carries over from the
|
|
107
119
|
left.
|
package/llms.txt
CHANGED
|
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
|
|
|
14
14
|
From a CDN, pin the version:
|
|
15
15
|
|
|
16
16
|
```js nocheck
|
|
17
|
-
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
18
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
19
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
17
|
+
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -219,6 +219,13 @@ const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
|
|
|
219
219
|
console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPerCm3(seen[k] - rest.c['h+'][0]).toExponential(1)} cm⁻³: μ_p ≈ ${(1e-3 / (E * tArrive) * 1e4).toFixed(0)} cm²/(V·s)`); // ≈ 1950 for the 1900 put in: a spreading, decaying pulse peaks a little early
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
+
To start from a state instead of simulating how it got there (a packet already injected, a
|
|
223
|
+
salt gradient laid down), give a region's `c0` a profile against the device's x:
|
|
224
|
+
`c0: { 'h+': { x: [...], c: [...] } }`, piecewise linear. A given `c0` is the starting state,
|
|
225
|
+
even for a species a contact feeds; species without one start at their contact's level, with φ
|
|
226
|
+
made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
|
|
227
|
+
state.
|
|
228
|
+
|
|
222
229
|
Transients cost more where a carrier's density spans many decades (minority holes in silicon
|
|
223
230
|
ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
|
|
224
231
|
|
|
@@ -236,9 +243,9 @@ a Web Worker.
|
|
|
236
243
|
<span id="readout"></span>
|
|
237
244
|
<div id="figure"></div>
|
|
238
245
|
<script type="module">
|
|
239
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
240
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
241
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
246
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js';
|
|
247
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/kit.js';
|
|
248
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/plot.js';
|
|
242
249
|
|
|
243
250
|
const def = build({
|
|
244
251
|
T: 300,
|
|
@@ -349,5 +356,7 @@ has the long form):
|
|
|
349
356
|
to write `2 e-`.
|
|
350
357
|
- **Changing c0 with `set()`.** A carried state keeps what it holds; `c0` is only the starting
|
|
351
358
|
composition. Make a new `Device` to start over from it.
|
|
359
|
+
- **`solve()` before advancing from a `c0` profile.** It replaces the profile with the steady
|
|
360
|
+
state. Advance from the new `Device` directly. A profile's x is the device's, in m.
|
|
352
361
|
- **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
|
|
353
362
|
Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driftlet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --test",
|
|
31
31
|
"bench": "node bench/bench.js",
|
|
32
|
+
"smoke": "node tools/smoke.js",
|
|
33
|
+
"shots": "node tools/shots.js",
|
|
32
34
|
"prepublishOnly": "npm test && npm run types",
|
|
33
35
|
"types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
|
|
34
36
|
},
|
package/src/describe.js
CHANGED
|
@@ -65,13 +65,26 @@ export function unitWarnings(def) {
|
|
|
65
65
|
if (sp.cRef !== undefined) conc(`${sp_}.cRef`, sp.cRef);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
const regionX0 = [0];
|
|
69
|
+
for (const reg of def.regions ?? []) regionX0.push(regionX0.at(-1) + (isObject(reg) && reg.length > 0 ? reg.length : 0));
|
|
68
70
|
(def.regions ?? []).forEach((reg, r) => {
|
|
69
71
|
if (!isObject(reg)) return;
|
|
70
72
|
const path = `regions[${r}]`;
|
|
71
73
|
if (reg.length > 1) warn(`${path}.length`, `${num(reg.length)} m is over a metre; lengths are m (units.um, units.nm)`);
|
|
72
74
|
if (reg.length > 0 && reg.length < 1e-10) warn(`${path}.length`, `${num(reg.length)} m is less than an atom; lengths are m`);
|
|
73
75
|
if (Math.abs(reg.fixedCharge) > 1e10) warn(`${path}.fixedCharge`, `${num(reg.fixedCharge)} C/m³ is over 6e22 cm⁻³ of charge; fixedCharge is C/m³ (units.perCm3(N) * FARADAY)`);
|
|
74
|
-
for (const [sname, c] of Object.entries(reg.c0 ?? {}))
|
|
76
|
+
for (const [sname, c] of Object.entries(reg.c0 ?? {})) {
|
|
77
|
+
if (!isObject(c)) {
|
|
78
|
+
conc(`${path}.c0.${sname}`, c);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(c.c)) conc(`${path}.c0.${sname}.c`, Math.max(...c.c));
|
|
82
|
+
// A profile is against the device's x, not the region's own.
|
|
83
|
+
const x0 = regionX0[r], x1 = x0 + reg.length;
|
|
84
|
+
if (Array.isArray(c.x) && c.x.length > 0 && (c.x.at(-1) < x0 || c.x[0] > x1)) {
|
|
85
|
+
warn(`${path}.c0.${sname}.x`, `the profile (x from ${num(c.x[0])} to ${num(c.x.at(-1))} m) misses the region (${num(x0)} to ${num(x1)} m); x is the device's, in m`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
75
88
|
});
|
|
76
89
|
const capacitance = (path, phi) => {
|
|
77
90
|
if (isObject(phi) && phi.C > 10) warn(`${path}.phi.C`, `${num(phi.C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
|
|
@@ -129,7 +142,9 @@ export function describe(def) {
|
|
|
129
142
|
// The screening concentration: Σ z²c from the initial composition, else the fixed charge's.
|
|
130
143
|
let zzc = 0;
|
|
131
144
|
species.forEach((sp, i) => {
|
|
132
|
-
if (mat.present[i]
|
|
145
|
+
if (!mat.present[i]) return;
|
|
146
|
+
if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
|
|
147
|
+
else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].c);
|
|
133
148
|
});
|
|
134
149
|
if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
|
|
135
150
|
const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
|
package/src/device.js
CHANGED
|
@@ -178,19 +178,33 @@ export function normalizeDevice(def) {
|
|
|
178
178
|
'so it must have the opposite sign to that carrier',
|
|
179
179
|
);
|
|
180
180
|
}
|
|
181
|
-
// Initial composition: the starting state, and the conserved amount of any spectator.
|
|
181
|
+
// Initial composition: the starting state, and the conserved amount of any spectator. Each
|
|
182
|
+
// entry is a number, or a profile { x, c } against the device's x (piecewise linear, constant
|
|
183
|
+
// beyond its ends), which is the starting state even for a species a contact feeds.
|
|
182
184
|
const c0 = new Float64Array(nSpecies).fill(NaN);
|
|
185
|
+
const c0Profile = new Array(nSpecies).fill(null);
|
|
183
186
|
if (reg.c0 !== undefined) {
|
|
184
187
|
need(isObject(reg.c0), `${path}.c0 must be an object mapping species names to concentrations`);
|
|
185
188
|
for (const [sname, v] of Object.entries(reg.c0)) {
|
|
186
|
-
|
|
189
|
+
const at = `${path}.c0.${sname}`;
|
|
190
|
+
need(speciesIndex.has(sname), `${at}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
187
191
|
const i = speciesIndex.get(sname);
|
|
188
|
-
need(mat.present[i], `${
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
need(mat.present[i], `${at}: '${sname}' is absent from material '${mat.name}'`);
|
|
193
|
+
const none = `; to have none of '${sname}' here, leave it out of material '${mat.name}' (a region of another material without it, if it's present elsewhere)`;
|
|
194
|
+
if (isObject(v)) {
|
|
195
|
+
fields(v, at, ['x', 'c']);
|
|
196
|
+
need(
|
|
197
|
+
Array.isArray(v.x) && Array.isArray(v.c) && v.x.length > 0 && v.x.length === v.c.length,
|
|
198
|
+
`${at} must be a profile { x, c }: two arrays of the same length, positions (m, the device's x) and concentrations (mol/m³)`,
|
|
199
|
+
);
|
|
200
|
+
v.x.forEach((xk, k) => {
|
|
201
|
+
need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${at}.x must be finite and strictly increasing, but x[${k}] is ${JSON.stringify(xk)}`);
|
|
202
|
+
});
|
|
203
|
+
v.c.forEach((ck, k) => need(isFiniteNumber(ck) && ck > 0, `${at}.c[${k}] must be a concentration > 0 (mol/m³), got ${JSON.stringify(ck)}${none}`));
|
|
204
|
+
c0Profile[i] = { x: Float64Array.from(v.x), c: Float64Array.from(v.c) };
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, c }, got ${JSON.stringify(v)}${none}`);
|
|
194
208
|
c0[i] = v;
|
|
195
209
|
}
|
|
196
210
|
}
|
|
@@ -200,6 +214,7 @@ export function normalizeDevice(def) {
|
|
|
200
214
|
length,
|
|
201
215
|
fixedCharge,
|
|
202
216
|
c0,
|
|
217
|
+
c0Profile,
|
|
203
218
|
background,
|
|
204
219
|
velocity,
|
|
205
220
|
mixing,
|
package/src/fermi.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// −2 ≤ x ≤ 50 piecewise Chebyshev fits, built on first use from an exact quadrature
|
|
7
7
|
// x > 50 Sommerfeld's asymptotic expansion (its error is ~e^{−x})
|
|
8
8
|
|
|
9
|
+
import { powi, powr } from './pow.js';
|
|
10
|
+
|
|
9
11
|
const INTERVALS = [-2, 1, 5, 12, 25, 50];
|
|
10
12
|
const NCHEB = 24;
|
|
11
13
|
const SQRT_PI = Math.sqrt(Math.PI);
|
|
@@ -64,7 +66,7 @@ function series(j, x) {
|
|
|
64
66
|
const e = Math.exp(x);
|
|
65
67
|
let term = e, s = 0;
|
|
66
68
|
for (let k = 1; k <= 40; k++) {
|
|
67
|
-
const t = term / k
|
|
69
|
+
const t = term / powr(k, j + 1);
|
|
68
70
|
s += k % 2 ? t : -t;
|
|
69
71
|
if (t < 1e-17 * s) break;
|
|
70
72
|
term *= e;
|
|
@@ -73,7 +75,7 @@ function series(j, x) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// η(2k) = (1 − 2^{1−2k}) ζ(2k), the Dirichlet eta function at even arguments.
|
|
76
|
-
const ETA = [Math.PI
|
|
78
|
+
const ETA = [powi(Math.PI, 2) / 12, (7 * powi(Math.PI, 4)) / 720, (31 * powi(Math.PI, 6)) / 30240, 0.99623300185264789922, 0.99903950759827156564, 0.99975768514385819085];
|
|
77
79
|
|
|
78
80
|
function asymptotic(j, x) {
|
|
79
81
|
// 𝓕_j(x) ~ x^{j+1}/Γ(j+2) · [1 + Σ_k 2η(2k) (j+1)(j)…(j+2−2k) x^{−2k}]
|
|
@@ -86,7 +88,7 @@ function asymptotic(j, x) {
|
|
|
86
88
|
s += 2 * ETA[k - 1] * fall * p;
|
|
87
89
|
}
|
|
88
90
|
const gamma = j === 0.5 ? (3 * SQRT_PI) / 4 : SQRT_PI / 2; // Γ(j + 2)
|
|
89
|
-
return (x
|
|
91
|
+
return (powr(x, j + 1) / gamma) * s;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function fermi(j, x) {
|
package/src/plot.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// rule on the page restyles them.
|
|
14
14
|
|
|
15
15
|
import { traces } from './traces.js';
|
|
16
|
+
import { powi } from './pow.js';
|
|
16
17
|
|
|
17
18
|
// Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
|
|
18
19
|
// dash and the legend carry identity too). A role with more members than colours cycles.
|
|
@@ -83,7 +84,7 @@ const span = (label) => labelParts(label).reduce((n, p) => n + (p.sub ? 0.75 : 1
|
|
|
83
84
|
|
|
84
85
|
function ticks(a, b, n) {
|
|
85
86
|
const step0 = (b - a) / n;
|
|
86
|
-
const mag = 10
|
|
87
|
+
const mag = powi(10, Math.floor(Math.log10(step0)));
|
|
87
88
|
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= step0) ?? 10 * mag;
|
|
88
89
|
const out = [];
|
|
89
90
|
for (let t = Math.ceil(a / step) * step; t <= b + 1e-9 * step; t += step) out.push(Math.abs(t) < 1e-9 * step ? 0 : t);
|
package/src/pow.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Powers that come out the same on every JavaScript engine. Math.pow (and **) doesn't: V8's
|
|
2
|
+
// changed between Node 22 and 24 by an ulp here and there, even for integer exponents, which was
|
|
3
|
+
// enough to flip a few accept/reject decisions in long adaptive transients. Integer exponents
|
|
4
|
+
// go by repeated squaring, half-integers add a correctly rounded square root, and anything else
|
|
5
|
+
// goes through exp and log (which agree across engine versions).
|
|
6
|
+
|
|
7
|
+
/** x^n for an integer n. */
|
|
8
|
+
export function powi(x, n) {
|
|
9
|
+
if (n < 0) return 1 / powi(x, -n);
|
|
10
|
+
let r = 1, b = x;
|
|
11
|
+
while (n > 0) {
|
|
12
|
+
if (n & 1) r *= b;
|
|
13
|
+
b *= b;
|
|
14
|
+
n >>= 1;
|
|
15
|
+
}
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** x^y for x ≥ 0 and any real y. */
|
|
20
|
+
export function powr(x, y) {
|
|
21
|
+
if (Number.isInteger(y)) return powi(x, y);
|
|
22
|
+
if (Number.isInteger(2 * y)) return powi(x, (2 * y - 1) / 2) * Math.sqrt(x);
|
|
23
|
+
return x === 0 ? (y > 0 ? 0 : Infinity) : Math.exp(y * Math.log(x));
|
|
24
|
+
}
|
package/src/solver.js
CHANGED
|
@@ -146,6 +146,21 @@ import { bernoulli, bernoulliDerivative } from './bernoulli.js';
|
|
|
146
146
|
import { EPS0, FARADAY } from './constants.js';
|
|
147
147
|
import { nextBreakpoint, sourceAt } from './device.js';
|
|
148
148
|
import { DeviceError } from './errors.js';
|
|
149
|
+
import { powi, powr } from './pow.js';
|
|
150
|
+
|
|
151
|
+
// A c0 profile's value at x: piecewise linear, constant beyond its ends.
|
|
152
|
+
function profileAt({ x, c }, at) {
|
|
153
|
+
if (at <= x[0]) return c[0];
|
|
154
|
+
const last = x.length - 1;
|
|
155
|
+
if (at >= x[last]) return c[last];
|
|
156
|
+
let lo = 0, hi = last;
|
|
157
|
+
while (hi - lo > 1) {
|
|
158
|
+
const mid = (lo + hi) >> 1;
|
|
159
|
+
if (x[mid] <= at) lo = mid;
|
|
160
|
+
else hi = mid;
|
|
161
|
+
}
|
|
162
|
+
return c[lo] + ((c[hi] - c[lo]) * (at - x[lo])) / (x[hi] - x[lo]);
|
|
163
|
+
}
|
|
149
164
|
|
|
150
165
|
export class SolverError extends Error {
|
|
151
166
|
constructor(message, details) {
|
|
@@ -739,9 +754,9 @@ export class Solver {
|
|
|
739
754
|
}
|
|
740
755
|
|
|
741
756
|
/**
|
|
742
|
-
* Cold start.
|
|
743
|
-
*
|
|
744
|
-
*
|
|
757
|
+
* Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
|
|
758
|
+
* the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
|
|
759
|
+
* profile varies), or continued across the interface dipole if nothing there responds.
|
|
745
760
|
*/
|
|
746
761
|
initFromComposition() {
|
|
747
762
|
const { model, n, M, u, z } = this;
|
|
@@ -755,10 +770,15 @@ export class Solver {
|
|
|
755
770
|
if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
|
|
756
771
|
const reg = regions[r], mat = materials[reg.material];
|
|
757
772
|
mode.fill(0);
|
|
773
|
+
const profiled = reg.c0Profile.some((p) => p !== null);
|
|
758
774
|
for (let i = 0; i < n; i++) {
|
|
759
775
|
if (!mat.present[i]) continue;
|
|
760
776
|
const st = this.stretches[this.stretchOf[r * n + i]];
|
|
761
|
-
if (
|
|
777
|
+
if (reg.c0Profile[i] || reg.c0[i] > 0) {
|
|
778
|
+
// A given c0 is the starting state, even where a contact feeds the species.
|
|
779
|
+
mode[i] = 2;
|
|
780
|
+
cFix[i] = reg.c0Profile[i] ? profileAt(reg.c0Profile[i], grid.x[grid.regionStart[r]]) : reg.c0[i];
|
|
781
|
+
} else if (!st.contactFed && mat.conductor) {
|
|
762
782
|
// A conductor away from the contacts: start uncharged, with its carrier's level in
|
|
763
783
|
// equilibrium with the first reaction on its left face that takes it, else at the
|
|
764
784
|
// running φ.
|
|
@@ -819,7 +839,8 @@ export class Solver {
|
|
|
819
839
|
return q;
|
|
820
840
|
};
|
|
821
841
|
const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
|
|
822
|
-
|
|
842
|
+
const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
|
|
843
|
+
const checkNeutral = (where) => {
|
|
823
844
|
// Strictly neutral, with every charged species' amount given: its c0 must be neutral.
|
|
824
845
|
let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
|
|
825
846
|
for (let i = 0; i < n; i++) {
|
|
@@ -829,11 +850,12 @@ export class Solver {
|
|
|
829
850
|
}
|
|
830
851
|
if (Math.abs(q) > 1e-9 * scale) {
|
|
831
852
|
throw new SolverError(
|
|
832
|
-
`regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m
|
|
853
|
+
`regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³${where} (Σ z·c0, with any fixed charge), ` +
|
|
833
854
|
'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
|
|
834
855
|
);
|
|
835
856
|
}
|
|
836
|
-
}
|
|
857
|
+
};
|
|
858
|
+
if (mustBalance && !profiled) checkNeutral('');
|
|
837
859
|
if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
|
|
838
860
|
// Nothing here fixes φ, but an electrode on the left does: start with its first reaction
|
|
839
861
|
// that takes a species from this side at equilibrium (the electrode at its open-circuit
|
|
@@ -854,8 +876,8 @@ export class Solver {
|
|
|
854
876
|
}
|
|
855
877
|
}
|
|
856
878
|
}
|
|
857
|
-
|
|
858
|
-
let lo =
|
|
879
|
+
const neutralPhi = (ph) => {
|
|
880
|
+
let lo = ph - 1, hi = ph + 1;
|
|
859
881
|
while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
|
|
860
882
|
while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
|
|
861
883
|
for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
|
|
@@ -863,10 +885,17 @@ export class Solver {
|
|
|
863
885
|
if (charge(m) > 0) lo = m;
|
|
864
886
|
else hi = m;
|
|
865
887
|
}
|
|
866
|
-
|
|
867
|
-
}
|
|
888
|
+
return 0.5 * (lo + hi);
|
|
889
|
+
};
|
|
890
|
+
if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
|
|
868
891
|
if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
|
|
869
892
|
for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
|
|
893
|
+
if (profiled) {
|
|
894
|
+
for (let i = 0; i < n; i++) if (reg.c0Profile[i]) cFix[i] = profileAt(reg.c0Profile[i], grid.x[g]);
|
|
895
|
+
if (mustBalance) checkNeutral(` at x = ${grid.x[g].toPrecision(4)} m`);
|
|
896
|
+
if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
|
|
897
|
+
if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
|
|
898
|
+
}
|
|
870
899
|
const b = this.blockOfNode[g];
|
|
871
900
|
u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
|
|
872
901
|
for (let i = 0; i < n; i++) {
|
|
@@ -1251,7 +1280,7 @@ export class Solver {
|
|
|
1251
1280
|
const i = rx.sp[p], nu = rx.nu[p];
|
|
1252
1281
|
if (nu > 0) {
|
|
1253
1282
|
const ci = c[g * n + i];
|
|
1254
|
-
P *= nu === 1 ? ci : ci
|
|
1283
|
+
P *= nu === 1 ? ci : powi(ci, nu);
|
|
1255
1284
|
this._dlnc(g, i, nu, dr);
|
|
1256
1285
|
}
|
|
1257
1286
|
aHi += nu * u[b * M + 1 + i];
|
|
@@ -1597,7 +1626,7 @@ export class Solver {
|
|
|
1597
1626
|
aLo -= p.nu * uLo[o];
|
|
1598
1627
|
if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1599
1628
|
const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
|
|
1600
|
-
pref *= (c[g * n + p.i] / this.cRef[g * n + p.i]
|
|
1629
|
+
pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
|
|
1601
1630
|
this._dlnc(g, p.i, e, p.side ? dR : dL);
|
|
1602
1631
|
}
|
|
1603
1632
|
const { g: bv, gp } = bvFactor(aHi + aLo, al);
|
|
@@ -2089,7 +2118,7 @@ export class Solver {
|
|
|
2089
2118
|
if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
|
|
2090
2119
|
let grow = 0; // longer first steps tried after a Newton failure (see below)
|
|
2091
2120
|
const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
|
|
2092
|
-
const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * (tol / err)
|
|
2121
|
+
const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
|
|
2093
2122
|
while (this.time < tEnd) {
|
|
2094
2123
|
if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
|
|
2095
2124
|
// Within round-off of the end (a target a hair past a breakpoint just landed on, say):
|
|
@@ -2224,7 +2253,7 @@ export class Solver {
|
|
|
2224
2253
|
const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
|
|
2225
2254
|
for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
|
|
2226
2255
|
const w = h / hp;
|
|
2227
|
-
const Cc = (h
|
|
2256
|
+
const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
|
|
2228
2257
|
const Cp = h * (h + hp) * (h + hp + hpp);
|
|
2229
2258
|
return { scale: Cc / (Cc + Cp), order: 2 };
|
|
2230
2259
|
}
|
package/src/statistics.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { FARADAY, EPS0, AVOGADRO } from './constants.js';
|
|
18
18
|
import { fermiHalf, fermiMinusHalf } from './fermi.js';
|
|
19
|
+
import { powi } from './pow.js';
|
|
19
20
|
import { SolverError } from './solver.js';
|
|
20
21
|
|
|
21
22
|
const MODEL_TYPES = ['ideal', 'fermi-dirac', 'lattice', 'redlich-kister', 'debye-huckel', 'insertion', 'custom'];
|
|
@@ -189,7 +190,7 @@ function fermiDiracModel(idx, cRef, order) {
|
|
|
189
190
|
const r = target[a] / cRef[a];
|
|
190
191
|
// ln 𝓕(ζ) is increasing with slope 𝓕′/𝓕 ∈ (0, 1]
|
|
191
192
|
const f = (y) => ({ v: Math.log(F(y)), d: dF(y) / F(y) });
|
|
192
|
-
const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt((0.75 * Math.sqrt(Math.PI) * r
|
|
193
|
+
const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt(powi(0.75 * Math.sqrt(Math.PI) * r, 2)) : r;
|
|
193
194
|
zeta[a] = solveIncreasing(f, Math.log(r), Math.log(r) - 1, Math.max(guess, Math.log(r)) + 10, guess);
|
|
194
195
|
}
|
|
195
196
|
},
|
|
@@ -264,7 +265,7 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
|
|
|
264
265
|
let q = 0;
|
|
265
266
|
for (let b = 0; b < k; b++) q += s[b] * c[b];
|
|
266
267
|
const kappa = Math.sqrt((FARADAY * FARADAY * q) / (eps * RT));
|
|
267
|
-
const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a)
|
|
268
|
+
const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a) * (1 + kappa * a)) : 0;
|
|
268
269
|
let s2c = 0;
|
|
269
270
|
for (let b = 0; b < k; b++) s2c += s[b] * s[b] * c[b];
|
|
270
271
|
for (let b = 0; b < k; b++) lng[b] = (-s[b] * lB * kappa) / (2 * (1 + kappa * a));
|
|
@@ -425,7 +426,7 @@ function choleskyOk(K, k) {
|
|
|
425
426
|
const L = Float64Array.from(K);
|
|
426
427
|
for (let j = 0; j < k; j++) {
|
|
427
428
|
let d = L[j * k + j];
|
|
428
|
-
for (let p = 0; p < j; p++) d -= L[j * k + p]
|
|
429
|
+
for (let p = 0; p < j; p++) d -= L[j * k + p] * L[j * k + p];
|
|
429
430
|
if (!(d > 0)) return false;
|
|
430
431
|
d = Math.sqrt(d);
|
|
431
432
|
L[j * k + j] = d;
|
package/src/types.js
CHANGED
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
* @property {number} length m
|
|
58
58
|
* @property {string} [name]
|
|
59
59
|
* @property {number} [fixedCharge] immobile charge density, C/m³
|
|
60
|
-
* @property {Record<string, number>} [c0] initial concentrations,
|
|
61
|
-
* not connected to a contact: it fixes the amount they conserve)
|
|
60
|
+
* @property {Record<string, number | { x: number[], c: number[] }>} [c0] initial concentrations,
|
|
61
|
+
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
62
|
+
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
62
63
|
* @property {number} [velocity] imposed flow toward +x, m/s, carrying every mobile species
|
|
63
64
|
* @property {number} [mixing] eddy (turbulent) mixing diffusivity, m²/s; current-free
|
|
64
65
|
* @property {GridOptions} [grid]
|
package/types/pow.d.ts
ADDED
package/types/solver.d.ts
CHANGED
|
@@ -120,9 +120,9 @@ export class Solver {
|
|
|
120
120
|
/** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
|
|
121
121
|
contactEta(side: any, i: any): number;
|
|
122
122
|
/**
|
|
123
|
-
* Cold start.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
123
|
+
* Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
|
|
124
|
+
* the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
|
|
125
|
+
* profile varies), or continued across the interface dipole if nothing there responds.
|
|
126
126
|
*/
|
|
127
127
|
initFromComposition(): void;
|
|
128
128
|
_materialAt(mat: any, background: any, eta: any, mode: any, cFix: any, ph: any, zeta: any, cc: any): void;
|
package/types/types.d.ts
CHANGED
|
@@ -130,10 +130,14 @@ export type RegionDef = {
|
|
|
130
130
|
*/
|
|
131
131
|
fixedCharge?: number;
|
|
132
132
|
/**
|
|
133
|
-
* initial concentrations,
|
|
134
|
-
* not connected to a contact: it fixes the amount they conserve)
|
|
133
|
+
* initial concentrations,
|
|
134
|
+
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
135
|
+
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
135
136
|
*/
|
|
136
|
-
c0?: Record<string, number
|
|
137
|
+
c0?: Record<string, number | {
|
|
138
|
+
x: number[];
|
|
139
|
+
c: number[];
|
|
140
|
+
}>;
|
|
137
141
|
/**
|
|
138
142
|
* imposed flow toward +x, m/s, carrying every mobile species
|
|
139
143
|
*/
|