driftlet 0.16.4 → 1.0.1

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 (49) hide show
  1. package/README.md +3 -3
  2. package/docs/device.md +28 -14
  3. package/docs/kit.md +3 -1
  4. package/docs/numerics.md +12 -0
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +106 -300
  8. package/package.json +15 -3
  9. package/src/channels.js +6 -4
  10. package/src/checks.js +7 -2
  11. package/src/data.js +5 -1
  12. package/src/device.js +10 -4
  13. package/src/devices.js +2 -0
  14. package/src/equation.js +2 -1
  15. package/src/errors.js +33 -0
  16. package/src/grid.js +12 -0
  17. package/src/index.js +17 -7
  18. package/src/junction.js +3 -1
  19. package/src/kit.js +1 -1
  20. package/src/live.js +2 -0
  21. package/src/particles.js +2 -0
  22. package/src/plot.js +4 -0
  23. package/src/polarization.js +4 -3
  24. package/src/recorder.js +2 -1
  25. package/src/solution.js +20 -5
  26. package/src/solver.js +70 -16
  27. package/src/sources.js +8 -1
  28. package/src/stack.js +14 -1
  29. package/src/statistics.js +1 -1
  30. package/src/traces.js +3 -0
  31. package/src/types.js +5 -2
  32. package/types/channels.d.ts +3 -3
  33. package/types/checks.d.ts +1 -1
  34. package/types/data.d.ts +3 -3
  35. package/types/devices.d.ts +1 -1
  36. package/types/equation.d.ts +1 -1
  37. package/types/errors.d.ts +8 -0
  38. package/types/index.d.ts +2 -5
  39. package/types/junction.d.ts +2 -2
  40. package/types/kit.d.ts +1 -1
  41. package/types/live.d.ts +1 -1
  42. package/types/particles.d.ts +1 -1
  43. package/types/plot.d.ts +3 -3
  44. package/types/polarization.d.ts +1 -1
  45. package/types/recorder.d.ts +1 -1
  46. package/types/solver.d.ts +2 -0
  47. package/types/sources.d.ts +7 -7
  48. package/types/traces.d.ts +1 -1
  49. package/types/types.d.ts +11 -2
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.4/src/index.js';
32
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
33
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/plot.js';
34
36
  ```
35
37
 
36
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -58,12 +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, a steady state on a grid twice as fine (a FAIL means refine;
65
- a transient you rerun on a finer grid yourself) and with its species listed in reverse (a
66
- FAIL there means a solve converged somewhere that isn't steady: compare a long advance()).
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()).
67
68
 
68
69
  ## How a device is written
69
70
 
@@ -98,7 +99,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
98
99
  `{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
99
100
  - **Drives.** A terminal is held at a voltage `V`, driven by a current `I` (open circuit is
100
101
  `I: 0`), or behind a resistance `{ V, R }`; in `build()`, the same object goes where the
101
- voltage would (`ohmic({ I: 0 }, ['e-'])`, `ohmic({ V: 1, R: 1e3 }, ['e-'])`). Either can be a waveform: a table `{ t, values }`
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 }`
102
104
  (piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
103
105
  amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
104
106
  start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
@@ -114,14 +116,17 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
114
116
  { tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
115
117
  srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
116
118
  injection. Light is a reaction from a reservoir (`photon = e- + h+`, uniform), or absorbed as
117
- it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`. To switch
118
- 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).
119
123
  - **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
120
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
121
125
  `k0: 10`).
122
126
  - **Grids** grade geometrically from `hmin` at each region's ends up to `hmax`, neighbouring cells
123
127
  differing by at most `ratio` (1.2): put `hmin` below the shortest length that matters (a Debye
124
- 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.
125
130
 
126
131
  `build()` is the usual way in. It writes a plain definition for `new Device()`, which some
127
132
  templates below write by hand (where a port spans a region, or no stack fits):
@@ -204,9 +209,9 @@ a CDN needs a long virtual-time budget, 30 s.)
204
209
  <span id="readout"></span>
205
210
  <div id="figure"></div>
206
211
  <script type="module">
207
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js';
208
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
209
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
212
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/index.js';
213
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/kit.js';
214
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.0.1/src/plot.js';
210
215
 
