driftlet 1.3.3 → 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/boundaries.js CHANGED
@@ -33,6 +33,19 @@
33
33
  // carrier at activity 1), or saturating (vmax), or SRH; −Af·ν·r into each participant's row.
34
34
  // gates (_faceGates): (x − x_old)/dt − α(V)(1 − x) + β(V)x = 0, V = φ_R − φ_L (no storage at
35
35
  // dt = ∞).
36
+ // diffuse layers (_diffuseTerms; itf.diffuse, on each strictly neutral side: s.diffList, its
37
+ // q, Γ and their derivatives from computeConcentrations, src/diffuse.js): each a slot after
38
+ // the gates, its drop s = Fψ/RT, which puts that side's plane at φ̂_edge + s: the φ law (each
39
+ // form above) runs to the planes, φ̂ → φ̂ + s. Its row: q(s, c_edge) − Q, Q the charge the face
40
+ // gives that side (D on the left, −(D + sheetCharge) on the right); −Af·q in the edge node's
41
+ // Gauss row, and Af (Γ_i − Γ_i,old)/dt in its balances (none at dt = ∞). A reaction's
42
+ // participant on that side reacts at the plane: its c times e^{−z s} in the rate (Butler–Volmer
43
+ // and saturating; SRH beside a layer is refused by normalizeDevice), and its level there w
44
+ // above the edge node's (a slot after the drops, _diffuseDraws: its η + w, its c times e^w;
45
+ // row 1 − e^w − N K/(D c), N what the reactions take from that side, K the layer's
46
+ // resistance to it; where K < 0, −w − X/√(1 + X²), X = N K/(D c)); a
47
+ // permeability link runs between the planes (c e^{−z s} at each, its drift over φ̂ + s), and
48
+ // gates follow the voltage between them (_faceVoltage).
36
49
  //
37
50
  // The terminals (_terminals), in order: the sources refreshed (_refreshSources); the live
38
51
  // terminals' termB, termC zeroed, and termDI, termDX, termI, qg, qgSlope; each port's terms but
@@ -78,15 +91,18 @@
78
91
  // from outside, read from its residual (so the current is conserved through the device).
79
92
  // 3. A face's flux enters its two edge nodes with opposite signs, times Af.
80
93
  // 4. Where μ̄ is level across a link (or a = 0 for a reaction), its flux (rate) is exactly 0.
94
+ // 4b. A diffuse layer's side holds the face's charge in the layer: at its row's solution the
95
+ // edge box's own charge (vol·ρ) is what its other terms put there, as without the face.
81
96
  // 5. The readouts (contactFlux, contactD, portFlux, portQ, portRates) are those of the state
82
97
  // assembled, written at every assembly.
83
98
  // Tests: test/contacts.test.js, test/ports.test.js, test/terminals.test.js, test/circuit.test.js,
84
99
  // test/gates.test.js, test/membrane.test.js, test/kinetics.test.js, test/coverage.test.js,
85
- // test/capacitance.test.js, test/metal.test.js, test/jacobian.test.js.
100
+ // test/capacitance.test.js, test/metal.test.js, test/diffuse.test.js, test/jacobian.test.js.
86
101
 
87
102
  import { sgFlux } from './bernoulli.js';
88
103
  import { FARADAY } from './constants.js';
89
104
  import { sourceAt } from './device.js';
105
+ import { diffuseLayer } from './diffuse.js';
90
106
  import { powi, powr } from './pow.js';
91
107
 
92
108
  // Butler–Volmer factor g(a) = e^{αa} − e^{−(1−α)a} = e^{−(1−α)a}·expm1(a) (precise near a = 0),
