driftlet 0.9.0 → 0.10.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/checks.js CHANGED
@@ -24,9 +24,9 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
24
24
  * - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
25
25
  * parts) and the definition's likely unit slips.
26
26
  * - **balance** (a steady state): each species' ledger, what comes in through each terminal and
27
- * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
28
- * species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
29
- * and it says where every carrier went.
27
+ * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) (mol/s if the device
28
+ * isn't planar) and, for a charged species, as a current F·|z|·rate. The terms must sum to
29
+ * zero; under light it's J = F(G − R), and it says where every carrier went.
30
30
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
31
  * what came in.
32
32
  * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
@@ -75,10 +75,13 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
75
75
  return { ok, items, text };
76
76
  }
77
77
 
78
+ // The units of totals through the cross-section: per m² of a planar device, or whole.
79
+ const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²' } : { rate: 'mol/s', current: 'A', charge: 'C' });
80
+
78
81
  // Each species' steady ledger: in through the terminals, made by the reactions. Rates in
79
- // mol/(m²·s), positive into the device or made.
82
+ // mol/(m²·s) (mol/s if not planar), positive into the device or made.
80
83
  function balance(device, sol) {
81
- const { model, def } = device, { species, regions, interfaces } = model;
84
+ const { model, def } = device, { species, regions, interfaces } = model, units = unitsOf(model);
82
85
  const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
83
86
  // Each species' compartments: runs of regions it's present in, joined by faces it crosses
84
87
  // freely (in equilibrium). A face it crosses only by a law (a permeability, a conductance) or
@@ -114,16 +117,18 @@ function balance(device, sol) {
114
117
  });
115
118
  });
116
119
  interfaces.forEach((itf, f) => {
120
+ // A face's flux and rates are per area; everything else here is a total, through A there.
121
+ const Af = model.grid.area[model.grid.regionEnd[f]];
117
122
  // Across the face, where it divides a species' compartments.
118
123
  species.forEach((sp, i) => {
119
124
  const type = itf.links[i].type;
120
125
  if (type === 'equilibrium' || type === 'blocked') return;
121
- const N = sol.interfaces[f].N[sp.name];
126
+ const N = Af * sol.interfaces[f].N[sp.name];
122
127
  add(i, f, `across face ${f} (${type})`, -N);
123
128
  add(i, f + 1, `across face ${f} (${type})`, N);
124
129
  });
125
130
  itf.reactions.forEach((rx, k) => {
126
- const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
131
+ const rate = Af * sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
127
132
  for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
128
133
  });
129
134
  });
@@ -147,14 +152,14 @@ function balance(device, sol) {
147
152
  worst = Math.max(worst, Math.abs(l.residual));
148
153
  // Terms that matter, largest first; a charged species' as a current too.
149
154
  const shown = l.terms.filter((t) => Math.abs(t.rate) > 1e-6 * size(l)).sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate));
