driftlet 0.8.0 → 0.10.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
@@ -30,7 +30,7 @@ The same equations go by different names in different fields. If your problem is
30
30
 
31
31
  | If you work on | you may call it | start from | checked against |
32
32
  |---|---|---|---|
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 |
33
+ | Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions; thin-film transistors (gradual channel, in the [saturation demo](https://markblundeberg.github.io/driftlet/demos/saturation.html#transistor)) | [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
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, corrosion | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions, mixed potentials, electrodes under a film | [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), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js), [`ports`](test/ports.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc; Wagner–Traud |
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 |
@@ -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.8.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
58
+ `https://cdn.jsdelivr.net/npm/driftlet@0.10.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
 
@@ -145,7 +145,7 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
145
145
  | [![pn junction](demos/screenshots/pn.png)](https://markblundeberg.github.io/driftlet/demos/pn.html) | [![solar cell](demos/screenshots/solar.png)](https://markblundeberg.github.io/driftlet/demos/solar.html) | [![MOS capacitor](demos/screenshots/mos.png)](https://markblundeberg.github.io/driftlet/demos/mos.html) |
146
146
  | **pn junction**: quasi-Fermi levels under bias; I–V against Shockley | **Solar cell**: J<sub>sc</sub> and V<sub>oc</sub> against collection theory | **MOS capacitor**: band bending; C–V against ideal theory |
147
147
  | [![cyclic voltammetry](demos/screenshots/redox.png)](https://markblundeberg.github.io/driftlet/demos/redox.html) | [![Daniell cell](demos/screenshots/daniell.png)](https://markblundeberg.github.io/driftlet/demos/daniell.html) | [![saturation](demos/screenshots/saturation.png)](https://markblundeberg.github.io/driftlet/demos/saturation.html) |
148
- | **Cyclic voltammetry**: a Fermi level against a redox level | **Daniell cell**: a real salt bridge, leaking, ion by ion | **Saturation**: Ag \| AgNO₃ \| Ag under a square wave, up to its limiting current |
148
+ | **Cyclic voltammetry**: a Fermi level against a redox level | **Daniell cell**: a real salt bridge, leaking, ion by ion | **Saturation**: Ag \| AgNO₃ \| Ag up to its limiting current, and a thin-film transistor pinching off, against the charge-sheet model |
149
149
  | [![impedance](demos/screenshots/impedance.png)](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [![ion-exchange membrane](demos/screenshots/membrane.png)](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [![double layer](demos/screenshots/double-layer.png)](https://markblundeberg.github.io/driftlet/demos/double-layer.html) |
150
150
  | **Impedance**: the Warburg arc, and the cell's small-signal response inside | **Ion-exchange membrane**: Donnan steps, ion by ion, against TMS theory | **Double layer**: dilute vs crowded ions, against closed forms |
151
151
  | [![insertion host](demos/screenshots/insertion.png)](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [![organic solar cell](demos/screenshots/organic.png)](https://markblundeberg.github.io/driftlet/demos/organic.html) | [![perovskite hysteresis](demos/screenshots/perovskite.png)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) |
@@ -262,6 +262,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
262
262
  | Metal regions | ohmic; Schottky face and MOS gate equal their contact forms; Ag \| AgNO₃ \| Ag with metal electrodes; bipolar electrode at V/2 | 1e-12; 1e-9; 1e-9; 1e-9 |
263
263
  | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel | 1e-4; 1e-9; 2e-3 |
264
264
  | Cross-sections (spherical, cylindrical, any A(x)) | steady diffusion to a sphere and a cylinder (exact on any grid); Cottrell with the spherical term; uptake by a sphere filling from its surface; Debye–Hückel around a charged sphere, potential and charge | 1e-10; 3e-4; 2e-4; 1e-3, 3e-4 |
265
+ | A capacitance through a port | a thin-film transistor against the charge-sheet model (below threshold to saturation, second order); de Levie impedance; gate impedance against dQ/dV | 5e-4; 1e-3; 1e-6 |
266
+ | Electrode surfaces (coverage) | Langmuir isotherm against potential; the charge to fill a surface; the active–passive curve, blocking exactly (1 − θ) | 1e-9; 1e-3; 1e-9 |
265
267
  | Electrodes spread through a port | transmission line with the reaction's linear kinetics as the conductance (O(h²)), rates along it; Wagner–Traud mixed potential of two Butler–Volmer couples, floating | 1e-4, 2e-3; 1e-6 V |
266
268
  | Impedance | Macdonald blocking-electrode spectrum, 100 Hz–1 GHz; finite-length Warburg (Ag \| AgNO₃ \| Ag); DC limit = differential resistance | 3e-4; 3e-5; 1e-6 |
267
269
  | Conservation | per step, and against time-integrated contact fluxes | 1e-11 relative |
package/docs/device.md CHANGED
@@ -421,65 +421,74 @@ with the contact behind it holding the conductor's electrons (see
421
421
  ## Internal ports
422
422
 
423
423
  A port is an outside phase with known levels, like a contact's, attached to a window of nodes
424
- inside one region instead of at an end. It's the 1D stand-in for whatever feeds or drains a
425
- species sideways: source and drain grounding a MOS channel, salt injected mid-solution, a
426
- reference electrode's reservoir.
424
+ inside one region instead of at an end: the 1D stand-in for whatever feeds, drains or charges the
425
+ region sideways. It's a [terminal](#terminals) like a contact (held at `V`, behind `R`, or driven
426
+ by `I`; a reference electrode is a port at `I: 0`), and in its window it does any of these, in
427
+ any combination:
428
+
429
+ | Field | What it adds | For |
430
+ |---|---|---|
431
+ | `species` | [levels held or exchanged](#levels-held-or-exchanged), per volume | source and drain grounding a channel, salt injected mid-solution, O₂ from the air |
432
+ | `reactions`, `area` | an [electrode spread through the window](#electrodes-spread-through-a-window) | a thin film on a metal, a crevice, a porous electrode |
433
+ | `surface` | [species on that electrode's sites](#an-electrodes-surface-adsorbates-and-passive-films) | adsorbates, a passive film |
434
+ | `capacitance`, `area` | [charge held across a capacitance](#a-capacitance-through-a-window) | a gate along a channel, a double layer |
427
435
 
428
436
  ```js nocheck
429
437
  ports: [{
430
438
  name: 'channel',
431
439
  region: 'Si', // name or index
432
440
  from: 495e-9, to: 500e-9, // window, m from the region's left end (default: the whole region)
433
- V: 0, // the port's terminal voltage (or I, or V and R: see terminals)
434
- terminal: 'e-',
435
- species: {
436
- 'e-': 'equilibrium', // μ̄ held at V_i = V + offset throughout the window
437
- 'Na+': { type: 'conductance', G: 1e9, offset: 0.2 }, // source G (V_out − V_i)/(zF) per volume, G in S/m³
438
- O2: { type: 'exchange', k: 1e-3, mu: -2e3 }, // neutral: source k (μ_out − μ)/RT per volume, k in mol/(m³·s)
439
- },
441
+ V: 0, // or I, or V and R (see terminals)
442
+ terminal: 'e-', // the species whose level V is
443
+ species: { 'e-': 'equilibrium' },
440
444
  }]
441
445
  ```
442
446
 
443
- Links and offsets are those of contacts, per volume instead of per area: 0 by default only for
444
- the terminal species, and an absolute `mu` for neutral species. A port is a terminal like a
445
- contact: held at a voltage, behind a resistance, or driven by a current (a reference electrode
446
- is a port at `I: 0`). Each solution reports `ports[k]`, with `{ name, V, flux, current }`: what
447
- the port brings into the device.
448
-
449
- A held (`'equilibrium'`) level leaves the device's two end nodes to their contacts. A port that
450
- exchanges only neutral species carries no current, so its voltage means nothing: give it no drive.
451
-
452
- An exchange link's source is linear in $`\mu_{out} - \mu`$, so it grows without bound as the
453
- species runs out ($`\ln c`$). A supply limited by diffusion through a film above the window, such as O₂
454
- reaching a thin layer of water from the air, is linear in $`c`$ instead: $`k(c_{sat} - c)`$. For that,
455
- use a [bulk reaction](#bulk-reactions) with the outside phase as a fixed participant, e.g.
456
- `{ equation: 'Air = O2', fixed: { Air: muSat }, kf: { water: profile } }`. Its rate is
457
- $`k_f (1 - c/c_{sat})`$, and a profile gives $`k_f`$ its spatial variation (a film of thickness $`h(x)`$:
458
- $`D c_{sat}/h^2`$).
459
-
460
- A port driven by a current (`I`, or a waveform) floats to whatever voltage delivers it: with a
461
- conductance `G` per volume over a window of width $`w`$, it sits about $`I/(G w)`$ above the
462
- window's level for that species. So `G` decides how the current is shared across the window, not
463
- how much enters. A small `G`, with the port volts above the window, spreads it evenly, like a
464
- current source; a large one pins the window near the port's level, and the current enters
465
- wherever the species is drawn away fastest. At `I: 0` the port still ties that species' level
466
- together across its window (a large `G` shorts the window for it), so keep such windows narrow.
467
-
468
447
  On a [conductor region](#materials), a port is a wire to the whole conductor: it takes no window,
469
448
  only the conductor's carrier, and a conductance link's `G` is per area (S/m², a resistance
470
449
  $`R \cdot A`$ to the port's voltage as $`G = 1/(R \cdot A)`$). That's how a floating electrode is tied
471
450
  to ground through a resistor.
472
451
 
473
- For example, in a 1D MOS capacitor without generation, inversion electrons can only arrive by
474
- minority-carrier diffusion from the back contact, which can take weeks. The inversion layer is
475
- then effectively floating, and its Fermi level is undetermined to within round-off, so no
476
- steady state can be computed there. A port holding the electrons beside the oxide at the
477
- channel's potential anchors it, as source and drain would, and the device then shows the
478
- low-frequency C–V.
452
+ ### Levels held or exchanged
453
+
454
+ ```js nocheck
455
+ species: {
456
+ 'e-': 'equilibrium', // μ̄ held at V_i = V + offset throughout the window
457
+ 'Na+': { type: 'conductance', G: 1e9, offset: 0.2 }, // source G (V_out − V_i)/(zF) per volume, G in S/m³
458
+ O2: { type: 'exchange', k: 1e-3, mu: -2e3 }, // neutral: source k (μ_out − μ)/RT per volume, k in mol/(m³·s)
459
+ }
460
+ ```
461
+
462
+ Links and offsets are those of contacts, per volume instead of per area: 0 by default only for
463
+ the terminal species, and an absolute `mu` for neutral species. A held (`'equilibrium'`) level
464
+ leaves the device's two end nodes to their contacts. A port that exchanges only neutral species
465
+ carries no current, so its voltage means nothing: give it no drive.
466
+
467
+ A port driven by a current floats to whatever voltage delivers it: with a conductance `G` per
468
+ volume over a window of width $`w`$, about $`I/(G w)`$ above the window's level for that species. So
469
+ `G` decides how the current is shared across the window, not how much enters. A small `G` spreads
470
+ it evenly, like a current source; a large one pins the window near the port's level, and the
471
+ current enters wherever the species is drawn away fastest. At `I: 0` the port still ties that
472
+ species' level together across its window (a large `G` shorts the window for it), so keep such
473
+ windows narrow.
474
+
475
+ An exchange link's source is linear in $`\mu_{out} - \mu`$, so it grows without bound as the
476
+ species runs out ($`\ln c`$). A supply limited by diffusion through a film above the window, such
477
+ as O₂ reaching a thin layer of water from the air, is linear in $`c`$ instead: $`k(c_{sat} - c)`$. For
478
+ that, use a [bulk reaction](#bulk-reactions) with the outside phase as a fixed participant,
479
+ `{ equation: 'Air = O2', fixed: { Air: muSat }, kf: { water: profile } }`, whose rate is
480
+ $`k_f (1 - c/c_{sat})`$; a profile gives $`k_f`$ its spatial variation ($`D c_{sat}/h^2`$ under a film
481
+ of thickness $`h(x)`$).
482
+
483
+ A held level can also anchor what nothing else holds. In a 1D MOS capacitor without generation,
484
+ inversion electrons can only arrive by minority-carrier diffusion from the back contact (weeks),
485
+ so the inversion layer's Fermi level is undetermined to within round-off and no steady state can
486
+ be computed. A port holding the electrons beside the oxide, as source and drain would, anchors it,
487
+ and the device shows the low-frequency C–V.
479
488
 
480
489
  ### Electrodes spread through a window
481
490
 
482
- A port can also hold an electrode: a metal whose carrier sits at the port's level, reacting all
491
+ A port can hold an electrode: a metal whose carrier sits at the port's level, reacting all
483
492
  through the window rather than at one face. That's the floor under a thin film of electrolyte, the
484
493
  walls of a crevice or a pit, or the matrix of a porous electrode, wherever the metal conducts well
485
494
  enough to be one level. Give the port its `reactions` (Butler–Volmer, as at a face), the carrier as
@@ -499,21 +508,122 @@ ports: [{
499
508
 
500
509
  Each node's rate per area is the face's law, with the region's species at that node and the
501
510
  carrier at activity 1 and level $`V`$. Times the area per volume, it makes and consumes the
502
- region's species there, and the electrons it takes or gives are the port's current. The port is
503
- a terminal like any other. Held at a voltage, it's a potentiostat. At `I: 0` (or with nothing
504
- else to carry current), the reactions settle at the mixed potential, the corrosion potential where
505
- anodic and cathodic currents cancel over the window, while each node can be a net anode or a net
506
- cathode. A floating one starts there, for the start's composition. Each solution reports the
507
- window's nodes and every reaction's rate at each, `ports[k].x` and `ports[k].rates`
508
- (mol/(m²·s) of electrode, forward), with `ports[k].area`.
511
+ region's species there, and the electrons it takes or gives are the port's current. Held at a
512
+ voltage, the port is a potentiostat. At `I: 0` (or with nothing else to carry current), the
513
+ reactions settle at the mixed potential, the corrosion potential where anodic and cathodic
514
+ currents cancel over the window, while each node can be a net anode or a net cathode; a floating
515
+ one starts there, for the start's composition (with a double layer, it starts uncharged and
516
+ charges toward it). An electrode that's the device's only path for current is at `I: 0` whatever
517
+ its drive: hold it at `V: 0`, which also fixes the level the water's φ is read against. Per volume of the window a rate is
518
+ `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
519
+
520
+ In 1D, a film on a metal is a slice along the metal: the film's thickness enters through `area`,
521
+ and through a bulk reaction for anything that reaches the film from above (O₂ from the air, see
522
+ above). Where the thickness varies, give the device its [cross-section](#geometry) too, so that ions
523
+ travelling along the film squeeze through where it thins: for a drop on a metal, seen from its
524
+ centre, $`A = 2\pi r\,h(r)`$ with `area` $`1/h(r)`$. The region must be strictly neutral
525
+ ($`\varepsilon = 0`$), as in porous-electrode theory: each spot's double layer is below the grid, so
526
+ the reactions can't leave the solution charged (give the port a
527
+ [capacitance](#a-capacitance-through-a-window) for the double layer's charge).
509
528
 
510
529
  Validated against the transmission line (linear kinetics along a bar, test/ports.test.js) and the
511
- Wagner–Traud mixed potential of two Butler–Volmer couples. In 1D, a film on a metal is a slice
512
- along the metal: the film's thickness enters through `area`, and through a bulk reaction for
513
- anything that reaches the film from above (O₂ from the air, see the exchange link above). Where
514
- the film's thickness varies, give the device its [cross-section](#geometry) too, so that ions
515
- travelling along the film squeeze through where it thins: for a drop on a metal, seen from its
516
- centre, $`A = 2\pi r\,h(r)`$ with `area` $`1/h(r)`$.
530
+ Wagner–Traud mixed potential of two Butler–Volmer couples.
531
+
532
+ ### An electrode's surface: adsorbates and passive films
533
+
534
+ An electrode port can carry species on its surface, sharing its sites: an adsorbed intermediate,
535
+ or a passive film taken as a coverage. Each has a coverage $`\theta`$ at every node of the window,
536
+ Langmuir statistics, $`\mu = \mu^\circ + RT\ln(\theta/\theta_0)`$ with $`\theta_0 = 1 - \sum\theta`$ the
537
+ bare fraction, and `capacity` $`\Gamma`$, mol of sites per m² of electrode. Reactions name them like
538
+ species, and a reaction with `bare: true` runs only on bare metal: its rate is multiplied by
539
+ $`\theta_0`$, both ways, so equilibrium stays exact. Written for adsorption itself, that's Langmuir
540
+ kinetics (on at a rate ∝ $`\theta_0`$, off ∝ $`\theta`$).
541
+
542
+ ```js nocheck
543
+ ports: [{
544
+ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1 / 100e-6,
545
+ surface: { 'Fe(OH)2': { mu0: -490e3, capacity: 2e-5, theta0: 1e-6 } }, // theta0: the start
546
+ reactions: [
547
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5, bare: true }, // dissolves where bare
548
+ { equation: 'Fe(s) + 2 OH- = Fe(OH)2 + 2 e-', fixed: { 'Fe(s)': 0 }, k0: 1e-4, alpha: 0.5, bare: true }, // the film forms (in minutes)
549
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.13e3 }, k0: 3e-9, alpha: 0.125 }, // on film or not
550
+ ],
551
+ }]
552
+ ```
553
+
554
+ A film forms where the potential and the pH favour it, through its own thermodynamics, and blocks
555
+ what needs bare metal: the dissolution current climbs with potential, peaks, and falls as the film
556
+ covers the metal, the active–passive curve. Each node's surface stores $`\Gamma\,d\theta/dt`$ per area
557
+ of electrode, so filling it passes a current, and what passes through a surface is conserved with
558
+ it. It's a monolayer picture: a real passive oxide is nanometres thick, grows, and breaks down
559
+ under chloride, none of which a coverage has.
560
+
561
+ Cautions:
562
+ - **Keep a film's stability within reason.** Blocking the bare fraction to 1e-3–1e-6 is already
563
+ a passive metal; far below that ($`\theta_0 \lesssim 10^{-10}`$) the coverage's η barely affects
564
+ anything and Newton struggles with it (a solution warns).
565
+ - **A film's μ° is a fitted number.** A monolayer given a bulk hydroxide's ΔG°f passivates iron far
566
+ into neutral water, so choose it to put the passivation where the metal's chemistry does.
567
+ - **Without a double layer, passivation can jump.** With no capacitance the potential follows the
568
+ kinetics instantly, and when the last active patch covers over, the potential where no current
569
+ flows can vanish from the active branch: a discontinuity, where a transient stops. Give the port
570
+ a [capacitance](#a-capacitance-through-a-window) and it's a quick, continuous swing, as on a real
571
+ electrode.
572
+
573
+ Validated in test/coverage.test.js: the Langmuir isotherm against the electrode's potential (to
574
+ 1e-9), filling against its exponential and its charge $`F\,\Gamma\,\Delta\theta`$ per area of electrode,
575
+ the active–passive curve with blocking exactly $`(1-\theta)`$ times Butler–Volmer, and a steady state
576
+ through a surface intermediate where the transient ends.
577
+
578
+ ### A capacitance through a window
579
+
580
+ A port can hold charge across a capacitance spread through its window: per area of electrode,
581
+ $`\sigma = C\,(V - \mathtt{zeroCharge} - \phi)`$ on the port's side (C in F/m²), with `area` per volume
582
+ as for reactions, and the window holds the opposite. Each node's charge balance gains $`a\sigma`$:
583
+ Gauss's law averaged across the section, $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$,
584
+ and at $`\varepsilon = 0`$ neutrality $`\rho + a\sigma = 0`$, the mobile species rearranging to supply
585
+ it (counter-ions in, co-ions out). The port passes the charging current, $`d(\int a\sigma\,dV)/dt`$,
586
+ none in a steady state. Capacitances on overlapping windows add (a channel's top and bottom
587
+ gates). A capacitance alone, driven by a current, only charges: it starts uncharged, `advance()`
588
+ follows it, and `solve()` refuses (there's no steady state, as for a gate contact driven by one).
589
+
590
+ ```js nocheck
591
+ // a thin-film transistor: the gate along a 10 nm channel through 1 mF/m² (35 nm of SiO₂)
592
+ ports: [{ name: 'gate', region: 'channel', V: 1.5, area: 1 / 10e-9, capacitance: { C: 1e-3, zeroCharge: 0.3 } }]
593
+ // a porous electrode's double layer, alongside its reactions
594
+ ports: [{ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1e4, capacitance: { C: 0.2, zeroCharge: -0.4 }, reactions: [/* … */] }]
595
+ ```
596
+
597
+ That's the gradual-channel approximation of a field-effect transistor, exact for a thin film
598
+ with no body (a TFT, an organic or oxide transistor) while the channel is long against the gate
599
+ dielectric, and failing where it pinches off (where the 2D field takes over). It's also the
600
+ Bernards model of an organic electrochemical transistor (a volumetric capacitance), cable theory
601
+ for an axon's membrane, and a porous electrode's double layer. The gate's flat level pins the
602
+ channel's standard level through C, as a blocked spectator's flat level does in an electrolyte,
603
+ and the current saturates as the carriers run out at the drain, in both.
604
+
605
+ Validated in test/capacitance.test.js: a thin-film transistor's current against the
606
+ charge-sheet model, $`I/W = (\mu/L)[(\sigma_s^2 - \sigma_d^2)/(2C) + V_T(\sigma_s - \sigma_d)]`$, from
607
+ below threshold through saturation (second order in the grid); a porous electrode's de Levie
608
+ impedance $`\sqrt{r/y}\coth(L\sqrt{ry})`$, the double layer in series with the ions' chemical
609
+ capacitance; and the gate's low-frequency impedance against $`dQ/dV_G`$ from steady states.
610
+
611
+ ### What a solution reports
612
+
613
+ Each solution's `ports[k]`, as the port has them:
614
+
615
+ | Field | Contents |
616
+ |---|---|
617
+ | `name`, `V` | the port's name and voltage |
618
+ | `flux[name]`, `current` | what it brings into the device: each species (mol/(m²·s)) and the current (A/m², the charging current included), totals through a [cross-section](#geometry) |
619
+ | `x[j]`, `area[j]` | with reactions or a capacitance: the window's node j (m) and the electrode's area per volume there (m²/m³) |
620
+ | `rates[q][j]` | reaction q's forward rate at node j, mol/(m²·s) of electrode (reaction first, then node) |
621
+ | `coverage[name][j]`, `bare[j]` | with a surface: each species' coverage θ, and the bare fraction θ₀ to full precision |
622
+ | `sigma[j]`, `charge` | with a capacitance: σ per area of electrode (C/m²), and the total the port's side holds |
623
+
624
+ The arrays are typed (`Float64Array`), so `Array.from` them before mapping to anything but
625
+ numbers. The kit's [`polarization()`](kit.md#polarization-curves-an-evans-diagram) gives each reaction's rate at a spot of the
626
+ electrode with its level moved, an Evans diagram.
517
627
 
518
628
  ## Terminals
519
629
 
@@ -536,7 +646,9 @@ ports: [{ name: 'ref', I: 0, ... }, // a reference electrode: no current, its v
536
646
  the voltage floats and is solved for.
537
647
  - **`V` and `R`**: a source behind a resistance, $`I = (V - V_{\mathrm{terminal}})/R`$.
538
648
 
539
- At least one terminal must be held at a voltage, or the device's overall level floats. In
649
+ At least one terminal must be held at a voltage, or the device's overall level floats, and if
650
+ one is driven by a current, a held one must pass current (a closed end held at a voltage fixes
651
+ nothing). In
540
652
  steady state the terminal currents sum to zero. A contact can be driven by a current only if
541
653
  something passes it (a linked species, or a gate's displacement).
542
654
 
@@ -690,7 +802,7 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
690
802
  | `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
691
803
  | `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
692
804
  | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
693
- | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
805
+ | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device; an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
694
806
  | `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)) |
695
807
  | `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 |
696
808
  | `charge` | total charge in the device, C/m² (C through a cross-section) |
package/docs/kit.md CHANGED
@@ -64,7 +64,7 @@ console.log(`I = ${sol.current.toFixed(1)} A/m²`);
64
64
  (`combine()` does the merging on its own). A species or material given twice must be given
65
65
  identically.
66
66
  - Nested lists in the stack are flattened, so a helper can return several items.
67
- - Reactions pass through as written (see below).
67
+ - Reactions pass through as written (see below), and so do `T`, `grid`, `ports` and `geometry`.
68
68
 
69
69
  ## Reactions and half-reactions
70
70
 
@@ -123,7 +123,7 @@ console.log(curve.reactions[0].level, curve.current[40]); // the couple's level
123
123
  solution beside it held as `sol` has it and the metal's level set to each `V` (an electron
124
124
  voltage, the terminals' convention). It works at a face (`{ face: f }`, the face's metal side) or
125
125
  at a spot of an [electrode port](device.md#electrodes-spread-through-a-window)
126
- (`{ port: name, x }`). Each reaction comes back with its rate (mol/(m²·s), forward), its partial
126
+ (`{ port: name, x }`, with its surface's coverages there held too). Each reaction comes back with its rate (mol/(m²·s), forward), its partial
127
127
  current (A/m², anodic positive: into the solution) and its `level`, where the rate vanishes (the
128
128
  couple's redox level there, as `level()` gives it). The net current is their sum. Plotting
129
129
  log|current| against V gives the Evans diagram. The net crosses zero at the spot's open-circuit
@@ -133,6 +133,11 @@ the SHE level (`sol.phi` with the usual tables' ions).
133
133
 
134
134
  The example above ends with the left electrode's curve.
135
135
 
136
+ An electrode port's surface is held too by default. With `{ surface: 'equilibrium' }` (a fifth
137
+ argument) its coverages are re-equilibrated at each V instead, each species by the reaction that
138
+ makes it from the solution: the steady-state curve, where a passive film's active–passive peak
139
+ shows (`curve.bare` gives the bare fraction along it).
140
+
136
141
  Concentrations are held, so these are the curves of that instant (for a mixed potential, the
137
142
  kinetic picture). Away from the actual level the composition beside the electrode would change,
138
143
  which is the transport the full solve accounts for.
package/docs/numerics.md CHANGED
@@ -358,7 +358,9 @@ reaction) and that nothing outside feeds ($`w = 0`$ on stretches reached by a co
358
358
  the null space of that stoichiometry. A stretch in none of them is fed. So Ag⁺ between silver
359
359
  electrodes is fed (Ag⁺ + e⁻ ⇌ Ag(s), the electrons fed by the contacts), and the steady equations
360
360
  are solved directly; Fe³⁺ and Fe²⁺ between platinum electrodes aren't, since Fe³⁺ + e⁻ ⇌ Fe²⁺
361
- conserves the iron whatever the electrons do, so their total is kept by huge steps (below). A
361
+ conserves the iron whatever the electrons do, so their total is kept by huge steps (below). An
362
+ electrode surface's species are columns too, holding $`\Gamma \sum v\,a\,\theta`$, so species fed only
363
+ through one (A⁺ + e⁻ = S, S + e⁻ = B⁻) conserve their total with what the surface holds. A
362
364
  floating conductor starts uncharged, with its carrier's level in equilibrium with the first
363
365
  reaction on its left face that takes it.
364
366
 
@@ -366,7 +368,8 @@ reaction on its left face that takes it.
366
368
 
367
369
  A port adds a source per volume to the balance rows of the nodes in its window, after every
368
370
  other term at those nodes and before the contacts. A held (`'equilibrium'`) level replaces the
369
- balance row with a Dirichlet row. The port's flux is then that row's residual, read just
371
+ balance row with a Dirichlet row, after every port's other terms (another port's electrode can
372
+ react in the same window). The port's flux is then that row's residual, read just
370
373
  before replacement, exactly as at a contact. Conductance and exchange links are linear in
371
374
  $`(\bar\mu_{\mathrm{out}} - \bar\mu)`$. Everything stays on the node's own block. A contact's flux
372
375
  readout at an end node already includes any port source there, so contact and port fluxes always
@@ -478,8 +481,15 @@ test checks the compact Jacobian column by column on devices that cover every as
478
481
  $`C_c = h^3(1+\omega)^2/(\omega(1+2\omega))`$ and $`C_p = h(h+h_1)(h+h_1+h_2)`$; linear after
479
482
  backward Euler, scaled by $`h/(2h + h_1)`$. The first step, with no history, is checked by step
480
483
  doubling.
484
+ - The state a first step starts from needn't satisfy the algebraic equations ($`\hat\phi`$ in a
485
+ strictly neutral material, an interface's unknowns), after a start or a jump; they jump in
486
+ the first instant. So the history starts from the first step's half step, not from there,
487
+ and the second step is backward Euler: a predictor through the starting state saw the jump
488
+ as error that shrank only as fast as the step, and a junction from a sharp boundary at a
489
+ tight tolerance stepped down into round-off.
481
490
  - The error is measured in thermal units over every state potential ($`\hat\phi`$ where defined,
482
- each present $`\eta`$, a floating terminal voltage). That includes species far below their
491
+ each present $`\eta`$, an electrode surface's coverages as their $`\eta`$; a floating terminal's
492
+ voltage follows from these). That includes species far below their
483
493
  largest concentration, such as minority carriers ahead of a diffusion front. Weighting
484
494
  them down (a mixed relative/absolute tolerance,
485
495
  $`\delta\eta \cdot c/(c + 10^{-10} c_{\mathrm{max}})`$) took a third fewer steps on a pn turn-on,
@@ -627,8 +637,10 @@ was, so storage and neutrality don't see it. Only the fluxes do. But in $`(\hat\
627
637
  and neutrality see it as pairs of huge entries that cancel, and round-off in that cancellation, of
628
638
  order storage/flux ~ $`h^2/(D \cdot dt)`$ times the range of concentrations, swamps the fluxes that
629
639
  fix it: a trace ion beside 3 M KCl lost about ten digits, and Newton stalled. So at each node that
630
- stays neutral (interior nodes, and edges at `neutral` faces; not the edge of a capacitive or
631
- pinned face, which holds the face's charge), assembled without storage and neutrality:
640
+ stays neutral (interior nodes, edges at `neutral` faces, and a device's end where its contact is
641
+ closed to every ion and leaves $`\phi`$ alone; not the edge of a capacitive or pinned face, which
642
+ holds the face's charge, nor an end whose contact holds a level), assembled without storage and
643
+ neutrality:
632
644
  - rows: the balance of the most abundant charged species (by $`z^2 c`$) becomes
633
645
  $`\sum (z_i/z_k) \times`$ each balance, which without storage is current continuity;
634
646
  - columns: the unknowns become $`\hat\phi'`$ and $`\eta'_i = \eta_i - z_i \hat\phi`$ (the update is
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.8.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -34,7 +34,9 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
34
34
  and h⁺). In voltage units the conduction band sits below the valence band. `NaN` marks where a
35
35
  species is absent.
36
36
  - **Current**: `sol.current` is positive toward +x through the device. `sol.terminals[name]` gives
37
- each terminal's `{ V, current }`, current into the device.
37
+ each terminal's `{ V, current }`, current into the device. Currents, fluxes and amounts are per
38
+ m² of a planar device; with a `geometry` (spherical, cylindrical or any cross-section A(x)) they
39
+ are totals through it.
38
40
  - **Nothing physical is defaulted.** Every interface between different materials needs an
39
41
  alignment (`dipole` or `step`), or `phi: 'neutral'` for a macroscopic model. Every metal face
40
42
  next to a dielectric needs its φ law. Every species that doesn't reach a contact needs its
@@ -247,6 +249,103 @@ const dev = new Device(electrode(rest.terminals.left.V + 0.2));
247
249
  console.log(`anodic current ${dev.solve().terminals.left.current.toFixed(2)} A/m²`);
248
250
  ```
249
251
 
252
+ ## Template: corrosion under a drop (an electrode spread through a film)
253
+
254
+ A **port with reactions** is an electrode spread through a window of a region: a metal under a
255
+ thin film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
256
+ `area` the electrode's area per volume of the window (1/h under a film h thick), and each
257
+ reaction is Butler–Volmer per area, as at a face. The region must be strictly neutral (ε = 0); a
258
+ `capacitance` adds the double layer. A **`geometry`** gives the device a cross-section A(x): here
259
+ x is the radius of a drop seen from its centre, A = 2πr·h(r), and every current and amount becomes
260
+ a total through it (A, mol), not per m².
261
+
262
+ ```js
263
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
264
+ import { aqueous, polarization } from 'driftlet/kit';
265
+
266
+ // Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
267
+ // the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
268
+ const T = 298.15, RT = GAS_CONSTANT * T, R = 3e-3, h0 = 1e-3, cO2 = 0.26, D = 2e-9;
269
+ const lib = aqueous(['Na+', 'Cl-', 'Fe2+', 'OH-', 'H+'], { epsr: 0 }); // strictly neutral water
270
+ lib.species.push({ name: 'O2', z: 0, cRef: 1000 }, { name: 'e-', z: -1 }); // e⁻: the iron's, in no material
271
+ lib.materials.water.species.O2 = { D, mu0: 16.4e3 };
272
+ const r = Array.from({ length: 61 }, (_, j) => (j / 60) * R);
273
+ const h = r.map((x) => Math.max(h0 * (1 - (x / R) * (x / R)), 0.02 * h0)); // the drop's height
274
+ const dev = new Device({
275
+ T,
276
+ species: lib.species,
277
+ materials: lib.materials,
278
+ geometry: { area: { x: r, values: r.map((x, j) => 2 * Math.PI * x * h[j]) } }, // ions squeeze through the thin rim
279
+ 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 } }],
280
+ contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
281
+ bulkReactions: [
282
+ { equation: 'H+ + OH- = H2O', fixed: { H2O: -237.129e3 }, kf: { water: 1.4e8 } },
283
+ // 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
284
+ // exchange link, whose rate is linear in μ rather than c)
285
+ { equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
286
+ ],
287
+ ports: [{
288
+ name: 'iron', region: 'drop', terminal: 'e-',
289
+ V: 0, // the only metal and the only path for current, so it passes none: held, it fixes the level φ is read against
290
+ area: { x: r, values: h.map((y) => 1 / y) }, // m² of iron per m³ of water
291
+ capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
292
+ reactions: [
293
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
294
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.129e3 }, k0: 3e-9, alpha: 0.125 },
295
+ ],
296
+ }],
297
+ grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
298
+ });
299
+ const sol = dev.advance(600); // ten minutes
300
+ const iron = sol.ports[0], last = iron.x.length - 1;
301
+ // rates[q][j]: reaction q at node j of the window, forward, mol/(m²·s) of iron; anodic current +
302
+ const anodic = (j) => 2 * FARADAY * -iron.rates[0][j] - 4 * FARADAY * iron.rates[1][j];
303
+ console.log(`iron at ${(iron.V - sol.phi[0]).toFixed(3)} V against the water at the centre (SHE, with table μ°)`);
304
+ 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`);
305
+ // The Evans diagram at the centre: each reaction's current with the iron's level moved, the water held.
306
+ const levels = [-0.55, -0.5, -0.45, -0.4].map((v) => v + sol.phi[0]);
307
+ const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
308
+ for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
309
+ ```
310
+
311
+ On an electrode's surface, species can take its sites (`surface: { name: { mu0, capacity } }`,
312
+ Langmuir coverages), and a reaction with `bare: true` runs only on bare metal: a passive film
313
+ (docs/device.md, "An electrode's surface").
314
+
315
+ ## Template: a thin-film transistor (a capacitance along a channel)
316
+
317
+ A **port with a `capacitance`** holds charge σ = C (V − zeroCharge − φ) per area of electrode
318
+ along its window, balanced by the region (Gauss's law, or neutrality at ε = 0): a gate along a
319
+ channel, a porous electrode's double layer. In 1D a field-effect transistor is its channel, x from
320
+ source to drain, with the gate spread along it: the gradual-channel approximation, exact for a thin
321
+ film until it pinches off.
322
+
323
+ ```js
324
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
325
+
326
+ const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
327
+ 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)
328
+ const tft = (Vg, Vd) =>
329
+ new Device({
330
+ T,
331
+ species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
332
+ materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
333
+ regions: [{ name: 'channel', material: 'film', length: L }],
334
+ contacts: {
335
+ left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
336
+ right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
337
+ },
338
+ ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
339
+ grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
340
+ });
341
+ // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
342
+ for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
343
+ const sol = tft(2, Vd).solve();
344
+ const I = -sol.current * t * W; // sol.current is per m² of the film's section
345
+ 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²`);
346
+ }
347
+ ```
348
+
250
349
  ## Template: a transient, with a pulse injected sideways
251
350
 
252
351
  A **port** feeds a window of nodes inside a region from the side (a point contact, an injector,
@@ -319,9 +418,9 @@ solve), as long as it settles rather than ping-pongs.
319
418
  <span id="readout"></span>
320
419
  <div id="figure"></div>
321
420
  <script type="module">
322
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/index.js';
323
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
324
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
421
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/index.js';
422
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/kit.js';
423
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/plot.js';
325
424
 
326
425
  const def = build({
327
426
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,7 @@
29
29
  "scripts": {
30
30
  "test": "node --test",
31
31
  "bench": "node bench/bench.js",
32
+ "hard": "node bench/hard.js",
32
33
  "smoke": "node tools/smoke.js",
33
34
  "shots": "node tools/shots.js",
34
35
  "prepublishOnly": "npm test && npm run types",