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/README.md +1 -1
- package/docs/device.md +79 -6
- package/docs/internals.md +5 -2
- package/docs/kit.md +7 -3
- package/docs/numerics.md +11 -0
- package/docs/statistics.md +7 -2
- package/docs/templates.md +157 -2
- package/llms.txt +19 -14
- package/package.json +1 -1
- package/src/boundaries.js +200 -14
- package/src/checks.js +13 -2
- package/src/describe.js +3 -1
- package/src/device.js +70 -5
- package/src/diffuse.js +240 -0
- package/src/impedance.js +2 -1
- package/src/kit.js +6 -2
- package/src/lawRows.js +26 -1
- package/src/newton.js +31 -5
- package/src/regions.js +6 -3
- package/src/solution.js +46 -6
- package/src/solver.js +87 -9
- package/src/start.js +37 -8
- package/src/statistics.js +1 -0
- package/src/steady.js +51 -22
- package/src/timeSteps.js +10 -6
- package/src/types.js +16 -2
- package/types/boundaries.d.ts +8 -0
- package/types/diffuse.d.ts +25 -0
- package/types/newton.d.ts +1 -1
- package/types/solver.d.ts +8 -2
- package/types/types.d.ts +36 -1
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 − φ
|
|
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 − φ
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
@@ -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
|
-
?
|
|
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)));
|
|
@@ -373,7 +381,10 @@ function orderCheck(device, sol, gross) {
|
|
|
373
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' });
|
|
374
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 });
|
|
375
383
|
}
|
|
376
|
-
|
|
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);
|
|
377
388
|
const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
|
|
378
389
|
const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
|
|
379
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
|
@@ -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);
|
|
@@ -993,7 +994,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
993
994
|
// macroscopic limit; the alignment then drops out. 'capacitive': a Helmholtz layer,
|
|
994
995
|
// D = C (Δφ − dipole). Between two ε = 0 (strictly neutral) materials the default is neutral.
|
|
995
996
|
if (matL.conductor || matR.conductor) return normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT);
|
|
996
|
-
fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates']);
|
|
997
|
+
fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates', 'diffuse']);
|
|
997
998
|
const bothNeutral = (matL.epsr === 0 && matR.epsr === 0) || matL.phiFree || matR.phiFree;
|
|
998
999
|
const rawPhi = idef.phi ?? (bothNeutral ? 'neutral' : 'pinned');
|
|
999
1000
|
const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
|
|
@@ -1004,7 +1005,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
1004
1005
|
// A pinned face is a capacitor of infinite C. With no field on either side (ε = 0), its
|
|
1005
1006
|
// charge would sit as free excess ions in the edge cells at no cost, so nothing determines it.
|
|
1006
1007
|
need(
|
|
1007
|
-
!(phi.type === 'pinned' && matL.epsr === 0 && matR.epsr === 0),
|
|
1008
|
+
!(phi.type === 'pinned' && matL.epsr === 0 && matR.epsr === 0 && idef.diffuse === undefined),
|
|
1008
1009
|
`${where}.phi: 'pinned' between two ε = 0 materials leaves the face's charge undetermined (no field on either side to hold it); ` +
|
|
1009
1010
|
"use 'neutral' (the default: a free Donnan jump), or { type: 'capacitive', C } for a charged layer such as a lipid bilayer",
|
|
1010
1011
|
);
|
|
@@ -1075,7 +1076,55 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
1075
1076
|
|
|
1076
1077
|
const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
|
|
1077
1078
|
checkReactingLinks(reactions, idef, where, matL, matR, species);
|
|
1078
|
-
|
|
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 };
|
|
1079
1128
|
}
|
|
1080
1129
|
|
|
1081
1130
|
// A face's gates (Hodgkin–Huxley): each a fraction x in [0, 1] with first-order kinetics in the
|
|
@@ -1157,7 +1206,7 @@ function normalizeConductor(mat, mname, path, species, speciesIndex) {
|
|
|
1157
1206
|
function normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT) {
|
|
1158
1207
|
if (idef === undefined || idef === null) idef = {};
|
|
1159
1208
|
need(isObject(idef), `${where} must be an object`);
|
|
1160
|
-
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)
|
|
1161
1210
|
const side = matL.conductor ? (matR.conductor ? 'both' : 'left') : 'right';
|
|
1162
1211
|
const other = side === 'left' ? matR : matL;
|
|
1163
1212
|
const metal = side === 'right' ? matR.conductor : matL.conductor;
|
|
@@ -1211,7 +1260,8 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
|
|
|
1211
1260
|
}
|
|
1212
1261
|
const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
|
|
1213
1262
|
checkReactingLinks(reactions, idef, where, matL, matR, species);
|
|
1214
|
-
|
|
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 };
|
|
1215
1265
|
}
|
|
1216
1266
|
|
|
1217
1267
|
// A species present on both sides crosses freely; one that can't move on either (D = 0, trap
|
|
@@ -1359,6 +1409,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1359
1409
|
need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
1360
1410
|
const i = speciesIndex.get(sname);
|
|
1361
1411
|
need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
|
|
1412
|
+
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
1413
|
cb[i] = positive(v, `${path}.bath.c.${sname}`);
|
|
1363
1414
|
charge += species[i].z * cb[i];
|
|
1364
1415
|
scale += Math.abs(species[i].z) * cb[i];
|
|
@@ -1413,6 +1464,20 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1413
1464
|
links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0),
|
|
1414
1465
|
`${path}.phi: 'bulk' needs at least one connected charged species at this contact`,
|
|
1415
1466
|
);
|
|
1467
|
+
// Neutral at the contact must be possible: an undoped layer of holes alone never is, and
|
|
1468
|
+
// ohmic(V, ['h+']) on one failed with "nothing specific showed why" (round 16's cold agent).
|
|
1469
|
+
if (!mat.conductor && !mat.phiFree && !mat.models.some((md) => md.type === 'insertion')) {
|
|
1470
|
+
const signs = new Set(species.flatMap((sp, i) => (mat.present[i] && sp.z !== 0 ? [Math.sign(sp.z)] : [])));
|
|
1471
|
+
const fixed = Math.sign(region.fixedCharge ?? 0);
|
|
1472
|
+
if (signs.size === 1 && !(fixed !== 0 && !signs.has(fixed))) {
|
|
1473
|
+
const [only] = signs;
|
|
1474
|
+
throw new DeviceError(
|
|
1475
|
+
`${path}.phi: 'bulk' (what ohmic() and a bath use) holds the contact neutral, but '${region.name ?? region.material}' can't be: ` +
|
|
1476
|
+
`its only charged species are ${only > 0 ? 'positive' : 'negative'} and no fixed charge balances them (an undoped layer of one carrier). ` +
|
|
1477
|
+
"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).",
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1416
1481
|
}
|
|
1417
1482
|
|
|
1418
1483
|
if (terminal !== null) {
|