driftlet 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
package/docs/device.md CHANGED
@@ -541,7 +541,12 @@ levels are that material's, and the end node takes them through a Donnan step.
541
541
  - `'pinned'` with `zeroCharge`: the $`C \to \infty`$ limit,
542
542
  $`\phi_{\mathrm{edge}} = V - \mathtt{zeroCharge}`$. This is what a "fixed $`\phi`$" boundary honestly
543
543
  means. For example, a Schottky barrier $`\phi_B`$ on n-type material with
544
- $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`.
544
+ $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`. With a carrier linked in equilibrium, a
545
+ pinned contact sets that carrier's density at the edge,
546
+ $`c_{\mathrm{edge}} = c_{\mathrm{ref}} \exp\big((z F\,\mathtt{zeroCharge} - \mu^\circ)/RT\big)`$, so a contact that injects holes at
547
+ $`N_v`$ (their `cRef`, $`\mu^\circ = 0`$) is `zeroCharge: 0`, and a 0.5 eV barrier for them is
548
+ `zeroCharge: -0.5`. This is the contact for a layer of one carrier with nothing to neutralise it
549
+ (a hole-only organic diode): `ohmic()`'s `'bulk'` law, local neutrality, can't hold there.
545
550
 
546
551
  An ideally blocking electrode (inert: no reactions, nothing crossing) is a contact with no
547
552
  terminal species and no links, `{ V, phi: 'pinned', zeroCharge }`: the solution's diffuse layer
package/docs/kit.md CHANGED
@@ -59,8 +59,9 @@ console.log(`I = ${sol.current.toFixed(1)} A/m²`);
59
59
  infinite-recombination contact; `['e-']` alone is selective, and is the current collector on
60
60
  a metal region). `bath(c, reference, drive, { offset, material })` is a bath whose terminal voltage is
61
61
  the level of its `reference` species, as an electrode reversible to it would read (`offset`
62
- places that species relative to the terminal voltage, e.g. to read a solution's potentials
63
- against SHE). `bath(c, drive)`, without a reference, is a bath whose terminal voltage is its
62
+ places that species relative to the terminal voltage, its level at V + `offset`, e.g. to read a
63
+ solution's potentials against SHE). Every species a bath lists is held at its level there, so
64
+ one absent from the bulk (a product) is given as a trace, not 0. `bath(c, drive)`, without a reference, is a bath whose terminal voltage is its
64
65
  φ: an ideal salt bridge, the convention of membrane potentials, so that between two such baths
65
66
  V_right − V_left is the φ difference a voltage clamp sets. A drive is a voltage, a waveform, or
66
67
  `{ V }`, `{ I }`, `{ V, R }`. A bath against a charged layer (an ion exchanger) gives its own
@@ -448,7 +449,10 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
448
449
  term named by its equation; two with the same equation (radiative and SRH recombination, both
449
450
  `e- + h+ = 0`) are told apart by their place, `e- + h+ = 0 (bulkReactions[1])`, and
450
451
  `sol.bulkReactions[k]` has each one's rates. A species at rest
451
- (in equilibrium, flows only of round-off against what it could carry) isn't listed.
452
+ (in equilibrium, flows only of round-off against what it could carry) isn't listed. Where none
453
+ is left, it says "equilibrium" only if no flow comes within ε of what it could carry; otherwise
454
+ it names the largest, which may be round-off or a real flow too small to check this way (a
455
+ reverse diode's 1 µA/m², 4e-15 of what its holes could carry).
452
456
  - **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
453
457
  what came in.
454
458
  - **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
package/docs/numerics.md CHANGED
@@ -951,6 +951,17 @@ A solve that fails falls back stage by stage:
951
951
  the floated solve fails, held solves find that voltage instead, by regula falsi within the
952
952
  bracket, to 1e-10 of the current. (Not a terminal at no current that keeps an amount or a
953
953
  charge: every voltage passes nothing there.)
954
+ - **A resistance.** A terminal behind a resistance R, its source at $`V_{\mathrm{src}}`$, is
955
+ found the same way, held at voltages marched from the held terminal's level until the
956
+ device's current crosses the load line's, $`(V_{\mathrm{src}} - V)/R`$, and floated from
957
+ beside it. Held at its source instead and the resistance put back, the state would sit
958
+ $`IR`$ from the answer, and the pseudo-transient relaxes that over $`RC`$ at the pace Newton
959
+ takes through the swing: a lit Schottky diode passing 0.9 A/m² took 0.05 s behind
960
+ 1 Ω·m² and 105 s behind 10⁵ Ω·m² that way, and takes 33 ms behind any R by the load line
961
+ (80 fuzzed diodes from 0.01 to 10⁷ Ω·m²: 1835 s → 34 s). That route remains for terminals the load line doesn't
962
+ fit: several behind resistances, or one beside terminals all driven by currents, where
963
+ Kirchhoff gives its current and it's held at $`V_{\mathrm{src}} - IR`$ (from there, putting
964
+ $`R`$ back moves nothing).
954
965
  - **The voltages.** The right terminal's voltage ramped to its target: from the contacts'
