driftlet 1.3.0 → 1.3.3

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.3.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.3.3/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
 
package/docs/device.md CHANGED
@@ -112,9 +112,11 @@ regions: [
112
112
  material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
113
113
  Where two strictly neutral regions meet at a face that holds ions in equilibrium and their
114
114
  `c0` leaves it out of equilibrium (a different fixed charge or immobile ion on each side), the
115
- two nodes at the face start in it, as the face reaches it at $`t = 0^+`$: the ions it passes
116
- shared between them so their $`\bar\mu`$ is equal across it and each side neutral, each
117
- one's amount over the two nodes kept, everything else as `c0` has it.
115
+ two nodes at the face start a transient in it, as the face reaches it at $`t = 0^+`$: the ions
116
+ it passes shared between them so their $`\bar\mu`$ is equal across it and each side neutral,
117
+ each one's amount over the two nodes kept, everything else as `c0` has it. (The first time
118
+ step from a cold start does this; a steady solve starts from `c0` as given, which it reaches
119
+ better from.)
118
120
  An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
119
121
  the region's own), piecewise linear between its points and constant beyond its ends, so a
120
122
  transient can start from a state it didn't simulate reaching: a packet already injected, a
@@ -539,7 +541,18 @@ levels are that material's, and the end node takes them through a Donnan step.
539
541
  - `'pinned'` with `zeroCharge`: the $`C \to \infty`$ limit,
540
542
  $`\phi_{\mathrm{edge}} = V - \mathtt{zeroCharge}`$. This is what a "fixed $`\phi`$" boundary honestly
541
543
  means. For example, a Schottky barrier $`\phi_B`$ on n-type material with
