driftlet 0.4.0 → 0.6.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,268 @@
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 } = model;
82
+ const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
83
+ const ledgers = species.map((sp) => ({ species: sp.name, z: sp.z, terms: [] }));
84
+ const add = (i, what, rate) => {
85
+ if (rate !== 0 && Number.isFinite(rate)) ledgers[i].terms.push({ what, rate });
86
+ };
87
+ species.forEach((sp, i) => {
88
+ add(i, 'left contact', sol.contacts.left.flux[sp.name]);
89
+ add(i, 'right contact', -sol.contacts.right.flux[sp.name]);
90
+ sol.ports.forEach((p) => add(i, `port ${p.name}`, p.flux[sp.name]));
91
+ });
92
+ model.reactions.forEach((rx, k) => {
93
+ const total = sol.bulkReactions[k].total, what = label(def.bulkReactions?.[k]);
94
+ for (const { i, nu } of rx.reactants) add(i, what, -nu * total);
95
+ for (const { i, nu } of rx.products) add(i, what, nu * total);
96
+ });
97
+ model.interfaces.forEach((itf, f) => {
98
+ itf.reactions.forEach((rx, k) => {
99
+ 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);
101
+ });
102
+ });
103
+
104
+ // A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
105
+ const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
106
+ const natural = naturalScales(device, sol);
107
+ const moving = ledgers.filter((l, i) => size(l) > 1e-9 * natural[i]);
108
+ let worst = 0;
109
+ const lines = [];
110
+ for (const l of moving) {
111
+ const sum = l.terms.reduce((a, t) => a + t.rate, 0);
112
+ l.residual = sum / size(l);
113
+ worst = Math.max(worst, Math.abs(l.residual));
114
+ // Terms that matter, largest first; a charged species' as a current too.
115
+ 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
+ const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
117
+ lines.push(`${l.species}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
118
+ }
119
+ const ok = worst < 1e-6;
120
+ const summary =
121
+ moving.length === 0
122
+ ? 'nothing flows or reacts (equilibrium)'
123
+ : `${moving.length} species' sources and sinks sum to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}; mol/(m²·s), + in or made`;
124
+ // The largest current any charged species carries anywhere: the scale a net current is read against.
125
+ const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
126
+ return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
127
+ }
128
+
129
+ // What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
130
+ // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
131
+ // flows are round-off of these.
132
+ function naturalScales(device, sol) {
133
+ const { model, solver } = device, { species } = model;
134
+ const box = (g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? sol.x[g] - sol.x[g - 1] : 0) + (g + 1 < sol.x.length && sol.region[g + 1] === sol.region[g] ? sol.x[g + 1] - sol.x[g] : 0)) / 2;
135
+ return species.map((sp, i) => {
136
+ let S = 0;
137
+ model.regions.forEach((reg, r) => {
138
+ const mat = model.materials[reg.material], L = reg.length;
139
+ 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));
141
+ return;
142
+ }
143
+ if (!mat.present[i] || !(mat.D[i] > 0)) return;
144
+ let cmax = 0;
145
+ 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);
147
+ });
148
+ solver.rxs.forEach((rx) => {
149
+ const p = rx.sp.indexOf(i);
150
+ if (p < 0) return;
151
+ let gross = 0;
152
+ for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += box(g) * solver.bulkOneWay(rx, g);
153
+ S = Math.max(S, Math.abs(rx.nu[p]) * gross);
154
+ });
155
+ return S;
156
+ });
157
+ }
158
+
159
+ // Closed, unreacting stretches keep their amounts in a transient.
160
+ function conservation(sol) {
161
+ const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
162
+ if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
163
+ // 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))));
166
+ return {
167
+ name: 'conservation',
168
+ ok: worst < 1e-8,
169
+ 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`,
170
+ details: { worst },
171
+ };
172
+ }
173
+
174
+ // The same device on a grid twice as fine (half the cells' sizes, the square root of the
175
+ // grading ratio), solved from this solution interpolated onto it, against this solution.
176
+ function gridCheck(device, sol, tol, gross) {
177
+ const { def, model } = device;
178
+ const fine = {
179
+ ...def,
180
+ regions: def.regions.map((reg, r) => {
181
+ if (model.materials[model.regions[r].material].conductor) return reg;
182
+ const g = { ...(def.grid ?? {}), ...(reg.grid ?? {}) };
183
+ if (def.grid === undefined && reg.grid === undefined) Object.assign(g, { hmin: reg.length / 1000, hmax: reg.length / 20 });
184
+ const out = { ratio: Math.sqrt(g.ratio ?? 1.2), minCells: 2 * (g.minCells ?? 8) };
185
+ if (g.hmin !== undefined) out.hmin = g.hmin / 2;
186
+ if (g.hmax !== undefined) out.hmax = g.hmax / 2;
187
+ return { ...reg, grid: out };
188
+ }),
189
+ };
190
+ // Started from this solution, interpolated onto the finer grid; failing that, from cold.
191
+ let other;
192
+ try {
193
+ const dev = new Device(fine);
194
+ dev.solver._warmFrom(device.solver);
195
+ other = dev.solve();
196
+ if (!other.converged) {
197
+ const cold = new Device(fine);
198
+ if (sol.time > 0) cold.solver.time = sol.time; // waveforms at the same time
199
+ other = cold.solve();
200
+ }
201
+ } catch (e) {
202
+ return { name: 'grid', ok: null, summary: `couldn't solve the refined device (${e.message})` };
203
+ }
204
+ if (!other.converged) return { name: 'grid', ok: null, summary: "the device on a twice-finer grid didn't solve; check resolution by hand" };
205
+
206
+ const diffs = [];
207
+ // The current, against the largest current any species carries (so at open circuit, or in
208
+ // equilibrium, round-off isn't read as a change).
209
+ const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
210
+ if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: 'A/m²' });
211
+ // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
212
+ const VT = model.RT / FARADAY;
213
+ model.terminals.forEach((t, k) => {
214
+ if (device.solver.floating.includes(k)) {
215
+ const a = sol.terminals[t.name].V, b = other.terminals[t.name].V;
216
+ diffs.push({ what: `${t.name} voltage`, a, b, rel: Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b), VT), unit: 'V' });
217
+ }
218
+ });
219
+ // Each region's charge (C/m²), against the largest: summed over the nodes' boxes, as the
220
+ // solver's Gauss law counts it.
221
+ const charges = (s) => s.regions.map((_, r) => {
222
+ let q = 0;
223
+ for (let g = 0; g < s.x.length; g++) {
224
+ if (s.region[g] !== r) continue;
225
+ 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;
226
+ q += rho(s, g, model, r) * ((left + right) / 2);
227
+ }
228
+ return q;
229
+ });
230
+ // Charges at round-off (strictly neutral regions) aren't compared: against the gross charge
231
+ // the regions hold, |fixed| + Σ|z|c, they must be more than a millionth.
232
+ const content = Math.max(...sol.regions.map((reg, r) => {
233
+ let q = 0;
234
+ for (let g = 0; g < sol.x.length; g++) {
235
+ if (sol.region[g] !== r) continue;
236
+ let a = Math.abs(model.regions[r].fixedCharge ?? 0);
237
+ 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]);
238
+ q = Math.max(q, a);
239
+ }
240
+ return q * (reg.x1 - reg.x0);
241
+ }));
242
+ const qa = charges(sol), qb = charges(other), scaleQ = Math.max(0, ...qa.map(Math.abs), ...qb.map(Math.abs));
243
+ if (scaleQ > 1e-6 * content) {
244
+ const named = (reg) => (/^region \d+$/.test(reg.name) ? `${reg.name} (${reg.material})` : reg.name);
245
+ 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²' }));
246
+ }
247
+ const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
248
+ if (!worst) return { name: 'grid', ok: true, summary: 'nothing to compare (no current, charge or floating voltage)' };
249
+ const ok = worst.rel <= tol;
250
+ return {
251
+ name: 'grid',
252
+ ok,
253
+ summary:
254
+ `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} ` +
255
+ `(${sig(worst.rel, 2)}${ok ? '' : `, over ${sig(tol, 1)}: refine the grid (hmin, hmax), most likely where profiles are steepest`})`,
256
+ details: { diffs, nodes: [sol.x.length, other.x.length] },
257
+ };
258
+ }
259
+
260
+ // Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
261
+ function rho(s, g, model, r) {
262
+ let q = model.regions[r].fixedCharge ?? 0;
263
+ for (const sp of model.species) {
264
+ const c = s.c[sp.name][g];
265
+ if (Number.isFinite(c)) q += FARADAY * sp.z * c;
266
+ }
267
+ return q;
268
+ }
package/src/describe.js CHANGED
@@ -139,14 +139,19 @@ export function describe(def) {
139
139
  }