955
966
  voltages at the last converged solve when the state is that solution (the left one first,
956
967
  where it moved), and otherwise from level terminals, where a cold start is consistent. (A
@@ -105,7 +105,11 @@ $`\ln\gamma_i = -z_i^2 \ell_B \kappa / (2(1 + \kappa a))`$, with the Bjerrum len
105
105
  $`\ell_B = e^2/(4\pi\varepsilon kT)`$ and $`\kappa^2 = F^2 \sum z^2 c / (\varepsilon RT)`$ from the
106
106
  solvent permittivity `epsr` (which defaults to the material's, if that's non-zero) and a
107
107
  distance of closest approach `a` (default 0, the limiting law). It stops being convex at high
108
- ionic strength, and the solver then reports a failure.
108
+ ionic strength, and the solver then reports a failure. It goes on a material's `statistics`, as
109
+ any model does; with `aqueous()`, on the piece it returns: `lib.materials.water.statistics =
110
+ [{ type: 'debye-huckel', species: ['H+', 'Cl-'], a: 4.5e-10 }]`. `a` is an ion size (a few
111
+ ångström: tabulated per ion, as Kielland's sizes, or fitted to measured γ±), and it matters:
112
+ in 10 mM HCl, 4.5 Å against the limiting law moves a concentration cell's voltage by 0.6 mV.
109
113
 
110
114
  Single-ion activity coefficients are a convention: only neutral combinations (the mean
111
115
  activity $`\gamma_\pm`$) are measurable. In a strictly neutral ($`\varepsilon = 0`$) material only
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.2/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
@@ -558,7 +558,8 @@ species }` (its flux) or `{ interface: f, gate }`.
558
558
  (docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
559
559
  battery sits at the OCV of its state of charge, a floating gate keeps its charge.
560
560
  - **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
561
- { terminal })` the small-signal Z(f) about the steady state, in Ω·m².
561
+ { terminal })` the small-signal Z(f) about the steady state, in Ω·m²: `{ f, Z: { re, im } }`,
562
+ each an array over the frequencies (`Z.re[k]`, not `Z[k].re`).
562
563
 
563
564
  ```js
564
565
  import { Device, units, FARADAY } from 'driftlet';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
package/src/checks.js CHANGED
@@ -181,6 +181,12 @@ function balance(device, sol) {
181
181
  // 1e-19 of its scale, to 1e-15), and round-off doesn't. (Above 1e-8 of the scale, any.)
182
182
  const balanced = (l) => Math.abs(l.terms.reduce((a, t) => a + t.rate, 0)) <= 1e-9 * size(l);
183
183
  const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l) || (size(l) > 1e-20 * scale(l) && balanced(l)));
184
+ // What's left unbalanced can be round-off or a small real flow the solve didn't settle (a
185
+ // reverse diode behind a resistance, 1.3 µA/m² at 4e-15 of its scale, balanced to 8e-5 by a
186
+ // solve that stalled), and nothing here tells them apart: round-off reaches 8e-12 of the scale
187
+ // in the stress devices, and one in a thousand of those flows balances to 1%. So above ε of the
188
+ // scale it isn't called equilibrium: the largest is named, as what balance can't check.
189
+ const unsure = ledgers.filter((l) => !moving.includes(l) && size(l) > 1e-16 * scale(l)).sort((a, b) => size(b) / scale(b) - size(a) / scale(a))[0];
184
190
  let worst = 0;
