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