driftlet 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
package/src/device.js ADDED
@@ -0,0 +1,954 @@
1
+ // Device definition: validation and normalisation.
2
+ //
3
+ // A definition is a plain, serialisable object (so it can be posted to a Worker). This module
4
+ // checks it thoroughly and turns it into an internal model with index-based lookups. Every
5
+ // problem is a DeviceError whose message names the offending path. There are no silent
6
+ // defaults for physically meaningful choices: interface alignments, reference concentrations
7
+ // and standard potentials must all be given.
8
+
9
+ import { buildGrid } from './grid.js';
10
+ import { FARADAY, GAS_CONSTANT } from './constants.js';
11
+ import { normalizeStatistics } from './statistics.js';
12
+ import { DeviceError } from './errors.js';
13
+ import { stoichiometry as equationStoichiometry, faceSides, parseEquation } from './equation.js';
14
+
15
+ export { DeviceError };
16
+
17
+ // Contacts use the same laws as internal faces: the outside is a phase with known levels.
18
+ const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange']);
19
+ const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance']);
20
+ const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
21
+ const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
22
+ // The fields of each kind of species link (to an outside phase, or across a face).
23
+ const LINK_FIELDS = {
24
+ blocked: ['type'],
25
+ equilibrium: ['type', 'offset', 'mu'],
26
+ conductance: ['type', 'G', 'offset'],
27
+ exchange: ['type', 'k', 'mu'],
28
+ };
29
+
30
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
31
+ const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
32
+
33
+ // For an unknown-name error: the names there are.
34
+ const known = (index) => ` (the species are ${[...index.keys()].map((k) => `'${k}'`).join(', ')})`;
35
+
36
+ function need(cond, message) {
37
+ if (!cond) throw new DeviceError(message);
38
+ }
39
+
40
+ // Every key of a spec object must be one it takes: a misspelt or misplaced field would otherwise
41
+ // be ignored silently.
42
+ function fields(obj, path, allowed) {
43
+ for (const [k, v] of Object.entries(obj)) {
44
+ need(v === undefined || allowed.includes(k), `${path}.${k}: not a field here (${allowed.join(', ')})`);
45
+ }
46
+ }
47
+
48
+ function finite(v, path) {
49
+ need(isFiniteNumber(v), `${path} must be a finite number, got ${JSON.stringify(v)}`);
50
+ return v;
51
+ }
52
+
53
+ function positive(v, path) {
54
+ need(isFiniteNumber(v) && v > 0, `${path} must be a positive number, got ${JSON.stringify(v)}`);
55
+ return v;
56
+ }
57
+
58
+ function nonNegative(v, path) {
59
+ need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
60
+ return v;
61
+ }
62
+
63
+ /**
64
+ * Validate and normalise a device definition.
65
+ * @param {object} def
66
+ * @returns {object} internal model
67
+ */
68
+ export function normalizeDevice(def) {
69
+ need(isObject(def), 'device definition must be an object');
70
+ fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid']);
71
+
72
+ const T = def.T === undefined ? 298.15 : positive(def.T, 'T');
73
+ const RT = GAS_CONSTANT * T;
74
+
75
+ // --- species
76
+ need(Array.isArray(def.species) && def.species.length > 0, 'species must be a non-empty array');
77
+ const species = [];
78
+ const speciesIndex = new Map();
79
+ def.species.forEach((sp, i) => {
80
+ const path = `species[${i}]`;
81
+ need(isObject(sp), `${path} must be an object`);
82
+ fields(sp, path, ['name', 'z', 'cRef']);
83
+ need(typeof sp.name === 'string' && sp.name.length > 0, `${path}.name must be a non-empty string`);
84
+ need(!speciesIndex.has(sp.name), `${path}.name: duplicate species '${sp.name}'`);
85
+ need(Number.isInteger(sp.z), `${path}.z (charge number) must be an integer, got ${JSON.stringify(sp.z)}`);
86
+ const cRef = sp.cRef === undefined ? undefined : positive(sp.cRef, `${path}.cRef`);
87
+ speciesIndex.set(sp.name, i);
88
+ species.push({ name: sp.name, z: sp.z, cRef });
89
+ });
90
+ const nSpecies = species.length;
91
+
92
+ // --- materials
93
+ const warnings = [];
94
+ need(isObject(def.materials) && Object.keys(def.materials).length > 0, 'materials must be a non-empty object');
95
+ const materials = [];
96
+ const materialIndex = new Map();
97
+ for (const [mname, mat] of Object.entries(def.materials)) {
98
+ const path = `materials.${mname}`;
99
+ need(isObject(mat), `${path} must be an object`);
100
+ if (mat.conductor !== undefined) {
101
+ materialIndex.set(mname, materials.length);
102
+ materials.push(normalizeConductor(mat, mname, path, species, speciesIndex));
103
+ continue;
104
+ }
105
+ // ε = 0 makes the material strictly neutral: Poisson becomes local neutrality there.
106
+ fields(mat, path, ['epsr', 'species', 'statistics']);
107
+ const epsr = nonNegative(mat.epsr, `${path}.epsr`);
108
+ need(isObject(mat.species), `${path}.species must be an object mapping species names to parameters`);
109
+ const present = new Uint8Array(nSpecies);
110
+ const D = new Float64Array(nSpecies);
111
+ const mu0 = new Float64Array(nSpecies);
112
+ const cRef = new Float64Array(nSpecies);
113
+ for (const [sname, p] of Object.entries(mat.species)) {
114
+ const spath = `${path}.species.${sname}`;
115
+ need(speciesIndex.has(sname), `${spath}: unknown species '${sname}'${known(speciesIndex)}`);
116
+ need(isObject(p), `${spath} must be an object`);
117
+ fields(p, spath, ['D', 'mu0', 'cRef']);
118
+ const i = speciesIndex.get(sname);
119
+ present[i] = 1;
120
+ D[i] = nonNegative(p.D, `${spath}.D`);
121
+ mu0[i] = finite(p.mu0, `${spath}.mu0`);
122
+ const c = p.cRef ?? species[i].cRef;
123
+ need(c !== undefined, `${spath}.cRef: no reference concentration (give it here or on the species)`);
124
+ cRef[i] = positive(c, `${spath}.cRef`);
125
+ }
126
+ const m = { name: mname, epsr, present, D, mu0, cRef };
127
+ const st = normalizeStatistics({ ...m, statistics: mat.statistics }, path, species, speciesIndex, RT, { need, finite, positive });
128
+ m.models = st.models;
129
+ m.modelOf = st.modelOf;
130
+ m.ideal = st.models.length === 0;
131
+ m.phiFree = st.phiFree; // every charged species is in a neutral combination: φ is undefined
132
+ warnings.push(...st.warnings);
133
+ materialIndex.set(mname, materials.length);
134
+ materials.push(m);
135
+ }
136
+ species.forEach((sp, i) => {
137
+ need(materials.some((m) => m.present[i]), `species '${sp.name}' is not present in any material`);
138
+ });
139
+
140
+ // --- regions
141
+ need(Array.isArray(def.regions) && def.regions.length > 0, 'regions must be a non-empty array');
142
+ const regions = def.regions.map((reg, r) => {
143
+ const path = `regions[${r}]`;
144
+ need(isObject(reg), `${path} must be an object`);
145
+ fields(reg, path, ['name', 'material', 'length', 'fixedCharge', 'c0', 'velocity', 'mixing', 'grid']);
146
+ need(materialIndex.has(reg.material), `${path}.material: unknown material ${JSON.stringify(reg.material)}`);
147
+ const length = positive(reg.length, `${path}.length`);
148
+ const fixedCharge = reg.fixedCharge === undefined ? 0 : finite(reg.fixedCharge, `${path}.fixedCharge`);
149
+ // Imposed flow (m/s, toward +x) carrying every mobile species, and eddy mixing (m²/s).
150
+ const velocity = reg.velocity === undefined ? 0 : finite(reg.velocity, `${path}.velocity`);
151
+ const mixing = reg.mixing === undefined ? 0 : nonNegative(reg.mixing, `${path}.mixing`);
152
+ if (reg.grid !== undefined) {
153
+ need(isObject(reg.grid), `${path}.grid must be an object`);
154
+ fields(reg.grid, `${path}.grid`, GRID_FIELDS);
155
+ }
156
+ const mat = materials[materialIndex.get(reg.material)];
157
+ if (mat.conductor) {
158
+ need(fixedCharge === 0, `${path}.fixedCharge: a conductor region is neutral in bulk`);
159
+ need(reg.c0 === undefined, `${path}.c0: a conductor region has no composition to give, only its carrier's level`);
160
+ need(velocity === 0 && mixing === 0, `${path}: a conductor region has no flow or mixing, only conduction`);
161
+ // Nothing is stored inside a metal (its charge is on its faces), so its Fermi level is
162
+ // exactly linear across it, and one cell is exact.
163
+ need(reg.grid === undefined, `${path}.grid: a conductor region is a single cell (exact for Ohm's law), so it takes no grid options`);
164
+ }
165
+ if (mat.epsr === 0) {
166
+ need(
167
+ species.some((sp, i) => mat.present[i] && sp.z !== 0) || fixedCharge === 0,
168
+ `${path}: material '${mat.name}' has ε = 0 (strictly neutral) but no mobile charged species to neutralise the fixed charge`,
169
+ );
170
+ }
171
+ // An insertion host's fixed charge is balanced by its own background carriers.
172
+ const host = mat.models.find((md) => md.type === 'insertion');
173
+ const background = host ? -fixedCharge / (species[host.carrier].z * FARADAY) : 0;
174
+ if (host) {
175
+ need(
176
+ background >= 0,
177
+ `${path}.fixedCharge: in an insertion host the fixed charge is balanced by background ${species[host.carrier].name}, ` +
178
+ 'so it must have the opposite sign to that carrier',
179
+ );
180
+ }
181
+ // Initial composition: the starting state, and the conserved amount of any spectator.
182
+ const c0 = new Float64Array(nSpecies).fill(NaN);
183
+ if (reg.c0 !== undefined) {
184
+ need(isObject(reg.c0), `${path}.c0 must be an object mapping species names to concentrations`);
185
+ for (const [sname, v] of Object.entries(reg.c0)) {
186
+ need(speciesIndex.has(sname), `${path}.c0.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
187
+ const i = speciesIndex.get(sname);
188
+ need(mat.present[i], `${path}.c0.${sname}: '${sname}' is absent from material '${mat.name}'`);
189
+ need(
190
+ isFiniteNumber(v) && v > 0,
191
+ `${path}.c0.${sname} must be a concentration > 0 (mol/m³), got ${JSON.stringify(v)}; to have none of '${sname}' here, ` +
192
+ `leave it out of material '${mat.name}' (a region of another material without it, if it's present elsewhere)`,
193
+ );
194
+ c0[i] = v;
195
+ }
196
+ }
197
+ return {
198
+ name: reg.name ?? `region ${r}`,
199
+ material: materialIndex.get(reg.material),
200
+ length,
201
+ fixedCharge,
202
+ c0,
203
+ background,
204
+ velocity,
205
+ mixing,
206
+ // With no grid options at all, each region is graded toward its ends: fine where profiles
207
+ // are steep (double layers, depleted electrodes), coarse inside.
208
+ grid: reg.grid ?? (def.grid === undefined ? { hmin: length / 1000, hmax: length / 20 } : undefined),
209
+ cells: mat.conductor ? [length] : undefined,
210
+ };
211
+ });
212
+
213
+ // --- interfaces (one per face between consecutive regions)
214
+ const nFaces = regions.length - 1;
215
+ const idefs = def.interfaces ?? [];
216
+ need(Array.isArray(idefs), 'interfaces must be an array (one entry per face between regions)');
217
+ need(idefs.length <= nFaces, `interfaces has ${idefs.length} entries but there are only ${nFaces} faces between regions`);
218
+ const interfaces = [];
219
+ for (let f = 0; f < nFaces; f++) {
220
+ interfaces.push(normalizeInterface(idefs[f], f, regions, materials, species, speciesIndex, RT));
221
+ }
222
+
223
+ // --- bulk reactions
224
+ need(def.bulkReactions === undefined || Array.isArray(def.bulkReactions), 'bulkReactions must be an array');
225
+ const reactions = (def.bulkReactions ?? []).map((rdef, k) =>
226
+ normalizeReaction(rdef, `bulkReactions[${k}]`, species, speciesIndex, materials, materialIndex, regions, RT),
227
+ );
228
+
229
+ // --- contacts
230
+ const cdefs = def.contacts ?? {};
231
+ need(isObject(cdefs), 'contacts must be an object with optional left and right');
232
+ for (const k of Object.keys(cdefs)) need(k === 'left' || k === 'right', `contacts.${k}: expected only 'left' and 'right'`);
233
+ const contacts = {
234
+ left: normalizeContact(cdefs.left, 'left', regions[0], materials, species, speciesIndex, RT),
235
+ right: normalizeContact(cdefs.right, 'right', regions[regions.length - 1], materials, species, speciesIndex, RT),
236
+ };
237
+
238
+ // --- internal ports (outside phases attached over windows of interior nodes)
239
+ need(def.ports === undefined || Array.isArray(def.ports), 'ports must be an array');
240
+ const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex));
241
+
242
+ // Every electrostatically coupled cluster of regions needs an anchor, or its φ (and every
243
+ // charged level with it) floats: shifting φ by s and each η_i by z_i s changes nothing.
244
+ // Regions are coupled across a face by a non-neutral φ law or by any charged species that
245
+ // crosses; a contact anchors its cluster through a gate, a reaction, or a connected ion.
246
+ checkAnchors(regions, materials, interfaces, contacts, species, ports);
247
+
248
+ // --- terminals: the two contacts and every port, each held at a voltage or driven by a current
249
+ const terminals = [
250
+ { name: 'left', kind: 'contact', side: 'left', drive: contacts.left.drive },
251
+ { name: 'right', kind: 'contact', side: 'right', drive: contacts.right.drive },
252
+ ...ports.map((port, k) => ({ name: port.name, kind: 'port', index: k, drive: port.drive })),
253
+ ];
254
+ const names = new Set();
255
+ for (const t of terminals) {
256
+ need(!names.has(t.name), `ports: the name '${t.name}' is taken (by a contact or another port)`);
257
+ names.add(t.name);
258
+ }
259
+ // A contact that passes nothing can't be driven by a current.
260
+ for (const side of ['left', 'right']) {
261
+ const ct = contacts[side];
262
+ const passes = ct.species.some((l) => l.type !== 'blocked') || ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
263
+ need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
264
+ ct.passes = passes;
265
+ }
266
+ need(
267
+ terminals.some((t) => t.drive.kind === 'V'),
268
+ 'every terminal is driven by a current, so the device\'s overall level floats: hold at least one at a voltage V',
269
+ );
270
+
271
+ // --- grid
272
+ if (def.grid !== undefined) {
273
+ need(isObject(def.grid), 'grid must be an object');
274
+ fields(def.grid, 'grid', GRID_FIELDS);
275
+ }
276
+ let grid;
277
+ try {
278
+ grid = buildGrid(regions, def.grid ?? {});
279
+ } catch (err) {
280
+ throw new DeviceError(`grid: ${err.message}`);
281
+ }
282
+
283
+ // Port windows: the nodes of the port's region within [from, to] of its left end.
284
+ ports.forEach((port, k) => {
285
+ const nodes = [];
286
+ const x0 = grid.x[grid.regionStart[port.region]];
287
+ for (let g = grid.regionStart[port.region]; g <= grid.regionEnd[port.region]; g++) {
288
+ const d = grid.x[g] - x0;
289
+ if (d >= port.from - 1e-15 * port.span && d <= port.to + 1e-15 * port.span) nodes.push(g);
290
+ }
291
+ need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
292
+ port.nodes = Int32Array.from(nodes);
293
+ });
294
+
295
+ return {
296
+ T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports,
297
+ };
298
+ }
299
+
300
+ // Bulk reaction Σ ν_R R ⇌ Σ ν_P P with mass-action rate r = k_f Π c_R^ν (1 − e^{−A/RT}), where
301
+ // the affinity A = Σ_R ν μ̄ − Σ_P ν μ̄ includes any fixed-activity neutral participants (e.g.
302
+ // H₂O, given by its μ). This equals k_f Π c_R − k_b Π c_P with k_b fixed by the standard
303
+ // potentials, so equilibrium is exactly A = 0. kf maps material names to rate constants (the
304
+ // reaction runs only in those materials).
305
+ function normalizeReaction(rdef, path, species, speciesIndex, materials, materialIndex, regions, RT) {
306
+ need(isObject(rdef), `${path} must be an object`);
307
+ fields(rdef, path, ['nu', 'equation', 'fixed', 'kf']);
308
+ need((rdef.nu === undefined) !== (rdef.equation === undefined), `${path}: give the reaction as nu or as an equation, one of them`);
309
+ const nu = rdef.nu ?? equationStoichiometry(rdef.equation, `${path}.equation`);
310
+ const st = stoichiometry(nu, rdef.equation === undefined ? `${path}.nu` : `${path}.equation`, rdef.fixed, `${path}.fixed`, species, speciesIndex, RT);
311
+ need(st.list.length > 0, `${path}: no mobile participants`);
312
+ need(st.charge === 0, `${path}: charge is not balanced (Σ ν z = ${st.charge})`);
313
+ need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
314
+ const kf = new Float64Array(materials.length);
315
+ for (const [mname, v] of Object.entries(rdef.kf)) {
316
+ need(materialIndex.has(mname), `${path}.kf.${mname}: unknown material`);
317
+ const m = materialIndex.get(mname);
318
+ kf[m] = nonNegative(v, `${path}.kf.${mname}`);
319
+ for (const { i } of st.list) {
320
+ need(materials[m].present[i], `${path}.kf.${mname}: '${species[i].name}' is absent from material '${mname}'`);
321
+ }
322
+ }
323
+ const reactants = st.list.filter((p) => p.nu < 0).map(({ i, nu }) => ({ i, nu: -nu }));
324
+ const products = st.list.filter((p) => p.nu > 0);
325
+ return {
326
+ reactants,
327
+ products,
328
+ fixedA: st.fixedA, // fixed participants' share of A/RT
329
+ kf,
330
+ // Species made only from (or turned only into) fixed reservoirs, such as photogeneration
331
+ // from a photon reservoir: a source that can hold the device far from equilibrium.
332
+ generation: st.nFixed > 0 && (reactants.length === 0 || products.length === 0),
333
+ };
334
+ }
335
+
336
+ // A signed stoichiometry { name: ν } (ν < 0 consumed, ν > 0 produced by the forward reaction).
337
+ // Species become { i, nu }; any other name is a fixed-activity neutral whose μ (J/mol) is given
338
+ // in `fixed`, and contributes −ν μ/RT to the affinity a = A/RT = −Σ ν μ̄/RT.
339
+ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
340
+ map ??= {};
341
+ fixed ??= {};
342
+ need(isObject(map), `${path} must map participants to signed stoichiometric coefficients (ν < 0 consumed)`);
343
+ need(isObject(fixed), `${fixedPath} must map fixed-activity participants to their μ (J/mol)`);
344
+ const list = [];
345
+ let fixedA = 0, charge = 0, nFixed = 0;
346
+ for (const [name, nu] of Object.entries(map)) {
347
+ need(Number.isInteger(nu) && nu !== 0, `${path}.${name} must be a non-zero integer, got ${JSON.stringify(nu)}`);
348
+ if (speciesIndex.has(name)) {
349
+ const i = speciesIndex.get(name);
350
+ list.push({ i, nu });
351
+ charge += nu * species[i].z;
352
+ } else {
353
+ need(
354
+ fixed[name] !== undefined,
355
+ `${path}.${name}: not a species, so give its μ in ${fixedPath} (fixed-activity participants are neutral)` +
356
+ (/^\d+\D/.test(name) ? `; if ${name.match(/^\d+/)[0]} is a coefficient, put a space after it` : ''),
357
+ );
358
+ fixedA -= (nu * finite(fixed[name], `${fixedPath}.${name}`)) / RT;
359
+ nFixed++;
360
+ }
361
+ }
362
+ return { list, fixedA, charge, nFixed };
363
+ }
364
+
365
+ // Reactions at a face, Butler–Volmer, with participants on either side (signed stoichiometry
366
+ // per side, ν < 0 consumed by the forward reaction). Each side's participants are species
367
+ // present there (on a conductor's side, only its carrier) or fixed-activity neutrals. Rate per
368
+ // area, with a = A/RT = −Σ ν μ̄/RT and k0 in mol/(m²·s):
369
+ // r = k0 Π_{ν<0} (c/c_ref)^{|ν|(1−α)} Π_{ν>0} (c/c_ref)^{να} (e^{αa} − e^{−(1−α)a})
370
+ // (a conductor's carrier has activity 1, so no factor). That's mass action with rate constants
371
+ // that depend on the electrical part of the affinity, and exactly zero at A = 0.
372
+ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT) {
373
+ need(idef.reactions === undefined || Array.isArray(idef.reactions), `${where}.reactions must be an array`);
374
+ return (idef.reactions ?? []).map((rdef, k) => {
375
+ const rpath = `${where}.reactions[${k}]`;
376
+ need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
377
+ fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha']);
378
+ let sides = rdef;
379
+ if (rdef.equation !== undefined) {
380
+ need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
381
+ const holds = (mat) => (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
382
+ sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: matL.name, holds: holds(matL), conductor: !!matL.conductor }, { name: matR.name, holds: holds(matR), conductor: !!matR.conductor }, rdef.fixed, `${rpath}.equation`);
383
+ }
384
+ const part = [];
385
+ let fixedA = 0, charge = 0;
386
+ for (const [key, mat] of [['left', matL], ['right', matR]]) {
387
+ const st = stoichiometry(sides[key], rdef.equation === undefined ? `${rpath}.${key}` : `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
388
+ for (const { i, nu } of st.list) {
389
+ need(mat.present[i], `${rpath}.${key}.${species[i].name}: absent from '${mat.name}'`);
390
+ part.push({ i, nu, side: key === 'left' ? 0 : 1 });
391
+ }
392
+ fixedA += st.fixedA;
393
+ charge += st.charge;
394
+ }
395
+ need(part.length > 0, `${rpath}: no species participate`);
396
+ need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
397
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
398
+ });
399
+ }
400
+
401
+ // A species that takes part in a reaction at a face and exists on both sides needs its link
402
+ // there given explicitly: the default ('equilibrium') would cross it freely alongside the
403
+ // reaction and short-circuit the kinetics. ('blocked' if it crosses only through the reaction.)
404
+ function checkReactingLinks(reactions, idef, where, matL, matR, species) {
405
+ for (const rx of reactions) {
406
+ for (const { i } of rx.part) {
407
+ const name = species[i].name;
408
+ need(
409
+ idef.species?.[name] !== undefined || !(matL.present[i] && matR.present[i]),
410
+ `${where}.species.${name}: '${name}' takes part in a reaction here and exists on both sides, so give its link ` +
411
+ "explicitly ('blocked' if it crosses only through the reaction)",
412
+ );
413
+ }
414
+ }
415
+ }
416
+
417
+ // A terminal's drive: held at a voltage V (behind a series resistance R, Ω·m², if given), or
418
+ // driven by a current I (A/m², into the device). V and I are numbers or piecewise-linear
419
+ // waveforms { t: [...], values: [...], repeat }. Neither given: held at V = 0.
420
+ function normalizeDrive(d, path) {
421
+ need(d.V === undefined || d.I === undefined, `${path}: give V or I, not both`);
422
+ if (d.I !== undefined) {
423
+ need(d.R === undefined, `${path}.R: a series resistance goes with a voltage source V`);
424
+ return { kind: 'I', src: normalizeSource(d.I, `${path}.I`), R: 0 };
425
+ }
426
+ return {
427
+ kind: 'V',
428
+ src: normalizeSource(d.V ?? 0, `${path}.V`),
429
+ R: d.R === undefined ? 0 : positive(d.R, `${path}.R (Ω·m²)`),
430
+ };
431
+ }
432
+
433
+ // A source value: a constant, or a piecewise-linear waveform through the points (t, value),
434
+ // constant beyond them, or periodic with period t_last − t_0 when repeat is true.
435
+ function normalizeSource(v, path) {
436
+ if (typeof v === 'number') return { value: finite(v, path) };
437
+ need(isObject(v), `${path} must be a number or a waveform { t: [...], values: [...], repeat }`);
438
+ fields(v, path, ['t', 'values', 'repeat']);
439
+ need(Array.isArray(v.t) && Array.isArray(v.values) && v.t.length >= 1 && v.t.length === v.values.length, `${path}: t and values need the same length, at least 1`);
440
+ v.t.forEach((t, k) => {
441
+ finite(t, `${path}.t[${k}]`);
442
+ need(k === 0 || t >= v.t[k - 1], `${path}.t must not decrease`);
443
+ need(k < 2 || t > v.t[k - 2], `${path}.t: at most two points at one time (a step)`);
444
+ });
445
+ v.values.forEach((x, k) => finite(x, `${path}.values[${k}]`));
446
+ need(v.repeat === undefined || typeof v.repeat === 'boolean', `${path}.repeat must be true or false`);
447
+ need(!v.repeat || v.t[v.t.length - 1] > v.t[0], `${path}: a repeating waveform needs a period (its last time after its first)`);
448
+ return { t: Float64Array.from(v.t), values: Float64Array.from(v.values), repeat: v.repeat === true };
449
+ }
450
+
451
+ /**
452
+ * The terminals' drives alone (in terminal order: left, right, then the ports), from a definition
453
+ * whose structure is unchanged: for a fast update of sources.
454
+ */
455
+ export function normalizeDrives(def, model) {
456
+ const drives = [
457
+ normalizeDrive(def.contacts?.left ?? {}, 'contacts.left'),
458
+ normalizeDrive(def.contacts?.right ?? {}, 'contacts.right'),
459
+ ...(def.ports ?? []).map((p, k) => normalizeDrive(p, `ports[${k}]`)),
460
+ ];
461
+ for (const [k, side] of [[0, 'left'], [1, 'right']]) {
462
+ need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
463
+ }
464
+ 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");
465
+ return drives;
466
+ }
467
+
468
+ /**
469
+ * A source's value at time t (s). At a step (two points at one time) it's the value after the
470
+ * step; with `before`, the limit from earlier times, as a time step ending at t sees it.
471
+ */
472
+ export function sourceAt(src, t, before = false) {
473
+ if (src.value !== undefined) return src.value;
474
+ const { t: ts, values: vs, repeat } = src, n = ts.length;
475
+ if (before) t -= 8 * Number.EPSILON * Math.max(Math.abs(t), ts[n - 1] - ts[0], 1e-300); // just before: a few ulps
476
+ if (repeat) {
477
+ const T = ts[n - 1] - ts[0];
478
+ t = ts[0] + ((((t - ts[0]) % T) + T) % T);
479
+ }
480
+ if (t < ts[0]) return vs[0];
481
+ if (t >= ts[n - 1]) return vs[n - 1];
482
+ let k = 0;
483
+ while (ts[k] < t) k++;
484
+ if (ts[k] === t) {
485
+ while (k + 1 < n && ts[k + 1] === t) k++; // after a step
486
+ return vs[k];
487
+ }
488
+ const w = (t - ts[k - 1]) / (ts[k] - ts[k - 1]);
489
+ return vs[k - 1] + w * (vs[k] - vs[k - 1]);
490
+ }
491
+
492
+ /** The first breakpoint of a source strictly after time t (Infinity if none). */
493
+ export function nextBreakpoint(src, t) {
494
+ if (src.value !== undefined) return Infinity;
495
+ const { t: ts, repeat } = src, n = ts.length;
496
+ if (!repeat) {
497
+ for (let k = 0; k < n; k++) if (ts[k] > t * (1 + 1e-12) + 1e-300) return ts[k];
498
+ return Infinity;
499
+ }
500
+ const T = ts[n - 1] - ts[0], cycle = Math.floor((t - ts[0]) / T);
501
+ for (let c = cycle; c <= cycle + 1; c++) {
502
+ for (let k = 0; k < n; k++) {
503
+ const tb = ts[k] + c * T;
504
+ if (tb > t + 1e-12 * Math.max(Math.abs(t), T)) return tb;
505
+ }
506
+ }
507
+ return Infinity;
508
+ }
509
+
510
+ // An internal port: an outside phase with known levels (V_i = V + offset_i, or μ for neutral
511
+ // species, as at a contact), exchanging with every node in a window of one region.
512
+ // Links per species: 'equilibrium' (μ̄ held at the outside level throughout the window),
513
+ // { type: 'conductance', G } for charged species (G in S/m³: a source G (V_out − V_i)/(zF) per
514
+ // volume), { type: 'exchange', k, mu } for neutral ones (k in mol/(m³·s): a source
515
+ // k (μ_out − μ)/RT per volume), or 'blocked' (the default).
516
+ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
517
+ need(isObject(pdef), `${path} must be an object`);
518
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species']);
519
+ let r;
520
+ if (Number.isInteger(pdef.region)) r = pdef.region;
521
+ else r = regions.findIndex((reg) => reg.name === pdef.region);
522
+ need(r >= 0 && r < regions.length, `${path}.region: give a region's name or index, got ${JSON.stringify(pdef.region)}`);
523
+ const reg = regions[r], mat = materials[reg.material];
524
+ // A metal is one cell with no interior, so a port attaches to the whole of it, as a wire would.
525
+ if (mat.conductor) need(pdef.from === undefined && pdef.to === undefined, `${path}: a port on a conductor attaches to all of it, so give no window`);
526
+ const from = pdef.from === undefined ? 0 : nonNegative(pdef.from, `${path}.from`);
527
+ const to = pdef.to === undefined ? reg.length : finite(pdef.to, `${path}.to`);
528
+ need(to >= from && to <= reg.length * (1 + 1e-12), `${path}: the window [from, to] must lie within the region (0 to ${reg.length} m)`);
529
+ const drive = normalizeDrive(pdef, path);
530
+ let terminal = null;
531
+ if (pdef.terminal !== undefined) {
532
+ need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
533
+ terminal = speciesIndex.get(pdef.terminal);
534
+ }
535
+ need(isObject(pdef.species), `${path}.species must map species names to port links`);
536
+ const links = species.map(() => ({ type: 'blocked' }));
537
+ for (const [sname, raw] of Object.entries(pdef.species)) {
538
+ const lpath = `${path}.species.${sname}`;
539
+ need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
540
+ const i = speciesIndex.get(sname);
541
+ const link = typeof raw === 'string' ? { type: raw } : raw;
542
+ need(isObject(link) && ['blocked', 'equilibrium', 'conductance', 'exchange'].includes(link.type), `${lpath}.type must be one of blocked, equilibrium, conductance, exchange`);
543
+ fields(link, lpath, LINK_FIELDS[link.type]);
544
+ if (link.type === 'blocked') continue;
545
+ need(mat.present[i], `${lpath}: '${sname}' is absent from ${reg.name} (material '${mat.name}')`);
546
+ if (mat.conductor) need(i === mat.conductor.i, `${lpath}: a conductor exchanges only its carrier, ${species[mat.conductor.i].name}`);
547
+ const z = species[i].z;
548
+ // The outside level: an offset from V for charged species, an absolute μ for neutral ones.
549
+ let level;
550
+ if (z === 0) {
551
+ need(link.type !== 'conductance', `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
552
+ level = { mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
553
+ } else {
554
+ need(link.type !== 'exchange', `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
555
+ need(link.mu === undefined, `${lpath}: a charged species is held by an offset from the port voltage, not by mu`);
556
+ const offset = link.offset ?? (terminal === i ? 0 : undefined);
557
+ need(offset !== undefined, `${lpath}.offset: give V_i − V (V); only the terminal species defaults to 0`);
558
+ level = { offset: finite(offset, `${lpath}.offset`) };
559
+ }
560
+ // On a metal, G is a lumped conductance per area (S/m²), spread over its thickness.
561
+ if (link.type === 'conductance') level.G = mat.conductor ? positive(link.G, `${lpath}.G (S/m², for a conductor)`) / reg.length : positive(link.G, `${lpath}.G (S/m³)`);
562
+ if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
563
+ links[i] = { type: link.type, ...level };
564
+ }
565
+ need(links.some((l) => l.type !== 'blocked'), `${path}.species: the port exchanges no species`);
566
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links };
567
+ }
568
+
569
+ function checkAnchors(regions, materials, interfaces, contacts, species, ports = []) {
570
+ const nR = regions.length;
571
+ const parent = Array.from({ length: nR }, (_, r) => r);
572
+ const find = (r) => (parent[r] === r ? r : (parent[r] = find(parent[r])));
573
+ interfaces.forEach((itf, f) => {
574
+ // (a reaction moving charge across the face couples the two sides too)
575
+ const charged = (rx, side) => rx.part.some((p) => p.side === side && species[p.i].z !== 0);
576
+ const coupled =
577
+ itf.phi.type !== 'neutral' ||
578
+ itf.links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0) ||
579
+ itf.reactions.some((rx) => charged(rx, 0) && charged(rx, 1));
580
+ if (coupled) parent[find(f)] = find(f + 1);
581
+ });
582
+ const anchors = (ct) =>
583
+ ct.phi.type === 'capacitive' ||
584
+ ct.phi.type === 'pinned' ||
585
+ ct.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
586
+ const anchored = new Set();
587
+ if (anchors(contacts.left)) anchored.add(find(0));
588
+ if (anchors(contacts.right)) anchored.add(find(nR - 1));
589
+ for (const port of ports) if (port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
590
+ for (let r = 0; r < nR; r++) {
591
+ const mat = materials[regions[r].material];
592
+ const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
593
+ if (!charged) continue; // nothing responds to φ there; it is simply not reported
594
+ need(
595
+ anchored.has(find(r)),
596
+ nR === 1 || anchored.size === 0
597
+ ? 'device has no electrostatic anchor: every species is blocked at both ends and no contact has a ' +
598
+ 'capacitive (gate) link, so the potential is undetermined. Add a gate or connect a species.'
599
+ : `regions[${r}] (${regions[r].name}) is electrostatically floating: it is cut off by neutral interfaces ` +
600
+ 'with no charged species crossing, and nothing anchors its potential.',
601
+ );
602
+ }
603
+ }
604
+
605
+ function normalizeInterface(idef, f, regions, materials, species, speciesIndex, RT) {
606
+ const left = regions[f], right = regions[f + 1];
607
+ const matL = materials[left.material], matR = materials[right.material];
608
+ const where = `interfaces[${f}] (between ${left.name} [${matL.name}] and ${right.name} [${matR.name}])`;
609
+ const same = left.material === right.material;
610
+ if (idef === undefined || idef === null) idef = {};
611
+ need(isObject(idef), `${where} must be an object`);
612
+
613
+ // Electrostatic law across the face. 'pinned': φ jumps by the alignment (exact when the grid
614
+ // resolves the double layers). 'neutral': no charge at the face (D = 0) and a free jump, the
615
+ // macroscopic limit; the alignment then drops out. 'capacitive': a Helmholtz layer,
616
+ // D = C (Δφ − dipole). Between two ε = 0 (strictly neutral) materials the default is neutral.
617
+ if (matL.conductor || matR.conductor) return normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT);
618
+ fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions']);
619
+ const bothNeutral = (matL.epsr === 0 && matR.epsr === 0) || matL.phiFree || matR.phiFree;
620
+ const rawPhi = idef.phi ?? (bothNeutral ? 'neutral' : 'pinned');
621
+ const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
622
+ need(isObject(phi), `${where}.phi must be a law name or { type, C }`);
623
+ fields(phi, `${where}.phi`, phi.type === 'capacitive' ? ['type', 'C'] : ['type']);
624
+ need(['pinned', 'neutral', 'capacitive'].includes(phi.type), `${where}.phi must be 'pinned', 'neutral' or { type: 'capacitive', C }`);
625
+ if (phi.type === 'capacitive') positive(phi.C, `${where}.phi.C`);
626
+ // A pinned face is a capacitor of infinite C. With no field on either side (ε = 0), its
627
+ // charge would sit as free excess ions in the edge cells at no cost, so nothing determines it.
628
+ need(
629
+ !(phi.type === 'pinned' && matL.epsr === 0 && matR.epsr === 0),
630
+ `${where}.phi: 'pinned' between two ε = 0 materials leaves the face's charge undetermined (no field on either side to hold it); ` +
631
+ "use 'neutral' (the default: a free Donnan jump), or { type: 'capacitive', C } for a charged layer such as a lipid bilayer",
632
+ );
633
+ const given = ['dipole', 'step'].filter((k) => idef[k] !== undefined);
634
+ need(given.length <= 1, `${where}: give exactly one alignment, got ${given.join(' and ')}`);
635
+ if (phi.type === 'neutral') {
636
+ need(given.length === 0, `${where}: a neutral interface has a free φ jump, so an alignment (${given[0]}) would have no effect`);
637
+ } else {
638
+ need(
639
+ given.length === 1 || same,
640
+ `${where}: an interface between different materials needs an alignment ` +
641
+ "({ dipole } or { step: { species, value } }; driftlet/kit's vacuumDipole estimates one), or phi: 'neutral' for a macroscopic model. " +
642
+ 'There is no default (no Anderson or Schottky–Mott rule).',
643
+ );
644
+ for (const [mat, reg] of [[matL, left], [matR, right]]) {
645
+ need(
646
+ (mat.epsr > 0 || species.some((sp, i) => mat.present[i] && sp.z !== 0)) && !mat.phiFree,
647
+ `${where}: ${reg.name} has ε = 0 and nothing there responds to φ, so its φ is undefined; use phi: 'neutral'`,
648
+ );
649
+ }
650
+ }
651
+
652
+ let dipole = 0;
653
+ if (idef.dipole !== undefined) {
654
+ dipole = finite(idef.dipole, `${where}.dipole`);
655
+ } else if (idef.step !== undefined) {
656
+ const st = idef.step;
657
+ need(isObject(st), `${where}.step must be { species, value }`);
658
+ fields(st, `${where}.step`, ['species', 'value']);
659
+ need(speciesIndex.has(st.species), `${where}.step.species: unknown species ${JSON.stringify(st.species)}${known(speciesIndex)}`);
660
+ const i = speciesIndex.get(st.species);
661
+ const z = species[i].z;
662
+ need(z !== 0, `${where}.step.species: '${st.species}' is neutral; alignment needs a charged species`);
663
+ need(matL.present[i] && matR.present[i], `${where}.step.species: '${st.species}' must be present on both sides`);
664
+ const value = finite(st.value, `${where}.step.value`);
665
+ // value = (μ°_R + zFφ_R) − (μ°_L + zFφ_L), so φ_R − φ_L = (value − Δμ°) / (zF)
666
+ dipole = (value - (matR.mu0[i] - matL.mu0[i])) / (z * FARADAY);
667
+ }
668
+ const sheetCharge = idef.sheetCharge === undefined ? 0 : finite(idef.sheetCharge, `${where}.sheetCharge`);
669
+
670
+ // Per-species laws across the face: local equilibrium (default where present on both sides),
671
+ // blocked, or an ohmic interface conductance G (S/m²).
672
+ const links = defaultInterfaceLinks(matL, matR, species);
673
+ if (idef.species !== undefined) {
674
+ need(isObject(idef.species), `${where}.species must map species names to interface links`);
675
+ for (const [sname, raw] of Object.entries(idef.species)) {
676
+ const lpath = `${where}.species.${sname}`;
677
+ need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
678
+ const i = speciesIndex.get(sname);
679
+ const link = typeof raw === 'string' ? { type: raw } : raw;
680
+ need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
681
+ fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
682
+ if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
683
+ if (link.type === 'conductance') {
684
+ need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
685
+ positive(link.G, `${lpath}.G`);
686
+ }
687
+ links[i] = { ...link };
688
+ }
689
+ }
690
+
691
+ const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
692
+ checkReactingLinks(reactions, idef, where, matL, matR, species);
693
+ return { phi, dipole, sheetCharge, links, reactions, conductor: null };
694
+ }
695
+
696
+ // A conductor (a metal, or a fast ion conductor): only its one mobile carrier, whose single
697
+ // unknown is its μ̄ (for a metal, the Fermi level). Its bulk is neutral and incompressible, so φ
698
+ // is undefined inside, and transport is ohmic, J = −σ∇V. Any charge it holds sits at its
699
+ // surfaces, as a sheet facing a charged interface. (The solver still calls it a metal.)
700
+ function normalizeConductor(mat, mname, path, species, speciesIndex) {
701
+ const mdef = mat.conductor;
702
+ need(isObject(mdef), `${path}.conductor must be { species, conductivity }`);
703
+ for (const k of ['epsr', 'species', 'statistics']) {
704
+ need(mat[k] === undefined, `${path}.${k}: a conductor takes only { conductor: { species, conductivity } }`);
705
+ }
706
+ need(speciesIndex.has(mdef.species), `${path}.conductor.species: unknown species ${JSON.stringify(mdef.species)}${known(speciesIndex)}`);
707
+ const i = speciesIndex.get(mdef.species);
708
+ need(species[i].z !== 0, `${path}.conductor.species: the conductor's carrier must be charged`);
709
+ const n = species.length;
710
+ const present = new Uint8Array(n);
711
+ present[i] = 1;
712
+ return {
713
+ name: mname,
714
+ epsr: 0,
715
+ present,
716
+ D: new Float64Array(n),
717
+ mu0: new Float64Array(n),
718
+ cRef: new Float64Array(n).fill(1),
719
+ models: [],
720
+ modelOf: new Int32Array(n).fill(-1),
721
+ ideal: true,
722
+ phiFree: true,
723
+ conductor: { i, sigma: positive(mdef.conductivity, `${path}.conductor.conductivity (S/m)`) },
724
+ };
725
+ }
726
+
727
+ // A face with a metal on one or both sides. The metal has no φ: a capacitive law ties the other
728
+ // side's φ to the metal's Fermi level V_F, as at a contact, with zeroCharge = V_F − φ_edge at zero
729
+ // charge: D toward the other side = C (V_F − zeroCharge − φ_edge), the metal's surface charge. (No
730
+ // 'pinned' law here: an internal conductor must hold that charge in a finite capacitance.)
731
+ // Electrode reactions take the metal's carriers at its Fermi level.
732
+ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT) {
733
+ if (idef === undefined || idef === null) idef = {};
734
+ need(isObject(idef), `${where} must be an object`);
735
+ fields(idef, where, ['phi', 'zeroCharge', 'dipole', 'step', 'sheetCharge', 'species', 'reactions']); // (the alignment fields only to say why not)
736
+ const side = matL.conductor ? (matR.conductor ? 'both' : 'left') : 'right';
737
+ const other = side === 'left' ? matR : matL;
738
+ const metal = side === 'right' ? matR.conductor : matL.conductor;
739
+ for (const k of ['dipole', 'step', 'sheetCharge']) {
740
+ need(idef[k] === undefined, `${where}.${k}: a conductor has no φ of its own; align with zeroCharge`);
741
+ }
742
+ const fieldOutside = side !== 'both' && !other.phiFree && other.epsr > 0;
743
+ const rawPhi = idef.phi ?? (fieldOutside ? undefined : 'neutral');
744
+ need(
745
+ rawPhi !== undefined,
746
+ `${where}.phi: a face between a conductor and a material with ε > 0 needs an explicit φ law ` +
747
+ "('neutral', or { type: 'capacitive', C } with zeroCharge)",
748
+ );
749
+ const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
750
+ need(isObject(phi), `${where}.phi must be a law name or { type, C }`);
751
+ fields(phi, `${where}.phi`, phi.type === 'capacitive' ? ['type', 'C'] : ['type']);
752
+ need(
753
+ phi.type !== 'pinned',
754
+ `${where}.phi: a conductor region holds its surface charge in a finite capacitance, so use { type: 'capacitive', C } ` +
755
+ '(a large C approaches a pinned barrier), or model it as a contact with a pinned law',
756
+ );
757
+ need(['neutral', 'capacitive'].includes(phi.type), `${where}.phi must be 'neutral' or { type: 'capacitive', C }`);
758
+ if (phi.type === 'capacitive') positive(phi.C, `${where}.phi.C`);
759
+ let zeroCharge = 0;
760
+ if (phi.type === 'neutral') {
761
+ need(idef.zeroCharge === undefined, `${where}: a neutral face has no charge, so an alignment would have no effect`);
762
+ } else {
763
+ need(side !== 'both', `${where}.phi: between two conductors only 'neutral' applies (neither has a φ)`);
764
+ need(!other.phiFree && (other.epsr > 0 || species.some((sp, i) => other.present[i] && sp.z !== 0)), `${where}.phi: φ is undefined on the other side; use 'neutral'`);
765
+ zeroCharge = finite(idef.zeroCharge, `${where}.zeroCharge (V_F − φ_edge at zero charge, V)`);
766
+ }
767
+ // Species laws: the metal's carrier may continue across (e.g. into a semiconductor).
768
+ const links = defaultInterfaceLinks(matL, matR, species);
769
+ if (idef.species !== undefined) {
770
+ need(isObject(idef.species), `${where}.species must map species names to interface links`);
771
+ for (const [sname, raw] of Object.entries(idef.species)) {
772
+ const lpath = `${where}.species.${sname}`;
773
+ need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
774
+ const i = speciesIndex.get(sname);
775
+ const link = typeof raw === 'string' ? { type: raw } : raw;
776
+ need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
777
+ fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
778
+ if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
779
+ if (link.type === 'conductance') {
780
+ need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
781
+ positive(link.G, `${lpath}.G`);
782
+ }
783
+ links[i] = { ...link };
784
+ }
785
+ }
786
+ const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
787
+ checkReactingLinks(reactions, idef, where, matL, matR, species);
788
+ return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, conductor: { side, i: metal.i } };
789
+ }
790
+
791
+ function defaultInterfaceLinks(matL, matR, species) {
792
+ return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] ? 'equilibrium' : 'blocked' }));
793
+ }
794
+
795
+ function transferCoefficient(v, path) {
796
+ if (v === undefined) return 0.5;
797
+ need(isFiniteNumber(v) && v > 0 && v < 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
798
+ return v;
799
+ }
800
+
801
+ // Reduced potentials ζ of a bath composition in the end material, through its statistics.
802
+ function bathZeta(mat, cb, background) {
803
+ const zeta = cb.map((c, i) => (c > 0 ? Math.log(c / mat.cRef[i]) : -800));
804
+ for (const md of mat.models) {
805
+ const z = Float64Array.from(md.idx, (i) => zeta[i]);
806
+ const fixed = md.idx.map((i) => cb[i] > 0);
807
+ const target = Float64Array.from(md.idx, (i) => cb[i]);
808
+ try {
809
+ md.invert(z, fixed, target, background);
810
+ } catch (err) {
811
+ throw new DeviceError(`bath: ${err.message}`);
812
+ }
813
+ md.idx.forEach((i, a) => (zeta[i] = z[a]));
814
+ }
815
+ return zeta;
816
+ }
817
+
818
+ // A contact has a terminal voltage V (held, or floating under a current drive) and a link for every
819
+ // species and for φ. A fixed charged species sits at V_i = V + offset_i, i.e.
820
+ // μ̄_i = z_i F (V + offset_i). The offset belongs to the outside phase: 0 for the terminal
821
+ // species (e.g. e⁻ at a metal); μ_M/(nF) for Mⁿ⁺ at a reversible M electrode (0 on table
822
+ // conventions, E° being carried by the ion's μ°). It is never defaulted for the others. A fixed neutral species takes an absolute μ̄. A bath computes the offsets from
823
+ // a composition, anchored through its reference species.
824
+ function normalizeContact(cdef, side, region, materials, species, speciesIndex, RT) {
825
+ const path = `contacts.${side}`;
826
+ const mat = materials[region.material];
827
+ const links = species.map(() => ({ type: 'blocked' }));
828
+ let phi = { type: 'neutral' };
829
+ let terminal = null;
830
+ if (cdef === undefined || cdef === null) return { drive: normalizeDrive({}, path), species: links, phi, terminal };
831
+ need(isObject(cdef), `${path} must be an object`);
832
+ fields(cdef, path, ['V', 'I', 'R', 'terminal', 'species', 'bath', 'phi', 'zeroCharge']);
833
+ const drive = normalizeDrive(cdef, path);
834
+
835
+ if (cdef.terminal !== undefined) {
836
+ need(speciesIndex.has(cdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(cdef.terminal)}${known(speciesIndex)}`);
837
+ terminal = speciesIndex.get(cdef.terminal);
838
+ }
839
+
840
+ if (cdef.species !== undefined) {
841
+ need(isObject(cdef.species), `${path}.species must be an object mapping species names to links`);
842
+ for (const [sname, raw] of Object.entries(cdef.species)) {
843
+ const lpath = `${path}.species.${sname}`;
844
+ need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
845
+ const i = speciesIndex.get(sname);
846
+ const link = typeof raw === 'string' ? { type: raw } : raw;
847
+ need(isObject(link), `${lpath} must be a link type string or an object with a type`);
848
+ need(SPECIES_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...SPECIES_LINK_TYPES].join(', ')}`);
849
+ fields(link, lpath, LINK_FIELDS[link.type]);
850
+ if (link.type !== 'blocked') {
851
+ need(mat.present[i], `${lpath}: '${sname}' is absent from the end material '${mat.name}', so it can only be blocked`);
852
+ }
853
+ if (link.type === 'conductance') {
854
+ // Ohmic exchange with an outside reservoir at V_i = V + offset: J = G·(V_out − V_i).
855
+ need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
856
+ const offset = link.offset ?? (terminal === i ? 0 : undefined);
857
+ need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir`);
858
+ links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
859
+ continue;
860
+ }
861
+ if (link.type === 'exchange') {
862
+ // A neutral species exchanging with the outside at its μ: N_in = k (μ_out − μ)/RT.
863
+ need(species[i].z === 0, `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
864
+ links[i] = { type: 'exchange', k: positive(link.k, `${lpath}.k (mol/(m²·s))`), mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
865
+ continue;
866
+ }
867
+ if (link.type === 'conductance') need(species[i].z !== 0, `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
868
+ if (link.type === 'equilibrium') {
869
+ // In equilibrium with the outside phase, whose levels are known: a Dirichlet condition.
870
+ if (species[i].z === 0) {
871
+ links[i] = { type: 'equilibrium', mu: finite(link.mu, `${lpath}.mu (a neutral species is held at its μ̄, J/mol)`) };
872
+ continue;
873
+ }
874
+ need(link.mu === undefined, `${lpath}: a charged species is held by an offset from the terminal voltage, not by mu`);
875
+ const offset = link.offset ?? (terminal === i ? 0 : undefined);
876
+ need(
877
+ offset !== undefined,
878
+ `${lpath}.offset: give V_i − V_terminal (V) for this species; only the terminal species defaults to 0`,
879
+ );
880
+ links[i] = { type: 'equilibrium', offset: finite(offset, `${lpath}.offset`) };
881
+ continue;
882
+ }
883
+ links[i] = { ...link };
884
+ }
885
+ }
886
+
887
+ if (cdef.bath !== undefined) {
888
+ need(cdef.species === undefined, `${path}: give either bath or species links, not both`);
889
+ const bath = cdef.bath;
890
+ need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
891
+ fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
892
+ need(speciesIndex.has(bath.reference), `${path}.bath.reference: unknown species ${JSON.stringify(bath.reference)}${known(speciesIndex)}`);
893
+ const r = speciesIndex.get(bath.reference);
894
+ need(species[r].z !== 0, `${path}.bath.reference: the reference species must be charged`);
895
+ need(bath.c[bath.reference] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
896
+ need(cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
897
+ terminal = r;
898
+ let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
899
+ const cb = new Float64Array(species.length);
900
+ for (const [sname, v] of Object.entries(bath.c)) {
901
+ need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
902
+ const i = speciesIndex.get(sname);
903
+ need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from the end material '${mat.name}'`);
904
+ cb[i] = positive(v, `${path}.bath.c.${sname}`);
905
+ charge += species[i].z * cb[i];
906
+ scale += Math.abs(species[i].z) * cb[i];
907
+ }
908
+ need(Math.abs(charge) <= 1e-9 * scale, `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`);
909
+ // The reference species pins the bath's φ; every other species follows from composition.
910
+ const zb = bathZeta(mat, cb, region.background);
911
+ const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
912
+ const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
913
+ const beta = refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
914
+ for (let i = 0; i < species.length; i++) {
915
+ if (!(cb[i] > 0)) continue;
916
+ links[i] =
917
+ species[i].z === 0
918
+ ? { type: 'equilibrium', mu: level(i) }
919
+ : { type: 'equilibrium', offset: beta + level(i) / (species[i].z * FARADAY) };
920
+ }
921
+ phi = { type: 'bulk' };
922
+ }
923
+
924
+ if (cdef.phi !== undefined) {
925
+ const raw = typeof cdef.phi === 'string' ? { type: cdef.phi } : cdef.phi;
926
+ need(isObject(raw), `${path}.phi must be a link type string or an object with a type`);
927
+ need(PHI_LINK_TYPES.has(raw.type), `${path}.phi.type must be one of ${[...PHI_LINK_TYPES].join(', ')}`);
928
+ fields(raw, `${path}.phi`, raw.type === 'capacitive' ? ['type', 'C'] : ['type']);
929
+ if (raw.type === 'capacitive' || raw.type === 'pinned') {
930
+ need(!mat.phiFree, `${path}.phi: φ is undefined in '${mat.name}' (only neutral combinations are charged there), so use 'bulk' or 'neutral'`);
931
+ if (raw.type === 'capacitive') positive(raw.C, `${path}.phi.C`);
932
+ // The alignment sits beside the law, as at a conductor's face: V − φ_edge at zero charge.
933
+ phi = { ...raw, zeroCharge: finite(cdef.zeroCharge, `${path}.zeroCharge (V − φ_edge at zero charge: a pzc, a barrier, or a gate's flat-band voltage less the bulk φ)`) };
934
+ } else {
935
+ need(cdef.zeroCharge === undefined, `${path}.zeroCharge: only a capacitive or pinned φ law takes an alignment`);
936
+ phi = { ...raw };
937
+ }
938
+ } else if (cdef.bath === undefined && links.some((l) => l.type !== 'blocked')) {
939
+ throw new DeviceError(
940
+ `${path}.phi: a contact with connected species needs an explicit φ law ('bulk', 'neutral', capacitive or pinned)`,
941
+ );
942
+ }
943
+ if (phi.type === 'bulk') {
944
+ need(
945
+ links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0),
946
+ `${path}.phi: 'bulk' needs at least one connected charged species at this contact`,
947
+ );
948
+ }
949
+
950
+ if (terminal !== null) {
951
+ need(links[terminal].type !== 'blocked', `${path}.terminal: '${species[terminal].name}' is blocked at this contact`);
952
+ }
953
+ return { drive, species: links, phi, terminal };
954
+ }