driftlet 0.7.0 → 0.9.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
@@ -26,13 +26,13 @@ minor versions.
26
26
  ## Is it for your problem?
27
27
 
28
28
  The same equations go by different names in different fields. If your problem is one-dimensional
29
- (planar), it's probably here:
29
+ (planar, or radial: spheres and cylinders, or any cross-section varying along x), it's probably here:
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) | [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
- | Electrochemistry | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html), [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) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc |
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 |
37
37
  | Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
38
38
  | Membranes, desalination | Donnan, ion exchange, liquid junctions, water dissociation | [membrane demo](https://markblundeberg.github.io/driftlet/demos/membrane.html), [`equilibrium`](test/equilibrium.test.js), [`neutral`](test/neutral.test.js) tests | Donnan partition, Planck EMF |
@@ -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.7.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
58
+ `https://cdn.jsdelivr.net/npm/driftlet@0.9.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
 
@@ -204,7 +204,9 @@ current. Supported physics:
204
204
 
205
205
  ## What it doesn't do
206
206
 
207
- - **More than one dimension.** Planar 1D only, so no porous-electrode (P2D) models. For 2D/3D,
207
+ - **More than one dimension.** 1D only: planar, or a cross-section varying along x (spheres,
208
+ cylinders, a thinning film), so no porous electrodes with diffusion inside their particles
209
+ (P2D models). For 2D/3D,
208
210
  see [ChargeTransport.jl](https://github.com/WIAS-PDELib/ChargeTransport.jl), TCAD tools or
209
211
  COMSOL.
210
212
  - **Cross-diffusion.** Each species moves down its own μ̄ with its own D (the statistics can be
@@ -259,6 +261,10 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
259
261
  | Advection and eddy mixing | exact convection–diffusion profile; `D + D_mix`; junction EMF unchanged by mixing | 1e-12; 1e-12; 1e-5 |
260
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 |
261
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
+ | 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 |
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 |
262
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 |
263
269
  | Conservation | per step, and against time-integrated contact fluxes | 1e-11 relative |
264
270
  | Strictly neutral limit (ε = 0) | Planck EMF; polarization with no overlimiting; Donnan at neutral faces | 1e-5; 1e-4; 1e-9 |
package/docs/device.md CHANGED
@@ -24,6 +24,7 @@ too (`FARADAY`, `GAS_CONSTANT`, `EPS0`, …).
24
24
  | `contacts` | `{ left, right }` |
25
25
  | `ports` | internal ports: outside phases exchanging with a window of nodes |
26
26
  | `grid` | default spacing for every region |
27
+ | `geometry` | the cross-section $`A(x)`$: planar (default), spherical or cylindrical shells, or a profile (see [geometry](#geometry)) |
27
28
 
28
29
  ## Species
29
30
 
@@ -445,7 +446,16 @@ contact: held at a voltage, behind a resistance, or driven by a current (a refer
445
446
  is a port at `I: 0`). Each solution reports `ports[k]`, with `{ name, V, flux, current }`: what
446
447
  the port brings into the device.
447
448
 
448
- A held (`'equilibrium'`) level leaves the device's two end nodes to their contacts.
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`$).
449
459
 
450
460
  A port driven by a current (`I`, or a waveform) floats to whatever voltage delivers it: with a
451
461
  conductance `G` per volume over a window of width $`w`$, it sits about $`I/(G w)`$ above the
@@ -467,6 +477,128 @@ steady state can be computed there. A port holding the electrons beside the oxid
467
477
  channel's potential anchors it, as source and drain would, and the device then shows the
468
478
  low-frequency C–V.
469
479
 
