driftlet 0.5.0 → 0.7.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 ADDED
@@ -0,0 +1,302 @@
1
+ // Checks on a solved device, for driftlet/kit: what any result should satisfy, worked out from the
2
+ // definition and the solution, so a page can show that its numbers hold up without its author
3
+ // writing each check by hand. Each check says what it compared and whether it passed.
4
+
5
+ import { Device } from './index.js';
6
+ import { FARADAY } from './constants.js';
7
+ import { unitWarnings } from './describe.js';
8
+
9
+ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs(v) >= 1e4 || Math.abs(v) < 1e-2 ? v.toExponential(p - 1) : String(+v.toPrecision(p)));
10
+
11
+ /**
12
+ * @typedef {object} CheckItem
13
+ * @property {string} name what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
14
+ * @property {boolean | null} ok whether it passed (null: look at it, a warning or a check that
15
+ * couldn't be made)
16
+ * @property {string} summary one line
17
+ * @property {object} [details] the numbers behind it
18
+ */
19
+
20
+ /**
21
+ * Check a device's present state (pass the solution solve() or advance() returned, for its
22
+ * convergence and warnings):
23
+ * - **converged**: the last solve converged.
24
+ * - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
25
+ * parts) and the definition's likely unit slips.
26
+ * - **balance** (a steady state): each species' ledger, what comes in through each terminal and
27
+ * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
28
+ * species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
29
+ * and it says where every carrier went.
30
+ * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
+ * what came in.
32
+ * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
33
+ * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
34
+ * region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
35
+ * wants less). The discretisation is second order, so the change estimates this grid's error
36
+ * (about 3/4 of it).
37
+ * @param {import('./index.js').Device} device
38
+ * @param {object} [sol] the solution to check (default: the device's present state)
39
+ * @param {{ refine?: boolean, tol?: number }} [opts]
40
+ * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
41
+ * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
42
+ * @returns {{ ok: boolean, items: CheckItem[], text: string }}
43
+ */
44
+ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
45
+ sol ??= device.solution();
46
+ const { model, solver, def } = device;
47
+ const steady = sol.steady ?? solver.atSteady;
48
+ const items = [];
49
+
50
+ items.push(
51
+ sol.converged
52
+ ? { name: 'converged', ok: true, summary: `${steady ? 'steady state' : `transient at t = ${sig(sol.time)} s`}, converged` }
53
+ : { name: 'converged', ok: false, summary: "the solve didn't converge: these numbers aren't a solution" },
54
+ );
55
+
56
+ const warnings = [...sol.warnings, ...unitWarnings(def)];
57
+ // Heuristics, so a prompt to look (null) rather than a failure.
58
+ items.push({
59
+ name: 'warnings',
60
+ ok: warnings.length === 0 ? true : null,
61
+ summary: warnings.length === 0 ? 'none' : `${warnings.length}: ${warnings.join(' · ')}`,
62
+ details: { warnings },
63
+ });
64
+
65
+ // The rest checks a solution, which an unconverged state isn't.
66
+ if (sol.converged) {
67
+ const ledger = steady ? balance(device, sol) : null;
68
+ items.push(ledger ?? conservation(sol));
69
+ if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
70
+ }
71
+
72
+ const ok = sol.converged && items.every((it) => it.ok !== false);
73
+ const mark = (it) => (it.ok === true ? 'ok ' : it.ok === false ? 'FAIL' : '? ');
74
+ const text = items.map((it) => `${mark(it)} ${it.name}: ${it.summary}${it.details?.lines?.length ? `\n${it.details.lines.map((l) => ` ${l}`).join('\n')}` : ''}`).join('\n');
75
+ return { ok, items, text };
76
+ }
77
+
78
+ // Each species' steady ledger: in through the terminals, made by the reactions. Rates in
79
+ // mol/(m²·s), positive into the device or made.
80
+ function balance(device, sol) {
81
+ const { model, def } = device, { species, regions, interfaces } = model;
82
+ const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
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 });
102
+ };
103
+ const last = regions.length - 1;
104
+ species.forEach((sp, i) => {
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]));
108
+ });
109
+ model.reactions.forEach((rx, k) => {
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
+ });
115
+ });
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
+ });
125
+ itf.reactions.forEach((rx, k) => {
126
+ const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
127
+ for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
128
+ });
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
+ }
137
+
138
+ // A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
139
+ const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
140
+ const natural = naturalScales(device, sol);
141
+ const moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
142
+ let worst = 0;
143
+ const lines = [];
144
+ for (const l of moving) {
145
+ const sum = l.terms.reduce((a, t) => a + t.rate, 0);
146
+ l.residual = sum / size(l);
147
+ worst = Math.max(worst, Math.abs(l.residual));
148
+ // Terms that matter, largest first; a charged species' as a current too.
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));
150
+ const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
151
+ lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
152
+ }
153
+ const ok = worst < 1e-6;
154
+ const summary =
155
+ moving.length === 0
156
+ ? 'nothing flows or reacts (equilibrium)'
157
+ : `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, mol/(m²·s), + in or made`;
158
+ // The largest current any charged species carries anywhere: the scale a net current is read against.
159
+ const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
160
+ return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
161
+ }
162
+
163
+ // What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
164
+ // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
165
+ // flows are round-off of these.
166
+ function naturalScales(device, sol) {
167
+ const { model, solver } = device, { species } = model;
168
+ 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;
169
+ return species.map((sp, i) => {
170
+ let S = 0;
171
+ model.regions.forEach((reg, r) => {
172
+ const mat = model.materials[reg.material], L = reg.length;
173
+ if (mat.conductor) {
174
+ if (mat.conductor.i === i) S = Math.max(S, (mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
175
+ return;
176
+ }
177
+ if (!mat.present[i] || !(mat.D[i] > 0)) return;
178
+ let cmax = 0;
179
+ 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]);
180
+ S = Math.max(S, (mat.D[i] * cmax) / L);
181
+ });
182
+ solver.rxs.forEach((rx) => {
183
+ const p = rx.sp.indexOf(i);
184
+ if (p < 0) return;
185
+ let gross = 0;
186
+ for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += box(g) * solver.bulkOneWay(rx, g);
187
+ S = Math.max(S, Math.abs(rx.nu[p]) * gross);
188
+ });
189
+ return S;
190
+ });
191
+ }
192
+
193
+ // Closed, unreacting stretches keep their amounts in a transient.
194
+ function conservation(sol) {
195
+ const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
196
+ if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
197
+ // Relative to the amount, but a drift below 1e-20 mol/m² (1e-15 C/m²) is nothing: a floating
198
+ // metal's excess electrons are themselves ~0.
199
+ const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 ? 0 : Math.abs(st.drift))));
200
+ return {
201
+ name: 'conservation',
202
+ ok: worst < 1e-8,
203
+ summary: `${closed.length} closed stretch${closed.length > 1 ? 'es' : ''} (a species held between blocking faces) kept ${worst < 1e-8 ? `to ${sig(worst, 1)}` : `only to ${sig(worst, 2)}`} of what ${closed.length > 1 ? 'they' : 'it'} held plus what came in`,
204
+ details: { worst },
205
+ };
206
+ }
207
+
208
+ // The same device on a grid twice as fine (half the cells' sizes, the square root of the
209
+ // grading ratio), solved from this solution interpolated onto it, against this solution.
210
+ function gridCheck(device, sol, tol, gross) {
211
+ const { def, model } = device;
212
+ const fine = {
213
+ ...def,
214
+ regions: def.regions.map((reg, r) => {
215
+ if (model.materials[model.regions[r].material].conductor) return reg;
216
+ const g = { ...(def.grid ?? {}), ...(reg.grid ?? {}) };
217
+ if (def.grid === undefined && reg.grid === undefined) Object.assign(g, { hmin: reg.length / 1000, hmax: reg.length / 20 });
218
+ const out = { ratio: Math.sqrt(g.ratio ?? 1.2), minCells: 2 * (g.minCells ?? 8) };
219
+ if (g.hmin !== undefined) out.hmin = g.hmin / 2;
220
+ if (g.hmax !== undefined) out.hmax = g.hmax / 2;
221
+ return { ...reg, grid: out };
222
+ }),
223
+ };
224
+ // Started from this solution, interpolated onto the finer grid; failing that, from cold.
225
+ let other;
226
+ try {
227
+ const dev = new Device(fine);
228
+ dev.solver._warmFrom(device.solver);
229
+ other = dev.solve();
230
+ if (!other.converged) {
231
+ const cold = new Device(fine);
232
+ if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
233
+ other = cold.solve();
234
+ }
235
+ } catch (e) {
236
+ return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
237
+ }
238
+ if (!other.converged) return { name: 'grid', ok: null, summary: "the device on a twice-finer grid didn't solve; check resolution by hand" };
239
+
240
+ const diffs = [];
241
+ // The current, against the largest current any species carries (so at open circuit, or in
242
+ // equilibrium, round-off isn't read as a change).
243
+ const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
244
+ if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: 'A/m²' });
245
+ // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
246
+ const VT = model.RT / FARADAY;
247
+ model.terminals.forEach((t, k) => {
248
+ if (device.solver.floating.includes(k)) {
249
+ const a = sol.terminals[t.name].V, b = other.terminals[t.name].V;
250
+ diffs.push({ what: `${t.name} voltage`, a, b, rel: Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b), VT), unit: 'V' });
251
+ }
252
+ });
253
+ // Each region's charge (C/m²), against the largest: summed over the nodes' boxes, as the
254
+ // solver's Gauss law counts it.
255
+ const charges = (s) => s.regions.map((_, r) => {
256
+ let q = 0;
257
+ for (let g = 0; g < s.x.length; g++) {
258
+ if (s.region[g] !== r) continue;
259
+ 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;
260
+ q += rho(s, g, model, r) * ((left + right) / 2);
261
+ }
262
+ return q;
263
+ });
264
+ // Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
265
+ // the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
266
+ const content = Math.max(...sol.regions.map((reg, r) => {
267
+ let q = 0;
268
+ for (let g = 0; g < sol.x.length; g++) {
269
+ if (sol.region[g] !== r) continue;
270
+ let a = Math.abs(model.regions[r].fixedCharge ?? 0);
271
+ 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]);
272
+ q = Math.max(q, a);
273
+ }
274
+ return q * (reg.x1 - reg.x0);
275
+ }));
276
+ const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
277
+ if (scaleQ > 1e-6 * content) {
278
+ const named = (reg) => (/^region \d+$/.test(reg.name) ? `${reg.name} (${reg.material})` : reg.name);
279
+ qa.forEach((a, r) => diffs.push({ what: `${named(sol.regions[r])} charge`, a, b: qb[r], rel: Math.abs(qb[r] - a) / scaleQ, unit: 'C/m²' }));
280
+ }
281
+ const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
282
+ if (!worst) return { name: 'grid', ok: true, summary: 'nothing to compare (no current, charge or floating voltage)' };
283
+ const ok = worst.rel <= tol;
284
+ return {
285
+ name: 'grid',
286
+ ok,
287
+ summary:
288
+ `on a grid twice as fine (${sol.x.length} → ${other.x.length} nodes), the largest change is ${worst.what}, ${sig(worst.a, 5)} → ${sig(worst.b, 5)} ${worst.unit} ` +
289
+ `(${sig(worst.rel, 2)}${ok ? '' : `, over ${sig(tol, 1)}: refine the grid (hmin, hmax), most likely where profiles are steepest`})`,
290
+ details: { diffs, nodes: [sol.x.length, other.x.length] },
291
+ };
292
+ }
293
+
294
+ // Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
295
+ function rho(s, g, model, r) {
296
+ let q = model.regions[r].fixedCharge ?? 0;
297
+ for (const sp of model.species) {
298
+ const c = s.c[sp.name][g];
299
+ if (Number.isFinite(c)) q += FARADAY * sp.z * c;
300
+ }
301
+ return q;
302
+ }
package/src/describe.js CHANGED
@@ -141,13 +141,17 @@ export function describe(def) {
141
141
  if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
142
142
  // The screening concentration: Σ z²c from the initial composition, plus the carriers that
143
143
  // balance the fixed charge (doping), which c0 doesn't list.
144
- let zzc = 0;
144
+ let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
145
145
  species.forEach((sp, i) => {
146
146
  if (!mat.present[i]) return;
147
- if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
148
- else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].values);
147
+ const c = Number.isFinite(reg.c0[i]) ? reg.c0[i] : reg.c0Profile[i] ? Math.max(...reg.c0Profile[i].values) : 0;
148
+ zzc += sp.z * sp.z * c;
149
+ q += sp.z * c;
149
150
  });