185
191
  const lines = [];
186
192
  for (const l of moving) {
@@ -195,7 +201,9 @@ function balance(device, sol) {
195
201
  const ok = worst < 1e-6;
196
202
  const summary =
197
203
  moving.length === 0
198
- ? 'nothing flows or reacts (equilibrium)'
204
+ ? unsure
205
+ ? `nothing flows that it can tell from round-off: the largest, ${unsure.title} at ${sig(size(unsure))} ${units.rate}${unsure.z !== 0 ? ` (${sig(FARADAY * Math.abs(unsure.z) * size(unsure))} ${units.current})` : ''}, is ${sig(size(unsure) / scale(unsure), 1)} of what it could carry and doesn't balance (to ${sig(Math.abs(unsure.terms.reduce((a, t) => a + t.rate, 0)) / size(unsure), 1)}), so it's round-off, or a flow too small for this check`
206
+ : 'nothing flows or reacts (equilibrium)'
199
207
  : `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, ${units.rate}, + in or made`;
200
208
  // The largest current any charged species carries anywhere: the scale a net current is read against.
201
209
  const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
package/src/device.js CHANGED
@@ -811,6 +811,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
811
811
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
812
812
  const i = speciesIndex.get(sname);
813
813
  need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${reg.name} (material '${mat.name}'), so its level outside means nothing here`);
814
+ need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
814
815
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
815
816
  }
816
817
  const zb = bathZeta(mat, cb, reg.background);
@@ -1359,6 +1360,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1359
1360
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
1360
1361
  const i = speciesIndex.get(sname);
1361
1362
  need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
1363
+ need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
1362
1364
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
1363
1365
  charge += species[i].z * cb[i];
1364
1366
  scale += Math.abs(species[i].z) * cb[i];
@@ -1413,6 +1415,20 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1413
1415
  links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0),
1414
1416
  `${path}.phi: 'bulk' needs at least one connected charged species at this contact`,
1415
1417
  );
1418
+ // Neutral at the contact must be possible: an undoped layer of holes alone never is, and
1419
+ // ohmic(V, ['h+']) on one failed with "nothing specific showed why" (round 16's cold agent).
1420
+ if (!mat.conductor && !mat.phiFree && !mat.models.some((md) => md.type === 'insertion')) {
1421
+ const signs = new Set(species.flatMap((sp, i) => (mat.present[i] && sp.z !== 0 ? [Math.sign(sp.z)] : [])));
1422
+ const fixed = Math.sign(region.fixedCharge ?? 0);
1423
+ if (signs.size === 1 && !(fixed !== 0 && !signs.has(fixed))) {
1424
+ const [only] = signs;
1425
+ throw new DeviceError(
1426
+ `${path}.phi: 'bulk' (what ohmic() and a bath use) holds the contact neutral, but '${region.name ?? region.material}' can't be: ` +
1427
+ `its only charged species are ${only > 0 ? 'positive' : 'negative'} and no fixed charge balances them (an undoped layer of one carrier). ` +
1428
+ "For a contact that sets the carrier's density, pin φ instead: phi: 'pinned' with zeroCharge, the contact's level against the carrier's (see docs/device.md, contacts).",
1429
+ );
1430
+ }
1431
+ }
1416
1432
  }
1417
1433
 
