driftlet 0.8.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,14 +215,16 @@ 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
- for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}`);
225
+ for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
226
+ if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
227
+ if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
220
228
  });
221
229
  if ((def.bulkReactions ?? []).length) {
222
230
  lines.push('bulk reactions:');
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) {
@@ -351,6 +348,15 @@ export function normalizeDevice(def) {
351
348
  need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
352
349
  port.nodes = Int32Array.from(nodes);
353
350
  });
351
+ // A node's surface belongs to one electrode.
352
+ const surfaced = new Int32Array(grid.nNodes).fill(-1);
353
+ ports.forEach((port, k) => {
354
+ if (port.surface.length === 0) return;
355
+ for (const g of port.nodes) {
356
+ need(surfaced[g] < 0, `ports[${k}]: its window overlaps ports[${surfaced[g]}]'s, and both have a surface; a spot of metal has one surface`);
357
+ surfaced[g] = k;
358
+ }
359
+ });
354
360
 
355
361
  return {
356
362
  T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
@@ -559,6 +565,20 @@ function normalizeSource(v, path) {
559
565
  return { t: Float64Array.from(v.t), values: Float64Array.from(v.values), repeat: v.repeat === true };
560
566
  }
561
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
+
562
582
  /**
563
583
  * The terminals' drives alone (in terminal order: left, right, then the ports), from a definition
564
584
  * whose structure is unchanged: for a fast update of sources.
@@ -573,7 +593,7 @@ export function normalizeDrives(def, model) {
573
593
  need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
574
594
  }
575
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`));
576
- 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]);
577
597
  return drives;
578
598
  }
579
599
 
@@ -649,7 +669,7 @@ function normalizeGeometry(g) {
649
669
  // the window, `area` (m²/m³) of it per volume.
650
670
  function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
651
671
  need(isObject(pdef), `${path} must be an object`);
652
- fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area']);
672
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance']);
653
673
  let r;
654
674
  if (Number.isInteger(pdef.region)) r = pdef.region;
655
675
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -666,8 +686,11 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
666
686
  need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
667
687
  terminal = speciesIndex.get(pdef.terminal);
668
688
  }
669
- const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT);
670
- need(pdef.species === undefined ? reactions.length > 0 : isObject(pdef.species), `${path}.species must map species names to port links`);
689
+ const surface = portSurface(pdef, path, speciesIndex);
690
+ const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
691
+ surface.forEach((sp, s) => need(reactions.some((rx) => rx.part.some((p) => p.side === 2 && p.s === s)), `${path}.surface.${sp.name}: no reaction of the port makes or uses it`));
692
+ const capacitance = portCapacitance(pdef, path, mat);
693
+ need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
671
694
  const links = species.map(() => ({ type: 'blocked' }));
672
695
  for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
673
696
  const lpath = `${path}.species.${sname}`;
@@ -697,48 +720,94 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
697
720
  if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
698
721
  links[i] = { type: link.type, ...level };
699
722
  }
700
- need(links.some((l) => l.type !== 'blocked') || reactions.length > 0, `${path}.species: the port exchanges no species`);
723
+ need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
701
724
  // One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
702
725
  // nobody's business: it can't be driven by one.
703
- const passes = reactions.length > 0 || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
726
+ const passes = reactions.length > 0 || capacitance !== null || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
704
727
  need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
705
728
  let area = null;
706
- if (reactions.length > 0) {
729
+ if (reactions.length > 0 || capacitance !== null) {
707
730
  need(pdef.area !== undefined, `${path}.area: give the electrode's area per volume of the window (m²/m³, e.g. 1/h for a film of thickness h on it), a number or a profile`);
708
731
  area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
709
- } else need(pdef.area === undefined, `${path}.area: only a port with reactions has an electrode area`);
710
- return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area };
732
+ } else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
733
+ need(surface.length === 0 || reactions.length > 0, `${path}.surface: only an electrode (a port with reactions) has a surface`);
734
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance };
735
+ }
736
+
737
+ // A capacitance spread through the window: a gate along a channel, an electrode's double layer.
738
+ // Per area of electrode it holds σ = C (V − zeroCharge − φ) on the port's side, and the window
739
+ // the opposite: the region's charge balance (Gauss's law, or neutrality at ε = 0) gains aσ.
740
+ function portCapacitance(pdef, path, mat) {
741
+ if (pdef.capacitance === undefined) return null;
742
+ const cpath = `${path}.capacitance`;
743
+ need(isObject(pdef.capacitance), `${cpath} must be { C, zeroCharge }`);
744
+ fields(pdef.capacitance, cpath, ['C', 'zeroCharge']);
745
+ need(!mat.conductor && !mat.phiFree, `${cpath}: a capacitance couples to φ, which a ${mat.conductor ? 'conductor' : 'region with no free charge'} doesn't have`);
746
+ return { C: positive(pdef.capacitance.C, `${cpath}.C (F/m² of electrode)`), zeroCharge: pdef.capacitance.zeroCharge === undefined ? 0 : finite(pdef.capacitance.zeroCharge, `${cpath}.zeroCharge (V)`) };
747
+ }
748
+
749
+ // An electrode port's surface species: coverages θ of its sites (Langmuir: each takes one site,
750
+ // μ = μ° + RT ln(θ/θ₀) with θ₀ = 1 − Σθ the bare fraction), Γ mol of sites per m² of electrode.
751
+ function portSurface(pdef, path, speciesIndex) {
752
+ if (pdef.surface === undefined) return [];
753
+ need(isObject(pdef.surface), `${path}.surface must map surface species to { mu0, capacity, theta0 }`);
754
+ const list = Object.entries(pdef.surface).map(([name, d]) => {
755
+ const spath = `${path}.surface.${name}`;
756
+ need(!speciesIndex.has(name), `${spath}: '${name}' is a species of the device; a surface species is the port's own, with its own name`);
757
+ need(isObject(d), `${spath} must be { mu0, capacity, theta0 }`);
758
+ fields(d, spath, ['mu0', 'capacity', 'theta0']);
759
+ const theta0 = d.theta0 === undefined ? 1e-6 : positive(d.theta0, `${spath}.theta0`);
760
+ return { name, mu0: finite(d.mu0, `${spath}.mu0 (J/mol)`), capacity: positive(d.capacity, `${spath}.capacity (mol of sites per m² of electrode)`), theta0 };
761
+ });
762
+ need(list.length > 0, `${path}.surface: give at least one surface species, or leave it out`);
763
+ const caps = new Set(list.map((sp) => sp.capacity));
764
+ need(caps.size === 1, `${path}.surface: the species share the electrode's sites, so give them one capacity`);
765
+ need(list.reduce((t, sp) => t + sp.theta0, 0) < 1, `${path}.surface: the starting coverages (theta0) must sum to less than 1`);
766
+ return list;
711
767
  }
712
768
 
713
769
  // A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
714
770
  // port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
715
771
  // electrode: part side 0 for the region's species, side 1 for the carrier.
716
- function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT) {
772
+ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface = []) {
717
773
  need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
718
774
  const list = pdef.reactions ?? [];
719
775
  if (list.length === 0) return [];
720
776
  need(!mat.conductor, `${path}.reactions: a port on a conductor is a wire; reactions belong on a face, or on a port in the solution`);
777
+ // The metal's countercharge (each spot's double layer) is below the grid, so the solution
778
+ // beside it must be neutral, as in porous-electrode theory; with ε > 0 the reactions would
779
+ // leave a net charge with nothing to balance it.
780
+ need(mat.epsr === 0 || mat.phiFree, `${path}.reactions: an electrode spread through a window needs its region strictly neutral (ε = 0): its double layers are below the grid`);
721
781
  need(terminal !== null && species[terminal].z !== 0, `${path}.terminal: a port with reactions needs its electrode's carrier as its terminal species (e.g. 'e-')`);
722
782
  need(!mat.present[terminal], `${path}.terminal: '${species[terminal].name}' is in the port's region too, so a reaction can't tell the electrode's from the region's`);
723
783
  const carrier = species[terminal].name;
724
784
  return list.map((rdef, k) => {
725
785
  const rpath = `${path}.reactions[${k}]`;
726
786
  need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
727
- fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha']);
787
+ fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha', 'bare']);
728
788
  const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
729
- const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier, conductor: true }, rdef.fixed, `${rpath}.equation`);
789
+ const onSurface = (name) => surface.findIndex((sp) => sp.name === name);
790
+ const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier || onSurface(name) >= 0, conductor: true }, rdef.fixed, `${rpath}.equation`);
730
791
  const part = [];
731
792
  let fixedA = 0, charge = 0;
732
- for (const [key, side] of [['left', 0], ['right', 1]]) {
733
- const st = stoichiometry(sides[key], `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
793
+ // The electrode's side: its carrier, and its surface species (side 2, neutral, by index s).
794
+ const electrode = {};
795
+ for (const [name, nu] of Object.entries(sides.right)) {
796
+ const s = onSurface(name);
797
+ if (s >= 0) part.push({ s, nu, side: 2 });
798
+ else electrode[name] = nu;
799
+ }
800
+ for (const [map, side] of [[sides.left, 0], [electrode, 1]]) {
801
+ const st = stoichiometry(map, `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
734
802
  for (const { i, nu } of st.list) part.push({ i, nu, side });
735
803
  fixedA += st.fixedA;
736
804
  charge += st.charge;
737
805
  }
738
806
  need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
739
- need(part.some((p) => p.side === 0), `${rpath}: no species of the port's region takes part`);
807
+ need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
740
808
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
741
- return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
809
+ need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
810
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
742
811
  });
743
812
  }
744
813
 
@@ -762,7 +831,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
762
831
  const anchored = new Set();
763
832
  if (anchors(contacts.left)) anchored.add(find(0));
764
833
  if (anchors(contacts.right)) anchored.add(find(nR - 1));
765
- for (const port of ports) if (port.reactions.length > 0 || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
834
+ for (const port of ports) if (port.reactions.length > 0 || port.capacitance || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
766
835
  for (let r = 0; r < nR; r++) {
767
836
  const mat = materials[regions[r].material];
768
837
  const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
package/src/index.js CHANGED
@@ -100,21 +100,34 @@ export class Device {
100
100
  solver.computeConcentrations();
101
101
  solver.time = old.time;
102
102
  solver.contactDEnd = old.contactDEnd;
103
+ if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
103
104
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
104
105
  // Floating terminals keep their voltages (a good start), where the terminals match.
105
106
  if (old.terms.length === solver.terms.length) {
106
107
  for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
107
108
  }
108
109
  // What each stretch conserves from here: a stretch that was closed and still is (same
109
- // species, same regions) keeps its amount; any other (newly closed, split, merged, open)
110
- // 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.
111
113
  const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
112
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]);
113
116
  solver.referenceAmounts = solver.stretches.map((st) => {
114
117
  const k = before.get(key(st));
115
- 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);
116
119
  });
117
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
+ });
118
131
  }
119
132
  return this;
120
133
  }
@@ -189,7 +202,7 @@ export class Device {
189
202
  */
190
203
  _checkpoint() {
191
204
  const solver = this.solver;
192
- 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() };
193
206
  }
