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 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 runs through explicit trap species | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
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.5.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
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 and `describe()`, which flags likely unit slips.
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.3 mA/cm² |
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.5.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/plot.js';
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`, …) and the vacuum-level helpers. `driftlet/plot`: `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`).
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. Probes read at accepted steps; dtMax keeps them close.
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.5.0/src/index.js';
252
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/kit.js';
253
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/plot.js';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
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
- if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
148
- else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].values);
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
- zzc += Math.abs(reg.fixedCharge) / FARADAY; // the mobile charge that balances the doping
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}`));