140
140
  const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
141
141
  if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
142
- // The screening concentration: Σ z²c from the initial composition, else the fixed charge's.
143
- let zzc = 0;
142
+ // The screening concentration: Σ z²c from the initial composition, plus the carriers that
143
+ // balance the fixed charge (doping), which c0 doesn't list.
144
+ let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
144
145
  species.forEach((sp, i) => {
145
146
  if (!mat.present[i]) return;
146
- if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
147
- 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;
148
150
  });
149
- if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
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);
150
155
  const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
151
156
  const Dmin = Math.min(...species.map((sp, i) => (mat.present[i] && mat.D[i] > 0 ? mat.D[i] : Infinity)));
152
157
  if (mat.epsr > 0 && zzc > 0) {
@@ -197,7 +202,7 @@ export function describe(def) {
197
202
  (def.ports ?? []).forEach((p, k) => lines.push(` port ${model.ports[k].name}: ${drive(p)}, in ${regions[model.ports[k].region].name}`));
198
203
  if ((def.bulkReactions ?? []).length) {
199
204
  lines.push('bulk reactions:');
200
- for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)} in ${Object.keys(rx.kf ?? {}).join(', ')}`);
205
+ for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);
201
206
  }
202
207
  if (times.length) lines.push('time scales:', ...times);
203
208
  if (warnings.length) lines.push('warnings:', ...warnings.map((w) => ` ${w}`));
package/src/device.js CHANGED
@@ -70,6 +70,29 @@ function profile(v, path, what, ok, okText) {
70
70
  return { x: Float64Array.from(v.x), values: Float64Array.from(v.values) };
71
71
  }
72
72
 
73
+ // SRH (trap-assisted) kinetics in place of mass action, for a reaction that consumes exactly one
74
+ // negative and one positive species and makes none, e⁻ + h⁺ = 0:
75
+ // r = n p (1 − e^{−A/RT}) / (t_p (n + n₁) + t_n (p + p₁)), p₁ = n p e^{−A/RT} / n₁,
76
+ // with t = τ (s) in the bulk, 1/v (m/s) at a face, and n₁ the negative species' concentration
77
+ // with its level at the trap (default n_i, a midgap trap). p₁ from the state keeps detailed
78
+ // balance exact, whatever lies between the two (a band offset, a φ jump).
79
+ function srhLaw(sdef, path, list, species, timeKeys) {
80
+ const [kn, kp] = timeKeys;
81
+ need(isObject(sdef), `${path} must be { ${kn}, ${kp}, n1 }`);
82
+ fields(sdef, path, [kn, kp, 'n1']);
83
+ const reactants = list.filter((p) => p.nu < 0);
84
+ need(
85
+ list.length === 2 && reactants.length === 2 && reactants.every((p) => p.nu === -1) && species[list[0].i].z * species[list[1].i].z < 0,
86
+ `${path}: SRH kinetics needs a reaction consuming one negative and one positive species and making none, such as 'e- + h+ = 0'`,
87
+ );
88
+ const neg = species[list[0].i].z < 0 ? list[0] : list[1], pos = neg === list[0] ? list[1] : list[0];
89
+ const time = (key) => {
90
+ const v = positive(sdef[key], `${path}.${key}`);
91
+ return key.startsWith('v') ? 1 / v : v; // a velocity's reciprocal is the time it stands for
92
+ };
93
+ return { n: neg, p: pos, tn: time(kn), tp: time(kp), n1: sdef.n1 === undefined ? NaN : positive(sdef.n1, `${path}.n1`) };
94
+ }
95
+
73
96
  function nonNegative(v, path) {
74
97
  need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
75
98
  return v;
@@ -324,12 +347,27 @@ export function normalizeDevice(def) {
324
347
  // reaction runs only in those materials).
325
348
  function normalizeReaction(rdef, path, species, speciesIndex, materials, materialIndex, regions, RT) {
326
349
  need(isObject(rdef), `${path} must be an object`);
327
- fields(rdef, path, ['nu', 'equation', 'fixed', 'kf']);
350
+ fields(rdef, path, ['nu', 'equation', 'fixed', 'kf', 'srh']);
328
351
  need((rdef.nu === undefined) !== (rdef.equation === undefined), `${path}: give the reaction as nu or as an equation, one of them`);
329
352
  const nu = rdef.nu ?? equationStoichiometry(rdef.equation, `${path}.equation`);
330
353
  const st = stoichiometry(nu, rdef.equation === undefined ? `${path}.nu` : `${path}.equation`, rdef.fixed, `${path}.fixed`, species, speciesIndex, RT);
331
354
  need(st.list.length > 0, `${path}: no mobile participants`);
332
355
  need(st.charge === 0, `${path}: charge is not balanced (Σ ν z = ${st.charge})`);
356
+ // SRH kinetics instead: srh maps material names to { tauN, tauP, n1 }.
357
+ need((rdef.kf === undefined) !== (rdef.srh === undefined), `${path}: give kf (mass action) or srh (trap-assisted), one of them`);
358
+ if (rdef.srh !== undefined) {
359
+ need(isObject(rdef.srh), `${path}.srh must map material names to { tauN, tauP, n1 }`);
360
+ const srh = new Array(materials.length).fill(null), kf = new Float64Array(materials.length), kfProfile = new Array(materials.length).fill(null);
361
+ for (const [mname, v] of Object.entries(rdef.srh)) {
362
+ need(materialIndex.has(mname), `${path}.srh.${mname}: unknown material`);
363
+ const m = materialIndex.get(mname);
364
+ srh[m] = srhLaw(v, `${path}.srh.${mname}`, st.list, species, ['tauN', 'tauP']);
365
+ for (const { i } of st.list) need(materials[m].present[i], `${path}.srh.${mname}: '${species[i].name}' is absent from material '${mname}'`);
366
+ kf[m] = 1; // (runs here)
367
+ }
368
+ const reactants = st.list.map(({ i, nu }) => ({ i, nu: -nu }));
369
+ return { reactants, products: [], fixedA: st.fixedA, kf, kfProfile, srh, generation: false };
370
+ }
333
371
  need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
334
372
  // Each a number, or a profile { x, values } against the device's x (e.g. absorption); kf[m]
335
373
  // is then its largest value, which says whether the reaction runs in that material at all.
@@ -400,7 +438,7 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
400
438
  return (idef.reactions ?? []).map((rdef, k) => {
401
439
  const rpath = `${where}.reactions[${k}]`;
402
440
  need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
403
- fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha']);
441
+ fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh']);
404
442
  let sides = rdef;
405
443
  if (rdef.equation !== undefined) {
406
444
  need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
@@ -420,6 +458,15 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
420
458
  }
421
459
  need(part.length > 0, `${rpath}: no species participate`);
422
460
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
461
+ if (rdef.srh !== undefined) {
462
+ need(rdef.k0 === undefined && rdef.alpha === undefined, `${rpath}: give srh or k0 (and alpha), not both`);
463
+ const srh = srhLaw(rdef.srh, `${rpath}.srh`, part, species, ['vn', 'vp']);
464
+ for (const p of part) {
465
+ const mat = p.side ? matR : matL;
466
+ need(!mat.conductor, `${rpath}.srh: SRH kinetics is for carriers in semiconductors, not a metal's ${species[p.i].name}`);
467
+ }
468
+ return { part, fixedA, srh };
469
+ }
423
470
  return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