@@ -143,35 +159,44 @@ export class BoundaryTerms {
143
159
  const grid = model.grid, interfaces = model.interfaces;
144
160
  const bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
145
161
  const itf = interfaces[f];
162
+ // A diffuse layer's drop s on a side (src/diffuse.js) puts that side's plane, where the Stern
163
+ // layer ends, at φ̂_edge + s: [left, right] layer indices (−1: none) and slots.
164
+ const [dlL, dlR] = this.diffAt[f], dl = this.diffList;
165
+ const sOf = (d) => (d < 0 ? 0 : u[dl[d].o]);
166
+ const jS = (rs, d, v) => d >= 0 && this._j(bf, rs, bf, dl[d].slot, v);
146
167
  // φ law: pinned jump (dipole), Helmholtz capacitor, or no charge at all (neutral).
147
168
  const law = itf.phi.type;
148
169
  const condSide = itf.conductor && law !== 'neutral' ? itf.conductor.side : null;
149
170
  if (condSide) {
150
171
  // Against a metal: the other side's φ is tied to the metal's Fermi level V_F = V_T η/z.
151
172
  const im = itf.conductor.i, bm = condSide === 'left' ? bL : bR, bo = condSide === 'left' ? bR : bL;
152
- const sg = condSide === 'left' ? 1 : -1;
173
+ const sg = condSide === 'left' ? 1 : -1, dlo = condSide === 'left' ? dlR : dlL;
153
174
  const vf = (u[bm * M + 1 + im] + uLo[bm * M + 1 + im]) / z[im]; // V_F / V_T
154
- const gap = vf - itf.zeroCharge / VT - (u[bo * M] + uLo[bo * M]); // (V_F − zeroCharge − φ_edge)/V_T
175
+ const gap = vf - itf.zeroCharge / VT - (u[bo * M] + uLo[bo * M]) - sOf(dlo); // (V_F − zeroCharge − φ_plane)/V_T
155
176
  if (law === 'pinned') {
156
177
  res[R[bf * M]] = gap;
157
178
  this._j(bf, 0, bm, 1 + im, 1 / z[im]);
158
179
  this._j(bf, 0, bo, 0, -1);
180
+ jS(0, dlo, -1);
159
181
  } else {
160
- // D toward the other side = C (V_F − zeroCharge − φ_edge); toward +x that's sg times it.
182
+ // D toward the other side = C (V_F − zeroCharge − φ_plane); toward +x that's sg times it.
161
183
  const kC = itf.phi.C * VT;
162
184
  res[R[bf * M]] = u[bf * M] - sg * kC * gap;
163
185
  this._j(bf, 0, bf, 0, 1);
164
186
  this._j(bf, 0, bm, 1 + im, (-sg * kC) / z[im]);
165
187
  this._j(bf, 0, bo, 0, sg * kC);
188
+ jS(0, dlo, sg * kC);
166
189
  }
167
190
  } else if (law === 'neutral') {
168
191
  // D = 0 (not an unknown); the jump is whatever each side's neutrality needs
169
192
  } else {
170
- const jump = u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) - itf.dipole / VT;
193
+ const jump = u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) + sOf(dlR) - sOf(dlL) - itf.dipole / VT;
171
194
  if (law === 'pinned') {
172
195
  res[R[bf * M]] = jump;
173
196
  this._j(bf, 0, bR, 0, 1);
174
197
  this._j(bf, 0, bL, 0, -1);
198
+ jS(0, dlR, 1);
199
+ jS(0, dlL, -1);
175
200
  } else {
176
201
  // D = −C (φ_R − φ_L − dipole): displacement toward +x drops across the layer.
177
202
  const kC = itf.phi.C * VT;
@@ -179,6 +204,8 @@ export class BoundaryTerms {
179
204
  this._j(bf, 0, bf, 0, 1);
180
205
  this._j(bf, 0, bR, 0, kC);
181
206
  this._j(bf, 0, bL, 0, -kC);
207
+ jS(0, dlR, kC);
208
+ jS(0, dlL, -kC);
182
209
  }
183
210
  }
184
211
  // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead. The
@@ -192,6 +219,8 @@ export class BoundaryTerms {
192
219
  res[R[bR * M]] -= Af * (u[bf * M] + itf.sheetCharge);
193
220
  this._j(bR, 0, bf, 0, -Af);
194
221
  }
222
+ if (dlL >= 0) this._diffuseTerms(dlL, bf, Af);
223
+ if (dlR >= 0) this._diffuseTerms(dlR, bf, Af);
195
224
  for (let i = 0; i < n; i++) {
196
225
  const r = 1 + i, o = bf * M + r;
197
226
  const type = itf.links[i].type;
@@ -208,14 +237,17 @@ export class BoundaryTerms {
208
237
  // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
209
238
  // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
210
239
  // and it's exactly zero where μ̄ is level.
240
+ // Beside a diffuse layer, between its plane's: c e^{−z s} at each, the drift over the
241
+ // drop between the planes (s acting as φ̂ does on its side).
211
242
  const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
212
243
  const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
213
- const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
244
+ const sL = sOf(dlL), sR = sOf(dlR), cl = this.c[kl] * (sL === 0 ? 1 : Math.exp(-zi * sL)), cr = this.c[kr] * (sR === 0 ? 1 : Math.exp(-zi * sR));
245
+ const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) + sR - sL) + shift;
214
246
  // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
