driftlet 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/checks.js CHANGED
@@ -65,7 +65,7 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
65
65
  // The rest checks a solution, which an unconverged state isn't.
66
66
  if (sol.converged) {
67
67
  const ledger = steady ? balance(device, sol) : null;
68
- items.push(ledger ?? conservation(sol));
68
+ items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
69
69
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
70
70
  }
71
71
 
@@ -78,33 +78,67 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
78
78
  // Each species' steady ledger: in through the terminals, made by the reactions. Rates in
79
79
  // mol/(m²·s), positive into the device or made.
80
80
  function balance(device, sol) {
81
- const { model, def } = device, { species } = model;
81
+ const { model, def } = device, { species, regions, interfaces } = model;
82
82
  const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
83
- const ledgers = species.map((sp) => ({ species: sp.name, z: sp.z, terms: [] }));
84
- const add = (i, what, rate) => {
85
- if (rate !== 0 && Number.isFinite(rate)) ledgers[i].terms.push({ what, rate });
83
+ // Each species' compartments: runs of regions it's present in, joined by faces it crosses
84
+ // freely (in equilibrium). A face it crosses only by a law (a permeability, a conductance) or
85
+ // by a reaction divides them, and what crosses there is a term in each side's ledger.
86
+ const ledgers = [], home = species.map(() => new Int32Array(regions.length).fill(-1));
87
+ species.forEach((sp, i) => {
88
+ let open = null;
89
+ regions.forEach((reg, r) => {
90
+ if (!model.materials[reg.material].present[i]) return void (open = null);
91
+ if (open && interfaces[r - 1].links[i].type === 'equilibrium') open.regions.push(r);
92
+ else ledgers.push((open = { species: sp.name, z: sp.z, i, regions: [r], terms: [] }));
93
+ home[i][r] = ledgers.length - 1;
94
+ });
95
+ });
96
+ const add = (i, r, what, rate) => {
97
+ const l = home[i][r];
98
+ if (!(l >= 0 && rate !== 0 && Number.isFinite(rate))) return;
99
+ const same = ledgers[l].terms.find((t) => t.what === what); // a bulk reaction, region by region
100
+ if (same) same.rate += rate;
101
+ else ledgers[l].terms.push({ what, rate });
86
102
  };
103
+ const last = regions.length - 1;
87
104
  species.forEach((sp, i) => {
88
- add(i, 'left contact', sol.contacts.left.flux[sp.name]);
89
- add(i, 'right contact', -sol.contacts.right.flux[sp.name]);
90
- sol.ports.forEach((p) => add(i, `port ${p.name}`, p.flux[sp.name]));
105
+ add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
106
+ add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
107
+ model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
91
108
  });
92
109
  model.reactions.forEach((rx, k) => {
93
- const total = sol.bulkReactions[k].total, what = label(def.bulkReactions?.[k]);
94
- for (const { i, nu } of rx.reactants) add(i, what, -nu * total);
95
- for (const { i, nu } of rx.products) add(i, what, nu * total);
110
+ const what = label(def.bulkReactions?.[k]);
111
+ 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);
114
+ });
96
115
  });
97
- model.interfaces.forEach((itf, f) => {
116
+ interfaces.forEach((itf, f) => {
117
+ // Across the face, where it divides a species' compartments.
118
+ species.forEach((sp, i) => {
119
+ const type = itf.links[i].type;
120
+ if (type === 'equilibrium' || type === 'blocked') return;
121
+ const N = sol.interfaces[f].N[sp.name];
122
+ add(i, f, `across face ${f} (${type})`, -N);
123
+ add(i, f + 1, `across face ${f} (${type})`, N);
124
+ });
98
125
  itf.reactions.forEach((rx, k) => {
99
126
  const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
100
- for (const { i, nu } of rx.part) add(i, what, nu * rate);
127
+ for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
101
128
  });
102
129
  });
130
+ // A species in one compartment is named alone; one in several, by where.
131
+ const several = species.map((_, i) => ledgers.filter((l) => l.i === i).length > 1);
132
+ for (const l of ledgers) {
133
+ const names = l.regions.map((r) => sol.regions[r].name);
134
+ l.compartment = names.length === 1 ? names[0] : `${names[0]} to ${names.at(-1)}`;
135
+ l.title = several[l.i] ? `${l.species} in ${l.compartment}` : l.species;
136
+ }
103
137
 
104
138
  // A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
105
139
  const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
106
140
  const natural = naturalScales(device, sol);
107
- const moving = ledgers.filter((l, i) => size(l) > 1e-9 * natural[i]);
141
+ const moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
108
142
  let worst = 0;
109
143
  const lines = [];
110
144
  for (const l of moving) {
@@ -114,13 +148,13 @@ function balance(device, sol) {
114
148
  // Terms that matter, largest first; a charged species' as a current too.
115
149
  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));
116
150
  const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
117
- lines.push(`${l.species}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
151
+ lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
118
152
  }
119
153
  const ok = worst < 1e-6;
120
154
  const summary =
121
155
  moving.length === 0
122
156
  ? 'nothing flows or reacts (equilibrium)'
123
- : `${moving.length} species' sources and sinks sum to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}; mol/(m²·s), + in or made`;
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`;
124
158
  // The largest current any charged species carries anywhere: the scale a net current is read against.
125
159
  const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
126
160
  return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
@@ -130,26 +164,27 @@ function balance(device, sol) {
130
164
  // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
131
165
  // flows are round-off of these.
132
166
  function naturalScales(device, sol) {
133
- const { model, solver } = device, { species } = model;
134
- const box = (g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? sol.x[g] - sol.x[g - 1] : 0) + (g + 1 < sol.x.length && sol.region[g + 1] === sol.region[g] ? sol.x[g + 1] - sol.x[g] : 0)) / 2;
167
+ const { model, solver } = device, { species, grid } = model;
168
+ // (totals through the cross-section, as the ledgers are: its largest in each region)
169
+ const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
135
170
  return species.map((sp, i) => {
136
171
  let S = 0;
137
172
  model.regions.forEach((reg, r) => {
138
173
  const mat = model.materials[reg.material], L = reg.length;
139
174
  if (mat.conductor) {
140
- if (mat.conductor.i === i) S = Math.max(S, (mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
175
+ if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
141
176
  return;
142
177
  }
143
178
  if (!mat.present[i] || !(mat.D[i] > 0)) return;
144
179
  let cmax = 0;
145
180
  for (let g = 0; g < sol.x.length; g++) if (sol.region[g] === r && Number.isFinite(sol.c[sp.name][g])) cmax = Math.max(cmax, sol.c[sp.name][g]);
146
- S = Math.max(S, (mat.D[i] * cmax) / L);
181
+ S = Math.max(S, (area[r] * mat.D[i] * cmax) / L);
147
182
  });
148
183
  solver.rxs.forEach((rx) => {
149
184
  const p = rx.sp.indexOf(i);
150
185
  if (p < 0) return;
151
186
  let gross = 0;
152
- for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += box(g) * solver.bulkOneWay(rx, g);
187
+ for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += grid.vol[g] * solver.bulkOneWay(rx, g);
153
188
  S = Math.max(S, Math.abs(rx.nu[p]) * gross);
154
189
  });
155
190
  return S;
@@ -157,12 +192,12 @@ function naturalScales(device, sol) {
157
192
  }
158
193
 
159
194
  // Closed, unreacting stretches keep their amounts in a transient.
160
- function conservation(sol) {
195
+ function conservation(sol, area = 1) {
161
196
  const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
162
197
  if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
163
198
  // Relative to the amount, but a drift below 1e-20 mol/m² (1e-15 C/m²) is nothing: a floating
164
- // metal's excess electrons are themselves ~0.
165
- const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 ? 0 : Math.abs(st.drift))));
199
+ // metal's excess electrons are themselves ~0. (Amounts are totals through the cross-section.)
200
+ const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 * area ? 0 : Math.abs(st.drift))));
166
201
  return {
167
202
  name: 'conservation',
168
203
  ok: worst < 1e-8,
package/src/describe.js CHANGED
@@ -125,6 +125,11 @@ export function describe(def) {
125
125
  const warnings = unitWarnings(def);
126
126
  const total = grid.x[grid.x.length - 1] - grid.x[0];
127
127
  lines.push(`${regions.length} region${regions.length > 1 ? 's' : ''}, ${si(total, 'm')}, ${grid.x.length} nodes, T = ${model.T} K`);
128
+ const geo = model.geometry;
129
+ if (geo.type !== 'planar') {
130
+ const shape = geo.type === 'profile' ? `a cross-section from ${num(Math.min(...grid.area))} to ${num(Math.max(...grid.area))} m²` : `${geo.type} shells, r = ${si(geo.r0, 'm')} + x${geo.type === 'cylindrical' ? ' (per metre of length)' : ''}`;
131
+ lines.push(`geometry: ${shape}; currents and fluxes are totals through it (A, mol/s)`);
132
+ }
128
133
  lines.push(`species: ${species.map((sp) => `${sp.name} (z = ${sp.z > 0 ? '+' : ''}${sp.z})`).join(', ')}`);
129
134
 
130
135
  lines.push('regions:');
@@ -186,8 +191,15 @@ export function describe(def) {
186
191
  const law = itf.phi.type === 'capacitive' ? `capacitive, C = ${num(itf.phi.C)} F/m²` : itf.phi.type;
187
192
  const align = itf.conductor ? (itf.phi.type === 'capacitive' ? `, zeroCharge ${num(itf.zeroCharge)} V` : '') : itf.phi.type === 'neutral' ? '' : `, dipole ${num(itf.dipole)} V`;
188
193
  const blocked = species.filter((sp, i) => itf.links[i].type === 'blocked' && materials[L.material].present[i] && materials[R.material].present[i]).map((sp) => sp.name);
189
- const rx = (idef.reactions ?? []).map((r) => r.equation ?? equation(r.left, r.right));
190
- lines.push(` ${L.name} | ${R.name}: ${law}${align}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`);
194
+ const kinetics = (r) => (r.vmax !== undefined ? ` (saturating, vmax ${num(r.vmax)} mol/(m²·s))` : r.srh ? ' (SRH)' : '');
195
+ const rx = (idef.reactions ?? []).map((r) => (r.equation ?? equation(r.left, r.right)) + kinetics(r));
196
+ const laws = species.flatMap((sp, i) => {
197
+ const l = itf.links[i];
198
+ return l.type === 'permeability' ? [`${sp.name} ${num(l.P)} m/s`] : l.type === 'conductance' ? [`${sp.name} ${num(l.G)} S/m²`] : [];
199
+ });
200
+ lines.push(
201
+ ` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`,
202
+ );
191
203
  });
192
204
  }
193
205
 
@@ -199,7 +211,13 @@ export function describe(def) {
199
211
  const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
200
212
  lines.push(` ${side}: ${drive(cdef)}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
201
213
  }
202
- (def.ports ?? []).forEach((p, k) => lines.push(` port ${model.ports[k].name}: ${drive(p)}, in ${regions[model.ports[k].region].name}`));
214
+ (def.ports ?? []).forEach((p, k) => {
215
+ const port = model.ports[k];
216
+ lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
217
+ // an electrode spread through the window
218
+ if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
219
+ for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}`);
220
+ });
203
221
  if ((def.bulkReactions ?? []).length) {
204
222
  lines.push('bulk reactions:');
205
223
  for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);
package/src/device.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // defaults for physically meaningful choices: interface alignments, reference concentrations
7
7
  // and standard potentials must all be given.
8
8
 
9
- import { buildGrid } from './grid.js';
9
+ import { buildGrid, applyGeometry } from './grid.js';
10
10
  import { FARADAY, GAS_CONSTANT } from './constants.js';
11
11
  import { normalizeStatistics } from './statistics.js';
12
12
  import { DeviceError } from './errors.js';
@@ -16,7 +16,7 @@ export { DeviceError };
16
16
 
17
17
  // Contacts use the same laws as internal faces: the outside is a phase with known levels.
18
18
  const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange']);
19
- const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance']);
19
+ const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
20
20
  const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
21
21
  const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
22
22
  // The fields of each kind of species link (to an outside phase, or across a face).
@@ -105,7 +105,7 @@ function nonNegative(v, path) {
105
105
  */
106
106
  export function normalizeDevice(def) {
107
107
  need(isObject(def), 'device definition must be an object');
108
- fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid']);
108
+ fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid', 'geometry']);
109
109
 
110
110
  const T = def.T === undefined ? 298.15 : positive(def.T, 'T');
111
111
  const RT = GAS_CONSTANT * T;
@@ -171,8 +171,14 @@ export function normalizeDevice(def) {
171
171
  materialIndex.set(mname, materials.length);
172
172
  materials.push(m);
173
173
  }
174
+ // (an electrode's carrier can live outside the device: a reacting port's terminal species)
175
+ const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
174
176
  species.forEach((sp, i) => {
175
- need(materials.some((m) => m.present[i]), `species '${sp.name}' is not present in any material`);
177
+ const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
178
+ need(
179
+ materials.some((m) => m.present[i]) || outside(sp),
180
+ `species '${sp.name}' is not present in any material` + (reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
181
+ );
176
182
  });
177
183
 
178
184
  // --- regions
@@ -280,7 +286,7 @@ export function normalizeDevice(def) {
280
286
 
281
287
  // --- internal ports (outside phases attached over windows of interior nodes)
282
288
  need(def.ports === undefined || Array.isArray(def.ports), 'ports must be an array');
283
- const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex));
289
+ const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex, RT));
284
290
 
285
291
  // Every electrostatically coupled cluster of regions needs an anchor, or its φ (and every
286
292
  // charged level with it) floats: shifting φ by s and each η_i by z_i s changes nothing.
@@ -322,6 +328,17 @@ export function normalizeDevice(def) {
322
328
  } catch (err) {
323
329
  throw new DeviceError(`grid: ${err.message}`);
324
330
  }
331
+ const geometry = normalizeGeometry(def.geometry);
332
+ if (geometry.type !== 'planar') {
333
+ regions.forEach((reg, r) => need(reg.velocity === 0, `regions[${r}].velocity: flow is for a planar device (a uniform velocity through a varying cross-section wouldn't conserve the liquid)`));
334
+ }
335
+ applyGeometry(grid, geometry);
336
+ // (zero only at an end, as at a sphere's centre: nothing passes there)
337
+ const inside = (x) => x > 0 && x < grid.length;
338
+ need(
339
+ grid.area.every((a, g) => a > 0 || g === 0 || g === grid.nNodes - 1) && (geometry.type !== 'profile' || geometry.values.every((a, k) => a > 0 || !inside(geometry.x[k]))),
340
+ 'geometry: the cross-section vanishes inside the device; only an end may have A = 0',
341
+ );
325
342
 
326
343
  // Port windows: the nodes of the port's region within [from, to] of its left end.
327
344
  ports.forEach((port, k) => {
@@ -336,7 +353,7 @@ export function normalizeDevice(def) {
336
353
  });
337
354
 
338
355
  return {
339
- T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports,
356
+ T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
340
357
  };
341
358
  }
342
359
 
@@ -433,12 +450,17 @@ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
433
450
  // r = k0 Π_{ν<0} (c/c_ref)^{|ν|(1−α)} Π_{ν>0} (c/c_ref)^{να} (e^{αa} − e^{−(1−α)a})
434
451
  // (a conductor's carrier has activity 1, so no factor). That's mass action with rate constants
435
452
  // that depend on the electrical part of the affinity, and exactly zero at A = 0.
453
+ // Or saturating (a transporter or an enzyme that turns over at most vmax per area): with K a
454
+ // half-saturation concentration for each species the forward reaction consumes,
455
+ // r = vmax Π_{ν<0} (c/(c + K))^{|ν|} (1 − e^{−a}),
456
+ // Michaelis–Menten in each substrate, and still exactly zero at A = 0 (the backward rate is the
457
+ // one detailed balance implies).
436
458
  function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT) {
437
459
  need(idef.reactions === undefined || Array.isArray(idef.reactions), `${where}.reactions must be an array`);
438
460
  return (idef.reactions ?? []).map((rdef, k) => {
439
461
  const rpath = `${where}.reactions[${k}]`;
440
462
  need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
441
- fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh']);
463
+ fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh', 'vmax', 'K']);
442
464
  let sides = rdef;
443
465
  if (rdef.equation !== undefined) {
444
466
  need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
@@ -458,6 +480,22 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
458
480
  }
459
481
  need(part.length > 0, `${rpath}: no species participate`);
460
482
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
483
+ if (rdef.vmax !== undefined || rdef.K !== undefined) {
484
+ need(rdef.k0 === undefined && rdef.alpha === undefined && rdef.srh === undefined, `${rpath}: give vmax and K (saturating), srh, or k0 and alpha (Butler–Volmer), one of them`);
485
+ const vmax = positive(rdef.vmax, `${rpath}.vmax (mol/(m²·s))`);
486
+ need(isObject(rdef.K), `${rpath}.K must map each species the forward reaction consumes to its half-saturation concentration (mol/m³)`);
487
+ for (const name of Object.keys(rdef.K)) {
488
+ need(part.some((p) => p.nu < 0 && species[p.i].name === name), `${rpath}.K.${name}: not a species the forward reaction consumes`);
489
+ positive(rdef.K[name], `${rpath}.K.${name} (mol/m³)`);
490
+ }
491
+ for (const p of part) {
492
+ if (p.nu >= 0) continue;
493
+ const mat = p.side ? matR : matL;
494
+ need(!mat.conductor, `${rpath}: saturating kinetics is for dissolved substrates, not a metal's ${species[p.i].name}`);
495
+ need(rdef.K[species[p.i].name] !== undefined, `${rpath}.K.${species[p.i].name}: give a half-saturation concentration for every species the forward reaction consumes`);
496
+ }
497
+ return { part, fixedA, vmax, K: part.map((p) => (p.nu < 0 ? rdef.K[species[p.i].name] : 0)) };
498
+ }
461
499
  if (rdef.srh !== undefined) {
462
500
  need(rdef.k0 === undefined && rdef.alpha === undefined, `${rpath}: give srh or k0 (and alpha), not both`);
463
501
  const srh = srhLaw(rdef.srh, `${rpath}.srh`, part, species, ['vn', 'vp']);
@@ -534,6 +572,7 @@ export function normalizeDrives(def, model) {
534
572
  for (const [k, side] of [[0, 'left'], [1, 'right']]) {
535
573
  need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
536
574
  }
575
+ 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`));
537
576
  need(drives.some((d) => d.kind === 'V'), "every terminal is driven by a current, so the device's overall level floats: hold at least one at a voltage V");
538
577
  return drives;
539
578
  }
@@ -580,15 +619,37 @@ export function nextBreakpoint(src, t) {
580
619
  return Infinity;
581
620
  }
582
621
 
622
+ // The device's cross-section A(x): planar (the default, A = 1 m², so currents read as densities),
623
+ // spherical or cylindrical about a centre r0 to the left of x = 0 (r = r0 + x), or a profile
624
+ // { x, values } of A in m². Every flux, current and amount is then a total through A.
625
+ function normalizeGeometry(g) {
626
+ if (g === undefined || g === 'planar') return { type: 'planar' };
627
+ if (g === 'spherical' || g === 'cylindrical') g = { type: g };
628
+ need(isObject(g), "geometry must be 'planar', 'spherical', 'cylindrical', { type, r0 } or { area: { x, values } }");
629
+ if (g.area !== undefined) {
630
+ fields(g, 'geometry', ['area']);
631
+ const p = profile(g.area, 'geometry.area', 'cross-sections (m²)', (a) => a >= 0, 'a cross-section ≥ 0 (m²)');
632
+ need([...p.values].some((a) => a > 0), 'geometry.area: the cross-section is zero everywhere');
633
+ return { type: 'profile', x: p.x, values: p.values };
634
+ }
635
+ fields(g, 'geometry', ['type', 'r0']);
636
+ need(['planar', 'spherical', 'cylindrical'].includes(g.type), "geometry.type must be 'planar', 'spherical' or 'cylindrical'");
637
+ if (g.type === 'planar') return { type: 'planar' };
638
+ const r0 = g.r0 === undefined ? 0 : nonNegative(g.r0, 'geometry.r0 (m, the radius at x = 0)');
639
+ return { type: g.type, r0 };
640
+ }
641
+
583
642
  // An internal port: an outside phase with known levels (V_i = V + offset_i, or μ for neutral
584
643
  // species, as at a contact), exchanging with every node in a window of one region.
585
644
  // Links per species: 'equilibrium' (μ̄ held at the outside level throughout the window),
586
645
  // { type: 'conductance', G } for charged species (G in S/m³: a source G (V_out − V_i)/(zF) per
587
646
  // volume), { type: 'exchange', k, mu } for neutral ones (k in mol/(m³·s): a source
588
- // k (μ_out − μ)/RT per volume), or 'blocked' (the default).
589
- function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
647
+ // k (μ_out − μ)/RT per volume), or 'blocked' (the default). And reactions with the port's
648
+ // terminal species, a metal's carrier at the port's level: an electrode surface spread through
649
+ // the window, `area` (m²/m³) of it per volume.
650
+ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
590
651
  need(isObject(pdef), `${path} must be an object`);
591
- fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species']);
652
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area']);
592
653
  let r;
593
654
  if (Number.isInteger(pdef.region)) r = pdef.region;
594
655
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -605,9 +666,10 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
605
666
  need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
606
667
  terminal = speciesIndex.get(pdef.terminal);
607
668
  }
608
- need(isObject(pdef.species), `${path}.species must map species names to port links`);
669
+ const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT);
670
+ need(pdef.species === undefined ? reactions.length > 0 : isObject(pdef.species), `${path}.species must map species names to port links`);
609
671
  const links = species.map(() => ({ type: 'blocked' }));
610
- for (const [sname, raw] of Object.entries(pdef.species)) {
672
+ for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
611
673
  const lpath = `${path}.species.${sname}`;
612
674
  need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
613
675
  const i = speciesIndex.get(sname);
@@ -635,8 +697,49 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
635
697
  if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
636
698
  links[i] = { type: link.type, ...level };
637
699
  }
638
- need(links.some((l) => l.type !== 'blocked'), `${path}.species: the port exchanges no species`);
639
- return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links };
700
+ need(links.some((l) => l.type !== 'blocked') || reactions.length > 0, `${path}.species: the port exchanges no species`);
701
+ // One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
702
+ // nobody's business: it can't be driven by one.
703
+ const passes = reactions.length > 0 || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
704
+ need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
705
+ let area = null;
706
+ if (reactions.length > 0) {
707
+ need(pdef.area !== undefined, `${path}.area: give the electrode's area per volume of the window (m²/m³, e.g. 1/h for a film of thickness h on it), a number or a profile`);
708
+ area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
709
+ } else need(pdef.area === undefined, `${path}.area: only a port with reactions has an electrode area`);
710
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area };
711
+ }
712
+
713
+ // A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
714
+ // port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
715
+ // electrode: part side 0 for the region's species, side 1 for the carrier.
716
+ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT) {
717
+ need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
718
+ const list = pdef.reactions ?? [];
719
+ if (list.length === 0) return [];
720
+ need(!mat.conductor, `${path}.reactions: a port on a conductor is a wire; reactions belong on a face, or on a port in the solution`);
721
+ need(terminal !== null && species[terminal].z !== 0, `${path}.terminal: a port with reactions needs its electrode's carrier as its terminal species (e.g. 'e-')`);
722
+ need(!mat.present[terminal], `${path}.terminal: '${species[terminal].name}' is in the port's region too, so a reaction can't tell the electrode's from the region's`);
723
+ const carrier = species[terminal].name;
724
+ return list.map((rdef, k) => {
725
+ const rpath = `${path}.reactions[${k}]`;
726
+ need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
727
+ fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha']);
728
+ const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
729
+ const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier, conductor: true }, rdef.fixed, `${rpath}.equation`);
730
+ const part = [];
731
+ let fixedA = 0, charge = 0;
732
+ for (const [key, side] of [['left', 0], ['right', 1]]) {
733
+ const st = stoichiometry(sides[key], `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
734
+ for (const { i, nu } of st.list) part.push({ i, nu, side });
735
+ fixedA += st.fixedA;
736
+ charge += st.charge;
737
+ }
738
+ need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
739
+ need(part.some((p) => p.side === 0), `${rpath}: no species of the port's region takes part`);
740
+ need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
741
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
742
+ });
640
743
  }
641
744
 
642
745
  function checkAnchors(regions, materials, interfaces, contacts, species, ports = []) {
@@ -659,7 +762,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
659
762
  const anchored = new Set();
660
763
  if (anchors(contacts.left)) anchored.add(find(0));
661
764
  if (anchors(contacts.right)) anchored.add(find(nR - 1));
662
- for (const port of ports) if (port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
765
+ for (const port of ports) if (port.reactions.length > 0 || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
663
766
  for (let r = 0; r < nR; r++) {
664
767
  const mat = materials[regions[r].material];
665
768
  const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
@@ -741,7 +844,8 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
741
844
  const sheetCharge = idef.sheetCharge === undefined ? 0 : finite(idef.sheetCharge, `${where}.sheetCharge`);
742
845
 
743
846
  // Per-species laws across the face: local equilibrium (default where present on both sides),
744
- // blocked, or an ohmic interface conductance G (S/m²).
847
+ // blocked, an ohmic interface conductance G (S/m²), or a permeability P (m/s): electrodiffusion
848
+ // through a thin membrane in a constant field, Goldman–Hodgkin–Katz.
745
849
  const links = defaultInterfaceLinks(matL, matR, species);
746
850
  if (idef.species !== undefined) {
747
851
  need(isObject(idef.species), `${where}.species must map species names to interface links`);
@@ -751,12 +855,16 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
751
855
  const i = speciesIndex.get(sname);
752
856
  const link = typeof raw === 'string' ? { type: raw } : raw;
753
857
  need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
754
- fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
858
+ fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : link.type === 'permeability' ? ['type', 'P'] : ['type']);
755
859
  if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
756
860
  if (link.type === 'conductance') {
757
861
  need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
758
862
  positive(link.G, `${lpath}.G`);
759
863
  }
864
+ if (link.type === 'permeability') {
865
+ positive(link.P, `${lpath}.P (m/s)`);
866
+ need(matL.modelOf[i] < 0 && matR.modelOf[i] < 0, `${lpath}: a permeability link needs ideal (dilute) statistics for '${sname}' on both sides`);
867
+ }
760
868
  links[i] = { ...link };
761
869
  }
762
870
  }
@@ -847,6 +955,7 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
847
955
  const i = speciesIndex.get(sname);
848
956
  const link = typeof raw === 'string' ? { type: raw } : raw;
849
957
  need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
958
+ need(link.type !== 'permeability', `${lpath}: a permeability link is for a membrane between two solutions, not at a conductor`);
850
959
  fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
851
960
  if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
852
961
  if (link.type === 'conductance') {
package/src/grid.js CHANGED
@@ -109,3 +109,76 @@ export function buildGrid(regions, opts = {}) {
109
109
  length: x0,
110
110
  };
111
111
  }
112
+
113
+ // The cross-section A(x) (m²), for a device that isn't planar: the conservation laws hold for
114
+ // the totals through it, flux × A, and a box holds its volume ∫A dx. A planar device has A = 1
115
+ // everywhere, and its currents read as densities.
116
+ // spherical: A = 4π r², cylindrical: A = 2π r (per metre of length), r = r0 + x;
117
+ // a profile: A(x) piecewise linear through the points given, constant beyond them.
118
+ function crossSection(geometry) {
119
+ const { type } = geometry;
120
+ if (type === 'spherical' || type === 'cylindrical') {
121
+ const r0 = geometry.r0, sph = type === 'spherical';
122
+ const sq = (x) => (r0 + x) * (r0 + x), cube = (x) => (r0 + x) * (r0 + x) * (r0 + x);
123
+ return {
124
+ at: (x) => (sph ? 4 * Math.PI * sq(x) : 2 * Math.PI * (r0 + x)),
125
+ // ∫A dx and ∫dx/A from a to b
126
+ integral: (a, b) => (sph ? (4 * Math.PI * (cube(b) - cube(a))) / 3 : Math.PI * (sq(b) - sq(a))),
127
+ resistance: (a, b) => (sph ? (1 / (r0 + a) - 1 / (r0 + b)) / (4 * Math.PI) : Math.log((r0 + b) / (r0 + a)) / (2 * Math.PI)),
128
+ };
129
+ }
130
+ const xs = geometry.x, vs = geometry.values, last = xs.length - 1;
131
+ const at = (x) => {
132
+ if (x <= xs[0]) return vs[0];
133
+ if (x >= xs[last]) return vs[last];
134
+ let k = 0;
135
+ while (xs[k + 1] < x) k++;
136
+ return vs[k] + ((vs[k + 1] - vs[k]) * (x - xs[k])) / (xs[k + 1] - xs[k]);
137
+ };
138
+ // Over [a, b], split at the profile's points, where A is linear on each piece.
139
+ const pieces = (a, b, f) => {
140
+ let s = 0, lo = a;
141
+ for (const xk of [...xs.filter((v) => v > a && v < b), b]) {
142
+ s += f(lo, xk, at(lo), at(xk));
143
+ lo = xk;
144
+ }
145
+ return s;
146
+ };
147
+ return {
148
+ at,
149
+ integral: (a, b) => pieces(a, b, (p, q, A1, A2) => ((A1 + A2) / 2) * (q - p)),
150
+ resistance: (a, b) =>
151
+ pieces(a, b, (p, q, A1, A2) => {
152
+ const d = A2 - A1, m = (A1 + A2) / 2;
153
+ return Math.abs(d) < 1e-6 * m ? (q - p) / m : ((q - p) * Math.log(A2 / A1)) / d;
154
+ }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Give a grid its cross-section: `area` (A at each node, m²), `segArea` (each segment's
160
+ * effective area, its length over ∫dx/A, so that a flux D·segArea·Δc/h is exact for steady
161
+ * diffusion between the nodes; A at its middle where A vanishes at an end, as at a sphere's
162
+ * centre) and `vol` (each node's box, ∫A dx). Planar (no geometry): A = 1, and nothing changes.
163
+ * @param {ReturnType<typeof buildGrid>} grid
164
+ * @param {{ type: 'planar' } | { type: 'spherical' | 'cylindrical', r0: number } | { type: 'profile', x: ArrayLike<number>, values: ArrayLike<number> }} geometry
165
+ */
166
+ export function applyGeometry(grid, geometry) {
167
+ const { nNodes, x, segLength } = grid;
168
+ grid.area = new Float64Array(nNodes).fill(1);
169
+ grid.segArea = new Float64Array(nNodes - 1).fill(1);
170
+ if (!geometry || geometry.type === 'planar') return grid;
171
+ const A = crossSection(geometry);
172
+ for (let g = 0; g < nNodes; g++) grid.area[g] = A.at(x[g]);
173
+ for (let s = 0; s < nNodes - 1; s++) {
174
+ const h = segLength[s];
175
+ if (h === 0) grid.segArea[s] = grid.area[s];
176
+ else if (grid.area[s] > 0 && grid.area[s + 1] > 0) grid.segArea[s] = h / A.resistance(x[s], x[s + 1]);
177
+ else grid.segArea[s] = A.at(x[s] + h / 2);
178
+ }
179
+ for (let g = 0; g < nNodes; g++) {
180
+ const lo = g > 0 ? x[g] - segLength[g - 1] / 2 : x[g], hi = g < nNodes - 1 ? x[g] + segLength[g] / 2 : x[g];
181
+ grid.vol[g] = hi > lo ? A.integral(lo, hi) : 0;
182
+ }
183
+ return grid;
184
+ }
package/src/index.js CHANGED
@@ -160,7 +160,7 @@ export class Device {
160
160
  * (one array per probe, alongside `trace.t`): what a detector at x sees.
161
161
  * @param {number} tEnd s
162
162
  * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
163
- * probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
163
+ * probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
164
164
  * @returns {Solution}
165
165
  */
166
166
  advance(tEnd, opts) {