542
- $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`.
544
+ $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`. With a carrier linked in equilibrium, a
545
+ pinned contact sets that carrier's density at the edge,
546
+ $`c_{\mathrm{edge}} = c_{\mathrm{ref}} \exp\big((z F\,\mathtt{zeroCharge} - \mu^\circ)/RT\big)`$, so a contact that injects holes at
547
+ $`N_v`$ (their `cRef`, $`\mu^\circ = 0`$) is `zeroCharge: 0`, and a 0.5 eV barrier for them is
548
+ `zeroCharge: -0.5`. This is the contact for a layer of one carrier with nothing to neutralise it
549
+ (a hole-only organic diode): `ohmic()`'s `'bulk'` law, local neutrality, can't hold there.
550
+
551
+ An ideally blocking electrode (inert: no reactions, nothing crossing) is a contact with no
552
+ terminal species and no links, `{ V, phi: 'pinned', zeroCharge }`: the solution's diffuse layer
553
+ is its whole double layer (the validation suite's Macdonald impedance is this cell), and a Stern layer in
554
+ series is `phi: { type: 'capacitive', C }` in place of `'pinned'`. (A metal layer behind a
555
+ capacitive face of large C does the same at more cost, and its C is warned of as a unit slip.)
543
556
 
544
557
  A Schottky contact is that pinned barrier with its carriers passing at their emission velocities:
545
558
  `{ V, terminal: 'e-', species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v: vp, offset: 0 } }, phi: 'pinned', zeroCharge: φ_B }`,
@@ -753,8 +766,12 @@ as for reactions, and the window holds the opposite. Each node's charge balance
753
766
  Gauss's law averaged across the section, $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$,
754
767
  and at $`\varepsilon = 0`$ neutrality $`\rho + a\sigma = 0`$, the mobile species rearranging to supply
755
768
  it (counter-ions in, co-ions out). The port passes the charging current, $`d(\int a\sigma\,dV)/dt`$,
756
- none in a steady state. Capacitances on overlapping windows add (a channel's top and bottom
757
- gates). A capacitance alone, driven by a current, only charges: it starts uncharged, `advance()`
769
+ none in a steady state. `zeroCharge` is in the frame a contact's is (see the capacitive contact
770
+ above): $`V - \phi`$ at zero charge, with $`\phi`$ the device's own, which a bath or contact sets
771
+ (beside a bath referenced to Cl⁻, with the library's μ°, it sits near −1.4 V). Omitted, it's 0, so
772
+ a port held at V = 0 there starts charged by 1.4 V; to start it uncharged, give `zeroCharge` = V −
773
+ φ at its window (`sol.phi` after a solve). Capacitances on overlapping windows add (a channel's
774
+ top and bottom gates). A capacitance alone, driven by a current, only charges: it starts uncharged, `advance()`
758
775
  follows it, and `solve()` refuses (there's no steady state, as for a gate contact driven by one).
759
776
 
760
777
  ```js nocheck
@@ -1004,14 +1021,19 @@ const now = dev.solution(); // snapshot of the current state
1004
1021
  as a transient slows, so give `dtMax` to resolve a signal in time.
1005
1022
  - `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
1006
1023
  it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
1007
- device. A held terminal's voltage is perturbed, or a driven one's current; the other terminals
1024
+ device, its input impedance whether it's held or driven by a current (for a driven one, about
1025
+ the state its drive holds: solve() it held, then set its I, solve(), then impedance()); the other terminals
1008
1026
  keep their drives (held ones at AC ground, driven ones open), so at least one other must be held
1009
1027
  for the current to return (with every other one driven, the impedance is infinite, and an error
1010
1028
  says to measure at a driven one). A terminal behind a resistance can't be the one measured: the
1011
1029
  resistance belongs to the external circuit. The grid must resolve how far the signal reaches,
1012
1030
  and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
1013
1031
  against a 3.4 µm depth put Z 7% off, falling as the cells do; at an electrode, the diffusion
1014
- length $`\sqrt{2D/\omega}`$ at the highest frequency). The result is `{ f, Z: { re, im },
1032
+ length $`\sqrt{2D/\omega}`$ at the highest frequency, and coarser than that, a port's window
1033
+ edges falling mid-cell moved a faradaic C 17%); and a double layer's capacitance wants cells
1034
+ well inside its Debye length (0.1 M NaCl: hmin λ_D/2.4 read C_dl 2.1% low, 0.1 nm 0.5%, 0.05 nm
1035
+ to 5e-4; the warning about unresolved double layers starts only at λ_D). `relError` is the
1036
+ round-off's, not the grid's. The result is `{ f, Z: { re, im },
1015
1037
  relError, warnings }`: `relError[k]`, each Z's estimated error relative to $`|Z|`$ (from
1016
1038
  round-off in the current and the solves' residuals, and cautious: it can exceed the true error
1017
1039
  many times), and the warnings, the steady state's and one where a Z isn't resolved (an error
@@ -1022,7 +1044,10 @@ const now = dev.solution(); // snapshot of the current state
1022
1044
  terminal is driven by a current.
1023
1045
  - `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
1024
1046
  or `I` drops the other (and given `I`, any series `R`): `{ contacts: { right: { I: 0 } } }`
1025
- switches it to open circuit, whether it was held or behind a resistance. A patch that changes only the
1047
+ switches it to open circuit, whether it was held or behind a resistance. Given `V` alone, a terminal
1048
+ behind a resistance stays behind it: `{ V, R: undefined }` takes the resistance off (`R: 0` and
1049
+ `null` are refused, and `undefined` doesn't survive JSON, so a worker's patch says it this way in
1050
+ JavaScript). A patch that changes only the
1026
1051
  terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
1027
1052
  Otherwise the device is rebuilt: the current state carries over while the species, regions
1028
1053
  and terminals are unchanged (on a new grid, interpolated onto its nodes, so a floating gate
package/docs/kit.md CHANGED
@@ -59,8 +59,9 @@ console.log(`I = ${sol.current.toFixed(1)} A/m²`);
59
59
  infinite-recombination contact; `['e-']` alone is selective, and is the current collector on
60
60
  a metal region). `bath(c, reference, drive, { offset, material })` is a bath whose terminal voltage is
61
61
  the level of its `reference` species, as an electrode reversible to it would read (`offset`
62
- places that species relative to the terminal voltage, e.g. to read a solution's potentials
63
- against SHE). `bath(c, drive)`, without a reference, is a bath whose terminal voltage is its
62
+ places that species relative to the terminal voltage, its level at V + `offset`, e.g. to read a
63
+ solution's potentials against SHE). Every species a bath lists is held at its level there, so
64
+ one absent from the bulk (a product) is given as a trace, not 0. `bath(c, drive)`, without a reference, is a bath whose terminal voltage is its
64
65
  φ: an ideal salt bridge, the convention of membrane potentials, so that between two such baths
