driftlet 0.9.0 → 0.11.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.
@@ -21,12 +21,15 @@ const fail = (message) => {
21
21
  * @param {{ face: number } | { port: string | number, x?: number }} where a face's index, or a port
22
22
  * (name or index) and the position in its window (m, the device's x; default its first node)
23
23
  * @param {number | ArrayLike<number>} V the metal's level(s), V
24
- * @returns {{ V: Float64Array, current: Float64Array, reactions: { equation: string, level: number,
24
+ * @param {{ surface?: 'held' | 'equilibrium' }} [opts] an electrode port's surface coverages: held as
25
+ * in `sol` (the default: this instant's curves), or in equilibrium at each V (the steady-state
26
+ * curve, with a passive film's active–passive peak), each species by the reaction that makes it
27
+ * @returns {{ V: Float64Array, current: Float64Array, bare?: Float64Array, reactions: { equation: string, level: number,
25
28
  * rate: Float64Array, current: Float64Array }[] }} per reaction: its equilibrium level here (where
26
29
  * its rate is zero), its rate (mol/(m²·s) of electrode, forward) and its current (A/m², anodic
27
30
  * positive: into the solution); and their sum
28
31
  */
29
- export function polarization(device, sol, where, V) {
32
+ export function polarization(device, sol, where, V, opts = {}) {
30
33
  const model = device.model, { species, grid, RT } = model, solver = device.solver;
31
34
  const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
32
35
  let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
@@ -58,16 +61,17 @@ export function polarization(device, sol, where, V) {
58
61
  } else fail('polarization: say where, { face: f } or { port: name, x }');
59
62
  if (reactions.length === 0) fail('polarization: no reactions there');
60
63
 
61
- const out = reactions.map((rx, k) => {
64
+ // Each reaction as a = base + s·V − Σ ν_s ln a_s (the surface species' activities apart), with
65
+ // its prefactor apart from them too, so a surface can be held or re-equilibrated at each V.
66
+ const parts = reactions.map((rx, k) => {
62
67
  if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
63
- // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
64
- let a0 = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
68
+ let base = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
69
+ const surf = [];
65
70
  rx.part.forEach((p, x) => {
66
71
  if (p.side === 2) {
67
- // a surface species, at activity θ/θ₀ (μ = μ° + RT ln(θ/θ₀))
68
- const ln = Math.log(surface.theta[p.s] / surface.bare), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
69
- a0 -= (p.nu * (surface.sp[p.s].mu0 / RT + ln));
70
- pref *= Math.exp(e * ln);
72
+ // a surface species: μ = μ° + RT ln a, a = θ/θ₀
73
+ base -= (p.nu * surface.sp[p.s].mu0) / RT;
74
+ surf.push({ s: p.s, nu: p.nu, e: p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha });
71
75
  return;
72
76
  }
73
77
  if (p.side === carrier.side && p.i === carrier.i) {
@@ -75,7 +79,7 @@ export function polarization(device, sol, where, V) {
75
79
  return;
76
80
  }
77
81
  const g = nodeOf(p), name = species[p.i].name;
78
- a0 -= (p.nu * sol.mu[name][g]) / RT;
82
+ base -= (p.nu * sol.mu[name][g]) / RT;
79
83
  q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
80
84
  const c = sol.c[name][g];
81
85
  if (rx.vmax) {
@@ -83,14 +87,45 @@ export function polarization(device, sol, where, V) {
83
87
  } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
84
88
  });
85
89
  if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
86
- if (rx.bare) pref *= surface ? surface.bare : 1; // on bare metal only
87
- const rate = Float64Array.from(Vs, (v) => {
88
- const a = a0 + s * v;
89
- return rx.vmax ? pref * -Math.expm1(-a) : pref * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
90
+ return { rx, k, base, s, q, pref, surf };
91
+ });
92
+ // The surface's activities (ln a) and bare fraction at a level: held as `sol` has them, or in
93
+ // equilibrium there, each species by the first reaction that makes it from the solution alone.
94
+ const held = surface && surface.theta.map((t) => Math.log(t / surface.bare));
95
+ let former = null;
96
+ if (surface && opts.surface === 'equilibrium') {
97
+ former = surface.sp.map((sp, x) => {
98
+ const f = parts.find((pt) => pt.surf.length === 1 && pt.surf[0].s === x);
99
+ if (!f) fail(`polarization: no reaction makes ${sp.name} from the solution alone, so its equilibrium coverage isn't defined`);
100
+ return f;
101
+ });
102
+ } else if (opts.surface !== undefined && opts.surface !== 'held') fail("polarization: surface must be 'held' (the default) or 'equilibrium'");
103
+ const surfaceAt = (v) => {
104
+ if (!surface) return { lnA: [], bare: 1 };
105
+ // base + s v − ν ln a = 0 for each species' former
106
+ const lnA = former ? former.map((f) => (f.base + f.s * v) / f.surf[0].nu) : held;
107
+ if (!former) return { lnA, bare: surface.bare };
108
+ const m = Math.max(0, ...lnA);
109
+ let sum = Math.exp(-m);
110
+ for (const l of lnA) sum += Math.exp(l - m);
111
+ return { lnA, bare: Math.exp(-m) / sum };
112
+ };
113
+ const at = Array.from(Vs, (v) => surfaceAt(v));
114
+ const out = parts.map(({ rx, k, base, s, q, pref, surf }) => {
115
+ const rate = Float64Array.from(Vs, (v, j) => {
116
+ const { lnA, bare } = at[j];
117
+ let a = base + s * v, p = pref * (rx.bare ? bare : 1);
118
+ for (const u of surf) {
119
+ a -= u.nu * lnA[u.s];
120
+ p *= Math.exp(u.e * lnA[u.s]);
121
+ }
122
+ return rx.vmax ? p * -Math.expm1(-a) : p * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
90
123
  });
91
- return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -a0 / s, rate, current: rate.map((r) => q * FARADAY * r) };
124
+ // (its level with the surface as held)
125
+ const aHeld = surf.reduce((t, u) => t - u.nu * (held ? held[u.s] : 0), base);
126
+ return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -aHeld / s, rate, current: rate.map((r) => q * FARADAY * r) };
92
127
  });
93
128
  const current = new Float64Array(Vs.length);
94
129
  for (const r of out) for (let j = 0; j < Vs.length; j++) current[j] += r.current[j];
95
- return { V: Vs, current, reactions: out };
130
+ return { V: Vs, current, ...(surface ? { bare: Float64Array.from(at, (x) => x.bare) } : {}), reactions: out };
96
131
  }
package/src/solution.js CHANGED
@@ -106,17 +106,17 @@ export function makeSolution(solver, result = {}) {
106
106
  flux[species[i].name] = solver.portFlux[k][i];
107
107
  current += F * species[i].z * solver.portFlux[k][i];
108
108
  }
109
- if (port.capacitance) {
110
- // a capacitance's charging current, and its charge (the port's side), like a gate's
111
- const dt = solver.lastDt;
112
- current += Number.isFinite(dt) ? (solver.portQ[k] - solver.portQOld[k]) / dt : 0;
113
- }
114
109
  const out = { name: port.name, V: solver.termV[2 + k], flux, current };
115
- if (port.capacitance) out.charge = solver.portQ[k];
116
110
  if (port.capacitance) {
117
- // its charge per area of electrode at each node, σ = C (V − zeroCharge − φ)
118
- const { C: Cs, zeroCharge } = port.capacitance;
119
- out.sigma = Float64Array.from(port.nodes, (g) => Cs * (solver.termV[2 + k] - zeroCharge - phi[g]));
111
+ // A capacitance's charging current, and its charge (the port's side), like a gate's; and
112
+ // its charge per area of electrode at each node, σ = C (V − zeroCharge − φ) (none at an
113
+ // end whose φ its contact sets: the contact holds that charge).
114
+ const dt = solver.lastDt;
115
+ out.current += Number.isFinite(dt) ? (solver.portQ[k] - solver.portQOld[k]) / dt : 0;
116
+ out.charge = solver.portQ[k];
117
+ const { C: Cs, zeroCharge } = port.capacitance, last = model.grid.nNodes - 1;
118
+ const sets = (side) => ['pinned', 'bulk'].includes(model.contacts[side].phi.type);
119
+ out.sigma = Float64Array.from(port.nodes, (g) => ((g === 0 && sets('left')) || (g === last && sets('right')) ? 0 : Cs * (solver.termV[2 + k] - zeroCharge - phi[g])));
120
120
  }
121
121
  if (port.area !== null) {
122
122
  out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
@@ -181,8 +181,26 @@ export function makeSolution(solver, result = {}) {
181
181
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
182
182
  // gate contacts), check the local Debye length against the adjacent cell.
183
183
  sol.warnings = [...(model.warnings ?? [])];
184
- const cond = solver.conditioning;
185
- if (result.converged === false && cond && cond.digits > 12) {
184
+ // A surface covered almost completely: its coverages' η barely move anything below ~1e-10 bare,
185
+ // and Newton loses them (a transient can stall there).
186
+ sol.ports.forEach((p) => {
187
+ if (!p.bare) return;
188
+ const min = Math.min(...p.bare);
189
+ if (min < 1e-10) {
190
+ sol.warnings.push(
191
+ `ports ${p.name}: its surface is covered to a bare fraction of ${min.toExponential(1)} at x = ${p.x[p.bare.indexOf(min)].toExponential(3)} m. ` +
192
+ 'Below ~1e-10 the coverage barely affects anything and the solver struggles with it; a passive metal blocks to 1e-3–1e-6, so make the film less stable (raise its μ°).',
193
+ );
194
+ }
195
+ });
196
+ const cond = solver.conditioning, un = solver.unreached;
197
+ if (result.converged === false && un) {
198
+ const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
199
+ sol.warnings.push(
200
+ `${un.terminal}: no steady state passes the driven current ${g(un.target)} ${A}; held from ${g(V0)} V to ${g(V1)} V ` +
201
+ `it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.`,
202
+ );
203
+ } else if (result.converged === false && cond && cond.digits > 12) {
186
204
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
187
205
  sol.warnings.push(
188
206
  `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +