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/README.md +14 -7
- package/docs/device.md +61 -7
- package/docs/kit.md +147 -0
- package/llms.txt +100 -15
- package/package.json +1 -1
- package/src/checks.js +302 -0
- package/src/describe.js +18 -7
- package/src/device.js +31 -4
- package/src/devices.js +135 -0
- package/src/index.js +1 -1
- package/src/junction.js +164 -0
- package/src/kit.js +4 -0
- package/src/recorder.js +89 -0
- package/src/solution.js +18 -0
- package/src/solver.js +135 -7
- package/src/stack.js +1 -1
- 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 -3
- package/types/junction.d.ts +32 -0
- package/types/kit.d.ts +4 -0
- package/types/recorder.d.ts +27 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +8 -1
- package/types/stack.d.ts +1 -0
- package/types/types.d.ts +15 -0
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
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,
|
|
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
|
|
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) {
|