65
66
  V_right − V_left is the φ difference a voltage clamp sets. A drive is a voltage, a waveform, or
66
67
  `{ V }`, `{ I }`, `{ V, R }`. A bath against a charged layer (an ion exchanger) gives its own
@@ -448,7 +449,10 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
448
449
  term named by its equation; two with the same equation (radiative and SRH recombination, both
449
450
  `e- + h+ = 0`) are told apart by their place, `e- + h+ = 0 (bulkReactions[1])`, and
450
451
  `sol.bulkReactions[k]` has each one's rates. A species at rest
451
- (in equilibrium, flows only of round-off against what it could carry) isn't listed.
452
+ (in equilibrium, flows only of round-off against what it could carry) isn't listed. Where none
453
+ is left, it says "equilibrium" only if no flow comes within ε of what it could carry; otherwise
454
+ it names the largest, which may be round-off or a real flow too small to check this way (a
455
+ reverse diode's 1 µA/m², 4e-15 of what its holes could carry).
452
456
  - **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
453
457
  what came in.
454
458
  - **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
package/docs/numerics.md CHANGED
@@ -661,10 +661,16 @@ About a steady state, a small sinusoidal excitation at one terminal gives, to fi
661
661
  $`v \cdot K`$, displacement), and likewise $`C`$ and $`C'`$ for the terminal currents. The
662
662
  time-derivative parts are read off two assemblies, $`J(dt) = J + M/dt`$, with a tiny $`dt`$ so that
663
663
  the subtraction loses nothing.
664
- - **At the measured terminal $`T`$,** a held voltage is perturbed ($`\delta V_T = 1`$, and $`\delta I_T`$
665
- read from its $`C`$ row), or a driven current ($`\delta I_T = 1`$, and $`\delta V_T`$ solved for).
666
- $`Z = \delta V_T/\delta I_T`$, with $`I`$ into the device. The other terminals keep their drives:
667
- held ones at AC ground, driven ones open.
664
+ - **At the measured terminal $`T`$,** its voltage is perturbed ($`\delta V_T = 1`$) and
665
+ $`\delta I_T`$ read, held or driven by a current alike: $`Z = \delta V_T/\delta I_T`$ is its input
666
+ impedance either way, with $`I`$ into the device. (Solving a driven one's $`\delta V_T`$ for a unit
667
+ current instead read its current through its own circuit row, at the contact alone: a reverse
668
+ diode's 4.3% off $`dV/dI`$.) The other terminals keep their drives: held ones at AC ground,
669
+ driven ones open. A floating one's circuit row reads its current where that reading's error is
670
+ least: at its contact, or at any cut between it and a port's window, the same current (through a
671
+ metal it's the round-off of $`G\,\Delta\eta`$); and where every terminal but $`T`$ floats, the
672
+ response to $`T`$ with them held (nearly every level shifted alike, its current a difference of
673
+ large terms) is read through the identity that shifting every terminal alike passes nothing.
668
674
 
669
675
  Each frequency costs one complex block-Thomas factorisation (rows scaled by their largest
670
676
  entry), plus one back-substitution per floating terminal for the bordering. Conserved (blocked)
@@ -945,6 +951,17 @@ A solve that fails falls back stage by stage:
945
951
  the floated solve fails, held solves find that voltage instead, by regula falsi within the
946
952
  bracket, to 1e-10 of the current. (Not a terminal at no current that keeps an amount or a
947
953
  charge: every voltage passes nothing there.)
954
+ - **A resistance.** A terminal behind a resistance R, its source at $`V_{\mathrm{src}}`$, is
955
+ found the same way, held at voltages marched from the held terminal's level until the
956
+ device's current crosses the load line's, $`(V_{\mathrm{src}} - V)/R`$, and floated from
957
+ beside it. Held at its source instead and the resistance put back, the state would sit
958
+ $`IR`$ from the answer, and the pseudo-transient relaxes that over $`RC`$ at the pace Newton
959
+ takes through the swing: a lit Schottky diode passing 0.9 A/m² took 0.05 s behind
960
+ 1 Ω·m² and 105 s behind 10⁵ Ω·m² that way, and takes 33 ms behind any R by the load line
961
+ (80 fuzzed diodes from 0.01 to 10⁷ Ω·m²: 1835 s → 34 s). That route remains for terminals the load line doesn't
962
+ fit: several behind resistances, or one beside terminals all driven by currents, where
963
+ Kirchhoff gives its current and it's held at $`V_{\mathrm{src}} - IR`$ (from there, putting
964
+ $`R`$ back moves nothing).
948
965
  - **The voltages.** The right terminal's voltage ramped to its target: from the contacts'