211
216
  const def = build({
212
217
  T: 300,
@@ -230,22 +235,17 @@ To draw in your own style, `traces(sol, opts)` (kit, the same options) gives the
230
235
  see "Reading level diagrams" below.
231
236
 
232
237
  For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
233
- mol/m² of a species, hopping at rates taken from the solution: on average their density is its
234
- concentration and their net crossing its flux. Say on the page that it samples the continuum
235
- solution, and how much a dot is. Where a dot would be lost among millions (majority carriers, a
236
- bath), a cell over `cap` dots is a sea: shade it by `expected(name)`. Each frame,
237
- `swarm.step(dt)` in the device's time (about h²/D per hop, h a cell), then draw `swarm.dots`
238
- (`{ species, x }`) and `swarm.events` (`'unmade'` is a recombination, `'cross'` an ion through a
239
- membrane between two seas); on a new steady solution, `swarm.update(sol, { resample: true })`.
240
- An ion beside a cell membrane hops ~1e6 times per crossing, so a membrane page shows seas and
241
- crossing events, not walking ions. The demos' page scripts do this for a diode's carriers (pn),
242
- 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).
243
242
 
244
243
  ## Template: a solar cell
245
244
 
246
245
  `photon = e- + h+`, with the photons' μ well above the gap, generates uniformly at the rate `kf`.
247
246
  Open circuit is `I: 0`, and the terminal's V is then V_oc. The photocurrent flows toward +x
248
- (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).
249
249
 
250
250
  ```js
251
251
  import { Device, units } from 'driftlet';
@@ -272,14 +272,10 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
272
272
  console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
273
273
  ```
274
274
 
275
- In an organic cell, light makes excitons: a neutral species `X` in the donor, made by
276
- `photogeneration({ ..., makes: 'X' })` and decaying by `{ equation: 'X = 0', kf: { donor: 1 / tau } }`.
277
- They split at the donor/acceptor face by a reaction there, `X = h+(left) + e-(right)` with e⁻ and
278
- h⁺ blocked at the face (the reaction moves them), and its reverse is charge-transfer
279
- recombination; the face's `step` sets E_CT. With `alpha: 0` the splitting is a velocity
280
- k0/cRef, a Robin condition on the exciton profile; with α > 0 it's k0/cRef · e^(α(E_X − E_CT)/RT).
281
- The organic demo's page script is a worked example (J_sc against exciton diffusion theory, V_oc
282
- 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.
283
279
 
284
280
  For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
285
281
  default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
@@ -336,73 +332,6 @@ the same C at any frequency with the port; without it, only below the minority s
336
332
  above. Draw it with
337
333
  `bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
338
334
 
339
- ## Template: a Schottky diode
340
-
341
- A barrier pinned at the metal (`phi: 'pinned'`, `zeroCharge` the barrier height for Si, whose
342
- μ°_e⁻ is 0), crossed at the emission velocity: a `velocity` link, N_in = v (c_eq − c). With
343
- `'equilibrium'` links (infinite velocity) it's diffusion theory instead, 20 times the current here.
344
-
345
- ```js
346
- import { Device, units, FARADAY, GAS_CONSTANT } from 'driftlet';
347
- import { build, layer, ohmic, semiconductor } from 'driftlet/kit';
348
-
349
- const T = 300, VT = (GAS_CONSTANT * T) / FARADAY, Si = semiconductor('Si');
350
- const phiB = 0.85, A = 1.12e6; // the barrier (V); Richardson's constant for n-Si, A/(m²K²)
351
- const v = (A * T * T) / (FARADAY * Si.materials.Si.species['e-'].cRef); // Bethe's emission velocity, 2.2e4 m/s (cRef is N_c)
352
- const schottky = (V) => ({
353
- V,
354
- terminal: 'e-',
355
- species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v, offset: 0 } },
356
- phi: 'pinned',
357
- zeroCharge: phiB, // E_c − E_F at the metal
358
- });
359
- 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) } }));
360
- for (const V of [0.1, 0.2, 0.3]) {
361
- dev.set({ contacts: { left: { V } } }); // the metal positive: forward
362
- const J = dev.solve().current, te = A * T * T * Math.exp(-phiB / VT) * Math.expm1(V / VT);
363
- console.log(`${V} V: J ${J.toExponential(3)} A/m², thermionic emission ${te.toExponential(3)}`); // about 4% below: the depletion region in series
364
- }
365
- ```
366
-
367
- The same `velocity` link at an ohmic contact is its surface recombination velocity.
368
-
369
- ## Template: a thin-film transistor (a capacitance along a channel)
370
-
371
- A **port with a `capacitance`** holds charge σ = C (V − zeroCharge − φ) per area of electrode
372
- along its window, balanced by the region's: a gate along a channel, a porous electrode's double
373
- layer. Here x runs from source to drain with the gate spread along it: the gradual-channel
374
- approximation, exact for a thin film until it pinches off.
375
-
376
- ```js
377
- import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
378
-
379
- const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
380
- 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)
381
- const tft = (Vg, Vd) =>
382
- new Device({
383
- T,
384
- species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
385
- materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
386
- regions: [{ name: 'channel', material: 'film', length: L }],
387
- contacts: {
388
- left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
389
- right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
390
- },
391
- ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
392
- grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
393
- });
394
- // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
395
- for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
396
- const sol = tft(2, Vd).solve();
397
- const I = -sol.current * t * W; // sol.current is per m² of the film's section
398
- 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²`);
399
- }
400
- ```
401
-
402
- To sweep the gate on one device instead, patch the port: `dev.set({ ports: { gate: { V } } })`.
403
- An organic electrochemical transistor is the same with a volumetric capacitance C*:
404
- `area: 1 / t, capacitance: { C: Cstar * t }`.
405
-
406
335
  ## Template: an electrochemical cell
