driftlet 1.2.3 → 1.3.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
@@ -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@1.2.3/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.3.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
 
@@ -94,13 +94,18 @@ Two optional transport terms describe a moving fluid:
94
94
 
95
95
  - **Advection** by an imposed velocity $`v`$ adds $`v \cdot c_i`$ to every mobile species' flux.
96
96
  - **Eddy mixing** adds $`-(D_{\mathrm{mix}}/RT) P \nabla\bar\mu`$, with
97
- $`P = C - C z z^\mathsf{T} C / (z^\mathsf{T} C z)`$ and $`C = \mathrm{diag}(c)`$.
97
+ $`P = K - K z z^\mathsf{T} K / (z^\mathsf{T} K z)`$ and $`K = \partial c/\partial\zeta`$ (the
98
+ statistics' $`K`$; $`\mathrm{diag}(c)`$ when dilute).
98
99
  - It's an Onsager term: symmetric, positive semi-definite, and zero at equilibrium (flat
99
100
  $`\bar\mu`$), so double layers at rest are untouched.
100
101
  - The projection removes any current ($`z^\mathsf{T} P = 0`$). Eddies move neutral fluid, so
101
102
  they mix salt and stir concentration gradients but conduct nothing.
102
- - For a neutral dilute species it's exactly $`-D_{\mathrm{mix}} \nabla c`$. For a binary salt it
103
- adds $`D_{\mathrm{mix}}`$ to the salt's ambipolar diffusivity and leaves diffusion potentials
103
+ - Since $`K\nabla\zeta = \nabla c`$, it's $`-D_{\mathrm{mix}} \nabla c`$ for every species in a
104
+ neutral solution, under any statistics: eddies carry parcels of fluid, so they mix
105
+ concentration, not activity. (Molecular diffusion differs on purpose: a molecule's own random
106
+ walk is driven by the thermodynamic force, so its mobility is $`D c`$, above.) For a neutral
107
+ dilute species it's $`-D_{\mathrm{mix}} \nabla c`$ anywhere. For a binary salt it adds
108
+ $`D_{\mathrm{mix}}`$ to the salt's ambipolar diffusivity and leaves diffusion potentials
104
109
  unchanged.
105
110
 
106
111
  ## Materials, regions, interfaces
package/docs/device.md CHANGED
@@ -589,6 +589,11 @@ ports: [{
589
589
  }]
590
590
  ```
591
591
 
592
+ The window holds exactly `to − from` of exchange, electrode and capacitance however the grid
593
+ falls: each node whose box reaches into it takes the part of its box inside. A held level
594
+ (`'equilibrium'`) holds the nodes inside the window, so it needs one there. A window of no width
595
+ is the node at that point, its whole box.
596
+
592
597
  On a [conductor region](#materials), a port is a wire to the whole conductor: it takes no window,
593
598
  only the conductor's carrier, and a conductance link's `G` is per area (S/m², a resistance
594
599
  $`R \cdot A`$ to the port's voltage as $`G = 1/(R \cdot A)`$). That's how a floating electrode is tied
@@ -662,7 +667,13 @@ voltage, the port is a potentiostat. At `I: 0` (or with nothing else to carry cu
662
667
  reactions settle at the mixed potential, the corrosion potential where anodic and cathodic
663
668
  currents cancel over the window, while each node can be a net anode or a net cathode; a floating
664
669
  one starts there, for the start's composition (with a double layer, it starts uncharged and
665
- charges toward it). An electrode that's the device's only path for current is at `I: 0` whatever
670
+ charges toward it). A floating double layer that starts far from the equilibrium of a path
671
+ beside it (an ion link whose outside level was set for the charged steady state, a fast
672
+ reaction) closes the gap in the first instant, which a transient may not be able to follow: it
673
+ then stops saying so. For a start in equilibrium, `solve()` first, then change what the
674
+ experiment changes, then `advance()`; where the solve can't cross the gap either (it says so too),
675
+ hold the electrode at a voltage, `solve()`, then let it float (`I: 0`), which keeps the coverages
676
+ the held state gave it. An electrode that's the device's only path for current is at `I: 0` whatever
666
677
  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
667
678
  `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
668
679
 
@@ -1001,7 +1012,11 @@ const now = dev.solution(); // snapshot of the current state
1001
1012
  and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
1002
1013
  against a 3.4 µm depth put Z 7% off, falling as the cells do; at an electrode, the diffusion