949
966
  voltages at the last converged solve when the state is that solution (the left one first,
950
967
  where it moved), and otherwise from level terminals, where a cold start is consistent. (A
@@ -97,7 +97,7 @@ recipes.
97
97
 
98
98
  The validation suite shows driftlet is right where it converges. Whether it converges, on
99
99
  devices nobody tuned it for, is what `npm run stress` tests. It builds random but plausible
100
- devices in five families:
100
+ devices in six families:
101
101
 
102
102
  | Family | What's drawn |
103
103
  |---|---|
@@ -105,6 +105,7 @@ devices in five families:
105
105
  | MOS capacitors | Si, Ge or GaAs, doped 1e15–1e18 cm⁻³; oxide 1–20 nm; Au, Al or Pt gate at any flat-band offset; with or without generation |
106
106
  | Electrolyte cells | one to three salts from the data library's ions, 0.1 mM–1 M, 1 µm–1 mm; double layers resolved or strictly neutral; bath, reversible or blocking electrodes |
107
107
  | Electrodes | Ag, Cu or Zn deposition, or Fe³⁺/Fe²⁺ on platinum, with Butler–Volmer kinetics over four decades of k₀; with or without a supporting salt; against a second electrode or a bath |
108
+ | Reacting solutions | complexations (Na⁺ + A⁻ = NaA, B + A⁻ = BA⁻, …) in 2–3 regions whose faces block some species, so conserved combinations overlap; against a bath, another bath, closed or a K⁺ electrode; sometimes an electrode at no current reducing a closed M⁺ |
108
109
  | Liquid junctions | two mixed solutions, 1 µM–1 M, sharp or graded, at open circuit |
109
110
 
110
111
  Each device goes through what a user would do with it: a cold solve at no bias, and one at a
@@ -114,26 +115,30 @@ after a voltage step; and the impedance about equilibrium, from far below its sl
114
115
  to 1 GHz. Every result is judged by `check()`, and by what must hold whatever the device:
115
116
  levels flat at equilibrium with no current, warm and cold solves agreeing, the current driven
116
117
  being the one passed, the impedance passive (to 1e-3 of |Z|) and, at low frequency, equal to
117
- the steady dI/dV, a MOS capacitance never above its oxide's, and its gate charge with the back
118
+ the steady dI/dV where it says it's resolved (`relError` under 1%), a MOS capacitance never above its oxide's, and its gate charge with the back
118
119
  contact moved what it is with the gate moved the other way. Each case is seeded from its family and index, so
119
120
  any failure reruns alone (`npm run stress -- semi 79`).
120
121
 
121
- At 500 devices per family (about three minutes on a desktop), 11,880 of 11,881 scenarios pass:
122
+ At 500 devices per family (about five minutes on a desktop), 16,379 of 16,381 scenarios pass:
122
123
 
123
- | Family | Cold at 0 V | Cold at bias | Warm sweep | Open circuit, current or back contact | Transient | Impedance |
124
- |---|---|---|---|---|---|---|
125
- | Semiconductor stacks | 500/500 | 500/500 | 500/500 | 105/105 | 500/500 | 395/395 |
126
- | MOS capacitors | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
127
- | Electrolyte cells | 500/500 | 500/500 | 500/500 | | 500/500 | 381/381 |
128
- | Electrodes | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
129
- | Liquid junctions | | | | 500/500 | 499/500 | |
124
+ | Family | Cold at 0 V | Cold at bias | Species reversed | Warm sweep | Open circuit, current or back contact | Transient | Impedance |
125
+ |---|---|---|---|---|---|---|---|
126
+ | Semiconductor stacks | 500/500 | 500/500 | 500/500 | 499/500 | 105/105 | 500/500 | 395/395 |
127
+ | MOS capacitors | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
128
+ | Electrolyte cells | 500/500 | 500/500 | 500/500 | 500/500 | | 500/500 | 381/381 |
129
+ | Electrodes | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
130
+ | Reacting solutions | 500/500 | 500/500 | 500/500 | 500/500 | | 500/500 | |
131
+ | Liquid junctions | | | | | 500/500 | 499/500 | |
130
132
 
131
133
  (Impedance only where the device has an equilibrium to linearise about; open circuit only for
132
134
  lit cells.) The MOS back contact came last: before steady solves held a group of reacting
133
135
  carriers flat where it reaches one contact alone, 43 of the 500 failed it, 5 of them silently
134
- (a gate charge up to 2× off). The one that fails is among the known limits below: a junction on a grid far too
135
- coarse for its double layers.
136
- `bench/stress.json` keeps the summary.
136
+ (a gate charge up to 2× off). Of the two that fail, one is among the known limits below, a junction on a grid far too
137
+ coarse for its double layers; the other is a diode whose current, a small difference of large fluxes, a warm sweep
138
+ reaches to 1.8e-6 of what a cold solve does, against the 1e-6 the check asks: Newton's tolerance, not the physics
139
+ (its own current varies 2.8e-6 along the device, its balances converged).
140
+ `bench/stress.json` keeps the summary, and CI runs `npm run stress -- --check` against it: any
141
+ scenario failing that passes there fails the build.
137
142
 
138
143
  Most of what driftlet's numerics do differently began as a failure here: the impedance's
139
144
  current read across the quietest cut of the device rather than at the contact, GMRES judged by
@@ -73,6 +73,19 @@ of exponentially. With one species it's Langmuir filling of sites. The dilute re
73
73
  $`c_{\mathrm{ref}}`$ is kept. To use the half-filling convention of site models
74
74
  ($`e^\zeta = x/(1-x)`$), set `cRef` equal to `cMax`.
75
75
 
76
+ `cMax` from an ion's size: a cube of side $`a`$ per site gives $`c_{\mathrm{max}} = 1/(N_A a^3)`$, so
77
+ $`a`$ = 0.66 nm (a hydrated ion, about) is 5.8 M, and 0.5 nm is 13 M. The model counts sites only:
78
+ D falling with concentration, solvent counterflow and ion pairing are left out. On a material from
79
+ `aqueous()`, add it to the material it returns:
80
+
81
+ ```js
82
+ import { AVOGADRO } from 'driftlet';
83
+ import { aqueous } from 'driftlet/kit';
84
+
85
+ const aq = aqueous(['Cu2+', 'SO42-', 'H+']);
86
+ aq.materials.water.statistics = [{ type: 'lattice', species: ['Cu2+', 'SO42-', 'H+'], cMax: 1 / (AVOGADRO * 0.66e-9 ** 3) }];
87
+ ```
88
+
76
89
  ### Redlich–Kister
77
90
 
78
91
  One species on its own $`c_{\mathrm{max}}`$ sites, with the excess Gibbs energy of mixing per site
@@ -92,7 +105,11 @@ $`\ln\gamma_i = -z_i^2 \ell_B \kappa / (2(1 + \kappa a))`$, with the Bjerrum len
92
105
  $`\ell_B = e^2/(4\pi\varepsilon kT)`$ and $`\kappa^2 = F^2 \sum z^2 c / (\varepsilon RT)`$ from the
93
106
  solvent permittivity `epsr` (which defaults to the material's, if that's non-zero) and a
94
107
  distance of closest approach `a` (default 0, the limiting law). It stops being convex at high
95
- ionic strength, and the solver then reports a failure.
108
+ ionic strength, and the solver then reports a failure. It goes on a material's `statistics`, as
109
+ any model does; with `aqueous()`, on the piece it returns: `lib.materials.water.statistics =
110
+ [{ type: 'debye-huckel', species: ['H+', 'Cl-'], a: 4.5e-10 }]`. `a` is an ion size (a few
111
+ ångström: tabulated per ion, as Kielland's sizes, or fitted to measured γ±), and it matters:
112
+ in 10 mM HCl, 4.5 Å against the limiting law moves a concentration cell's voltage by 0.6 mV.
96
113
 
97
114
  Single-ion activity coefficients are a convention: only neutral combinations (the mean
98
115
  activity $`\gamma_\pm`$) are measurable. In a strictly neutral ($`\varepsilon = 0`$) material only
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.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';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/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.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';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
@@ -317,6 +317,8 @@ const mos = (Vg, zeroCharge) =>
317
317
  layer('Si', units.um(1), { name: 'Si', acceptors: NA }),
318
318
  ohmic(0),
319
319
  ],
320
+ // The inversion layer's supply: with it, the low-frequency C–V at every frequency, even
321
+ // impedance at 1 MHz. Leave it out for the high-frequency curve (see below the code).
320
322
  ports: [{ name: 'channel', region: 'Si', from: 0, to: units.nm(1), V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' } }],
321
323
  grid: { hmin: units.nm(0.25), hmax: units.nm(50), ratio: 1.15 },
322
324
  });
@@ -383,8 +385,9 @@ electrons' V minus φ in the bulk (with the library's μ°), and a Cl⁻ termina
383
385
  that, so an electrode held at 0 V beside it is strongly anodic: `describe(def)` says where SHE