407
336
 
408
337
  An electrode is a metal layer with the reaction at its face, behind a contact holding the
@@ -488,69 +417,62 @@ for (const t of [3e-3, 1e-2, 3e-2]) {
488
417
 
489
418
  A cyclic voltammogram is this electrode driven by `triangle({ from, to, period })`, the period
490
419
  being 2|to − from| over the scan rate: `solve()` it at `from` first, since the scan starts there
491
- at t = 0, then `advance()`. Its potential against the bath's terminal is −`trace.voltage` (that
492
- is V_right − V_left), and `trace.current` is anodic positive.
493
-
494
- ## Template: corrosion under a drop (an electrode spread through a film)
495
-
496
- A **port with reactions** is an electrode spread through a strictly neutral region: a metal under
497
- a film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
498
- `area` its area per volume (1/h under a film h thick), each reaction Butler–Volmer per area, and
499
- a `capacitance` the double layer. A **`geometry`** gives the device a cross-section A(x): here x
500
- 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).
501
435
 
502
436
  ```js
503
- import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
504
- import { aqueous, polarization, H2O } from 'driftlet/kit';
505
-
506
- // Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
507
- // the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
508
- const T = 298.15, RT = GAS_CONSTANT * T, R = 3e-3, h0 = 1e-3, cO2 = 0.26, D = 2e-9;
509
- const lib = aqueous(['Na+', 'Cl-', 'Fe2+', 'OH-', 'H+'], { epsr: 0 }); // strictly neutral water
510
- lib.species.push({ name: 'O2', z: 0, cRef: 1000 }, { name: 'e-', z: -1 }); // e⁻: the iron's, in no material
511
- lib.materials.water.species.O2 = { D, mu0: 16.4e3 };
512
- const r = Array.from({ length: 61 }, (_, j) => (j / 60) * R);
513
- const h = r.map((x) => Math.max(h0 * (1 - (x / R) * (x / R)), 0.02 * h0)); // the drop's height
514
- const dev = new Device({
515
- T,
516
- species: lib.species,
517
- materials: lib.materials,
518
- geometry: { area: { x: r, values: r.map((x, j) => 2 * Math.PI * x * h[j]) } }, // ions squeeze through the thin rim
519
- 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 } }],
520
- contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
521
- bulkReactions: [
522
- { equation: 'H+ + OH- = H2O', fixed: { H2O }, kf: { water: 1.4e8 } }, // H2O: water's μ, −237.129 kJ/mol
523
- // 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
524
- // exchange link, whose rate is linear in μ rather than c)
525
- { equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
526
- ],
527
- ports: [{
528
- name: 'iron', region: 'drop', terminal: 'e-',
529
- V: 0, // the only metal and the only path for current, so it passes none: held, it fixes the level φ is read against
530
- area: { x: r, values: h.map((y) => 1 / y) }, // m² of iron per m³ of water
531
- capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
532
- reactions: [
533
- { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
534
- { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O }, k0: 3e-9, alpha: 0.125 },
535
- ],
536
- }],
537
- grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
538
- });
539
- const sol = dev.advance(600); // ten minutes
540
- const iron = sol.ports[0], last = iron.x.length - 1;
541
- // rates[q][j]: reaction q at node j of the window, forward, mol/(m²·s) of iron; anodic current +
542
- const anodic = (j) => 2 * FARADAY * -iron.rates[0][j] - 4 * FARADAY * iron.rates[1][j];
543
- console.log(`iron at ${(iron.V - sol.phi[0]).toFixed(3)} V against the water at the centre (SHE, with table μ°)`);
544
- 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`);
545
- // The Evans diagram at the centre: each reaction's current with the iron's level moved, the water held.
546
- const levels = [-0.55, -0.5, -0.45, -0.4].map((v) => v + sol.phi[0]);
547
- const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
548
- for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
549
- ```
437
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
550
438
 
551
- Species can take an electrode's surface sites (`surface`, Langmuir coverages), and a reaction
552
- with `bare: true` runs only on bare metal: a passive film (docs/device.md). A battery's insertion
553
- 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
+ ```
554
476
 
