driftlet 0.14.0 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solution.js CHANGED
@@ -152,6 +152,16 @@ export function makeSolution(solver, result = {}) {
152
152
  }
153
153
  return out;
154
154
  });
155
+ // A floating terminal passes what its circuit says, exactly (see passedCurrent).
156
+ if (sol.converged) {
157
+ for (const k of solver.floating) {
158
+ const I = solver.passedCurrent(k);
159
+ if (k === 0) sol.contacts.left.current = I;
160
+ else if (k === 1) sol.contacts.right.current = -I;
161
+ else sol.ports[k - 2].current = I;
162
+ }
163
+ }
164
+ sol.current = sol.contacts.right.current;
155
165
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
156
166
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
157
167
  // device, conduction plus displacement. They sum to zero in steady state.
@@ -228,8 +238,15 @@ export function makeSolution(solver, result = {}) {
228
238
  );
229
239
  }
230
240
  });
231
- const cond = solver.conditioning, un = solver.unreached;
232
- if (result.converged === false && un) {
241
+ const cond = solver.conditioning, un = solver.unreached, ch = solver.charging;
242
+ if (result.converged === false && ch) {
243
+ const A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
244
+ sol.warnings.push(
245
+ `${ch.terminal}: driven at ${ch.current.toPrecision(3)} ${A}, it has no steady state: ` +
246
+ (ch.what.length > 0 ? `what it feeds (${ch.what.join(', ')}) is closed but for it, so it only stores what comes in (a host filling)` : 'it passes current only by charging its capacitance') +
247
+ '. advance() in time instead; at no current, a steady solve keeps the charge it holds.',
248
+ );
249
+ } else if (result.converged === false && un) {
233
250
  const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
234
251
  // The opposite sign within reach: likely a sign slip (a drive is the current into the device).
235
252
  const flipped = Math.min(I0, I1) <= -un.target && -un.target <= Math.max(I0, I1);
@@ -245,13 +262,31 @@ export function makeSolution(solver, result = {}) {
245
262
  );
246
263
  } else if (result.converged === false && cond && cond.digits > 12) {
247
264
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
265
+ // An ion there so scarce beside the others (a minority swept out of a junction by a large
266
+ // bias, excluded at a sharp neutral face) that its level can't be held in double precision.
267
+ let scarce = null;
268
+ for (const g of cond.nodes ?? []) {
269
+ let all = 0;
270
+ for (let i = 0; i < model.species.length; i++) if (model.species[i].z !== 0 && solver.c[g * model.species.length + i] > 0) all += Math.abs(model.species[i].z) * solver.c[g * model.species.length + i];
271
+ for (let i = 0; i < model.species.length; i++) {
272
+ const c = solver.c[g * model.species.length + i], r = c / all;
273
+ if (model.species[i].z !== 0 && c > 0 && r < 1e-10 && !(r >= scarce?.r)) scarce = { r, name: model.species[i].name };
274
+ }
275
+ }
248
276
  sol.warnings.push(
249
- `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +
250
- 'only weakly, e.g. a floating region coupled to the rest through tiny conductances or rates, or a stiff ' +
251
- 'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
277
+ `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): ` +
278
+ (scarce?.r < 1e-24
279
+ ? `there ${scarce.name} is ${scarce.r.toExponential(0)} of the ions around it, too scarce for its level to be held in double precision ` +
280
+ '(a minority swept out of a junction by a large bias, or excluded at a sharp neutral face). Drive less; or, at a face, ' +
281
+ 'resolve its double layer (ε > 0) rather than make it a sharp neutral step.'
282
+ : 'part of the device is held only weakly, e.g. a floating region coupled to the rest through tiny conductances or rates, or a stiff ' +
283
+ 'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).' +
284
+ (scarce
285
+ ? ` Or a species there too scarce to hold: ${scarce.name} is ${scarce.r.toExponential(0)} of the ions around it (a layer depleted past a limiting current, say), which a smaller drive avoids.`
286
+ : '')),
252
287
  );
253
288
  }
254
- if (result.converged === false && !result.stopped && !un && !(cond && cond.digits > 12)) {
289
+ if (result.converged === false && !result.stopped && !un && !ch && !(cond && cond.digits > 12)) {
255
290
  sol.warnings.push(
256
291
  'the solve did not converge, and nothing specific showed why. ' +
257
292
  'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
@@ -271,15 +306,136 @@ export function makeSolution(solver, result = {}) {
271
306
  );
272
307
  }
273
308
  };
309
+ // Beside a strictly neutral (ε = 0) material, a face's charge is held in the edge node's box,
310
+ // which then acts as a diffuse layer as wide as the box: a capacitance F²Σz²c·w/RT in series
311
+ // with the face's (a Helmholtz C, or for a pinned face whatever holds the charge on the other
312
+ // side: a conductor's sheet, or a resolved diffuse layer, ε/λ_D), which shrinks as the grid is
313
+ // refined. Flag it where it lowers that capacitance by over 2%. (Where the box is the smaller
314
+ // part, as an ε = 0 metal's against a semiconductor's depletion, it's a fine stand-in for the
315
+ // metal's own screening.)
316
+ const boxed = (g, h, phi, other, where) => {
317
+ const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
318
+ if (mat.conductor || mat.epsr !== 0 || solver.phiUndefined?.[g]) return;
319
+ const s2 = solver.screening(g), Cbox = (F * F * s2 * h) / (2 * RT);
320
+ if (!(s2 > 0)) return;
321
+ let C = phi.C;
322
+ if (phi.type === 'pinned') {
323
+ const om = other < 0 ? null : model.materials[model.regions[grid.nodeRegion[other]].material];
324
+ C = !om || om.conductor ? Infinity : (om.epsr * EPS0) / debye(other);
325
+ }
326
+ if (!(C / (C + Cbox) > 0.02)) return;
327
+ const lowers = Number.isFinite(C) ? `lowering the double-layer capacitance by ${((100 * C) / (C + Cbox)).toFixed(0)}%` : 'which alone sets its charge';
328
+ sol.warnings.push(
329
+ `${where}: the face's charge sits in the strictly neutral cell beside it (${h.toExponential(2)} m), which acts as a diffuse layer that wide ` +
330
+ `(${Cbox.toPrecision(2)} F/m² in series), ${lowers}, so it depends on the mesh. ` +
331
+ (Number.isFinite(C)
332
+ ? `Coarsen the grid there: a cell of ${((2 * 49 * C * RT) / (F * F * s2)).toExponential(1)} m or more keeps it within 2%.`
333
+ : "Give the face a Helmholtz capacitance (phi: { type: 'capacitive', C }), or make it neutral."),
334
+ );
335
+ };
274
336
  interfaces.forEach((itf, f) => {
275
337
  if (itf.phi.type === 'neutral') return;
276
338
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
277
339
  check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
278
340
  check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
341
+ boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`);
342
+ boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`);
279
343
  });
