driftlet 0.16.2 → 1.0.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.
Files changed (51) hide show
  1. package/README.md +14 -3
  2. package/docs/device.md +29 -11
  3. package/docs/kit.md +10 -1
  4. package/docs/numerics.md +68 -12
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +107 -299
  8. package/package.json +15 -3
  9. package/src/blockTridiagonal.js +73 -1
  10. package/src/channels.js +6 -4
  11. package/src/checks.js +78 -3
  12. package/src/data.js +5 -1
  13. package/src/device.js +1 -1
  14. package/src/devices.js +2 -0
  15. package/src/equation.js +2 -1
  16. package/src/errors.js +33 -0
  17. package/src/grid.js +12 -0
  18. package/src/index.js +21 -10
  19. package/src/junction.js +3 -1
  20. package/src/kit.js +1 -1
  21. package/src/live.js +2 -0
  22. package/src/particles.js +2 -0
  23. package/src/plot.js +4 -0
  24. package/src/polarization.js +2 -1
  25. package/src/recorder.js +2 -1
  26. package/src/solution.js +39 -5
  27. package/src/solver.js +248 -30
  28. package/src/sources.js +8 -1
  29. package/src/stack.js +14 -1
  30. package/src/statistics.js +1 -1
  31. package/src/traces.js +3 -0
  32. package/src/types.js +3 -1
  33. package/types/blockTridiagonal.d.ts +1 -0
  34. package/types/channels.d.ts +3 -3
  35. package/types/checks.d.ts +9 -3
  36. package/types/data.d.ts +3 -3
  37. package/types/devices.d.ts +1 -1
  38. package/types/equation.d.ts +1 -1
  39. package/types/errors.d.ts +8 -0
  40. package/types/index.d.ts +4 -7
  41. package/types/junction.d.ts +2 -2
  42. package/types/kit.d.ts +1 -1
  43. package/types/live.d.ts +1 -1
  44. package/types/particles.d.ts +1 -1
  45. package/types/plot.d.ts +3 -3
  46. package/types/polarization.d.ts +1 -1
  47. package/types/recorder.d.ts +1 -1
  48. package/types/solver.d.ts +8 -1
  49. package/types/sources.d.ts +7 -7
  50. package/types/traces.d.ts +1 -1
  51. package/types/types.d.ts +9 -1
package/llms.txt CHANGED
@@ -12,12 +12,14 @@ Every code block below runs in the test suite and prints its closed-form check.
12
12
  two sections, then go to the template nearest your device:
13
13
 
14
14
  - semiconductors: a pn junction (with an I–V sweep), a live page with a slider, a solar cell, a
15
- MOS capacitor, a Schottky diode, a thin-film transistor, a pulse drifting along a bar (under
16
- Transients);
15
+ MOS capacitor, a pulse drifting along a bar (under Transients);
17
16
  - electrochemistry: a cell, an electrode in a stirred solution (a potential step, and a cyclic
18
- voltammogram), corrosion;
19
- - physiology: a cell membrane (a resting potential), a voltage clamp, an action potential, one
20
- travelling along an axon.
17
+ voltammogram), a battery particle discharging;
18
+ - physiology: a cell membrane (a resting potential).
19
+
20
+ More in docs/templates.md: a Schottky diode, a thin-film transistor, corrosion under a drop (an
21
+ electrode spread through a film), a voltage clamp, an action potential, one travelling along an
22
+ axon.
21
23
 
22
24
  The references are in docs/: device.md (the definition), kit.md (the helpers), conventions.md