384
386
  sits, and an offset of that much (`bath(c, 'Cl-', 0, { offset })`) makes the terminal read SHE.
385
387
  The working electrode's current is positive when oxidising. The layer's resistance is in series
386
- (uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of salt still
387
- leaves the limiting current about 1% off pure diffusion.
388
+ (uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of a 1:1 salt
389
+ still leaves the limiting current about 1% off pure diffusion, and migration counts for more with
390
+ more charge: Cu²⁺ in 1 M H₂SO₄ (20:1) reads 3% above it, with no acid 2×.
388
391
 
389
392
  ```js
390
393
  import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
@@ -555,7 +558,8 @@ species }` (its flux) or `{ interface: f, gate }`.
555
558
  (docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
556
559
  battery sits at the OCV of its state of charge, a floating gate keeps its charge.
557
560
  - **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
558
- { terminal })` the small-signal Z(f) about the steady state, in Ω·m².
561
+ { terminal })` the small-signal Z(f) about the steady state, in Ω·m²: `{ f, Z: { re, im } }`,
562
+ each an array over the frequencies (`Z.re[k]`, not `Z[k].re`).
559
563
 
560
564
  ```js
561
565
  import { Device, units, FARADAY } from 'driftlet';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.3.0",
3
+ "version": "1.3.3",
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": {
package/src/checks.js CHANGED
@@ -181,6 +181,12 @@ function balance(device, sol) {
181
181
  // 1e-19 of its scale, to 1e-15), and round-off doesn't. (Above 1e-8 of the scale, any.)
182
182
  const balanced = (l) => Math.abs(l.terms.reduce((a, t) => a + t.rate, 0)) <= 1e-9 * size(l);
183
183
  const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l) || (size(l) > 1e-20 * scale(l) && balanced(l)));
184
+ // What's left unbalanced can be round-off or a small real flow the solve didn't settle (a
185
+ // reverse diode behind a resistance, 1.3 µA/m² at 4e-15 of its scale, balanced to 8e-5 by a
186
+ // solve that stalled), and nothing here tells them apart: round-off reaches 8e-12 of the scale
187
+ // in the stress devices, and one in a thousand of those flows balances to 1%. So above ε of the
188
+ // scale it isn't called equilibrium: the largest is named, as what balance can't check.
189
+ const unsure = ledgers.filter((l) => !moving.includes(l) && size(l) > 1e-16 * scale(l)).sort((a, b) => size(b) / scale(b) - size(a) / scale(a))[0];
184
190
  let worst = 0;
185
191
  const lines = [];
186
192
  for (const l of moving) {
@@ -195,7 +201,9 @@ function balance(device, sol) {
195
201
  const ok = worst < 1e-6;
196
202
  const summary =
197
203
  moving.length === 0
198
- ? 'nothing flows or reacts (equilibrium)'
204
+ ? unsure
205
+ ? `nothing flows that it can tell from round-off: the largest, ${unsure.title} at ${sig(size(unsure))} ${units.rate}${unsure.z !== 0 ? ` (${sig(FARADAY * Math.abs(unsure.z) * size(unsure))} ${units.current})` : ''}, is ${sig(size(unsure) / scale(unsure), 1)} of what it could carry and doesn't balance (to ${sig(Math.abs(unsure.terms.reduce((a, t) => a + t.rate, 0)) / size(unsure), 1)}), so it's round-off, or a flow too small for this check`
206
+ : 'nothing flows or reacts (equilibrium)'
199
207
  : `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, ${units.rate}, + in or made`;
200
208
  // The largest current any charged species carries anywhere: the scale a net current is read against.
201
209
  const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
package/src/describe.js CHANGED
@@ -114,6 +114,8 @@ export function unitWarnings(def) {
114
114
  if (!isObject(p)) return;
115
115
  capacitance(`ports[${k}].capacitance.C`, p.capacitance?.C);
116
116
  kinetics(`ports[${k}]`, p.reactions);
117
+ // (an area per volume typed in cm²/cm³ is 100 times too small: a mesh at 10 cm²/cm³ went by)
118
+ if (typeof p.area === 'number' && p.area > 0 && p.area < 100) warn(`ports[${k}].area`, `${num(p.area)} m²/m³ is under 1 cm² of electrode per cm³; area is m²/m³ (1 cm²/cm³ = 100 m²/m³)`);
117
119
  });
118
120
  for (const side of ['left', 'right']) {
119
121
  const c = def.contacts?.[side];
package/src/device.js CHANGED
@@ -811,6 +811,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
811
811
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
812
812
  const i = speciesIndex.get(sname);
813
813
  need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${reg.name} (material '${mat.name}'), so its level outside means nothing here`);
814
+ need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
814
815
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
815
816
  }