215
247
  const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
216
- const N1 = gated.length ? sgFlux(link.P, d, -deta, this.c[kl], this.c[kr], this.sg)[0] : 0; // (fully open)
248
+ const N1 = gated.length ? sgFlux(link.P, d, -deta, cl, cr, this.sg)[0] : 0; // (fully open)
217
249
  const P = link.P * (gated.length ? this._open(gated, gx) : 1);
218
- const sg = sgFlux(P, d, -deta, this.c[kl], this.c[kr], this.sg); // (−deta = η_R − η_L)
250
+ const sg = sgFlux(P, d, -deta, cl, cr, this.sg); // (−deta = η_R − η_L)
219
251
  const N = sg[0], dNdd = sg[1];
220
252
  res[R[o]] = u[o] - N;
221
253
  this._j(bf, r, bf, r, 1);
@@ -228,6 +260,8 @@ export class BoundaryTerms {
228
260
  if (zi !== 0) {
229
261
  this._j(bf, r, bL, 0, zi * dNdd + zi * N);
230
262
  this._j(bf, r, bR, 0, -zi * dNdd);
263
+ jS(r, dlL, zi * dNdd + zi * N);
264
+ jS(r, dlR, -zi * dNdd);
231
265
  }
232
266
  } else {
233
267
  // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
@@ -250,6 +284,113 @@ export class BoundaryTerms {
250
284
  }
251
285
  itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
252
286
  if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
287
+ if (dlL >= 0) this._diffuseDraws(dlL, bf);
288
+ if (dlR >= 0) this._diffuseDraws(dlR, bf);
289
+ }
290
+
291
+ // Layer d's drawn species (those its face's reactions take or make at its plane): each one's
292
+ // level there, w = ln(a_plane/a_edge), below the edge node's by the flux N its reactions draw
293
+ // across the layer's resistance K (src/diffuse.js), D c (1 − e^w) = N K: its row, 1 − e^w − N K/(D c).
294
+ // Where it repels the species, K grows as c/c(plane), and the plane can run out of it (at
295
+ // N = D c/K). Where it gathers one, K < 0, bounded by about the layer's width (the bulk the grid
296
+ // counts in its place conducts it less well): a correction of order λ_D/L, and a negative
297
+ // resistance in series, which far from the solution can draw a product's plane level down the
298
+ // more of it is made; its row is bounded there (below).
299
+ _diffuseDraws(d, bf) {
300
+ const { n, M, u, c, res } = this, R = this.rix;
301
+ const { b, g, side, f, dl, drawSlot, Dm } = this.diffList[d], itf = this.model.interfaces[f];
302
+ if (!drawSlot.some((q) => q >= 0)) return;
303
+ const dc = this._diffuseChain(d), K0 = 1 + n;
304
+ for (let i = 0; i < n; i++) {
305
+ const q = drawSlot[i];
306
+ if (q < 0) continue;
307
+ // N, what the reactions take from this side (mol/(m²·s)), from their rates' slots
308
+ let N = 0;
309
+ itf.reactions.forEach((rx, k) => {
310
+ for (const p of rx.part) if (p.side === side && p.i === i) N -= p.nu * u[bf * M + K0 + k];
311
+ });
312
+ // X = N K/(D c); where the layer gathers the species (K < 0) the row is w = −X/√(1 + X²)
313
+ // instead, the same to O(X³) where it's solved (|X| about λ_D/L there) and bounded far off
314
+ const K = dl.K[i], w = u[bf * M + q], cc = Math.max(c[g * n + i], 1e-300), Dc = Dm[i] * cc, X = (N * K) / Dc;
315
+ const gathered = K < 0, root = Math.sqrt(1 + X * X), gX = gathered ? 1 / (root * root * root) : 1;
316
+ res[R[bf * M + q]] = gathered ? -w - X / root : -Math.expm1(w) - X;
317
+ this._j(bf, q, bf, q, gathered ? -1 : -Math.exp(Math.min(700, w)));
318
+ itf.reactions.forEach((rx, k) => {
319
+ let nu = 0;
320
+ for (const p of rx.part) if (p.side === side && p.i === i) nu += p.nu;
321
+ if (nu !== 0) this._j(bf, q, bf, K0 + k, (gX * nu * K) / Dc);
322
+ });
323
+ this._j(bf, q, bf, this.diffList[d].slot, (-gX * N * dl.dKds[i]) / Dc);
324
+ // through the edge node's c: K's ∂/∂c_j, and 1/c_i's
325
+ for (let r = 0; r <= n; r++) {
326
+ let v = (K * dc[i * M + r]) / cc;
327
+ for (let j = 0; j < n; j++) v -= dl.dKdc[i * n + j] * dc[j * M + r];
328
+ if (v !== 0) this._j(bf, q, b, r, (gX * N * v) / Dc);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Diffuse layer d's terms (src/diffuse.js; its q and Γ from computeConcentrations): its row,
334
+ // q(s, c) equal to the charge its side holds (D on the left, −(D + sheetCharge) on the right);
335
+ // its charge in the edge box's Gauss row and its excesses in the box's balances, stored like the
336
+ // box's own ions (so the box's bulk stays neutral, and what the face's charge draws in is
337
+ // counted). The edge node's c enters through its ∂c_j/∂(φ̂, η) (_diffuseChain).
338
+ _diffuseTerms(d, bf, Af) {
339
+ const { n, M, u, res, gam, gamOld } = this, R = this.rix, dt = this.dtNow;
340
+ const { b, slot, o, side, f, dl, holds } = this.diffList[d], dc = this._diffuseChain(d);
341
+ const sheet = this.model.interfaces[f].sheetCharge, w = this.dW ?? (this.dW = new Float64Array(M));
342
+ // ∂(Σ_j a_j c_j)/∂(the edge node's slot r), into w
343
+ const chain = (a) => {
344
+ w.fill(0);
345
+ for (let j = 0; j < n; j++) if (a[j] !== 0) for (let r = 0; r <= n; r++) w[r] += a[j] * dc[j * M + r];
346
+ return w;
347
+ };
348
+ // its row (per area): q − Q
349
+ res[R[o]] = dl.q - (side ? -(u[bf * M] + sheet) : u[bf * M]);
350
+ this._j(bf, slot, bf, slot, dl.dqds);
351
+ this._j(bf, slot, bf, 0, side ? 1 : -1);
352
+ chain(dl.dqdc);
353
+ for (let r = 0; r <= n; r++) {
354
+ if (w[r] === 0) continue;
355
+ this._j(bf, slot, b, r, w[r]);
356
+ this._j(b, 0, b, r, -Af * w[r]); // (and the box's charge: −Af·q in its Gauss row)
357
+ }
358
+ res[R[b * M]] -= Af * dl.q;
359
+ this._j(b, 0, bf, slot, -Af * dl.dqds);
360
+ // the box's store of each species it holds: Af (Γ_i − Γ_i,old)/dt
361
+ if (!Number.isFinite(dt)) return;
362
+ const k = Af / dt;
363
+ for (let i = 0; i < n; i++) {
364
+ if (!this.present[this.diffList[d].g * n + i] || !holds[i]) continue;
365
+ res[R[b * M + 1 + i]] += k * (gam[d * n + i] - gamOld[d * n + i]);
366
+ this._j(b, 1 + i, bf, slot, k * dl.dgds[i]);
367
+ chain(dl.dgdc.subarray(i * n, i * n + n));
368
+ for (let r = 0; r <= n; r++) if (w[r] !== 0) this._j(b, 1 + i, b, r, k * w[r]);
369
+ }
370
+ }
371
+
372
+ // Layer d's edge node's ∂c_j/∂(slot r) for r = φ̂, η_1…η_n, [j·M + r] (src/regions.js, _dc).
373
+ _diffuseChain(d) {
374
+ const { n, M } = this, g = this.diffList[d].g, out = this.diffList[d].chain ?? (this.diffList[d].chain = new Float64Array(n * M));
375
+ const t = this.dV2 ?? (this.dV2 = new Float64Array(M));
376
+ for (let j = 0; j < n; j++) {
377
+ if (!this.present[g * n + j]) {
378
+ out.fill(0, j * M, (j + 1) * M);
379
+ continue;
380
+ }
381
+ this._dc(g, j, t);
382
+ out.set(t, j * M);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ // The diffuse layers' charges and excesses at the present state (after the concentrations).
388
+ _diffuseLayers() {
389
+ const { n, u, c, z, gam } = this;
390
+ this.diffList.forEach((d, k) => {
391
+ diffuseLayer(c.subarray(d.g * n, d.g * n + n), z, n, u[d.o], d.units, d.dl, d.lat, d.draw);
392
+ gam.set(d.dl.gam, k * n);
393
+ });
253
394
  }
254
395
 
255
396
  // Full index of face f's gate q (its fraction open).
@@ -269,10 +410,12 @@ export class BoundaryTerms {
269
410
  return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
270
411
  }
271
412
 
272
- // The voltage across face f, φ_right − φ_left (V).
413
+ // The voltage across face f, φ_right − φ_left (V): between its diffuse layers' planes, where it
414
+ // has them (what the face's own layer, a membrane, has across it).
273
415
  _faceVoltage(f) {
274
- const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
275
- return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
416
+ const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1, [dL, dR] = this.diffAt[f];
417
+ const planes = (dR < 0 ? 0 : u[this.diffList[dR].o]) - (dL < 0 ? 0 : u[this.diffList[dL].o]);
418
+ return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) + planes);
276
419
  }
277
420
 
278
421
  // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
@@ -288,6 +431,10 @@ export class BoundaryTerms {
288
431
  const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
289
432
  this._j(bf, k0 + q, bR, 0, dV);
290
433
  this._j(bf, k0 + q, bL, 0, -dV);
434
+ // (and per unit of each diffuse layer's drop, as of its side's φ̂)
435
+ const [dlL, dlR] = this.diffAt[f];
436
+ if (dlR >= 0) this._j(bf, k0 + q, bf, this.diffList[dlR].slot, dV);
437
+ if (dlL >= 0) this._j(bf, k0 + q, bf, this.diffList[dlL].slot, -dV);
291
438
  });
292
439
  }
293
440
 
@@ -302,30 +449,66 @@ export class BoundaryTerms {
302
449
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
303
450
  const al = rx.alpha;
304
451
  const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
452
+ // Beside a diffuse layer (src/diffuse.js), a participant reacts at the plane, where its
453
+ // concentration is c e^{−z s}/D(s) (its μ̄ level through the layer; D = 1 unless the layer is
454
+ // crowded): dS[side] = ∂ ln(prefactor)/∂s, and a crowded D's ∂/∂c_j into the side's slots.
455
+ const dla = this.diffAt[f], dS = [0, 0];
456
+ const layer = (p) => (dla[p.side] < 0 ? null : this.diffList[dla[p.side]]), atPlane = (p) => layer(p) && (this.z[p.i] !== 0 || layer(p).lat?.on[p.i]);
457
+ // A drawn participant's level at the plane is w below its edge node's (_diffuseDraws): its c
458
+ // there e^w times as much, its η w more; the face-block slot of that w, or −1.
459
+ const wSlot = (p) => (atPlane(p) ? layer(p).drawSlot[p.i] : -1);
460
+ const wOf = (p) => (wSlot(p) < 0 ? 0 : u[bf * M + wSlot(p)]);
461
+ const plane = (p) => (atPlane(p) ? Math.exp(Math.max(-700, Math.min(700, -this.z[p.i] * u[layer(p).o] + wOf(p)))) / layer(p).dl.D : 1);
462
+ const dW = this.dWr ?? (this.dWr = new Float64Array(M)), aW = this.aWr ?? (this.aWr = new Float64Array(M)); // ∂ ln(prefactor), ∂a by each face slot w
463
+ dW.fill(0);
464
+ aW.fill(0);
465
+ // ∂ ln(e^{−z s}/D) by s, and w·(−∂ ln D/∂c_j) through the edge node's slots into d
466
+ const planeSlope = (p) => -this.z[p.i] - layer(p).dl.dDds / layer(p).dl.D;
467
+ const crowdInto = (p, w, d) => {
468
+ const L = layer(p);
469
+ if (!L.lat || w === 0) return;
470
+ const dc = this._diffuseChain(dla[p.side]);
471
+ for (let j = 0; j < n; j++) {
472
+ const a = (-w * L.dl.dDdc[j]) / L.dl.D;
473
+ if (a !== 0) for (let r = 0; r <= n; r++) d[r] += a * dc[j * M + r];
474
+ }
475
+ };
305
476
  let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
306
477
  for (const p of rx.part) {
307
478
  const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
308
- aHi -= p.nu * u[o];
479
+ aHi -= p.nu * (u[o] + wOf(p));
309
480
  aLo -= p.nu * uLo[o];
481
+ if (wSlot(p) >= 0) aW[wSlot(p)] -= p.nu;
310
482
  if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
311
483
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
312
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
484
+ pref *= powr((c[g * n + p.i] * plane(p)) / this.cRef[g * n + p.i], e);
313
485
  this._dlnc(g, p.i, e, p.side ? dR : dL);
486
+ if (atPlane(p)) {
487
+ dS[p.side] += e * planeSlope(p);
488
+ crowdInto(p, e, p.side ? dR : dL);
489
+ if (wSlot(p) >= 0) dW[wSlot(p)] += e;
490
+ }
314
491
  }
315
492
  if (rx.vmax) {
316
493
  // Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
317
494
  let S = 1;
318
495
  rx.part.forEach((p, q) => {
319
496
  if (p.nu >= 0) return;
320
- const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
497
+ const g = p.side ? gR : gL, cc = c[g * n + p.i] * plane(p), K = rx.K[q];
321
498
  S *= powi(cc / (cc + K), -p.nu);
322
499
  this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
500
+ if (atPlane(p)) {
501
+ dS[p.side] += ((-p.nu * K) / (cc + K)) * planeSlope(p);
502
+ crowdInto(p, (-p.nu * K) / (cc + K), p.side ? dR : dL);
503
+ if (wSlot(p) >= 0) dW[wSlot(p)] += (-p.nu * K) / (cc + K);
504
+ }
323
505
  });
324
506
  const a = aHi + aLo, back = Math.exp(-a);
325
507
  rate = rx.vmax * S * -Math.expm1(-a);
326
508
  for (let s = 0; s < M; s++) {
327
509
  dL[s] *= rate;
328
510
  dR[s] *= rate;
511
+ dW[s] = rate * dW[s] + rx.vmax * S * back * aW[s];
329
512
  }
330
513
  for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
331
514
  } else if (rx.srh) {
@@ -343,12 +526,15 @@ export class BoundaryTerms {
343
526
  for (let s = 0; s < M; s++) {
344
527
  dL[s] *= rate;
345
528
  dR[s] *= rate;
529
+ dW[s] = rate * dW[s] + pref * gp * aW[s];
346
530
  }
347
531
  for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
348
532
  }
349
533
  const slot = 1 + n + k, o = bf * M + slot;
350
534
  res[R[o]] = u[o] - rate;
351
535
  this._j(bf, slot, bf, slot, 1);
536
+ for (const sd of [0, 1]) if (dS[sd] !== 0) this._j(bf, slot, bf, this.diffList[dla[sd]].slot, -rate * dS[sd]);
537
+ if (!rx.srh) for (let q = 0; q < M; q++) if (dW[q] !== 0) this._j(bf, slot, bf, q, -dW[q]);
352
538
  for (let s = 0; s < M; s++) {
353
539
  if (dL[s] !== 0) this._j(bf, slot, bL, s, -dL[s]);
354
540
  if (dR[s] !== 0) this._j(bf, slot, bR, s, -dR[s]);
package/src/checks.js CHANGED
@@ -381,7 +381,10 @@ function orderCheck(device, sol, gross) {
381
381
  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' });
382
382
  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 });
383
383
  }
384
- 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);
384
+ // (with what the faces' diffuse layers hold, below the grid)
385
+ const vol = model.grid.vol, area = model.grid.area;
386
+ const held = (s, name) => s.interfaces.reduce((m, itf, f) => m + Object.entries(itf.diffuse ?? {}).reduce((t, [side, d]) => t + area[side === 'left' ? model.grid.regionEnd[f] : model.grid.regionStart[f + 1]] * (d.excess[name] ?? 0), 0), 0);
387
+ const amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0) + held(s, name);
385
388
  const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