555
477
  ## Template: a cell membrane
556
478
 
@@ -601,132 +523,10 @@ console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cyto
601
523
  Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
602
524
  current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium.
603
525
 
604
- ## Template: a voltage clamp
605
-
606
- A face's `gates` (each a fraction open, dx/dt = α(V)(1 − x) − β(V)x with V = φ_right − φ_left)
607
- scale its links (`gates: { m: 3, h: 1 }`). `hodgkinHuxley({ T, linear: true })` (kit) writes the
608
- squid axon's as Hodgkin and Huxley had them, conductances g(V − E). Between two baths without a
609
- reference species, V_right − V_left is V_m, so a held step is their voltage clamp.
610
-
611
- ```js
612
- import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
613
- import { build, layer, bath, aqueous, hodgkinHuxley } from 'driftlet/kit';
614
-
615
- const T = 279.45, VT = (GAS_CONSTANT * T) / FARADAY; // HH's 6.3 °C
616
- const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
617
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
618
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
619
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
620
- const hh = hodgkinHuxley({ T, linear: true });
621
- const step = -0.01; // from −65 mV to −10 mV at 1 ms
622
- const dev = new Device(
623
- build({
624
- T,
625
- library: [water],
626
- stack: [
627
- bath(out), // the outside, on the left
628
- layer('water', 1e-6, { c0: out }),
629
- { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
630
- layer('water', 1e-6, { c0: inside }),
631
- bath(inside, { t: [0, 1e-3, 1e-3, 1], values: [-0.065, -0.065, step, step] }),
632
- ],
633
- grid: { hmin: 20e-9, hmax: 0.2e-6 },
634
- }),
635
- );
636
- dev.solve(); // the rest at −65 mV, the gates at their values there
637
- const run = dev.advance(11e-3, { dtMax: 0.02e-3, probes: [{ interface: 0, species: 'Na+' }, { interface: 0, species: 'K+' }] });
638
- const [na, k] = run.trace.probes.map((N) => N.map((x) => -FARADAY * x)); // outward currents, A/m²
639
- const peak = na.indexOf(Math.min(...na)), EK = VT * Math.log(out['K+'] / inside['K+']);
640
- 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
641
- 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
642
- ```
643
-
644
- ## Template: an action potential
645
-
646
- Without `linear: true` (given `inside` and `outside`), `hodgkinHuxley` writes GHK permeabilities
647
- matched to HH's conductances at −65 mV, which rectify as real channels do. A pump-less cell has no
648
- steady rest (`solve()` finds Donnan), so start the transient from the concentrations given.
649
-
650
- ```js
651
- import { Device } from 'driftlet';
652
- import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
653
-
654
- const T = 279.45; // HH's 6.3 °C
655
- const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
656
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
657
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
658
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
659
- const hh = hodgkinHuxley({ inside, outside: out, T }); // GHK channels; outside on the left
660
- const axon = new Device(
661
- build({
662
- T,
663
- library: [water],
664
- stack: [
665
- bath(out, 'Cl-'),
666
- layer('water', 5e-6, { name: 'outside', c0: out }),
667
- { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
668
- layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
669
- { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
670
- ],
671
- // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
672
- ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
673
- grid: { hmin: 50e-9, hmax: 1e-6 },
674
- }),
675
- );
676
- let peak = -1;
677
- for (let k = 1; k <= 300; k++) {
678
- const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
679
- peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
680
- }
681
- 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)
682
- ```
683
-
684
- ## Template: an action potential travelling along an axon
685
-
686
- Here x runs along the axon: the axoplasm is one region, and the membrane a port spanning it, a
687
- capacitance and gated channels to an outside given as a `bath` (the port's V is then the
688
- outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
689
- per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
690
- `ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
691
- its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
692
- the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
693
- (scale h with √a). The stimulus is a current per volume, so a thinner axon needs it as 1/a.
694
-
695
- ```js
696
- import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
697
- import { hodgkinHuxley, injector, pulse } from 'driftlet/kit';
698
-
699
- const T = 291.65, VT = (GAS_CONSTANT * T) / FARADAY, a = 238e-6; // 18.5 °C, a squid giant axon
700
- const inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52.5, 'A-': 397.5 }; // mM
701
- // Outside levels that put the Nernst potentials at HH's +50, −77 and −54.4 mV (leak as Cl⁻).
702
- const outside = { 'Na+': 50 * Math.exp(0.05 / VT), 'K+': 400 * Math.exp(-0.077 / VT), 'Cl-': 52.5 * Math.exp(0.054387 / VT) };
703
- // One D for the small ions, giving HH's 35.4 Ω·cm.
704
- const D = 1 / 0.354 / ((FARADAY * FARADAY) / (GAS_CONSTANT * T)) / (50 + 400 + 52.5);
705
- const sp = (d) => ({ D: d, mu0: 0, cRef: 1000 });
706
- const sealed = { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' };
707
- const hh = hodgkinHuxley({ T, area: 2 / a });
708
- const axon = new Device({
709
- T,
710
- species: [{ name: 'Na+', z: 1 }, { name: 'K+', z: 1 }, { name: 'Cl-', z: -1 }, { name: 'A-', z: -1 }],
711
- materials: { axoplasm: { epsr: 0, species: { 'Na+': sp(D), 'K+': sp(D), 'Cl-': sp(D), 'A-': sp(1e-12) } } },
712
- regions: [{ name: 'axon', material: 'axoplasm', length: 0.06, c0: inside }],
713
- contacts: { left: sealed, right: sealed },
714
- ports: [
715
- { name: 'membrane', region: 'axon', V: 0, area: 2 / a, capacitance: { C: 0.01, zeroCharge: 0 }, bath: { c: outside }, gates: hh.gates, species: hh.species },
716
- injector({ name: 'stim', region: 'axon', from: 0, to: 1e-3, species: 'K+', I: pulse({ start: 30e-3, width: 0.2e-3, amplitude: 30 }) }),
717
- ],
718
- grid: { hmin: 2e-4, hmax: 2e-4 },
719
- });
720
- axon.advance(30e-3); // settle to rest (no pump: start from the concentrations, not solve())
721
- const sol = axon.advance(34e-3, { dtMax: 5e-6, probes: [{ x: 0.02, quantity: 'phi' }, { x: 0.04, quantity: 'phi' }] });
722
- const { t, probes } = sol.trace, at = (v) => t[v.findIndex((x) => x > 0)]; // (φ inside, the outside's held at 0)
723
- 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)
724
- ```
725
-
726
526
  ## Transients
727
527
 
728
528
  `advance(t, opts)` steps adaptively from the device's present state (its `c0`, or the steady
729
- 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`
730
530
  of this call's accepted steps (`t`, `current`, `voltage`: V_right − V_left). It stops short at