480
+ ### Electrodes spread through a window
481
+
482
+ A port can also hold an electrode: a metal whose carrier sits at the port's level, reacting all
483
+ through the window rather than at one face. That's the floor under a thin film of electrolyte, the
484
+ walls of a crevice or a pit, or the matrix of a porous electrode, wherever the metal conducts well
485
+ enough to be one level. Give the port its `reactions` (Butler–Volmer, as at a face), the carrier as
486
+ its `terminal` (the species need be in no material), and the electrode's `area` per volume of the
487
+ window:
488
+
489
+ ```js nocheck
490
+ ports: [{
491
+ name: 'iron', region: 'film', terminal: 'e-', V: 0,
492
+ area: 1 / 100e-6, // m²/m³: a film 100 µm thick on the metal; or a profile against x
493
+ reactions: [
494
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
495
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.13e3 }, k0: 3e-9, alpha: 0.125 },
496
+ ],
497
+ }]
498
+ ```
499
+
500
+ Each node's rate per area is the face's law, with the region's species at that node and the
501
+ 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[j]` (m) and `ports[k].area[j]`
508
+ (m²/m³) for the window's node j, and `ports[k].rates[q][j]` for reaction q there (mol/(m²·s) of
509
+ electrode, forward), so reaction first, then node. They're typed arrays (`Float64Array`), so
510
+ `Array.from` them before mapping to anything but numbers. Per volume of the window it's
511
+ `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
512
+
513
+ Validated against the transmission line (linear kinetics along a bar, test/ports.test.js) and the
514
+ Wagner–Traud mixed potential of two Butler–Volmer couples. In 1D, a film on a metal is a slice
515
+ along the metal: the film's thickness enters through `area`, and through a bulk reaction for
516
+ anything that reaches the film from above (O₂ from the air, see the exchange link above). Where
517
+ the film's thickness varies, give the device its [cross-section](#geometry) too, so that ions
518
+ travelling along the film squeeze through where it thins: for a drop on a metal, seen from its
519
+ centre, $`A = 2\pi r\,h(r)`$ with `area` $`1/h(r)`$.
520
+
521
+ The region must be strictly neutral ($`\varepsilon = 0`$), as in porous-electrode theory: each spot's
522
+ double layer, the metal's countercharge, is below the grid, so the reactions can't leave the
523
+ solution charged.
524
+
525
+ ### An electrode's surface: adsorbates and passive films
526
+
527
+ An electrode port can carry species on its surface, sharing its sites: an adsorbed intermediate,
528
+ or a passive film taken as a coverage. Each has a coverage $`\theta`$ at every node of the window,
529
+ Langmuir statistics, $`\mu = \mu^\circ + RT\ln(\theta/\theta_0)`$ with $`\theta_0 = 1 - \sum\theta`$ the
530
+ bare fraction, and `capacity` $`\Gamma`$, mol of sites per m² of electrode. Reactions name them like
531
+ species, and a reaction with `bare: true` runs only on bare metal: its rate is multiplied by
532
+ $`\theta_0`$, both ways, so equilibrium stays exact. Written for adsorption itself, that's Langmuir
533
+ kinetics (on at a rate ∝ $`\theta_0`$, off ∝ $`\theta`$).
534
+
535
+ ```js nocheck
536
+ ports: [{
537
+ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1 / 100e-6,
538
+ surface: { 'Fe(OH)2': { mu0: -490e3, capacity: 2e-5, theta0: 1e-6 } }, // theta0: the start
539
+ reactions: [
540
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5, bare: true }, // dissolves where bare
541
+ { equation: 'Fe(s) + 2 OH- = Fe(OH)2 + 2 e-', fixed: { 'Fe(s)': 0 }, k0: 1e-6, alpha: 0.5, bare: true }, // the film forms
542
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.13e3 }, k0: 3e-9, alpha: 0.125 }, // on film or not
543
+ ],
544
+ }]
545
+ ```
546
+
547
+ A film forms where the potential and the pH favour it, through its own thermodynamics, and blocks
548
+ what needs bare metal: the dissolution current climbs with potential, peaks, and falls as the film
549
+ covers the metal, the active–passive curve. Each node's surface stores
550
+ $`\Gamma\,d\theta/dt`$ per area of electrode, so filling it passes a current, and a solution reports
551
+ `ports[k].coverage[name][j]`. It's a monolayer picture: a real passive oxide is nanometres thick,
552
+ grows, and breaks down under chloride, none of which a coverage has.
553
+
554
+ Two cautions. Keep a film's stability within reason: blocking the bare fraction to 1e-3–1e-6 is
555
+ already a passive metal, and far below that ($`\theta_0 \lesssim 10^{-10}`$) the coverage's η barely
556
+ affects anything, so Newton struggles with it. And an electrode port has no double-layer
557
+ capacitance (its solution is strictly neutral), so its potential follows its kinetics instantly.
558
+ With a passivating film, current against potential has the active–passive peak, and when the
559
+ last active patch covers over, the potential where no current flows can vanish from the active
560
+ branch and must jump to the passive one. A real electrode makes that jump quickly but
561
+ continuously, through its double layer; here it's a discontinuity, and a transient stops there.
562
+ Give the port a [capacitance](#a-capacitance-through-a-window-a-gate-along-a-channel-a-double-layer) and the jump becomes a quick, continuous swing.
563
+
564
+ Validated in test/coverage.test.js: the Langmuir isotherm against the electrode's potential (to
565
+ 1e-9), the charge to fill the surface, $`F\,\Gamma\,\Delta\theta`$ per area of electrode, and the
566
+ active–passive curve with blocking exactly $`(1-\theta)`$ times Butler–Volmer.
567
+
568
+ ### A capacitance through a window: a gate along a channel, a double layer
569
+
570
+ A port can hold charge across a capacitance spread through its window: per area of electrode,
571
+ $`\sigma = C\,(V - \mathtt{zeroCharge} - \phi)`$ on the port's side (C in F/m²), with `area` per volume
572
+ as for reactions, and the window holds the opposite. Each node's charge balance gains $`a\sigma`$:
573
+ Gauss's law averaged across the section,
574
+ $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$, and at $`\varepsilon = 0`$ neutrality
575
+ $`\rho + a\sigma = 0`$, the mobile species rearranging to supply it (counter-ions in, co-ions out).
576
+ The port passes the charging current, $`d(\int a\sigma\,dV)/dt`$, none in a steady state. Solutions
577
+ report `ports[k].sigma` at each node of the window and the total `ports[k].charge`.
578
+
579
+ ```js nocheck
580
+ // a thin-film transistor: the gate along a 10 nm channel through 1 mF/m² (35 nm of SiO₂)
581
+ ports: [{ name: 'gate', region: 'channel', V: 1.5, area: 1 / 10e-9, capacitance: { C: 1e-3, zeroCharge: 0.3 } }]
582
+ // a porous electrode's double layer, alongside its reactions
583
+ ports: [{ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1e4, capacitance: { C: 0.2, zeroCharge: -0.4 }, reactions: [/* … */] }]
584
+ ```
585
+
586
+ That's the gradual-channel approximation of a field-effect transistor, exact for a thin film
587
+ with no body (a TFT, an organic or oxide transistor) while the channel is long against the gate
588
+ dielectric, and failing where it pinches off (where the 2D field takes over). It's also the
589
+ Bernards model of an organic electrochemical transistor (a volumetric capacitance), cable theory
590
+ for an axon's membrane, and a porous electrode's double layer, which lets an electrode port's
591
+ potential move continuously: without it, a passivating film that covers the last active patch
592
+ forces a jump (see above). The gate's flat level pins the channel's standard level through C, as
593
+ a blocked spectator's flat level does in an electrolyte, and the current saturates as the
594
+ carriers run out at the drain, in both.
595
+
596
+ Validated in test/capacitance.test.js: a thin-film transistor's current against the
597
+ charge-sheet model, $`I/W = (\mu/L)[(\sigma_s^2 - \sigma_d^2)/(2C) + V_T(\sigma_s - \sigma_d)]`$, from
598
+ below threshold through saturation (second order in the grid); a porous electrode's de Levie
599
+ impedance $`\sqrt{r/y}\coth(L\sqrt{ry})`$, the double layer in series with the ions' chemical
600
+ capacitance; and the gate's low-frequency impedance against $`dQ/dV_G`$ from steady states.
601
+
470
602
  ## Terminals