386
389
  const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
387
390
  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: unitsOf(model).amount });
package/src/describe.js CHANGED
@@ -213,6 +213,8 @@ export function describe(def) {
213
213
  }
214
214
  const law = itf.phi.type === 'capacitive' ? `capacitive, C = ${num(itf.phi.C)} F/m²` : itf.phi.type;
215
215
  const align = itf.conductor ? (itf.phi.type === 'capacitive' ? `, zeroCharge ${num(itf.zeroCharge)} V` : '') : itf.phi.type === 'neutral' ? '' : `, dipole ${num(itf.dipole)} V`;
216
+ const sides = itf.diffuse ? [itf.diffuse.left && L.name, itf.diffuse.right && R.name].filter(Boolean) : [];
217
+ const diffuse = sides.length ? `; a diffuse layer below the grid (Gouy–Chapman, εr ${num(itf.diffuse.epsr)}) on the ${sides.join(' and ')} side${sides.length > 1 ? 's' : ''}` : '';
216
218
  const blocked = species.filter((sp, i) => itf.links[i].type === 'blocked' && materials[L.material].present[i] && materials[R.material].present[i]).map((sp) => sp.name);
217
219
  const kinetics = (r) => (r.vmax !== undefined ? ` (saturating, vmax ${num(r.vmax)} mol/(m²·s))` : r.srh ? ' (SRH)' : '');
