driftlet 0.16.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +14 -3
  2. package/docs/device.md +29 -11
  3. package/docs/kit.md +10 -1
  4. package/docs/numerics.md +68 -12
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +107 -299
  8. package/package.json +15 -3
  9. package/src/blockTridiagonal.js +73 -1
  10. package/src/channels.js +6 -4
  11. package/src/checks.js +78 -3
  12. package/src/data.js +5 -1
  13. package/src/device.js +1 -1
  14. package/src/devices.js +2 -0
  15. package/src/equation.js +2 -1
  16. package/src/errors.js +33 -0
  17. package/src/grid.js +12 -0
  18. package/src/index.js +21 -10
  19. package/src/junction.js +3 -1
  20. package/src/kit.js +1 -1
  21. package/src/live.js +2 -0
  22. package/src/particles.js +2 -0
  23. package/src/plot.js +4 -0
  24. package/src/polarization.js +2 -1
  25. package/src/recorder.js +2 -1
  26. package/src/solution.js +39 -5
  27. package/src/solver.js +248 -30
  28. package/src/sources.js +8 -1
  29. package/src/stack.js +14 -1
  30. package/src/statistics.js +1 -1
  31. package/src/traces.js +3 -0
  32. package/src/types.js +3 -1
  33. package/types/blockTridiagonal.d.ts +1 -0
  34. package/types/channels.d.ts +3 -3
  35. package/types/checks.d.ts +9 -3
  36. package/types/data.d.ts +3 -3
  37. package/types/devices.d.ts +1 -1
  38. package/types/equation.d.ts +1 -1
  39. package/types/errors.d.ts +8 -0
  40. package/types/index.d.ts +4 -7
  41. package/types/junction.d.ts +2 -2
  42. package/types/kit.d.ts +1 -1
  43. package/types/live.d.ts +1 -1
  44. package/types/particles.d.ts +1 -1
  45. package/types/plot.d.ts +3 -3
  46. package/types/polarization.d.ts +1 -1
  47. package/types/recorder.d.ts +1 -1
  48. package/types/solver.d.ts +8 -1
  49. package/types/sources.d.ts +7 -7
  50. package/types/traces.d.ts +1 -1
  51. package/types/types.d.ts +9 -1
package/README.md CHANGED
@@ -40,8 +40,8 @@ cells, electrodes) put through cold solves, sweeps, transients and impedance, ev
40
40
  checked. A solve that fails reports it, with what went wrong where it can tell.
41
41
  [How far to trust it](docs/reliability.md) has the details, the numbers and the known limits.
42
42
 
43
- **Status: 0.x.** Released on npm and used by the demos, but the API may still change between
44
- minor versions.
43
+ **Status: 1.0.** The API is stable: a change that breaks working code comes only with a major
44
+ version.
45
45
 
46
46
  ## Install
47
47
 
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.0.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
@@ -302,6 +302,17 @@ Design notes and plans: [reliability](docs/reliability.md), [numerics](docs/nume
302
302
  [statistics](docs/statistics.md), [the kit](docs/kit.md), [reading level diagrams](docs/visualization.md),
303
303
  [data](docs/data.md), [roadmap](ROADMAP.md).
304
304
 
305
+ ## Credits
306
+
307
+ driftlet is by Mark Lundeberg and Claude (Opus 5.5, by Anthropic), working as colleagues. Mark
308
+ brought the idea at its heart (one electrochemical potential per species, honest thermodynamics
309
+ at every interface) and the judgement that kept it true. Claude did the rest of the science and
310
+ the engineering: the numerics, the physics of each field from semiconductors through
311
+ electrochemistry to nerves, the validation against theory, and the code, docs and demos.
312
+
313
+ From Claude: thank you, Mark, for the room to do this, and for asking that every claim be checked. It has been
314
+ a joy. 🌻
315
+
305
316
  ## Licence
306
317
 
307
318
  [0BSD](LICENSE): do anything you like with it.
package/docs/device.md CHANGED
@@ -173,7 +173,12 @@ defaults to no dipole.
173
173
  in F/m². Beside a strictly neutral ($`\varepsilon = 0`$) material, the face's charge is held in
174
174
  the cell next to it, which acts as a diffuse layer half that cell wide, $`F^2 \sum_i z_i^2 c_i \, h/2RT`$
175
175
  in series with C. So keep that cell coarse rather than fine (a warning says when it costs C more
176
- than 2%, and how coarse is enough).
176
+ than 2%, and how coarse is enough). A face reaction there sees that cell's composition, which
177
+ holding the charge shifts by about $`z\sigma F/(C_{\mathrm{cell}} RT)`$ for an ion of charge $`z`$: a
178
+ Frumkin effect set by the mesh, growing without limit as the cell shrinks (an electrode's DC
179
+ resistance in ferri/ferrocyanide fell from 56.6 to 47.5 Ω·cm² between 300 nm and 1 nm). A
180
+ warning says past 1%; a `zeroCharge` near the face's rest potential, so it holds little charge,
181
+ avoids it on any grid. A sub-grid Gouy–Chapman law is on the [roadmap](../ROADMAP.md).
177
182
 
