driftlet 1.3.2 → 1.4.0

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/src/solver.js CHANGED
@@ -10,8 +10,9 @@ import { Assembly } from './assemble.js';
10
10
  import { BlockTridiagonal } from './blockTridiagonal.js';
11
11
  import { bernoulli, SG_FAR, sgFlux } from './bernoulli.js';
12
12
  import { BoundaryTerms } from './boundaries.js';
13
- import { FARADAY } from './constants.js';
13
+ import { EPS0, FARADAY } from './constants.js';
14
14
  import { sourceAt } from './device.js';
15
+ import { diffuseScratch, diffuseUnits } from './diffuse.js';
15
16
  import { DeviceError } from './errors.js';
16
17
  import { conservedMoieties, findLaws, noCurrent } from './laws.js';
17
18
  import { newton as newtonFn } from './newton.js';
@@ -60,15 +61,29 @@ export class SolverError extends Error {
60
61
  }
61
62
  }
62
63
 
64
+ // The species a face's diffuse layer on that side (0 left, 1 right) passes to its reactions:
65
+ // each participant there that the layer holds (charged, or on its lattice), but SRH's (refused
66
+ // beside a layer), in species order. A flux across the layer lowers each one's level at the plane.
67
+ function drawSpecies(itf, side, species) {
68
+ const lat = itf.diffuse.lattice[side], out = new Set();
69
+ for (const rx of itf.reactions) {
70
+ if (rx.srh) continue;
71
+ for (const p of rx.part) if (p.side === side && (species[p.i].z !== 0 || lat?.on[p.i])) out.add(p.i);
72
+ }
73
+ return [...out].sort((a, b) => a - b);
74
+ }
75
+
63
76
  export class Solver {
64
77
  constructor(model) {
65
78
  this.model = model;
66
79
  const { grid, species, materials, regions, contacts } = model;
67
80
  const n = species.length;
68
81
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
69
- // one per face reaction (its rate) and one per gate (its fraction open), as many as the
70
- // busiest face has.
71
- const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length + itf.gates.length), 0);
82
+ // one per face reaction (its rate), one per gate (its fraction open) and one per diffuse
83
+ // layer (its drop) and one per species that layer passes to a face reaction (its level at the
84
+ // plane: drawSpecies), as many as the busiest face has.
85
+ const nDiff = (itf) => (itf.diffuse ? [0, 1].reduce((m, side) => m + ((side ? itf.diffuse.right : itf.diffuse.left) ? 1 + drawSpecies(itf, side, species).length : 0), 0) : 0);
86
+ const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length + itf.gates.length + nDiff(itf)), 0);
72
87
  // (and at an electrode port's nodes, one per surface species: its coverage's η)
73
88
  const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
74
89
  // (and a membrane port's gates, after them: each one's fraction open)