218
220
  const rx = (idef.reactions ?? []).map((r) => (r.equation ?? equation(r.left, r.right)) + kinetics(r));
@@ -223,7 +225,7 @@ export function describe(def) {
223
225
  });
224
226
  const gates = (itf.gates ?? []).map((g) => g.name);
225
227
  lines.push(
226
- ` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}${gates.length ? `; gates (voltage-dependent, see interfaces[f].gates): ${gates.join(', ')}` : ''}`,
228
+ ` ${L.name} | ${R.name}: ${law}${align}${diffuse}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}${gates.length ? `; gates (voltage-dependent, see interfaces[f].gates): ${gates.join(', ')}` : ''}`,
227
229
  );
228
230
  });
229
231
  }
package/src/device.js CHANGED
@@ -994,7 +994,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
994
994
  // macroscopic limit; the alignment then drops out. 'capacitive': a Helmholtz layer,
995
995
  // D = C (Δφ − dipole). Between two ε = 0 (strictly neutral) materials the default is neutral.
996
996
  if (matL.conductor || matR.conductor) return normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT);
997
- fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates']);
997
+ fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates', 'diffuse']);
998
998
  const bothNeutral = (matL.epsr === 0 && matR.epsr === 0) || matL.phiFree || matR.phiFree;