280
344
  const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
281
- if (resolves(contacts.left)) check(0, grid.segLength[0], 'contacts.left');
282
- if (resolves(contacts.right)) check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
345
+ if (resolves(contacts.left)) {
346
+ check(0, grid.segLength[0], 'contacts.left');
347
+ boxed(0, grid.segLength[0], contacts.left.phi, -1, 'contacts.left');
348
+ }
349
+ if (resolves(contacts.right)) {
350
+ check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
351
+ boxed(nNodes - 1, grid.segLength[nNodes - 2], contacts.right.phi, -1, 'contacts.right');
352
+ }
353
+ // Electrons or holes above their band's effective density of states (the material's cRef)
354
+ // under Boltzmann statistics: degenerate, where those statistics no longer hold (a surface
355
+ // driven past what a reaction can take away piles holes up to 1e9 mol/m³ and still converges).
356
+ model.regions.forEach((reg, r) => {
357
+ const mat = model.materials[reg.material];
358
+ if (mat.conductor) return;
359
+ model.species.forEach((sp, i) => {
360
+ if ((sp.name !== 'e-' && sp.name !== 'h+') || !mat.present[i] || mat.modelOf?.[i] >= 0) return;
361
+ let top = 0, at = NaN;
362
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
363
+ const c = solver.c[g * model.species.length + i];
364
+ if (c > top) [top, at] = [c, grid.x[g]];
365
+ }
366
+ if (top > mat.cRef[i]) {
367
+ sol.warnings.push(
368
+ `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, above its cRef (${mat.cRef[i].toExponential(2)}, the band's effective density of states), where Boltzmann statistics no longer hold: ` +
369
+ 'give the material Fermi–Dirac statistics (docs/statistics.md), or ask whether this state is physical (a surface driven past what can carry its carriers away).',
370
+ );
371
+ }
372
+ });
373
+ });
374
+ // A reaction a steady state needs at equilibrium, left out of it (see stuckReactions).
375
+ if (sol.steady) {
376
+ for (const { where, affinity } of solver.stuckReactions()) {
377
+ sol.warnings.push(
378
+ `${where}: out of equilibrium by ${(affinity * solver.VT * 1e3).toPrecision(3)} mV of affinity wherever it acts, yet nothing else makes or uses what it changes, so a steady state needs it at equilibrium. ` +
379
+ 'Its rate is too small for the solve to resolve, and it was left where the solve began: this is not a steady state. Raise its rate constant (the steady state does not depend on it), or advance() in time to see how slowly it gets there.',
380
+ );
381
+ }
382
+ }
383
+ // Any other species denser than any material can be (1e6 mol/m³: water is 5.5e4, a metal's
384
+ // atoms ~1.5e5): a level set where the device can't follow it, such as a contact's for one ion
385
+ // far from its neighbours' (Na⁺ held at 1.2e37 mol/m³, whose currents then read 1e28 A/m²).
386
+ model.regions.forEach((reg, r) => {
387
+ const mat = model.materials[reg.material];
388
+ if (mat.conductor) return;
389
+ model.species.forEach((sp, i) => {
390
+ if (sp.name === 'e-' || sp.name === 'h+' || !mat.present[i]) return;
391
+ let top = 0, at = NaN;
392
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
393
+ const c = solver.c[g * model.species.length + i];
394
+ if (c > top) [top, at] = [c, grid.x[g]];
395
+ }
396
+ if (top > 1e6) {
397
+ sol.warnings.push(
398
+ `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, denser than any material: ` +
399
+ 'a level set where the device cannot follow it. Check what holds it there (the V or bath of a contact, its mu0 and cRef) with describe() from driftlet/kit.',
400
+ );
401
+ }
402
+ });
403
+ });
404
+ // A contact that holds its end neutral ('bulk': a bath, an ohmic contact) cuts off any space
405
+ // charge reaching it: a double layer longer than its region. The charge it cuts off is about
406
+ // ρ λ_D at the node beside it; against the region's own space charge (half Σ|ρ| over it, the
407
+ // two signs of a double layer), warn past 1e-3.
408
+ for (const side of ['left', 'right']) {
409
+ if (contacts[side].phi.type !== 'bulk') continue;
410
+ const r = side === 'left' ? 0 : model.regions.length - 1, a = grid.regionStart[r], b = grid.regionEnd[r];
411
+ const mat = model.materials[model.regions[r].material];
412
+ if (!(mat.epsr > 0) || mat.conductor || b - a < 3) continue;
413
+ const n = model.species.length, fixed = model.regions[r].fixedCharge ?? 0;
414
+ const rho = (g) => {
415
+ let q = fixed;
416
+ for (let i = 0; i < n; i++) if (solver.c[g * n + i] > 0) q += F * model.species[i].z * solver.c[g * n + i];
417
+ return q;
418
+ };
419
+ // (and beside the contact the material is itself out of neutrality, by more than round-off:
420
+ // a quasi-neutral layer split off a junction's holds almost no space charge, so the ratio
421
+ // below alone would read its round-off as a cut-off layer)
422
+ const gross = (g) => {
423
+ let q = Math.abs(fixed);
424
+ for (let i = 0; i < n; i++) if (solver.c[g * n + i] > 0) q += F * Math.abs(model.species[i].z) * solver.c[g * n + i];
425
+ return q;
426
+ };
427
+ let total = 0;
428
+ for (let g = a; g <= b; g++) total += (Math.abs(rho(g)) * grid.vol[g]) / 2;
429
+ const g1 = side === 'left' ? a + 1 : b - 1, lam = debye(g1), w = (grid.x[g1 + 1] - grid.x[g1 - 1]) / 2;
430
+ const cut = (Math.abs(rho(g1)) * lam * grid.vol[g1]) / w;
431
+ if (Number.isFinite(cut) && total > 0 && cut > 1e-3 * total && Math.abs(rho(g1)) > 1e-3 * gross(g1)) {
432
+ sol.warnings.push(
433
+ `contacts.${side}: the space charge in ${model.regions[r].name} reaches the contact, which holds its end neutral and so cuts it off (a double layer or depletion longer than the region): ` +
434
+ `the charge beside the contact is ${(cut / total).toPrecision(2)} of the space charge in the region, and the error in that charge can be several times more. ` +
435
+ 'Lengthen the region (a crowded double layer grows as the root of the voltage).',
436
+ );
437
+ }
438
+ }
283
439
 
284
440
  // Steep profiles carrying current: Scharfetter–Gummel takes the field as uniform across each
285
441
  // cell, which fails where it isn't, as at an electrode where a species is nearly depleted (there