471
603
 
472
604
  The two contacts and every port are the device's terminals, named `left`, `right` and by each
@@ -521,6 +653,34 @@ cells exceeds the limiting current by 1.4% at 0.5 V, while `{ hmin: 10e-9, hmax:
521
653
  within 0.1% with as many nodes. Where a species carrying the current is steep across a region's
522
654
  end cells in this way, the solution's `warnings` say so, with a rough estimate of the excess.
523
655
 
656
+ ## Geometry
657
+
658
+ A device is planar unless it says otherwise: a slab whose cross-section $`A`$ is 1 m², so every
659
+ current, flux, amount and charge reads per m². With `geometry`, the cross-section varies along x,
660
+ and the conservation laws hold for totals through it: a box holds $`\int A\,dx`$, and what crosses
661
+ a face is the flux there times $`A`$ there.
662
+
663
+ ```js nocheck
664
+ geometry: { type: 'spherical', r0: 10e-6 }, // shells, r = r0 + x: a microelectrode of radius r0
665
+ geometry: { type: 'cylindrical', r0: 0 }, // a wire or fibre from its axis (per metre of length)
666
+ geometry: { area: { x: [0, 1e-3], values: [3e-6, 1e-7] } }, // A (m²) against x: a film thinning, a pit
667
+ ```
668
+
669
+ This is how radial problems become 1D, with the right $`1/r^2`$ (or $`1/r`$) in every divergence and in
670
+ Gauss's law, without writing them out. The finite volumes integrate over shells, so conservation
671
+ is exact as in a slab. Each segment's transport is weighted by its length over $`\int dx/A`$, which
672
+ makes steady diffusion between two nodes exact for any $`A(x)`$ (the $`1/r`$ and $`\ln r`$ profiles
673
+ on any grid). Where $`A`$ vanishes at an end (a sphere's centre, `r0: 0`), the segment takes $`A`$ at
674
+ its middle, and nothing passes the end. Only an end may have $`A = 0`$. Flow (`velocity`) is for
675
+ planar devices.
676
+
677
+ With a cross-section, the solution's currents, contact and port fluxes, terminal currents and
678
+ `charge` are totals (A, mol/s; per metre for cylinders), and so are `I` drives. Face laws,
679
+ rates, `interfaces[f].N` and `D`, and concentrations stay per area or per volume. Validated against
680
+ steady diffusion to a sphere and a cylinder (exact), Cottrell's transient with the spherical term,
681
+ uptake by a sphere filling from its surface, and Debye–Hückel screening around a charged sphere
682
+ (test/geometry.test.js).
683
+
524
684
  ## Using a device
525
685
 
526
686
  ```js nocheck