@@ -122,6 +137,36 @@ export class Solver {
122
137
  let j = 0;
123
138
  for (let f = 0; f < nFaces; j += model.interfaces[f].gates.length, f++) this.gateIndex[f] = j;
124
139
  this.portGateIndex = model.ports.map((port) => ((j += port.nodes.length * port.gates.length), j - port.nodes.length * port.gates.length));
140
+ // Diffuse layers (src/diffuse.js): one per strictly neutral side of a face that has them, in
141
+ // face order, left side first. Each: its face f, side (0 left, 1 right), edge node g and its
142
+ // block b, its drop s = Fψ/RT in slot o of the face's block (after the gates), the layer's
143
+ // constants, its lattice (crowded: { cMax, on }, the species on it; or null, dilute), which
144
+ // species it holds an excess of (the charged, and a lattice's neutral ones, displaced), and
145
+ // its scratch (q, Γ and their derivatives, D, from computeConcentrations). Γ
146
+ // [d·n + i], the excesses, now and at the step's start; diffAt[f] = [left d, right d] (−1: none).
147
+ // And the species it passes to the face's reactions (the drawn: each one's level at the plane
148
+ // in slot drawSlot[i], after both sides' drops, −1 where not drawn; draw[i] = 1 for them), and
149
+ // their diffusivities there, Dm.
150
+ this.diffList = [];
151
+ this.diffAt = model.interfaces.map(() => [-1, -1]);
152
+ model.interfaces.forEach((itf, f) => {
153
+ if (!itf.diffuse) return;
154
+ let slot = 1 + n + itf.reactions.length + itf.gates.length;
155
+ const sides = [0, 1].filter((side) => (side ? itf.diffuse.right : itf.diffuse.left));
156
+ let level = slot + sides.length;
157
+ for (const side of sides) {
158
+ const g = side ? grid.regionStart[f + 1] : grid.regionEnd[f];
159
+ this.diffAt[f][side] = this.diffList.length;
160
+ const lat = itf.diffuse.lattice[side], drawSlot = new Int32Array(n).fill(-1);
161
+ for (const i of drawSpecies(itf, side, species)) drawSlot[i] = level++;
162
+ const draw = drawSlot.some((q) => q >= 0) ? Uint8Array.from(drawSlot, (q) => (q >= 0 ? 1 : 0)) : null;
163
+ const Dm = model.materials[regions[grid.nodeRegion[g]].material].D;
164
+ this.diffList.push({ f, side, g, b: this.blockOfNode[g], slot, o: this.blockOfFace[f] * M + slot, units: diffuseUnits(itf.diffuse.epsr * EPS0, model.RT, FARADAY), dl: diffuseScratch(n), lat, holds: Uint8Array.from(species, (sp, i) => (sp.z !== 0 || lat?.on[i] ? 1 : 0)), drawSlot, draw, Dm });
165
+ slot++;
166
+ }
167
+ });
168
+ this.gam = new Float64Array(this.diffList.length * n);
169
+ this.gamOld = new Float64Array(this.diffList.length * n);
125
170
 
126
171
  // Per-node material data, flattened [g·n + i].
127
172
  this.present = new Uint8Array(nNodes * n);
@@ -177,6 +222,7 @@ export class Solver {
177
222
  // A face with φ undefined on both sides holds no charge, whatever its law says.
178
223
  model.interfaces.forEach((itf, f) => {
179
224
  if (itf.phi.type !== 'neutral' && this.phiUndefined[grid.regionEnd[f]] && this.phiUndefined[grid.regionEnd[f] + 1]) itf.phi = { type: 'neutral' };
225
+ // (a diffuse side has ions, so φ is defined there: the face above never has one)
180
226
  });
181
227
 
182
228
  // Unknowns as compensated double-doubles, u + uLo. Only differences of η need the extra
@@ -438,6 +484,10 @@ export class Solver {
438
484
  for (let i = 0; i < n; i++) active[bf * M + 1 + i] = itf.links[i].type === 'blocked' ? 0 : 1;
439
485
  for (let k = 0; k < itf.reactions.length + itf.gates.length; k++) active[bf * M + 1 + n + k] = 1;
440
486
  });
487
+ for (const d of this.diffList) {
488
+ active[d.o] = 1;
489
+ for (const q of d.drawSlot) if (q >= 0) active[this.blockOfFace[d.f] * M + q] = 1;
490
+ }
441
491
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
442
492
  for (let b = 0; b < nB; b++) for (let r = 0; r < M; r++) if (active[b * M + r]) loc[b * M + r] = sizes[b]++;
443
493
  const sys = (this.sys = new BlockTridiagonal(nB, sizes));
@@ -624,6 +674,18 @@ export class Solver {
624
674
  this.contactLaw = found.contactLaw;
625
675
  this.carriers = found.carriers;
626
676
  this.unkept = found.unkept; // (laws no row here can keep: see _directSteady)
677
+ // (room in each law's derivative for the diffuse layers' excesses, which src/laws.js, reading
678
+ // the structure alone, doesn't count: each layer's drop and its edge node's slots)
679
+ if (this.diffList.length > 0) {
680
+ const more = this.diffList.length * (this.M + 1);
681
+ for (const cs of [...this.constraints, ...this.timeLaws]) {
682
+ const idx = new Int32Array(cs.idx.length + more), w = new Float64Array(cs.w.length + more);
683
+ idx.set(cs.idx);
684
+ w.set(cs.w);
685
+ cs.idx = idx;
686
+ cs.w = w;
687
+ }
688
+ }
627
689
  this.contactQ = [null, null]; // (each contact's charge as _contact captures it, for contactLaw)
628
690
  this.pinnedIslands = [];
629
691
  this.islandsOn = false; // (newton() turns them on, see there)
@@ -652,12 +714,22 @@ export class Solver {
652
714
  return port.surface[0].capacity * s;
653
715
  }
654
716
 
655
- /** Total amount (mol per unit area) of a stretch's species in the current state. */
717
+ /** Total amount (mol per unit area) of a stretch's species in the current state, its faces' diffuse layers' included. */
656
718
  amount(stretch) {
657
719
  const { n, c, model } = this;
658
720
  const vol = model.grid.vol;
659
721
  let s = 0;
660
722
  for (let g = stretch.nodes[0]; g <= stretch.nodes[1]; g++) s += vol[g] * c[g * n + stretch.species];
723
+ return s + this._diffuseHeld(stretch, this.gam);
724
+ }
725
+
726
+ // What the faces' diffuse layers hold of a stretch's species (mol, per area if planar), by the
727
+ // excesses gam (now, or gamOld at the step's start).
728
+ _diffuseHeld(stretch, gam) {
729
+ let s = 0;
730
+ this.diffList.forEach((dl, k) => {
731
+ if (dl.g >= stretch.nodes[0] && dl.g <= stretch.nodes[1]) s += this.model.grid.area[dl.g] * gam[k * this.n + stretch.species];
732
+ });
661
733
  return s;
662
734
  }
663
735
 
@@ -848,6 +920,7 @@ export class Solver {
848
920
  cOld: Float64Array.from(this.cOld),
849
921
  thOld: Float64Array.from(this.thOld),
850
922
  th0Old: Float64Array.from(this.th0Old),
923
+ gamOld: Float64Array.from(this.gamOld),
851
924
  gateOld: Float64Array.from(this.gateOld),
852
925
  time: this.time,
853
926
  lastDt: this.lastDt,
@@ -872,6 +945,7 @@ export class Solver {
872
945
  this.cOld.set(s.cOld);
873
946
  this.thOld.set(s.thOld);
874
947
  this.th0Old.set(s.th0Old);
948
+ this.gamOld.set(s.gamOld);
875
949
  this.gateOld.set(s.gateOld);
876
950
  this.time = s.time;
877
951
  this.lastDt = s.lastDt;
@@ -972,19 +1046,22 @@ export class Solver {
972
1046
  if (link.type !== 'permeability') return null;
973
1047
  const kl = gl * n + i, kr = gr * n + i;
974
1048
  const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
975
- const d = z[i] * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
1049
+ // (between the diffuse layers' planes, where the face has them: see _face)
1050
+ const [dL, dR] = this.diffAt[f], sL = dL < 0 ? 0 : u[this.diffList[dL].o], sR = dR < 0 ? 0 : u[this.diffList[dR].o];
1051
+ const cl = this.c[kl] * Math.exp(-z[i] * sL), cr = this.c[kr] * Math.exp(-z[i] * sR);
1052
+ const d = z[i] * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) + sR - sL) + shift;
976
1053
  const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]);
977
1054
  const gated = link.gates ?? [];
978
1055
  const P = link.P * (gated.length ? this._open(gated, (q) => u[this._gateSlot(f, q)]) : 1);
979
- const right = P * bernoulli(d) * this.c[kl];
1056
+ const right = P * bernoulli(d) * cl;
980
1057
  // (far up a drift, right × e^Δη is 0·∞: the backward one is then sgFlux's, whole)
981
- return [right, d < SG_FAR ? right * Math.exp(deta) : sgFlux(P, d, deta, this.c[kl], this.c[kr], this.sg)[3]];
1058
+ return [right, d < SG_FAR ? right * Math.exp(deta) : sgFlux(P, d, deta, cl, cr, this.sg)[3]];
982
1059
  });