816
817
  const zb = bathZeta(mat, cb, reg.background);
@@ -1359,6 +1360,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1359
1360
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
1360
1361
  const i = speciesIndex.get(sname);
1361
1362
  need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
1363
+ need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
1362
1364
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
1363
1365
  charge += species[i].z * cb[i];
1364
1366
  scale += Math.abs(species[i].z) * cb[i];
@@ -1413,6 +1415,20 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1413
1415
  links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0),
1414
1416
  `${path}.phi: 'bulk' needs at least one connected charged species at this contact`,
1415
1417
  );
1418
+ // Neutral at the contact must be possible: an undoped layer of holes alone never is, and
1419
+ // ohmic(V, ['h+']) on one failed with "nothing specific showed why" (round 16's cold agent).
1420
+ if (!mat.conductor && !mat.phiFree && !mat.models.some((md) => md.type === 'insertion')) {
1421
+ const signs = new Set(species.flatMap((sp, i) => (mat.present[i] && sp.z !== 0 ? [Math.sign(sp.z)] : [])));
1422
+ const fixed = Math.sign(region.fixedCharge ?? 0);
1423
+ if (signs.size === 1 && !(fixed !== 0 && !signs.has(fixed))) {
1424
+ const [only] = signs;
1425
+ throw new DeviceError(
1426
+ `${path}.phi: 'bulk' (what ohmic() and a bath use) holds the contact neutral, but '${region.name ?? region.material}' can't be: ` +
1427
+ `its only charged species are ${only > 0 ? 'positive' : 'negative'} and no fixed charge balances them (an undoped layer of one carrier). ` +
1428
+ "For a contact that sets the carrier's density, pin φ instead: phi: 'pinned' with zeroCharge, the contact's level against the carrier's (see docs/device.md, contacts).",
1429
+ );
1430
+ }
1431
+ }
1416
1432
  }
1417
1433
 
1418
1434
  if (terminal !== null) {