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.
@@ -74,6 +74,14 @@ export class BlockTridiagonal {
74
74
  this.minPivotRatio = Infinity;
75
75
  /** The block found exactly singular by the last factor(), or −1. */
76
76
  this.singularBlock = -1;
77
+ /**
78
+ * Static pivoting: an exactly zero pivot (all its candidates cancelled to nothing) is
79
+ * replaced by 1e-15 of its block's largest entry rather than thrown on, for a caller that
80
+ * refines its solves (and counted in `perturbedPivots`).
81
+ */
82
+ this.staticPivots = false;
83
+ /** How many pivots the last factor() replaced. */
84
+ this.perturbedPivots = 0;
77
85
  }
78
86
 
79
87
  /** Zero all of A, B, C (ready for fresh assembly). */
@@ -86,13 +94,14 @@ export class BlockTridiagonal {
86
94
 
87
95
  /**
88
96
  * Factorise the current A, B, C. A, B, C are left untouched.
89
- * Throws if a diagonal block is exactly singular.
97
+ * Throws if a diagonal block is exactly singular (unless `staticPivots`).
90
98
  */
91
99
  factor() {
92
100
  const { n, A, B, C, sizes, offA, offB, offC, offX } = this;
93
101
  const lu = this._lu, piv = this._piv, cp = this._cp, tmp = this._tmp;
94
102
  let minRatio = Infinity;
95
103
  this.singularBlock = -1;
104
+ this.perturbedPivots = 0;
96
105
 
97
106
  for (let i = 0; i < n; i++) {
98
107
  const m = sizes[i], o = offB[i];
@@ -110,7 +119,7 @@ export class BlockTridiagonal {
110
119
  }
111
120
  }
112
121
  }
113
- const ratio = luFactor(lu, o, piv, offX[i], m);
122
+ const ratio = luFactor(lu, o, piv, offX[i], m, this);
114
123
  if (!(ratio > 0)) {
115
124
  this.factored = false;
116
125
  this.singularBlock = i;
@@ -244,7 +253,7 @@ export class BlockTridiagonal {
244
253
 
245
254
  // In-place LU with partial pivoting of the m×m block at a[o…]. Row swaps are recorded in
246
255
  // piv[p…]. Returns min |pivot| / max |entry| (0 if singular).
247
- function luFactor(a, o, piv, p, m) {
256
+ function luFactor(a, o, piv, p, m, perturb = null) {
248
257
  let maxEntry = 0;
249
258
  for (let k = 0; k < m * m; k++) {
250
259
  const v = Math.abs(a[o + k]);
@@ -259,7 +268,11 @@ function luFactor(a, o, piv, p, m) {
259
268
  if (v > bestVal) { best = r; bestVal = v; }
260
269
  }
261
270
  piv[p + k] = best;
262
- if (bestVal === 0) return 0;
271
+ if (bestVal === 0) {
272
+ if (!perturb?.staticPivots) return 0;
273
+ bestVal = a[o + best * m + k] = 1e-15 * maxEntry;
274
+ perturb.perturbedPivots++;
275
+ }
263
276
  if (bestVal < minPivot) minPivot = bestVal;
264
277
  if (best !== k) {
265
278
  for (let c = 0; c < m; c++) {
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
@@ -93,7 +96,9 @@ function balance(device, sol) {
93
96
  home[i][r] = ledgers.length - 1;
94
97
  });
95
98
  });
96
- const add = (i, r, what, rate) => {
99
+ const partners = new Map(); // a reaction term's species: its net rate is round-off against any of theirs
100
+ const add = (i, r, what, rate, species) => {
101
+ if (species) partners.set(what, species);
97
102
  const l = home[i][r];
98
103
  if (!(l >= 0 && rate !== 0 && Number.isFinite(rate))) return;
99
104
  const same = ledgers[l].terms.find((t) => t.what === what); // a bulk reaction, region by region
@@ -109,22 +114,26 @@ function balance(device, sol) {
109
114
  model.reactions.forEach((rx, k) => {
110
115
  const what = label(def.bulkReactions?.[k]);
111
116
  sol.bulkReactions[k].regions.forEach((total, r) => {
112
- for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total);
113
- for (const { i, nu } of rx.products) add(i, r, what, nu * total);
117
+ const all = [...rx.reactants, ...rx.products].map((p) => p.i);
118
+ for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total, all);
119
+ for (const { i, nu } of rx.products) add(i, r, what, nu * total, all);
114
120
  });
115
121
  });
116
122
  interfaces.forEach((itf, f) => {
123
+ // A face's flux and rates are per area; everything else here is a total, through A there.
124
+ const Af = model.grid.area[model.grid.regionEnd[f]];
117
125
  // Across the face, where it divides a species' compartments.
118
126
  species.forEach((sp, i) => {
119
127
  const type = itf.links[i].type;
120
128
  if (type === 'equilibrium' || type === 'blocked') return;
121
- const N = sol.interfaces[f].N[sp.name];
129
+ const N = Af * sol.interfaces[f].N[sp.name];
122
130
  add(i, f, `across face ${f} (${type})`, -N);
123
131
  add(i, f + 1, `across face ${f} (${type})`, N);
124
132
  });
125
133
  itf.reactions.forEach((rx, k) => {
126
- const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
127
- for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
134
+ const rate = Af * sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
135
+ const all = rx.part.map((p) => p.i);
136
+ for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate, all);
128
137
  });
129
138
  });
130
139
  // A species in one compartment is named alone; one in several, by where.
@@ -135,10 +144,12 @@ function balance(device, sol) {
135
144
  l.title = several[l.i] ? `${l.species} in ${l.compartment}` : l.species;
136
145
  }
137
146
 
138
- // A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
147
+ // A species at rest (nothing beyond round-off moving, against what it or its reactions' partners
148
+ // could carry) isn't reported.
139
149
  const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
140
- const natural = naturalScales(device, sol);
141
- const moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
150
+ const natural = naturalScales(device, sol), inSolution = naturalScales(device, sol, false); // (partners' scale: not a metal's conductance)
151
+ const scale = (l) => Math.max(natural[l.i], ...l.terms.flatMap((t) => (partners.get(t.what) ?? []).map((j) => inSolution[j])));
152
+ const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l));
142
153
  let worst = 0;
143
154
  const lines = [];
144
155
  for (const l of moving) {
@@ -147,14 +158,14 @@ function balance(device, sol) {
147
158
  worst = Math.max(worst, Math.abs(l.residual));
148
159
  // Terms that matter, largest first; a charged species' as a current too.
149
160
  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²)` : '');
161
+ const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} ${units.current})` : '');
151
162
  lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
152
163
  }
153
164
  const ok = worst < 1e-6;
154
165
  const summary =
155
166
  moving.length === 0
156
167
  ? '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`;
168
+ : `${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
169
  // The largest current any charged species carries anywhere: the scale a net current is read against.
159
170
  const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
160
171
  return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
@@ -162,8 +173,9 @@ function balance(device, sol) {
162
173
 
163
174
  // What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
164
175
  // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
165
- // flows are round-off of these.
166
- function naturalScales(device, sol) {
176
+ // flows are round-off of these. Without `conductors`, transport through conductor regions isn't
177
+ // counted.
178
+ function naturalScales(device, sol, conductors = true) {
167
179
  const { model, solver } = device, { species, grid } = model;
168
180
  // (totals through the cross-section, as the ledgers are: its largest in each region)
169
181
  const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
@@ -172,7 +184,7 @@ function naturalScales(device, sol) {
172
184
  model.regions.forEach((reg, r) => {
173
185
  const mat = model.materials[reg.material], L = reg.length;
174
186
  if (mat.conductor) {
175
- if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
187
+ if (conductors && mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
176
188
  return;
177
189
  }
178
190
  if (!mat.present[i] || !(mat.D[i] > 0)) return;
@@ -223,15 +235,17 @@ function gridCheck(device, sol, tol, gross) {
223
235
  }),
224
236
  };
225
237
  // Started from this solution, interpolated onto the finer grid; failing that, from cold.
226
- let other;
238
+ let other, otherGrid;
227
239
  try {
228
240
  const dev = new Device(fine);
229
241
  dev.solver._warmFrom(device.solver);
230
242
  other = dev.solve();
243
+ otherGrid = dev.model.grid;
231
244
  if (!other.converged) {
232
245
  const cold = new Device(fine);
233
246
  if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
234
247
  other = cold.solve();
248
+ otherGrid = cold.model.grid;
235
249
  }
236
250
  } catch (e) {
237
251
  return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
@@ -242,7 +256,7 @@ function gridCheck(device, sol, tol, gross) {
242
256
  // The current, against the largest current any species carries (so at open circuit, or in
243
257
  // equilibrium, round-off isn't read as a change).
244
258
  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²' });
259
+ 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
260
  // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
247
261
  const VT = model.RT / FARADAY;
248
262
  model.terminals.forEach((t, k) => {
@@ -251,33 +265,30 @@ function gridCheck(device, sol, tol, gross) {
251
265
  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
266
  }
253
267
  });
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) => {
268
+ // Each region's charge, against the largest: summed over the nodes' boxes, as the solver's
269
+ // Gauss law counts it.
270
+ const charges = (s, grid) => s.regions.map((_, r) => {
257
271
  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
- }
272
+ for (let g = 0; g < s.x.length; g++) if (s.region[g] === r) q += rho(s, g, model, r) * grid.vol[g];
263
273
  return q;
264
274
  });
265
275
  // Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
266
276
  // the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
267
277
  const content = Math.max(...sol.regions.map((reg, r) => {
268
- let q = 0;
278
+ let q = 0, v = 0;
269
279
  for (let g = 0; g < sol.x.length; g++) {
270
280
  if (sol.region[g] !== r) continue;
271
281
  let a = Math.abs(model.regions[r].fixedCharge ?? 0);
272
282
  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
283
  q = Math.max(q, a);
284
+ v += model.grid.vol[g];
274
285
  }
275
- return q * (reg.x1 - reg.x0);
286
+ return q * v;
276
287
  }));
277
- const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
288
+ const qa = charges(sol, model.grid), qb = charges(other, otherGrid), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
278
289
  if (scaleQ > 1e-6 * content) {
279
290
  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²' }));
291
+ 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
292
  }
282
293
  const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
283
294
  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
 
@@ -0,0 +1,142 @@
1
+ // The dilute kernels' Jacobian terms kept in difference form, for an exact J·v.
2
+ //
3
+ // Assembled, a Scharfetter–Gummel flux's dependence on η at its two ends is two entries, G and
4
+ // −G(1 + E), that cancel for a nearly uniform v; where G is huge (an inversion layer's 0.1 nm
5
+ // cells), J·v loses the flux to round-off. Kept as terms, each is evaluated on differences first:
6
+ // δN = a·(δη_L − z δφ̂_L) + b·(δη_R − δη_L) + d·(δφ̂_R − δφ̂_L),
7
+ // with a = −G E (through c_L), b = −G(1 + E) and d = z ∂N/∂Δ (through the drift). A node's
8
+ // storage and space charge depend on δη_i − z_i δφ̂ alone; the displacement on δφ̂_R − δφ̂_L. A
9
+ // uniform shift of a region (z_i s on each η_i, s on φ̂) then gives exactly zero, as it must.
10
+ //
11
+ // Indices are compact (the solver's rix), with the sink (index N) for an unknown that isn't one:
12
+ // vectors passed in are read there as zero.
13
+ export class DifferenceTerms {
14
+ constructor(N, nodes, segments) {
15
+ this.N = N;
16
+ this.dead = new Uint8Array(N + 1); // rows replaced since (a contact's level, a held stretch)
17
+ this.nI = new Int32Array(nodes); // node term: η column (= its balance row), φ̂ column (= Gauss row)
18
+ this.nP = new Int32Array(nodes);
19
+ this.nZ = new Float64Array(nodes);
20
+ this.nMass = new Float64Array(nodes); // storage per unit (δη − z δφ̂), times 1/dt (or iω)
21
+ this.nCharge = new Float64Array(nodes); // into the Gauss row, per unit (δη − z δφ̂)
22
+ this.sI = new Int32Array(4 * segments); // segment term: η_L, η_R, φ̂_L, φ̂_R
23
+ this.sZ = new Float64Array(segments);
24
+ this.sC = new Float64Array(3 * segments); // a, b, d
25
+ this.dI = new Int32Array(2 * segments); // displacement: φ̂_L, φ̂_R
26
+ this.dK = new Float64Array(segments);
27
+ this.combos = []; // [rk, ri, w]: row rk += w × row ri (strictly neutral nodes' charge rows)
28
+ this.t = new Float64Array(N + 1);
29
+ this.x = new Float64Array(N + 1);
30
+ this.reset();
31
+ }
32
+
33
+ // Afresh for an assembly; `rate` multiplies storage in captured rows (1/dt).
34
+ reset(rate = 0) {
35
+ this.rate = rate;
36
+ this.nn = this.ns = this.nd = 0;
37
+ this.dead.fill(0);
38
+ this.combos.length = 0;
39
+ }
40
+
41
+ node(i, p, z, mass, charge) {
42
+ const k = this.nn++;
43
+ this.nI[k] = i;
44
+ this.nP[k] = p;
45
+ this.nZ[k] = z;
46
+ this.nMass[k] = mass;
47
+ this.nCharge[k] = charge;
48
+ }
49
+
50
+ segment(iL, iR, pL, pR, z, a, b, d) {
51
+ const k = this.ns++;
52
+ this.sI[4 * k] = iL;
53
+ this.sI[4 * k + 1] = iR;
54
+ this.sI[4 * k + 2] = pL;
55
+ this.sI[4 * k + 3] = pR;
56
+ this.sZ[k] = z;
57
+ this.sC[3 * k] = a;
58
+ this.sC[3 * k + 1] = b;
59
+ this.sC[3 * k + 2] = d;
60
+ }
61
+
62
+ displacement(pL, pR, k) {
63
+ const q = this.nd++;
64
+ this.dI[2 * q] = pL;
65
+ this.dI[2 * q + 1] = pR;
66
+ this.dK[q] = k;
67
+ }
68
+
69
+ kill(row) {
70
+ this.dead[row] = 1;
71
+ }
72
+
73
+ combine(rk, ri, w) {
74
+ this.combos.push([rk, ri, w]);
75
+ }
76
+
77
+ // into[col] += w × row's coefficients, the steady ones plus `mass` times the storage's (a row
78
+ // captured as plain entries: a contact's current, read before its row is replaced).
79
+ capture(row, w, mass, into) {
80
+ const N = this.N, add = (col, v) => {
81
+ if (col < N && v !== 0) into[col] += w * v;
82
+ };
83
+ for (let k = 0; k < this.nn; k++) {
84
+ const i = this.nI[k], p = this.nP[k], z = this.nZ[k];
85
+ let f = 0;
86
+ if (row === i) f += mass * this.nMass[k];
87
+ if (row === p) f += this.nCharge[k];
88
+ if (f === 0) continue;
89
+ add(i, f);
90
+ add(p, -z * f);
91
+ }
92
+ for (let k = 0; k < this.ns; k++) {
93
+ const iL = this.sI[4 * k], iR = this.sI[4 * k + 1];
94
+ const s = row === iL ? 1 : row === iR ? -1 : 0;
95
+ if (s === 0) continue;
96
+ const z = this.sZ[k], a = this.sC[3 * k], b = this.sC[3 * k + 1], d = this.sC[3 * k + 2];
97
+ add(iL, s * (a - b));
98
+ add(iR, s * b);
99
+ add(this.sI[4 * k + 2], s * (-z * a - d));
100
+ add(this.sI[4 * k + 3], s * d);
101
+ }
102
+ for (let q = 0; q < this.nd; q++) {
103
+ const pL = this.dI[2 * q], pR = this.dI[2 * q + 1], s = row === pL ? 1 : row === pR ? -1 : 0;
104
+ if (s === 0) continue;
105
+ add(pL, s * this.dK[q]);
106
+ add(pR, -s * this.dK[q]);
107
+ }
108
+ }
109
+
110
+ // out += (steady terms) × v + mass × (storage terms) × v, over the live rows. v is read over
111
+ // its first N entries; transformed (the solver's charge rows), it's mapped back by the caller.
112
+ apply(v, out, steady = 1, mass = 0) {
113
+ const { N, t, x } = this;
114
+ for (let j = 0; j < N; j++) x[j] = v[j];
115
+ x[N] = 0;
116
+ t.fill(0);
117
+ const { nI, nP, nZ, nMass, nCharge } = this;
118
+ for (let k = 0; k < this.nn; k++) {
119
+ const i = nI[k], p = nP[k], w = x[i] - nZ[k] * x[p];
120
+ t[i] += mass * nMass[k] * w;
121
+ t[p] += steady * nCharge[k] * w;
122
+ }
123
+ if (steady !== 0) {
124
+ const { sI, sZ, sC, dI, dK } = this;
125
+ for (let k = 0; k < this.ns; k++) {
126
+ const iL = sI[4 * k], iR = sI[4 * k + 1], pL = sI[4 * k + 2], pR = sI[4 * k + 3];
127
+ const dN = steady * (sC[3 * k] * (x[iL] - sZ[k] * x[pL]) + sC[3 * k + 1] * (x[iR] - x[iL]) + sC[3 * k + 2] * (x[pR] - x[pL]));
128
+ t[iL] += dN;
129
+ t[iR] -= dN;
130
+ }
131
+ for (let q = 0; q < this.nd; q++) {
132
+ const pL = dI[2 * q], pR = dI[2 * q + 1], dD = -steady * dK[q] * (x[pR] - x[pL]);
133
+ t[pL] += dD;
134
+ t[pR] -= dD;
135
+ }
136
+ }
137
+ for (let j = 0; j < N; j++) if (this.dead[j]) t[j] = 0;
138
+ for (const [rk, ri, w] of this.combos) t[rk] += w * t[ri];
139
+ for (let j = 0; j < N; j++) out[j] += t[j];
140
+ return out;
141
+ }
142
+ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // driftlet: 1D drift–diffusion–reaction solver.
2
2
 
3
- import { normalizeDevice } from './device.js';
3
+ import { normalizeDevice, DeviceError } from './device.js';
4
4
  import { Solver, SolverError } from './solver.js';
5
5
  import { makeSolution } from './solution.js';
6
6
  import { merge } from './merge.js';
@@ -64,20 +64,32 @@ export class Device {
64
64
 
65
65
  /**
66
66
  * Change part of the definition (deep-merged). A contact given V or I drops the other, so
67
- * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A change to the terminals'
67
+ * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
68
+ * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
68
69
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
69
70
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
70
71
  * @param {object} patch a partial device definition, merged into the current one
71
72
  * @returns {this}
72
73
  */
73
74
  set(patch) {
74
- // A contact given a new kind of drive drops the old one: { I: 0 } replaces a held V.
75
+ // A terminal given a new kind of drive drops the old one: { I: 0 } replaces a held V.
76
+ const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
75
77
  for (const side of ['left', 'right']) {
76
78
  const c = patch?.contacts?.[side];
77
- if (c && typeof c === 'object') {
78
- if ('I' in c && !('V' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, V: undefined } } };
79
- else if ('V' in c && !('I' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, I: undefined } } };
79
+ if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
80
+ }
81
+ // Ports: an array replaces them all; an object patches them by name, { gate: { V: 0.2 } }.
82
+ const ports = patch?.ports;
83
+ if (ports !== undefined && !Array.isArray(ports)) {
84
+ if (ports === null || typeof ports !== 'object') throw new DeviceError('set: ports must be an array (replacing them all) or an object of patches by port name, e.g. { gate: { V: 0.2 } }');
85
+ const old = this.def.ports ?? [];
86
+ for (const name of Object.keys(ports)) {
87
+ if (!old.some((p, k) => (p.name ?? `ports[${k}]`) === name)) throw new DeviceError(`set: ports.${name}: no port named '${name}'${old.length ? ` (the ports: ${old.map((p, k) => p.name ?? `ports[${k}]`).join(', ')})` : ''}`);
80
88
  }
89
+ patch = { ...patch, ports: old.map((p, k) => {
90
+ const q = ports[p.name ?? `ports[${k}]`];
91
+ return q === undefined ? p : merge(p, redrive(q));
92
+ }) };
81
93
  }
82
94
  const def = merge(this.def, patch);
83
95
  // Only the terminals' drives changed: update them in place, keeping the solver and its state
@@ -97,6 +109,10 @@ export class Device {
97
109
  if (old && old.u.length === solver.u.length && old.n === solver.n) {
98
110
  solver.u.set(old.u);
99
111
  solver.uLo.set(old.uLo);
112
+ // A node newly on an electrode's surface (a window moved, a surface added) starts at its
113
+ // starting coverages, not at whatever its slots held.
114
+ const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
115
+ for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
100
116
  solver.computeConcentrations();
101
117
  solver.time = old.time;
102
118
  solver.contactDEnd = old.contactDEnd;
@@ -107,15 +123,27 @@ export class Device {
107
123
  for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
108
124
  }
109
125
  // 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.
126
+ // species, same regions, same boxes) keeps its amount; any other (newly closed, split,
127
+ // merged, open, or with its boxes resized by a new grid or geometry) starts from what the
128
+ // carried state holds.
112
129
  const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
113
130
  const before = new Map(old.stretches.map((st, k) => [key(st), k]));
131
+ const va = old.model.grid.vol, vb = model.grid.vol, sameBoxes = va.length === vb.length && va.every((v, g) => v === vb[g]);
114
132
  solver.referenceAmounts = solver.stretches.map((st) => {
115
133
  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);
134
+ return sameBoxes && k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
117
135
  });
118
136
  solver.boundaryIntake.fill(0);
137
+ // Each surface's reference likewise: kept where its window and the boxes are unchanged.
138
+ const sameSurface = (j) => {
139
+ const [k, q] = solver.surfCols[j], o = old.surfCols.findIndex(([kk, qq]) => old.model.ports[kk].name === model.ports[k].name && qq === q);
140
+ const a = old.model.ports[old.surfCols[o]?.[0]], b = model.ports[k];
141
+ 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;
142
+ };
143
+ solver.surfaceRef = Float64Array.from(solver.surfCols, (_, j) => {
144
+ const o = sameBoxes ? sameSurface(j) : -1;
145
+ return o >= 0 ? old.surfaceRef[o] : solver.surfaceAmount(j);
146
+ });
119
147
  }
120
148
  return this;
121
149
  }
@@ -190,7 +218,7 @@ export class Device {
190
218
  */
191
219
  _checkpoint() {
192
220
  const solver = this.solver;
193
- return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice() };
221
+ return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice(), surfaceRef: solver.surfaceRef.slice() };
194
222
  }
195
223
 
196
224
  /**
@@ -208,6 +236,7 @@ export class Device {
208
236
  cp.solver._restore(cp.snap);
209
237
  cp.solver.solvedV = cp.solvedV;
210
238
  cp.solver.referenceAmounts = cp.referenceAmounts.slice();
239
+ cp.solver.surfaceRef = cp.surfaceRef.slice();
211
240
  }
212
241
 
213
242
  /**