driftlet 1.3.0 → 1.3.2

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.2/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
@@ -541,6 +543,12 @@ levels are that material's, and the end node takes them through a Donnan step.
541
543
  means. For example, a Schottky barrier $`\phi_B`$ on n-type material with
542
544
  $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`.
543
545
 
546
+ An ideally blocking electrode (inert: no reactions, nothing crossing) is a contact with no
547
+ terminal species and no links, `{ V, phi: 'pinned', zeroCharge }`: the solution's diffuse layer
548
+ is its whole double layer (the validation suite's Macdonald impedance is this cell), and a Stern layer in
549
+ series is `phi: { type: 'capacitive', C }` in place of `'pinned'`. (A metal layer behind a
550
+ capacitive face of large C does the same at more cost, and its C is warned of as a unit slip.)
551
+
544
552
  A Schottky contact is that pinned barrier with its carriers passing at their emission velocities:
545
553
  `{ V, terminal: 'e-', species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v: vp, offset: 0 } }, phi: 'pinned', zeroCharge: φ_B }`,
546
554
  with Bethe's thermionic-emission velocity $`v = A^* T^2/(F N_c)`$ ($`N_c`$ in mol/m³, the data
@@ -753,8 +761,12 @@ as for reactions, and the window holds the opposite. Each node's charge balance
753
761
  Gauss's law averaged across the section, $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$,
754
762
  and at $`\varepsilon = 0`$ neutrality $`\rho + a\sigma = 0`$, the mobile species rearranging to supply
755
763
  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()`
764
+ none in a steady state. `zeroCharge` is in the frame a contact's is (see the capacitive contact
765
+ above): $`V - \phi`$ at zero charge, with $`\phi`$ the device's own, which a bath or contact sets
766
+ (beside a bath referenced to Cl⁻, with the library's μ°, it sits near −1.4 V). Omitted, it's 0, so
767
+ a port held at V = 0 there starts charged by 1.4 V; to start it uncharged, give `zeroCharge` = V −
768
+ φ at its window (`sol.phi` after a solve). Capacitances on overlapping windows add (a channel's
769
+ top and bottom gates). A capacitance alone, driven by a current, only charges: it starts uncharged, `advance()`
758
770
  follows it, and `solve()` refuses (there's no steady state, as for a gate contact driven by one).
759
771
 
760
772
  ```js nocheck
@@ -1004,14 +1016,19 @@ const now = dev.solution(); // snapshot of the current state
1004
1016
  as a transient slows, so give `dtMax` to resolve a signal in time.
1005
1017
  - `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
1006
1018
  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
1019
+ device, its input impedance whether it's held or driven by a current (for a driven one, about
1020
+ the state its drive holds: solve() it held, then set its I, solve(), then impedance()); the other terminals
1008
1021
  keep their drives (held ones at AC ground, driven ones open), so at least one other must be held
1009
1022
  for the current to return (with every other one driven, the impedance is infinite, and an error
1010
1023
  says to measure at a driven one). A terminal behind a resistance can't be the one measured: the
1011
1024
  resistance belongs to the external circuit. The grid must resolve how far the signal reaches,
1012
1025
  and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
1013
1026
  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 },
1027
+ length $`\sqrt{2D/\omega}`$ at the highest frequency, and coarser than that, a port's window
1028
+ edges falling mid-cell moved a faradaic C 17%); and a double layer's capacitance wants cells
1029
+ 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
1030
+ to 5e-4; the warning about unresolved double layers starts only at λ_D). `relError` is the
1031
+ round-off's, not the grid's. The result is `{ f, Z: { re, im },
1015
1032
  relError, warnings }`: `relError[k]`, each Z's estimated error relative to $`|Z|`$ (from
1016
1033
  round-off in the current and the solves' residuals, and cautious: it can exceed the true error
