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/README.md +15 -6
- package/docs/device.md +138 -10
- package/docs/kit.md +58 -4
- package/docs/numerics.md +16 -0
- package/llms.txt +78 -15
- package/package.json +1 -1
- package/src/checks.js +59 -24
- package/src/describe.js +21 -3
- package/src/device.js +126 -17
- package/src/grid.js +73 -0
- package/src/index.js +1 -1
- package/src/junction.js +164 -0
- package/src/kit.js +2 -0
- package/src/polarization.js +84 -0
- package/src/recorder.js +1 -1
- package/src/solution.js +12 -4
- package/src/solver.js +240 -47
- package/src/types.js +15 -4
- package/types/grid.d.ts +29 -0
- package/types/index.d.ts +3 -3
- package/types/junction.d.ts +32 -0
- package/types/kit.d.ts +2 -0
- package/types/polarization.d.ts +31 -0
- package/types/recorder.d.ts +3 -3
- package/types/solver.d.ts +6 -2
- package/types/types.d.ts +41 -4
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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
|
|
190
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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) {
|