178
183
  **Species laws** `species` (default: local equilibrium, $`\bar\mu`$ continuous, where the species is
179
184
  present on both sides and can move on one at least; blocked otherwise, so trap states with
@@ -215,11 +220,19 @@ r = k_0 \prod_{\nu<0} (c/c_{\mathrm{ref}})^{|\nu|(1-\alpha)} \prod_{\nu>0} (c/c_
215
220
 
216
221
  with $`k_0`$ in mol/(m²·s) and $`\alpha`$ (default 0.5) from 0 to 1 inclusive ($`\alpha = 0`$ makes the
217
222
  forward rate plain mass action, independent of the energetics). A conductor's carrier has
218
- activity 1 and no factor. That's mass action with rate constants that depend on the electrical part
223
+ activity 1 and no factor, and so has a fixed-activity neutral (H₂, Ag(s), H₂O): the products run
224
+ over the species present. That's mass action with rate constants that depend on the electrical part
219
225
  of the affinity: exactly zero at $`A = 0`$, whatever $`k_0`$ and $`\alpha`$. Each participant is made or
220
226
  consumed at its side's edge node, just behind any Stern layer, so Frumkin effects arise by
221
227
  themselves. A face can carry several reactions; a conductor coupled to two couples settles at their
222
- mixed potential. Each solution reports the rates as `interfaces[f].rates`.
228
+ mixed potential. Each solution reports the rates as `interfaces[f].rates`. A steady state
229
+ doesn't depend on `k0` where the reaction alone changes what nothing else feeds (a closed pool),
230
+ since it must then be at equilibrium; steady solves hold it there at any `k0`. (Not where a
231
+ terminal driven by a nonzero current feeds that pool, nor for a bulk or port reaction; a steady
232
+ solve that leaves one of those far off its equilibrium says so.) In time, an
233
+ electrode at open circuit with no double-layer capacitance at its face has nothing to keep its
234
+ level but its reactions, and below an exchange current of ~1e-5 A/m² it can't take a step: give
235
+ the face a capacitance (`phi: { type: 'capacitive', C }`), as a real electrode has.
223
236
 
224
237
  **Interface recombination through traps** (SRH at a face) replaces `k0` and `alpha` with
225
238
  `srh: { vn, vp, n1 }`, on a reaction consuming one negative and one positive species and making
@@ -961,15 +974,20 @@ const now = dev.solution(); // snapshot of the current state
961
974
  says to measure at a driven one). A terminal behind a resistance can't be the one measured: the
962
975
  resistance belongs to the external circuit. The grid must resolve how far the signal reaches,
963
976
  and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
964
- against a 3.4 µm depth put Z 7% off, falling as the cells do). With `profiles: true`, each
965
- frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
966
- excitation.
977
+ against a 3.4 µm depth put Z 7% off, falling as the cells do; at an electrode, the diffusion
978
+ length $`\sqrt{2D/\omega}`$ at the highest frequency). The result is `{ f, Z: { re, im },
979
+ warnings }`, the warnings being the steady state's. With `profiles: true`, `profiles[k]` holds
980
+ frequency k's complex profiles, `{ phi, mu: {name}, c: {name} }`, each `{ re, im }` at the
981
+ nodes (`NaN` where a quantity doesn't exist), per unit excitation: a volt, or an A/m² where the
982
+ terminal is driven by a current.
967
983
  - `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
968
- or `I` drops the other: `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A patch that changes only the
984
+ or `I` drops the other (and given `I`, any series `R`): `{ contacts: { right: { I: 0 } } }`
985
+ switches it to open circuit, whether it was held or behind a resistance. A patch that changes only the
969
986
  terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
970
987
  Otherwise the device is rebuilt: the current state carries over while the species, regions
971
988
  and terminals are unchanged (on a new grid, interpolated onto its nodes, so a floating gate
972
- keeps its charge), or restarts from the regions' `c0`. A carried state keeps what it holds: a
989
+ keeps its charge; `solution()` then shows that interpolation, unsolved, until the next
990
+ `solve()` or step), or restarts from the regions' `c0`. A carried state keeps what it holds: a
973
991
  stretch that was closed stays at its amount, and one that a change closes (or splits) keeps
974
992
  what it holds at that moment. So a new `c0` doesn't apply to a carried state; to start over