1017
1034
  many times), and the warnings, the steady state's and one where a Z isn't resolved (an error
@@ -1022,7 +1039,10 @@ const now = dev.solution(); // snapshot of the current state
1022
1039
  terminal is driven by a current.
1023
1040
  - `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
1024
1041
  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
1042
+ switches it to open circuit, whether it was held or behind a resistance. Given `V` alone, a terminal
1043
+ behind a resistance stays behind it: `{ V, R: undefined }` takes the resistance off (`R: 0` and
1044
+ `null` are refused, and `undefined` doesn't survive JSON, so a worker's patch says it this way in
1045
+ JavaScript). A patch that changes only the
1026
1046
  terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
1027
1047
  Otherwise the device is rebuilt: the current state carries over while the species, regions
1028
1048
  and terminals are unchanged (on a new grid, interpolated onto its nodes, so a floating gate
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)
@@ -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
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.2/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/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.2/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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/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/impedance.js CHANGED
@@ -22,33 +22,44 @@
22
22
  // → { f, Z: { re, im }, relError, profiles? }, Z in Ω·m² (Ω for a non-planar device) at each
23
23
  // frequency.
24
24
  // Throws a SolverError where the terminal doesn't exist, has a series resistance, or every
25
- // other terminal is driven by a current (no return path: Z infinite), or where a frequency
26
- // isn't positive and finite.
25
+ // other terminal is driven by a current (no return path: Z infinite), where a frequency
26
+ // isn't positive and finite, or where the linearised system is singular at one.
27
27
  // Linearised about the present state: (J + iωS)·δx + Σ_k B_k δV_k = 0 for the grid's rows and,
28
28
  // for each floating terminal k (driven by a current), (C_k + iωC′_k)·δx + (DI_k + iωDI′_k) δV_k
29
29
  // + Σ_k2 (DX + iωDX′)_k,k2 δV_k2 = δI_k, with J the steady Jacobian (dt = ∞) and S, C′, DI′,
30
- // DX′ the storage parts, (X(dt) − X(∞))·dt at dt = 1e-30. A floating terminal that alone feeds
30
+ // DX′ the storage parts, (X(dt) − X(∞))·dt at dt = 1e-30. The measured terminal is always
31
+ // excited by its voltage: driven by a current, it's measured as if held (δV = 1, Z = 1/δI, the
32
+ // same input impedance), its circuit row out of the small system, its current read as a held
33
+ // one's (with its circuit law's change where it has one); its profiles then scaled by Z, per
34
+ // unit current. A floating terminal that alone feeds
31
35
  // a conserved combination (a circuit law: a constraint or time law with its circuit row) has
32
36
  // its current read by that combination's change at every frequency, iω F δS/λ plus what a
33
- // resistance passes: at dt = 1e-30 every circuit law reads by the change (this.lawForm), and
34
- // its steady parts are C = 0, DI = 1/R (0 driven by a current), DX = 0. The measured terminal: held at a
35
- // voltage, δV = 1 and Z = 1/δI, δI its current into the device; driven by a current, δI = 1
36
- // and Z = δV. Every other terminal keeps its drive: held, δV = 0 (AC ground); driven, δI = 0
37
- // (AC open).
37
+ // resistance passes: at dt = 1e-30 every circuit law with a current to count (λ set) reads by
38
+ // the change (this.lawForm), and
39
+ // its steady parts are C = 0, DI = 1/R (0 driven by a current), DX = 0. δI is the measured
40
+ // terminal's current into the device. Every other terminal keeps its drive: held, δV = 0 (AC
41
+ // ground); driven, δI = 0 (AC open).
38
42
  // Each frequency: J + iωS with each row divided by its largest entry, factorised, preconditions
39
43
  // GMRES (m 12, 8 restarts, tol 1e-8; its best iterate kept) on the exact J·v (difference form).
40
- // Solved: y, the response with the floating terminals held (to the measured one's δV, or none),
44
+ // A floating contact otherwise reads its current, in its circuit row, at the reading of least
45
+ // error by the solved responses (its own, or any cut between it and a port's window: the same
46
+ // current); and where every other terminal floats, a reading with no direct V dependence (a
47
+ // cut's, a law's change) reads y as Σ X_b, every terminal's uniform shift passing nothing.
48
+ // Solved: y, the response with the floating terminals held (to the measured one's δV = 1),
41
49
  // and X_k, to each floating terminal's unit δV_k; then the floating terminals' rows, a small
42
50
  // complex system, for their δV; δx = y − Σ X_k δV_k.
43
51
  // The current read (held terminal): DI + iωDI′ + Σ (DX + iωDX′) δV + (C + iωC′)·δx, its error
44
- // estimated from its terms' round-off and the solves' residuals; for a contact, also across
52
+ // estimated from its terms' round-off, the solves' residuals, and each unknown's round-off in
53
+ // its own row, ε Σ_k |A_jk x_k| / |A_jj| (a metal's current unknown, G Δη to a huge G: a
54
+ // bound, cautious where the coupled system pins it better); for a contact, also across
45
55
  // each cut (_currentCuts: a segment's linearised total current, conduction and displacement,
46
56
  // between that contact and the nearest port's window where there are ports), the reading with
47
57
  // the least error taken; and where every other terminal floats, by Kirchhoff, Σ δV_k/R_k over
48
58
  // those behind a resistance, its error (ε + the solves' residual)·|δV_k|/R_k. With no floating
49
59
  // terminal, cuts, and that error over 1% of the current:
50
60
  // the response split into each region's uniform shift (_regionShift) and a remainder solved to
51
- // GMRES's floor, the shift's effect taken only at the rows it can touch (_regionEdgeRows), and
61
+ // GMRES's floor, the shift's effect taken only at the rows it can touch (_regionEdgeRows; a
62
+ // metal's carriers shifted, its current unknown not), and
52
63
  // that reading kept where its error is less.
53
64
  // relError: per frequency, the chosen reading's error estimate over |δI| (held); driven, the
54
65
  // same estimate of its unit current read back from δx. Cautious: it can exceed the true error
@@ -75,8 +86,7 @@
75
86
  // Also written, left as the last assembly at dt = ∞ left them: this.sourceTime (the present
76
87
  // time), cOld, thOld, th0Old, gateOld (the present state's), contactDStart, portQStart, the
77
88
  // residual, the Jacobian and the terminal terms, sysRest and the concentrations; this.lawForm
78
- // (every circuit law by the change, until a step sets it afresh). _regionShift treats every
79
- // node's slot 0 as φ̂, a metal's flux unknown too: harmless while a metal region is one cell.
89
+ // (every circuit law by the change, until a step sets it afresh).
80
90
  // Tests: test/impedance.test.js (Macdonald's blocking electrodes, a junction's depletion
81
91
  // capacitance, a MOS capacitor at both terminals, ports, profiles, Fermi–Dirac against
82
92
  // Boltzmann); the demos' EIS.
@@ -218,8 +228,8 @@ export class SmallSignal {
218
228
  * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
219
229
  * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
220
230
  * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
221
- * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
222
- * from C, or a driven current is perturbed and its voltage solved for. The other terminals
231
+ * At the measured terminal, its voltage is perturbed (rhs = −B δV) and its current read, held
232
+ * or driven by a current alike (one input impedance; see the contract). The other terminals
223
233
  * keep their drives: held ones at AC ground, driven ones at AC open circuit.
224
234
  * Z = δV/δI with I into the device (positive real part for a passive device).
225
235
  * @param {ArrayLike<number>} frequencies Hz
@@ -249,8 +259,14 @@ export class SmallSignal {
249
259
  `its impedance is infinite. Measure at one of those instead (impedance(f, { terminal: '${others[0].name}' }), its voltage against '${terminal}'), or hold one of them at a voltage`,
250
260
  );
251
261
  }
252
- const N = nB * M, fl = this.floating, K = fl.length;
253
- const kirchhoff = dT.kind === 'V' && others.every((t) => t.drive.kind === 'I' || t.drive.R > 0);
262
+ // Z is the device's input impedance at kT, the same whether its voltage is perturbed and its
263
+ // current read or the other way round. A terminal driven by a current is measured as if held
264
+ // (δV = 1, Z = 1/δI) to read that current as a held one's is (cuts, Kirchhoff, a circuit law's
265
+ // change): read through its own circuit row instead, at its contact alone, it was a metal's or
266
+ // a barrier's round-off (a reverse-biased diode's contact 4.3% off dV/dI; a Schottky
267
+ // contact's Re Z −32 for 1282), with an error estimate that never saw it.
268
+ const N = nB * M, fl = this.floating.filter((k) => k !== kT), K = fl.length;
269
+ const kirchhoff = others.every((t) => t.drive.kind === 'I' || t.drive.R > 0);
254
270
  this.sourceTime = this.time;
255
271
  this.computeConcentrations();
256
272
  this.cOld.set(this.c);
@@ -271,11 +287,14 @@ export class SmallSignal {
271
287
  // flux at dt = ∞ and by the change at dt = 1e-30, a floating gate's current was counted
272
288
  // twice (its capacitance 2× dQ/dV); read by the flux throughout, through a metal it is the
273
289
  // round-off of G Δη (a gate behind 1 kΩ·m², measured at the back contact: Re Z = −2.7).
274
- this.lawForm = new Map([...this.constraints, ...this.timeLaws].map((cs) => [cs, true]));
290
+ // (A law that counts no current, λ unset, as for a terminal at no current that feeds
291
+ // nothing, can't be read by its change: forced to, a closed Fe³⁺/Fe²⁺ cell's Z was NaN.)
292
+ const byChange = (cs) => cs.circuit >= 0 && !(cs.contacts?.length > 0) && Number.isFinite(cs.lambda) && cs.lambda !== 0;
293
+ this.lawForm = new Map([...this.constraints, ...this.timeLaws].map((cs) => [cs, byChange(cs)]));
275
294
  this.assemble(dts);
276
295
  for (const cs of [...this.constraints, ...this.timeLaws]) {
277
296
  const k = cs.circuit;
278
- if (k < 0 || !fl.includes(k) || cs.contacts?.length > 0) continue;
297
+ if (!byChange(cs) || !(fl.includes(k) || (k === kT && dT.kind === 'I'))) continue;
279
298
  Ct[k].fill(0);
280
299
  DI[k] = this.terms[k].drive.R > 0 ? 1 / this.terms[k].drive.R : 0;
281
300
  DX[k].fill(0);
@@ -288,11 +307,31 @@ export class SmallSignal {
288
307
  // Where the measured contact's current may be read across a segment: anywhere without
289
308
  // ports; with them, only between it and the nearest window (a port's exchange takes current
290
309
  // in or out along it). Never for a port's own terminal.
291
- let cuts = kT < 2 ? this._currentCuts() : null;
292
- if (cuts && this.model.ports.length > 0) {
293
- const windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
294
- const lo = Math.min(...windows), hi = Math.max(...windows);
295
- cuts = cuts.filter((cut) => (kT === 0 ? cut.s + 1 <= lo : cut.s >= hi));
310
+ const allCuts = this._currentCuts(), windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
311
+ const lo = windows.length ? Math.min(...windows) : Infinity, hi = windows.length ? Math.max(...windows) : -Infinity;
312
+ const cutsOf = (k) => allCuts.filter((cut) => (k === 0 ? cut.s + 1 <= lo : cut.s >= hi));
313
+ const cuts = kT < 2 ? cutsOf(kT) : null;
314
+ // A floating contact on a metal: its circuit row's current through the metal is the current
315
+ // unknown G Δη, round-off to a huge G (a Pt electrode behind 3000 Ω·m², read at the bath: Z −
316
+ // R came out 70.5 for 3070.5). Any cut between it and a port's window carries the same current
317
+ // (a metal stores none; the total current is continuous through its face), so each frequency
318
+ // reads it where its error, by the solved responses, is least (see readings, below).
319
+ const lawRead = new Set([...this.constraints, ...this.timeLaws].filter(byChange).map((cs) => cs.circuit));
320
+ const choices = new Map(); // k → [{ c, cs, di, dis, dx, dxs }]: its own reading, then each cut's
321
+ for (const k of fl) {
322
+ if (k > 1 || lawRead.has(k)) continue;
323
+ const own = { c: Ct[k].slice(), cs: Cs[k].slice(), di: DI[k], dis: DIs[k], dx: DX[k].slice(), dxs: DXs[k].slice() }, list = [own];
324
+ const sgn = k === 0 ? 1 : -1, R = this.terms[k].drive.R;
325
+ for (const cut of cutsOf(k)) {
326
+ const c = new Float64Array(own.c.length), cs = new Float64Array(own.c.length);
327
+ cut.idx.forEach((x, a) => {
328
+ if (this.rix[x] >= this.sys.size) return;
329
+ c[this.rix[x]] += sgn * cut.g[a];
330
+ cs[this.rix[x]] += sgn * cut.d[a];
331
+ });
332
+ list.push({ c, cs, di: R > 0 ? 1 / R : 0, dis: 0, dx: new Float64Array(own.dx.length), dxs: new Float64Array(own.dx.length) });
333
+ }
334
+ if (list.length > 1) choices.set(k, list);
296
335
  }
297
336
  // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
298
337
  // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
@@ -348,7 +387,12 @@ export class SmallSignal {
348
387
  }
349
388
  }
350
389
  }
351
- csys.factor();
390
+ try {
391
+ csys.factor();
392
+ } catch (err) {
393
+ // (it threw a bare Error, its block number all it said: a cell 2.8 V off its pzc)
394
+ throw new SolverError(`impedance: the linearised equations are singular at ${f} Hz (${err.message}): some part of the device is held by nothing at that frequency, as a double layer far from where it balances can leave it. See the steady state's warnings (solve()).`);
395
+ }
352
396
  const sx = (X, vr, out) => {
353
397
  for (let blk = 0; blk < nB; blk++) {
354
398
  const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
@@ -404,24 +448,77 @@ export class SmallSignal {
404
448
  };
405
449
  let accuracy = 1e-14; // (relative, of the solves: the readouts' error is weighed with it)
406
450
  // y: the response with the floating terminals held; X_k: to a unit δV_k.
407
- const heldT = dT.kind === 'V';
408
451
  for (let j = 0; j < Nc; j++) {
409
- rr[j] = heldT ? -Bt[kT][j] * scale[j] : 0;
452
+ rr[j] = -Bt[kT][j] * scale[j];
410
453
  ri[j] = 0;
411
454
  }
412
- solve(rr, ri, yr, yi, heldT ? kT : -1, heldT ? 1 : 0);
455
+ solve(rr, ri, yr, yi, kT, 1);
413
456
  fl.forEach((k, a) => {
414
457
  for (let j = 0; j < Nc; j++) rr[j] = Bt[k][j] * scale[j];
415
458
  ri.fill(0);
416
459
  solve(rr, ri, Xr[a], Xi[a], k, -1);
417
460
  });
461
+ // Each unknown's round-off in its own row: |x_j| to ε Σ_k |A_jk x_k| / |A_jj|, A = J + iωS. A
462
+ // metal's current unknown, G Δη to a huge G, is known only to ε G |δη| this way, which the
463
+ // terms' round-off alone never saw (a platinum electrode's current read 20% off at 1 nHz,
464
+ // its estimate 1e-8).
465
+ const rowFloor = (xr0, xi0) => {
466
+ const out = new Float64Array(Nc);
467
+ for (let blk = 0; blk < nB; blk++) {
468
+ const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
469
+ for (let r = 0; r < m; r++) {
470
+ let acc = 0;
471
+ const row = (X, o, len, x0) => {
472
+ for (let cc = 0; cc < len; cc++) acc += (Math.abs(J[X][o + cc]) + w * Math.abs(S[X][o + cc])) * Math.hypot(xr0[x0 + cc], xi0[x0 + cc]);
473
+ };
474
+ if (mp) row('A', offA[blk] + r * mp, mp, offX[blk - 1]);
475
+ row('B', offB[blk] + r * m, m, offX[blk]);
476
+ if (mn) row('C', offC[blk] + r * mn, mn, offX[blk + 1]);
477
+ const dIdx = offB[blk] + r * m + r, diag = Math.hypot(J.B[dIdx], w * S.B[dIdx]);
478
+ out[offX[blk] + r] = diag > 0 ? (1.1e-16 * acc) / diag : 0;
479
+ }
480
+ }
481
+ return out;
482
+ };
483
+ // Each floating metal contact's reading, of those it can take, with the least error by the
484
+ // solved responses (their terms' round-off and each unknown's in its own row).
485
+ const gaugeRead = new Set(lawRead); // (readings with no direct dependence on any V: see y below)
486
+ if (choices.size) {
487
+ const eps = 1.1e-16, parts = [[yr, yi], ...fl.map((_, b) => [Xr[b], Xi[b]])];
488
+ const mag = new Float64Array(Nc), floor = new Float64Array(Nc);
489
+ for (const [pr, pi] of parts) {
490
+ const rf = rowFloor(pr, pi);
491
+ for (let j = 0; j < Nc; j++) {
492
+ mag[j] += Math.hypot(pr[j], pi[j]);
493
+ floor[j] += rf[j];
494
+ }
495
+ }
496
+ for (const [k, list] of choices) {
497
+ let best = null, least = Infinity;
498
+ for (const r of list) {
499
+ let e = eps * (Math.abs(r.di) + w * Math.abs(r.dis));
500
+ for (let j = 0; j < Nc; j++) e += (Math.abs(r.c[j]) + w * Math.abs(r.cs[j])) * (eps * mag[j] + floor[j]);
501
+ if (e < least) [best, least] = [r, e];
502
+ }
503
+ if (best !== list[0]) gaugeRead.add(k);
504
+ Ct[k].set(best.c);
505
+ Cs[k].set(best.cs);
506
+ DI[k] = best.di;
507
+ DIs[k] = best.dis;
508
+ DX[k].set(best.dx);
509
+ DXs[k].set(best.dxs);
510
+ }
511
+ }
418
512
  // The floating terminals' circuit rows, a small complex system for their δV.
419
513
  const Ar = Array.from({ length: K }, () => new Float64Array(K + 1)), Ai = Array.from({ length: K }, () => new Float64Array(K + 1));
420
514
  fl.forEach((k, a) => {
515
+ let sre = 0, sim = 0;
421
516
  for (let b = 0; b < K; b++) {
422
517
  const [re, im] = dot(k, w, Xr[b], Xi[b]);
423
518
  Ar[a][b] = -re;
424
519
  Ai[a][b] = -im;
520
+ sre += re;
521
+ sim += im;
425
522
  }
426
523
  Ar[a][a] += DI[k];
427
524
  Ai[a][a] += w * DIs[k];
@@ -432,9 +529,14 @@ export class SmallSignal {
432
529
  Ar[a][b] += DX[k][k2];
433
530
  Ai[a][b] += w * DXs[k][k2];
434
531
  });
435
- const [yre, yim] = dot(k, w, yr, yi);
436
- Ar[a][K] = (k === kT ? 1 : 0) - yre - (heldT ? DX[k][kT] : 0);
437
- Ai[a][K] = -yim - (heldT ? w * DXs[k][kT] : 0);
532
+ // Every terminal shifted alike moves nothing and passes no current, and y + Σ (−X_b) is
533
+ // that shift where kT and the floating terminals are all there are: so a reading with
534
+ // no direct dependence on a V reads y as Σ X_b, exactly, from responses it resolves, where
535
+ // y itself is nearly that shift and its current a difference of them (a Pt electrode
536
+ // behind 3000 Ω·m², at 1 nHz: its cuts read y 10% apart).
537
+ const [yre, yim] = kirchhoff && gaugeRead.has(k) ? [sre, sim] : dot(k, w, yr, yi);
538
+ Ar[a][K] = -yre - DX[k][kT];
539
+ Ai[a][K] = -yim - w * DXs[k][kT];
438
540
  });
439
541
  const dV = complexSolve(Ar, Ai, K);
440
542
  // δx = y − Σ X_k δV_k
@@ -475,7 +577,8 @@ export class SmallSignal {
475
577
  const [re, im] = dot(kT, w, p.r, p.i);
476
578
  Ir += re;
477
579
  Ii += im;
478
- for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor);
580
+ p.rf ??= rowFloor(p.r, p.i);
581
+ for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor + p.rf[j]);
479
582
  }