999
999
  const rawPhi = idef.phi ?? (bothNeutral ? 'neutral' : 'pinned');
1000
1000
  const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
@@ -1005,7 +1005,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
1005
1005
  // A pinned face is a capacitor of infinite C. With no field on either side (ε = 0), its
1006
1006
  // charge would sit as free excess ions in the edge cells at no cost, so nothing determines it.
1007
1007
  need(
1008
- !(phi.type === 'pinned' && matL.epsr === 0 && matR.epsr === 0),
1008
+ !(phi.type === 'pinned' && matL.epsr === 0 && matR.epsr === 0 && idef.diffuse === undefined),
1009
1009
  `${where}.phi: 'pinned' between two ε = 0 materials leaves the face's charge undetermined (no field on either side to hold it); ` +
1010
1010
  "use 'neutral' (the default: a free Donnan jump), or { type: 'capacitive', C } for a charged layer such as a lipid bilayer",
1011
1011
  );
@@ -1076,7 +1076,55 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
1076
1076
 
1077
1077
  const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
1078
1078
  checkReactingLinks(reactions, idef, where, matL, matR, species);
1079
- return { phi, dipole, sheetCharge, links, reactions, gates, conductor: null };
1079
+ const diffuse = normalizeDiffuse(idef.diffuse, where, phi, matL, matR, species, reactions);
1080
+ return { phi, dipole, sheetCharge, links, reactions, gates, conductor: null, diffuse };
1081
+ }
1082
+
1083
+ // A face's diffuse layers (src/diffuse.js): { epsr } gives each strictly neutral side of a
1084
+ // charged face a Gouy–Chapman layer below the grid, of that permittivity, holding the side's
1085
+ // share of the face's charge as surface excesses of its ions instead of as excess ions in the
1086
+ // edge cell. Normalised to { epsr, left, right } (the sides that have one), or null.
1087
+ function normalizeDiffuse(raw, where, phi, matL, matR, species, reactions) {
1088
+ if (raw === undefined) return null;
1089
+ const path = `${where}.diffuse`;
1090
+ need(isObject(raw), `${path} must be { epsr } (the diffuse layer's relative permittivity)`);
1091
+ fields(raw, path, ['epsr']);
1092
+ const epsr = positive(raw.epsr, `${path}.epsr`);
1093
+ need(phi.type !== 'neutral', `${path}: a neutral face holds no charge, so it has no diffuse layer; give phi a capacitive (Stern) or pinned law`);
1094
+ const charged = (mat) => species.some((sp, i) => mat.present[i] && sp.z !== 0);
1095
+ const holds = (mat) => !mat.conductor && !mat.phiFree && mat.epsr === 0 && charged(mat);
1096
+ const left = holds(matL), right = holds(matR);
1097
+ need(
1098
+ left || right,
1099
+ `${path}: neither side is a strictly neutral (ε = 0) material with ions in it, so there is no diffuse layer below the grid to model; ` +
1100
+ 'where ε > 0 the grid resolves the double layer itself (refine it below the Debye length)',
1101
+ );
1102
+ const lattice = [null, null];
1103
+ for (const [mat, has, side] of [[matL, left, 0], [matR, right, 1]]) {
1104
+ if (!has) continue;
1105
+ // Its ions dilute (ideal), or all on one lattice (crowding: Bikerman's layer, on the bulk's own sites).
1106
+ const charged = species.flatMap((sp, i) => (mat.present[i] && sp.z !== 0 ? [i] : []));
1107
+ const md = mat.modelOf[charged[0]] >= 0 ? mat.models[mat.modelOf[charged[0]]] : null;
1108
+ for (const i of charged) {
1109
+ need(
1110
+ md ? mat.modelOf[i] >= 0 && mat.models[mat.modelOf[i]] === md && md.type === 'lattice' : mat.modelOf[i] < 0,
1111
+ `${path}: '${species[i].name}' in '${mat.name}' has statistics a diffuse layer can't take: its ions must all be ideal (a dilute layer) ` +
1112
+ 'or all on one lattice model (a crowded one, on the same sites); or resolve the double layer (epsr > 0, hmin below the Debye length)',
1113
+ );
1114
+ }
1115
+ if (md) lattice[side] = { cMax: md.cMax, on: Uint8Array.from(species, (_, i) => (md.idx.includes(i) ? 1 : 0)) };
1116
+ for (let i = 0; i < species.length; i++) {
1117
+ need(
1118
+ !(mat.present[i] && species[i].z !== 0 && !(mat.D[i] > 0)),
1119
+ `${path}: '${species[i].name}' is charged and can't move in '${mat.name}' (D = 0), so it can't gather into a diffuse layer as a Boltzmann layer's ions do; ` +
1120
+ 'make it fixed charge (the region\'s fixedCharge), or resolve the double layer (epsr > 0)',
1121
+ );
1122
+ }
1123
+ for (const rx of reactions) {
1124
+ if (rx.srh && rx.part.some((p) => p.side === side)) need(false, `${path}: SRH kinetics beside a diffuse layer isn't supported (its carriers would be read at the plane); resolve the layer instead`);
1125
+ }
1126
+ }
1127
+ return { epsr, left, right, lattice };
1080
1128
  }
