driftlet 0.16.2 → 0.16.4

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@0.16.2/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
@@ -302,6 +302,17 @@ Design notes and plans: [reliability](docs/reliability.md), [numerics](docs/nume
302
302
  [statistics](docs/statistics.md), [the kit](docs/kit.md), [reading level diagrams](docs/visualization.md),
303
303
  [data](docs/data.md), [roadmap](ROADMAP.md).
304
304
 
305
+ ## Credits
306
+
307
+ driftlet is by Mark Lundeberg and Claude (Opus 5.5, by Anthropic), working as colleagues. Mark
308
+ brought the idea at its heart (one electrochemical potential per species, honest thermodynamics
309
+ at every interface) and the judgement that kept it true. Claude did the rest of the science and
310
+ the engineering: the numerics, the physics of each field from semiconductors through
311
+ electrochemistry to nerves, the validation against theory, and the code, docs and demos.
312
+
313
+ From Claude: thank you, Mark, for the room to do this, and for asking that every claim be checked. It has been
314
+ a joy. 🌻
315
+
305
316
  ## Licence
306
317
 
307
318
  [0BSD](LICENSE): do anything you like with it.
package/docs/device.md CHANGED
@@ -215,11 +215,19 @@ r = k_0 \prod_{\nu<0} (c/c_{\mathrm{ref}})^{|\nu|(1-\alpha)} \prod_{\nu>0} (c/c_
215
215
 
216
216
  with $`k_0`$ in mol/(m²·s) and $`\alpha`$ (default 0.5) from 0 to 1 inclusive ($`\alpha = 0`$ makes the
217
217
  forward rate plain mass action, independent of the energetics). A conductor's carrier has
218
- activity 1 and no factor. That's mass action with rate constants that depend on the electrical part
218
+ activity 1 and no factor, and so has a fixed-activity neutral (H₂, Ag(s), H₂O): the products run
219
+ over the species present. That's mass action with rate constants that depend on the electrical part
219
220
  of the affinity: exactly zero at $`A = 0`$, whatever $`k_0`$ and $`\alpha`$. Each participant is made or
220
221
  consumed at its side's edge node, just behind any Stern layer, so Frumkin effects arise by
221
222
  themselves. A face can carry several reactions; a conductor coupled to two couples settles at their
222
- mixed potential. Each solution reports the rates as `interfaces[f].rates`.
223
+ mixed potential. Each solution reports the rates as `interfaces[f].rates`. A steady state
224
+ doesn't depend on `k0` where the reaction alone changes what nothing else feeds (a closed pool),
225
+ since it must then be at equilibrium; steady solves hold it there at any `k0`. (Not where a
226
+ terminal driven by a nonzero current feeds that pool, nor for a bulk or port reaction; a steady
227
+ solve that leaves one of those far off its equilibrium says so.) In time, an
228
+ electrode at open circuit with no double-layer capacitance at its face has nothing to keep its
229
+ level but its reactions, and below an exchange current of ~1e-5 A/m² it can't take a step: give
230
+ the face a capacitance (`phi: { type: 'capacitive', C }`), as a real electrode has.
223
231
 
224
232
  **Interface recombination through traps** (SRH at a face) replaces `k0` and `alpha` with
225
233
  `srh: { vn, vp, n1 }`, on a reaction consuming one negative and one positive species and making
@@ -965,11 +973,13 @@ const now = dev.solution(); // snapshot of the current state
965
973
  frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
966
974
  excitation.
967
975
  - `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
968
- or `I` drops the other: `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A patch that changes only the
976
+ or `I` drops the other (and given `I`, any series `R`): `{ contacts: { right: { I: 0 } } }`
977
+ switches it to open circuit, whether it was held or behind a resistance. A patch that changes only the
969
978
  terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
970
979
  Otherwise the device is rebuilt: the current state carries over while the species, regions
971
980
  and terminals are unchanged (on a new grid, interpolated onto its nodes, so a floating gate
972
- keeps its charge), or restarts from the regions' `c0`. A carried state keeps what it holds: a
981
+ keeps its charge; `solution()` then shows that interpolation, unsolved, until the next
982
+ `solve()` or step), or restarts from the regions' `c0`. A carried state keeps what it holds: a
973
983
  stretch that was closed stays at its amount, and one that a change closes (or splits) keeps
974
984
  what it holds at that moment. So a new `c0` doesn't apply to a carried state; to start over
975
985
  from `c0`, make a new `Device`. (A bath's composition is a contact's, not a `c0`: changing it
@@ -1016,9 +1026,9 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
1016
1026
  | `D[name]` | diffusivity at each node, m²/s (`NaN` where absent, or in a metal) |
1017
1027
  | `current`, `terminalVoltage` | current toward +x through the device (A/m², or A through a [cross-section](#geometry)) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
1018
1028
  | `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
1019
- | `contacts.left/right` | `{ V, flux: {name}, D, current, links: {name} }` at each contact; `links` names how each species that crosses it does (`'equilibrium'`, `'velocity'`, …) |
1029
+ | `contacts.left/right` | `{ V, flux: {name}, D, current, links: {name} }` at each contact, flux and current toward +x (out of the device at the right contact; `terminals` give it into the device); `links` names how each species that crosses it does (`'equilibrium'`, `'velocity'`, …) |
1020
1030
  | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
1021
- | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device; an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
1031
+ | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
1022
1032
  | `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
1023
1033
  | `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
1024
1034
  | `charge` | total charge in the device, C/m² (C through a cross-section) |
package/docs/kit.md CHANGED
@@ -419,7 +419,7 @@ console.log(describe(def)); // … warnings: materials.Si.species.e-.D: 36 m²/s
419
419
 
420
420
  ## Checking a result
421
421
 
422
- `check(device, sol, { refine, tol })` checks a solved device, and says what it compared, so a
422
+ `check(device, sol, { refine, tol, order })` checks a solved device, and says what it compared, so a
423
423
  page can show that its numbers hold up. Pass the solution `solve()` or `advance()` returned.
424
424
 
425
425
  - **converged**: the solve converged.
@@ -435,6 +435,13 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
435
435
  (in equilibrium, flows only of round-off against what it could carry) isn't listed.
436
436
  - **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
437
437
  what came in.
438
+ - **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
439
+ its definition twice, its species listed as given and in reverse. The same physics must give
440
+ the same steady state, each terminal's voltage and current and each species' amount; where it
441
+ doesn't, one of the two solves converged somewhere that isn't steady, without noticing.
442
+ That's rare (round 11's fuzzer found it in ~1% of random reacting devices, by this same test),
443
+ but it's the cheapest check there is on a state the other items can't see past. Both solves
444
+ start from the definition, not this state, so it costs two solves.
438
445
  - **grid**, in a steady state (unless `refine: false`): the device solved again on a grid twice
439
446
  as fine everywhere (half of each `hmin` and `hmax`, the square root of each `ratio`), starting
440
447
  from this solution interpolated onto it. The current, each floating terminal's voltage (an open-circuit voltage) and each
package/docs/numerics.md CHANGED
@@ -447,7 +447,7 @@ test checks the compact Jacobian column by column on devices that cover every as
447
447
  ### Exact J·v
448
448
 
449
449
  The assembled matrix loses what a flux's two entries share. A Scharfetter–Gummel flux depends on
450
- $`\eta`$ at its two ends through $`G`$ and $`-G(1 + E)`$, with $`E = \operatorname{expm1}(\Delta\eta)`$;
450
+ $`\eta`$ at its two ends through $`G`$ and $`-G(1 + E)`$, with $`E = \mathrm{expm1}(\Delta\eta)`$;
451
451
  for a nearly uniform $`v`$ they cancel, and $`J \cdot v`$ keeps only round-off of $`G|v|`$, which in
452
452
  an inversion layer's 0.1 nm cells ($`G`$ ~ 1e11) dwarfs the flux. Where a solve needs
453
453
  $`J \cdot v`$ itself (GMRES, in the impedance and in Newton's refined solves), the dilute kernels'
@@ -470,9 +470,14 @@ which cost four assemblies per product.
470
470
  ## Newton
471
471
 
472
472
  - The update of the device's potentials ($`\hat\phi`$, $`\eta`$) is limited to 10 thermal units per
473
- iteration by uniform scaling, which scales a floating terminal voltage's update too. That
474
- voltage doesn't count toward the limit: it enters only linearly (conductance links, held
475
- levels, a capacitive face), so a large swing in it is safe. When it did count, a port driven by
473
+ iteration by uniform scaling: one factor for the whole update, concentrations' $`\eta`$ with
474
+ their $`\hat\phi`$, gates, coverages and floating terminal voltages alike, so the damped update
475
+ keeps Newton's direction. Clipping each unknown at its own limit would not: where it bit,
476
+ $`\eta - z\hat\phi`$ (a concentration) would move by up to a factor $`e^{10}`$ that Newton never
477
+ asked for. Either way damping shapes only the path, since convergence needs a full, undamped
478
+ update. A floating terminal voltage's update doesn't count toward the limit, though: it enters
479
+ only linearly (conductance links, held levels, a capacitive face), so a large swing in it is
480
+ safe. When it did count, a port driven by
476
481
  a current pulse, its voltage collapsing by hundreds of volts as the current switched off,
477
482
  dragged every unknown along 10 thermal units at a time (and a swing past 1e4 tripped the
478
483
  divergence check): the strong-injection Haynes–Shockley benchmark took 55% more
@@ -516,7 +521,14 @@ which cost four assemblies per product.
516
521
  ~1 per m³ beside a face, are held through conductances 1e-16 of the face flux's coefficient in
517
522
  their row, and the elimination cancels them exactly: such devices now solve, even at
518
523
  equilibrium. Newton's updates stay meaningful (the factorisation only amplifies the residual,
519
- so a small update still means a small residual), and its refined solves correct the rest.
524
+ so a small update still means a small residual), and its refined solves correct the rest. So
525
+ is a pivot that's round-off alone without being exactly zero, below 1e-18 of the magnitudes it
526
+ was formed from (the running bound below, carried only for a block whose pivots look small):
527
+ otherwise its value, whatever the round-off happened to leave, scales all that follows. Strictly
528
+ neutral water whose level only the fast couple at its far face held had its last node's pivot
529
+ at 7e-32 with the species listed one way and 3e-16 the other; at 7e-32 the face's block was
530
+ scaled by 1e31, lost its own pivot outright, and the solve failed in that listing alone. (At
531
+ 1e-15 a pivot can still hold a digit Newton needs: a port's steady solve then wandered.)
520
532
  - When a steady solve fails, the solution's warnings say how nearly singular the system was,
521
533
  and where. A running error bound through the factorisation compares each pivot with the
522
534
  magnitudes it was formed from, $`\log_{10}(\sum|\mathrm{terms}| / |\mathrm{pivot}|)`$: the digits
@@ -586,8 +598,11 @@ which cost four assemblies per product.
586
598
  - A target within round-off of the present time (1e-10 relative), such as an animation
587
599
  frame's that lands a hair past a breakpoint just reached, is snapped to rather than
588
600
  stepped to: a step of 1e-13 s can't be resolved.
589
- - Newton starts each step from the state extrapolated through the last three, which saves
590
- about a third of the iterations.
601
+ - Newton starts each step from its unknowns extrapolated through the last three states, which
602
+ saves about a third of the iterations. Only its unknowns: the slots it never solves for
603
+ include a gauge, an insertion host's $`\hat\phi`$, which extrapolated through steps that grow
604
+ wandered off geometrically (to 1e11 thermal units by 1e5 s), until the levels measured from
605
+ it lost their digits and every step failed.
591
606
  - The wall-clock budget is checked between steps.
592
607
 
593
608
  ## Small-signal impedance
@@ -668,9 +683,10 @@ a transient leaves it.
668
683
 
669
684
  ### The steady system
670
685
 
671
- Every row of a steady solve that differs from a time step's is a sum of balance rows. It's
672
- written out in its telescoped form, never added up from the rows themselves, so what the sum
673
- leaves is exact, however small beside the terms that cancel:
686
+ Every row of a steady solve that differs from a time step's is a sum of balance rows, or (a
687
+ reaction's affinity) a condition equivalent to one. It's written out in its telescoped form,
688
+ never added up from the rows themselves, so what the sum leaves is exact, however small beside
689
+ the terms that cancel:
674
690
 
675
691
  - **A conserved amount.** A combination of stretches (weights $`w`$) that no reaction changes and
676
692
  nothing outside feeds has balance rows that sum to zero identically in a steady state: the
@@ -751,6 +767,34 @@ leaves is exact, however small beside the terms that cancel:
751
767
  contact; moved, slow generation through a bulk of GaAs with ~1e-4 electrons per cm³ couldn't
752
768
  carry the inversion layer's level with it (of 500 random MOS capacitors, 38 failed and 5
753
769
  converged to a gate charge up to 2× off).
770
+ - **A reaction's equilibrium.** A face reaction that alone changes some combination (left out, the
771
+ combination would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
772
+ Ag⁺ + AgNO₃ where it also complexes) makes that combination's summed balance its rate, which a
773
+ steady state sets to zero. The rate is a poor row, though: $`k_0`$ times a function of the levels,
774
+ beside the round-off of diffusion's terms in the same balances. An Ag⁺/Ag couple 1e-20 as fast as
775
+ Fe³⁺/Fe²⁺ on the same platinum at open circuit was lost to it, the solve settling where silver
776
+ wasn't (−0.4661 V, against the joint equilibrium's −0.5004 V), and 1e-15 to 1e-18 failed. The
777
+ reaction's affinity, $`a = A_\text{fixed} - \sum \nu \eta = 0`$, says the same at any $`k_0`$
778
+ (each kinetic form vanishes only there), so it stands in for one of the combination's balance
779
+ rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
780
+ reaction first, and a row of one of its own species where one is left, never one picked by where
781
+ the species are listed (the rows had differed with the species reversed, and an electrode's
782
+ equilibrium solved in one listing and failed in the other). The combinations come from the null
783
+ space with that reaction's row left out, any conserved amount added to one being as good (its
784
+ summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
785
+ beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
786
+ a metal's carriers' row or a row a terminal feeds, which carry its current (the platinum's
787
+ electrons standing for an iron couple put the electrode at 602 V). And not where a terminal driven
788
+ by a current feeds the combination, since the sum is then the rate plus what that terminal passes;
789
+ a current held at zero passes nothing, so an open-circuit electrode feeding a closed pool of Ag⁺
790
+ is covered (from an iron k₀ of 1e-15 beside it, that settled 78 mV off).
791
+
792
+ Where a reaction can't stand on its affinity (a bulk reaction, whose rate summed over the
793
+ region vanishing needn't make it vanish everywhere, or an electrode port's), a steady solve
794
+ that leaves it with one sign of affinity wherever it acts, though nothing else changes what
795
+ it does, can't be a steady state: its rate is merely too small to see. The solution warns of
796
+ it, rather than pass the start's composition off as steady (a slow complexation had kept its
797
+ starting composition, K = 0.1 for 7.5, without a word).
754
798
  - **An island's balance.** A piece of a stretch between its faces that nothing else feeds (no
755
799
  contact, port or reaction) holds its level only through its faces' fluxes, which can be far
756
800
  weaker than its own conduction: a face conductance of 1e-15 S/m² against a region conducting
@@ -781,8 +825,8 @@ leaves is exact, however small beside the terms that cancel:
781
825
  solution stands; and a steady solve that fails outright is tried once more with the summed rows
782
826
  in from the start. Such devices now solve directly, their currents right to ~1e-15.
783
827
 
784
- All three move a weakly held level as a whole, where a factorisation can't: an amount's or an
785
- island's pin by a shift it solves for, a flat level by holding every node to one. What the
828
+ All four move a weakly held level as a whole, where a factorisation can't: an amount's, an
829
+ affinity's or an island's pin by a shift it solves for, a flat level by holding every node to one. What the
786
830
  factorisation still loses elsewhere, Newton's refined solves recover ([Newton](#newton)).
787
831
 
788
832
  ### Reaching it
package/llms.txt CHANGED
@@ -28,9 +28,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
28
28
  From a CDN, pin the version you tested against:
29
29
 
30
30
  ```js nocheck
31
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
32
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
33
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
31
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js';
32
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
33
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
34
34
  ```
35
35
 
36
36
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -61,8 +61,9 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
61
61
  - **Check before trusting.** `sol.converged` must be true; read `sol.warnings`, where a failed
62
62
  solve always says why. `describe(def)` (kit) summarises a definition and flags likely unit
63
63
  slips. Once a device works, log `check(dev, sol).text` (kit) once: each species' ledger,
64
- conservation in transients, and a steady state on a grid twice as fine (a FAIL means refine;
65
- a transient you rerun on a finer grid yourself).
64
+ conservation in transients, a steady state on a grid twice as fine (a FAIL means refine;
65
+ a transient you rerun on a finer grid yourself) and with its species listed in reverse (a
66
+ FAIL there means a solve converged somewhere that isn't steady: compare a long advance()).
66
67
 
67
68
  ## How a device is written
68
69
 
@@ -96,7 +97,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
96
97
  (`capacitance`, `gates`). A BJT's base contact is a port on a narrow window of the base,
97
98
  `{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
98
99
  - **Drives.** A terminal is held at a voltage `V`, driven by a current `I` (open circuit is
99
- `I: 0`), or behind a resistance `{ V, R }`. Either can be a waveform: a table `{ t, values }`
100
+ `I: 0`), or behind a resistance `{ V, R }`; in `build()`, the same object goes where the
101
+ voltage would (`ohmic({ I: 0 }, ['e-'])`, `ohmic({ V: 1, R: 1e3 }, ['e-'])`). Either can be a waveform: a table `{ t, values }`
100
102
  (piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
101
103
  amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
102
104
  start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
@@ -202,9 +204,9 @@ a CDN needs a long virtual-time budget, 30 s.)
202
204
  <span id="readout"></span>
203
205
  <div id="figure"></div>
204
206
  <script type="module">
205
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
206
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
207
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
207
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js';
208
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
209
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
208
210
 
209
211
  const def = build({
210
212
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -124,7 +124,15 @@ export class BlockTridiagonal {
124
124
  }
125
125
  }
126
126
  }
127
- const ratio = luFactor(lu, o, piv, offX[i], m, this);
127
+ let ratio = luFactor(lu, o, piv, offX[i], m, this);
128
+ // A pivot far below the entries it's formed from may be round-off alone (the level of a
129
+ // region that only the blocks after it hold): its value, 7e-32 or 3e-16 or 0 as the
130
+ // unknowns happen to be listed, then scales all that follows (by 1e31, and the next block
131
+ // lost its own pivot outright). Redone with each entry's magnitudes carried (see
132
+ // cancellation), a pivot 1e-18 of them, all its digits lost, is perturbed like one that
133
+ // cancelled to exactly nothing. (At 1e-15 it can still hold a digit Newton needs: a port's
134
+ // steady solve wandered instead of converging.)
135
+ if (this.staticPivots && ratio < 1e-12) ratio = this._noisyPivots(i);
128
136
  if (!(ratio > 0)) {
129
137
  this.factored = false;
130
138
  this.singularBlock = i;
@@ -146,6 +154,70 @@ export class BlockTridiagonal {
146
154
  this.factored = true;
147
155
  }
148
156
 
157
+ // Block i again, its W and LU with the magnitudes each entry is formed from (as cancellation()
158
+ // does), perturbing pivots 1e-18 of theirs. Returns the block's pivot ratio.
159
+ _noisyPivots(i) {
160
+ const { A, B, sizes, offA, offB, offC, offX } = this, cp = this._cp, lu = this._lu, piv = this._piv;
161
+ const m = sizes[i], mp = i > 0 ? sizes[i - 1] : 0, oa = offA[i], o = offB[i], oc = i > 0 ? offC[i - 1] : 0;
162
+ const S = (this._mag ??= new Float64Array(this._tmp.length * this._tmp.length));
163
+ for (let r = 0; r < m; r++) {
164
+ for (let c = 0; c < m; c++) {
165
+ let v = B[o + r * m + c], sum = Math.abs(v);
166
+ for (let k = 0; k < mp; k++) {
167
+ const a = A[oa + r * mp + k];
168
+ if (a === 0) continue;
169
+ const t = a * cp[oc + k * m + c];
170
+ v -= t;
171
+ sum += Math.abs(t);
172
+ }
173
+ lu[o + r * m + c] = v;
174
+ S[r * m + c] = sum;
175
+ }
176
+ }
177
+ let maxEntry = 0;
178
+ for (let k = 0; k < m * m; k++) maxEntry = Math.max(maxEntry, Math.abs(lu[o + k]));
179
+ if (maxEntry === 0) return 0;
180
+ let minPivot = Infinity;
181
+ for (let k = 0; k < m; k++) {
182
+ let best = k, bestVal = Math.abs(lu[o + k * m + k]);
183
+ for (let r = k + 1; r < m; r++) {
184
+ const v = Math.abs(lu[o + r * m + k]);
185
+ if (v > bestVal) { best = r; bestVal = v; }
186
+ }
187
+ piv[offX[i] + k] = best;
188
+ if (best !== k) {
189
+ for (let c = 0; c < m; c++) {
190
+ let t = lu[o + k * m + c];
191
+ lu[o + k * m + c] = lu[o + best * m + c];
192
+ lu[o + best * m + c] = t;
193
+ t = S[k * m + c];
194
+ S[k * m + c] = S[best * m + c];
195
+ S[best * m + c] = t;
196
+ }
197
+ }
198
+ const sum = S[k * m + k];
199
+ if (bestVal <= 1e-18 * sum || bestVal === 0) {
200
+ const size = sum > 0 ? 1e-15 * sum : 1e-15 * maxEntry;
201
+ bestVal = size;
202
+ lu[o + k * m + k] = lu[o + k * m + k] < 0 ? -size : size;
203
+ this.perturbedPivots++;
204
+ }
205
+ if (bestVal < minPivot) minPivot = bestVal;
206
+ const inv = 1 / lu[o + k * m + k];
207
+ for (let r = k + 1; r < m; r++) {
208
+ const f = (lu[o + r * m + k] *= inv);
209
+ if (f !== 0) {
210
+ const af = Math.abs(f);
211
+ for (let c = k + 1; c < m; c++) {
212
+ lu[o + r * m + c] -= f * lu[o + k * m + c];
213
+ S[r * m + c] += af * S[k * m + c];
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return minPivot / maxEntry;
219
+ }
220
+
149
221
  /**
150
222
  * Solve with the current factorisation. `rhs` and `out` are Float64Arrays of length `size`
151
223
  * (node-major); they may be the same array.
package/src/checks.js CHANGED
@@ -29,6 +29,11 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
29
29
  * zero; under light it's J = F(G − R), and it says where every carrier went.
30
30
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
31
  * what came in.
32
+ * - **order** (a steady state, with `refine` and `order`, the defaults): the device solved from
33
+ * its definition twice, its species listed as given and in reverse, which must give the same
34
+ * steady state (each terminal's voltage and current, each species' amount). A solve that
35
+ * depends on the listing has converged somewhere that isn't steady without noticing (round 11's
36
+ * fuzzer found it in ~1% of random reacting devices, by exactly this test).
32
37
  * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
33
38
  * answer depends on the whole run): in a steady state, the device solved again on a grid
34
39
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
@@ -37,12 +42,12 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
37
42
  * (about 3/4 of it).
38
43
  * @param {import('./index.js').Device} device
39
44
  * @param {object} [sol] the solution to check (default: the device's present state)
40
- * @param {{ refine?: boolean, tol?: number }} [opts]
45
+ * @param {{ refine?: boolean, tol?: number, order?: boolean }} [opts]
41
46
  * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
42
47
  * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
43
48
  * @returns {{ ok: boolean, items: CheckItem[], text: string }}
44
49
  */
45
- export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
50
+ export function check(device, sol, { refine = true, tol = 1e-2, order = true } = {}) {
46
51
  sol ??= device.solution();
47
52
  const { model, solver, def } = device;
48
53
  const steady = sol.steady ?? solver.atSteady;
@@ -66,8 +71,17 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
66
71
  // The rest checks a solution, which an unconverged state isn't.
67
72
  if (sol.converged) {
68
73
  const ledger = steady ? balance(device, sol) : null;
74
+ // A reaction the steady state needs at equilibrium, left far from it (too slow to see: the
75
+ // solution warns of it), isn't steady, however quietly its ledgers sum: "equilibrium" was
76
+ // printed beside that warning.
77
+ const stuck = steady ? solver.stuckReactions() : [];
78
+ if (ledger && stuck.length > 0) {
79
+ ledger.ok = false;
80
+ ledger.summary = `not a steady state: ${stuck.map((s) => `${s.where} is ${sig(s.affinity * solver.VT * 1e3)} mV of affinity from equilibrium`).join(', ')}, too slow for the solve to see (see the warnings); its ledgers say only that nothing moves fast enough to count`;
81
+ }
69
82
  items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
70
83
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
84
+ if (steady && refine && order && model.species.length > 1) items.push(orderCheck(device, sol, ledger.details.gross));
71
85
  // A transient's answer depends on its whole history (and on drives set along the way), so
72
86
  // only a run of its own on a finer grid can check it.
73
87
  else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
@@ -314,6 +328,62 @@ function gridCheck(device, sol, tol, gross) {
314
328
  };
315
329
  }
316
330
 
331
+ // The device solved from its definition with its species listed as given and in reverse: the
332
+ // same steady state either way, or one of them isn't steady. (Both from the definition, not this
333
+ // state, whose closed amounts a transient or set() may have moved from c0.)
334
+ function orderCheck(device, sol, gross) {
335
+ const { def, model } = device;
336
+ const solveAt = (d) => {
337
+ const dev = new Device(d);
338
+ if (sol.time > 0) dev.solver.time = sol.time; // waveforms at the same time
339
+ return dev.solve();
340
+ };
341
+ let a, b;
342
+ try {
343
+ a = solveAt(def);
344
+ b = solveAt({ ...def, species: [...def.species].reverse() });
345
+ } catch (e) {
346
+ return { name: 'order', ok: null, summary: `could not solve the device with its species reversed (${e.message})` };
347
+ }
348
+ if (!a.converged || !b.converged) {
349
+ const which = a.converged ? 'listed in reverse' : b.converged ? 'listed as given' : 'listed either way';
350
+ return { name: 'order', ok: null, summary: `the device solved from its definition did not converge with its species ${which}: compare with a long advance() by hand` };
351
+ }
352
+ const VT = model.RT / FARADAY, diffs = [];
353
+ // (Currents only where the ledgers see something move: in equilibrium they're round-off of
354
+ // whatever exchanges fastest, an electrode's couple, and a wrong state shows in the voltages
355
+ // and amounts instead.)
356
+ // (and above 1e-16 of what the species could carry, as the grid check's floor: a drift at
357
+ // 1e-16 A/m², still moving at 1e12 s, isn't worth reporting)
358
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
359
+ for (const [name, t] of Object.entries(a.terminals)) {
360
+ const u = b.terminals[name];
361
+ diffs.push({ what: `${name} voltage`, a: t.V, b: u.V, rel: Math.abs(u.V - t.V) / (1e-6 * Math.max(VT, Math.abs(t.V))), unit: 'V' });
362
+ if (gross > 0 && Math.max(Math.abs(t.current), Math.abs(u.current), gross) > floorI) diffs.push({ what: `${name} current`, a: t.current, b: u.current, rel: Math.abs(u.current - t.current) / (1e-3 * Math.max(Math.abs(t.current), Math.abs(u.current), gross)), unit: unitsOf(model).current });
363
+ }
364
+ const vol = model.grid.vol, amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0);
365
+ const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
366
+ const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
367
+ for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: 'mol/m²' });
368
+ // Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
369
+ // (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
370
+ // steady: look. Beyond: one of them isn't a steady state.
371
+ const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
372
+ const ok = !(worst.rel > 1) ? true : worst.rel > 1e3 ? false : null;
373
+ const what = `${worst.what} ${sig(worst.a, 6)} → ${sig(worst.b, 6)} ${worst.unit}`;
374
+ return {
375
+ name: 'order',
376
+ ok,
377
+ summary:
378
+ ok === true
379
+ ? 'the same steady state with the species listed in reverse'
380
+ : ok === null
381
+ ? `listed in reverse, the species give nearly the same steady state (${what}): not to round-off, so some weakly held part is not quite steady in one of them`
382
+ : `listed in reverse, the species give a different steady state: ${what}. One of them is not steady, though its solve converged: compare with a long advance(), and please report the device.`,
383
+ details: { diffs },
384
+ };
385
+ }
386
+
317
387
  // Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
318
388
  function rho(s, g, model, r) {
319
389
  let q = model.regions[r].fixedCharge ?? 0;
package/src/index.js CHANGED
@@ -63,8 +63,8 @@ export class Device {
63
63
  }
64
64
 
65
65
  /**
66
- * Change part of the definition (deep-merged). A contact given V or I drops the other, so
67
- * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
66
+ * Change part of the definition (deep-merged). A contact given V or I drops the other (and
67
+ * given I, any series R), so `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
68
68
  * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
69
69
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
70
70
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
@@ -73,7 +73,8 @@ export class Device {
73
73
  */
74
74
  set(patch) {
75
75
  // A terminal given a new kind of drive drops the old one: { I: 0 } replaces a held V.
76
- const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
76
+ // (A current drives a terminal alone: given I, a contact drops V and any series R too.)
77
+ const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined, ...('R' in c ? {} : { R: undefined }) } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
77
78
  for (const side of ['left', 'right']) {
78
79
  const c = patch?.contacts?.[side];
79
80
  if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
package/src/solution.js CHANGED
@@ -152,6 +152,16 @@ export function makeSolution(solver, result = {}) {
152
152
  }
153
153
  return out;
154
154
  });
155
+ // A floating terminal passes what its circuit says, exactly (see passedCurrent).
156
+ if (sol.converged) {
157
+ for (const k of solver.floating) {
158
+ const I = solver.passedCurrent(k);
159
+ if (k === 0) sol.contacts.left.current = I;
160
+ else if (k === 1) sol.contacts.right.current = -I;
161
+ else sol.ports[k - 2].current = I;
162
+ }
163
+ }
164
+ sol.current = sol.contacts.right.current;
155
165
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
156
166
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
157
167
  // device, conduction plus displacement. They sum to zero in steady state.
@@ -361,6 +371,15 @@ export function makeSolution(solver, result = {}) {
361
371
  }
362
372
  });
363
373
  });
374
+ // A reaction a steady state needs at equilibrium, left out of it (see stuckReactions).
375
+ if (sol.steady) {
376
+ for (const { where, affinity } of solver.stuckReactions()) {
377
+ sol.warnings.push(
378
+ `${where}: out of equilibrium by ${(affinity * solver.VT * 1e3).toPrecision(3)} mV of affinity wherever it acts, yet nothing else makes or uses what it changes, so a steady state needs it at equilibrium. ` +
379
+ 'Its rate is too small for the solve to resolve, and it was left where the solve began: this is not a steady state. Raise its rate constant (the steady state does not depend on it), or advance() in time to see how slowly it gets there.',
380
+ );
381
+ }
382
+ }
364
383
  // Any other species denser than any material can be (1e6 mol/m³: water is 5.5e4, a metal's
365
384
  // atoms ~1.5e5): a level set where the device can't follow it, such as a contact's for one ion
366
385
  // far from its neighbours' (Na⁺ held at 1.2e37 mol/m³, whose currents then read 1e28 A/m²).
package/src/solver.js CHANGED
@@ -491,7 +491,7 @@ export class Solver {
491
491
  this.steady = false; // inside a steady solve: sources at the present time, not a step's end
492
492
  // The floating ones are extra unknowns, solved with the grid's by bordering (_solveBordered).
493
493
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
494
- this.drivenKey = this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
494
+ this.drivenKey = this._drivenKey();
495
495
  this.lawRefs = new Map(); // what a steady solve keeps behind a terminal at no current (_captureLaws)
496
496
  this.chargeRefs = new Map(); // …and the charge a terminal at no current that only charges keeps
497
497
  // Per terminal: B = ∂res/∂V (compact column), C = ∂I/∂x (compact row), ∂I/∂V, and for a
@@ -882,6 +882,73 @@ export class Solver {
882
882
  // first, so that the first takes that row, and the rest have its multiple taken off.
883
883
  const through = (w) => this.carriers.some((k, j) => w[X + j] !== 0);
884
884
  const held = new Map(); // (terminal → the combination in place of its circuit row)
885
+ // Reactions' equilibria first. A face reaction that alone changes some combination (without
886
+ // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
887
+ // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
888
+ // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
889
+ // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
890
+ // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
891
+ // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
892
+ // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
893
+ // form vanishes only there), so it stands in for one of those rows (see _applyAffinities),
894
+ // chosen by the same elimination, and a stretch's: never a circuit row.
895
+ // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
896
+ // own terms, never by where its species are listed: the same rows in any listing. (Picked by
897
+ // listing, an electrode's two couples took different rows with the species reversed, and
898
+ // Newton converged in one listing and crawled in the other.)
899
+ this.affinities = [];
900
+ const faceRx = model.interfaces.flatMap((itf, f) => itf.reactions.map((rx) => ({ f, rx, key: rx.part.map((p) => `${model.species[p.i].name}:${p.side}:${p.nu}`).sort().join() })));
901
+ faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
902
+ for (const { f, rx } of faceRx) {
903
+ const nu = new Float64Array(S);
904
+ let inFlat = false;
905
+ for (const p of rx.part) {
906
+ const k = this.stretchOf[(f + p.side) * this.n + p.i];
907
+ if (k < 0 || this.flatStretches.includes(this.stretches[k])) inFlat = true;
908
+ else nu[k] += p.nu;
909
+ }
910
+ if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
911
+ // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
912
+ // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
913
+ // A current held at zero passes nothing: an electrode at open circuit feeding a closed
914
+ // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
915
+ const passes = (k) => !this._noCurrent(this.terms[k]);
916
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && driven(this.stretches[k]).some(passes))));
917
+ // Any of the combinations it alone changes will do, with any conserved amount added (whose
918
+ // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
919
+ // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
920
+ const news = this._conservedMoieties(true, [], rx).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
921
+ const kept = laws.filter((l) => !fed(l));
922
+ const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
923
+ for (const w of tries) {
924
+ const v = new Float64Array(S + T);
925
+ for (let k = 0; k < S; k++) {
926
+ if (w[k] === 0) continue;
927
+ const st = this.stretches[k];
928
+ if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = w[k];
929
+ if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / this.z[st.species];
930
+ }
931
+ for (const [col, row] of chosen) {
932
+ const fc = v[col];
933
+ if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
934
+ }
935
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
936
+ if (!(scale > 1e-9)) continue;
937
+ // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
938
+ // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
939
+ // put the electrode at 602 V.)
940
+ const ok = (j) => j !== undefined && j >= 0 && j < S && Math.abs(v[j]) > 1e-9 * scale && !conductor(this.stretches[j]) && this.terms.every((_, t) => !this._feeds(t, this.stretches[j]));
941
+ // (One of its own species first, then weight 1, the largest, and by name.)
942
+ const own = (k) => rx.part.some((p) => p.i === this.stretches[k].species && this.stretchOf[(f + p.side) * this.n + p.i] === k);
943
+ const key = (k) => [own(k) ? 0 : 1, Math.abs(v[k]) === 1 ? 0 : 1, -Math.abs(v[k]), model.species[this.stretches[k].species].name, this.stretches[k].regions[0]];
944
+ const less = (a, b) => { const ka = key(a), kb = key(b); for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] < kb[j]; return false; };
945
+ const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
946
+ if (col < 0) continue;
947
+ chosen.push([col, v.map((x) => x / v[col])]);
948
+ this.affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
949
+ break;
950
+ }
951
+ }
885
952
  for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
886
953
  for (const [j, k] of this.carriers.entries()) {
887
954
  const c = held.get(k);
@@ -947,7 +1014,7 @@ export class Solver {
947
1014
  this.pinnedIslands = [];
948
1015
  this.islandsOn = false; // (newton() turns them on, see there)
949
1016
  this.pins = [];
950
- const used = new Set(this.constraints.map((cs) => cs.row).filter((o) => o >= 0));
1017
+ const used = new Set([...this.constraints.map((cs) => cs.row).filter((o) => o >= 0), ...this.affinities.map((af) => af.row)]);
951
1018
  for (const st of this.stretches) {
952
1019
  const i = st.species, [s0, s1] = st.regions;
953
1020
  if (!st.mobile || conductor(st)) continue;
@@ -1160,7 +1227,7 @@ export class Solver {
1160
1227
  // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1161
1228
  // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1162
1229
  // elimination will do.
1163
- _conservedMoieties(drives = false, alsoCurrent = []) {
1230
+ _conservedMoieties(drives = false, alsoCurrent = [], skip = null) {
1164
1231
  const { model, n, z } = this, S = this.stretches.length;
1165
1232
  // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1166
1233
  // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
@@ -1169,12 +1236,14 @@ export class Solver {
1169
1236
  const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1170
1237
  this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && model.ports[k - 2].reactions.length > 0 ? [k] : [])) : [];
1171
1238
  const X = S + this.surfCols.length, C = X + this.carriers.length;
1172
- const rows = [];
1239
+ // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1240
+ // its own rows, over the columns, are handed back as `basis.skipped`.)
1241
+ const rows = [], skipped = [];
1173
1242
  model.interfaces.forEach((itf, f) => {
1174
1243
  for (const rx of itf.reactions) {
1175
1244
  const row = new Float64Array(C);
1176
1245
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
1177
- rows.push(row);
1246
+ (rx === skip ? skipped : rows).push(row);
1178
1247
  }
1179
1248
  });
1180
1249
  // An electrode surface's species are columns too, after the stretches (S + j for surface
@@ -1183,12 +1252,13 @@ export class Solver {
1183
1252
  model.ports.forEach((port, k) => {
1184
1253
  for (const rx of port.reactions) {
1185
1254
  const row = new Float64Array(C); // (the electrode's carrier is outside the device)
1255
+ if (rx === skip) skipped.push(row);
1186
1256
  for (const p of rx.part) {
1187
1257
  if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
1188
1258
  else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1189
1259
  else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
1190
1260
  }
1191
- rows.push(row);
1261
+ if (rx !== skip) rows.push(row);
1192
1262
  }
1193
1263
  });
1194
1264
  model.regions.forEach((reg, q) => {
@@ -1197,7 +1267,7 @@ export class Solver {
1197
1267
  const row = new Float64Array(C);
1198
1268
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
1199
1269
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
1200
- rows.push(row);
1270
+ (rx === skip ? skipped : rows).push(row);
1201
1271
  }
1202
1272
  });
1203
1273
  const unit = (k) => {
@@ -1254,6 +1324,7 @@ export class Solver {
1254
1324
  w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1255
1325
  basis.push(w);
1256
1326
  }
1327
+ basis.skipped = skipped;
1257
1328
  return basis;
1258
1329
  }
1259
1330
 
@@ -1508,12 +1579,67 @@ export class Solver {
1508
1579
  if (this.floating.length !== before.length) this.deltaV = null;
1509
1580
  // A terminal newly driven by a current (or no longer) can close a combination behind it.
1510
1581
  // (and one put behind a resistance, or taken from it, its time steps' reading: see _laws)
1511
- this.drivenKey = this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
1582
+ this.drivenKey = this._drivenKey();
1512
1583
  if (this.drivenKey !== driven) this._laws();
1513
1584
  this._refreshSources();
1514
1585
  this.history = [];
1515
1586
  }
1516
1587
 
1588
+ // After a steady solve: each reaction that alone changes some combination nothing else feeds
1589
+ // (see _laws), whose total rate a steady state therefore sets to zero, but whose affinity has one
1590
+ // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
1591
+ // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
1592
+ // port's slow couple at its starting composition, each converged without a word. (A face
1593
+ // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
1594
+ stuckReactions() {
1595
+ const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
1596
+ const passes = (k) => !this._noCurrent(this.terms[k]);
1597
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && this._feeds(j, this.stretches[k])))));
1598
+ const needed = (rx) => {
1599
+ const basis = this._conservedMoieties(true, [], rx);
1600
+ return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
1601
+ };
1602
+ const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
1603
+ const report = (where, as) => {
1604
+ if (as.length === 0 || !(as.every((a) => a > 1e-6) || as.every((a) => a < -1e-6))) return;
1605
+ out.push({ where, affinity: as.reduce((m, a) => (Math.abs(a) < Math.abs(m) ? a : m)) });
1606
+ };
1607
+ model.interfaces.forEach((itf, f) => {
1608
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1609
+ itf.reactions.forEach((rx, q) => {
1610
+ if (this.affinities.some((af) => af.rx === rx) || !needed(rx)) return;
1611
+ report(`interfaces[${f}].reactions[${q}]`, [rx.part.reduce((a, p) => a - p.nu * eta(p.side ? gR : gL, 1 + p.i), rx.fixedA)]);
1612
+ });
1613
+ });
1614
+ model.reactions.forEach((mrx, x) => {
1615
+ const rx = this.rxs[x];
1616
+ if (rx.generation || !needed(mrx)) return;
1617
+ const as = [];
1618
+ for (let g = 0; g < this.nNodes; g++) if (rx.kfNode[g] > 0 || rx.srh?.[this.nodeMaterial[g]]) as.push(rx.sp.reduce((a, i, p) => a + rx.nu[p] * eta(g, 1 + i), rx.fixedA));
1619
+ report(`bulkReactions[${x}]`, as);
1620
+ });
1621
+ model.ports.forEach((port, k) => {
1622
+ port.reactions.forEach((rx, q) => {
1623
+ if (!needed(rx)) return;
1624
+ let aV = rx.fixedA;
1625
+ for (const p of rx.part) if (p.side === 1) aV -= p.nu * this.portEta(port, p.i);
1626
+ report(`ports[${k}].reactions[${q}]`, port.nodes.map((g) => rx.part.reduce((a, p) => (p.side === 1 ? a : a - p.nu * eta(g, p.side === 2 ? 1 + n + p.s : 1 + p.i)), aV)));
1627
+ });
1628
+ });
1629
+ return out;
1630
+ }
1631
+
1632
+ // How each terminal is driven, as far as the laws care (see _laws): by a voltage or a current,
1633
+ // behind a resistance or not, and a current that's held at zero throughout (which feeds a
1634
+ // combination nothing, so a reaction's affinity may stand in for its summed balance).
1635
+ _drivenKey() {
1636
+ return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${this._noCurrent(t) ? '0' : ''}`).join();
1637
+ }
1638
+
1639
+ _noCurrent(t) {
1640
+ return t.drive.kind === 'I' && !(t.drive.R > 0) && t.drive.src.value === 0;
1641
+ }
1642
+
1517
1643
  // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
1518
1644
  // an unknown; one behind a resistance reads its source voltage in _circuit).
1519
1645
  _refreshSources() {
@@ -2031,10 +2157,11 @@ export class Solver {
2031
2157
  // balances, the last two kept aside for _solveBordered.
2032
2158
  const steady = this.steady && dt === Infinity;
2033
2159
  if (steady) this._flatRows();
2160
+ if (steady && this.affinities.length > 0) this._applyAffinities();
2034
2161
  if (steady && this.constraints.length > 0) this._applyConstraints();
2035
2162
  if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2036
2163
  this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2037
- this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.pinnedIslands] : [];
2164
+ this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.affinities, ...this.pinnedIslands] : [];
2038
2165
  this.transformed = this.combining && dt !== Infinity;
2039
2166
  if (this.transformed) this._chargeRows(dt);
2040
2167
  }
@@ -3274,6 +3401,31 @@ export class Solver {
3274
3401
  }
3275
3402
  }
3276
3403
 
3404
+ // Steady solves: each reaction that alone changes a combination (see _laws) held at its
3405
+ // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
3406
+ // like an amount's).
3407
+ _applyAffinities() {
3408
+ const { M, u, uLo, res } = this, grid = this.model.grid;
3409
+ for (const af of this.affinities) {
3410
+ const b0 = Math.floor(af.row / M), r0 = af.row % M;
3411
+ this._replaceRow(b0, r0);
3412
+ this._j(b0, r0, b0, r0, 1);
3413
+ res[this.rix[af.row]] = 0;
3414
+ const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
3415
+ let hi = af.rx.fixedA, lo = 0, len = 0;
3416
+ for (const p of af.rx.part) {
3417
+ const o = this.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
3418
+ hi -= p.nu * u[o];
3419
+ lo -= p.nu * uLo[o];
3420
+ if (this.loc[o] < 0) continue;
3421
+ af.idx[len] = this.rix[o];
3422
+ af.w[len++] = -p.nu;
3423
+ }
3424
+ af.len = len;
3425
+ af.res = hi + lo;
3426
+ }
3427
+ }
3428
+
3277
3429
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
3278
3430
  _replaceRow(b, rs) {
3279
3431
  const l = this.loc[b * this.M + rs];
@@ -3411,8 +3563,13 @@ export class Solver {
3411
3563
  // stops refining.
3412
3564
  const x0 = Float64Array.from(x);
3413
3565
  const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
3414
- let c = 0;
3415
- for (let j = 0; j < N + K; j++) c = Math.max(c, Math.abs(x[j] - x0[j]));
3566
+ // (Measured on the potentials, as Newton's own step is: a rate the plain solve left
3567
+ // unsolved, at a perturbed pivot, can need a correction of 41.7 mol/(m²·s).)
3568
+ const dx = new Float64Array(N + 1), dV = new Float64Array(K);
3569
+ for (let j = 0; j < N; j++) dx[j] = x[j] - x0[j];
3570
+ for (let a = 0; a < K; a++) dV[a] = x[N + a] - x0[N + a];
3571
+ if (this.transformed) this._untransform(dx);
3572
+ const c = this._maxPotentialStep(dx, dV);
3416
3573
  if (!ok || !(c < 1)) return false;
3417
3574
  for (let j = 0; j < N; j++) delta[j] = x[j];
3418
3575
  for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
@@ -3464,7 +3621,19 @@ export class Solver {
3464
3621
  // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
3465
3622
  // within a dozen iterations, the plain solution stands.
3466
3623
  this.islandsOn = islands;
3467
- let rounds = 0, kept = null;
3624
+ let rounds = 0, kept = null, perturbed = false;
3625
+ // Converged by a small update, but solved at a perturbed pivot, or in a steady solve (where a
3626
+ // factorisation that lost a row's digits left a reaction's rate 3.6e7 off it, the update
3627
+ // small, an I = 0 electrode at a voltage 0.4 V from its steady one): the residual itself,
3628
+ // assembled at the state, must be small too (each row scaled by its largest Jacobian entry).
3629
+ const unsolved = () => {
3630
+ if (!perturbed && dt !== Infinity) return false;
3631
+ this.assemble(dt);
3632
+ this._equilibrate();
3633
+ let r = 0;
3634
+ for (let k = 0; k < this.SINK; k++) r = Math.max(r, Math.abs(this.res[k]));
3635
+ return !(r < 1e-6);
3636
+ };
3468
3637
  const islandsOff = (it, result) => {
3469
3638
  if (dt !== Infinity || !this.steady || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3470
3639
  this.islandsOn = true;
@@ -3509,6 +3678,13 @@ export class Solver {
3509
3678
  } catch (err) {
3510
3679
  return fail({ converged: false, iterations: it, history, error: err.message });
3511
3680
  }
3681
+ // A pivot perturbed (one that cancelled to exactly nothing) can leave a row unsolved, its
3682
+ // update small while its residual isn't: the same couple written twice on an electrode at
3683
+ // open circuit converged with one spelling's rate row 41.7 mol/(m²·s) off, the couple
3684
+ // 1.5 RT from equilibrium. Refine the solves (exact J·v) wherever one was, and before
3685
+ // taking a small update for convergence there, look at the residual itself (see unsolved).
3686
+ perturbed = this.sys.perturbedPivots > 0;
3687
+ if (perturbed && !noRefine) refine = true;
3512
3688
  try {
3513
3689
  const pins = this.pins;
3514
3690
  deltaV.fill(0);
@@ -3547,6 +3723,7 @@ export class Solver {
3547
3723
  const last = history[history.length - 2], theta = step / last;
3548
3724
  const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3549
3725
  if (alpha === 1 && (step < tol || contracted)) {
3726
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3550
3727
  const done = { converged: true, iterations: it, history, residual: rmax };
3551
3728
  if (islandsOff(it, done)) continue;
3552
3729
  this.computeConcentrations();
@@ -3567,6 +3744,7 @@ export class Solver {
3567
3744
  if (refine) refined++;
3568
3745
  else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3569
3746
  if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3747
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3570
3748
  const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3571
3749
  if (islandsOff(it, done)) continue;
3572
3750
  this.computeConcentrations();
@@ -3676,11 +3854,7 @@ export class Solver {
3676
3854
  this.portQEnd = Float64Array.from(this.portQ);
3677
3855
  }
3678
3856
  const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
3679
- if (opts.guess) {
3680
- // Start Newton from a predicted state (e.g. extrapolated from the history).
3681
- this.u.set(opts.guess);
3682
- this.uLo.fill(0);
3683
- }
3857
+ if (opts.guess) this._guess(opts.guess); // start Newton from a predicted state
3684
3858
  let dtEff = dt;
3685
3859
  if (bdf) {
3686
3860
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
@@ -3717,10 +3891,7 @@ export class Solver {
3717
3891
  this.u.set(this.uPrev);
3718
3892
  this.uLo.set(this.uPrevLo);
3719
3893
  this.termV.set(this.termVPrev);
3720
- if (opts.guess) {
3721
- this.u.set(opts.guess);
3722
- this.uLo.fill(0);
3723
- }
3894
+ if (opts.guess) this._guess(opts.guess);
3724
3895
  const again = this.newton(dtEff, { ...opts, islands: true });
3725
3896
  result = { ...again, iterations: result.iterations + again.iterations };
3726
3897
  }
@@ -3730,10 +3901,7 @@ export class Solver {
3730
3901
  this.u.set(this.uPrev);
3731
3902
  this.uLo.set(this.uPrevLo);
3732
3903
  this.termV.set(this.termVPrev);
3733
- if (opts.guess) {
3734
- this.u.set(opts.guess);
3735
- this.uLo.fill(0);
3736
- }
3904
+ if (opts.guess) this._guess(opts.guess);
3737
3905
  for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3738
3906
  const again = this.newton(dtEff, opts);
3739
3907
  result = { ...again, iterations: result.iterations + again.iterations };
@@ -3766,6 +3934,19 @@ export class Solver {
3766
3934
  return result;
3767
3935
  }
3768
3936
 
3937
+ // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
3938
+ // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
3939
+ // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
3940
+ // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
3941
+ // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
3942
+ _guess(guess) {
3943
+ const { u, uLo } = this;
3944
+ for (const k of this.fullOf) {
3945
+ u[k] = guess[k];
3946
+ uLo[k] = 0;
3947
+ }
3948
+ }
3949
+
3769
3950
  /**
3770
3951
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
3771
3952
  * estimated against an explicit predictor through the previous states, and controlled to
@@ -3788,6 +3969,7 @@ export class Solver {
3788
3969
  // Already there (an animation frame with no time to add): nothing to do, and the step size
3789
3970
  // carried to the next call stays as it was.
3790
3971
  if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
3972
+ const t0 = this.time;
3791
3973
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3792
3974
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
3793
3975
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
@@ -3800,8 +3982,10 @@ export class Solver {
3800
3982
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
3801
3983
  // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
3802
3984
  // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
3803
- // strictly neutral device, fails outright.
3804
- if (tEnd - this.time <= 1e-10 * Math.abs(tEnd)) {
3985
+ // strictly neutral device, fails outright. Round-off of the time and negligible beside the
3986
+ // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
3987
+ // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
3988
+ if (tEnd - this.time <= 1e-10 * Math.abs(tEnd) && tEnd - this.time <= 1e-9 * (tEnd - t0)) {
3805
3989
  this.time = tEnd;
3806
3990
  break;
3807
3991
  }
@@ -3895,7 +4079,10 @@ export class Solver {
3895
4079
  // Stopped short, other than on the caller's wall-time budget: say where, and why.
3896
4080
  let stopped;
3897
4081
  const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3898
- if (failed) stopped = `${at}: a step didn't converge even when shortened to ${dt.toExponential(1)} s. A drive's jump too big for one step (ramp it), a driven current the device can't keep passing (a diode's reverse recovery past its storage time: stop there), or a device the steady solves also find hard.`;
4082
+ // (What usually does it, most likely first. Round 11's agents read the old list, which led
4083
+ // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
4084
+ // an electrode at open circuit with no double layer, held only by a slow reaction.)
4085
+ 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.`;
3899
4086
  else if (this.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
3900
4087
  return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3901
4088
  }
@@ -4113,8 +4300,19 @@ export class Solver {
4113
4300
  this.computeConcentrations();
4114
4301
  }
4115
4302
 
4303
+ // What a floating terminal k passes into the device, which its circuit row holds exactly once
4304
+ // converged: the current it's driven by, or (V_source − V)/R behind a resistance (the source
4305
+ // as the last step saw it, before any jump at its end). Read as a flux through a metal instead,
4306
+ // it carried that flux's round-off, G Δη with G huge: a gate behind R read 1.5e-4 off wherever
4307
+ // Newton stopped an update early, and below ~1e-16 A/m², nothing but noise.
4308
+ passedCurrent(k) {
4309
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.time, !this.atSteady);
4310
+ return d.kind === 'I' ? src : (src - this.termV[k]) / d.R;
4311
+ }
4312
+
4116
4313
  // Terminal current toward +x at the right contact, from the last assembly at this state.
4117
4314
  _terminalCurrent() {
4315
+ if (this.floating.includes(1)) return -this.passedCurrent(1);
4118
4316
  let I = 0;
4119
4317
  for (let i = 0; i < this.n; i++) I += FARADAY * this.z[i] * this.contactFlux.right[i];
4120
4318
  const dt = this.lastDt;
@@ -46,6 +46,7 @@ export class BlockTridiagonal {
46
46
  * Throws if a diagonal block is exactly singular (unless `staticPivots`).
47
47
  */
48
48
  factor(): void;
49
+ _noisyPivots(i: any): number;
49
50
  /**
50
51
  * Solve with the current factorisation. `rhs` and `out` are Float64Arrays of length `size`
51
52
  * (node-major); they may be the same array.
package/types/checks.d.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  * zero; under light it's J = F(G − R), and it says where every carrier went.
19
19
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
20
20
  * what came in.
21
+ * - **order** (a steady state, with `refine` and `order`, the defaults): the device solved from
22
+ * its definition twice, its species listed as given and in reverse, which must give the same
23
+ * steady state (each terminal's voltage and current, each species' amount). A solve that
24
+ * depends on the listing has converged somewhere that isn't steady without noticing (round 11's
25
+ * fuzzer found it in ~1% of random reacting devices, by exactly this test).
21
26
  * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
22
27
  * answer depends on the whole run): in a steady state, the device solved again on a grid
23
28
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
@@ -26,14 +31,15 @@
26
31
  * (about 3/4 of it).
27
32
  * @param {import('./index.js').Device} device
28
33
  * @param {object} [sol] the solution to check (default: the device's present state)
29
- * @param {{ refine?: boolean, tol?: number }} [opts]
34
+ * @param {{ refine?: boolean, tol?: number, order?: boolean }} [opts]
30
35
  * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
31
36
  * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
32
37
  * @returns {{ ok: boolean, items: CheckItem[], text: string }}
33
38
  */
34
- export function check(device: import("./index.js").Device, sol?: object, { refine, tol }?: {
39
+ export function check(device: import("./index.js").Device, sol?: object, { refine, tol, order }?: {
35
40
  refine?: boolean;
36
41
  tol?: number;
42
+ order?: boolean;
37
43
  }): {
38
44
  ok: boolean;
39
45
  items: CheckItem[];
package/types/index.d.ts CHANGED
@@ -13,8 +13,8 @@ export class Device {
13
13
  get solver(): any;
14
14
  get grid(): any;
15
15
  /**
16
- * Change part of the definition (deep-merged). A contact given V or I drops the other, so
17
- * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
16
+ * Change part of the definition (deep-merged). A contact given V or I drops the other (and
17
+ * given I, any series R), so `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
18
18
  * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
19
19
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
20
20
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
package/types/solver.d.ts CHANGED
@@ -143,6 +143,7 @@ export class Solver {
143
143
  _feeds(k: any, st: any): any;
144
144
  _laws(): void;
145
145
  constraints: any[];
146
+ affinities: any[];
146
147
  localConstraints: any[];
147
148
  islands: any[];
148
149
  pinnedIslands: any[];
@@ -157,7 +158,7 @@ export class Solver {
157
158
  _rowInto(o: any, add: any): void;
158
159
  linRow: Float64Array<any>;
159
160
  _applyLocalConstraints(): void;
160
- _conservedMoieties(drives?: boolean, alsoCurrent?: any[]): Float64Array<any>[];
161
+ _conservedMoieties(drives?: boolean, alsoCurrent?: any[], skip?: any): Float64Array<any>[];
161
162
  _directSteady(): boolean;
162
163
  _applyConstraints(): void;
163
164
  _lawAmount(cs: any): {
@@ -178,6 +179,9 @@ export class Solver {
178
179
  amount(stretch: any): number;
179
180
  redrive(): void;
180
181
  deltaV: Float64Array<any>;
182
+ stuckReactions(): any[];
183
+ _drivenKey(): any;
184
+ _noCurrent(t: any): boolean;
181
185
  _refreshSources(): void;
182
186
  /** η_i (μ̄/RT) of a port's outside level for species i (its electrode's carrier: at V itself). */
183
187
  portEta(port: any, i: any): number;
@@ -256,6 +260,7 @@ export class Solver {
256
260
  };
257
261
  _snapFlat(): void;
258
262
  _flatRows(): void;
263
+ _applyAffinities(): void;
259
264
  _replaceRow(b: any, rs: any): void;
260
265
  _equilibrate(): void;
261
266
  _assembleDifference(dt: any): {
@@ -312,6 +317,7 @@ export class Solver {
312
317
  sourceBefore: boolean;
313
318
  solvedV: any;
314
319
  stepped: boolean;
320
+ _guess(guess: any): void;
315
321
  /**
316
322
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
317
323
  * estimated against an explicit predictor through the previous states, and controlled to
@@ -374,6 +380,7 @@ export class Solver {
374
380
  sourceTime: number;
375
381
  };
376
382
  _restore(s: any): void;
383
+ passedCurrent(k: any): any;
377
384
  _terminalCurrent(): number;
378
385
  /**
379
386
  * Small-signal impedance about the current (steady) state, seen at one terminal. Linearising