480
583
  // Kirchhoff, where every other terminal floats: what they pass comes back through kT,
481
584
  // δI = Σ δV_k/R_k over those behind a resistance (one driven by a current passes no small
@@ -503,7 +606,7 @@ export class SmallSignal {
503
606
  // (g + iωd)(xr + i xi)
504
607
  re += gk * p.fr[x] - dk * p.fi[x];
505
608
  im += gk * p.fi[x] + dk * p.fr[x];
506
- e += (Math.abs(gk) + Math.abs(dk)) * (eps * Math.hypot(p.fr[x], p.fi[x]) + p.floor);
609
+ e += (Math.abs(gk) + Math.abs(dk)) * (eps * Math.hypot(p.fr[x], p.fi[x]) + p.floor + (this.rix[x] < Nc ? p.rf[this.rix[x]] : 0));
507
610
  }
508
611
  }
509
612
  if (e < err) [err, Ir, Ii] = [e, sgn * re, sgn * im];
@@ -524,7 +627,7 @@ export class SmallSignal {
524
627
  return [fr, fi];
525
628
  };
526
629
  let Zr, Zi;
527
- if (heldT) {
630
+ {
528
631
  // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal…
529
632
  let { Ir, Ii, err } = read([{ r: yr, i: yi, fr: xr, fi: xi, floor: accuracy * largest(yr, yi) }]);
530
633
  // …and where the solves leave that unresolved (the estimate past 1% of the current), the
@@ -565,14 +668,10 @@ export class SmallSignal {
565
668
  const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
566
669
  Zr = Ir / d2;
567
670
  Zi = -Ii / d2;
568
- } else {
569
- // δV per unit current; its error as the unit current's, read back from δx, is uncertain
570
- // (round-off in the current's terms, and the solves' residuals: see read)
571
- const a = fl.indexOf(kT);
572
- Zr = dV.re[a];
573
- Zi = dV.im[a];
574
- const back = read([{ r: yr, i: yi, fr: xr, fi: xi, floor: accuracy * largest(yr, yi) }], [Zr, Zi]);
575
- out.relError[q] = back.err / Math.hypot(back.Ir, back.Ii);
671
+ }
672
+ // (profiles per unit excitation: for a terminal driven by a current, per A/m², δx·Z)
673
+ if (profiles && dT.kind === 'I') {
674
+ for (let j = 0; j < N; j++) [xr[j], xi[j]] = [xr[j] * Zr - xi[j] * Zi, xr[j] * Zi + xi[j] * Zr];
576
675
  }
577
676
  out.Z.re[q] = Zr;
578
677
  out.Z.im[q] = Zi;
@@ -590,9 +689,12 @@ export class SmallSignal {
590
689
  for (let r = 0; r < model.regions.length; r++) {
591
690
  let sr = 0, si = 0, k = 0;
592
691
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
692
+ // (a metal's slot 0 is its segment's current, not φ̂: shifted, a platinum region of many
693
+ // cells read its electrode's current 8% off, at an error estimate of 1e-8)
694
+ const phiAt = (g) => this.nodeConductor[g] < 0 && loc[this.blockOfNode[g] * M] >= 0;
593
695
  for (let g = g0; g <= g1; g++) {
594
696
  const o = this.blockOfNode[g] * M;
595
- if (loc[o] >= 0) {
697
+ if (phiAt(g)) {
596
698
  sr += yr[R[o]];
597
699
  si += yi[R[o]];
598
700
  k++;
@@ -614,7 +716,7 @@ export class SmallSignal {
614
716
  si /= k;
615
717
  for (let g = g0; g <= g1; g++) {
616
718
  const o = this.blockOfNode[g] * M;
617
- if (loc[o] >= 0) {
719
+ if (phiAt(g)) {
618
720
  er[R[o]] = sr;
619
721
  ei[R[o]] = si;
620
722
  }
package/src/index.js CHANGED
@@ -132,6 +132,7 @@ export class Device {
132
132
  solver.sourceTime = old.sourceTime;
133
133
  solver._refreshSources();
134
134
  solver.dtNext = old.dtNext;
135
+ solver.coldStart = old.coldStart; // (a carried state is as cold as it was: see equilibrateFaces)
135
136
  solver.contactDEnd = old.contactDEnd;
136
137
  if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
137
138
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
package/src/newton.js CHANGED
@@ -75,7 +75,9 @@
75
75
  // Also written: s.islandsOn (as asked, or on by 8); s.lostMode, s.accepted (7); s.delta, s.deltaV,
76
76
  // s.rowDiv; what an assembly writes, at the returned state where a probe or 6's check ran;
77
77
  // s.conditioning (a steady solve's failure: the worst cancellation a factorisation showed, for
78
- // its message).
78
+ // its message); s.modelError, the message of a SolverError a statistics model threw at a state
79
+ // Newton reached (in the assembly, or computing the concentrations where it stops), which fails
80
+ // the call rather than leaving it (cleared by solveSteady and integrate, for their failures).
79
81
  // Tests: every solve and step goes through here; test/newton.test.js (the islands round, the
80
82
  // residual check's failure) and the MOS gate step in test/ports.test.js (a lost mode, to 1e-6);
81
83
  // test/jacobian.test.js checks the system it solves. The devices that needed each branch are in
@@ -401,6 +403,19 @@ export function newton(s, dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands
401
403
  };
402
404
  // A steady solve that fails: record how nearly singular the system was, and where; or, failing
403
405
  // with the islands' rows in, go back to the solution without them.
406
+ // The concentrations at the state Newton stops at, where a statistics model can refuse it (a
407
+ // Debye–Hückel solution past its range, at the converged state of a transient: the error was
408
+ // thrown from here, uncaught, out of solve() and advance()). Its message, for the failure.
409
+ const settle = () => {
410
+ try {
411
+ s.computeConcentrations();
412
+ return null;
413
+ } catch (err) {
414
+ if (!(err instanceof SolverError)) throw err;
415
+ s.modelError = err.message;
416
+ return err.message;
417
+ }
418
+ };
404
419
  const fail = (r) => {
405
420
  if (kept) {
406
421
  s.u.set(kept.u);
@@ -430,6 +445,7 @@ export function newton(s, dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands
430
445
  } catch (err) {
431
446
  // A statistics model can fail far from the solution (e.g. Debye–Hückel beyond its range).
432
447
  if (!(err instanceof SolverError)) throw err;
448
+ s.modelError = err.message;
433
449
  return fail({ converged: false, iterations: it, history, error: err.message });
434
450
  }
435
451
  equilibrate(s);
@@ -500,7 +516,8 @@ export function newton(s, dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands
500
516
  const done = { converged: true, iterations: it, history, residual: rmax };
501
517
  if (probed()) continue;
502
518
  if (islandsOff(it, done)) continue;
503
- s.computeConcentrations();
519
+ const refused = settle();
520
+ if (refused) return fail({ converged: false, iterations: it, history, error: refused });
504
521
  return done;
505
522
  }
506
523
  // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
@@ -523,12 +540,13 @@ export function newton(s, dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands
523
540
  const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
524
541
  if (probed()) continue;
525
542
  if (islandsOff(it, done)) continue;
526
- s.computeConcentrations();
543
+ const refused = settle();
544
+ if (refused) return fail({ converged: false, iterations: it, history, error: refused });
527
545
  return done;
528
546
  }
529
547
  }
530
- s.computeConcentrations();
531
- return fail({ converged: false, iterations: maxIter, history });
548
+ const refused = settle();
549
+ return fail({ converged: false, iterations: maxIter, history, ...(refused ? { error: refused } : {}) });
532
550
  }
533
551
 
534
552
  // How far the terminal currents (as last assembled) are from adding up to zero, relative to the
package/src/solution.js CHANGED
@@ -311,6 +311,11 @@ export function makeSolution(solver, result = {}) {
311
311
  `the solve did not converge from a start far from equilibrium: ${solver.violent.join('; ')}, its double layer uncharged. ` +
312
312
  'Hold that terminal at a voltage first, solve(), then let it float (set its I: 0): it keeps the state the held one gave it.',
313
313
  );
314
+ } else if (result.converged === false && !result.stopped && !un && !ch && !lost && slow.length === 0 && !(cond && cond.digits > 12) && solver.modelError) {
315
+ sol.warnings.push(
316
+ `the solve did not converge: the states it reached were refused by a statistics model: ${solver.modelError}. ` +
317
+ 'The model doesn\'t hold there: a smaller concentration or drive, or a model for it (lattice for crowding).',
318
+ );
314
319
  } else if (result.converged === false && !result.stopped && !un && !ch && !lost && slow.length === 0 && !(cond && cond.digits > 12)) {
315
320
  sol.warnings.push(
316
321
  'the solve did not converge, and nothing specific showed why. ' +
package/src/solver.js CHANGED
@@ -291,9 +291,15 @@ export class Solver {
291
291
  this.termDX = this.terms.map(() => new Float64Array(this.terms.length));
292
292
  this.termRes = new Float64Array(this.terms.length);
293
293
  this._refreshSources();
294
- // A floating terminal starts level with the first held one (no current).
295
- const level = this.termV[this.terms.findIndex((_, k) => !this.floating.includes(k))];
296
- for (const k of this.floating) this.termV[k] = this.terms[k].drive.kind === 'V' ? this.termV[k] : level;
294
+ // A floating terminal starts level with the first held one (no current); where none is held,
295
+ // with the source behind the first resistance (it was NaN there, and a metal contact's
296
+ // carrier, its level from that V, asked for a c0 a conductor can't be given), else 0.
297
+ const heldK = this.terms.findIndex((_, k) => !this.floating.includes(k)), behindR = this.terms.find((t) => t.drive.R > 0);
298
+ const level = heldK >= 0 ? this.termV[heldK] : behindR ? sourceAt(behindR.drive.src, this.time) : 0;
299
+ // (One behind a resistance too: at its own source, a silver cell held at 0 V and 16 V behind R
300
+ // began with 16 V across it; left at 0 V, one behind a −30 V source beside a current began
301
+ // with 30 V across it. Level, the circuit rows move them from a device at rest.)
302
+ for (const k of this.floating) this.termV[k] = level;
297
303
  // Accepted steps, most recent first: start time, size, start state (for BDF2 and the
298
304
  // error estimate of adaptive stepping).
299
305
  this.history = [];
@@ -830,6 +836,7 @@ export class Solver {
830
836
  }
831
837
  this.termV.set(src.termV);
832
838
  this.solvedV = src.solvedV;
839
+ this.coldStart = src.coldStart;
833
840
  this.time = src.time;
834
841
  this.computeConcentrations();
835
842
  }
@@ -855,6 +862,7 @@ export class Solver {
855
862
  history: this.history.slice(),
856
863
  termV: Float64Array.from(this.termV),
857
864
  sourceTime: this.sourceTime,
865
+ coldStart: this.coldStart,
858
866
  };
859
867
  }
860
868
 
@@ -878,6 +886,7 @@ export class Solver {
878
886
  this.history = s.history.slice();
879
887
  this.termV.set(s.termV);
880
888
  this.sourceTime = s.sourceTime;
889
+ this.coldStart = s.coldStart;
881
890
  this.computeConcentrations();
882
891
  }
883
892
 
package/src/start.js CHANGED
@@ -27,18 +27,22 @@
27
27
  // plus each face's dipole. In a region where a level-fixed charged species responds to φ̂,
28
28
  // φ̂ is chosen for neutrality (a bisection; node by node where a profile varies);
29
29
  // otherwise it's the running φ̂, or where a conductor on the left or an electrode spread
30
- // through the region has a reaction that can balance, the φ̂ that balances it. Where
30
+ // through the region has a reaction that can balance, the φ̂ that balances it (by bisection;
31
+ // a trial a non-ideal model refuses counts its ideal charge, which has the sign there). Where
31
32
  // neutrality is out of reach (c0's amounts leave a charge no held level balances), the
32
33
  // running φ̂, and a strictly neutral region is noted in s.unbalanced (for a transient that
33
34
  // fails from it to say why). A strictly
34
35
  // neutral region (ε = 0) with every charged species' amount given must have a neutral c0:
35
36
  // an error otherwise. A non-ideal material's composition comes from its statistics
36
37
  // (materialAt).
37
- // 2. A face between two strictly neutral ideal regions, its φ type neutral, whose links hold
38
- // some charged species in equilibrium: its two nodes are set to that equilibrium (each
39
- // passing species' μ̄ equal across it, the left node neutral, each passing species' amount
40
- // over the two nodes kept; the jump in φ̂ between them the one unknown), everything else at
41
- // them unchanged in concentration.
38
+ // 2. Marks the state cold (s.coldStart). equilibrateFaces(s), which the first time step from it
39
+ // calls (src/timeSteps.js) and a steady solve doesn't: a face between two strictly neutral
40
+ // ideal regions, its φ type neutral, whose links hold some charged species in equilibrium,
41
+ // its two nodes set to that equilibrium, its t = 0⁺ (each passing species' μ̄ equal across
42
+ // it, the left node neutral, each passing species' amount over the two nodes kept; the jump
43
+ // in φ̂ between them the one unknown), everything else at them unchanged in concentration;
44
+ // s.coldStart false. A solve that converges, a warm start and a carried state set it as
45
+ // their state is.
42
46
  // 3. An insertion host (a φ-free material, not a conductor): its φ̂ and every η shifted by z·Δ so that its first
43
47
  // charged species shared with a neighbour across a face starts at that neighbour's level.
44
48
  // 4. A floating contact facing a held one: V = the held V + the difference between the two
@@ -55,8 +59,8 @@
55
59
  // pass the current it's driven at, or behind a resistance (V_src − V)/R, by bisection.
56
60
  //
57
61
  // Invariants (each says what a reviewer should be able to check here):
58
- // 1. A species given by its amount starts at c0 (or its profile) at every node, but for the
59
- // two nodes of a face equilibrated in step 2, where its amount over the two is kept.
62
+ // 1. A species given by its amount starts at c0 (or its profile) at every node; at a face
63
+ // equilibrated by step 2 (a transient's first step), its amount over the two nodes is kept.
60
64
  // 2. A species given by its level starts at the level of what feeds it, as step 1 orders them.
61
65
  // 3. Where a level-fixed charged species responds, each node starts neutral (Σ z c + the fixed
62
66
  // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1); a
@@ -65,7 +69,7 @@
65
69
  // z·Δ), step 2 moves the passing species between a face's two nodes only.
66
70
  // 5. Each floating terminal's V is set by the last of steps 4, 6, 7, 9 that applies to it;
67
71
  // held terminals' V are untouched.
68
- // 6. Only u, uLo, termV, s.unbalanced and what computeConcentrations derives are written; the
72
+ // 6. Only u, uLo, termV, s.unbalanced, s.coldStart and what computeConcentrations derives are written; the
69
73
  // result depends on the model, every terminal's voltage and s.time, deterministically.
70
74
  // Known, and not an invariant (a cold review; see ROADMAP): the gauge (φ̂ and each η by z·Δ)
71
75
  // runs from the left, so behind a closed or floating left end the start sits away from a right
@@ -163,10 +167,22 @@ export function initFromComposition(s) {
163
167
  const zeta = new Float64Array(n), cc = new Float64Array(n);
164
168
  const charge = (ph) => {
165
169
  if (!mat.ideal) {
166
- materialAt(s, mat, reg.background, eta, mode, cFix, ph, zeta, cc);
167
- let q = reg.fixedCharge / FARADAY;
168
- for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
169
- return q;
170
+ // A model can refuse a trial φ̂ far out, where one ion is many orders denser than the rest
171
+ // (Debye–Hückel's limiting law threw out of the start, for KCl at 10 mM, its neutral φ̂
172
+ // 57 units from where the search began): there the ideal charge has the sign, and the
173
+ // search goes on by it.
174
+ let refused = false;
175
+ try {
176
+ materialAt(s, mat, reg.background, eta, mode, cFix, ph, zeta, cc);
177
+ } catch (err) {
178
+ if (!(err instanceof SolverError)) throw err;
179
+ refused = true;
180
+ }
181
+ if (!refused) {
182
+ let q = reg.fixedCharge / FARADAY;
183
+ for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
184
+ return q;
185
+ }
170
186
  }
171
187
  let q = reg.fixedCharge / FARADAY;
172
188
  for (let i = 0; i < n; i++) {
@@ -279,53 +295,6 @@ export function initFromComposition(s) {
279
295
  }
280
296
  }
281
297
  }
282
- // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
283
- // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
284
- // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
285
- // first step made to take that jump met an exactly singular pivot. Start there: the ions the
286
- // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
287
- // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
288
- // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
289
- // the two nodes' charge summing to what it was, zero.
290
- for (let f = 0; f < interfaces.length; f++) {
291
- const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
292
- const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
293
- if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
294
- const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = s.blockOfNode[gL] * M, bR = s.blockOfNode[gR] * M;
295
- const pass = [];
296
- for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
297
- if (!pass.some((i) => z[i] !== 0)) continue;
298
- const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
299
- const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
300
- const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
301
- let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
302
- for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
303
- // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
304
- const split = (D) =>
305
- pass.map((i, j) => {
306
- const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
307
- return [K * cR, cR];
308
- });
309
- const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
310
- const D0 = u[bR] - u[bL];
311
- let lo = D0 - 1, hi = D0 + 1;
312
- while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
313
- while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
314
- if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
315
- for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
316
- const m = 0.5 * (lo + hi);
317
- if (qL(m) < 0) lo = m;
318
- else hi = m;
319
- }
320
- const D = 0.5 * (lo + hi), cs = split(D);
321
- const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
322
- u[bR] = u[bL] + D;
323
- for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
324
- pass.forEach((i, j) => {
325
- u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
326
- u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
327
- });
328
- }
329
298
  // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
330
299
  // splits between its ion and its electrons. Start its ion level with the same ion's beside it
331
300
  // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
@@ -447,6 +416,69 @@ export function initFromComposition(s) {
447
416
  }
448
417
  s.termV[k] = 0.5 * (lo + hi);
449
418
  }
419
+ s.coldStart = true; // (until a solve or a step moves it: see equilibrateFaces)
420
+ }
421
+
422
+ /**
423
+ * The t = 0⁺ state at the faces between strictly neutral regions, taken by the first time step
424
+ * from a cold start (src/timeSteps.js) and not by a steady solve, which needs no t = 0⁺: there it
425
+ * was the worse start, its face nodes moved while the rest of a region stayed (a stack whose
426
+ * middle region started 2.2 V from its neighbour's level failed every steady solve, where the
427
+ * cold start converged in one).
428
+ */
429
+ export function equilibrateFaces(s) {
430
+ const { model, n, M, u, z } = s;
431
+ const { regions, interfaces, materials } = model;
432
+ const grid = model.grid;
433
+ s.coldStart = false;
434
+ // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
435
+ // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
436
+ // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
437
+ // first step made to take that jump met an exactly singular pivot. Start there: the ions the
438
+ // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
439
+ // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
440
+ // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
441
+ // the two nodes' charge summing to what it was, zero.
442
+ for (let f = 0; f < interfaces.length; f++) {
443
+ const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
444
+ const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
445
+ if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
446
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = s.blockOfNode[gL] * M, bR = s.blockOfNode[gR] * M;
447
+ const pass = [];
448
+ for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
449
+ if (!pass.some((i) => z[i] !== 0)) continue;
450
+ const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
451
+ const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
452
+ const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
453
+ let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
454
+ for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
455
+ // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
456
+ const split = (D) =>
457
+ pass.map((i, j) => {
458
+ const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
459
+ return [K * cR, cR];
460
+ });
461
+ const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
462
+ const D0 = u[bR] - u[bL];
463
+ let lo = D0 - 1, hi = D0 + 1;
464
+ while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
465
+ while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
466
+ if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
467
+ for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
468
+ const m = 0.5 * (lo + hi);
469
+ if (qL(m) < 0) lo = m;
470
+ else hi = m;
471
+ }
472
+ const D = 0.5 * (lo + hi), cs = split(D);
473
+ const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
474
+ u[bR] = u[bL] + D;
475
+ for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
476
+ pass.forEach((i, j) => {
477
+ u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
478
+ u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
479
+ });
480
+ }
481
+ s.computeConcentrations();
450
482
  }
451
483
 
452
484
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
package/src/steady.js CHANGED
@@ -50,7 +50,10 @@
50
50
  // start (initFromComposition), whose u and uLo become the stages' start (termV stays the
51
51
  // start's: a floating terminal keeps the voltage the transient left it at).
52
52
  // 2. Continuation: generation ramped up (_generationContinuation); a terminal driven by a
53
- // current, not bound (_bound), held at voltages instead (_floatingContinuation); with
53
+ // terminals behind a resistance held (at their source voltages, or where every other
54
+ // terminal is driven by a current at V_src − I R, Kirchhoff's I), then the resistances put
55
+ // back and the steady system solved from there (_resistanceContinuation); a terminal
56
+ // driven by a current, not bound (_bound), held at voltages instead (_floatingContinuation); with
54
57
  // canContinue, the right contact's voltage ramped to its target (_continuation): from the
55
58
  // voltages of the last converged solve (this.solvedV), the left contact's first where it
56
59
  // moved, else from level contacts, a cold start.
@@ -190,6 +193,7 @@ export class SteadySolves {
190
193
  this.steady = true; // (the steady system's rows; sources at the present time)
191
194
  this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
192
195
  this.drifted = null; // (an amount no row keeps, lost by a giant step: see _steadySteps)
196
+ this.modelError = null; // (a statistics model's refusal, for the failure: see newton)
193
197
  // A solve that fails leaves where the state was solved, and whether it was stepped, as they
194
198
  // were (Device.solve restores the state): a floating continuation's held solves had set them,
195
199
  // and the next held solve ramped warm from a voltage the state was never at, and failed.
@@ -252,6 +256,7 @@ export class SteadySolves {
252
256
  if (q.converged) {
253
257
  this.solvedV = [this.termV[0], this.termV[1]]; // (a contact's own: driven by a current, not the current)
254
258
  this.stepped = false;
259
+ this.coldStart = false; // (solved: no t = 0⁺ left for a transient to start from)
255
260
  }
256
261
  return q;
257
262
  };
@@ -289,6 +294,15 @@ export class SteadySolves {
289
294
  if (g.converged) return solved(g);
290
295
  r = g;
291
296
  }
297
+ // A terminal behind a resistance, cold: held at its source voltage first (with that its own
298
+ // continuations), then the resistance put back and solved from there (a diode behind 7 Ω·m²
299
+ // failed cold, and converged so; round 14's fuzzer, 4 of 9 such re-solves).
300
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0)) {
301
+ restart();
302
+ const q = this._resistanceContinuation(opts, r);
303
+ if (q.converged) return solved(q);
304
+ r = q;
305
+ }
292
306
  // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
293
307
  // voltage until the current crosses its target, and float it from there.
294
308
  if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
@@ -342,9 +356,10 @@ export class SteadySolves {
342
356
  // voltage instead, whose steady solves have their own continuation, the voltage marched from
343
357
  // the start's until the current crosses its target, the crossing narrowed by bisection, and
344
358
  // the terminal floated again from beside it.
345
- _floatingContinuation(opts, r) {
346
- const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
347
- const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
359
+ // (With `line`, terminal k behind a resistance: the current its load line passes at V,
360
+ // (V_src − V)/R, the target, the march from the first held terminal's level.)
361
+ _floatingContinuation(opts, r, k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j)), line = null) {
362
+ const drive = this.terms[k].drive, target = line ? NaN : sourceAt(drive.src, this.time), want = (V) => (line ? line(V) : target);
348
363
  // Its drive put back: redrive() finds the laws again for it, with the local constraints'
349
364
  // references at zero, so they're taken from the state again (an immobile species held at 0
350
365
  // at every node: the floated solve failed, 80 steps, and the regula falsi rescued it at 6×).
@@ -363,11 +378,12 @@ export class SteadySolves {
363
378
  iterations += q.iterations ?? 0;
364
379
  history.push({ held: V, converged: q.converged });
365
380
  if (q.converged) seen.push([V, this.termI[k]]);
366
- return q.converged ? this.termI[k] - target : NaN;
381
+ return q.converged ? this.termI[k] - want(V) : NaN;
367
382
  };
368
383
  const fail = () => ({ converged: false, steps, iterations, history });
369
384
  const seen = []; // [V, I] of each held solve that converged
370
- let Va = this.termV[k], fa, Vb, fb;
385
+ const heldK = this.terms.findIndex((t) => t.drive.kind === 'V' && !(t.drive.R > 0));
386
+ let Va = line ? (heldK >= 0 ? this.termV[heldK] : 0) : this.termV[k], fa, Vb, fb;
371
387
  try {
372
388
  fa = hold(Va);
373
389
  if (!Number.isFinite(fa)) {
@@ -396,7 +412,7 @@ export class SteadySolves {
396
412
  }
397
413
  if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
398
414
  // What the held voltages passed, at the two extremes reached.
399
- if (seen.length > 1) {
415
+ if (seen.length > 1 && !line) {
400
416
  seen.sort((a, b) => a[0] - b[0]);
401
417
  this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
402
418
  }
@@ -423,7 +439,7 @@ export class SteadySolves {
423
439
  // one at the voltage that passes the target: found by held solves, by regula falsi
424
440
  // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
425
441
  // current the march saw (a target of 0 is never met exactly).
426
- const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
442
+ const scale = Math.max(line ? 0 : Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
427
443
  try {
428
444
  let side = 0;
429
445
  for (let it = 0; it < 60; it++) {
@@ -446,6 +462,53 @@ export class SteadySolves {
446
462
  }
447
463
  }
448
464
 
465
+ // Each terminal behind a resistance held at its source voltage (R taken off), solved, then the
466
+ // resistances put back and the steady system solved from that state.
467
+ _resistanceContinuation(opts, r) {
468
+ const ks = this.floating.filter((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0);
469
+ const drives = ks.map((k) => this.terms[k].drive);
470
+ let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
471
+ const history = (r.history ?? []).slice();
472
+ // Where every other terminal is driven by a current, Kirchhoff gives the one behind R its
473
+ // current, and so the voltage it ends at, V_src − I R: held there, putting R back moves
474
+ // nothing (held at V_src, it had to slide the whole device by IR, and failed past volts).
475
+ const others = this.terms.every((t, j) => ks.includes(j) || t.drive.kind === 'I');
476
+ const at = (k) => {
477
+ const d = this.terms[k].drive, V = sourceAt(d.src, this.time);
478
+ if (!(others && ks.length === 1)) return V;
479
+ const I = -this.terms.reduce((t, tm, j) => (j === k ? t : t + sourceAt(tm.drive.src, this.time)), 0);
480
+ return V - I * d.R;
481
+ };
482
+ let heldOk = false;
483
+ const start = [Float64Array.from(this.u), Float64Array.from(this.uLo), Float64Array.from(this.termV)];
484
+ try {
485
+ const held = ks.map(at);
486
+ ks.forEach((k, j) => (this.terms[k].drive = { ...this.terms[k].drive, src: { value: held[j] }, R: 0 }));
487
+ this.redrive();
488
+ const q = this._steadyFromHere(opts);
489
+ steps += q.steps ?? 0;
490
+ iterations += q.iterations ?? 0;
491
+ history.push({ resistancesOff: true, converged: q.converged });
492
+ heldOk = q.converged;
493
+ } finally {
494
+ ks.forEach((k, j) => (this.terms[k].drive = drives[j]));
495
+ this.redrive();
496
+ captureLocal(this);
497
+ }
498
+ const q = heldOk ? this._steadySteps(opts) : { converged: false, steps: 0, iterations: 0, history: [] };
499
+ const done = { ...q, steps: steps + q.steps, iterations: iterations + q.iterations, history: [...history, ...q.history] };
500
+ if (q.converged || ks.length !== 1) return done;
501
+ // One terminal behind R, the rest not all driven by currents (so its current isn't known):
502
+ // its crossing with the load line, (V_src − V)/R, by held solves, as an open circuit's is
503
+ // with its target (a silver cell held at 0 V on one side, 16 V behind R on the other, failed).
504
+ const d = drives[0], Vs = sourceAt(d.src, this.time);
505
+ this.u.set(start[0]);
506
+ this.uLo.set(start[1]);
507
+ this.termV.set(start[2]);
508
+ this.computeConcentrations(); // (from where this stage began, not where the failed one left it)
509
+ return this._floatingContinuation(opts, done, ks[0], (V) => (Vs - V) / d.R);
510
+ }
511
+
449
512
  // Steady solves with the generation reactions' rates scaled from 1e-30 up to 1, ×100 a step
450
513
  // while they converge, smaller steps where they don't.
451
514
  _generationContinuation(opts, r) {
package/src/timeSteps.js CHANGED
@@ -21,6 +21,8 @@
21
21
  // (ports' charges) }: what BDF2 and the predictor need.
22
22
  //
23
23
  // step(s, dt, opts) → newton's result (+ bdf)
24
+ // From a cold start (s.coldStart, outside a steady solve), the faces between strictly neutral
25
+ // regions first set to their t = 0⁺ equilibrium (equilibrateFaces, src/start.js).
24
26
  // One step from s.time to tEnd = s.time + dt (or s.landing exactly, where it's within 1e-9·dt
25
27
  // of that). Backward Euler; BDF2 when opts.method is 'bdf2', a previous step exists and the
26
28
  // ratio ω = dt/dt_prev ≤ 2: then c* = b1·c_n − b2·c_{n−1} (b1 = (1+ω)/a0, b2 = ω²/((1+ω)a0),
@@ -85,6 +87,7 @@
85
87
 
86
88
  import { nextBreakpoint } from './device.js';
87
89
  import { SolverError } from './solver.js';
90
+ import { equilibrateFaces } from './start.js';
88
91
 
89
92
  // Lagrange weights at x through the points s0, s1, s2.
90
93
  function lagrange3(x, s0, s1, s2) {
@@ -120,6 +123,8 @@ export function advance(s, dt, opts, depth = 0) {
120
123
  * the start-of-step values, and dt/a0 replaces dt.
121
124
  */
122
125
  export function step(s, dt, opts = {}) {
126
+ // A transient's first step from a cold start begins at its t = 0⁺ (see equilibrateFaces).
127
+ if (s.coldStart && !s.steady) equilibrateFaces(s);
123
128
  // What the step overwrites before Newton runs, for a failed one to put back: the Solution read
124
129
  // after it takes its contacts' fluxes against the start-of-step values (a host emptied by its
125
130
  // current read its contact's Li⁺ flux 8× off at the same state).
@@ -262,6 +267,7 @@ export function integrate(s, tEnd, opts = {}) {
262
267
  const sample = s._probes(opts.probes);
263
268
  const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
264
269
  let steps = 0, rejected = 0, iterations = 0, failed = false;
270
+ s.modelError = null; // (a statistics model's refusal, for the failure: see newton)
265
271
  // Already there (an animation frame with no time to add): nothing to do, and the step size
266
272
  // carried to the next call stays as it was. A time already passed, or reached again outside
267
273
  // an animation, is most likely advance(dt) read as a duration: say so.
@@ -269,7 +275,7 @@ export function integrate(s, tEnd, opts = {}) {
269
275
  const relative = tEnd < s.time || (s.time > 0 && opts.budgetMs === undefined);
270
276
  return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace, ...(relative ? { stopped: `advance(${tEnd}) did nothing: ${absolute}. To run for a further dt, advance(sol.time + dt)` } : {}) };
271
277
  }
272
- const t0 = s.time;
278
+ const t0 = s.time, fromLayout = s.history.length === 0 && !s.stepped && !s.atSteady; // (c0's layout, not a solution)
273
279
  // A cold start whose floating double layers start uncharged far from the equilibrium of an ion
274
280
  // link beside them (a capacitive contact or port passing K⁺, its outside level set for the
275
281
  // charged steady state): the link closes the gap in the first instant, which can be far below
@@ -395,6 +401,12 @@ export function integrate(s, tEnd, opts = {}) {
395
401
  stopped = `${at}: the start can't be neutral in ${unbalanced.join(', ')}, strictly neutral (ε = 0): the amounts its c0 gives leave a net charge that no species held at a contact's level can balance, and a step can't move a stored amount at once. Give c0 that balances (with any fixed charge), or leave out of c0 the species a contact feeds, so that they start at its level.`;
396
402
  else if (failed && violent.length > 0)
397
403
  stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. The start is violent: ${violent.join('; ')}. A double layer starts uncharged, and what passes beside it closes such a gap in the first instant, faster than any step can follow. For a start in equilibrium, solve() first (the steady state at the starting drives), then change what the experiment changes, then advance(). Where solve() fails from there too, hold that terminal at a voltage, solve(), then let it float (set its I: 0): it keeps the state the held one gave it.`;
404
+ else if (failed && s.modelError)
405
+ stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s, the state it reached refused by a statistics model: ${s.modelError}. The model doesn't hold there: a smaller concentration or drive, or a model for it (lattice for crowding).`;
406
+ else if (failed && fromLayout && steps === 0)
407
+ // (round 15's fuzzer: the pn template advanced from its c0, a step of 3e-24 s failing, 23 of 62
408
+ // plain diodes; nothing said to solve first)
409
+ stopped = `${at}: the first step did not converge even when shortened to ${dt.toExponential(1)} s, from the starting state, which is c0's layout rather than a solution (a junction's depletion not yet formed, say). For a transient from equilibrium, solve() first at the starting drives, then change what the experiment changes, then advance().`;
398
410
  else if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
399
411
  else if (s.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
400
412
  return { converged: !failed, done: s.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
@@ -4,8 +4,8 @@ export class SmallSignal {
4
4
  * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
5
5
  * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
6
6
  * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
7
- * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
8
- * from C, or a driven current is perturbed and its voltage solved for. The other terminals
7
+ * At the measured terminal, its voltage is perturbed (rhs = −B δV) and its current read, held
8
+ * or driven by a current alike (one input impedance; see the contract). The other terminals
9
9
  * keep their drives: held ones at AC ground, driven ones at AC open circuit.
10
10
  * Z = δV/δI with I into the device (positive real part for a passive device).
11
11
  * @param {ArrayLike<number>} frequencies Hz
package/types/solver.d.ts CHANGED
@@ -273,6 +273,7 @@ export class Solver {
273
273
  };
274
274
  _warmFrom(src: any): void;
275
275
  solvedV: any;
276
+ coldStart: any;
276
277
  _snapshot(): {
277
278
  u: Float64Array<ArrayBuffer>;
278
279
  uLo: Float64Array<ArrayBuffer>;
@@ -299,6 +300,7 @@ export class Solver {
299
300
  history: any[];
300
301
  termV: Float64Array<ArrayBuffer>;
301
302
  sourceTime: number;
303
+ coldStart: any;
302
304
  };
303
305
  _restore(s: any): void;
304
306
  contactDEnd: any;
package/types/start.d.ts CHANGED
@@ -4,3 +4,11 @@
4
4
  * profile varies), or continued across the interface dipole if nothing there responds.
5
5
  */
6
6
  export function initFromComposition(s: any): void;
7
+ /**
8
+ * The t = 0⁺ state at the faces between strictly neutral regions, taken by the first time step
9
+ * from a cold start (src/timeSteps.js) and not by a steady solve, which needs no t = 0⁺: there it
10
+ * was the worse start, its face nodes moved while the rest of a region stayed (a stack whose
11
+ * middle region started 2.2 V from its neighbour's level failed every steady solve, where the
12
+ * cold start converged in one).
13
+ */
14
+ export function equilibrateFaces(s: any): void;
package/types/steady.d.ts CHANGED
@@ -29,6 +29,7 @@ export class SteadySolves {
29
29
  steady: boolean;
30
30
  steadyAt: any;
31
31
  drifted: any;
32
+ modelError: any;
32
33
  _solveSteadyAll(opts: any): any;
33
34
  atSteady: any;
34
35
  _steadyFromHere(opts: any): any;
@@ -36,9 +37,11 @@ export class SteadySolves {
36
37
  sourceTime: any;
37
38
  solvedV: any[];
38
39
  stepped: boolean;
40
+ coldStart: boolean;
39
41
  dimmer: boolean;
40
42
  _bound(k: any): any;
41
- _floatingContinuation(opts: any, r: any): any;
43
+ _floatingContinuation(opts: any, r: any, k?: any, line?: any): any;
44
+ _resistanceContinuation(opts: any, r: any): any;
42
45
  _generationContinuation(opts: any, r: any): {
43
46
  converged: boolean;
44
47
  steps: any;