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/README.md +8 -4
- package/docs/device.md +33 -3
- package/docs/kit.md +123 -0
- package/docs/numerics.md +6 -2
- package/docs/visualization.md +9 -0
- package/llms.txt +46 -11
- package/package.json +1 -1
- package/src/checks.js +268 -0
- package/src/describe.js +11 -6
- package/src/device.js +50 -3
- package/src/devices.js +135 -0
- package/src/index.js +11 -2
- package/src/kit.js +3 -0
- package/src/plot.js +3 -2
- package/src/recorder.js +89 -0
- package/src/solution.js +19 -1
- package/src/solver.js +209 -12
- package/src/stack.js +1 -1
- package/src/traces.js +23 -10
- package/src/types.js +4 -1
- package/types/checks.d.ts +59 -0
- package/types/devices.d.ts +77 -0
- package/types/index.d.ts +3 -2
- package/types/kit.d.ts +3 -0
- package/types/plot.d.ts +2 -1
- package/types/recorder.d.ts +27 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +18 -1
- package/types/stack.d.ts +1 -0
- package/types/traces.d.ts +7 -2
- package/types/types.d.ts +15 -0
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,
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
67
|
-
*
|
|
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
|
|
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
|
}
|