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 +12 -1
- package/docs/device.md +16 -6
- package/docs/kit.md +8 -1
- package/docs/numerics.md +56 -12
- package/llms.txt +11 -9
- package/package.json +1 -1
- package/src/blockTridiagonal.js +73 -1
- package/src/checks.js +72 -2
- package/src/index.js +4 -3
- package/src/solution.js +19 -0
- package/src/solver.js +226 -28
- package/types/blockTridiagonal.d.ts +1 -0
- package/types/checks.d.ts +8 -2
- package/types/index.d.ts +2 -2
- package/types/solver.d.ts +8 -1
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.
|
|
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
|
|
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 } } }`
|
|
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
|
|
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 = \
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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
|
|
672
|
-
|
|
673
|
-
leaves is exact, however small beside
|
|
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
|
|
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.
|
|
32
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.
|
|
33
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.
|
|
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,
|
|
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 }
|
|
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.
|
|
206
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.
|
|
207
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.
|
|
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
package/src/blockTridiagonal.js
CHANGED
|
@@ -124,7 +124,15 @@ export class BlockTridiagonal {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|