424
471
  });
425
472
  }
@@ -820,7 +867,7 @@ function defaultInterfaceLinks(matL, matR, species) {
820
867
 
821
868
  function transferCoefficient(v, path) {
822
869
  if (v === undefined) return 0.5;
823
- need(isFiniteNumber(v) && v > 0 && v < 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
870
+ need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
824
871
  return v;
825
872
  }
826
873
 
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
@@ -63,13 +63,22 @@ export class Device {
63
63
  }
64
64
 
65
65
  /**
66
- * Change part of the definition (deep-merged). A change to the terminals' drives alone (V, I,
67
- * R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
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. A change to the terminals'
68
+ * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
68
69
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
69
70
  * @param {object} patch a partial device definition, merged into the current one
70
71
  * @returns {this}
71
72
  */
72
73
  set(patch) {
74
+ // A contact given a new kind of drive drops the old one: { I: 0 } replaces a held V.
75
+ for (const side of ['left', 'right']) {
76
+ const c = patch?.contacts?.[side];
77
+ if (c && typeof c === 'object') {
78
+ if ('I' in c && !('V' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, V: undefined } } };
79
+ else if ('V' in c && !('I' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, I: undefined } } };
80
+ }
81
+ }
73
82
  const def = merge(this.def, patch);
74
83
  // Only the terminals' drives changed: update them in place, keeping the solver and its state
75
84
  // (a step in a source restarts the time stepping's order, as at any discontinuity).
package/src/kit.js CHANGED
@@ -11,6 +11,9 @@ export { traces, speciesRole, typeset } from './traces.js';
11
11
  export { live } from './live.js';
12
12
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
13
13
  export { describe, unitWarnings } from './describe.js';
14
+ export { recorder } from './recorder.js';
15
+ export { check } from './checks.js';
16
+ export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
14
17
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
15
18
 
16
19
  const fail = (message) => {
package/src/plot.js CHANGED
@@ -257,12 +257,13 @@ export function levelChart(tr, { width = 640, height = 360, ylabel = 'voltage (V
257
257
 
258
258
  /**
259
259
  * A solution's level diagram as an SVG string: each charged species' voltage (thick) and
260
- * standard level (thin), regions as bands. Options are those of `traces()` and `levelChart()`.
260
+ * standard level (thin), regions as bands; or with `energy: true`, the familiar energy-up band
261
+ * diagram of electrons and holes. Options are those of `traces()` and `levelChart()`.
261
262
  * @param {import('./types.js').Solution} sol
262
263
  * @param {Parameters<typeof traces>[1] & Parameters<typeof levelChart>[1]} [opts]
263
264
  * @returns {string}
264
265
  */
265
266
  export function bandDiagram(sol, opts = {}) {
266
- const { width, height, ylabel, range, xlim, title, ytick, ...pick } = opts;
267
+ const { width, height, ylabel = opts.energy ? 'electron energy (eV)' : undefined, range, xlim, title, ytick, ...pick } = opts;
267
268
  return levelChart(traces(sol, pick), { width, height, ylabel, range, xlim, title, ytick });
268
269
  }