1081
1129
 
1082
1130
  // A face's gates (Hodgkin–Huxley): each a fraction x in [0, 1] with first-order kinetics in the
@@ -1158,7 +1206,7 @@ function normalizeConductor(mat, mname, path, species, speciesIndex) {
1158
1206
  function normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT) {
1159
1207
  if (idef === undefined || idef === null) idef = {};
1160
1208
  need(isObject(idef), `${where} must be an object`);
1161
- fields(idef, where, ['phi', 'zeroCharge', 'dipole', 'step', 'sheetCharge', 'species', 'reactions']); // (the alignment fields only to say why not)
1209
+ fields(idef, where, ['phi', 'zeroCharge', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'diffuse']); // (the alignment fields only to say why not)
1162
1210
  const side = matL.conductor ? (matR.conductor ? 'both' : 'left') : 'right';
1163
1211
  const other = side === 'left' ? matR : matL;
1164
1212
  const metal = side === 'right' ? matR.conductor : matL.conductor;
@@ -1212,7 +1260,8 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
1212
1260
  }
1213
1261
  const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
1214
1262
  checkReactingLinks(reactions, idef, where, matL, matR, species);
1215
- return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
1263
+ const diffuse = normalizeDiffuse(idef.diffuse, where, phi, matL, matR, species, reactions);
1264
+ return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i }, diffuse };
1216
1265
  }
1217
1266
 
1218
1267
  // A species present on both sides crosses freely; one that can't move on either (D = 0, trap