150
- zzc += Math.abs(reg.fixedCharge) / FARADAY; // the mobile charge that balances the doping
151
+ // Plus the mobile charge that balances whatever c0 leaves unbalanced: a doped layer's
152
+ // carriers, which c0 doesn't list (and nothing, where c0 lists them, as a perovskite's
153
+ // vacancies over their background).
154
+ zzc += Math.abs(q);
151
155
  const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
152
156
  const Dmin = Math.min(...species.map((sp, i) => (mat.present[i] && mat.D[i] > 0 ? mat.D[i] : Infinity)));
153
157
  if (mat.epsr > 0 && zzc > 0) {
@@ -182,8 +186,15 @@ export function describe(def) {
182
186
  const law = itf.phi.type === 'capacitive' ? `capacitive, C = ${num(itf.phi.C)} F/m²` : itf.phi.type;
183
187
  const align = itf.conductor ? (itf.phi.type === 'capacitive' ? `, zeroCharge ${num(itf.zeroCharge)} V` : '') : itf.phi.type === 'neutral' ? '' : `, dipole ${num(itf.dipole)} V`;
184
188
  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);
185
- const rx = (idef.reactions ?? []).map((r) => r.equation ?? equation(r.left, r.right));
186
- lines.push(` ${L.name} | ${R.name}: ${law}${align}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`);
189
+ const kinetics = (r) => (r.vmax !== undefined ? ` (saturating, vmax ${num(r.vmax)} mol/(m²·s))` : r.srh ? ' (SRH)' : '');
190
+ const rx = (idef.reactions ?? []).map((r) => (r.equation ?? equation(r.left, r.right)) + kinetics(r));
191
+ const laws = species.flatMap((sp, i) => {
192
+ const l = itf.links[i];
193
+ return l.type === 'permeability' ? [`${sp.name} ${num(l.P)} m/s`] : l.type === 'conductance' ? [`${sp.name} ${num(l.G)} S/m²`] : [];
194
+ });
195
+ lines.push(
196
+ ` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`,
197
+ );
187
198
  });
