driftlet 0.5.0 → 0.6.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 +6 -4
- package/docs/device.md +2 -0
- package/docs/kit.md +117 -0
- package/llms.txt +36 -9
- package/package.json +1 -1
- package/src/checks.js +268 -0
- package/src/describe.js +9 -5
- package/src/devices.js +135 -0
- package/src/kit.js +3 -0
- package/src/recorder.js +89 -0
- package/src/solution.js +18 -0
- package/src/solver.js +75 -1
- package/src/stack.js +1 -1
- package/src/types.js +4 -1
- package/types/checks.d.ts +59 -0
- package/types/devices.d.ts +77 -0
- package/types/kit.d.ts +3 -0
- package/types/recorder.d.ts +27 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +8 -1
- package/types/stack.d.ts +1 -0
- package/types/types.d.ts +15 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The same equations go by different names in different fields. If your problem is
|
|
|
31
31
|
| If you work on | you may call it | start from | checked against |
|
|
32
32
|
|---|---|---|---|
|
|
33
33
|
| Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
|
|
34
|
-
| Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH
|
|
34
|
+
| Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH as a rate law, in the bulk and at faces | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
|
|
35
35
|
| Electrochemistry | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html) and [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg |
|
|
36
36
|
| Batteries, intercalation | OCV, insertion hosts, chemical diffusion | [insertion demo](https://markblundeberg.github.io/driftlet/demos/insertion.html), [`statistics`](test/statistics.test.js) test | composition vs OCV, π²D/4L² relaxation |
|
|
37
37
|
| Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
|
|
@@ -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.6.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
|
|
|
@@ -128,7 +128,9 @@ Solutions are plain objects of `Float64Array`s, ready to plot (fresh arrays from
|
|
|
128
128
|
and the voltage views `V`, `Vstd`. They also carry the terminals' voltages and currents, fluxes,
|
|
129
129
|
conservation bookkeeping and warnings; see [the device reference](docs/device.md). The
|
|
130
130
|
[kit](docs/kit.md) adds reactions written as equations (`'Ag+ + e- = Ag(s)'`), redox levels,
|
|
131
|
-
level diagrams, slider demos
|
|
131
|
+
level diagrams, slider demos, a transient recorder, `describe()` (which flags likely unit slips),
|
|
132
|
+
`check()` (a solution's ledgers and a grid-convergence check),
|
|
133
|
+
and curated devices with reference results: IonMonger's perovskite cell, with its J–V scans.
|
|
132
134
|
|
|
133
135
|
## Demos
|
|
134
136
|
|
|
@@ -236,7 +238,7 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
236
238
|
| Beer–Lambert photogeneration in an n⁺p cell | J_sc = qΦ∫αe^(−αx)η(x)dx with the emitter's, depletion layer's and base's collection, α from 1e3 to 1e7 /m on one coarse grid | 5e-4 (3e-3 in the emitter) |
|
|
237
239
|
| Organic bilayer: excitons | J_sc from excitons diffusing to the donor/acceptor interface (Beer–Lambert, a blocking anode, the splitting reaction's finite velocity); V_oc from charge-transfer detailed balance | 5e-4; 1e-6 V |
|
|
238
240
|
| SRH recombination | bulk: G = R_SRH(n, p) with n = N_D + p, midgap and shallow traps, low to high injection; at a face: zero in the dark, the law between the two edge nodes, every pair collected or recombined | 1e-6; 1e-9 |
|
|
239
|
-
| Perovskite J–V hysteresis, against IonMonger | an independent code (finite elements, Octave): mobile iodide vacancies, interface SRH, scans from 1 mV/s to 1 kV/s — hysteresis index, P_max, V_oc, the whole loop | 1.5e-3; 0.05 mW/cm²; 2 mV; 0.
|
|
241
|
+
| Perovskite J–V hysteresis, against IonMonger | an independent code (finite elements, Octave): mobile iodide vacancies, interface SRH, scans from 1 mV/s to 1 kV/s — hysteresis index, P_max, V_oc, the whole loop (as current where it's gentle, as a voltage offset near V_oc, where it falls at up to 1100 mA/cm² per volt) | 1.5e-3; 0.05 mW/cm²; 2 mV; 0.03 mA/cm² and 1 mV (0.010 and 0.31 mV found) |
|
|
240
242
|
| Illuminated long pn diode | `J_sc = qG(L_n + L_p + W)` from a cold start; superposition at low injection | 5e-3; 1e-2 |
|
|
241
243
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
242
244
|
| Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
|
package/docs/device.md
CHANGED
|
@@ -564,10 +564,12 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
|
|
|
564
564
|
| `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
|
|
565
565
|
| `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
|
|
566
566
|
| `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates }`: the names of the regions the face joins, what crosses it by its links, and each face reaction's rate (mol/(m²·s)) |
|
|
567
|
+
| `bulkReactions[k]` | `{ rate, regions, total }`: each bulk reaction's forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
|
|
567
568
|
| `charge` | total charge in the device, C/m² |
|
|
568
569
|
| `conservation` | per species stretch: amount, reference, intake through contacts, drift |
|
|
569
570
|
| `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, and for a failed solve, where the system is nearly singular |
|
|
570
571
|
| `converged`, `iterations`, `steps`, `substeps`, `history`, `time` | solver bookkeeping |
|
|
572
|
+
| `steady` | whether this is a converged steady state (from `solve()`), not a transient's: what `check()` in the kit decides its checks by |
|
|
571
573
|
| `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step |
|
|
572
574
|
|
|
573
575
|
A stretch is a run of regions in which a species is present and connected. Its `drift`
|
package/docs/kit.md
CHANGED
|
@@ -169,6 +169,71 @@ const seen = run.trace.probes[0], k = seen.indexOf(Math.max(...seen));
|
|
|
169
169
|
console.log(`the pulse passes at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ≈ ${units.toPerCm3(seen[k]).toExponential(1)} cm⁻³`);
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
+
## Recording a transient
|
|
173
|
+
|
|
174
|
+
`recorder(device, { every, times, probes })` runs a transient in pieces and keeps what a demo
|
|
175
|
+
needs to show it: `trace`, the terminal current and voltage (and the probes' readings) at every
|
|
176
|
+
accepted step from the start, joined up across calls; and `frames`, whole solutions to scrub back
|
|
177
|
+
through or draw side by side. Frames are kept at the start, then at each of `times` or every
|
|
178
|
+
`every` seconds, which the stepping lands on exactly; with neither, at the end of each call.
|
|
179
|
+
Its `advance(tEnd, opts)` takes `Device.advance()`'s options, so an animation calls it once per
|
|
180
|
+
frame with a `budgetMs` (`done` says whether it got there), and a run in budgets is step for
|
|
181
|
+
step the run made in one go. `frame(t)` is the frame at or just before t.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
import { Device } from 'driftlet';
|
|
185
|
+
import { build, layer, bath, recorder } from 'driftlet/kit';
|
|
186
|
+
|
|
187
|
+
// A salt step relaxing: 100 mM KCl in from a bath, into 10 mM held behind a blocking wall.
|
|
188
|
+
const dev = new Device(
|
|
189
|
+
build({
|
|
190
|
+
species: [
|
|
191
|
+
{ name: 'K+', z: 1, cRef: 1000 },
|
|
192
|
+
{ name: 'Cl-', z: -1, cRef: 1000 },
|
|
193
|
+
],
|
|
194
|
+
materials: { water: { epsr: 0, species: { 'K+': { D: 1.96e-9, mu0: 0 }, 'Cl-': { D: 2.03e-9, mu0: 0 } } } },
|
|
195
|
+
stack: [bath({ 'K+': 100, 'Cl-': 100 }, 'Cl-'), layer('water', 100e-6, { c0: { 'K+': 10, 'Cl-': 10 } }), { species: { 'K+': 'blocked', 'Cl-': 'blocked' }, phi: 'neutral' }],
|
|
196
|
+
grid: { hmin: 2e-6, hmax: 2e-6 },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
const rec = recorder(dev, { every: 1, probes: [{ x: 100e-6, species: 'K+' }] });
|
|
200
|
+
rec.advance(10, { tol: 1e-4 });
|
|
201
|
+
console.log(`${rec.frames.length} frames, ${rec.trace.t.length} steps; at the wall after 5 s: ${rec.frame(5).c['K+'].at(-1).toFixed(2)} mol/m³`);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Curated devices
|
|
205
|
+
|
|
206
|
+
Whole devices from a published parameter set, with reference results from an independent code
|
|
207
|
+
to check a run against, so a demo starts from numbers that someone has measured or
|
|
208
|
+
benchmarked rather than invented ones. Each returns an ordinary definition (print it to see
|
|
209
|
+
every parameter, or edit it).
|
|
210
|
+
|
|
211
|
+
- **`perovskiteCell({ V, light, bulkSRH })`**: IonMonger's default planar perovskite solar
|
|
212
|
+
cell (Courtier et al., J. Comput. Electron. 18, 1435 (2019)): TiO₂ (100 nm) | MAPbI₃ (400 nm)
|
|
213
|
+
with mobile iodide vacancies over an equal immobile background | spiro-OMeTAD (200 nm), at
|
|
214
|
+
298 K, lit through the TiO₂ (1.4e21 photons/(m²·s) at α = 1.3e7 /m, times `light`). SRH at
|
|
215
|
+
both faces and, unless `bulkSRH: false`, in the perovskite. `V` drives the spiro side's contact,
|
|
216
|
+
so photocurrent is positive. `PEROVSKITE_SCANS` holds IonMonger's own J–V scans of it (steady
|
|
217
|
+
at 1.2 V under light, then 1.2 → 0 → 1.2 V, at seven rates from 1 mV/s to 1 kV/s): per scan
|
|
218
|
+
the current at 0 V, each sweep's maximum power and V_oc, and the hysteresis index, with bulk
|
|
219
|
+
SRH (`full`) and without (`noBulkSRH`). driftlet's test suite reproduces them (and the whole
|
|
220
|
+
loops, to 0.03 mA/cm² where they're gentle and 1 mV where steep). `hysteresis(trace, turn)`
|
|
221
|
+
reads the same figures from a run's trace.
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
import { Device } from 'driftlet';
|
|
225
|
+
import { perovskiteCell, PEROVSKITE_SCANS, hysteresis, recorder } from 'driftlet/kit';
|
|
226
|
+
|
|
227
|
+
const rate = 10, half = 1.2 / rate; // V/s
|
|
228
|
+
const dev = new Device(perovskiteCell({ V: 1.2 }));
|
|
229
|
+
dev.solve();
|
|
230
|
+
dev.set({ contacts: { right: { V: { t: [0, half, 2 * half], values: [1.2, 0, 1.2] } } } });
|
|
231
|
+
const rec = recorder(dev);
|
|
232
|
+
rec.advance(2 * half, { tol: 1e-4, dtMax: (2 * half) / 480 });
|
|
233
|
+
const ours = hysteresis(rec.trace, half), theirs = PEROVSKITE_SCANS.full.find((s) => s.rate === rate);
|
|
234
|
+
console.log(`P_max ${ours.rev.Pmax.toFixed(1)} W/m² going down (IonMonger ${theirs.rev.Pmax}), hysteresis index ${ours.hi.toFixed(4)} (${theirs.hi})`);
|
|
235
|
+
```
|
|
236
|
+
|
|
172
237
|
## Plotting
|
|
173
238
|
|
|
174
239
|
Solutions are plain arrays, ready for any plotting toolkit. Two helpers save the bookkeeping of
|
|
@@ -263,6 +328,58 @@ const def = build({
|
|
|
263
328
|
console.log(describe(def)); // … warnings: materials.Si.species.e-.D: 36 m²/s is beyond any real diffusivity …
|
|
264
329
|
```
|
|
265
330
|
|
|
331
|
+
## Checking a result
|
|
332
|
+
|
|
333
|
+
`check(device, sol, { refine, tol })` checks a solved device, and says what it compared, so a
|
|
334
|
+
page can show that its numbers hold up. Pass the solution `solve()` or `advance()` returned.
|
|
335
|
+
|
|
336
|
+
- **converged**: the solve converged.
|
|
337
|
+
- **warnings**: the solution's warnings (unresolved double layers, steep profiles, a weakly held
|
|
338
|
+
part) and the definition's likely unit slips. They're heuristics, so they're marked `?`, a
|
|
339
|
+
prompt to look, and don't fail the check.
|
|
340
|
+
- **balance**, in a steady state: each species' ledger, what comes in through each terminal and
|
|
341
|
+
what each reaction (bulk, or at a face) makes or uses, in mol/(m²·s) and for a charged species
|
|
342
|
+
as a current. The terms must sum to zero. Under light it's J = F(G − R), and it says where
|
|
343
|
+
every carrier went: collected, or recombined in the bulk or at which face. A species at rest
|
|
344
|
+
(in equilibrium, flows only of round-off against what it could carry) isn't listed.
|
|
345
|
+
- **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
|
|
346
|
+
what came in.
|
|
347
|
+
- **grid**, in a steady state (unless `refine: false`): the device solved again on a grid twice
|
|
348
|
+
as fine everywhere (half of each `hmin` and `hmax`, the square root of each `ratio`), starting
|
|
349
|
+
from this solution interpolated onto it. The current, each floating terminal's voltage (an open-circuit voltage) and each
|
|
350
|
+
region's charge must agree to `tol` (relative; default 1e-2, about what a plot shows; a
|
|
351
|
+
benchmark wants 1e-3 or less). The discretisation is second order, so the change estimates
|
|
352
|
+
this grid's own error (about ¾ of it). It costs one more solve, so run it once, not on every
|
|
353
|
+
slider move.
|
|
354
|
+
|
|
355
|
+
It returns `{ ok, items, text }`: `ok` when nothing failed, an item per check
|
|
356
|
+
(`{ name, ok, summary, details }`, with `ok: null` for a prompt to look), and `text`, a line
|
|
357
|
+
per check marked `ok`, `FAIL` or `?`, ready to log or put on the page.
|
|
358
|
+
|
|
359
|
+
```js
|
|
360
|
+
import { Device, units } from 'driftlet';
|
|
361
|
+
import { build, layer, ohmic, semiconductor, photogeneration, check } from 'driftlet/kit';
|
|
362
|
+
|
|
363
|
+
// An n⁺p silicon cell under blue-green light (α = 1e5 /m), at 0.5 V.
|
|
364
|
+
const W = units.um(60.5);
|
|
365
|
+
const dev = new Device(
|
|
366
|
+
build({
|
|
367
|
+
T: 300,
|
|
368
|
+
library: [semiconductor('Si')],
|
|
369
|
+
stack: [ohmic(0), layer('Si', units.um(0.5), { donors: units.perCm3(1e19) }), layer('Si', units.um(60), { acceptors: units.perCm3(1e16) }), ohmic(0.5)],
|
|
370
|
+
bulkReactions: [{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }, photogeneration({ material: 'Si', flux: 3e-3, alpha: 1e5, mu: units.eV(3), to: W })],
|
|
371
|
+
grid: { hmin: units.nm(1), hmax: units.um(1) },
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
const report = check(dev, dev.solve());
|
|
375
|
+
console.log(report.text);
|
|
376
|
+
// ok balance: 2 species' sources and sinks sum to zero …
|
|
377
|
+
// e-: photon = e- + h+: +2.99e-3 (289 A/m²); left contact: -2.23e-3 (-215 A/m²); …
|
|
378
|
+
// FAIL grid: on a grid twice as fine (172 → 339 nodes), the largest change is region 0 (Si)
|
|
379
|
+
// charge, … (0.021, over 0.01: refine the grid …): the current is fine (1e-4), but the
|
|
380
|
+
// emitter's depletion charge is 2 % off on this grid; hmin: units.nm(0.25) fixes it.
|
|
381
|
+
```
|
|
382
|
+
|
|
266
383
|
## Live demos
|
|
267
384
|
|
|
268
385
|
`live(def, opts)` wraps a device for sliders. Call `set(patch)` as often as a control moves
|
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.6.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -40,13 +40,18 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
|
40
40
|
next to a dielectric needs its φ law. Every species that doesn't reach a contact needs its
|
|
41
41
|
initial amount (`c0`). An error names the path and says what to give.
|
|
42
42
|
- **Check before trusting.** `sol.converged` must be true, and read `sol.warnings`.
|
|
43
|
-
`describe(def)` (kit) summarises a definition and flags likely unit slips.
|
|
43
|
+
`describe(def)` (kit) summarises a definition and flags likely unit slips. Once a device
|
|
44
|
+
works, run `check(dev, sol)` (kit) and show or log its `text`: each species' ledger (what
|
|
45
|
+
every terminal and reaction brings in or uses, summing to zero: J = F(G − R) under light),
|
|
46
|
+
conservation in transients, and the same device on a grid twice as fine (a FAIL means the
|
|
47
|
+
answer depends on the grid: refine it). It costs a solve, so run it once, not per frame.
|
|
44
48
|
- **Exports.** `driftlet`: `Device`, `units`, and the constants `FARADAY`, `GAS_CONSTANT`,
|
|
45
49
|
`EPS0`, `BOLTZMANN`, `ELEMENTARY_CHARGE`, `AVOGADRO` (there's no thermal-voltage constant:
|
|
46
50
|
V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
|
|
47
51
|
`level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
|
|
48
52
|
`injector`, `recombination`, `photogeneration`), the data library (`aqueous`, `semiconductor`,
|
|
49
|
-
`metal`, `IONS`, …)
|
|
53
|
+
`metal`, `IONS`, …), `check` (a solution's checks), `recorder` (a transient's trace and frames across calls), the curated
|
|
54
|
+
`perovskiteCell` with IonMonger's `PEROVSKITE_SCANS` and `hysteresis`, and the vacuum-level helpers. `driftlet/plot` (a quick look; `traces()` is the data for your own charts): `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`), `themeColor`.
|
|
50
55
|
- **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
|
|
51
56
|
`new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
|
|
52
57
|
- **Rate constants carry units.** A bulk reaction's rate is `kf · Π c_reactants` in mol/(m³·s),
|
|
@@ -217,7 +222,8 @@ const def = build({
|
|
|
217
222
|
const dev = new Device(def);
|
|
218
223
|
const rest = dev.solve(); // the steady state at t = 0 (solve() doesn't advance time)
|
|
219
224
|
// In a page, call advance(tNext, { budgetMs: 12, probes }) once per animation frame, carrying on
|
|
220
|
-
// next frame while sol.done is false
|
|
225
|
+
// next frame while sol.done is false (or let recorder() join the traces and keep frames to scrub
|
|
226
|
+
// back through). Probes read at accepted steps; dtMax keeps them close.
|
|
221
227
|
const run = dev.advance(10e-6, { dtMax: 20e-9, probes: [{ x: 1.525e-3, species: 'h+' }] });
|
|
222
228
|
const seen = run.trace.probes[0], k = seen.indexOf(Math.max(...seen));
|
|
223
229
|
const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
|
|
@@ -234,6 +240,17 @@ state.
|
|
|
234
240
|
Transients cost more where a carrier's density spans many decades (minority holes in silicon
|
|
235
241
|
ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
|
|
236
242
|
|
|
243
|
+
`recorder(dev, { every, times, probes })` (kit) runs a transient in pieces: its `advance(tEnd,
|
|
244
|
+
opts)` takes the same options, `rec.trace` is the whole run's trace from the start, and
|
|
245
|
+
`rec.frames` are whole solutions (at the start, then at `times`, every `every` s, or the end of
|
|
246
|
+
each call), with `rec.frame(t)` the one at or before t, for a time slider.
|
|
247
|
+
|
|
248
|
+
**Prefer a curated device to inventing numbers** where one fits: `perovskiteCell({ V, light,
|
|
249
|
+
bulkSRH })` is IonMonger's default perovskite solar cell (mobile iodide vacancies, SRH in the bulk
|
|
250
|
+
and at both faces), and `PEROVSKITE_SCANS` holds IonMonger's J–V scans of it at seven rates;
|
|
251
|
+
`hysteresis(rec.trace, turn)` reads the same figures from yours, so a demo can show its agreement.
|
|
252
|
+
The definition is plain data: print it, edit what your demo varies.
|
|
253
|
+
|
|
237
254
|
## Template: a live page with a slider
|
|
238
255
|
|
|
239
256
|
`live()` merges slider moves made during a solve, warm-starts each solve and keeps the last
|
|
@@ -248,9 +265,9 @@ a Web Worker.
|
|
|
248
265
|
<span id="readout"></span>
|
|
249
266
|
<div id="figure"></div>
|
|
250
267
|
<script type="module">
|
|
251
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
252
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
253
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
268
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/index.js';
|
|
269
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/kit.js';
|
|
270
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/plot.js';
|
|
254
271
|
|
|
255
272
|
const def = build({
|
|
256
273
|
T: 300,
|
|
@@ -275,6 +292,16 @@ slider.addEventListener('input', () => dev.set({ contacts: { right: { V: +slider
|
|
|
275
292
|
What `bandDiagram()` and `traces()` draw, and how to explain it to a reader (docs/visualization.md
|
|
276
293
|
has the long form):
|
|
277
294
|
|
|
295
|
+
- **Plotting in your own style.** `bandDiagram()` is a quick look. For a page of your own, draw
|
|
296
|
+
with your own charting code from `traces(sol, opts)` (in `driftlet/kit`, the same options):
|
|
297
|
+
`{ x, series: [{ id, label, kind, role, slot, y }], regions, faces, range }`, where `kind` says how
|
|
298
|
+
a line reads (`'level'` thick, `'standard'` thin, `'redox'`, `'phi'`), `role` and `slot` keep a
|
|
299
|
+
species' colour stable (`themeColor(role, slot)` in `driftlet/plot` gives ours), labels mark
|
|
300
|
+
subscripts TeX-style (`labelParts(label)` splits them), `NaN` breaks a line, and doubled
|
|
301
|
+
interface nodes share an x (draw them as vertical steps). Or read the solution directly:
|
|
302
|
+
`sol.x`, `sol.V[name]`, `sol.Vstd[name]`, `sol.c[name]`, `sol.phi`; electron energies are
|
|
303
|
+
E = −V (eV) for both carriers' levels and band edges.
|
|
304
|
+
|
|
278
305
|
- **Which picture.** For a device of electrons and holes only, draw the familiar band diagram,
|
|
279
306
|
energy up: `bandDiagram(sol, { energy: true })` (E_c, E_v, E_Fn, E_Fp in eV). The species
|
|
280
307
|
voltages below are the same lines in volts, turned over; use them where ions share the diagram,
|
package/package.json
CHANGED
package/src/checks.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Checks on a solved device, for driftlet/kit: what any result should satisfy, worked out from the
|
|
2
|
+
// definition and the solution, so a page can show that its numbers hold up without its author
|
|
3
|
+
// writing each check by hand. Each check says what it compared and whether it passed.
|
|
4
|
+
|
|
5
|
+
import { Device } from './index.js';
|
|
6
|
+
import { FARADAY } from './constants.js';
|
|
7
|
+
import { unitWarnings } from './describe.js';
|
|
8
|
+
|
|
9
|
+
const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs(v) >= 1e4 || Math.abs(v) < 1e-2 ? v.toExponential(p - 1) : String(+v.toPrecision(p)));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} CheckItem
|
|
13
|
+
* @property {string} name what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
|
|
14
|
+
* @property {boolean | null} ok whether it passed (null: look at it, a warning or a check that
|
|
15
|
+
* couldn't be made)
|
|
16
|
+
* @property {string} summary one line
|
|
17
|
+
* @property {object} [details] the numbers behind it
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check a device's present state (pass the solution solve() or advance() returned, for its
|
|
22
|
+
* convergence and warnings):
|
|
23
|
+
* - **converged**: the last solve converged.
|
|
24
|
+
* - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
|
|
25
|
+
* parts) and the definition's likely unit slips.
|
|
26
|
+
* - **balance** (a steady state): each species' ledger, what comes in through each terminal and
|
|
27
|
+
* what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
|
|
28
|
+
* species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
|
|
29
|
+
* and it says where every carrier went.
|
|
30
|
+
* - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
|
|
31
|
+
* what came in.
|
|
32
|
+
* - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
|
|
33
|
+
* twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
|
|
34
|
+
* region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
|
|
35
|
+
* wants less). The discretisation is second order, so the change estimates this grid's error
|
|
36
|
+
* (about 3/4 of it).
|
|
37
|
+
* @param {import('./index.js').Device} device
|
|
38
|
+
* @param {object} [sol] the solution to check (default: the device's present state)
|
|
39
|
+
* @param {{ refine?: boolean, tol?: number }} [opts]
|
|
40
|
+
* Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
|
|
41
|
+
* made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
|
|
42
|
+
* @returns {{ ok: boolean, items: CheckItem[], text: string }}
|
|
43
|
+
*/
|
|
44
|
+
export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
|
|
45
|
+
sol ??= device.solution();
|
|
46
|
+
const { model, solver, def } = device;
|
|
47
|
+
const steady = sol.steady ?? solver.atSteady;
|
|
48
|
+
const items = [];
|
|
49
|
+
|
|
50
|
+
items.push(
|
|
51
|
+
sol.converged
|
|
52
|
+
? { name: 'converged', ok: true, summary: `${steady ? 'steady state' : `transient at t = ${sig(sol.time)} s`}, converged` }
|
|
53
|
+
: { name: 'converged', ok: false, summary: "the solve didn't converge: these numbers aren't a solution" },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const warnings = [...sol.warnings, ...unitWarnings(def)];
|
|
57
|
+
// Heuristics, so a prompt to look (null) rather than a failure.
|
|
58
|
+
items.push({
|
|
59
|
+
name: 'warnings',
|
|
60
|
+
ok: warnings.length === 0 ? true : null,
|
|
61
|
+
summary: warnings.length === 0 ? 'none' : `${warnings.length}: ${warnings.join(' · ')}`,
|
|
62
|
+
details: { warnings },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The rest checks a solution, which an unconverged state isn't.
|
|
66
|
+
if (sol.converged) {
|
|
67
|
+
const ledger = steady ? balance(device, sol) : null;
|
|
68
|
+
items.push(ledger ?? conservation(sol));
|
|
69
|
+
if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ok = sol.converged && items.every((it) => it.ok !== false);
|
|
73
|
+
const mark = (it) => (it.ok === true ? 'ok ' : it.ok === false ? 'FAIL' : '? ');
|
|
74
|
+
const text = items.map((it) => `${mark(it)} ${it.name}: ${it.summary}${it.details?.lines?.length ? `\n${it.details.lines.map((l) => ` ${l}`).join('\n')}` : ''}`).join('\n');
|
|
75
|
+
return { ok, items, text };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Each species' steady ledger: in through the terminals, made by the reactions. Rates in
|
|
79
|
+
// mol/(m²·s), positive into the device or made.
|
|
80
|
+
function balance(device, sol) {
|
|
81
|
+
const { model, def } = device, { species } = model;
|
|
82
|
+
const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
|
|
83
|
+
const ledgers = species.map((sp) => ({ species: sp.name, z: sp.z, terms: [] }));
|
|
84
|
+
const add = (i, what, rate) => {
|
|
85
|
+
if (rate !== 0 && Number.isFinite(rate)) ledgers[i].terms.push({ what, rate });
|
|
86
|
+
};
|
|
87
|
+
species.forEach((sp, i) => {
|
|
88
|
+
add(i, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
89
|
+
add(i, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
90
|
+
sol.ports.forEach((p) => add(i, `port ${p.name}`, p.flux[sp.name]));
|
|
91
|
+
});
|
|
92
|
+
model.reactions.forEach((rx, k) => {
|
|
93
|
+
const total = sol.bulkReactions[k].total, what = label(def.bulkReactions?.[k]);
|
|
94
|
+
for (const { i, nu } of rx.reactants) add(i, what, -nu * total);
|
|
95
|
+
for (const { i, nu } of rx.products) add(i, what, nu * total);
|
|
96
|
+
});
|
|
97
|
+
model.interfaces.forEach((itf, f) => {
|
|
98
|
+
itf.reactions.forEach((rx, k) => {
|
|
99
|
+
const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
|
|
100
|
+
for (const { i, nu } of rx.part) add(i, what, nu * rate);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
|
|
105
|
+
const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
|
|
106
|
+
const natural = naturalScales(device, sol);
|
|
107
|
+
const moving = ledgers.filter((l, i) => size(l) > 1e-9 * natural[i]);
|
|
108
|
+
let worst = 0;
|
|
109
|
+
const lines = [];
|
|
110
|
+
for (const l of moving) {
|
|
111
|
+
const sum = l.terms.reduce((a, t) => a + t.rate, 0);
|
|
112
|
+
l.residual = sum / size(l);
|
|
113
|
+
worst = Math.max(worst, Math.abs(l.residual));
|
|
114
|
+
// Terms that matter, largest first; a charged species' as a current too.
|
|
115
|
+
const shown = l.terms.filter((t) => Math.abs(t.rate) > 1e-6 * size(l)).sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate));
|
|
116
|
+
const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
|
|
117
|
+
lines.push(`${l.species}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
|
|
118
|
+
}
|
|
119
|
+
const ok = worst < 1e-6;
|
|
120
|
+
const summary =
|
|
121
|
+
moving.length === 0
|
|
122
|
+
? 'nothing flows or reacts (equilibrium)'
|
|
123
|
+
: `${moving.length} species' sources and sinks sum to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}; mol/(m²·s), + in or made`;
|
|
124
|
+
// The largest current any charged species carries anywhere: the scale a net current is read against.
|
|
125
|
+
const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
|
|
126
|
+
return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
|
|
130
|
+
// carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
|
|
131
|
+
// flows are round-off of these.
|
|
132
|
+
function naturalScales(device, sol) {
|
|
133
|
+
const { model, solver } = device, { species } = model;
|
|
134
|
+
const box = (g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? sol.x[g] - sol.x[g - 1] : 0) + (g + 1 < sol.x.length && sol.region[g + 1] === sol.region[g] ? sol.x[g + 1] - sol.x[g] : 0)) / 2;
|
|
135
|
+
return species.map((sp, i) => {
|
|
136
|
+
let S = 0;
|
|
137
|
+
model.regions.forEach((reg, r) => {
|
|
138
|
+
const mat = model.materials[reg.material], L = reg.length;
|
|
139
|
+
if (mat.conductor) {
|
|
140
|
+
if (mat.conductor.i === i) S = Math.max(S, (mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!mat.present[i] || !(mat.D[i] > 0)) return;
|
|
144
|
+
let cmax = 0;
|
|
145
|
+
for (let g = 0; g < sol.x.length; g++) if (sol.region[g] === r && Number.isFinite(sol.c[sp.name][g])) cmax = Math.max(cmax, sol.c[sp.name][g]);
|
|
146
|
+
S = Math.max(S, (mat.D[i] * cmax) / L);
|
|
147
|
+
});
|
|
148
|
+
solver.rxs.forEach((rx) => {
|
|
149
|
+
const p = rx.sp.indexOf(i);
|
|
150
|
+
if (p < 0) return;
|
|
151
|
+
let gross = 0;
|
|
152
|
+
for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += box(g) * solver.bulkOneWay(rx, g);
|
|
153
|
+
S = Math.max(S, Math.abs(rx.nu[p]) * gross);
|
|
154
|
+
});
|
|
155
|
+
return S;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Closed, unreacting stretches keep their amounts in a transient.
|
|
160
|
+
function conservation(sol) {
|
|
161
|
+
const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
|
|
162
|
+
if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
|
|
163
|
+
// Relative to the amount, but a drift below 1e-20 mol/m² (1e-15 C/m²) is nothing: a floating
|
|
164
|
+
// metal's excess electrons are themselves ~0.
|
|
165
|
+
const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 ? 0 : Math.abs(st.drift))));
|
|
166
|
+
return {
|
|
167
|
+
name: 'conservation',
|
|
168
|
+
ok: worst < 1e-8,
|
|
169
|
+
summary: `${closed.length} closed stretch${closed.length > 1 ? 'es' : ''} (a species held between blocking faces) kept ${worst < 1e-8 ? `to ${sig(worst, 1)}` : `only to ${sig(worst, 2)}`} of what ${closed.length > 1 ? 'they' : 'it'} held plus what came in`,
|
|
170
|
+
details: { worst },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The same device on a grid twice as fine (half the cells' sizes, the square root of the
|
|
175
|
+
// grading ratio), solved from this solution interpolated onto it, against this solution.
|
|
176
|
+
function gridCheck(device, sol, tol, gross) {
|
|
177
|
+
const { def, model } = device;
|
|
178
|
+
const fine = {
|
|
179
|
+
...def,
|
|
180
|
+
regions: def.regions.map((reg, r) => {
|
|
181
|
+
if (model.materials[model.regions[r].material].conductor) return reg;
|
|
182
|
+
const g = { ...(def.grid ?? {}), ...(reg.grid ?? {}) };
|
|
183
|
+
if (def.grid === undefined && reg.grid === undefined) Object.assign(g, { hmin: reg.length / 1000, hmax: reg.length / 20 });
|
|
184
|
+
const out = { ratio: Math.sqrt(g.ratio ?? 1.2), minCells: 2 * (g.minCells ?? 8) };
|
|
185
|
+
if (g.hmin !== undefined) out.hmin = g.hmin / 2;
|
|
186
|
+
if (g.hmax !== undefined) out.hmax = g.hmax / 2;
|
|
187
|
+
return { ...reg, grid: out };
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
// Started from this solution, interpolated onto the finer grid; failing that, from cold.
|
|
191
|
+
let other;
|
|
192
|
+
try {
|
|
193
|
+
const dev = new Device(fine);
|
|
194
|
+
dev.solver._warmFrom(device.solver);
|
|
195
|
+
other = dev.solve();
|
|
196
|
+
if (!other.converged) {
|
|
197
|
+
const cold = new Device(fine);
|
|
198
|
+
if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
|
|
199
|
+
other = cold.solve();
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
|
|
203
|
+
}
|
|
204
|
+
if (!other.converged) return { name: 'grid', ok: null, summary: "the device on a twice-finer grid didn't solve; check resolution by hand" };
|
|
205
|
+
|
|
206
|
+
const diffs = [];
|
|
207
|
+
// The current, against the largest current any species carries (so at open circuit, or in
|
|
208
|
+
// equilibrium, round-off isn't read as a change).
|
|
209
|
+
const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
|
|
210
|
+
if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: 'A/m²' });
|
|
211
|
+
// Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
|
|
212
|
+
const VT = model.RT / FARADAY;
|
|
213
|
+
model.terminals.forEach((t, k) => {
|
|
214
|
+
if (device.solver.floating.includes(k)) {
|
|
215
|
+
const a = sol.terminals[t.name].V, b = other.terminals[t.name].V;
|
|
216
|
+
diffs.push({ what: `${t.name} voltage`, a, b, rel: Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b), VT), unit: 'V' });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// Each region's charge (C/m²), against the largest: summed over the nodes' boxes, as the
|
|
220
|
+
// solver's Gauss law counts it.
|
|
221
|
+
const charges = (s) => s.regions.map((_, r) => {
|
|
222
|
+
let q = 0;
|
|
223
|
+
for (let g = 0; g < s.x.length; g++) {
|
|
224
|
+
if (s.region[g] !== r) continue;
|
|
225
|
+
const left = g > 0 && s.region[g - 1] === r ? s.x[g] - s.x[g - 1] : 0, right = g + 1 < s.x.length && s.region[g + 1] === r ? s.x[g + 1] - s.x[g] : 0;
|
|
226
|
+
q += rho(s, g, model, r) * ((left + right) / 2);
|
|
227
|
+
}
|
|
228
|
+
return q;
|
|
229
|
+
});
|
|
230
|
+
// Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
|
|
231
|
+
// the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
|
|
232
|
+
const content = Math.max(...sol.regions.map((reg, r) => {
|
|
233
|
+
let q = 0;
|
|
234
|
+
for (let g = 0; g < sol.x.length; g++) {
|
|
235
|
+
if (sol.region[g] !== r) continue;
|
|
236
|
+
let a = Math.abs(model.regions[r].fixedCharge ?? 0);
|
|
237
|
+
for (const sp of model.species) if (Number.isFinite(sol.c[sp.name][g])) a += FARADAY * Math.abs(sp.z * sol.c[sp.name][g]);
|
|
238
|
+
q = Math.max(q, a);
|
|
239
|
+
}
|
|
240
|
+
return q * (reg.x1 - reg.x0);
|
|
241
|
+
}));
|
|
242
|
+
const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
|
|
243
|
+
if (scaleQ > 1e-6 * content) {
|
|
244
|
+
const named = (reg) => (/^region \d+$/.test(reg.name) ? `${reg.name} (${reg.material})` : reg.name);
|
|
245
|
+
qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit: 'C/m²' }));
|
|
246
|
+
}
|
|
247
|
+
const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
|
|
248
|
+
if (!worst) return { name: 'grid', ok: true, summary: 'nothing to compare (no current, charge or floating voltage)' };
|
|
249
|
+
const ok = worst.rel <= tol;
|
|
250
|
+
return {
|
|
251
|
+
name: 'grid',
|
|
252
|
+
ok,
|
|
253
|
+
summary:
|
|
254
|
+
`on a grid twice as fine (${sol.x.length} → ${other.x.length} nodes), the largest change is ${worst.what}, ${sig(worst.a, 5)} → ${sig(worst.b, 5)} ${worst.unit} ` +
|
|
255
|
+
`(${sig(worst.rel, 2)}${ok ? '' : `, over ${sig(tol, 1)}: refine the grid (hmin, hmax), most likely where profiles are steepest`})`,
|
|
256
|
+
details: { diffs, nodes: [sol.x.length, other.x.length] },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
|
|
261
|
+
function rho(s, g, model, r) {
|
|
262
|
+
let q = model.regions[r].fixedCharge ?? 0;
|
|
263
|
+
for (const sp of model.species) {
|
|
264
|
+
const c = s.c[sp.name][g];
|
|
265
|
+
if (Number.isFinite(c)) q += FARADAY * sp.z * c;
|
|
266
|
+
}
|
|
267
|
+
return q;
|
|
268
|
+
}
|
package/src/describe.js
CHANGED
|
@@ -141,13 +141,17 @@ export function describe(def) {
|
|
|
141
141
|
if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
|
|
142
142
|
// The screening concentration: Σ z²c from the initial composition, plus the carriers that
|
|
143
143
|
// balance the fixed charge (doping), which c0 doesn't list.
|
|
144
|
-
let zzc = 0;
|
|
144
|
+
let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
|
|
145
145
|
species.forEach((sp, i) => {
|
|
146
146
|
if (!mat.present[i]) return;
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
const c = Number.isFinite(reg.c0[i]) ? reg.c0[i] : reg.c0Profile[i] ? Math.max(...reg.c0Profile[i].values) : 0;
|
|
148
|
+
zzc += sp.z * sp.z * c;
|
|
149
|
+
q += sp.z * c;
|
|
149
150
|
});
|
|
150
|
-
|
|
151
|
+
// Plus the mobile charge that balances whatever c0 leaves unbalanced: a doped layer's
|
|
152
|
+
// carriers, which c0 doesn't list (and nothing, where c0 lists them, as a perovskite's
|
|
153
|
+
// vacancies over their background).
|
|
154
|
+
zzc += Math.abs(q);
|
|
151
155
|
const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
|
|
152
156
|
const Dmin = Math.min(...species.map((sp, i) => (mat.present[i] && mat.D[i] > 0 ? mat.D[i] : Infinity)));
|
|
153
157
|
if (mat.epsr > 0 && zzc > 0) {
|
|
@@ -198,7 +202,7 @@ export function describe(def) {
|
|
|
198
202
|
(def.ports ?? []).forEach((p, k) => lines.push(` port ${model.ports[k].name}: ${drive(p)}, in ${regions[model.ports[k].region].name}`));
|
|
199
203
|
if ((def.bulkReactions ?? []).length) {
|
|
200
204
|
lines.push('bulk reactions:');
|
|
201
|
-
for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)} in ${Object.keys(rx.kf ?? {}).join(', ')}`);
|
|
205
|
+
for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);
|
|
202
206
|
}
|
|
203
207
|
if (times.length) lines.push('time scales:', ...times);
|
|
204
208
|
if (warnings.length) lines.push('warnings:', ...warnings.map((w) => ` ${w}`));
|