driftlet 0.14.0 → 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/alignment.md +6 -3
- package/docs/data.md +3 -1
- package/docs/device.md +102 -33
- package/docs/kit.md +55 -17
- package/docs/numerics.md +244 -121
- package/docs/reliability.md +15 -9
- package/docs/statistics.md +28 -4
- package/llms.txt +73 -41
- package/package.json +1 -1
- package/src/blockTridiagonal.js +85 -7
- package/src/checks.js +83 -6
- package/src/describe.js +46 -5
- package/src/device.js +33 -10
- package/src/index.js +23 -10
- package/src/particles.js +131 -55
- package/src/solution.js +164 -8
- package/src/solver.js +923 -277
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/blockTridiagonal.d.ts +1 -0
- package/types/checks.d.ts +8 -2
- package/types/index.d.ts +2 -2
- package/types/particles.d.ts +3 -6
- package/types/solver.d.ts +50 -29
- package/types/stack.d.ts +6 -4
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.
|
|
32
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
33
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
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
|
|
|
@@ -73,14 +74,15 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
|
|
|
73
74
|
- **The library** is pieces with `species` and `materials`: `semiconductor('Si')` (Si, Ge, GaAs, at
|
|
74
75
|
300 K), `aqueous(names)` (H⁺ Li⁺ Na⁺ K⁺ Ag⁺ Cu²⁺ Zn²⁺ Fe²⁺ Fe³⁺ OH⁻ Cl⁻ NO₃⁻ SO₄²⁻ in water, at 25
|
|
75
76
|
°C; `{ epsr: 0 }` makes it strictly neutral, with no double layers), `metal('Ag')` (Ag Cu Au Al Zn
|
|
76
|
-
Li Pt)
|
|
77
|
-
and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the
|
|
78
|
-
N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0),
|
|
79
|
-
6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists
|
|
80
|
-
`lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`,
|
|
81
|
-
mu0 }`. Only μ° differences within a reaction matter:
|
|
82
|
-
(against SHE, the library's μ°_H⁺ being 0). An
|
|
83
|
-
species: [], materials: { SiO2: { epsr: 3.9, species:
|
|
77
|
+
Li Pt), all from `driftlet/kit`. A semiconductor's textbook numbers are in `SEMICONDUCTORS.Si`
|
|
78
|
+
(`Eg` and `chi` in eV, `Nc` and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the
|
|
79
|
+
material, e⁻'s and h⁺'s `cRef` are N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0),
|
|
80
|
+
so n_i = √(N_c N_v) e^(−E_g/2RT), 6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists
|
|
81
|
+
(`z: 0` for a neutral one): `lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`,
|
|
82
|
+
`lib.materials.water.species['A-'] = { D, mu0 }`. Only μ° differences within a reaction matter:
|
|
83
|
+
for a redox couple μ°_red − μ°_ox = −F·E° (against SHE, the library's μ°_H⁺ being 0). An
|
|
84
|
+
insulator is a material with no species: `{ species: [], materials: { SiO2: { epsr: 3.9, species:
|
|
85
|
+
{} } } }`.
|
|
84
86
|
- **Faces.** Between two different non-metals, give an alignment, `{ dipole }` or `{ step: {
|
|
85
87
|
species: 'e-', value } }` (J/mol), or `phi: 'neutral'`. `vacuumDipole(def, { material, anchor:
|
|
86
88
|
'e-', offset: χ }, …)` estimates a dipole from electron affinities (Anderson's rule); it reads
|
|
@@ -95,7 +97,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
|
|
|
95
97
|
(`capacitance`, `gates`). A BJT's base contact is a port on a narrow window of the base,
|
|
96
98
|
`{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
|
|
97
99
|
- **Drives.** A terminal is held at a voltage `V`, driven by a current `I` (open circuit is
|
|
98
|
-
`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 }`
|
|
99
102
|
(piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
|
|
100
103
|
amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
|
|
101
104
|
start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
|
|
@@ -111,8 +114,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
|
|
|
111
114
|
{ tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
|
|
112
115
|
srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
|
|
113
116
|
injection. Light is a reaction from a reservoir (`photon = e- + h+`, uniform), or absorbed as
|
|
114
|
-
it goes in,
|
|
115
|
-
|
|
117
|
+
it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`. To switch
|
|
118
|
+
it during a run, `set()` the device's `bulkReactions` (the state and the step size carry over).
|
|
116
119
|
- **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
|
|
117
120
|
at cRef: i₀ = nF·k0 there, and k0 = k°·cRef for a textbook k° in m/s (1 cm/s at 1 M is
|
|
118
121
|
`k0: 10`).
|
|
@@ -145,6 +148,14 @@ const tK = D['K+'] / (D['K+'] + D['Cl-']), VT = (GAS_CONSTANT * 298.15) / FARADA
|
|
|
145
148
|
console.log(`EMF ${(sol.terminals.right.V * 1e3).toFixed(3)} mV, 2t₊V_T ln 10 = ${(2 * tK * VT * Math.log(10) * 1e3).toFixed(3)} mV`); // 58.048, 58.047
|
|
146
149
|
```
|
|
147
150
|
|
|
151
|
+
An ion-exchange membrane is a layer whose material has its ions slower, with a `fixedCharge` (−X·F
|
|
152
|
+
for X mol/m³ of fixed anions), between layers of water. A bath against the membrane itself names
|
|
153
|
+
its own material, `bath(c, 'Cl-', 0, { material: 'water' })`, and meets it through a Donnan step.
|
|
154
|
+
An exchange or partition constant between the membrane and the water is a difference of μ°:
|
|
155
|
+
give the membrane's ions μ° shifted from the water's by s_i, and K = e^(−(s_Na − s_H)/RT) for
|
|
156
|
+
Na⁺ against H⁺ (a large K draws in salt too, unless the co-ion's s is raised). The membrane
|
|
157
|
+
demo's page script is a worked example.
|
|
158
|
+
|
|
148
159
|
## Template: a semiconductor junction
|
|
149
160
|
|
|
150
161
|
```js
|
|
@@ -193,9 +204,9 @@ a CDN needs a long virtual-time budget, 30 s.)
|
|
|
193
204
|
<span id="readout"></span>
|
|
194
205
|
<div id="figure"></div>
|
|
195
206
|
<script type="module">
|
|
196
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
197
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
198
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
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';
|
|
199
210
|
|
|
200
211
|
const def = build({
|
|
201
212
|
T: 300,
|
|
@@ -220,17 +231,15 @@ see "Reading level diagrams" below.
|
|
|
220
231
|
|
|
221
232
|
For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
|
|
222
233
|
mol/m² of a species, hopping at rates taken from the solution: on average their density is its
|
|
223
|
-
concentration and their net crossing its flux.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
a membrane
|
|
232
|
-
The pn demo's page script draws a diode's carriers on its band diagram this way, and the axon
|
|
233
|
-
demo's the ions crossing its membrane (`cap: 0`, every cell a sea, so only the crossings).
|
|
234
|
+
concentration and their net crossing its flux. Say on the page that it samples the continuum
|
|
235
|
+
solution, and how much a dot is. Where a dot would be lost among millions (majority carriers, a
|
|
236
|
+
bath), a cell over `cap` dots is a sea: shade it by `expected(name)`. Each frame,
|
|
237
|
+
`swarm.step(dt)` in the device's time (about h²/D per hop, h a cell), then draw `swarm.dots`
|
|
238
|
+
(`{ species, x }`) and `swarm.events` (`'unmade'` is a recombination, `'cross'` an ion through a
|
|
239
|
+
membrane between two seas); on a new steady solution, `swarm.update(sol, { resample: true })`.
|
|
240
|
+
An ion beside a cell membrane hops ~1e6 times per crossing, so a membrane page shows seas and
|
|
241
|
+
crossing events, not walking ions. The demos' page scripts do this for a diode's carriers (pn),
|
|
242
|
+
ions crossing a nerve membrane (axon, `cap: 0`) and Donnan exclusion (membrane).
|
|
234
243
|
|
|
235
244
|
## Template: a solar cell
|
|
236
245
|
|
|
@@ -263,6 +272,15 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
|
|
|
263
272
|
console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
|
|
264
273
|
```
|
|
265
274
|
|
|
275
|
+
In an organic cell, light makes excitons: a neutral species `X` in the donor, made by
|
|
276
|
+
`photogeneration({ ..., makes: 'X' })` and decaying by `{ equation: 'X = 0', kf: { donor: 1 / tau } }`.
|
|
277
|
+
They split at the donor/acceptor face by a reaction there, `X = h+(left) + e-(right)` with e⁻ and
|
|
278
|
+
h⁺ blocked at the face (the reaction moves them), and its reverse is charge-transfer
|
|
279
|
+
recombination; the face's `step` sets E_CT. With `alpha: 0` the splitting is a velocity
|
|
280
|
+
k0/cRef, a Robin condition on the exciton profile; with α > 0 it's k0/cRef · e^(α(E_X − E_CT)/RT).
|
|
281
|
+
The organic demo's page script is a worked example (J_sc against exciton diffusion theory, V_oc
|
|
282
|
+
against detailed balance).
|
|
283
|
+
|
|
266
284
|
For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
|
|
267
285
|
default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
|
|
268
286
|
and `hysteresis(trace, turn)` to read the same figures from yours.
|
|
@@ -308,11 +326,14 @@ for (const Vg of [-2, -0.95, 0, 0.4, 2]) {
|
|
|
308
326
|
console.log(`V_G ${Vg} V: C/C_ox ${(C / Cox).toFixed(3)}`); // 0.949, 0.701 (flat band), 0.267, 0.375, 0.973
|
|
309
327
|
}
|
|
310
328
|
// At flat band the silicon is a capacitor of ε/L_D, its Debye length's.
|
|
311
|
-
const LD = Math.sqrt((11.
|
|
312
|
-
console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.
|
|
329
|
+
const LD = Math.sqrt((11.9 * EPS0 * GAS_CONSTANT * 300) / (FARADAY * FARADAY * NA)); // (the kit's Si: ε 11.9)
|
|
330
|
+
console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.9 * EPS0))).toFixed(3)}`); // 0.701
|
|
313
331
|
```
|
|
314
332
|
|
|
315
|
-
`dev.impedance([f], { terminal: 'left' })`
|
|
333
|
+
`dev.impedance([f], { terminal: 'left' })` (C = Im(1/Z)/ω, from `{ f, Z: { re, im } }`) gives
|
|
334
|
+
the same C at any frequency with the port; without it, only below the minority supply's knee
|
|
335
|
+
(generation, or diffusion from the back contact: 0.01 Hz and below), and the high-frequency C–V
|
|
336
|
+
above. Draw it with
|
|
316
337
|
`bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
|
|
317
338
|
|
|
318
339
|
## Template: a Schottky diode
|
|
@@ -419,10 +440,12 @@ console.log(level(sol, silver)[mid] - level(sol, SHE, { standard: true })[mid]);
|
|
|
419
440
|
|
|
420
441
|
A working electrode, a stagnant layer, and the stirred bulk as a bath whose terminal is its
|
|
421
442
|
reference species (Cl⁻: an Ag/AgCl reference). An electrode's potential against SHE is its
|
|
422
|
-
electrons' V minus φ in the bulk (with the library's μ°)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
443
|
+
electrons' V minus φ in the bulk (with the library's μ°), and a Cl⁻ terminal sits ~1.36 V above
|
|
444
|
+
that, so an electrode held at 0 V beside it is strongly anodic: `describe(def)` says where SHE
|
|
445
|
+
sits, and an offset of that much (`bath(c, 'Cl-', 0, { offset })`) makes the terminal read SHE.
|
|
446
|
+
The working electrode's current is positive when oxidising. The layer's resistance is in series
|
|
447
|
+
(uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of salt still
|
|
448
|
+
leaves the limiting current about 1% off pure diffusion.
|
|
426
449
|
|
|
427
450
|
```js
|
|
428
451
|
import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
|
|
@@ -665,7 +688,9 @@ capacitance and gated channels to an outside given as a `bath` (the port's V is
|
|
|
665
688
|
outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
|
|
666
689
|
per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
|
|
667
690
|
`ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
|
|
668
|
-
its own port (an internode's just a small capacitance).
|
|
691
|
+
its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
|
|
692
|
+
the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
|
|
693
|
+
(scale h with √a). The stimulus is a current per volume, so a thinner axon needs it as 1/a.
|
|
669
694
|
|
|
670
695
|
```js
|
|
671
696
|
import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
|
|
@@ -710,8 +735,15 @@ species }` (its flux) or `{ interface: f, gate }`.
|
|
|
710
735
|
|
|
711
736
|
- **Events.** To stop at one (a cut-off voltage, a diode's storage time), advance in short calls
|
|
712
737
|
and test after each: a driven current held past what the device can pass fails to converge.
|
|
713
|
-
- **
|
|
714
|
-
|
|
738
|
+
- **The first instants.** The first step is sized by error control (from 1e-4 of the interval),
|
|
739
|
+
so a fast start (an initial plateau, a charging spike) can fall inside it: give `dt0` (and
|
|
740
|
+
`dtMax`) to see it. A voltage step's first point carries the displacement that charges the bulk
|
|
741
|
+
at once (a spike of ~1e4 A/m² for ~1e-10 s across a cell's Stern layers): leave it out of
|
|
742
|
+
integrals of I²R.
|
|
743
|
+
- **Storage.** A device that only stores what comes in (a battery host filling at a current, a
|
|
744
|
+
gate charging) has no steady state: `advance()` it from its `c0`, don't `solve()` it
|
|
745
|
+
(docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
|
|
746
|
+
battery sits at the OCV of its state of charge, a floating gate keeps its charge.
|
|
715
747
|
- **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
|
|
716
748
|
{ terminal })` the small-signal Z(f) about the steady state, in Ω·m².
|
|
717
749
|
|
|
@@ -803,7 +835,7 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
|
|
|
803
835
|
everything floating (hold one terminal at a `V`), a species that reaches no contact without a
|
|
804
836
|
`c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
|
|
805
837
|
`'2 e-'`, a current drive of the wrong sign. `interfaces[f]` is the stack's f-th face.
|
|
806
|
-
- **A gate as a contact on an oxide.** It fails as `'e-' is
|
|
838
|
+
- **A gate as a contact on an oxide.** It fails as `'e-' is absent from the end material 'SiO2'`: make the
|
|
807
839
|
gate a metal layer with a capacitive face, as in the MOS template.
|
|
808
840
|
- **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
|
|
809
841
|
- **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
|
package/package.json
CHANGED
package/src/blockTridiagonal.js
CHANGED
|
@@ -110,16 +110,29 @@ export class BlockTridiagonal {
|
|
|
110
110
|
if (i === 0 || sizes[i - 1] === 0) {
|
|
111
111
|
for (let k = 0; k < m * m; k++) lu[o + k] = B[o + k];
|
|
112
112
|
} else {
|
|
113
|
+
// Row by row, skipping A's zeros (a gate or a local unknown has none to its neighbours):
|
|
114
|
+
// the same sums, subtracted in the same order.
|
|
113
115
|
const mp = sizes[i - 1], oa = offA[i], op = offC[i - 1]; // C'_{i−1}: mp × m
|
|
114
116
|
for (let r = 0; r < m; r++) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const or = o + r * m;
|
|
118
|
+
for (let c = 0; c < m; c++) lu[or + c] = B[or + c];
|
|
119
|
+
for (let k = 0; k < mp; k++) {
|
|
120
|
+
const a = A[oa + r * mp + k];
|
|
121
|
+
if (a === 0) continue;
|
|
122
|
+
const ok = op + k * m;
|
|
123
|
+
for (let c = 0; c < m; c++) lu[or + c] -= a * cp[ok + c];
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
|
-
|
|
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);
|
|
123
136
|
if (!(ratio > 0)) {
|
|
124
137
|
this.factored = false;
|
|
125
138
|
this.singularBlock = i;
|
|
@@ -130,8 +143,9 @@ export class BlockTridiagonal {
|
|
|
130
143
|
if (i < n - 1) {
|
|
131
144
|
const mn = sizes[i + 1], oc = offC[i];
|
|
132
145
|
for (let c = 0; c < mn; c++) {
|
|
133
|
-
|
|
134
|
-
|
|
146
|
+
let any = false;
|
|
147
|
+
for (let r = 0; r < m; r++) if ((tmp[r] = C[oc + r * mn + c]) !== 0) any = true;
|
|
148
|
+
if (any) luSolve(lu, o, piv, offX[i], m, tmp); // (a zero column stays zero)
|
|
135
149
|
for (let r = 0; r < m; r++) cp[oc + r * mn + c] = tmp[r];
|
|
136
150
|
}
|
|
137
151
|
}
|
|
@@ -140,6 +154,70 @@ export class BlockTridiagonal {
|
|
|
140
154
|
this.factored = true;
|
|
141
155
|
}
|
|
142
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
|
+
|
|
143
221
|
/**
|
|
144
222
|
* Solve with the current factorisation. `rhs` and `out` are Float64Arrays of length `size`
|
|
145
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' });
|
|
@@ -110,9 +124,12 @@ function balance(device, sol) {
|
|
|
110
124
|
else ledgers[l].terms.push({ what, rate });
|
|
111
125
|
};
|
|
112
126
|
const last = regions.length - 1;
|
|
127
|
+
// (A contact that blocks a species is no source of it: what its flux reads there is the end
|
|
128
|
+
// box's own imbalance, which the ledger must show, not balance.)
|
|
129
|
+
const through = (side, i) => model.contacts[side].species[i].type !== 'blocked';
|
|
113
130
|
species.forEach((sp, i) => {
|
|
114
|
-
add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
115
|
-
add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
131
|
+
if (through('left', i)) add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
132
|
+
if (through('right', i)) add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
116
133
|
model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
|
|
117
134
|
});
|
|
118
135
|
model.reactions.forEach((rx, k) => {
|
|
@@ -259,8 +276,12 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
259
276
|
const diffs = [];
|
|
260
277
|
// The current, against the largest current any species carries (so at open circuit, or in
|
|
261
278
|
// equilibrium, round-off isn't read as a change).
|
|
262
|
-
|
|
263
|
-
|
|
279
|
+
// A current too small for the ledgers to call moving (a diode at low bias: 1e-4 A/m² against
|
|
280
|
+
// majority carriers that could carry 6e8) is compared still, above a floor at 1e-16 of what the
|
|
281
|
+
// species could carry, above what solves resolve (~1e-19 of it in a GaAs junction).
|
|
282
|
+
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));
|
|
283
|
+
const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross, floorI);
|
|
284
|
+
if (gross > 0 || Math.max(Math.abs(sol.current), Math.abs(other.current)) > floorI) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
|
|
264
285
|
// Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
|
|
265
286
|
const VT = model.RT / FARADAY;
|
|
266
287
|
model.terminals.forEach((t, k) => {
|
|
@@ -307,6 +328,62 @@ function gridCheck(device, sol, tol, gross) {
|
|
|
307
328
|
};
|
|
308
329
|
}
|
|
309
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
|
+
|
|
310
387
|
// Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
|
|
311
388
|
function rho(s, g, model, r) {
|
|
312
389
|
let q = model.regions[r].fixedCharge ?? 0;
|
package/src/describe.js
CHANGED
|
@@ -49,6 +49,10 @@ export function unitWarnings(def) {
|
|
|
49
49
|
if (c > 1e6) warn(path, `${num(c)} mol/m³ is over 1000 M; concentrations are mol/m³ (units.molar, and units.perCm3 for carriers)`);
|
|
50
50
|
};
|
|
51
51
|
(def.species ?? []).forEach((sp, i) => sp?.cRef !== undefined && conc(`species[${i}].cRef`, sp.cRef));
|
|
52
|
+
// SRH's n₁ is a concentration too (a value per cm³ typed in is 6e5 times too large)
|
|
53
|
+
(def.bulkReactions ?? []).forEach((rx, k) => {
|
|
54
|
+
for (const [m, law] of Object.entries(isObject(rx?.srh) ? rx.srh : {})) if (isObject(law) && law.n1 !== undefined) conc(`bulkReactions[${k}].srh.${m}.n1`, law.n1);
|
|
55
|
+
});
|
|
52
56
|
for (const [mname, mat] of Object.entries(def.materials ?? {})) {
|
|
53
57
|
if (!isObject(mat)) continue;
|
|
54
58
|
const path = `materials.${mname}`;
|
|
@@ -92,13 +96,19 @@ export function unitWarnings(def) {
|
|
|
92
96
|
};
|
|
93
97
|
const kinetics = (path, reactions) => {
|
|
94
98
|
(reactions ?? []).forEach((rx, k) => {
|
|
95
|
-
|
|
99
|
+
// (an exchange current only where electrons take part: an ion transfer's k0 can be large)
|
|
100
|
+
const said = rx?.equation ?? (rx?.left || rx?.right ? JSON.stringify([rx.left, rx.right]) : null);
|
|
101
|
+
const electrons = said === null || /(^|[^\w])e-($|[^\w])/.test(said);
|
|
102
|
+
if (rx?.k0 > 10 && electrons) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
|
|
96
103
|
});
|
|
97
104
|
};
|
|
98
105
|
(def.interfaces ?? []).forEach((f, k) => {
|
|
99
106
|
if (!isObject(f)) return;
|
|
100
107
|
capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
|
|
101
|
-
|
|
108
|
+
// (an exchange current is an electrode's: a metal on one side, or sides not yet known; a
|
|
109
|
+
// splitting exciton's k0, between two semiconductors, is a velocity times cRef)
|
|
110
|
+
const sides = [def.regions?.[k], def.regions?.[k + 1]].map((r) => (isObject(r) ? def.materials?.[r.material] : undefined));
|
|
111
|
+
if (sides.some((m) => m === undefined || isObject(m?.conductor))) kinetics(`interfaces[${k}]`, f.reactions);
|
|
102
112
|
});
|
|
103
113
|
(def.ports ?? []).forEach((p, k) => {
|
|
104
114
|
if (!isObject(p)) return;
|
|
@@ -149,7 +159,12 @@ export function describe(def) {
|
|
|
149
159
|
return;
|
|
150
160
|
}
|
|
151
161
|
const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
|
|
152
|
-
|
|
162
|
+
// (donors and acceptors where there are electrons; an ion exchanger's charge in mol/m³)
|
|
163
|
+
const doped = species.some((sp, i) => mat.present[i] && (sp.name === 'e-' || sp.name === 'h+'));
|
|
164
|
+
if (reg.fixedCharge !== 0) {
|
|
165
|
+
const X = Math.abs(reg.fixedCharge) / FARADAY;
|
|
166
|
+
parts.push(doped ? `fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(X)}` : `fixed charge ${reg.fixedCharge > 0 ? '+' : '−'}${num(X)} mol/m³`);
|
|
167
|
+
}
|
|
153
168
|
// The screening concentration: Σ z²c from the initial composition, plus the carriers that
|
|
154
169
|
// balance the fixed charge (doping), which c0 doesn't list.
|
|
155
170
|
let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
|
|
@@ -216,8 +231,34 @@ export function describe(def) {
|
|
|
216
231
|
const cdef = def.contacts?.[side];
|
|
217
232
|
const ct = model.contacts[side];
|
|
218
233
|
const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
|
|
219
|
-
|
|
220
|
-
|
|
234
|
+
// (a bath without a reference species: its V is its φ)
|
|
235
|
+
const term = ct.she && !ct.she.reference ? ' (terminal: the bath φ)' : ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
|
|
236
|
+
// A bath read through a reference species: where the SHE level sits against its terminal,
|
|
237
|
+
// so a held electrode's V reads on the familiar scale (a Cl⁻ reference on table μ° puts it
|
|
238
|
+
// 1.37 V below, a surprise to anyone expecting 0 V to mean SHE).
|
|
239
|
+
let she = '';
|
|
240
|
+
if (ct.she?.reference) {
|
|
241
|
+
const d = ct.she.shift, at = Math.abs(d) < 5e-4 ? 'V' : `V ${d < 0 ? '−' : '+'} ${Math.abs(d).toFixed(3)} V`;
|
|
242
|
+
she = ct.she.exact ? `; the bath's SHE level is at ${at}` : `; the bath's φ is at ${at} (on the usual tables' μ°, where μ°_H⁺ = 0, that's its SHE level)`;
|
|
243
|
+
}
|
|
244
|
+
lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}${she}`);
|
|
245
|
+
// An outside phase out of equilibrium with a reaction that runs at the contact: the reaction
|
|
246
|
+
// then runs there for ever, a current at zero bias (H⁺ and OH⁻ given off Kw in a bath, say).
|
|
247
|
+
const end = regions[side === 'left' ? 0 : regions.length - 1].material;
|
|
248
|
+
model.reactions.forEach((rx, k) => {
|
|
249
|
+
if (!(rx.kf[end] > 0) || rx.kfProfile[end]) return; // (light, a profile, is meant to drive)
|
|
250
|
+
const parts = [...rx.reactants.map(({ i, nu }) => [i, -nu]), ...rx.products.map(({ i, nu }) => [i, nu])];
|
|
251
|
+
const level = (i) => {
|
|
252
|
+
const l = ct.species[i];
|
|
253
|
+
return l.type !== 'equilibrium' ? NaN : species[i].z === 0 ? l.mu : l.offset === undefined ? NaN : species[i].z * FARADAY * l.offset;
|
|
254
|
+
};
|
|
255
|
+
// A/RT from the outside's levels (the terminal voltage cancels: Σ ν z = 0)
|
|
256
|
+
const a = rx.fixedA - parts.reduce((sum, [i, nu]) => sum + nu * level(i), 0) / RT;
|
|
257
|
+
if (Math.abs(a) > 1e-3) {
|
|
258
|
+
const rdef = def.bulkReactions[k];
|
|
259
|
+
warnings.push(`contacts.${side}: the outside isn't in equilibrium with bulkReactions[${k}] (${rdef.equation ?? equation(rdef.nu)}; A = ${num(a)} RT there), so it runs at the contact for ever, a current even at zero bias. Unless that's meant (a source), give the outside a composition in equilibrium with it (H⁺ and OH⁻ at √K_w, say)`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
221
262
|
}
|
|
222
263
|
(def.ports ?? []).forEach((p, k) => {
|
|
223
264
|
const port = model.ports[k];
|