1418
1434
  if (terminal !== null) {
package/src/kit.js CHANGED
@@ -50,10 +50,14 @@ export function vacuumLevel(def, side, path = 'vacuum') {
50
50
  if (!Number.isFinite(side.offset)) fail(`${path}.offset must be a finite number (V)`);
51
51
  if (side.anchor === 'phi') return -side.offset;
52
52
  const sp = (def.species ?? []).find((s) => s.name === side.anchor);
53
- if (!sp) fail(`${path}.anchor must be 'phi' or a charged species, got ${JSON.stringify(side.anchor)}`);
53
+ if (!sp)
54
+ fail(
55
+ `${path}.anchor must be 'phi' or a charged species of the device, got ${JSON.stringify(side.anchor)}` +
56
+ (typeof side.anchor === 'string' ? ` (to anchor on it, list it in the library: aqueous([..., '${side.anchor}']), say)` : ''),
57
+ );
54
58
  if (!(sp.z !== 0)) fail(`${path}.anchor: '${side.anchor}' is neutral, so it has no level in volts`);
55
59
  const entry = mat.species?.[side.anchor];
56
- if (!isObject(entry)) fail(`${path}.anchor: '${side.anchor}' is absent from '${side.material}'`);
60
+ if (!isObject(entry)) fail(`${path}.anchor: '${side.anchor}' is absent from '${side.material}' (to anchor on it, give the material that species)`);
57
61
  if (!Number.isFinite(entry.mu0)) fail(`${path}.anchor: '${side.anchor}' in '${side.material}' has no mu0`);
58
62
  return entry.mu0 / (sp.z * FARADAY) - side.offset;
59
63
  }
package/src/solution.js CHANGED
@@ -272,10 +272,16 @@ export function makeSolution(solver, result = {}) {
272
272
  'advance() in time instead, or give the still species a small D where it is still.',
273
273
  );
274
274
  } else if (slow.length > 0) {
275
+ // Too slow to wait out only where the solve waited: where no time step was taken at all, the
276
+ // start was the trouble (a hole-only layer full of the anode's holes, with traps: "raise its
277
+ // rate constant" changed nothing, ×1e4).
278
+ const waited = (result.history ?? []).some((h) => h.converged && h.dt > 0);
275
279
  for (const { where, affinity } of slow) {
276
280
  sol.warnings.push(
277
281
  `the solve did not converge, likely because ${where} is out of equilibrium by ${(affinity * solver.VT * 1e3).toPrecision(3)} mV of affinity, yet nothing else makes or uses what it changes, so a steady state needs it at equilibrium: ` +
278
- 'it is too slow for the steady solve to wait out. Raise its rate constant (the steady state does not depend on it), or advance() in time.',
282
+ (waited
283
+ ? 'it is too slow for the steady solve to wait out. Raise its rate constant (the steady state does not depend on it), or advance() in time.'
284
+ : 'the solve failed from its first steps, so start it nearer that equilibrium: c0 near it, or solve without the reaction and set() it back.'),
279
285
  );
280
286
  }
281
287
  } else if (result.converged === false && cond && cond.digits > 12) {
package/src/start.js CHANGED
@@ -29,9 +29,11 @@
29
29
  // otherwise it's the running φ̂, or where a conductor on the left or an electrode spread
30
30
  // through the region has a reaction that can balance, the φ̂ that balances it (by bisection;
31
31
  // a trial a non-ideal model refuses counts its ideal charge, which has the sign there). Where
32
- // neutrality is out of reach (c0's amounts leave a charge no held level balances), the
33
- // running φ̂, and a strictly neutral region is noted in s.unbalanced (for a transient that
34
- // fails from it to say why). A strictly
32
+ // neutrality is out of reach (c0's amounts leave a charge no held level balances): in a
33
+ // strictly neutral region the running φ̂, the region noted in s.unbalanced (for a transient
34
+ // that fails from it to say why); otherwise the φ̂ nearest the running one, toward less
35
+ // charge, where the charge is at most ε V_T/(F L²) (L the region's length: what it holds with
36
+ // a thermal voltage across it), or where it stops falling (a fixed charge). A strictly
35
37
  // neutral region (ε = 0) with every charged species' amount given must have a neutral c0:
36
38
  // an error otherwise. A non-ideal material's composition comes from its statistics
37
39
  // (materialAt).
@@ -63,7 +65,8 @@
63
65
  // equilibrated by step 2 (a transient's first step), its amount over the two nodes is kept.
64
66
  // 2. A species given by its level starts at the level of what feeds it, as step 1 orders them.
65
67
  // 3. Where a level-fixed charged species responds, each node starts neutral (Σ z c + the fixed
66
- // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1); a
68
+ // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1: then
69
+ // its charge at most ε V_T/(F L²), or the least φ̂ gives, to the bisection's tolerance); a
67
70
  // strictly neutral region given by amounts is neutral by its c0, checked.
68
71
  // 4. Steps 2 and 3 change no concentration but as they say: step 3 is a gauge (φ̂ and each η by
69
72
  // z·Δ), step 2 moves the passing species between a face's two nodes only.
@@ -81,7 +84,7 @@
81
84
  // Tests: every test (each device starts here); test/transient.test.js on what a start must be.
82
85
 
83
86
  import { gateRate } from './boundaries.js';
84
- import { FARADAY } from './constants.js';
87
+ import { EPS0, FARADAY } from './constants.js';
85
88
  import { sourceAt } from './device.js';
86
89
  import { profileAt, SolverError } from './solver.js';
87
90
 
@@ -260,13 +263,39 @@ export function initFromComposition(s) {
260
263
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
261
264
  while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
262
265
  // Out of reach (every responding carrier has one sign and nothing balances it, as in an
263
- // undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
266
+ // undoped layer that holds only holes): not run to ±∞, but where the charge is no more than
267
+ // the region holds with a thermal voltage across it, ε V_T/(F L²), or as little as it gets
268
+ // (a fixed charge left). Kept at the running φ̂ (a contact's level), a hole-only layer 100
269
+ // nm thick started full of the anode's 1e27 m⁻³ holes, 7 kV of space charge: with traps in
270
+ // it, every step failed from there.
264
271
  if (charge(lo) < 0 || charge(hi) > 0) {
265
272
  // In a strictly neutral region that's a start that can't be: the amounts c0 gives leave
266
273
  // a charge no level-held species can balance. Noted, for a transient that fails from it
267
274
  // to say so (a steady solve, its levels from the contacts, can still find the state).
268
- if (mat.epsr === 0) s.unbalanced.push(r);
269
- return ph;
275
+ if (mat.epsr === 0) {
276
+ s.unbalanced.push(r);
277
+ return ph;
278
+ }
279
+ const L = grid.x[grid.regionEnd[r]] - grid.x[grid.regionStart[r]];
280
+ const most = (mat.epsr * EPS0 * model.RT) / (FARADAY * FARADAY * L * L);
281
+ const q0 = charge(ph), dir = q0 > 0 ? 1 : -1; // (the charge falls as φ̂ rises)
282
+ if (!(Math.abs(q0) > most)) return ph;
283
+ let a = ph, qa = Math.abs(q0);
284
+ for (let step = 1; step < 1e4; step *= 2) {
285
+ let b = a + dir * step;
286
+ const qb = Math.abs(charge(b));
287
+ if (qb <= most) {
288
+ for (let it = 0; it < 100 && Math.abs(b - a) > 1e-9; it++) {
289
+ const m = 0.5 * (a + b);
290
+ if (Math.abs(charge(m)) > most) a = m;
291
+ else b = m;
292
+ }
293
+ return b;
294
+ }
295
+ if (!(qb < qa * (1 - 1e-9))) return a; // (a fixed charge: as little as it gets)
296
+ [a, qa] = [b, qb];
297
+ }
298
+ return a;
270
299
  }
271
300
  for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
272
301
  const m = 0.5 * (lo + hi);
package/src/steady.js CHANGED
@@ -49,11 +49,14 @@
49
49
  // (this.stepped), on a direct device with continuation not off, the same again from a cold
50
50
  // start (initFromComposition), whose u and uLo become the stages' start (termV stays the
51
51
  // start's: a floating terminal keeps the voltage the transient left it at).
52
- // 2. Continuation: generation ramped up (_generationContinuation); a terminal driven by a
53
- // terminals behind a resistance held (at their source voltages, or where every other
54
- // terminal is driven by a current at V_src − I R, Kirchhoff's I), then the resistances put
55
- // back and the steady system solved from there (_resistanceContinuation); a terminal
56
- // driven by a current, not bound (_bound), held at voltages instead (_floatingContinuation); with
52
+ // 2. Continuation: generation ramped up (_generationContinuation); terminals behind a
53
+ // resistance (_resistanceContinuation): one whose current Kirchhoff doesn't give, held at
54
+ // voltages marched to its load line's crossing (_floatingContinuation with a line); else, or
55
+ // where that fails, each held (at its source voltage, or where every other terminal is
56
+ // driven by a current at V_src − I R, Kirchhoff's I), then the resistances put back and the
57
+ // steady system solved from there (and where that fails for one terminal held by
58
+ // Kirchhoff, its load line); a terminal driven by a current, not bound (_bound), held at
59
+ // voltages instead (_floatingContinuation); with
57
60
  // canContinue, the right contact's voltage ramped to its target (_continuation): from the
58
61
  // voltages of the last converged solve (this.solvedV), the left contact's first where it
59
62
  // moved, else from level contacts, a cold start.
@@ -462,8 +465,10 @@ export class SteadySolves {
462
465
  }
463
466
  }
464
467
 
465
- // Each terminal behind a resistance held at its source voltage (R taken off), solved, then the
466
- // resistances put back and the steady system solved from that state.
468
+ // One terminal behind a resistance: its load line's crossing by held solves. Otherwise (or where
469
+ // that fails), each terminal behind a resistance held at its source voltage (R taken off; at
470
+ // V_src − I R where Kirchhoff gives I), solved, then the resistances put back and the steady
471
+ // system solved from that state.
467
472
  _resistanceContinuation(opts, r) {
468
473
  const ks = this.floating.filter((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0);
469
474
  const drives = ks.map((k) => this.terms[k].drive);
@@ -479,8 +484,29 @@ export class SteadySolves {
479
484
  const I = -this.terms.reduce((t, tm, j) => (j === k ? t : t + sourceAt(tm.drive.src, this.time)), 0);
480
485
  return V - I * d.R;
481
486
  };
482
- let heldOk = false;
483
487
  const start = [Float64Array.from(this.u), Float64Array.from(this.uLo), Float64Array.from(this.termV)];
488
+ const restore = () => {
489
+ this.u.set(start[0]);
490
+ this.uLo.set(start[1]);
491
+ this.termV.set(start[2]);
492
+ this.computeConcentrations();
493
+ };
494
+ // One terminal behind R, the rest not all driven by currents (so its current isn't known):
495
+ // its crossing with the load line, (V_src − V)/R, by held solves, as an open circuit's is
496
+ // with its target, first. Held at its source instead, the state is I R from the answer and
497
+ // the pseudo-transient relaxes it over R C at Newton's pace through that swing (a lit Schottky
498
+ // diode passing 0.9 A/m²: 0.05 s at 1 Ω·m², 10 s at 1e4, 105 s at 1e5, steps ∝ R).
499
+ const d = drives[0], Vs = sourceAt(d.src, this.time), line = (V) => (Vs - V) / d.R;
500
+ const byLine = ks.length === 1 && !others;
501
+ if (byLine) {
502
+ const q = this._floatingContinuation(opts, r, ks[0], line);
503
+ if (q.converged) return q;
504
+ restore(); // (from where this stage began, not where the failed one left it)
505
+ steps = q.steps ?? steps;
506
+ iterations = q.iterations ?? iterations;
507
+ history.splice(0, history.length, ...(q.history ?? []));
508
+ }
509
+ let heldOk = false;
484
510
  try {
485
511
  const held = ks.map(at);
486
512
  ks.forEach((k, j) => (this.terms[k].drive = { ...this.terms[k].drive, src: { value: held[j] }, R: 0 }));
@@ -497,16 +523,10 @@ export class SteadySolves {
497
523
  }
498
524
  const q = heldOk ? this._steadySteps(opts) : { converged: false, steps: 0, iterations: 0, history: [] };
499
525
  const done = { ...q, steps: steps + q.steps, iterations: iterations + q.iterations, history: [...history, ...q.history] };
500
- if (q.converged || ks.length !== 1) return done;
501
- // One terminal behind R, the rest not all driven by currents (so its current isn't known):
502
- // its crossing with the load line, (V_src − V)/R, by held solves, as an open circuit's is
503
- // with its target (a silver cell held at 0 V on one side, 16 V behind R on the other, failed).
504
- const d = drives[0], Vs = sourceAt(d.src, this.time);
505
- this.u.set(start[0]);
506
- this.uLo.set(start[1]);
507
- this.termV.set(start[2]);
508
- this.computeConcentrations(); // (from where this stage began, not where the failed one left it)
509
- return this._floatingContinuation(opts, done, ks[0], (V) => (Vs - V) / d.R);
526
+ if (q.converged || ks.length !== 1 || byLine) return done;
527
+ // Where Kirchhoff's hold failed too, the load line.
528
+ restore();
529
+ return this._floatingContinuation(opts, done, ks[0], line);
510
530
  }
511
531
 
512
532
  // Steady solves with the generation reactions' rates scaled from 1e-30 up to 1, ×100 a step