731
531
  `budgetMs` (an animation frame: call again) or, with a warning saying why, at `maxSteps` (100000)
732
532
  or a step that won't converge. `probes`, read at accepted steps (give `dtMax` to resolve them):
@@ -814,7 +614,8 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
814
614
  reaction; `shifts` (marked ⌇) moves one species' lines to fit a shared axis.
815
615
  - **Your own style.** `traces(sol, opts)` gives `{ x, series: [{ id, label, kind, role, slot, y }],
816
616
  regions, faces, range }`: `kind` says how a line reads (`'level'`, `'standard'`, `'redox'`,
817
- `'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,
818
619
  `labelParts(label)` splits TeX-style subscripts, `NaN` breaks a line, and a face's doubled nodes
819
620
  share an x (a vertical step).
820
621
 
@@ -830,17 +631,16 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
830
631
 
831
632
  ## Common mistakes
832
633
 
833
- - **Errors that say what to give.** A missing alignment at a heterointerface (`vacuumDipole`
834
- estimates one from electron affinities, docs/alignment.md), a metal face with no φ law,
835
- everything floating (hold one terminal at a `V`), a species that reaches no contact without a
836
- `c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
837
- `'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
+
838
637
  - **A gate as a contact on an oxide.** It fails as `'e-' is absent from the end material 'SiO2'`: make the
839
638
  gate a metal layer with a capacitive face, as in the MOS template.
840
- - **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
841
639
  - **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
842
640
  them (`species: { 'Li+': 'blocked' }`) if they cross only by the reaction.
843
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.
844
644
  - **The library's data are for 25 °C** (its semiconductors for 300 K: give `T: 300`). At body
845
645
  temperature the same μ° and D are a fair start; D rises about 2% per kelvin in water.
846
646
  - **A steady solve of a closed cell where every ion can cross**: its steady state is complete
@@ -848,6 +648,10 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
848
648
  - **A sharp junction between very different solutions** (3 M KCl against 1 µM) can't start:
849
649
  grade it over a few thin layers, as at a frit.
850
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.
851
655
  - **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
852
656
  Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
853
657
 
@@ -863,9 +667,11 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
863
667
  - sources: `pulse`, `square`, `triangle`, `ramp`, `injector`, `recombination`,
864
668
  `photogeneration`;
865
669
  - channels: `hodgkinHuxley`, `ghkCurrent`;
866
- - 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
867
671
  metal levels, with the solution held: an Evans diagram), `henderson` and `planck`
868
672
  (liquid-junction potentials);
869
- - pages: `live`, `traces`, `recorder`, `particles`;
673
+ - pages: `live`, `traces` (with `speciesRole` and `typeset` for its labels), `recorder`,
674
+ `particles`;
870
675
  - curated devices: `perovskiteCell`, `PEROVSKITE_SCANS`, `hysteresis`.
871
- - `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.4",
4
- "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
3
+ "version": "1.0.1",
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/",
package/src/channels.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // driftlet/kit: voltage-gated channels as plain definition data. Hodgkin and Huxley's squid axon:
2
2
  // its gates, and permeabilities for its Na⁺, K⁺ and leak channels, for a membrane face.
3
3
 
4
- import { DeviceError } from './errors.js';
4
+ import { DeviceError, checkOptions } from './errors.js';
5
5
  import { FARADAY, GAS_CONSTANT } from './constants.js';
6
6
 
7
7
  const fail = (message) => {
@@ -49,13 +49,15 @@ export function ghkCurrent(z, inside, outside, V, T) {
49
49
  * port along a region: links that are their linear conductances, G = g × area per volume, gated the
50
50
  * same, each ion's reversal potential following from the port's bath and the concentrations inside
51
51
  * (so `inside`, `outside` and `at` aren't needed).
52
- * @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
52
+ * @param {{ inside: object, outside: object, T: number, g?: { Na?: number, K?: number, leak?: number },
53
53
  * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string }, linear?: boolean, area?: number }} opts concentrations in
54
54
  * mol/m³ by species name, K, S/m², V
55
55
  * @returns {{ gates: object, species: object }}
56
56
  */
57
- export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
58
- need(num(T) && T > 0, `hodgkinHuxley: T must be a temperature in K, got ${T}`);
57
+ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
58
+ checkOptions('hodgkinHuxley', arguments[0], ['inside', 'outside', 'T', 'g', 'leak', 'leakZ', 'at', 'names', 'linear', 'area']);
59
+ // T sets the gates' rates, so it must be the device's: never defaulted (their 6.3 °C, under a body-temperature device, doubled a spike's peak, silently).
60
+ need(num(T) && T > 0, `hodgkinHuxley: T must be the device's temperature in K (their own experiments were at 279.45 K, 6.3 °C), got ${T}`);
59
61
  need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
60
62
  const G = { Na: 1200, K: 360, leak: 3, ...g };
61
63
  const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';