983
1060
  }
984
1061
 
985
1062
  // Add this step's contact fluxes (at the converged state) to each stretch's intake.
986
1063
  // (A BDF2 step also moves each amount by Σ v (c* − c_n), its history term.)
987
- _accumulateBoundaryIntake(dt, cN) {
1064
+ _accumulateBoundaryIntake(dt, cN, gamN = this.gamOld) {
988
1065
  this._assembleBookkeeping(dt);
989
1066
  this.contactDEnd = { ...this.contactD };
990
1067
  this.portQEnd = Float64Array.from(this.portQ);
@@ -1008,6 +1085,7 @@ export class Solver {
1008
1085
  if (driven.has(k)) q = driven.get(k);
1009
1086
  let hist = 0;
1010
1087
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) hist += vol[g] * (cOld[g * n + st.species] - cN[g * n + st.species]);
1088
+ hist += this._diffuseHeld(st, this.gamOld) - this._diffuseHeld(st, gamN);
1011
1089
  this.boundaryIntake[k] += q * dt + hist;
1012
1090
  });
1013
1091
  }
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/statistics.js CHANGED
@@ -207,6 +207,7 @@ function latticeModel(idx, cRef, cMax) {
207
207
  return {
208
208
  type: 'lattice',
209
209
  idx,
210
+ cMax, // (read by a face's diffuse layer beside it, which crowds on the same sites: src/diffuse.js)
210
211
  // c_a = c_max w_a / (1 + Σ w), w_a = (c_ref,a / c_max) e^{ζ_a}; K = diag(c) − c cᵀ/c_max.
211
212
  evaluate(zeta, c, K) {
212
213
  let m = 0;
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.
@@ -69,7 +72,8 @@
69
72
  // (∞ where the device can be solved directly; else 1e6·tau): a first step at ∞ that fails is
70
73
  // retried once with maxStep 3. A step that fails sends dt down (to tau·1e-6 from giant, then
71
74
  // ÷4, stopping below min(1e-15·tau, 1e-2·fastestTime)); one that converges sends it back up
72
- // (×2 after a failure, growing by ×1.5 to ×10 a step; past 1e6·tau, giant); on a direct device,
75
+ // (×2 after a failure, growing by ×1.5 to ×10 a step; past 1e6·tau, giant, or on a direct
76
+ // device past 1e12·tau); on a direct device,
73
77
  // once dt ≥ tau, a step at ∞ is tried once a decade of dt, the state kept where it fails. Past
74
78
  // giant (not direct), dt grows ×10 a step to 1e6·giant. Converged where a step at ∞ converged,
75
79
  // or, not direct, where a step of dt ≥ giant moved the potentials under tol (finished by one
@@ -130,7 +134,7 @@
130
134
  // here is more than u, uLo, termV and time: this.solvedV, this.stepped, whether the history
131
135
  // is empty (this.violent) and the drives, all of which choose the stages.
132
136
  // 5. Reach: time steps reach a mode as slow as ~1e12·tau (1e6·tau a step, past the giant; on a
133
- // direct device no finite step is longer than 1e6·tau). A reaction that alone settles a
137
+ // direct device by finite steps to 1e12·tau). A reaction that alone settles a
134
138
  // closed combination, slower than that, isn't waited out: the solve fails or converges where
135
139
  // it began, and stuckReactions names it (Solution's warnings, both ways).
136
140
  // Tests: every Device.solve goes through here; test/steady.test.js (the stages, the laws, the
@@ -462,8 +466,10 @@ export class SteadySolves {
462
466
  }
463
467
  }
464
468
 
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.
469
+ // One terminal behind a resistance: its load line's crossing by held solves. Otherwise (or where
470
+ // that fails), each terminal behind a resistance held at its source voltage (R taken off; at
471
+ // V_src − I R where Kirchhoff gives I), solved, then the resistances put back and the steady
472
+ // system solved from that state.
467
473
  _resistanceContinuation(opts, r) {
468
474
  const ks = this.floating.filter((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0);
469
475
  const drives = ks.map((k) => this.terms[k].drive);
@@ -479,8 +485,29 @@ export class SteadySolves {
479
485
  const I = -this.terms.reduce((t, tm, j) => (j === k ? t : t + sourceAt(tm.drive.src, this.time)), 0);
480
486
  return V - I * d.R;
481
487
  };
482
- let heldOk = false;
483
488
  const start = [Float64Array.from(this.u), Float64Array.from(this.uLo), Float64Array.from(this.termV)];
489
+ const restore = () => {
490
+ this.u.set(start[0]);
491
+ this.uLo.set(start[1]);
492
+ this.termV.set(start[2]);
493
+ this.computeConcentrations();
494
+ };
495
+ // One terminal behind R, the rest not all driven by currents (so its current isn't known):
496
+ // its crossing with the load line, (V_src − V)/R, by held solves, as an open circuit's is
497
+ // with its target, first. Held at its source instead, the state is I R from the answer and
498
+ // the pseudo-transient relaxes it over R C at Newton's pace through that swing (a lit Schottky
499
+ // diode passing 0.9 A/m²: 0.05 s at 1 Ω·m², 10 s at 1e4, 105 s at 1e5, steps ∝ R).
500
+ const d = drives[0], Vs = sourceAt(d.src, this.time), line = (V) => (Vs - V) / d.R;
501
+ const byLine = ks.length === 1 && !others;
502
+ if (byLine) {
503
+ const q = this._floatingContinuation(opts, r, ks[0], line);
504
+ if (q.converged) return q;
505
+ restore(); // (from where this stage began, not where the failed one left it)
506
+ steps = q.steps ?? steps;
507
+ iterations = q.iterations ?? iterations;
508
+ history.splice(0, history.length, ...(q.history ?? []));
509
+ }
510
+ let heldOk = false;
484
511
  try {
485
512
  const held = ks.map(at);
486
513
  ks.forEach((k, j) => (this.terms[k].drive = { ...this.terms[k].drive, src: { value: held[j] }, R: 0 }));
@@ -497,16 +524,10 @@ export class SteadySolves {
497
524
  }
498
525
  const q = heldOk ? this._steadySteps(opts) : { converged: false, steps: 0, iterations: 0, history: [] };
499
526
  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);
527
+ if (q.converged || ks.length !== 1 || byLine) return done;
528
+ // Where Kirchhoff's hold failed too, the load line.
529
+ restore();
530
+ return this._floatingContinuation(opts, done, ks[0], line);
510
531
  }
511
532
 
512
533
  // Steady solves with the generation reactions' rates scaled from 1e-30 up to 1, ×100 a step
@@ -687,7 +708,10 @@ export class SteadySolves {
687
708
  if (dt < giant) {
688
709
  dt *= grow; // ramping up after a failure
689
710
  grow = Math.min(10, grow * 1.5);
690
- if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
711
+ // Then the giant step; or, solved directly, on to 1e12 τ, the direct solve tried once a
712
+ // decade (above): jumping to dt = ∞ at 1e6 τ, its failure sent dt back to 1e-6 τ, round
713
+ // and round below 70 s, where traps filled by 1e9 cm⁻³ electrons took 1e3 s (τ 7e-5 s).
714
+ if (dt > (direct ? 1e12 : 1e6) * tau) dt = giant;
691
715
  } else if (dt < 1e6 * giant) {
692
716
  dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)
693
717
  }
@@ -734,7 +758,9 @@ export class SteadySolves {
734
758
  // it, and there's nothing here to shift.
735
759
  if (!st.spectator || this.model.materials[this.model.regions[st.regions[0]].material].conductor) return;
736
760
  const want = this.referenceAmounts[k];
737
- const ideal = this.nodeIdeal.subarray(st.nodes[0], st.nodes[1] + 1).every((v) => v === 1);
761
+ // (one shift of η scales an ideal stretch's amount exactly, but not what a diffuse layer
762
+ // at its edge holds, whose excess isn't proportional to c: iterate there too)
763
+ const ideal = this.nodeIdeal.subarray(st.nodes[0], st.nodes[1] + 1).every((v) => v === 1) && this._diffuseHeld(st, this.gam) === 0;
738
764
  for (let it = 0; it < (ideal ? 1 : 30); it++) {
739
765
  const now = this.amount(st);
740
766
  if (!(now > 0) || now === want) return;
@@ -746,6 +772,9 @@ export class SteadySolves {
746
772
  const dc = this.nodeIdeal[g] ? this.c[g * n + st.species] : this.K[g * n * n + st.species * (n + 1)];
747
773
  dA += this.model.grid.vol[g] * dc;
748
774
  }
775
+ this.diffList.forEach((dl, d) => {
776
+ if (dl.g >= st.nodes[0] && dl.g <= st.nodes[1]) dA += this.model.grid.area[dl.g] * dl.dl.dgdc[st.species * (n + 1)] * this.c[dl.g * n + st.species];
777
+ });
749
778
  shift = (want - now) / dA;
750
779
  if (!(Math.abs(shift) > 1e-16)) return;
751
780
  }
package/src/timeSteps.js CHANGED
@@ -8,7 +8,8 @@
8
8
  // in docs/internals.md. The Solver's own step, advance and integrate call them (the API, the
9
9
  // steady solves' time stepping and the kit call those). What they use of it:
10
10
  // s.newton(dt, opts) solves the equations at the step's end, from the state as it stands,
11
- // with storage (c − c*)/dt against s.cOld, s.thOld, s.th0Old, s.gateOld,
11
+ // with storage (c − c*)/dt against s.cOld, s.thOld, s.th0Old, s.gamOld (the
12
+ // diffuse layers' excesses), s.gateOld,
12
13
  // s.contactDStart, s.portQStart; leaves the state at its last iterate.
13
14
  // s._snapshot() / s._restore(snap) the whole time state (u, uLo, termV, the start-of-step
14
15
  // values, time, lastDt, history, the boundary intake, sourceTime).
@@ -17,7 +18,7 @@
17
18
  //
18
19
  // The state: u + uLo (Newton's unknowns, η and φ̂ in thermal units), termV (terminal voltages),
19
20
  // time. The history: s.history, the accepted steps before this one, newest first, at most 3,
20
- // each { t (its start), dt, u (its start state), c, th, th0, g (gates), D (contacts' D), Q
21
+ // each { t (its start), dt, u (its start state), c, th, th0, gam, g (gates), D (contacts' D), Q
21
22
  // (ports' charges) }: what BDF2 and the predictor need.
22
23
  //
23
24
  // step(s, dt, opts) → newton's result (+ bdf)
@@ -128,7 +129,7 @@ export function step(s, dt, opts = {}) {
128
129
  // What the step overwrites before Newton runs, for a failed one to put back: the Solution read
129
130
  // after it takes its contacts' fluxes against the start-of-step values (a host emptied by its
130
131
  // current read its contact's Li⁺ flux 8× off at the same state).
131
- const was = { cOld: Float64Array.from(s.cOld), thOld: Float64Array.from(s.thOld), th0Old: Float64Array.from(s.th0Old), gateOld: Float64Array.from(s.gateOld), contactDStart: s.contactDStart, portQStart: s.portQStart, atSteady: s.atSteady };
132
+ const was = { cOld: Float64Array.from(s.cOld), thOld: Float64Array.from(s.thOld), th0Old: Float64Array.from(s.th0Old), gamOld: Float64Array.from(s.gamOld), gateOld: Float64Array.from(s.gateOld), contactDStart: s.contactDStart, portQStart: s.portQStart, atSteady: s.atSteady };
132
133
  s.atSteady = false;
133
134
  const prev = s.history[0];
134
135
  const w = prev ? dt / prev.dt : 0;
@@ -137,7 +138,7 @@ export function step(s, dt, opts = {}) {
137
138
  s.uPrevLo.set(s.uLo);
138
139
  s.termVPrev.set(s.termV);
139
140
  s.computeConcentrations();
140
- const cN = Float64Array.from(s.c), thN = Float64Array.from(s.th), th0N = Float64Array.from(s.th0);
141
+ const cN = Float64Array.from(s.c), thN = Float64Array.from(s.th), th0N = Float64Array.from(s.th0), gamN = Float64Array.from(s.gam);
141
142
  const gN = Float64Array.from(s.gateList, (gt) => s.u[gt.o] + s.uLo[gt.o]);
142
143
  // Contact displacement before the step: as it was at the end of the previous step (under
143
144
  // the parameters then), so a gate-voltage change shows up as displacement current.
@@ -155,6 +156,7 @@ export function step(s, dt, opts = {}) {
155
156
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
156
157
  for (let k = 0; k < thN.length; k++) s.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
157
158
  for (let k = 0; k < th0N.length; k++) s.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
159
+ for (let k = 0; k < gamN.length; k++) s.gamOld[k] = b1 * gamN[k] - b2 * prev.gam[k];
158
160
  for (let k = 0; k < gN.length; k++) s.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
159
161
  s.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
160
162
  s.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
@@ -163,6 +165,7 @@ export function step(s, dt, opts = {}) {
163
165
  s.cOld.set(cN);
164
166
  s.thOld.set(thN);
165
167
  s.th0Old.set(th0N);
168
+ s.gamOld.set(gamN);
166
169
  s.gateOld.set(gN);
167
170
  s.contactDStart = DN;
168
171
  s.portQStart = QN;
@@ -205,13 +208,13 @@ export function step(s, dt, opts = {}) {
205
208
  }
206
209
  result.bdf = bdf;
207
210
  if (result.converged) {
208
- s.history.unshift({ t: s.time, dt, u: Float64Array.from(s.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
211
+ s.history.unshift({ t: s.time, dt, u: Float64Array.from(s.uPrev), c: cN, th: thN, th0: th0N, gam: gamN, g: gN, D: DN, Q: QN });
209
212
  if (s.history.length > 3) s.history.length = 3;
210
213
  s.time = tEnd;
211
214
  s.lastDt = dtEff;
212
215
  s.contactDOld = s.contactDStart;
213
216
  s.portQOld = s.portQStart;
214
- s._accumulateBoundaryIntake(dtEff, cN);
217
+ s._accumulateBoundaryIntake(dtEff, cN, gamN);
215
218
  // (In time, the state is no longer the steady solve's that a continuation would start from.)
216
219
  if (!s.steady) {
217
220
  s.solvedV = undefined;
@@ -224,6 +227,7 @@ export function step(s, dt, opts = {}) {
224
227
  s.cOld.set(was.cOld);
225
228
  s.thOld.set(was.thOld);
226
229
  s.th0Old.set(was.th0Old);
230
+ s.gamOld.set(was.gamOld);
227
231
  s.gateOld.set(was.gateOld);
228
232
  s.contactDStart = was.contactDStart;
229
233
  s.portQStart = was.portQStart;
package/src/types.js CHANGED
@@ -114,6 +114,15 @@
114
114
  * @property {Record<string, number>} [K] with vmax: a half-saturation concentration (mol/m³) for
115
115
  * every species the forward reaction consumes
116
116
  */
117
+ /**
118
+ * A face's diffuse layer on one side, in a solution.
119
+ * @typedef {object} DiffuseLayerResult
120
+ * @property {number} psi the drop from the side's edge node to the plane, φ_plane − φ_edge (V)
121
+ * @property {number} charge the layer's charge (C/m²)
122
+ * @property {Record<string, number>} excess each ion's surface excess, ∫ (c − c_edge) dx (mol/m²)
123
+ * @property {Record<string, number>} plane each ion's concentration at the plane, c e^{−zFψ/RT} (mol/m³; on a
124
+ * lattice, over its crowding; for what a face reaction takes or makes there, times its level's drop across the layer)
125
+ */
117
126
  /**
118
127
  * One face between consecutive regions. For different materials under a 'pinned' or
119
128
  * 'capacitive' law, exactly one alignment (step or dipole) is required.
@@ -124,6 +133,9 @@
124
133
  * @property {InterfacePhiLaw} [phi] electrostatic law (default 'pinned'; 'neutral' between ε = 0 materials;
125
134
  * beside a conductor 'neutral' or capacitive, required next to an ε > 0 material)
126
135
  * @property {number} [sheetCharge] C/m²
136
+ * @property {{ epsr: number }} [diffuse] a Gouy–Chapman diffuse layer below the grid on each strictly
137
+ * neutral (ε = 0) side of a charged face, of relative permittivity epsr: the side's share of the
138
+ * face's charge held as its ions' surface excesses, the face's reactions at its plane
127
139
  * @property {Record<string, InterfaceSpeciesLink>} [species] per-species laws (default equilibrium
128
140
  * where present on both sides and mobile on one, blocked otherwise; a species reacting at the
129
141
  * face and present on both sides has no default)
@@ -283,11 +295,13 @@
283
295
  * side holds; with gates, each gate's fraction open at each node and the voltage they follow (V)
284
296
  * @property {{ left: string, right: string, dipole: number, sheetCharge: number, D: number, N: Record<string, number>,
285
297
  * rates: number[], links: Record<string, string>, oneWay?: Record<string, [number, number]>,
286
- * gates?: Record<string, number>, V?: number }[]} interfaces each face: the regions it joins, its dipole
298
+ * gates?: Record<string, number>, V?: number,
299
+ * diffuse?: { left?: DiffuseLayerResult, right?: DiffuseLayerResult } }[]} interfaces each face: the regions it joins, its dipole
287
300
  * (V) and sheet charge (C/m²), its displacement D (C/m²), each species' flux N across it and each face
288
301
  * reaction's net rate (mol/(m²·s), toward +x and forward), how each species that crosses it does (its
289
302
  * link type); for those crossing by permeability, the one-way fluxes [toward +x, toward −x] whose
290
- * difference is N; with gates, each one's fraction open and the voltage they follow, φ_right − φ_left (V)
303
+ * difference is N; with gates, each one's fraction open and the voltage they follow, φ_right − φ_left (V; between the diffuse layers' planes, where it has them);
304
+ * with a diffuse layer, each side's
291
305
  * @property {{ rate: Float64Array, forward: Float64Array, nu: Record<string, number>, regions: number[], total: number }[]} bulkReactions
292
306
  * each bulk reaction's net rate per node (mol/(m³·s), forward minus backward, NaN where it doesn't
293
307
  * run), its one-way forward rate (the backward is forward − rate), what one forward reaction makes
@@ -2,12 +2,20 @@ export function gateRate(law: any, V: any): number[];
2
2
  export class BoundaryTerms {
3
3
  _portRate(port: any, rx: any, g: any): number;
4
4
  _face(f: any): void;
5
+ _diffuseDraws(d: any, bf: any): void;
6
+ _diffuseTerms(d: any, bf: any, Af: any): void;
7
+ dW: Float64Array<any>;
8
+ _diffuseChain(d: any): any;
9
+ dV2: Float64Array<any>;
10
+ _diffuseLayers(): void;
5
11
  _gateSlot(f: any, q: any): any;
6
12
  _gateVoltage(gt: any): number;
7
13
  _portVoltage(k: any, g: any): number;
8
14
  _faceVoltage(f: any): number;
9
15
  _faceGates(f: any, bf: any, bL: any, bR: any): void;
10
16
  _faceReaction(rx: any, k: any, f: any, bf: any, bL: any, bR: any, Af: any): void;
17
+ dWr: Float64Array<any>;
18
+ aWr: Float64Array<any>;
11
19
  _liveTerms(): any;
12
20
  _allTerms: any;
13
21
  _terminals(dt: any): void;
@@ -0,0 +1,25 @@
1
+ /** The diffuse layer's charge, excesses and their derivatives (see the contract above). */
2
+ export function diffuseLayer(c: any, z: any, n: any, s: any, { A, B }: {
3
+ A: any;
4
+ B: any;
5
+ }, out: any, lat?: any, draw?: any): any;
6
+ /** A scratch object for diffuseLayer over n species. */
7
+ export function diffuseScratch(n: any): {
8
+ q: number;
9
+ dqds: number;
10
+ dqdc: Float64Array<any>;
11
+ gam: Float64Array<any>;
12
+ dgds: Float64Array<any>;
13
+ dgdc: Float64Array<ArrayBuffer>;
14
+ K: Float64Array<any>;
15
+ dKds: Float64Array<any>;
16
+ dKdc: Float64Array<ArrayBuffer>;
17
+ D: number;
18
+ dDds: number;
19
+ dDdc: Float64Array<any>;
20
+ };
21
+ /** The constants A = √(εRT/2)/F and B = √(2εRT) for a layer of permittivity eps (F/m) at RT (J/mol). */
22
+ export function diffuseUnits(eps: any, RT: any, F: any): {
23
+ A: number;
24
+ B: number;
25
+ };
package/types/newton.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export function maxPotentialStep(s: any, delta: any, deltaV?: any): number;
1
+ export function maxPotentialStep(s: any, delta: any, deltaV?: any, layers?: boolean): number;
2
2
  export function newton(s: any, dt: any, { maxIter, tol, maxStep, islands }?: {
3
3
  maxIter?: number;
4
4
  tol?: number;
package/types/solver.d.ts CHANGED
@@ -32,6 +32,10 @@ export class Solver {
32
32
  gateOld: Float64Array<ArrayBuffer>;
33
33
  gateIndex: Int32Array<ArrayBuffer>;
34
34
  portGateIndex: any;
35
+ diffList: any[];
36
+ diffAt: any;
37
+ gam: Float64Array<ArrayBuffer>;
38
+ gamOld: Float64Array<ArrayBuffer>;
35
39
  present: Uint8Array<ArrayBuffer>;
36
40
  cRef: Float64Array<ArrayBuffer>;
37
41
  mu0hat: Float64Array<ArrayBuffer>;
@@ -234,8 +238,9 @@ export class Solver {
234
238
  _surfaceStart(g: any): void;
235
239
  /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
236
240
  surfaceAmount(j: any): number;
237
- /** Total amount (mol per unit area) of a stretch's species in the current state. */
241
+ /** Total amount (mol per unit area) of a stretch's species in the current state, its faces' diffuse layers' included. */
238
242
  amount(stretch: any): number;
243
+ _diffuseHeld(stretch: any, gam: any): number;
239
244
  redrive(): void;
240
245
  deltaV: any;
241
246
  _drivenKey(): any;
@@ -280,6 +285,7 @@ export class Solver {
280
285
  cOld: Float64Array<ArrayBuffer>;
281
286
  thOld: Float64Array<ArrayBuffer>;
282
287
  th0Old: Float64Array<ArrayBuffer>;
288
+ gamOld: Float64Array<ArrayBuffer>;
283
289
  gateOld: Float64Array<ArrayBuffer>;
284
290
  time: number;
285
291
  lastDt: number;
@@ -308,6 +314,6 @@ export class Solver {
308
314
  _terminalCurrent(): number;
309
315
  segmentFluxes(): Float64Array<ArrayBuffer>[];
310
316
  faceOneWay(f: any): any;
311
- _accumulateBoundaryIntake(dt: any, cN: any): void;
317
+ _accumulateBoundaryIntake(dt: any, cN: any, gamN?: Float64Array<ArrayBuffer>): void;
312
318
  }
313
319
  import { BlockTridiagonal } from './blockTridiagonal.js';