150
- const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
155
+ const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} ${units.current})` : '');
151
156
  lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
152
157
  }
153
158
  const ok = worst < 1e-6;
154
159
  const summary =
155
160
  moving.length === 0
156
161
  ? 'nothing flows or reacts (equilibrium)'
157
- : `${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, mol/(m²·s), + in or made`;
162
+ : `${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`;
158
163
  // The largest current any charged species carries anywhere: the scale a net current is read against.
159
164
  const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
160
165
  return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
@@ -223,15 +228,17 @@ function gridCheck(device, sol, tol, gross) {
223
228
  }),
224
229
  };
225
230
  // Started from this solution, interpolated onto the finer grid; failing that, from cold.
226
- let other;
231
+ let other, otherGrid;
227
232
  try {
228
233
  const dev = new Device(fine);
229
234
  dev.solver._warmFrom(device.solver);
230
235
  other = dev.solve();
236
+ otherGrid = dev.model.grid;
231
237
  if (!other.converged) {
232
238
  const cold = new Device(fine);
233
239
  if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
234
240
  other = cold.solve();
241
+ otherGrid = cold.model.grid;
235
242
  }
236
243
  } catch (e) {
237
244
  return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
@@ -242,7 +249,7 @@ function gridCheck(device, sol, tol, gross) {
242
249
  // The current, against the largest current any species carries (so at open circuit, or in
243
250
  // equilibrium, round-off isn't read as a change).
244
251
  const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
245
- if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: 'A/m²' });
252
+ if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
246
253
  // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
247
254
  const VT = model.RT / FARADAY;
248
255
  model.terminals.forEach((t, k) => {
@@ -251,33 +258,30 @@ function gridCheck(device, sol, tol, gross) {
251
258
  diffs.push({ what: `${t.name} voltage`, a, b, rel: Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b), VT), unit: 'V' });
252
259
  }
253
260
  });
254
- // Each region's charge (C/m²), against the largest: summed over the nodes' boxes, as the
255
- // solver's Gauss law counts it.
256
- const charges = (s) => s.regions.map((_, r) => {
261
+ // Each region's charge, against the largest: summed over the nodes' boxes, as the solver's
262
+ // Gauss law counts it.
263
+ const charges = (s, grid) => s.regions.map((_, r) => {
257
264
  let q = 0;
258
- for (let g = 0; g < s.x.length; g++) {
259
- if (s.region[g] !== r) continue;
260
- const left = g > 0 && s.region[g - 1] === r ? s.x[g] - s.x[g - 1] : 0, right = g + 1 < s.x.length && s.region[g + 1] === r ? s.x[g + 1] - s.x[g] : 0;
261
- q += rho(s, g, model, r) * ((left + right) / 2);
262
- }
265
+ for (let g = 0; g < s.x.length; g++) if (s.region[g] === r) q += rho(s, g, model, r) * grid.vol[g];
263
266
  return q;
264
267
  });
265
268
  // Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
266
269
  // the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
267
270
  const content = Math.max(...sol.regions.map((reg, r) => {
268
- let q = 0;
271
+ let q = 0, v = 0;
269
272
  for (let g = 0; g < sol.x.length; g++) {
270
273
  if (sol.region[g] !== r) continue;
271
274
  let a = Math.abs(model.regions[r].fixedCharge ?? 0);
272
275
  for (const sp of model.species) if (Number.isFinite(sol.c[sp.name][g])) a += FARADAY * Math.abs(sp.z * sol.c[sp.name][g]);
273
276
  q = Math.max(q, a);
277
+ v += model.grid.vol[g];
274
278
  }
275
- return q * (reg.x1 - reg.x0);
279
+ return q * v;
276
280
  }));
277
- const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
281
+ const qa = charges(sol, model.grid), qb = charges(other, otherGrid), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
278
282
  if (scaleQ > 1e-6 * content) {
279
283
  const named = (reg) => (/^region \d+$/.test(reg.name) ? `${reg.name} (${reg.material})` : reg.name);
280
- qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit: 'C/m²' }));
284
+ qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit: unitsOf(model).charge }));
281
285
  }
282
286
  const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
283
287
  if (!worst) return { name: 'grid', ok: true, summary: 'nothing to compare (no current, charge or floating voltage)' };
package/src/describe.js CHANGED
@@ -27,10 +27,11 @@ function equation(...maps) {
27
27
  return `${lhs.join(' + ') || '0'} = ${rhs.join(' + ') || '0'}`;
28
28
  }
29
29
 
30
- function drive(c) {
30
+ // (Per m² of a planar device; a whole one's current and resistance otherwise.)
31
+ function drive(c, planar) {
31
32
  const v = (x) => (isObject(x) ? 'a waveform' : `${num(x)}`);
32
- if (c?.I !== undefined) return `driven at I = ${v(c.I)} A/m²`;
33
- if (c?.R !== undefined) return `a source of ${v(c.V ?? 0)} V behind R = ${num(c.R)} Ω·m²`;
33
+ if (c?.I !== undefined) return `driven at I = ${v(c.I)} ${planar ? 'A/m²' : 'A'}`;
34
+ if (c?.R !== undefined) return `a source of ${v(c.V ?? 0)} V behind R = ${num(c.R)} ${planar ? 'Ω·m²' : 'Ω'}`;
34
35
  return `held at ${v(c?.V ?? 0)} V`;
35
36
  }
36
37
 
@@ -86,8 +87,8 @@ export function unitWarnings(def) {
86
87
  }
87
88
  }
88
89
  });
89
- const capacitance = (path, phi) => {
90
- if (isObject(phi) && phi.C > 10) warn(`${path}.phi.C`, `${num(phi.C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
90
+ const capacitance = (path, C) => {
91
+ if (C > 10) warn(path, `${num(C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
91
92
  };
92
93
  const kinetics = (path, reactions) => {
93
94
  (reactions ?? []).forEach((rx, k) => {
@@ -96,13 +97,18 @@ export function unitWarnings(def) {
96
97
  };
97
98
  (def.interfaces ?? []).forEach((f, k) => {
98
99
  if (!isObject(f)) return;
99
- capacitance(`interfaces[${k}]`, f.phi);
100
+ capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
100
101
  kinetics(`interfaces[${k}]`, f.reactions);
101
102
  });
103
+ (def.ports ?? []).forEach((p, k) => {
104
+ if (!isObject(p)) return;
105
+ capacitance(`ports[${k}].capacitance.C`, p.capacitance?.C);
106
+ kinetics(`ports[${k}]`, p.reactions);
107
+ });
102
108
  for (const side of ['left', 'right']) {
103
109
  const c = def.contacts?.[side];
104
110
  if (!isObject(c)) continue;
105
- capacitance(`contacts.${side}`, c.phi);
111
+ capacitance(`contacts.${side}.phi.C`, c.phi?.C);
106
112
  for (const [sname, v] of Object.entries(c.bath?.c ?? {})) conc(`contacts.${side}.bath.c.${sname}`, v);
107
113
  if (typeof c.V === 'number' && Math.abs(c.V) > 50) warn(`contacts.${side}.V`, `${c.V} V is a very large bias; voltages are V`);
108
114
  }
@@ -209,11 +215,11 @@ export function describe(def) {
209
215
  const ct = model.contacts[side];
210
216
  const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
211
217
  const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
212
- lines.push(` ${side}: ${drive(cdef)}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
218
+ lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
213
219
  }
214
220
  (def.ports ?? []).forEach((p, k) => {
215
221
  const port = model.ports[k];
216
- lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
222
+ lines.push(` port ${port.name}: ${drive(p, model.geometry.type === 'planar')}, in ${regions[port.region].name}`);
217
223
  // an electrode spread through the window
218
224
  if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
219
225
  for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
package/src/device.js CHANGED
@@ -312,10 +312,7 @@ export function normalizeDevice(def) {
312
312
  need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
313
313
  ct.passes = passes;
314
314
  }
315
- need(
316
- terminals.some((t) => t.drive.kind === 'V'),
317
- 'every terminal is driven by a current, so the device\'s overall level floats: hold at least one at a voltage V',
318
- );
315
+ checkLevel(terminals.map((t) => t.drive), terminals.map((t) => (t.kind === 'port' ? ports[t.index] : contacts[t.side])));
319
316
 
320
317
  // --- grid
321
318
  if (def.grid !== undefined) {
@@ -568,6 +565,20 @@ function normalizeSource(v, path) {
568
565
  return { t: Float64Array.from(v.t), values: Float64Array.from(v.values), repeat: v.repeat === true };
569
566
  }
570
567
 
568
+ // Something must hold the device's overall level: a terminal held at a voltage, and if any is
569
+ // driven by a current, one held that passes current (one that passes none, a closed end, ties the
570
+ // level of nothing, and the driven terminal's voltage would float with φ).
571
+ function checkLevel(drives, owners) {
572
+ need(drives.some((d) => d.kind === 'V'), "every terminal is driven by a current, so the device's overall level floats: hold at least one at a voltage V");
573
+ const driven = drives.findIndex((d) => d.kind === 'I');
574
+ if (driven < 0) return;
575
+ const name = (k) => (k < 2 ? `contacts.${k === 0 ? 'left' : 'right'}` : `ports[${k - 2}]`);
576
+ need(
577
+ drives.some((d, k) => d.kind === 'V' && owners[k].passes),
578
+ `${name(driven)} is driven by a current, but no terminal held at a voltage passes any, so nothing fixes the level its voltage is read against: hold one that does (the port of an electrode at V: 0, say: at the only electrode of a device, the same as I: 0)`,
579
+ );
580
+ }
581
+
571
582
  /**
572
583
  * The terminals' drives alone (in terminal order: left, right, then the ports), from a definition
573
584
  * whose structure is unchanged: for a fast update of sources.
@@ -582,7 +593,7 @@ export function normalizeDrives(def, model) {
582
593
  need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
583
594
  }
584
595
  model.ports.forEach((port, k) => need(port.passes || drives[2 + k].kind === 'V', `ports[${k}].I: this port passes no current (it exchanges only neutral species), so give it no drive`));
585
- need(drives.some((d) => d.kind === 'V'), "every terminal is driven by a current, so the device's overall level floats: hold at least one at a voltage V");
596
+ checkLevel(drives, [model.contacts.left, model.contacts.right, ...model.ports]);
586
597
  return drives;
587
598
  }
588
599
 
package/src/index.js CHANGED
@@ -107,15 +107,27 @@ export class Device {
107
107
  for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
108
108
  }
109
109
  // What each stretch conserves from here: a stretch that was closed and still is (same
110
- // species, same regions) keeps its amount; any other (newly closed, split, merged, open)
111
- // starts from what the carried state holds.
110
+ // species, same regions, same boxes) keeps its amount; any other (newly closed, split,
111
+ // merged, open, or with its boxes resized by a new grid or geometry) starts from what the
112
+ // carried state holds.
112
113
  const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
113
114
  const before = new Map(old.stretches.map((st, k) => [key(st), k]));
115
+ const va = old.model.grid.vol, vb = model.grid.vol, sameBoxes = va.length === vb.length && va.every((v, g) => v === vb[g]);
114
116
  solver.referenceAmounts = solver.stretches.map((st) => {
115
117
  const k = before.get(key(st));
116
- return k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
118
+ return sameBoxes && k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
117
119
  });
118
120
  solver.boundaryIntake.fill(0);
121
+ // Each surface's reference likewise: kept where its window and the boxes are unchanged.
122
+ const sameSurface = (j) => {
123
+ const [k, q] = solver.surfCols[j], o = old.surfCols.findIndex(([kk, qq]) => old.model.ports[kk].name === model.ports[k].name && qq === q);
124
+ const a = old.model.ports[old.surfCols[o]?.[0]], b = model.ports[k];
125
+ return o >= 0 && a.surface[q].name === b.surface[q].name && a.nodes.length === b.nodes.length && a.nodes.every((g, w) => g === b.nodes[w]) ? o : -1;
126
+ };
127
+ solver.surfaceRef = Float64Array.from(solver.surfCols, (_, j) => {
128
+ const o = sameBoxes ? sameSurface(j) : -1;
129
+ return o >= 0 ? old.surfaceRef[o] : solver.surfaceAmount(j);
130
+ });
119
131
  }
120
132
  return this;
121
133
  }
@@ -190,7 +202,7 @@ export class Device {
190
202
  */
191
203
  _checkpoint() {
192
204
  const solver = this.solver;
193
- return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice() };
205
+ return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice(), surfaceRef: solver.surfaceRef.slice() };
194
206
  }
195
207
 
196
208
  /**
@@ -208,6 +220,7 @@ export class Device {
208
220
  cp.solver._restore(cp.snap);
209
221
  cp.solver.solvedV = cp.solvedV;
210
222
  cp.solver.referenceAmounts = cp.referenceAmounts.slice();
223
+ cp.solver.surfaceRef = cp.surfaceRef.slice();
211
224
  }
212
225
 
213
226
  /**
@@ -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,6 +181,18 @@ 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
+ // 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
+ });
184
196
  const cond = solver.conditioning;
185
197
  if (result.converged === false && cond && cond.digits > 12) {
186
198
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';