1003
1014
  length $`\sqrt{2D/\omega}`$ at the highest frequency). The result is `{ f, Z: { re, im },
1004
- warnings }`, the warnings being the steady state's. With `profiles: true`, `profiles[k]` holds
1015
+ relError, warnings }`: `relError[k]`, each Z's estimated error relative to $`|Z|`$ (from
1016
+ round-off in the current and the solves' residuals, and cautious: it can exceed the true error
1017
+ many times), and the warnings, the steady state's and one where a Z isn't resolved (an error
1018
+ over 1%: far below a capacitive device's relaxation, almost no current flows, and what does is
1019
+ lost to round-off) or Re Z is under a tenth of its error, relError·|Z|. With `profiles: true`, `profiles[k]` holds
1005
1020
  frequency k's complex profiles, `{ phi, mu: {name}, c: {name} }`, each `{ re, im }` at the
1006
1021
  nodes (`NaN` where a quantity doesn't exist), per unit excitation: a volt, or an A/m² where the
1007
1022
  terminal is driven by a current.
package/docs/internals.md CHANGED
@@ -15,10 +15,18 @@ them is wrong: fix both, with a test, in the same commit.
15
15
  | --- | --- |
16
16
  | `src/laws.js` | yes: the steady system's conservation laws |
17
17
  | `src/lawRows.js` | yes: the laws written into the Newton system, and the bordered solve |
18
+ | `src/timeSteps.js` | yes: one step (backward Euler, BDF2), advance's halving, integrate's error control |
19
+ | `src/start.js` | yes: the cold start, from each region's c0 and the contacts' and ports' levels |
20
+ | `src/newton.js` | yes: Newton's iteration, its scaled rows, GMRES-refined solves and the islands' round |
21
+ | `src/assemble.js` | yes: the equations assembled, the conventions of every row, and the order of the terms |
22
+ | `src/regions.js` | yes: statistics, storage, fluxes along segments, bulk reactions, the neutral nodes' charge rows |
23
+ | `src/boundaries.js` | yes: faces (φ laws, links, reactions, gates), ports, contacts, circuit rows |
24
+ | `src/steady.js` | yes: steady solves, their stages and continuations, the pseudo-transient ramp |
25
+ | `src/impedance.js` | yes: the small-signal impedance, its readouts and profiles |
18
26
  | `src/difference.js` | header: the dilute kernels in difference form |
19
27
  | `src/blockTridiagonal.js` | header: the block Thomas solver |
20
28
  | `src/statistics.js` | header: each model's `evaluate` / `invert` |
21
- | `src/solver.js` | not yet (below) |
29
+ | `src/solver.js` | no: setup, bookkeeping and drivers, described below |
22
30
 
23
31
  ## From a definition to a solution
24
32
 
@@ -45,7 +53,9 @@ them is wrong: fix both, with a test, in the same commit.
45
53
  its block (−1: inactive), `rix[o]` its index in compact vectors, with `rix = N` (the sink) for
46
54
  inactive ones. `res`, `delta` and the Jacobian (`sys`, a `BlockTridiagonal`) are compact.
47
55
  - **Unknowns** are `u + uLo`, a double-double, so that differences of η keep their digits; the
48
- Jacobian and solves are plain doubles. Concentrations `c[g·n + i]` and coverages
56
+ Jacobian and solves are plain doubles. (η is read as `u + uLo` wherever a difference of levels
57
+ is taken; φ̂ in the statistics and the displacement, a face's own slots and the gates as `u`
58
+ alone.) Concentrations `c[g·n + i]` and coverages
49
59
  `th[g·nSurf + q]` follow from `u` (`computeConcentrations`).
50
60
  - **Terminals:** 0 the left contact, 1 the right, `2 + k` port k. A floating terminal (driven by
51
61
  a current, or behind a resistance) has its voltage as an extra unknown and its circuit row
@@ -60,23 +70,34 @@ By method, in file order:
60
70
 
61
71
  - **Setup:** the constructor (layout, per-node data, φ-undefined regions, terminals),
62
72
  `_activeSlots`, `_findStretches` (stretches, flat stretches), `_laws` and `_structure` (the
63
- laws, from `src/laws.js`), `_directSteady`.
73
+ laws, from `src/laws.js`).
64
74
  - **Applying the laws** is `src/lawRows.js` (`captureLaws`, `captureLocal`, `snapFlat`,
65
- `applyLaws`, `solveBordered`), called from `assemble`, `newton`, `_refine` and the steady
66
- solves; `_replaceRow` and `_j`, which it uses, stay in the solver.
67
- - **Starting state:** `initFromComposition`, `_surfaceStart`.
68
- - **Statistics and assembly:** `computeConcentrations`, `_nodeStatistics`, `assemble` and the
69
- kernels it calls (nodes, segments, faces, bulk reactions, ports, contacts, charge rows),
70
- `_assembleDifference` (with `src/difference.js`).
71
- - **Newton:** `newton`, `_refine`, `_equilibrate`, `_kirchhoff`, `_noteConditioning`.
72
- - **Time steps:** `advance`, `step`, `integrate` (BDF with error control, breakpoints from the
73
- sources), `_predict`, `_errorNorm`, `_snapshot` / `_restore`.
74
- - **Small signal:** `impedance` and the helpers after it.
75
+ `applyLaws`, `solveBordered`), called from `assemble`, Newton and the steady solves.
76
+ - **Starting state** is `src/start.js` (`initFromComposition`, which the Solver's own calls);
77
+ `_surfaceStart`, which the API uses too, stays in the solver.
78
+ - **Assembly** is `src/assemble.js` (`assemble`, `_assembleDifference` with `src/difference.js`,
79
+ `_assembleBookkeeping`, and `_j` and `_replaceRow`, which write the Jacobian), with its kernels
80
+ in `src/regions.js` (`computeConcentrations` and the statistics, nodes, segments, bulk
81
+ reactions and their rates, `_chargeRows` and `_untransform`) and `src/boundaries.js` (faces,
82
+ `_terminals`: ports, contacts, circuit rows). Their methods are the Solver's own (installed on
83
+ its prototype), since every term reads and writes its arrays.
84
+ - **Newton** is `src/newton.js` (the iteration, its row scaling, the GMRES-refined solves, the
85
+ islands' second round), which the Solver's own `newton` calls; `_factor` stays in the solver.
86
+ - **Time steps** are `src/timeSteps.js` (`step`, `advance`, `integrate`, the predictor and the
87
+ error norm), which the Solver's own `step`, `advance` and `integrate` call; `_snapshot` /
88
+ `_restore`, `_warmFrom` and `_probes`, which the API uses too, stay in the solver.
89
+ - **Small signal** is `src/impedance.js` (`impedance` and its helpers: the current cuts, the
90
+ regions' uniform shift, the profiles; the complex GMRES and small dense solve), methods of the
91
+ Solver like the assembly's.
75
92
  - **Bookkeeping:** `segmentFluxes`, `faceOneWay`, `_assembleBookkeeping`, the contacts' intake.
76
- - **Steady solves:** `solveSteady`, `_steadyFromHere`, the continuations (floating, generation,
77
- a terminal's level), `_steadySteps`.
93
+ - **Steady solves** are `src/steady.js` (`solveSteady`, `_steadyFromHere`, the continuations:
94
+ floating, generation, a contact's voltage; `_steadySteps`, the pseudo-transient ramp, and
95
+ `_renormalizeSpectators`; `_directSteady`, `slowestTime`, `fastestTime`, `stuckReactions`),
96
+ methods of the Solver like the assembly's.
78
97
 
79
- The rest of `src/solver.js` is to be split along these lines, each piece with its contract.
98
+ What stays in `src/solver.js` is the setup, the bookkeeping, the probes and snapshots, and the thin
99
+ drivers that call the modules: about a thousand lines, with no contract of its own beyond this
100
+ section.
80
101
 
81
102
  ## Reviewing one module
82
103
 
package/docs/numerics.md CHANGED
@@ -171,13 +171,25 @@ potential and the quasi-Fermi difference are both shifted by the cell Péclet nu
171
171
  $`\mathrm{Pe} = v h/D`$, $`\Delta \to \Delta - \mathrm{Pe}`$, $`\Delta\eta \to \Delta\eta - \mathrm{Pe}`$.
172
172
  That's the exact constant-flux solution of $`N = v c - D(c' + c \psi')`$ across the cell, so pure
173
173
  convection–diffusion is exact on any grid, upwinding itself automatically at large $`\mathrm{Pe}`$.
174
+ Past a drift of 300 thermal units across a cell (flow toward −x past a cell Péclet number of 300),
175
+ the flux is taken from its downstream end, $`g B(-\Delta) c_R \operatorname{expm1}(-\Delta\eta)`$, the
176
+ same flux, since there $`B(\Delta)`$ underflows and $`\operatorname{expm1}(\Delta\eta)`$ overflows.
174
177
 
175
178
  **Eddy mixing** is discretised on each segment as
176
- $`N_i = -(D_{\mathrm{mix}}/h) \sum_j \bar P_{ij} \Delta\eta_j`$, where $`\bar P`$ is built from the
177
- logarithmic mean of each concentration across the segment. The logarithmic mean makes a neutral
178
- species' mixing flux exactly $`-D_{\mathrm{mix}} \Delta c/h`$. The flux is exactly zero at
179
+ $`N_i = -(D_{\mathrm{mix}}/h) \sum_j \bar P_{ij} \Delta\eta_j`$, where $`\bar P`$ is built from a
180
+ segment mobility $`\bar K`$. Between ideal ends $`\bar K`$ is the logarithmic mean of each
181
+ concentration, which makes a neutral species' mixing flux exactly $`-D_{\mathrm{mix}} \Delta c/h`$.
182
+ Between crowded ends it's $`\bar K = \tfrac12(R_L K_L R_L + R_R K_R R_R)`$ with
183
+ $`R = \mathrm{diag}(\sqrt{\bar c/c})`$ at each end: each end's $`K`$ rescaled to the logarithmic
184
+ means and averaged. That's the dilute form where statistics are ideal, positive definite, and
185
+ smooth in the state, and in a neutral solution it gives $`-D_{\mathrm{mix}} \Delta c/h`$ to second
186
+ order in the cells. (A secant $`\bar K`$, exact in $`\Delta c`$, can be built from the ends' $`c`$ and
187
+ $`\zeta`$ alone, but not smoothly: where a solution is uniform its correction depends on the
188
+ direction of $`\Delta\zeta`$, a kink right where a salt carries current.) The flux is exactly zero at
179
189
  equilibrium, and exactly current-free ($`z^\mathsf{T}\bar P = 0`$ for any $`\bar P`$ built this way). Its
180
- full Jacobian, including the dependence of $`\bar P`$ on both ends, keeps Newton quadratic.
190
+ full Jacobian, including the dependence of $`\bar P`$ on both ends, keeps Newton quadratic. Through
191
+ $`K_L`$ and $`K_R`$ that needs $`\partial K/\partial\zeta`$, which the statistics models don't give, so
192
+ it's taken by central differences of the model (a step of 1e-5, accurate to about 1e-10).
181
193
 
182
194
  With non-ideal statistics, $`\ln c = \zeta - \mathrm{ex}`$, where the excess
183
195
  $`\mathrm{ex} = \zeta - \ln(c/c_{\mathrm{ref}})`$ is zero for ideal statistics. The excess enters
@@ -508,8 +520,9 @@ which cost four assemblies per product.
508
520
  GMRES on $`J\delta = r`$, the factorised system as the preconditioner and $`J \cdot v`$ exact, each
509
521
  flux on its $`\eta`$ difference (see [exact J·v](#exact-jv)), as the impedance does. A lost mode
510
522
  takes GMRES a couple of iterations. The refined update is kept where GMRES converged and it
511
- moves no level by a thermal unit or more; otherwise refining stops for the rest of the solve.
512
- A MOS capacitor's gate step now reaches the low-frequency charge in ~70 steps (it took 80,000,
523
+ moves no level by a thermal unit or more; otherwise refining stops for the rest of the solve
524
+ (on a device marked as below, for two iterations, three times at most).
525
+ A MOS capacitor's gate step now reaches the low-frequency charge in ~30 steps (it took 80,000,
513
526
  and ended 1.5% off). Once refined, a solve stays refined: a GaAs junction without
514
527
  recombination, whose minority carriers reach it only from the contacts, passes ~1.5e-7 A/m²,
515
528
  which plain solves past the first refinement left to round-off (cold and warm solves 1e-3
@@ -541,9 +554,32 @@ which cost four assemblies per product.
541
554
  conductances, loses about $`\log_{10}(g/G)`$ and fails near 15. It's not reported for solves that
542
555
  converge, since digits can be lost harmlessly too (a saturated species whose $`\eta`$ nothing
543
556
  depends on).
544
- - The last iteration usually only confirms convergence (updates go like 4e-3, 7e-6, 3e-11).
545
- Stopping one iteration early on a convergence-rate estimate would save a third of the work,
546
- but leave ~1e-11 in $`\eta`$ per step, which shows up as conservation drift, so it isn't done.
557
+ - The last iteration usually only confirms convergence (updates go like 1e-2, 1e-9, 1e-15). A
558
+ time step stops one iteration early where the convergence rate says the next update would be
559
+ under the tolerance: with $`\theta`$ the last update over the one before (under 0.1, the update
560
+ under 1e-6), $`\theta/(1-\theta)`$ of the update (Hairer and Wanner's estimate of the error
561
+ left) under 1e-10. That saves a third of a typical step's work. Steady solves don't, since
562
+ their acceptance is checked against the residual anyway.
563
+ - **A mode the plain solves never see, in time steps.** Neither a plain update nor the residual
564
+ shows a lost mode. Newton accepted a MOS capacitor's gate step at updates of 1e-10 while the
565
+ inversion layer's level sat 2.4e-4 thermal units off, the residual 7e-15 there and at the
566
+ solution alike, and the gate's charge ended 3.4e-5 off the steady one (a cold review of
567
+ `src/newton.js` found it: a tight re-solve from each accepted state was the instrument). So a
568
+ refinement that corrects a time step's nearly converged plain update (under 1e-4) by more than
569
+ its own size and 1e-6, by a correction the next update keeps, marks the device: from then on
570
+ its time steps are refined throughout, converge only on a refined update, and don't stop
571
+ early on the rate. The gate step then ends 4e-10 off, in fewer factorisations (121 against
572
+ 222; 2 steps rejected, not 5). A correction the next update undoes is $`J \cdot v`$'s own
573
+ round-off in a row of huge entries (one ulp's worth, $`2^{-13}`$ or $`2^{-19}`$ exactly, on
574
+ healthy devices), and marks nothing. A lost mode can also never stall Newton at all: a floating
575
+ gate at rest in inversion converged quadratically at every step while the extrapolated guess's
576
+ error in the layer's level grew, 8.5e-5 V by 1e5 s, and by 1e7 s took 63,000 steps. So a time
577
+ step's 1st, 2nd, 4th, 8th… and every 32nd convergence on a device not yet marked is probed
578
+ with one refined update at the accepted state; one over 1e-8 thermal units (about a dozen over
579
+ the suite, which the next update undid on healthy devices) sends Newton on, refining, and the
580
+ next update decides as above. The gate then stays within 1e-14 V, in 7 steps to 1e7 s. The
581
+ probes and the refining cost the suite 2.5% more assemblies and 1.3% more factorisations;
582
+ strictly neutral transients, marked, came out up to 1.7e-5 off and now 1.9e-6.
547
583
  - Tried and rejected for large jumps (the retry at 3 thermal units per iteration takes 30–50
548
584
  iterations for a 1.4 V jump on a pn diode): per-component logarithmic damping, as in
549
585
  SPICE junction limiting, and an adaptive limit that grows while updates shrink. Both
@@ -776,7 +812,11 @@ the terms that cancel:
776
812
  next: there, backward-Euler steps at a huge $`dt`$ (10⁶ × the slowest diffusion time) keep
777
813
  the storage term, growing ×10 (capped) while the state still moves, each spectator's level
778
814
  first shifted uniformly to restore its amount exactly (round-off creeps through the vanishing
779
- storage term), and one last step at the base huge $`dt`$, where pinning is tight.
815
+ storage term), and one last step at the base huge $`dt`$, where pinning is tight. The amounts
816
+ that only those steps keep are checked after each, and a step that moved one fails: where the
817
+ slowest species is very slow (D ~ 1e-22 m²/s), the storage term falls below round-off against a
818
+ fast species' conduction, and a closed Y + Z, Y still in part of its stretch, had converged 1e8
819
+ times too large. The slowest diffusion time counts only materials some region is made of.
780
820
  - **A flat level.** A species that no reaction or port touches, reached by one contact at most,
781
821
  carries no flux in a steady state: every sum of its balance rows from its closed end is the
782
822
  flux through one segment, zero. In an ideal region without flow or mixing that's a level flat
@@ -916,7 +956,11 @@ A solve that fails falls back stage by stage:
916
956
  the direct solve. After a failed step it grows ×2, then ×1.5 more each success, back to ×10.
917
957
  Once steps reach the slowest diffusion time, the direct solve is tried from there, once a
918
958
  decade of $`dt`$ (the state kept if it fails): with immobile traps and ions of D = 1e-17 m²/s,
919
- steps from 1e8 to 1e9 s kept failing on the way to the end.
959
+ steps from 1e8 to 1e9 s kept failing on the way to the end. The ramp reaches about 1e12 times
960
+ the slowest diffusion time, so a reaction slower than that, which alone settles a closed
961
+ combination (no row of the steady system holds a reaction at equilibrium node by node), isn't
962
+ waited out: the solve fails, or converges where it began, and its warnings name the reaction
963
+ either way. A transient gets there (Y = Z at $`k_f`$ = 1e-12 s⁻¹ in 1610 steps).
920
964
 
921
965
  Each of these carries cases that nothing else does (switched off one at a time, each fails
922
966
  tests or stress cases the others pass).
@@ -76,9 +76,9 @@ recipes.
76
76
  | Second law | free energy in through the terminals (Σ N μ̄ over every species) equals the dissipation, every term ≥ 0: a pn diode, an open-circuit junction running on chemical input alone, electrodes with a bipolar plate, a port and a contact behind conductances | 1e-10 |
77
77
  | Transients | RC charging of a gated island; water relaxation rate | 1%; 2e-3 |
78
78
  | Time integration | BE first order, BDF2 second order; adaptive error control | ratios 2, 4 |
79
- | Advection and eddy mixing | exact convection–diffusion profile; `D + D_mix`; junction EMF unchanged by mixing | 1e-12; 1e-12; 1e-5 |
79
+ | Advection and eddy mixing | exact convection–diffusion profile; `D + D_mix`; junction EMF unchanged by mixing, ideal and crowded; crowded mixing −D_mix Δc (a neutral species on a lattice, second order; ions, lattice and Debye–Hückel, on 20 cells) | 1e-12; 1e-12; 1e-5; ratios 4, 1e-4 |
80
80
  | 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 |
81
- | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel, and without one the same equilibrium, and a gate step filling the inversion layer to it | 1e-4; 1e-9; 2e-3; 1e-6; 1e-3 |
81
+ | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); a window's exchange and electrode exactly `to − from` on any grid; held level; MOS low-frequency C–V with a grounded channel, and without one the same equilibrium, and a gate step filling the inversion layer to it | 1e-4; 1e-9; 1e-9; 2e-3; 1e-6; 1e-3 |
82
82
  | 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 |
83
83
  | 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 |
84
84
  | 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 |
@@ -174,7 +174,11 @@ disagree (a port's voltage at 0 for −1.42 V, θ 0.05 for 0.015, a contact 24 m
174
174
  the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices,
175
175
  gives the same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a
176
176
  membrane capacitor where the transient loses K⁺. Since a face out of equilibrium in `c0` starts
177
- in it (1.2.3), 607 agree and 115 fail, the same 13 differing.
177
+ in it (1.2.3), 607 agree and 115 fail, the same 13 differing. Those 13 were a missing derivative:
178
+ a contact's current, read from its end box, didn't take the membrane port's voltage that reaches
179
+ it (a cold review of `src/boundaries.js`), so Newton stopped short in a way the tolerance set. With
180
+ it in (1.2.7), the transient settles on the steady state to 1e-14 V, in a third of the steps, and
181
+ 648 agree, 87 transients fail, none differ.
178
182
 
179
183
  ## Consistent: the derivatives
180
184
 
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.0/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,206 @@
1
+ // The equations at one time, assembled: every row's residual and its Jacobian, for Newton's
2
+ // iteration (src/newton.js), the time steps' bookkeeping and the small signal.
3
+ //
4
+ // CONTRACT
5
+ //
6
+ // These are methods of the Solver (src/solver.js installs them on its prototype), as are the
7
+ // kernels they call: the regions' in src/regions.js and the faces' and terminals' in
8
+ // src/boundaries.js, each with its own contract under this one. The layout they write into
9
+ // (blocks, slots, row o = block·M + slot, its compact index R = rix[o], loc[o] < 0 for an
10
+ // inactive slot, u + uLo, terminals 0, 1, 2 + k) is in docs/internals.md.
11
+ //
12
+ // The discretisation. Every region boundary is a doubled grid node (one per side) with a
13
+ // zero-volume flux block between them (the face's), so the Jacobian stays block-tridiagonal. A
14
+ // node's slots: φ̂ = Fφ/RT (in a metal, its carrier's flux J along the segment to its right),
15
+ // η_i = μ̄_i/RT, and at a port's nodes its coverages and gates. A face's: its displacement D, each
16
+ // linked species' flux N_i (toward +x, per area), each of its reactions' rate, each gate's
17
+ // fraction. Geometry is in the grid (src/grid.js): a node's box vol[g] = ∫A dx, the area A at a
18
+ // node, a segment's h = length/segArea. The rows are totals over a box (mol/s and C/s, or C):
19
+ // Gauss (φ̂ row): Σ D out of the box − vol·ρ = 0, ρ = F Σ z_i c_i + ρ_fixed (+ a port's
20
+ // capacitive charge, a face's sheet charge)
21
+ // balance (η_i): vol·(c_i − c_i,old)/dt + Σ N_i out − Σ N_i in + vol·Σ ν r (consumed: a
22
+ // reactant's ν > 0) − (made) − (a port's or contact's supply) = 0
23
+ // a face's slot: unknown − its law (D, N_i, a rate), or the law itself (a pinned jump, μ̄
24
+ // continuous), or a gate's (x − x_old)/dt − α(1 − x) + βx
25
+ // A flux along a segment is computed once and added with opposite signs to its two nodes, so
26
+ // sums of balance rows over boxes telescope exactly. At dt = Infinity there is no storage term
27
+ // (a steady system), and a gate's or surface's storage drops out the same way.
28
+ //
29
+ // assemble(dt), in order:
30
+ // 1. sys cleared and res zeroed; dtNow = dt; in a steady solve at dt = ∞, snapFlat (src/lawRows.js).
31
+ // 2. computeConcentrations (c, K, ex, coverages, from u + uLo).
32
+ // 3. Each region by its kind: a conductor's nodes and segments; else its nodes (dilute or
33
+ // concentrated), its bulk reactions, then per segment the displacement, eddy mixing, a
34
+ // concentrated region's fluxes; a dilute region's fluxes last.
35
+ // 4. Each face (_face).
36
+ // 5. The terminals (_terminals): the ports, the contacts, the floating ones' circuit rows.
37
+ // 6. applyLaws(dt) (src/lawRows.js): rows the laws take in place of the rows above.
38
+ // 7. On a time step with strictly neutral nodes (combining), _chargeRows: those nodes' rows
39
+ // and unknowns transformed (transformed = true; Newton maps the update back).
40
+ //
41
+ // What it writes:
42
+ // res (compact): each active row's residual as above; sys: its Jacobian, ∂res/∂(unknowns),
43
+ // by _j into the three blocks; inactive rows and columns go nowhere (rix = N, the sink).
44
+ // termB[k]: ∂res/∂V_k (per volt) for terminal k; termC[k]: ∂I_k/∂(unknowns); termI[k]: the
45
+ // current into the device through terminal k (A, planar: A/m²); termDI[k], termDX[k][j]:
46
+ // ∂ of k's circuit residual by V_k and V_j; termRes[k]: a floating k's circuit residual. Kept
47
+ // exactly only for _liveTerms() (the floating terminals, or all where allTerminals).
48
+ // The terminals' readouts (src/boundaries.js): contactFlux, contactD, portFlux, portQ, qg,
49
+ // qgSlope, portRates, contactQ; and s.pins (applyLaws).
50
+ // Where this.lin is set (only by _assembleDifference), the dilute kernels' Jacobian terms go into
51
+ // it, in difference form (src/difference.js), and the rest into sys, which is then not Newton's.
52
+ //
53
+ // Invariants (each says what a reviewer should be able to check here):
54
+ // 1. Every Jacobian entry is the derivative of the residual as assembled: in (φ̂, η, the face
55
+ // and port slots), by V_k in termB, and in the transformed unknowns after 7.
56
+ // (test/jacobian.test.js checks it against central differences, on the devices it lists.)
57
+ // 2. res, sys and the live terminals' terms depend only on the state (u, uLo, termV), dt, the
58
+ // sources' time and what a step or solve set (cOld, the gates' and coverages' old values,
59
+ // the charges at the step's start, steady, combining, the laws' references), never on an
60
+ // earlier assembly's leftovers: assemble(dt) twice at one state gives the same, bit for bit.
61
+ // (A held terminal's termB and termC aren't cleared, so they aren't kept.)
62
+ // 3. A kernel adds into res and sys (+=) and sets a row only where the row is its own (a face's
63
+ // slots, a metal's flux slot, a port's gates) or it replaces the row whole (a contact's or
64
+ // a port's held level, a contact's φ law; the laws in 6), and no kernel adds to a replaced
65
+ // row after it is replaced. A row read as a flux before it is replaced (a contact's, a held
66
+ // level's) has every other kernel's terms in it by then.
67
+ // 4. _assembleDifference(dt): res and the terminal terms as assemble(dt) gives them; its
68
+ // matrix plus its difference terms is assemble(dt)'s Jacobian (J·v equal to round-off);
69
+ // this.sys and this.lin as they were.
70
+ // 5. _assembleBookkeeping(dt): contactFlux, contactD, portFlux, portQ and termI bit for bit
71
+ // as assemble(dt) would set them at the same state (one a steady assembly has already
72
+ // snapped flat: it doesn't snap), with sys, res, termB, termC and termDI elsewhere left
73
+ // partial, not to be read; it doesn't recompute concentrations (they must be current).
74
+ // Tests: every test (every solve assembles); test/jacobian.test.js (invariant 1), test/layout.test.js
75
+ // (difference form, the bookkeeping's readouts).
76
+
77
+ import { BlockTridiagonal } from './blockTridiagonal.js';
78
+ import { DifferenceTerms } from './difference.js';
79
+ import { applyLaws, snapFlat } from './lawRows.js';
80
+
81
+ export class Assembly {
82
+ // Add ∂(row rb, slot rs)/∂(block cb, slot cs) to the Jacobian (nothing if either slot isn't
83
+ // an unknown).
84
+ _j(rb, rs, cb, cs, v) {
85
+ const M = this.M, r = this.loc[rb * M + rs], c = this.loc[cb * M + cs];
86
+ if (r < 0 || c < 0) return;
87
+ const sys = this.sys, sz = sys.sizes;
88
+ if (cb === rb) sys.B[sys.offB[rb] + r * sz[rb] + c] += v;
89
+ else if (cb === rb - 1) sys.A[sys.offA[rb] + r * sz[cb] + c] += v;
90
+ else if (cb === rb + 1) sys.C[sys.offC[rb] + r * sz[cb] + c] += v;
91
+ else throw new Error(`internal: non-tridiagonal coupling ${rb}→${cb}`);
92
+ }
93
+
94
+ /** Assemble residual and Jacobian for a backward-Euler step of size dt. */
95
+ assemble(dt) {
96
+ const { model, res, sys } = this;
97
+ const { grid, materials, regions } = model;
98
+ sys.clear();
99
+ res.fill(0);
100
+ this.dtNow = dt;
101
+ if (this.lin) this.lin.reset(1 / dt);
102
+ if (this.steady && dt === Infinity) snapFlat(this);
103
+ this.computeConcentrations();
104
+
105
+ // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
106
+ // dilute region (ideal statistics, the fast path) or a concentrated one (any statistics).
107
+ for (let r = 0; r < regions.length; r++) {
108
+ const reg = regions[r], mat = materials[reg.material];
109
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
110
+ if (mat.conductor) {
111
+ for (let g = g0; g <= g1; g++) this._nodeConductor(g, dt);
112
+ for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
113
+ continue;
114
+ }
115
+ if (mat.ideal) this._nodesDilute(g0, g1, dt);
116
+ else for (let g = g0; g <= g1; g++) this._nodeConcentrated(g, dt);
117
+ const rxs = this.rxsIn[reg.material];
118
+ if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
119
+ for (let s = g0; s < g1; s++) {
120
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
121
+ this._segmentDisplacement(s, bL, bR, mat, h);
122
+ if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
123
+ if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
124
+ }
125
+ if (mat.ideal) this._segmentsDilute(g0, g1, r, mat, reg.velocity);
126
+ }
127
+
128
+ // Faces: the flux node carries D, the linked species' fluxes and the reaction rates.
129
+ for (let f = 0; f < this.nFaces; f++) this._face(f);
130
+
131
+ // Terminals: ports (after every other term at their nodes, so a held level can read its
132
+ // flux), then contacts, then the circuit rows of the floating ones.
133
+ this._terminals(dt);
134
+ // The laws' rows (src/lawRows.js): a time step's circuit laws; a steady system's levels held
135
+ // flat, affinities, conserved amounts, islands' balances, and the pins held aside of them.
136
+ applyLaws(this, dt);
137
+ this.transformed = this.combining && dt !== Infinity;
138
+ if (this.transformed) this._chargeRows(dt);
139
+ }
140
+
141
+ // Zero one row of the Jacobian (all three blocks), ready to be replaced.
142
+ _replaceRow(b, rs) {
143
+ const l = this.loc[b * this.M + rs];
144
+ if (l < 0) return;
145
+ if (this.lin) this.lin.kill(this.rix[b * this.M + rs]);
146
+ const { sys, nB } = this, sz = sys.sizes, m = sz[b];
147
+ sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
148
+ if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
149
+ if (b < nB - 1) sys.C.fill(0, sys.offC[b] + l * sz[b + 1], sys.offC[b] + (l + 1) * sz[b + 1]);
150
+ }
151
+
152
+ // The Jacobian at dt with the dilute kernels' terms kept aside in difference form (see
153
+ // DifferenceTerms), the rest assembled into a matrix of its own. The residual and terminal terms
154
+ // come out as from assemble(dt); the Newton matrix is left as it was.
155
+ _assembleDifference(dt) {
156
+ const keep = this.sys;
157
+ const rest = this.sysRest ?? (this.sysRest = new BlockTridiagonal(keep.n, keep.sizes));
158
+ const n = this.n, nN = this.nNodes;
159
+ const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, nN * n * n, nN * n, 2 * nN * n * n));
160
+ this.sys = rest;
161
+ this.lin = lin;
162
+ try {
163
+ this.assemble(dt);
164
+ } finally {
165
+ this.sys = keep;
166
+ this.lin = null;
167
+ }
168
+ return { rest, lin };
169
+ }
170
+
171
+ // The contact and port readouts (fluxes, displacements, the last segment's current) at the
172
+ // current state, from only the boxes they're read from. Each of those boxes gets the same terms
173
+ // in the same order as in assemble(), so the readouts are identical; other residuals and the
174
+ // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
175
+ _assembleBookkeeping(dt) {
176
+ const { model } = this;
177
+ this.dtNow = dt;
178
+ const { grid, materials, regions } = model;
179
+ this.res.fill(0);
180
+ for (let r = 0; r < regions.length; r++) {
181
+ const { nodes, segs } = this.bookkeeping[r];
182
+ if (nodes.length === 0 && segs.length === 0) continue;
183
+ const reg = regions[r], mat = materials[reg.material];
184
+ if (mat.conductor) {
185
+ for (const g of nodes) this._nodeConductor(g, dt);
186
+ for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
187
+ continue;
188
+ }
189
+ for (const g of nodes) {
190
+ if (mat.ideal) this._nodesDilute(g, g, dt);
191
+ else this._nodeConcentrated(g, dt);
192
+ }
193
+ const rxs = this.rxsIn[reg.material];
194
+ if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
195
+ for (const s of segs) {
196
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
197
+ this._segmentDisplacement(s, bL, bR, mat, h);
198
+ if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
199
+ if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
200
+ }
201
+ if (mat.ideal) for (const s of segs) this._segmentsDilute(s, s + 1, r, mat, reg.velocity);
202
+ }
203
+ for (const f of this.bookkeepingFaces) this._face(f);
204
+ this._terminals(dt);
205
+ }
206
+ }
package/src/bernoulli.js CHANGED
@@ -27,3 +27,35 @@ export function bernoulliDerivative(x) {
27
27
  // B' = (B/x)(1 − B(−x)) = (B/x)(1 − B − x)
28
28
  return (b / x) * (1 - b - x);
29
29
  }
30
+
31
+ // Past a drift of SG_FAR thermal units across a segment, a Scharfetter–Gummel flux's B(d) heads
32
+ // for underflow and its expm1(Δη) for overflow (both by d ≈ 710, where their product is 0·∞):
33
+ // the product is then taken whole.
34
+ export const SG_FAR = 300;
35
+
36
+ /**
37
+ * A Scharfetter–Gummel flux N = −g·B(d)·c_L·expm1(Δη) and its derivatives, into `out`: [N, ∂N/∂d,
38
+ * g·B(d)·c_L (= ∂N/∂η_L), g·B(d)·c_L·e^{Δη} (= −∂N/∂η_R), −g·expm1(Δη)·(B(d) + B′(d)) (the
39
+ * non-ideal kernel's ∂N/∂ζ_L per unit K_L)]. d is the drift, Δη the difference of levels, so
40
+ * that c_R = c_L e^{Δη−d}. From d = SG_FAR on, the same flux from the downstream end, N =
41
+ * g·B(−d)·c_R·expm1(−Δη), whose factors stay finite; ∂N/∂d = N·B′(d)/B(d).
42
+ */
43
+ export function sgFlux(g, d, deta, cL, cR, out) {
44
+ if (d < SG_FAR) {
45
+ const E = Math.expm1(deta), B = bernoulli(d), Bp = bernoulliDerivative(d), gBc = g * B * cL;
46
+ out[0] = -gBc * E;
47
+ out[1] = -g * Bp * cL * E;
48
+ out[2] = gBc;
49
+ out[3] = gBc * (E + 1);
50
+ out[4] = -g * E * (B + Bp);
51
+ return out;
52
+ }
53
+ const B = bernoulli(d), up = g * bernoulli(-d) * cR, L = (1 - B - d) / d; // L = B′/B
54
+ out[0] = up * Math.expm1(-deta);
55
+ out[1] = out[0] * L;
56
+ out[2] = g * B * cL;
57
+ out[3] = up;
58
+ // (B·expm1(Δη), taken whole: d e^{Δη−d}(1 − e^{−Δη})/(1 − e^{−d}))
59
+ out[4] = -g * ((d * Math.exp(deta - d) * -Math.expm1(-deta)) / -Math.expm1(-d)) * (1 + L);
60
+ return out;
61
+ }