23
25
  (signs and levels), visualization.md, alignment.md (band offsets), statistics.md (non-ideal
@@ -28,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
28
30
  From a CDN, pin the version you tested against:
29
31
 
30
32
  ```js nocheck
31
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
32
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
33
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/plot.js';
34
36
  ```
35
37
 
36
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -58,11 +60,11 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
58
60
  - **What isn't defaulted**: an alignment between different materials, a φ law where a metal
59
61
  meets a dielectric, and the amount of anything a contact doesn't feed (`c0`). The error names
60
62
  the path and says what to give. (Kinetic details are defaulted: listed in docs/device.md.)
61
- - **Check before trusting.** `sol.converged` must be true; read `sol.warnings`, where a failed
62
- solve always says why. `describe(def)` (kit) summarises a definition and flags likely unit
63
- slips. Once a device works, log `check(dev, sol).text` (kit) once: each species' ledger,
64
- conservation in transients, and a steady state on a grid twice as fine (a FAIL means refine;
65
- a transient you rerun on a finer grid yourself).
63
+ - **Check before trusting, early.** `sol.converged` must be true; read `sol.warnings`, where a
64
+ failed solve always says why. Before comparing with theory, log `describe(def)` (kit: a summary
65
+ that flags unit slips) and `check(dev, sol).text` (kit): each species' ledger, conservation in
66
+ transients, a grid twice as fine (a FAIL means refine) and the species listed in reverse (a FAIL
67
+ means a solve converged somewhere that isn't steady: compare a long advance()).
66
68
 
67
69
  ## How a device is written
68
70
 
@@ -96,7 +98,9 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
96
98
  (`capacitance`, `gates`). A BJT's base contact is a port on a narrow window of the base,
97
99
  `{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
98
100
  - **Drives.** A terminal is held at a voltage `V`, driven by a current `I` (open circuit is
99
- `I: 0`), or behind a resistance `{ V, R }`. Either can be a waveform: a table `{ t, values }`
101
+ `I: 0`), or behind a resistance `{ V, R }`; in `build()`, the same object goes where the
102
+ voltage would (`ohmic({ I: 0 }, ['e-'])`, `ohmic({ V: 1, R: 1e3 }, ['e-'])`), and `set()` may
103
+ switch one kind for another (`{ I: 0 }` drops a held `V`). Either can be a waveform: a table `{ t, values }`
100
104
  (piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
101
105
  amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
102
106
  start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
@@ -112,14 +116,17 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
112
116
  { tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
113
117
  srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
114
118
  injection. Light is a reaction from a reservoir (`photon = e- + h+`, uniform), or absorbed as
115
- it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`. To switch
116
- it during a run, `set()` the device's `bulkReactions` (the state and the step size carry over).
119
+ it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`, `from` and
120
+ `to` being x in the device (a port's window is instead m from its region's left end). To switch
121
+ it during a run, `set()` the device's whole `bulkReactions` list, recombination included: in
122
+ `set()`, a list replaces and an object merges (the state and the step size carry over).
117
123
  - **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
118
124
  at cRef: i₀ = nF·k0 there, and k0 = k°·cRef for a textbook k° in m/s (1 cm/s at 1 M is
119
125
  `k0: 10`).
120
126
  - **Grids** grade geometrically from `hmin` at each region's ends up to `hmax`, neighbouring cells
121
127
  differing by at most `ratio` (1.2): put `hmin` below the shortest length that matters (a Debye
122
- length, a depletion edge). `check()` says whether it's fine enough.
128
+ length, a depletion edge). Left out, `grid` is a coarse default for a first look; `check()`
129
+ says whether a grid is fine enough.
123
130
 
124
131
  `build()` is the usual way in. It writes a plain definition for `new Device()`, which some
125
132
  templates below write by hand (where a port spans a region, or no stack fits):
@@ -202,9 +209,9 @@ a CDN needs a long virtual-time budget, 30 s.)
202
209
  <span id="readout"></span>
203
210
  <div id="figure"></div>
204
211
  <script type="module">
205
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
206
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
207
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
212
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/index.js';
213
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/kit.js';
214
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/plot.js';
208
215
 
209
216
  const def = build({
210
217
  T: 300,
@@ -228,22 +235,17 @@ To draw in your own style, `traces(sol, opts)` (kit, the same options) gives the
228
235
  see "Reading level diagrams" below.
229
236
 
230
237
  For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
231
- mol/m² of a species, hopping at rates taken from the solution: on average their density is its
232
- concentration and their net crossing its flux. Say on the page that it samples the continuum
233
- solution, and how much a dot is. Where a dot would be lost among millions (majority carriers, a
234
- bath), a cell over `cap` dots is a sea: shade it by `expected(name)`. Each frame,
235
- `swarm.step(dt)` in the device's time (about h²/D per hop, h a cell), then draw `swarm.dots`
236
- (`{ species, x }`) and `swarm.events` (`'unmade'` is a recombination, `'cross'` an ion through a
237
- membrane between two seas); on a new steady solution, `swarm.update(sol, { resample: true })`.
238
- An ion beside a cell membrane hops ~1e6 times per crossing, so a membrane page shows seas and
239
- crossing events, not walking ions. The demos' page scripts do this for a diode's carriers (pn),
240
- ions crossing a nerve membrane (axon, `cap: 0`) and Donnan exclusion (membrane).
238
+ mol/m² of a species, hopping at the solution's rates, so their density and net crossings agree
239
+ with it on average; say on the page that they sample the continuum solution. A cell over `cap`
240
+ dots is a sea, shaded by `expected(name)`. Each frame, `swarm.step(dt)`, then draw `swarm.dots`
241
+ and `swarm.events` (docs/kit.md; the pn, axon and membrane demos are worked examples).
241
242
 
242
243
  ## Template: a solar cell
243
244
 
244
245
  `photon = e- + h+`, with the photons' μ well above the gap, generates uniformly at the rate `kf`.
245
246
  Open circuit is `I: 0`, and the terminal's V is then V_oc. The photocurrent flows toward +x
246
- (holes to the p side, on the right).
247
+ (holes to the p side, on the right), so the power the cell delivers is V·`sol.current`. Light
248
+ absorbed as it goes in, for a cell's thickness to matter, is `photogeneration()` (above).
247
249
 
248
250
  ```js
249
251
  import { Device, units } from 'driftlet';
@@ -270,14 +272,10 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
270
272
  console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
271
273
  ```
272
274
 
273
- In an organic cell, light makes excitons: a neutral species `X` in the donor, made by
274
- `photogeneration({ ..., makes: 'X' })` and decaying by `{ equation: 'X = 0', kf: { donor: 1 / tau } }`.
275
- They split at the donor/acceptor face by a reaction there, `X = h+(left) + e-(right)` with e⁻ and
276
- h⁺ blocked at the face (the reaction moves them), and its reverse is charge-transfer
277
- recombination; the face's `step` sets E_CT. With `alpha: 0` the splitting is a velocity
278
- k0/cRef, a Robin condition on the exciton profile; with α > 0 it's k0/cRef · e^(α(E_X − E_CT)/RT).
279
- The organic demo's page script is a worked example (J_sc against exciton diffusion theory, V_oc
280
- against detailed balance).
275
+ In an organic cell, light makes excitons, a neutral species made by `photogeneration({ ...,
276
+ makes: 'X' })`, which split at the donor/acceptor face by a reaction, `X = h+(left) + e-(right)`
277
+ (e⁻ and h⁺ blocked there; the face's `step` sets E_CT). The organic demo's page script is a
278
+ worked example, checked against exciton diffusion and detailed balance.
281
279
 
282
280
  For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
283
281
  default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
@@ -334,73 +332,6 @@ the same C at any frequency with the port; without it, only below the minority s
334
332
  above. Draw it with
335
333
  `bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
336
334
 
337
- ## Template: a Schottky diode
338
-
339
- A barrier pinned at the metal (`phi: 'pinned'`, `zeroCharge` the barrier height for Si, whose
340
- μ°_e⁻ is 0), crossed at the emission velocity: a `velocity` link, N_in = v (c_eq − c). With
341
- `'equilibrium'` links (infinite velocity) it's diffusion theory instead, 20 times the current here.
342
-
343
- ```js
344
- import { Device, units, FARADAY, GAS_CONSTANT } from 'driftlet';
345
- import { build, layer, ohmic, semiconductor } from 'driftlet/kit';
346
-
347
- const T = 300, VT = (GAS_CONSTANT * T) / FARADAY, Si = semiconductor('Si');
348
- const phiB = 0.85, A = 1.12e6; // the barrier (V); Richardson's constant for n-Si, A/(m²K²)
349
- const v = (A * T * T) / (FARADAY * Si.materials.Si.species['e-'].cRef); // Bethe's emission velocity, 2.2e4 m/s (cRef is N_c)
350
- const schottky = (V) => ({
351
- V,
352
- terminal: 'e-',
353
- species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v, offset: 0 } },
354
- phi: 'pinned',
355
- zeroCharge: phiB, // E_c − E_F at the metal
356
- });
357
- const dev = new Device(build({ T, library: [Si], stack: [schottky(0), layer('Si', units.um(2), { donors: units.perCm3(1e16) }), ohmic(0)], grid: { hmin: units.nm(1), hmax: units.nm(20) } }));
358
- for (const V of [0.1, 0.2, 0.3]) {
359
- dev.set({ contacts: { left: { V } } }); // the metal positive: forward
360
- const J = dev.solve().current, te = A * T * T * Math.exp(-phiB / VT) * Math.expm1(V / VT);
361
- console.log(`${V} V: J ${J.toExponential(3)} A/m², thermionic emission ${te.toExponential(3)}`); // about 4% below: the depletion region in series
362
- }
363
- ```
364
-
365
- The same `velocity` link at an ohmic contact is its surface recombination velocity.
366
-
367
- ## Template: a thin-film transistor (a capacitance along a channel)
368
-
369
- A **port with a `capacitance`** holds charge σ = C (V − zeroCharge − φ) per area of electrode
370
- along its window, balanced by the region's: a gate along a channel, a porous electrode's double
371
- layer. Here x runs from source to drain with the gate spread along it: the gradual-channel
372
- approximation, exact for a thin film until it pinches off.
373
-
374
- ```js
375
- import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
376
-
377
- const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
378
- const t = 20e-9, L = 10e-6, W = 100e-6, C = 1e-3, mu = 1e-3; // film, length, width (m); F/m² of gate; m²/(V·s)
379
- const tft = (Vg, Vd) =>
380
- new Device({
381
- T,
382
- species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
383
- materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
384
- regions: [{ name: 'channel', material: 'film', length: L }],
385
- contacts: {
386
- left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
387
- right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
388
- },
389
- ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
390
- grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
391
- });
392
- // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
393
- for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
394
- const sol = tft(2, Vd).solve();
395
- const I = -sol.current * t * W; // sol.current is per m² of the film's section
396
- console.log(`V_D ${Vd.toFixed(1)} V: I_D ${(I * 1e6).toFixed(2)} µA, σ at the drain ${(sol.ports[0].sigma.at(-1) * 1e3).toFixed(3)} mC/m²`);
397
- }
398
- ```
399
-
400
- To sweep the gate on one device instead, patch the port: `dev.set({ ports: { gate: { V } } })`.
401
- An organic electrochemical transistor is the same with a volumetric capacitance C*:
402
- `area: 1 / t, capacitance: { C: Cstar * t }`.
403
-
404
335
  ## Template: an electrochemical cell
405
336
 
406
337
  An electrode is a metal layer with the reaction at its face, behind a contact holding the
@@ -486,69 +417,62 @@ for (const t of [3e-3, 1e-2, 3e-2]) {
486
417
 
487
418
  A cyclic voltammogram is this electrode driven by `triangle({ from, to, period })`, the period
488
419
  being 2|to − from| over the scan rate: `solve()` it at `from` first, since the scan starts there
489
- at t = 0, then `advance()`. Its potential against the bath's terminal is −`trace.voltage` (that
490
- is V_right − V_left), and `trace.current` is anodic positive.
491
-
492
- ## Template: corrosion under a drop (an electrode spread through a film)
493
-
494
- A **port with reactions** is an electrode spread through a strictly neutral region: a metal under
495
- a film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
496
- `area` its area per volume (1/h under a film h thick), each reaction Butler–Volmer per area, and
497
- a `capacitance` the double layer. A **`geometry`** gives the device a cross-section A(x): here x
498
- is the radius of a drop, A = 2πr·h(r), and currents and amounts become totals (A, mol).
420
+ at t = 0, then `advance()` with a `dtMax` of a millivolt's worth of sweep, or the trace's points
421
+ fall a few mV apart and miss the peaks. For a diffusion peak (Randles–Ševčík), the stagnant layer
422
+ must be well past √(D·t_scan), a few hundred µm: the template's 20 µm gives a thin-layer wave.
423
+ Its potential against the bath's terminal is −`trace.voltage` (that is V_right − V_left), and
424
+ `trace.current` is anodic positive.
425
+
426
+ ## Template: a battery particle discharging
427
+
428
+ A battery's insertion electrode is a material described by its OCV curve (docs/statistics.md):
429
+ here one particle of a cathode, a sphere, the single-particle model. Li⁺ is held at its surface
430
+ by the electrolyte, and the electrons come in through a port there (a current into the device,
431
+ so negative to discharge a cathode). With no steady state while a current flows, it's `advance()`d
432
+ from its `c0`, never `solve()`d. `D` is the dilute value: an ideal host's chemical diffusivity is
433
+ D/(1 − x), so it fills more easily the fuller it gets. A real OCV curve with a plateau needs a
434
+ little of the ideal slope (the error says how much).
499
435
 
500
436
  ```js
501
- import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
502
- import { aqueous, polarization, H2O } from 'driftlet/kit';
503
-
504
- // Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
505
- // the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
506
- const T = 298.15, RT = GAS_CONSTANT * T, R = 3e-3, h0 = 1e-3, cO2 = 0.26, D = 2e-9;
507
- const lib = aqueous(['Na+', 'Cl-', 'Fe2+', 'OH-', 'H+'], { epsr: 0 }); // strictly neutral water
508
- lib.species.push({ name: 'O2', z: 0, cRef: 1000 }, { name: 'e-', z: -1 }); // e⁻: the iron's, in no material
509
- lib.materials.water.species.O2 = { D, mu0: 16.4e3 };
510
- const r = Array.from({ length: 61 }, (_, j) => (j / 60) * R);
511
- const h = r.map((x) => Math.max(h0 * (1 - (x / R) * (x / R)), 0.02 * h0)); // the drop's height
512
- const dev = new Device({
513
- T,
514
- species: lib.species,
515
- materials: lib.materials,
516
- geometry: { area: { x: r, values: r.map((x, j) => 2 * Math.PI * x * h[j]) } }, // ions squeeze through the thin rim
517
- regions: [{ name: 'drop', material: 'water', length: R, c0: { 'Na+': 500, 'Cl-': 500 + 2e-6, 'Fe2+': 1e-6, 'OH-': 1e-4, 'H+': 1e-4, O2: cO2 } }],
518
- contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
519
- bulkReactions: [
520
- { equation: 'H+ + OH- = H2O', fixed: { H2O }, kf: { water: 1.4e8 } }, // H2O: water's μ, −237.129 kJ/mol
521
- // O₂ from the air through the drop: a rate k_f (1 − c/c_sat), k_f = D c_sat/h² (not a port's
522
- // exchange link, whose rate is linear in μ rather than c)
523
- { equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
524
- ],
525
- ports: [{
526
- name: 'iron', region: 'drop', terminal: 'e-',
527
- V: 0, // the only metal and the only path for current, so it passes none: held, it fixes the level φ is read against
528
- area: { x: r, values: h.map((y) => 1 / y) }, // m² of iron per m³ of water
529
- capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
530
- reactions: [
531
- { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
532
- { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O }, k0: 3e-9, alpha: 0.125 },
533
- ],
534
- }],
535
- grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
536
- });
537
- const sol = dev.advance(600); // ten minutes
538
- const iron = sol.ports[0], last = iron.x.length - 1;
539
- // rates[q][j]: reaction q at node j of the window, forward, mol/(m²·s) of iron; anodic current +
540
- const anodic = (j) => 2 * FARADAY * -iron.rates[0][j] - 4 * FARADAY * iron.rates[1][j];
541
- console.log(`iron at ${(iron.V - sol.phi[0]).toFixed(3)} V against the water at the centre (SHE, with table μ°)`);
542
- console.log(`net current on the iron: ${anodic(0).toFixed(2)} A/m² at the centre, ${anodic(last).toFixed(2)} A/m² at the rim`);
543
- // The Evans diagram at the centre: each reaction's current with the iron's level moved, the water held.
544
- const levels = [-0.55, -0.5, -0.45, -0.4].map((v) => v + sol.phi[0]);
545
- const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
546
- for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
547
- ```
437
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
548
438
 
549
- Species can take an electrode's surface sites (`surface`, Langmuir coverages), and a reaction
550
- with `bare: true` runs only on bare metal: a passive film (docs/device.md). A battery's insertion
551
- electrode is a material described by its OCV curve (docs/statistics.md).
439
+ const T = 298.15, VT = (GAS_CONSTANT * T) / FARADAY;
440
+ const R = 5e-6, D = 1e-14, cMax = 50000, x0 = 0.1; // m, m²/s, mol/m³ of sites, starting fill
441
+ const xs = Array.from({ length: 99 }, (_, k) => (k + 1) / 100);
442
+ const particle = (I) =>
443
+ new Device({
444
+ T,
445
+ species: [{ name: 'Li+', z: 1 }, { name: 'e-', z: -1 }],
446
+ materials: {
447
+ host: {
448
+ epsr: 0,
449
+ species: { 'Li+': { D, mu0: 0, cRef: cMax }, 'e-': { D: 1e-4, mu0: 0, cRef: cMax } },
450
+ statistics: [{ type: 'insertion', species: ['Li+', 'e-'], cMax, ocv: { x: xs, E: xs.map((x) => 4 - VT * Math.log(x / (1 - x))), muRef: 0 } }],
451
+ },
452
+ },
453
+ geometry: { type: 'spherical', r0: 0 }, // x is the radius; currents are totals (A)
454
+ regions: [{ name: 'p', material: 'host', length: R, c0: { 'Li+': x0 * cMax, 'e-': x0 * cMax } }],
455
+ contacts: {
456
+ left: { phi: 'neutral' }, // the centre
457
+ right: { V: 0, terminal: 'Li+', species: { 'Li+': 'equilibrium' }, phi: 'bulk' }, // Li⁺ at the electrolyte's level
458
+ },
459
+ ports: [{ name: 'wire', region: 'p', from: 0.98 * R, to: R, I, terminal: 'e-', species: { 'e-': { type: 'conductance', G: 1e15 } } }],
460
+ grid: { hmin: R / 400, hmax: R / 40 },
461
+ });
462
+ const Q = FARADAY * cMax * (4 / 3) * Math.PI * R ** 3; // C for Δx = 1
463
+ const xEnd = 1 / (1 + Math.exp(-0.1 / VT)); // the OCV reaches a 3.9 V cutoff at x = 0.98
464
+ for (const C of [0.2, 20]) {
465
+ const I = -(C * (xEnd - x0) * Q) / 3600; // a C-rate: the whole window in 1/C hours
466
+ const dev = particle(I);
467
+ // Halfway: in u = −ln(1 − x), whose diffusivity is D however full, a constant flux q in makes a
468
+ // quasi-steady parabola, surface − centre = qR/(2D c_max), once t ≫ R²/(π²D) (250 s here).
469
+ const half = dev.advance(1800 / C), u = (c) => -Math.log(1 - c / cMax), q = -I / (FARADAY * 4 * Math.PI * R * R);
470
+ const du = u(half.c['Li+'].at(-1)) - u(half.c['Li+'][0]);
471
+ let sol = half;
472
+ while (sol.converged && sol.terminals.wire.V > 3.9) sol = dev.advance(sol.time + 36 / C); // on to the cutoff, in 1% steps
473
+ console.log(`${C}C: ${((100 * (-I * sol.time)) / Q / (xEnd - x0)).toFixed(0)}% of the capacity; halfway, Δu ${du.toFixed(4)}, qR/(2D c_max) ${((q * R) / (2 * D * cMax)).toFixed(4)}`);
474
+ } // 0.2C: 100%, 0.0205 against 0.0204; 20C: 91%, and halfway (90 s) is too soon for the parabola
475
+ ```
552
476
 
553
477
  ## Template: a cell membrane
554
478
 
@@ -599,132 +523,10 @@ console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cyto
599
523
  Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
600
524
  current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium.
601
525
 
602
- ## Template: a voltage clamp
603
-
604
- A face's `gates` (each a fraction open, dx/dt = α(V)(1 − x) − β(V)x with V = φ_right − φ_left)
605
- scale its links (`gates: { m: 3, h: 1 }`). `hodgkinHuxley({ T, linear: true })` (kit) writes the
606
- squid axon's as Hodgkin and Huxley had them, conductances g(V − E). Between two baths without a
607
- reference species, V_right − V_left is V_m, so a held step is their voltage clamp.
608
-
609
- ```js
610
- import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
611
- import { build, layer, bath, aqueous, hodgkinHuxley } from 'driftlet/kit';
612
-
613
- const T = 279.45, VT = (GAS_CONSTANT * T) / FARADAY; // HH's 6.3 °C
614
- const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
615
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
616
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
617
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
618
- const hh = hodgkinHuxley({ T, linear: true });
619
- const step = -0.01; // from −65 mV to −10 mV at 1 ms
620
- const dev = new Device(
621
- build({
622
- T,
623
- library: [water],
624
- stack: [
625
- bath(out), // the outside, on the left
626
- layer('water', 1e-6, { c0: out }),
627
- { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
628
- layer('water', 1e-6, { c0: inside }),
629
- bath(inside, { t: [0, 1e-3, 1e-3, 1], values: [-0.065, -0.065, step, step] }),
630
- ],
631
- grid: { hmin: 20e-9, hmax: 0.2e-6 },
632
- }),
633
- );
634
- dev.solve(); // the rest at −65 mV, the gates at their values there
635
- const run = dev.advance(11e-3, { dtMax: 0.02e-3, probes: [{ interface: 0, species: 'Na+' }, { interface: 0, species: 'K+' }] });
636
- const [na, k] = run.trace.probes.map((N) => N.map((x) => -FARADAY * x)); // outward currents, A/m²
637
- const peak = na.indexOf(Math.min(...na)), EK = VT * Math.log(out['K+'] / inside['K+']);
638
- console.log(`I_Na peaks ${((run.trace.t[peak] - 1e-3) * 1e3).toFixed(2)} ms into the step, ${(na[peak] * 0.1).toFixed(2)} mA/cm²`); // 0.73 ms, −1.48
639
- console.log(`g_K after 10 ms: ${((k.at(-1) / (step - EK)) * 0.1).toFixed(2)} mS/cm²`); // 21.12: HH's 36 n∞⁴ is 21.5, which n nearly reaches by 10 ms
640
- ```
641
-
642
- ## Template: an action potential
643
-
644
- Without `linear: true` (given `inside` and `outside`), `hodgkinHuxley` writes GHK permeabilities
645
- matched to HH's conductances at −65 mV, which rectify as real channels do. A pump-less cell has no
646
- steady rest (`solve()` finds Donnan), so start the transient from the concentrations given.
647
-
648
- ```js
649
- import { Device } from 'driftlet';
650
- import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
651
-
652
- const T = 279.45; // HH's 6.3 °C
653
- const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
654
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
655
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
656
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
657
- const hh = hodgkinHuxley({ inside, outside: out, T }); // GHK channels; outside on the left
658
- const axon = new Device(
659
- build({
660
- T,
661
- library: [water],
662
- stack: [
663
- bath(out, 'Cl-'),
664
- layer('water', 5e-6, { name: 'outside', c0: out }),
665
- { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
666
- layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
667
- { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
668
- ],
669
- // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
670
- ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
671
- grid: { hmin: 50e-9, hmax: 1e-6 },
672
- }),
673
- );
674
- let peak = -1;
675
- for (let k = 1; k <= 300; k++) {
676
- const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
677
- peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
678
- }
679
- console.log(`peak ${(peak * 1e3).toFixed(1)} mV`); // +12.6: GHK's Na⁺ current is limited by the 50 mM inside (linear: true gives +37.9, HH's own)
680
- ```
681
-
682
- ## Template: an action potential travelling along an axon
683
-
684
- Here x runs along the axon: the axoplasm is one region, and the membrane a port spanning it, a
685
- capacitance and gated channels to an outside given as a `bath` (the port's V is then the
686
- outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
687
- per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
688
- `ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
689
- its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
690
- the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
691
- (scale h with √a). The stimulus is a current per volume, so a thinner axon needs it as 1/a.
692
-
693
- ```js
694
- import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
695
- import { hodgkinHuxley, injector, pulse } from 'driftlet/kit';
696
-
697
- const T = 291.65, VT = (GAS_CONSTANT * T) / FARADAY, a = 238e-6; // 18.5 °C, a squid giant axon
698
- const inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52.5, 'A-': 397.5 }; // mM
699
- // Outside levels that put the Nernst potentials at HH's +50, −77 and −54.4 mV (leak as Cl⁻).
700
- const outside = { 'Na+': 50 * Math.exp(0.05 / VT), 'K+': 400 * Math.exp(-0.077 / VT), 'Cl-': 52.5 * Math.exp(0.054387 / VT) };
701
- // One D for the small ions, giving HH's 35.4 Ω·cm.
702
- const D = 1 / 0.354 / ((FARADAY * FARADAY) / (GAS_CONSTANT * T)) / (50 + 400 + 52.5);
703
- const sp = (d) => ({ D: d, mu0: 0, cRef: 1000 });
704
- const sealed = { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' };
705
- const hh = hodgkinHuxley({ T, area: 2 / a });
706
- const axon = new Device({
707
- T,
708
- species: [{ name: 'Na+', z: 1 }, { name: 'K+', z: 1 }, { name: 'Cl-', z: -1 }, { name: 'A-', z: -1 }],
709
- materials: { axoplasm: { epsr: 0, species: { 'Na+': sp(D), 'K+': sp(D), 'Cl-': sp(D), 'A-': sp(1e-12) } } },
710
- regions: [{ name: 'axon', material: 'axoplasm', length: 0.06, c0: inside }],
711
- contacts: { left: sealed, right: sealed },
712
- ports: [
713
- { name: 'membrane', region: 'axon', V: 0, area: 2 / a, capacitance: { C: 0.01, zeroCharge: 0 }, bath: { c: outside }, gates: hh.gates, species: hh.species },
714
- injector({ name: 'stim', region: 'axon', from: 0, to: 1e-3, species: 'K+', I: pulse({ start: 30e-3, width: 0.2e-3, amplitude: 30 }) }),
715
- ],
716
- grid: { hmin: 2e-4, hmax: 2e-4 },
717
- });
718
- axon.advance(30e-3); // settle to rest (no pump: start from the concentrations, not solve())
719
- const sol = axon.advance(34e-3, { dtMax: 5e-6, probes: [{ x: 0.02, quantity: 'phi' }, { x: 0.04, quantity: 'phi' }] });
720
- const { t, probes } = sol.trace, at = (v) => t[v.findIndex((x) => x > 0)]; // (φ inside, the outside's held at 0)
721
- console.log(`${(0.02 / (at(probes[1]) - at(probes[0]))).toFixed(1)} m/s`); // 18.8 here (finely resolved, 18.73; HH computed 18.8)
722
- ```
723
-
724
526
  ## Transients
725
527
 
726
528
  `advance(t, opts)` steps adaptively from the device's present state (its `c0`, or the steady
727
- state if you called `solve()`) to time t. It returns the solution there, with `done` and a `trace`
529
+ state if you called `solve()`) to time t, absolute (for a further dt, `advance(sol.time + dt)`). It returns the solution there, with `done` and a `trace`
728
530
  of this call's accepted steps (`t`, `current`, `voltage`: V_right − V_left). It stops short at
729
531
  `budgetMs` (an animation frame: call again) or, with a warning saying why, at `maxSteps` (100000)
730
532
  or a step that won't converge. `probes`, read at accepted steps (give `dtMax` to resolve them):
@@ -812,7 +614,8 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
812
614
  reaction; `shifts` (marked ⌇) moves one species' lines to fit a shared axis.
813
615
  - **Your own style.** `traces(sol, opts)` gives `{ x, series: [{ id, label, kind, role, slot, y }],
814
616
  regions, faces, range }`: `kind` says how a line reads (`'level'`, `'standard'`, `'redox'`,
815
- `'redox-standard'`, `'phi'`), `themeColor(role, slot)` (plot) gives our colours,
617
+ `'redox-standard'`, `'phi'`; an `id` is the kind and species, `V:e-`, `Vstd:h+`), `themeColor(role,
618
+ slot)` (plot) gives our colours,
816
619
  `labelParts(label)` splits TeX-style subscripts, `NaN` breaks a line, and a face's doubled nodes
817
620
  share an x (a vertical step).
818
621
 
@@ -828,17 +631,16 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
828
631
 
829
632
  ## Common mistakes
830
633
 
831
- - **Errors that say what to give.** A missing alignment at a heterointerface (`vacuumDipole`
832
- estimates one from electron affinities, docs/alignment.md), a metal face with no φ law,
833
- everything floating (hold one terminal at a `V`), a species that reaches no contact without a
834
- `c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
835
- `'2 e-'`, a current drive of the wrong sign. `interfaces[f]` is the stack's f-th face.
634
+ Errors say what to give, and a misspelt option is an error that names the one meant. Beyond
635
+ those:
636
+
836
637
  - **A gate as a contact on an oxide.** It fails as `'e-' is absent from the end material 'SiO2'`: make the
837
638
  gate a metal layer with a capacitive face, as in the MOS template.
838
- - **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
839
639
  - **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
840
640
  them (`species: { 'Li+': 'blocked' }`) if they cross only by the reaction.
841
641
  - **Unit slips.** D in cm²/s, μ° in eV, µF/cm², mS/cm², T in °C. `describe(def)` flags them.
642
+ - **A couple's μ° with the wrong sign** solves happily at −E° for +E°: μ°_red − μ°_ox = −F·E°.
643
+ Check the rest potential against E°, as the stirred-solution template does.
842
644
  - **The library's data are for 25 °C** (its semiconductors for 300 K: give `T: 300`). At body
843
645
  temperature the same μ° and D are a fair start; D rises about 2% per kelvin in water.
844
646
  - **A steady solve of a closed cell where every ion can cross**: its steady state is complete
@@ -846,6 +648,10 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
846
648
  - **A sharp junction between very different solutions** (3 M KCl against 1 µM) can't start:
847
649
  grade it over a few thin layers, as at a frit.
848
650
  - **Changing `c0` with `set()`.** A carried state keeps what it holds: make a new `Device`.
651
+ - **A charged electrode face beside a strictly neutral solution** (`epsr: 0`, a capacitive face
652
+ with reactions): refining the grid there makes the kinetics worse, not better, since the edge
653
+ cell holds the double layer's charge. Read `sol.warnings`; a `zeroCharge` near the rest
654
+ potential avoids it.
849
655
  - **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
850
656
  Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
851
657
 
@@ -861,9 +667,11 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
861
667
  - sources: `pulse`, `square`, `triangle`, `ramp`, `injector`, `recombination`,
862
668
  `photogeneration`;
863
669
  - channels: `hodgkinHuxley`, `ghkCurrent`;
864
- - checking: `describe`, `check`, `polarization` (an electrode's reactions at hypothetical
670
+ - checking: `describe`, `unitWarnings` (describe's unit slips alone), `check`, `polarization` (an electrode's reactions at hypothetical
865
671
  metal levels, with the solution held: an Evans diagram), `henderson` and `planck`
866
672
  (liquid-junction potentials);
867
- - pages: `live`, `traces`, `recorder`, `particles`;
673
+ - pages: `live`, `traces` (with `speciesRole` and `typeset` for its labels), `recorder`,
674
+ `particles`;
868
675
  - curated devices: `perovskiteCell`, `PEROVSKITE_SCANS`, `hysteresis`.
869
- - `driftlet/plot` (a quick look): `bandDiagram`, `levelChart`, `labelParts`, `themeColor`.
676
+ - `driftlet/plot` (a quick look): `bandDiagram`, `levelChart`, `labelParts`, `themeColor`,
677
+ `THEME`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.16.2",
4
- "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
3
+ "version": "1.0.0",
4
+ "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -42,8 +42,20 @@
42
42
  "keywords": [
43
43
  "drift-diffusion",
44
44
  "poisson-nernst-planck",
45
- "electrochemistry",
45
+ "nernst-planck",
46
46
  "semiconductor",
47
+ "pn-junction",
48
+ "band-diagram",
49
+ "solar-cell",
50
+ "device-simulation",
51
+ "electrochemistry",
52
+ "battery",
53
+ "impedance-spectroscopy",
54
+ "cyclic-voltammetry",
55
+ "ion-transport",
56
+ "membrane-potential",
57
+ "hodgkin-huxley",
58
+ "physics",
47
59
  "simulation"
48
60
  ],
49
61
  "homepage": "https://markblundeberg.github.io/driftlet/demos/",