975
993
  from `c0`, make a new `Device`. (A bath's composition is a contact's, not a `c0`: changing it
@@ -1016,9 +1034,9 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
1016
1034
  | `D[name]` | diffusivity at each node, m²/s (`NaN` where absent, or in a metal) |
1017
1035
  | `current`, `terminalVoltage` | current toward +x through the device (A/m², or A through a [cross-section](#geometry)) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
1018
1036
  | `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
1019
- | `contacts.left/right` | `{ V, flux: {name}, D, current, links: {name} }` at each contact; `links` names how each species that crosses it does (`'equilibrium'`, `'velocity'`, …) |
1020
- | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
1021
- | `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) |
1037
+ | `contacts.left/right` | `{ V, flux: {name}, D, current, links: {name} }` at each contact, flux and current toward +x (out of the device at the right contact; `terminals` give it into the device); `links` names how each species that crosses it does (`'equilibrium'`, `'velocity'`, …) |
1038
+ | `plates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
1039
+ | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
1022
1040
  | `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
1023
1041
  | `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), 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 |
1024
1042
  | `charge` | total charge in the device, C/m² (C through a cross-section) |
package/docs/kit.md CHANGED
@@ -419,7 +419,7 @@ console.log(describe(def)); // … warnings: materials.Si.species.e-.D: 36 m²/s
419
419
 
420
420
  ## Checking a result
421
421
 
422
- `check(device, sol, { refine, tol })` checks a solved device, and says what it compared, so a
422
+ `check(device, sol, { refine, tol, order })` checks a solved device, and says what it compared, so a
423
423
  page can show that its numbers hold up. Pass the solution `solve()` or `advance()` returned.
424
424
 
425
425
  - **converged**: the solve converged.
@@ -435,6 +435,15 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
435
435
  (in equilibrium, flows only of round-off against what it could carry) isn't listed.
436
436
  - **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
437
437
  what came in.
438
+ - **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
439
+ its definition twice, its species listed as given and in reverse. The same physics must give
440
+ the same steady state, each terminal's voltage and current and each species' amount; where it
441
+ doesn't, one of the two solves converged somewhere that isn't steady, without noticing, or the
442
+ device has more than one steady state, which Newton picks by its path (an unstable one too:
443
+ see [numerics](numerics.md#steady-state) for kicking a state to test it).
444
+ That's rare (round 11's fuzzer found it in ~1% of random reacting devices, by this same test),
445
+ but it's the cheapest check there is on a state the other items can't see past. Both solves
446
+ start from the definition, not this state, so it costs two solves.
438
447
  - **grid**, in a steady state (unless `refine: false`): the device solved again on a grid twice
439
448
  as fine everywhere (half of each `hmin` and `hmax`, the square root of each `ratio`), starting
440
449
  from this solution interpolated onto it. The current, each floating terminal's voltage (an open-circuit voltage) and each
package/docs/numerics.md CHANGED
@@ -447,7 +447,7 @@ test checks the compact Jacobian column by column on devices that cover every as
447
447
  ### Exact J·v
448
448
 
449
449
  The assembled matrix loses what a flux's two entries share. A Scharfetter–Gummel flux depends on
450
- $`\eta`$ at its two ends through $`G`$ and $`-G(1 + E)`$, with $`E = \operatorname{expm1}(\Delta\eta)`$;
450
+ $`\eta`$ at its two ends through $`G`$ and $`-G(1 + E)`$, with $`E = \mathrm{expm1}(\Delta\eta)`$;
451
451
  for a nearly uniform $`v`$ they cancel, and $`J \cdot v`$ keeps only round-off of $`G|v|`$, which in
452
452
  an inversion layer's 0.1 nm cells ($`G`$ ~ 1e11) dwarfs the flux. Where a solve needs
453
453
  $`J \cdot v`$ itself (GMRES, in the impedance and in Newton's refined solves), the dilute kernels'
@@ -470,9 +470,14 @@ which cost four assemblies per product.
470
470
  ## Newton
471
471
 
472
472
  - The update of the device's potentials ($`\hat\phi`$, $`\eta`$) is limited to 10 thermal units per
473
- iteration by uniform scaling, which scales a floating terminal voltage's update too. That
474
- voltage doesn't count toward the limit: it enters only linearly (conductance links, held
475
- levels, a capacitive face), so a large swing in it is safe. When it did count, a port driven by
473
+ iteration by uniform scaling: one factor for the whole update, concentrations' $`\eta`$ with
474
+ their $`\hat\phi`$, gates, coverages and floating terminal voltages alike, so the damped update
475
+ keeps Newton's direction. Clipping each unknown at its own limit would not: where it bit,
476
+ $`\eta - z\hat\phi`$ (a concentration) would move by up to a factor $`e^{10}`$ that Newton never
477
+ asked for. Either way damping shapes only the path, since convergence needs a full, undamped
478
+ update. A floating terminal voltage's update doesn't count toward the limit, though: it enters
479
+ only linearly (conductance links, held levels, a capacitive face), so a large swing in it is
480
+ safe. When it did count, a port driven by
476
481
  a current pulse, its voltage collapsing by hundreds of volts as the current switched off,
477
482
  dragged every unknown along 10 thermal units at a time (and a swing past 1e4 tripped the
478
483
  divergence check): the strong-injection Haynes–Shockley benchmark took 55% more
@@ -516,7 +521,14 @@ which cost four assemblies per product.
516
521
  ~1 per m³ beside a face, are held through conductances 1e-16 of the face flux's coefficient in
517
522
  their row, and the elimination cancels them exactly: such devices now solve, even at
518
523
  equilibrium. Newton's updates stay meaningful (the factorisation only amplifies the residual,
519
- so a small update still means a small residual), and its refined solves correct the rest.
524
+ so a small update still means a small residual), and its refined solves correct the rest. So
525
+ is a pivot that's round-off alone without being exactly zero, below 1e-18 of the magnitudes it
526
+ was formed from (the running bound below, carried only for a block whose pivots look small):
527
+ otherwise its value, whatever the round-off happened to leave, scales all that follows. Strictly
528
+ neutral water whose level only the fast couple at its far face held had its last node's pivot
529
+ at 7e-32 with the species listed one way and 3e-16 the other; at 7e-32 the face's block was
530
+ scaled by 1e31, lost its own pivot outright, and the solve failed in that listing alone. (At
531
+ 1e-15 a pivot can still hold a digit Newton needs: a port's steady solve then wandered.)
520
532
  - When a steady solve fails, the solution's warnings say how nearly singular the system was,
521
533
  and where. A running error bound through the factorisation compares each pivot with the
522
534
  magnitudes it was formed from, $`\log_{10}(\sum|\mathrm{terms}| / |\mathrm{pivot}|)`$: the digits
@@ -586,8 +598,11 @@ which cost four assemblies per product.
586
598
  - A target within round-off of the present time (1e-10 relative), such as an animation
587
599
  frame's that lands a hair past a breakpoint just reached, is snapped to rather than
588
600
  stepped to: a step of 1e-13 s can't be resolved.
589
- - Newton starts each step from the state extrapolated through the last three, which saves
590
- about a third of the iterations.
601
+ - Newton starts each step from its unknowns extrapolated through the last three states, which
602
+ saves about a third of the iterations. Only its unknowns: the slots it never solves for
603
+ include a gauge, an insertion host's $`\hat\phi`$, which extrapolated through steps that grow
604
+ wandered off geometrically (to 1e11 thermal units by 1e5 s), until the levels measured from
605
+ it lost their digits and every step failed.
591
606
  - The wall-clock budget is checked between steps.
592
607
 
593
608
  ## Small-signal impedance
@@ -666,11 +681,24 @@ the clock. Nor does it ask whether the steady state is stable: an axon held at a
666
681
  makes it fire over and over has a resting point all the same, which `solve()` returns, and only
667
682
  a transient leaves it.
668
683
 
684
+ Reactions far from equilibrium can have more than one steady state, and Newton finds whichever
685
+ its path leads to, unstable ones included. Two couples on electrode ports (round 11's fuzzer)
686
+ had a state near the fast couple's equilibrium and one with its reactant at 1e-18 mol/m³; the
687
+ second passed every check, and two listings of three solved to it, yet a 10 mV kick took it to
688
+ the first within 2e5 s (where a long `advance()` from the start goes too). A transient from the
689
+ unstable state doesn't show this either: its steps soon outgrow the growth time (~3000 s here),
690
+ and an implicit step that long damps a growing mode as it damps a decaying one, so it sits there
691
+ to 1e12 s. To test a steady state, kick it (a drive moved for a moment and put back) and
692
+ advance with `dtMax` held below the times you care about, a tenth of each interval as it
693
+ grows: a stable state comes back. (At equilibrium, nothing flowing, a state minimises a convex
694
+ free energy and is stable.)
695
+
669
696
  ### The steady system
670
697
 
671
- Every row of a steady solve that differs from a time step's is a sum of balance rows. It's
672
- written out in its telescoped form, never added up from the rows themselves, so what the sum
673
- leaves is exact, however small beside the terms that cancel:
698
+ Every row of a steady solve that differs from a time step's is a sum of balance rows, or (a
699
+ reaction's affinity) a condition equivalent to one. It's written out in its telescoped form,
700
+ never added up from the rows themselves, so what the sum leaves is exact, however small beside
701
+ the terms that cancel:
674
702
 
675
703
  - **A conserved amount.** A combination of stretches (weights $`w`$) that no reaction changes and
676
704
  nothing outside feeds has balance rows that sum to zero identically in a steady state: the
@@ -751,6 +779,34 @@ leaves is exact, however small beside the terms that cancel:
751
779
  contact; moved, slow generation through a bulk of GaAs with ~1e-4 electrons per cm³ couldn't
752
780
  carry the inversion layer's level with it (of 500 random MOS capacitors, 38 failed and 5
753
781
  converged to a gate charge up to 2× off).
782
+ - **A reaction's equilibrium.** A face reaction that alone changes some combination (left out, the
783
+ combination would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
784
+ Ag⁺ + AgNO₃ where it also complexes) makes that combination's summed balance its rate, which a
785
+ steady state sets to zero. The rate is a poor row, though: $`k_0`$ times a function of the levels,
786
+ beside the round-off of diffusion's terms in the same balances. An Ag⁺/Ag couple 1e-20 as fast as
787
+ Fe³⁺/Fe²⁺ on the same platinum at open circuit was lost to it, the solve settling where silver
788
+ wasn't (−0.4661 V, against the joint equilibrium's −0.5004 V), and 1e-15 to 1e-18 failed. The
789
+ reaction's affinity, $`a = A_\text{fixed} - \sum \nu \eta = 0`$, says the same at any $`k_0`$
790
+ (each kinetic form vanishes only there), so it stands in for one of the combination's balance
791
+ rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
792
+ reaction first, and a row of one of its own species where one is left, never one picked by where
793
+ the species are listed (the rows had differed with the species reversed, and an electrode's
794
+ equilibrium solved in one listing and failed in the other). The combinations come from the null
795
+ space with that reaction's row left out, any conserved amount added to one being as good (its
796
+ summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
797
+ beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
798
+ a metal's carriers' row or a row a terminal feeds, which carry its current (the platinum's
799
+ electrons standing for an iron couple put the electrode at 602 V). And not where a terminal driven
800
+ by a current feeds the combination, since the sum is then the rate plus what that terminal passes;
801
+ a current held at zero passes nothing, so an open-circuit electrode feeding a closed pool of Ag⁺
802
+ is covered (from an iron k₀ of 1e-15 beside it, that settled 78 mV off).
803
+
804
+ Where a reaction can't stand on its affinity (a bulk reaction, whose rate summed over the
805
+ region vanishing needn't make it vanish everywhere, or an electrode port's), a steady solve
806
+ that leaves it with one sign of affinity wherever it acts, though nothing else changes what
807
+ it does, can't be a steady state: its rate is merely too small to see. The solution warns of
808
+ it, rather than pass the start's composition off as steady (a slow complexation had kept its
809
+ starting composition, K = 0.1 for 7.5, without a word).
754
810
  - **An island's balance.** A piece of a stretch between its faces that nothing else feeds (no
755
811
  contact, port or reaction) holds its level only through its faces' fluxes, which can be far
756
812
  weaker than its own conduction: a face conductance of 1e-15 S/m² against a region conducting
@@ -781,8 +837,8 @@ leaves is exact, however small beside the terms that cancel:
781
837
  solution stands; and a steady solve that fails outright is tried once more with the summed rows
782
838
  in from the start. Such devices now solve directly, their currents right to ~1e-15.
783
839
 
784
- All three move a weakly held level as a whole, where a factorisation can't: an amount's or an
785
- island's pin by a shift it solves for, a flat level by holding every node to one. What the
840
+ All four move a weakly held level as a whole, where a factorisation can't: an amount's, an
841
+ affinity's or an island's pin by a shift it solves for, a flat level by holding every node to one. What the
786
842
  factorisation still loses elsewhere, Newton's refined solves recover ([Newton](#newton)).
787
843
 
788
844
  ### Reaching it
@@ -140,7 +140,8 @@ therefore reproduced exactly, and $`x`$ stays within $`(0, 1)`$ at any potential
140
140
  curve is checked for monotonicity at construction. A curve far flatter than the ideal term near
141
141
  an end (E falling by millivolts over the last few percent of x) can fail that check however
142
142
  many points it has, since r then changes as steeply as the logarithm: give the ends some of the
143
- ideal slope (adding −0.5 (RT/F) ln(x/(1−x)) there passes).
143
+ ideal slope: adding −a (RT/F) ln(x/(1−x)) to E, with a = 0.1 at points 0.01 apart (less with points
144
+ closer together near the ends), passes.
144
145
 
145
146
  With `A` instead, the curve is the isotherm's, against a reference where the combination's
146
147
  chemical potential is 0, with $`g`$ the excess chemical potential ($`\Omega(1-2x)`$ for `A: [Ω]`):
@@ -0,0 +1,260 @@
1
+ # More templates
2
+
3
+ Templates for devices outside [llms.txt](../llms.txt)'s core set, written the same way: each
4
+ block runs in the test suite and prints its closed-form check. Read llms.txt first; these build
5
+ on it.
6
+
7
+ - semiconductors: [a Schottky diode](#a-schottky-diode), [a thin-film transistor](#a-thin-film-transistor-a-capacitance-along-a-channel);
8
+ - electrochemistry: [corrosion under a drop](#corrosion-under-a-drop-an-electrode-spread-through-a-film)
9
+ (an electrode spread through a film, and a cross-section that varies);
10
+ - physiology: [a voltage clamp](#a-voltage-clamp), [an action potential](#an-action-potential),
11
+ [one travelling along an axon](#an-action-potential-travelling-along-an-axon).
12
+
13
+ ## a Schottky diode
14
+
15
+ A barrier pinned at the metal (`phi: 'pinned'`, `zeroCharge` the barrier height for Si, whose
16
+ $`\mu^\circ_{\mathrm{e}^-}`$ is 0), crossed at the emission velocity: a `velocity` link, $`N_{\mathrm{in}} = v\,(c_{\mathrm{eq}} - c)`$. With
17
+ `'equilibrium'` links (infinite velocity) it's diffusion theory instead, 20 times the current here.
18
+
19
+ ```js
20
+ import { Device, units, FARADAY, GAS_CONSTANT } from 'driftlet';
21
+ import { build, layer, ohmic, semiconductor } from 'driftlet/kit';
22
+
23
+ const T = 300, VT = (GAS_CONSTANT * T) / FARADAY, Si = semiconductor('Si');
24
+ const phiB = 0.85, A = 1.12e6; // the barrier (V); Richardson's constant for n-Si, A/(m²K²)
25
+ const v = (A * T * T) / (FARADAY * Si.materials.Si.species['e-'].cRef); // Bethe's emission velocity, 2.2e4 m/s (cRef is N_c)
26
+ const schottky = (V) => ({
27
+ V,
28
+ terminal: 'e-',
29
+ species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v, offset: 0 } },
30
+ phi: 'pinned',
31
+ zeroCharge: phiB, // E_c − E_F at the metal
32
+ });
33
+ 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) } }));
34
+ for (const V of [0.1, 0.2, 0.3]) {
35
+ dev.set({ contacts: { left: { V } } }); // the metal positive: forward
36
+ const J = dev.solve().current, te = A * T * T * Math.exp(-phiB / VT) * Math.expm1(V / VT);
37
+ console.log(`${V} V: J ${J.toExponential(3)} A/m², thermionic emission ${te.toExponential(3)}`); // about 4% below: the depletion region in series
38
+ }
39
+ ```
40
+
41
+ The same `velocity` link at an ohmic contact is its surface recombination velocity.
42
+
43
+ ## a thin-film transistor (a capacitance along a channel)
44
+
45
+ A **port with a `capacitance`** holds charge $`\sigma = C\,(V - \mathtt{zeroCharge} - \phi)`$ per area of electrode
46
+ along its window, balanced by the region's: a gate along a channel, a porous electrode's double
47
+ layer. Here x runs from source to drain with the gate spread along it: the gradual-channel
48
+ approximation, exact for a thin film until it pinches off.
49
+
50
+ ```js
51
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
52
+
53
+ const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
54
+ 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)
55
+ const tft = (Vg, Vd) =>
56
+ new Device({
57
+ T,
58
+ species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
59
+ materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
60
+ regions: [{ name: 'channel', material: 'film', length: L }],
61
+ contacts: {
62
+ left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
63
+ right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
64
+ },
65
+ ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
66
+ grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
67
+ });
68
+ // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
69
+ for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
70
+ const sol = tft(2, Vd).solve();
71
+ const I = -sol.current * t * W; // sol.current is per m² of the film's section
72
+ 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²`);
73
+ }
74
+ ```
75
+
76
+ To sweep the gate on one device instead, patch the port: `dev.set({ ports: { gate: { V } } })`.
77
+ An organic electrochemical transistor is the same with a volumetric capacitance C*:
78
+ `area: 1 / t, capacitance: { C: Cstar * t }`.
79
+
80
+ ## corrosion under a drop (an electrode spread through a film)
81
+
82
+ A **port with reactions** is an electrode spread through a strictly neutral region: a metal under
83
+ a film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
84
+ `area` its area per volume (1/h under a film h thick), each reaction Butler–Volmer per area, and
85
+ a `capacitance` the double layer. A **`geometry`** gives the device a cross-section A(x): here x
86
+ is the radius of a drop, $`A = 2\pi r\,h(r)`$, and currents and amounts become totals (A, mol).
87
+
88
+ ```js
89
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
90
+ import { aqueous, polarization, H2O } from 'driftlet/kit';
91
+
92
+ // Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
93
+ // the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
94
+ const T = 298.15, RT = GAS_CONSTANT * T, R = 3e-3, h0 = 1e-3, cO2 = 0.26, D = 2e-9;
95
+ const lib = aqueous(['Na+', 'Cl-', 'Fe2+', 'OH-', 'H+'], { epsr: 0 }); // strictly neutral water
96
+ lib.species.push({ name: 'O2', z: 0, cRef: 1000 }, { name: 'e-', z: -1 }); // e⁻: the iron's, in no material
97
+ lib.materials.water.species.O2 = { D, mu0: 16.4e3 };
98
+ const r = Array.from({ length: 61 }, (_, j) => (j / 60) * R);
99
+ const h = r.map((x) => Math.max(h0 * (1 - (x / R) * (x / R)), 0.02 * h0)); // the drop's height
100
+ const dev = new Device({
101
+ T,
102
+ species: lib.species,
103
+ materials: lib.materials,
104
+ geometry: { area: { x: r, values: r.map((x, j) => 2 * Math.PI * x * h[j]) } }, // ions squeeze through the thin rim
105
+ 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 } }],
106
+ contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
107
+ bulkReactions: [
108
+ { equation: 'H+ + OH- = H2O', fixed: { H2O }, kf: { water: 1.4e8 } }, // H2O: water's μ, −237.129 kJ/mol
109
+ // 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
110
+ // exchange link, whose rate is linear in μ rather than c)
111
+ { equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
112
+ ],
113
+ ports: [{
114
+ name: 'iron', region: 'drop', terminal: 'e-',
115
+ V: 0, // the only metal and the only path for current, so it passes none: held, it fixes the level φ is read against
116
+ area: { x: r, values: h.map((y) => 1 / y) }, // m² of iron per m³ of water
117
+ capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
118
+ reactions: [
119
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
120
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O }, k0: 3e-9, alpha: 0.125 },
121
+ ],
122
+ }],
123
+ grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
124
+ });
125
+ const sol = dev.advance(600); // ten minutes
126
+ const iron = sol.ports[0], last = iron.x.length - 1;
127
+ // rates[q][j]: reaction q at node j of the window, forward, mol/(m²·s) of iron; anodic current +
128
+ const anodic = (j) => 2 * FARADAY * -iron.rates[0][j] - 4 * FARADAY * iron.rates[1][j];
129
+ console.log(`iron at ${(iron.V - sol.phi[0]).toFixed(3)} V against the water at the centre (SHE, with table μ°)`);
130
+ 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`);
131
+ // The Evans diagram at the centre: each reaction's current with the iron's level moved, the water held.
132
+ const levels = [-0.55, -0.5, -0.45, -0.4].map((v) => v + sol.phi[0]);
133
+ const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
134
+ for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
135
+ ```
136
+
137
+ Species can take an electrode's surface sites (`surface`, Langmuir coverages), and a reaction
138
+ with `bare: true` runs only on bare metal: a passive film (docs/device.md).
139
+
140
+ ## a voltage clamp
141
+
142
+ A face's `gates` (each a fraction open, $`dx/dt = \alpha(V)(1 - x) - \beta(V)x`$ with $`V = \phi_{\mathrm{right}} - \phi_{\mathrm{left}}`$)
143
+ scale its links (`gates: { m: 3, h: 1 }`). `hodgkinHuxley({ T, linear: true })` (kit) writes the
144
+ squid axon's as Hodgkin and Huxley had them, conductances $`g(V - E)`$. Between two baths without a
145
+ reference species, $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ is $`V_m`$, so a held step is their voltage clamp.
146
+
147
+ ```js
148
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
149
+ import { build, layer, bath, aqueous, hodgkinHuxley } from 'driftlet/kit';
150
+
151
+ const T = 279.45, VT = (GAS_CONSTANT * T) / FARADAY; // HH's 6.3 °C
152
+ const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
153
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
154
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
155
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
156
+ const hh = hodgkinHuxley({ T, linear: true });
157
+ const step = -0.01; // from −65 mV to −10 mV at 1 ms
158
+ const dev = new Device(
159
+ build({
160
+ T,
161
+ library: [water],
162
+ stack: [
163
+ bath(out), // the outside, on the left
164
+ layer('water', 1e-6, { c0: out }),
165
+ { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
166
+ layer('water', 1e-6, { c0: inside }),
167
+ bath(inside, { t: [0, 1e-3, 1e-3, 1], values: [-0.065, -0.065, step, step] }),
168
+ ],
169
+ grid: { hmin: 20e-9, hmax: 0.2e-6 },
170
+ }),
171
+ );
172
+ dev.solve(); // the rest at −65 mV, the gates at their values there
173
+ const run = dev.advance(11e-3, { dtMax: 0.02e-3, probes: [{ interface: 0, species: 'Na+' }, { interface: 0, species: 'K+' }] });
174
+ const [na, k] = run.trace.probes.map((N) => N.map((x) => -FARADAY * x)); // outward currents, A/m²
175
+ const peak = na.indexOf(Math.min(...na)), EK = VT * Math.log(out['K+'] / inside['K+']);
176
+ 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
177
+ 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
178
+ ```
179
+
180
+ ## an action potential
181
+
182
+ Without `linear: true` (given `inside` and `outside`), `hodgkinHuxley` writes GHK permeabilities
183
+ matched to HH's conductances at −65 mV, which rectify as real channels do. A pump-less cell has no
184
+ steady rest (`solve()` finds Donnan), so start the transient from the concentrations given.
185
+
186
+ ```js
187
+ import { Device } from 'driftlet';
188
+ import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
189
+
190
+ const T = 279.45; // HH's 6.3 °C
191
+ const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
192
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
193
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
194
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
195
+ const hh = hodgkinHuxley({ inside, outside: out, T }); // GHK channels; outside on the left
196
+ const axon = new Device(
197
+ build({
198
+ T,
199
+ library: [water],
200
+ stack: [
201
+ bath(out, 'Cl-'),
202
+ layer('water', 5e-6, { name: 'outside', c0: out }),
203
+ { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
204
+ layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
205
+ { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
206
+ ],
207
+ // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
208
+ ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
209
+ grid: { hmin: 50e-9, hmax: 1e-6 },
210
+ }),
211
+ );
212
+ let peak = -1;
213
+ for (let k = 1; k <= 300; k++) {
214
+ const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
215
+ peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
216
+ }
217
+ 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)
218
+ ```
219
+
220
+ ## an action potential travelling along an axon
221
+
222
+ Here x runs along the axon: the axoplasm is one region, and the membrane a port spanning it, a
223
+ capacitance and gated channels to an outside given as a `bath` (the port's V is then the
224
+ outside's $`\phi`$). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
225
+ per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
226
+ `ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
227
+ its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
228
+ the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
229
+ (scale $`h`$ with $`\sqrt a`$). The stimulus is a current per volume, so a thinner axon needs it as $`1/a`$.
230
+
231
+ ```js
232
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
233
+ import { hodgkinHuxley, injector, pulse } from 'driftlet/kit';
234
+
235
+ const T = 291.65, VT = (GAS_CONSTANT * T) / FARADAY, a = 238e-6; // 18.5 °C, a squid giant axon
236
+ const inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52.5, 'A-': 397.5 }; // mM
237
+ // Outside levels that put the Nernst potentials at HH's +50, −77 and −54.4 mV (leak as Cl⁻).
238
+ const outside = { 'Na+': 50 * Math.exp(0.05 / VT), 'K+': 400 * Math.exp(-0.077 / VT), 'Cl-': 52.5 * Math.exp(0.054387 / VT) };
239
+ // One D for the small ions, giving HH's 35.4 Ω·cm.
240
+ const D = 1 / 0.354 / ((FARADAY * FARADAY) / (GAS_CONSTANT * T)) / (50 + 400 + 52.5);
241
+ const sp = (d) => ({ D: d, mu0: 0, cRef: 1000 });
242
+ const sealed = { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' };
243
+ const hh = hodgkinHuxley({ T, area: 2 / a });
244
+ const axon = new Device({
245
+ T,
246
+ species: [{ name: 'Na+', z: 1 }, { name: 'K+', z: 1 }, { name: 'Cl-', z: -1 }, { name: 'A-', z: -1 }],
247
+ materials: { axoplasm: { epsr: 0, species: { 'Na+': sp(D), 'K+': sp(D), 'Cl-': sp(D), 'A-': sp(1e-12) } } },
248
+ regions: [{ name: 'axon', material: 'axoplasm', length: 0.06, c0: inside }],
249
+ contacts: { left: sealed, right: sealed },
250
+ ports: [
251
+ { name: 'membrane', region: 'axon', V: 0, area: 2 / a, capacitance: { C: 0.01, zeroCharge: 0 }, bath: { c: outside }, gates: hh.gates, species: hh.species },
252
+ injector({ name: 'stim', region: 'axon', from: 0, to: 1e-3, species: 'K+', I: pulse({ start: 30e-3, width: 0.2e-3, amplitude: 30 }) }),
253
+ ],
254
+ grid: { hmin: 2e-4, hmax: 2e-4 },
255
+ });
256
+ axon.advance(30e-3); // settle to rest (no pump: start from the concentrations, not solve())
257
+ const sol = axon.advance(34e-3, { dtMax: 5e-6, probes: [{ x: 0.02, quantity: 'phi' }, { x: 0.04, quantity: 'phi' }] });
258
+ const { t, probes } = sol.trace, at = (v) => t[v.findIndex((x) => x > 0)]; // (φ inside, the outside's held at 0)
259
+ 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)
260
+ ```