@@ -610,14 +770,14 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
610
770
  | `phi` | bookkeeping $`\phi`$, V (`NaN` where undefined) |
611
771
  | `c[name]`, `mu[name]`, `muStd[name]` | concentration, $`\bar\mu`$, standard level $`\mu^\circ + zF\phi`$ (`NaN` where absent) |
612
772
  | `V[name]`, `Vstd[name]` | species voltage $`\bar\mu/(zF)`$ and standard level as a voltage (charged species) |
613
- | `current`, `terminalVoltage` | current toward +x through the device (A/m²) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
773
+ | `current`, `terminalVoltage` | current toward +x through the device (A/m², or A through a [cross-section](#geometry)) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
614
774
  | `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
615
775
  | `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
616
776
  | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
617
777
  | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
618
778
  | `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)) |
619
779
  | `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 |
620
- | `charge` | total charge in the device, C/m² |
780
+ | `charge` | total charge in the device, C/m² (C through a cross-section) |
621
781
  | `conservation` | per species stretch: amount, reference, intake through contacts, drift |
622
782
  | `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, and for a failed solve, where the system is nearly singular |
623
783
  | `converged`, `iterations`, `steps`, `substeps`, `history`, `time` | solver bookkeeping |
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
 
@@ -82,7 +82,7 @@ $`\mu_{\mathrm{H_2}} = 0`$, so its standard level is $`\phi + \mu^\circ_{\mathrm
82
82
 
83
83
  ```js
84
84
  import { Device } from 'driftlet';
85
- import { build, layer, ohmic, half, level } from 'driftlet/kit';
85
+ import { build, layer, ohmic, half, level, polarization } from 'driftlet/kit';
86
86
 
87
87
  const silver = half('Ag+ + e- = Ag(s)', { 'Ag(s)': 0 });
88
88
  const stern = { phi: { type: 'capacitive', C: 0.2 }, zeroCharge: 0.1 }; // an assumed pzc
@@ -108,11 +108,35 @@ const def = build({
108
108
  grid: { hmin: 0.1e-9, hmax: 100e-9, ratio: 1.15 },
109
109
  });
110
110
  console.log(JSON.stringify(def.interfaces[0].reactions[0])); // { left: { e-: -1, Ag(s): 1 }, right: { Ag+: -1 }, … }
111
- const sol = new Device(def).solve();
111
+ const dev = new Device(def), sol = dev.solve();
112
112
  const redox = level(sol, silver); // V_Ag⁺(x) in the solution
113
113
  console.log(`redox level at mid-cell: ${redox[sol.x.length >> 1].toFixed(4)} V`);
114
+ // The left electrode's polarization curve, 0.2 V either side of its own level (see below).
115
+ const metalSide = sol.V['e-'][0];
116
+ const curve = polarization(dev, sol, { face: 0 }, Array.from({ length: 81 }, (_, j) => metalSide - 0.2 + j * 0.005));
117
+ console.log(curve.reactions[0].level, curve.current[40]); // the couple's level here, and the current at the metal's own
114
118
  ```
115
119
 
120
+ ### Polarization curves: an Evans diagram
121
+
122
+ `polarization(device, sol, where, V)` evaluates every electrode reaction at one spot with the
123
+ solution beside it held as `sol` has it and the metal's level set to each `V` (an electron
124
+ voltage, the terminals' convention). It works at a face (`{ face: f }`, the face's metal side) or
125
+ at a spot of an [electrode port](device.md#electrodes-spread-through-a-window)
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
+ current (A/m², anodic positive: into the solution) and its `level`, where the rate vanishes (the
128
+ couple's redox level there, as `level()` gives it). The net current is their sum. Plotting
129
+ log|current| against V gives the Evans diagram. The net crosses zero at the spot's open-circuit
130
+ potential, between the couples' levels, and the metal's actual level is where the solve put it.
131
+ The law is the solver's own, so at that level the rates are the solution's. Against SHE, subtract
132
+ the SHE level (`sol.phi` with the usual tables' ions).
133
+
134
+ The example above ends with the left electrode's curve.
135
+
136
+ Concentrations are held, so these are the curves of that instant (for a mixed potential, the
137
+ kinetic picture). Away from the actual level the composition beside the electrode would change,
138
+ which is the transport the full solve accounts for.
139
+
116
140
  ## Sources
117
141
 
118
142
  Helpers that write sources as plain data:
package/docs/numerics.md CHANGED
@@ -117,6 +117,22 @@ Grids grade geometrically from `hmin` at each region end up to `hmax`. They're n
117
117
  automatically, to avoid performance surprises. Instead, solutions warn when a double layer the
118
118
  model is meant to resolve is coarser than the local Debye length.
119
119
 
120
+ ### A cross-section
121
+
122
+ With a [`geometry`](device.md#geometry), the device has a cross-section $`A(x)`$ (spherical
123
+ shells $`4\pi r^2`$, cylindrical $`2\pi r`$ with $`r = r_0 + x`$, or a profile), and every balance
124
+ is for totals through it:
125
+ $`\partial_t (A c_i) + \partial_x (A N_i) = A \sum_r \nu_{ir} r_r`$, and Gauss's law
126
+ $`-\partial_x (A\varepsilon\,\partial_x\phi) = A\rho`$. The box method carries this without writing out any
127
+ $`1/r`$ terms: a box's volume becomes $`\int A\,dx`$ over it (exactly, for each shape), and what a
128
+ segment, a face or a contact passes is multiplied by the area it passes through. A segment's
129
+ $`1/h`$ becomes $`1/\int dx/A`$ over it, its resistance per unit conductivity, which is the exact
130
+ weighting for steady diffusion (a constant total flux $`AN`$, so $`\Delta c = AN\int dx/A\,/D`$).
131
+ Scharfetter–Gummel keeps its form with that weighting, and so does the displacement. Where $`A`$
132
+ vanishes at an end (a sphere's centre), $`\int dx/A`$ diverges, so that segment takes $`A`$ at its
133
+ middle. A flux node's unknowns stay per area, entering each side's balance times the face's
134
+ area. A planar device has $`A = 1`$ and every factor is exactly 1, so it's unchanged to the last bit.
135
+
120
136
  ### What lives where
121
137
 
122
138
  | Place | Holds |
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.7.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -50,7 +50,7 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
50
50
  V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
51
51
  `level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
52
52
  `injector`, `recombination`, `photogeneration`), the data library (`aqueous`, `semiconductor`,
53
- `metal`, `IONS`, …), `check` (a solution's checks), `henderson` and `planck` (liquid-junction potentials), `recorder` (a transient's trace and frames across calls), the curated
53
+ `metal`, `IONS`, …), `check` (a solution's checks), `polarization` (an electrode's reactions at hypothetical metal levels, with the solution held: an Evans diagram), `henderson` and `planck` (liquid-junction potentials), `recorder` (a transient's trace and frames across calls), the curated
54
54
  `perovskiteCell` with IonMonger's `PEROVSKITE_SCANS` and `hysteresis`, and the vacuum-level helpers. `driftlet/plot` (a quick look; `traces()` is the data for your own charts): `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`), `themeColor`.
55
55
  - **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
56
56
  `new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
@@ -319,9 +319,9 @@ solve), as long as it settles rather than ping-pongs.
319
319
  <span id="readout"></span>
320
320
  <div id="figure"></div>
321
321
  <script type="module">
322
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/index.js';
323
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/kit.js';
324
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/plot.js';
322
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/index.js';
323
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/kit.js';
324
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/plot.js';
325
325
 
326
326
  const def = build({
327
327
  T: 300,
@@ -405,11 +405,16 @@ has the long form):
405
405
  do. It checks the setup: a solver's output looks authoritative even when an input is wrong, and
406
406
  a mismatch is the cheapest way to catch it. Where the two should differ (high injection, a
407
407
  finite pulse), say why.
408
- - **Stay honestly inside 1D.** driftlet models planar devices. Where the real device isn't one,
408
+ - **Stay honestly inside 1D.** driftlet models devices along one coordinate: planar, or with a
409
+ cross-section A(x) (`geometry`: spherical or cylindrical shells, or a profile), in which case
410
+ currents and fluxes are totals through it. Where the real device isn't one,
409
411
  say so and pick the slice that still tells the true story: a MOSFET's gate stack, read as the
410
412
  MOS capacitor its channel sits on (pinch-off along the channel is 2D); a BJT along its
411
413
  emitter–base–collector axis; one particle of a porous battery electrode, or a thin dense film,
412
- rather than the whole porous electrode (that's a P2D model).
414
+ rather than the whole porous electrode with diffusion inside its particles (that's a P2D model).
415
+ A metal that reacts all along a film of electrolyte (corroding iron under a drop, a crevice's
416
+ walls, a well-conducting porous matrix) is a port with `reactions` and an `area` per volume:
417
+ an electrode spread through a window, not a face at each end.
413
418
  - **Check `sol.converged` and `sol.warnings`** on every solve, and pin the CDN version you
414
419
  tested against.
415
420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
package/src/checks.js CHANGED
@@ -65,7 +65,7 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
65
65
  // The rest checks a solution, which an unconverged state isn't.
66
66
  if (sol.converged) {
67
67
  const ledger = steady ? balance(device, sol) : null;
68
- items.push(ledger ?? conservation(sol));
68
+ items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
69
69
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
70
70
  }
71
71
 
@@ -164,26 +164,27 @@ function balance(device, sol) {
164
164
  // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
165
165
  // flows are round-off of these.
166
166
  function naturalScales(device, sol) {
167
- const { model, solver } = device, { species } = model;
168
- const box = (g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? sol.x[g] - sol.x[g - 1] : 0) + (g + 1 < sol.x.length && sol.region[g + 1] === sol.region[g] ? sol.x[g + 1] - sol.x[g] : 0)) / 2;
167
+ const { model, solver } = device, { species, grid } = model;
168
+ // (totals through the cross-section, as the ledgers are: its largest in each region)
169
+ const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
169
170
  return species.map((sp, i) => {
170
171
  let S = 0;
171
172
  model.regions.forEach((reg, r) => {
172
173
  const mat = model.materials[reg.material], L = reg.length;
173
174
  if (mat.conductor) {
174
- if (mat.conductor.i === i) S = Math.max(S, (mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
175
+ if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
175
176
  return;
176
177
  }
177
178
  if (!mat.present[i] || !(mat.D[i] > 0)) return;
178
179
  let cmax = 0;
179
180
  for (let g = 0; g < sol.x.length; g++) if (sol.region[g] === r && Number.isFinite(sol.c[sp.name][g])) cmax = Math.max(cmax, sol.c[sp.name][g]);
180
- S = Math.max(S, (mat.D[i] * cmax) / L);
181
+ S = Math.max(S, (area[r] * mat.D[i] * cmax) / L);
181
182
  });
182
183
  solver.rxs.forEach((rx) => {
183
184
  const p = rx.sp.indexOf(i);
184
185
  if (p < 0) return;
185
186
  let gross = 0;
186
- for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += box(g) * solver.bulkOneWay(rx, g);
187
+ for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += grid.vol[g] * solver.bulkOneWay(rx, g);
187
188
  S = Math.max(S, Math.abs(rx.nu[p]) * gross);
188
189
  });
189
190
  return S;
@@ -191,12 +192,12 @@ function naturalScales(device, sol) {
191
192
  }
192
193
 
193
194
  // Closed, unreacting stretches keep their amounts in a transient.
194
- function conservation(sol) {
195
+ function conservation(sol, area = 1) {
195
196
  const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
196
197
  if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
197
198
  // Relative to the amount, but a drift below 1e-20 mol/m² (1e-15 C/m²) is nothing: a floating
198
- // metal's excess electrons are themselves ~0.
199
- const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 ? 0 : Math.abs(st.drift))));
199
+ // metal's excess electrons are themselves ~0. (Amounts are totals through the cross-section.)
200
+ const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 * area ? 0 : Math.abs(st.drift))));
200
201
  return {
201
202
  name: 'conservation',
202
203
  ok: worst < 1e-8,
package/src/describe.js CHANGED
@@ -125,6 +125,11 @@ export function describe(def) {
125
125
  const warnings = unitWarnings(def);
126
126
  const total = grid.x[grid.x.length - 1] - grid.x[0];
127
127
  lines.push(`${regions.length} region${regions.length > 1 ? 's' : ''}, ${si(total, 'm')}, ${grid.x.length} nodes, T = ${model.T} K`);
128
+ const geo = model.geometry;
129
+ if (geo.type !== 'planar') {
130
+ const shape = geo.type === 'profile' ? `a cross-section from ${num(Math.min(...grid.area))} to ${num(Math.max(...grid.area))} m²` : `${geo.type} shells, r = ${si(geo.r0, 'm')} + x${geo.type === 'cylindrical' ? ' (per metre of length)' : ''}`;
131
+ lines.push(`geometry: ${shape}; currents and fluxes are totals through it (A, mol/s)`);
132
+ }
128
133
  lines.push(`species: ${species.map((sp) => `${sp.name} (z = ${sp.z > 0 ? '+' : ''}${sp.z})`).join(', ')}`);
129
134
 
130
135
  lines.push('regions:');
@@ -206,7 +211,15 @@ export function describe(def) {
206
211
  const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
207
212
  lines.push(` ${side}: ${drive(cdef)}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
208
213
  }
209
- (def.ports ?? []).forEach((p, k) => lines.push(` port ${model.ports[k].name}: ${drive(p)}, in ${regions[model.ports[k].region].name}`));
214
+ (def.ports ?? []).forEach((p, k) => {
215
+ const port = model.ports[k];
216
+ lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
217
+ // an electrode spread through the window
218
+ if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
219
+ for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
220
+ if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
221
+ if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
222
+ });
210
223
  if ((def.bulkReactions ?? []).length) {
211
224
  lines.push('bulk reactions:');
212
225
  for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);