188
199
  }
189
200
 
@@ -198,7 +209,7 @@ export function describe(def) {
198
209
  (def.ports ?? []).forEach((p, k) => lines.push(` port ${model.ports[k].name}: ${drive(p)}, in ${regions[model.ports[k].region].name}`));
199
210
  if ((def.bulkReactions ?? []).length) {
200
211
  lines.push('bulk reactions:');
201
- for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)} in ${Object.keys(rx.kf ?? {}).join(', ')}`);
212
+ for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);
202
213
  }
203
214
  if (times.length) lines.push('time scales:', ...times);
204
215
  if (warnings.length) lines.push('warnings:', ...warnings.map((w) => ` ${w}`));
package/src/device.js CHANGED
@@ -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).
@@ -433,12 +433,17 @@ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
433
433
  // r = k0 Π_{ν<0} (c/c_ref)^{|ν|(1−α)} Π_{ν>0} (c/c_ref)^{να} (e^{αa} − e^{−(1−α)a})
434
434
  // (a conductor's carrier has activity 1, so no factor). That's mass action with rate constants
435
435
  // that depend on the electrical part of the affinity, and exactly zero at A = 0.
436
+ // Or saturating (a transporter or an enzyme that turns over at most vmax per area): with K a
437
+ // half-saturation concentration for each species the forward reaction consumes,
438
+ // r = vmax Π_{ν<0} (c/(c + K))^{|ν|} (1 − e^{−a}),
439
+ // Michaelis–Menten in each substrate, and still exactly zero at A = 0 (the backward rate is the
440
+ // one detailed balance implies).
436
441
  function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT) {
437
442
  need(idef.reactions === undefined || Array.isArray(idef.reactions), `${where}.reactions must be an array`);
438
443
  return (idef.reactions ?? []).map((rdef, k) => {
439
444
  const rpath = `${where}.reactions[${k}]`;
440
445
  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']);
446
+ fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh', 'vmax', 'K']);
442
447
  let sides = rdef;
443
448
  if (rdef.equation !== undefined) {
444
449
  need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
@@ -458,6 +463,22 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
458
463
  }
459
464
  need(part.length > 0, `${rpath}: no species participate`);
460
465
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
466
+ if (rdef.vmax !== undefined || rdef.K !== undefined) {
467
+ 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`);
468
+ const vmax = positive(rdef.vmax, `${rpath}.vmax (mol/(m²·s))`);
469
+ need(isObject(rdef.K), `${rpath}.K must map each species the forward reaction consumes to its half-saturation concentration (mol/m³)`);
470
+ for (const name of Object.keys(rdef.K)) {
471
+ need(part.some((p) => p.nu < 0 && species[p.i].name === name), `${rpath}.K.${name}: not a species the forward reaction consumes`);
472
+ positive(rdef.K[name], `${rpath}.K.${name} (mol/m³)`);
473
+ }
474
+ for (const p of part) {
475
+ if (p.nu >= 0) continue;
476
+ const mat = p.side ? matR : matL;
477
+ need(!mat.conductor, `${rpath}: saturating kinetics is for dissolved substrates, not a metal's ${species[p.i].name}`);
478
+ 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`);
479
+ }
480
+ return { part, fixedA, vmax, K: part.map((p) => (p.nu < 0 ? rdef.K[species[p.i].name] : 0)) };
481
+ }
461
482
  if (rdef.srh !== undefined) {
462
483
  need(rdef.k0 === undefined && rdef.alpha === undefined, `${rpath}: give srh or k0 (and alpha), not both`);
463
484
  const srh = srhLaw(rdef.srh, `${rpath}.srh`, part, species, ['vn', 'vp']);
@@ -741,7 +762,8 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
741
762
  const sheetCharge = idef.sheetCharge === undefined ? 0 : finite(idef.sheetCharge, `${where}.sheetCharge`);
742
763
 
743
764
  // Per-species laws across the face: local equilibrium (default where present on both sides),
744
- // blocked, or an ohmic interface conductance G (S/m²).
765
+ // blocked, an ohmic interface conductance G (S/m²), or a permeability P (m/s): electrodiffusion
766
+ // through a thin membrane in a constant field, Goldman–Hodgkin–Katz.
745
767
  const links = defaultInterfaceLinks(matL, matR, species);
746
768
  if (idef.species !== undefined) {
747
769
  need(isObject(idef.species), `${where}.species must map species names to interface links`);
@@ -751,12 +773,16 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
751
773
  const i = speciesIndex.get(sname);
752
774
  const link = typeof raw === 'string' ? { type: raw } : raw;
753
775
  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']);
776
+ fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : link.type === 'permeability' ? ['type', 'P'] : ['type']);
755
777
  if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
756
778
  if (link.type === 'conductance') {
757
779
  need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
758
780
  positive(link.G, `${lpath}.G`);
759
781
  }
782
+ if (link.type === 'permeability') {
783
+ positive(link.P, `${lpath}.P (m/s)`);
784
+ need(matL.modelOf[i] < 0 && matR.modelOf[i] < 0, `${lpath}: a permeability link needs ideal (dilute) statistics for '${sname}' on both sides`);
785
+ }
760
786
  links[i] = { ...link };
761
787
  }
762
788
  }
@@ -847,6 +873,7 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
847
873
  const i = speciesIndex.get(sname);
848
874
  const link = typeof raw === 'string' ? { type: raw } : raw;
849
875
  need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
876
+ need(link.type !== 'permeability', `${lpath}: a permeability link is for a membrane between two solutions, not at a conductor`);
850
877
  fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
851
878
  if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
852
879
  if (link.type === 'conductance') {
package/src/devices.js ADDED
@@ -0,0 +1,135 @@
1
+ // Curated devices, for driftlet/kit: whole definitions from a published parameter set (light and
2
+ // contacts included), with reference results from an independent code to check a run against.
3
+ // Each returns ordinary definition data, to print, edit and pass to new Device.
4
+
5
+ import { AVOGADRO, FARADAY, GAS_CONSTANT, units } from './constants.js';
6
+ import { photogeneration } from './sources.js';
7
+ import { drive } from './stack.js';
8
+
9
+ const m3 = (x) => x / AVOGADRO, eV = units.eV;
10
+
11
+ // IonMonger's default parameters (parameters_template.m, IonMonger 2.0): Courtier, Cave, Walker,
12
+ // Richardson & Foster, J. Comput. Electron. 18, 1435 (2019). Levels in eV on the vacuum scale,
13
+ // densities per m³, D in m²/s, lifetimes in s, recombination velocities in m/s.
14
+ const P = {
15
+ T: 298, b: 400e-9, eps: 24.1, alpha: 1.3e7, Ec: -3.7, Ev: -5.4, Dn: 1.7e-4, Dp: 1.7e-4, gc: 8.1e24, gv: 5.8e24,
16
+ N0: 1.6e25, DIinf: 6.5e-8, EAI: 0.58, // vacancy D = DIinf·exp(−EAI/kT), ≈ 1.0e-17 m²/s at 298 K
17
+ dE: 1e24, gcE: 5e25, EcE: -4.0, bE: 100e-9, epsE: 10, DE: 1e-5,
18
+ dH: 1e24, gvH: 5e25, EvH: -5.1, bH: 200e-9, epsH: 3, DH: 1e-6,
19
+ tn: 3e-9, tp: 3e-7, vnE: 1e5, vpE: 10, vnH: 0.1, vpH: 1e5, Fph: 1.4e21,
20
+ };
21
+
22
+ /**
23
+ * IonMonger's default planar perovskite solar cell: TiO₂ (100 nm) | MAPbI₃ (400 nm) with mobile
24
+ * iodide vacancies over an equal immobile background | spiro-OMeTAD (200 nm), at 298 K, lit
25
+ * through the TiO₂. Species e⁻, h⁺ and the vacancies 'V+'. Recombination is SRH at both faces
26
+ * (IonMonger's interface law is driftlet's face SRH, with n₁ from the TiO₂'s electron density)
27
+ * and, with `bulkSRH`, SRH through midgap traps in the perovskite (τₙ = 3 ns, τₚ = 300 ns).
28
+ * Light is 1.4e21 photons/(m²·s) at α = 1.3e7 /m (IonMonger's one sun), times `light`. The left
29
+ * contact is the TiO₂'s, at 0 V; the right is the spiro's, at `V`, so photocurrent is positive.
30
+ *
31
+ * `PEROVSKITE_SCANS` holds IonMonger's own results for this cell, scan by scan.
32
+ * @param {{ V?: import('./types.js').Source | { V?: import('./types.js').Source, I?: import('./types.js').Source, R?: number }, light?: number, bulkSRH?: boolean }} [opts]
33
+ * @returns {import('./types.js').DeviceDefinition}
34
+ */
35
+ export function perovskiteCell({ V = 1.2, light = 1, bulkSRH = true } = {}) {
36
+ const { T } = P, VT = (GAS_CONSTANT * T) / FARADAY;
37
+ const ni = Math.sqrt(P.gc * P.gv) * Math.exp(-(P.Ec - P.Ev) / (2 * VT));
38
+ const n0 = P.gc * Math.exp((P.EcE + VT * Math.log(P.dE / P.gcE) - P.Ec) / VT); // the perovskite's n at the TiO₂'s Fermi level
39
+ const DI = P.DIinf * Math.exp(-P.EAI / VT);
40
+ return {
41
+ T,
42
+ species: [
43
+ { name: 'e-', z: -1 },
44
+ { name: 'h+', z: 1 },
45
+ { name: 'V+', z: 1 }, // iodide vacancies
46
+ ],
47
+ materials: {
48
+ // Levels on the vacuum scale: μ° is the band edge's depth (minus it, for electrons).
49
+ 'TiO₂': { epsr: P.epsE, species: { 'e-': { D: P.DE, mu0: eV(P.EcE), cRef: m3(P.gcE) } } },
50
+ 'MAPbI₃': {
51
+ epsr: P.eps,
52
+ species: {
53
+ 'e-': { D: P.Dn, mu0: eV(P.Ec), cRef: m3(P.gc) },
54
+ 'h+': { D: P.Dp, mu0: eV(-P.Ev), cRef: m3(P.gv) },
55
+ 'V+': { D: DI, mu0: 0, cRef: m3(P.N0) },
56
+ },
57
+ },
58
+ 'spiro-OMeTAD': { epsr: P.epsH, species: { 'h+': { D: P.DH, mu0: eV(-P.EvH), cRef: m3(P.gvH) } } },
59
+ },
60
+ regions: [
61
+ { material: 'TiO₂', length: P.bE, fixedCharge: FARADAY * m3(P.dE) },
62
+ { material: 'MAPbI₃', length: P.b, fixedCharge: -FARADAY * m3(P.N0), c0: { 'V+': m3(P.N0) } },
63
+ { material: 'spiro-OMeTAD', length: P.bH, fixedCharge: -FARADAY * m3(P.dH) },
64
+ ],
65
+ interfaces: [
66
+ // Recombination through interface states (SRH), saturating at one carrier's capture: holes
67
+ // reaching the TiO₂ recombine at v_p however many electrons wait there.
68
+ { dipole: 0, species: { 'e-': 'equilibrium' }, reactions: [{ equation: 'e-(left) + h+ = 0', srh: { vn: P.vnE, vp: P.vpE, n1: m3((P.dE / n0) * ni) } }] },
69
+ { dipole: 0, species: { 'h+': 'equilibrium' }, reactions: [{ equation: 'e- + h+(right) = 0', srh: { vn: P.vnH, vp: P.vpH, n1: m3(ni) } }] },
70
+ ],
71
+ contacts: {
72
+ left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'bulk' },
73
+ right: { ...drive(V, 'perovskiteCell: V'), terminal: 'h+', species: { 'h+': 'equilibrium' }, phi: 'bulk' },
74
+ },
75
+ bulkReactions: [
76
+ ...(bulkSRH ? [{ equation: 'e- + h+ = 0', srh: { 'MAPbI₃': { tauN: P.tn, tauP: P.tp } } }] : []),
77
+ ...(light > 0 ? [photogeneration({ material: 'MAPbI₃', flux: m3(P.Fph * light), alpha: P.alpha, mu: eV(3), from: P.bE, to: P.bE + P.b })] : []),
78
+ ],
79
+ grid: { hmin: 0.05e-9, hmax: 5e-9, ratio: 1.15 },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * IonMonger's J–V scans of `perovskiteCell()` (IonMonger 2.0, commit 1ae4f9e, run in GNU Octave):
85
+ * steady state at 1.2 V under light, then 1.2 → 0 → 1.2 V at `rate` (V/s). `full` is the cell
86
+ * as given (bulk SRH on); `noBulkSRH` is `perovskiteCell({ bulkSRH: false })`. Per scan: J0, the
87
+ * current at 0 V (A/m²); each sweep's maximum power Pmax (W/m², the largest V·J among IonMonger's
88
+ * 201 output points per loop) and Voc (V, interpolated); and the hysteresis index
89
+ * hi = (Pmax,rev − Pmax,fwd)/Pmax,rev. `hysteresis()` reads the same from a run's trace.
90
+ * driftlet reproduces these to 0.15 % in hi, 0.5 W/m² in Pmax and 2 mV in Voc (its test suite).
91
+ */
92
+ export const PEROVSKITE_SCANS = Object.freeze({
93
+ source: 'IonMonger 2.0 (github.com/PerovskiteSCModelling/IonMonger, commit 1ae4f9e), default parameters',
94
+ full: [
95
+ { rate: 0.001, J0: 220.66, rev: { Pmax: 201.95, Voc: 1.09296 }, fwd: { Pmax: 200.42, Voc: 1.08517 }, hi: 0.00756 },
96
+ { rate: 0.01, J0: 220.89, rev: { Pmax: 210.97, Voc: 1.11157 }, fwd: { Pmax: 195.18, Voc: 1.06566 }, hi: 0.07483 },
97
+ { rate: 0.1, J0: 222.28, rev: { Pmax: 216.46, Voc: 1.11698 }, fwd: { Pmax: 144.85, Voc: 1.01277 }, hi: 0.33083 },
98
+ { rate: 1, J0: 223.01, rev: { Pmax: 217.02, Voc: 1.11712 }, fwd: { Pmax: 210.91, Voc: 1.08238 }, hi: 0.02815 },
99
+ { rate: 10, J0: 223.01, rev: { Pmax: 217.04, Voc: 1.11708 }, fwd: { Pmax: 216.55, Voc: 1.11509 }, hi: 0.00225 },
100
+ { rate: 100, J0: 223.06, rev: { Pmax: 217.09, Voc: 1.11708 }, fwd: { Pmax: 216.93, Voc: 1.11693 }, hi: 0.0007 },
101
+ { rate: 1000, J0: 223.54, rev: { Pmax: 217.58, Voc: 1.11714 }, fwd: { Pmax: 216.48, Voc: 1.11699 }, hi: 0.00505 },
102
+ ],
103
+ noBulkSRH: [
104
+ { rate: 0.001, J0: 220.96, rev: { Pmax: 203.06, Voc: 1.0957 }, fwd: { Pmax: 201.54, Voc: 1.08762 }, hi: 0.00751 },
105
+ { rate: 0.01, J0: 221.17, rev: { Pmax: 214.35, Voc: 1.1168 }, fwd: { Pmax: 196.01, Voc: 1.06737 }, hi: 0.08556 },
106
+ { rate: 0.1, J0: 222.43, rev: { Pmax: 221.67, Voc: 1.12199 }, fwd: { Pmax: 145.73, Voc: 1.01331 }, hi: 0.3426 },
107
+ { rate: 1, J0: 223.02, rev: { Pmax: 222.39, Voc: 1.12257 }, fwd: { Pmax: 213.41, Voc: 1.08376 }, hi: 0.04038 },
108
+ { rate: 10, J0: 223.02, rev: { Pmax: 222.54, Voc: 1.12263 }, fwd: { Pmax: 221.77, Voc: 1.11951 }, hi: 0.00348 },
109
+ { rate: 100, J0: 223.07, rev: { Pmax: 222.61, Voc: 1.12264 }, fwd: { Pmax: 222.38, Voc: 1.1223 }, hi: 0.00103 },
110
+ { rate: 1000, J0: 223.55, rev: { Pmax: 223.12, Voc: 1.12271 }, fwd: { Pmax: 221.98, Voc: 1.12252 }, hi: 0.00513 },
111
+ ],
112
+ });
113
+
114
+ /**
115
+ * A J–V loop's figures of merit, from a transient's trace (`{ t, voltage, current }`, as a
116
+ * recorder or advance() gives it): the loop splits at time `turn` into the sweep before (`rev`)
117
+ * and after (`fwd`). Each sweep's maximum power V·I (W/m², the largest among the trace's points)
118
+ * and its open-circuit voltage (where I changes sign, interpolated linearly); and the
119
+ * hysteresis index (Pmax,rev − Pmax,fwd)/Pmax,rev. Power out is positive, so a cell whose
120
+ * current runs the other way should have it negated first.
121
+ * @param {{ t: ArrayLike<number>, voltage: ArrayLike<number>, current: ArrayLike<number> }} trace
122
+ * @param {number} turn s
123
+ */
124
+ export function hysteresis(trace, turn) {
125
+ const out = {};
126
+ for (const [name, on] of [['rev', (t) => t <= turn], ['fwd', (t) => t >= turn]]) {
127
+ const pts = [];
128
+ for (let k = 0; k < trace.t.length; k++) if (on(trace.t[k]) && Number.isFinite(trace.current[k])) pts.push([trace.voltage[k], trace.current[k]]);
129
+ pts.sort((a, b) => a[0] - b[0]);
130
+ const k = pts.findIndex(([, I], j) => j > 0 && pts[j - 1][1] > 0 && I <= 0);
131
+ const Voc = k > 0 ? pts[k - 1][0] + (pts[k - 1][1] * (pts[k][0] - pts[k - 1][0])) / (pts[k - 1][1] - pts[k][1]) : NaN;
132
+ out[name] = { Pmax: Math.max(...pts.map(([V, I]) => V * I)), Voc };
133
+ }
134
+ return { ...out, hi: (out.rev.Pmax - out.fwd.Pmax) / out.rev.Pmax };
135
+ }
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) {