194
207
 
195
208
  /**
@@ -207,6 +220,7 @@ export class Device {
207
220
  cp.solver._restore(cp.snap);
208
221
  cp.solver.solvedV = cp.solvedV;
209
222
  cp.solver.referenceAmounts = cp.referenceAmounts.slice();
223
+ cp.solver.surfaceRef = cp.surfaceRef.slice();
210
224
  }
211
225
 
212
226
  /**
@@ -21,15 +21,18 @@ 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
- let reactions, nodeOf, carrier, names;
35
+ let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
33
36
  if (where?.face !== undefined) {
34
37
  const f = where.face, itf = model.interfaces[f];
35
38
  if (!itf) fail(`polarization: no face ${f} (the device has ${model.interfaces.length})`);
@@ -49,22 +52,34 @@ export function polarization(device, sol, where, V) {
49
52
  if (where.x !== undefined) for (const h of port.nodes) if (Math.abs(grid.x[h] - where.x) < Math.abs(grid.x[g] - where.x)) g = h;
50
53
  carrier = { side: 1, i: port.terminal };
51
54
  nodeOf = () => g;
55
+ if (port.surface.length > 0) {
56
+ const w = port.nodes.indexOf(g), theta = port.surface.map((sp) => sol.ports[k].coverage[sp.name][w]);
57
+ surface = { sp: port.surface, theta, bare: sol.ports[k].bare[w] };
58
+ }
52
59
  reactions = port.reactions;
53
60
  names = device.def.ports?.[k]?.reactions;
54
61
  } else fail('polarization: say where, { face: f } or { port: name, x }');
55
62
  if (reactions.length === 0) fail('polarization: no reactions there');
56
63
 
57
- 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) => {
58
67
  if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
59
- // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
60
- 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 = [];
61
70
  rx.part.forEach((p, x) => {
71
+ if (p.side === 2) {
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 });
75
+ return;
76
+ }
62
77
  if (p.side === carrier.side && p.i === carrier.i) {
63
78
  s -= (p.nu * species[p.i].z * FARADAY) / RT;
64
79
  return;
65
80
  }
66
81
  const g = nodeOf(p), name = species[p.i].name;
67
- a0 -= (p.nu * sol.mu[name][g]) / RT;
82
+ base -= (p.nu * sol.mu[name][g]) / RT;
68
83
  q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
69
84
  const c = sol.c[name][g];
70
85
  if (rx.vmax) {
@@ -72,13 +87,45 @@ export function polarization(device, sol, where, V) {
72
87
  } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
73
88
  });
74
89
  if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
75
- const rate = Float64Array.from(Vs, (v) => {
76
- const a = a0 + s * v;
77
- 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));
78
123
  });
79
- 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) };
80
127
  });
81
128
  const current = new Float64Array(Vs.length);
82
129
  for (const r of out) for (let j = 0; j < Vs.length; j++) current[j] += r.current[j];
83
- return { V: Vs, current, reactions: out };
130
+ return { V: Vs, current, ...(surface ? { bare: Float64Array.from(at, (x) => x.bare) } : {}), reactions: out };
84
131
  }
package/src/solution.js CHANGED
@@ -107,11 +107,29 @@ export function makeSolution(solver, result = {}) {
107
107
  current += F * species[i].z * solver.portFlux[k][i];
108
108
  }
109
109
  const out = { name: port.name, V: solver.termV[2 + k], flux, current };
110
- if (port.reactions.length > 0) {
111
- // An electrode spread through the window: where it sits, and each reaction's rate there.
110
+ if (port.capacitance) {
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
+ }
121
+ if (port.area !== null) {
112
122
  out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
113
123
  out.area = Float64Array.from(solver.portArea[k]);
124
+ }
125
+ if (port.reactions.length > 0) {
126
+ // An electrode spread through the window: each reaction's rate at its nodes.
114
127
  out.rates = solver.portRates[k].map((r) => Float64Array.from(r)); // mol/(m²·s), forward
128
+ // its surface: each species' coverage θ at each node of the window
129
+ if (port.surface.length > 0) {
130
+ out.coverage = Object.fromEntries(port.surface.map((sp, q) => [sp.name, Float64Array.from(port.nodes, (g) => solver.th[g * solver.nSurf + q])]));
131
+ out.bare = Float64Array.from(port.nodes, (g) => solver.th0[g]); // θ₀, to full precision however covered
132
+ }
115
133
  }
116
134
  return out;
117
135
  });
@@ -163,6 +181,18 @@ export function makeSolution(solver, result = {}) {
163
181
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
164
182
  // gate contacts), check the local Debye length against the adjacent cell.
165
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
+ });
166
196
  const cond = solver.conditioning;
167
197
  if (result.converged === false && cond && cond.digits > 12) {
168
198
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';