driftlet 0.5.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/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/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) => {
@@ -0,0 +1,89 @@
1
+ // A transient recorder, for driftlet/kit: advance() in pieces (a run to completion, or a little
2
+ // each animation frame), with the trace joined up across calls and whole solutions kept as
3
+ // frames, for scrubbing back through a run or plotting snapshots side by side.
4
+
5
+ import { DeviceError } from './errors.js';
6
+
7
+ const fail = (message) => {
8
+ throw new DeviceError(message);
9
+ };
10
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
11
+
12
+ /**
13
+ * Record a device's transient. Frames are whole solutions: at the start, then at each of
14
+ * `times` (or every `every` seconds from the start), which `advance()` lands on exactly; with
15
+ * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
16
+ * `probes` if given) runs from the start through every accepted step of every call.
17
+ * @param {import('./index.js').Device} device
18
+ * @param {{ every?: number, times?: number[], probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
19
+ */
20
+ export function recorder(device, opts = {}) {
21
+ const { every, times, probes } = opts;
22
+ if (every !== undefined && times !== undefined) fail('recorder: give every or times, not both');
23
+ if (every !== undefined && !(every > 0 && Number.isFinite(every))) fail('recorder: every must be a time > 0 (s)');
24
+ if (times !== undefined && !(Array.isArray(times) && times.every(Number.isFinite))) fail('recorder: times must be an array of times (s)');
25
+ const first = device.solution();
26
+ const t0 = first.time;
27
+ const schedule = times ? [...times].filter((t) => t > t0).sort((a, b) => a - b) : null;
28
+ const scheduled = schedule !== null || every !== undefined;
29
+ let next = every !== undefined ? 1 : 0; // the next frame due: schedule[next], or t0 + next·every
30
+ const due = () => (schedule ? schedule[next] : every !== undefined ? t0 + next * every : undefined);
31
+
32
+ const trace = { t: [first.time], current: [first.current], voltage: [first.terminals.right.V - first.terminals.left.V] };
33
+ if (probes) trace.probes = device.solver._probes(probes).read().map((v) => [v]); // the start's readings
34
+ const frames = [first];
35
+
36
+ /**
37
+ * Advance to tEnd (s), stopping at each frame time on the way to keep a frame. Takes
38
+ * advance()'s options; with `budgetMs` it returns when that much wall time is spent, `done`
39
+ * saying whether tEnd was reached. Returns the latest solution (its own `trace` is only the
40
+ * last piece: the recorder's `trace` is the whole run).
41
+ * @param {number} tEnd
42
+ * @param {object} [aopts] as for Device.advance()
43
+ */
44
+ function advance(tEnd, aopts = {}) {
45
+ const start = now(), budget = aopts.budgetMs ?? Infinity;
46
+ let sol;
47
+ for (;;) {
48
+ const stop = due();
49
+ const target = stop !== undefined && stop < tEnd ? stop : tEnd;
50
+ const left = budget - (now() - start);
51
+ sol = device.advance(target, { ...aopts, ...(probes ? { probes } : {}), budgetMs: left > 0 ? left : 0 });
52
+ const tr = sol.trace;
53
+ for (let k = 0; k < tr.t.length; k++) {
54
+ trace.t.push(tr.t[k]);
55
+ trace.current.push(tr.current[k]);
56
+ trace.voltage.push(tr.voltage[k]);
57
+ if (probes) tr.probes.forEach((p, j) => trace.probes[j].push(p[k]));
58
+ }
59
+ if (!sol.converged || !sol.done) break;
60
+ if (target === stop) {
61
+ frames.push(sol);
62
+ next++;
63
+ }
64
+ if (target === tEnd) break;
65
+ if (now() - start > budget) {
66
+ sol.done = false;
67
+ break;
68
+ }
69
+ }
70
+ if (!scheduled && sol.converged && sol.time > frames.at(-1).time) frames.push(sol);
71
+ return sol;
72
+ }
73
+
74
+ /**
75
+ * The frame at or just before time t (the first frame before the start).
76
+ * @param {number} t
77
+ */
78
+ function frame(t) {
79
+ let lo = 0, hi = frames.length - 1;
80
+ while (lo < hi) {
81
+ const mid = (lo + hi + 1) >> 1;
82
+ if (frames[mid].time <= t) lo = mid;
83
+ else hi = mid - 1;
84
+ }
85
+ return frames[lo];
86
+ }
87
+
88
+ return { advance, frame, frames, trace };
89
+ }
package/src/solution.js CHANGED
@@ -27,6 +27,7 @@ export function makeSolution(solver, result = {}) {
27
27
  V: {},
28
28
  Vstd: {},
29
29
  time: solver.time,
30
+ steady: solver.atSteady,
30
31
  converged: result.converged ?? true,
31
32
  iterations: result.iterations ?? 0,
32
33
  steps: result.steps,
@@ -124,6 +125,23 @@ export function makeSolution(solver, result = {}) {
124
125
  return { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
125
126
  });
126
127
 
128
+ // Bulk reactions: each one's forward rate at every node (mol/(m³·s), NaN where it doesn't run),
129
+ // and integrated over each region and the device (mol/(m²·s)), by the nodes' control volumes,
130
+ // just as the balances count it.
131
+ sol.bulkReactions = solver.rxs.map((rx) => {
132
+ const rate = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
133
+ let total = 0;
134
+ for (let g = 0; g < nNodes; g++) {
135
+ const m = solver.nodeMaterial[g];
136
+ if (!(rx.kf[m] > 0)) continue;
137
+ rate[g] = solver.bulkRate(rx, g);
138
+ const amount = grid.vol[g] * rate[g];
139
+ regions[grid.nodeRegion[g]] += amount;
140
+ total += amount;
141
+ }
142
+ return { rate, regions, total };
143
+ });
144
+
127
145
  // Total charge per area in the device (space charge plus sheet charges).
128
146
  let q = 0;
129
147
  for (let g = 0; g < nNodes; g++) {
package/src/solver.js CHANGED
@@ -321,6 +321,7 @@ export class Solver {
321
321
  this.jR = new Float64Array(M);
322
322
  this.time = 0;
323
323
  this.lastDt = Infinity;
324
+ this.atSteady = false; // whether the state is a converged steady solve
324
325
  // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
325
326
  // and the displacement there (the metal's surface charge for a neutral link).
326
327
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
@@ -802,6 +803,7 @@ export class Solver {
802
803
  // After the terminals' drives change (Device.set): which are floating, and the held values. A
803
804
  // change of source is a discontinuity, so time stepping restarts its order.
804
805
  redrive() {
806
+ this.atSteady = false;
805
807
  const before = this.floating;
806
808
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
807
809
  if (this.floating.length !== before.length) this.deltaV = null;
@@ -1715,6 +1717,34 @@ export class Solver {
1715
1717
  d[0] = -this._Kz(g, k);
1716
1718
  }
1717
1719
 
1720
+ // A bulk reaction's rate at node g (mol/(m³·s), forward), as assembled: for the solution.
1721
+ bulkRate(rx, g) {
1722
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1723
+ let aHi = rx.fixedA, aLo = 0;
1724
+ for (let p = 0; p < rx.sp.length; p++) {
1725
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
1726
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
1727
+ }
1728
+ const law = rx.srh?.[this.nodeMaterial[g]];
1729
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo).rate;
1730
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1731
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
1732
+ return P * -Math.expm1(-(aHi + aLo));
1733
+ }
1734
+
1735
+ // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
1736
+ // rate is read against (in equilibrium the net rate is their round-off).
1737
+ bulkOneWay(rx, g) {
1738
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1739
+ let a = rx.fixedA;
1740
+ for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
1741
+ const law = rx.srh?.[this.nodeMaterial[g]];
1742
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).oneWay;
1743
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1744
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
1745
+ return P * Math.max(1, Math.exp(-a));
1746
+ }
1747
+
1718
1748
  // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
1719
1749
  // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
1720
1750
  _srhRate(law, cn, cp, a) {
@@ -1731,7 +1761,7 @@ export class Solver {
1731
1761
  const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
1732
1762
  const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
1733
1763
  const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
1734
- return { rate, Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
1764
+ return { rate, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
1735
1765
  }
1736
1766
 
1737
1767
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
@@ -2171,6 +2201,7 @@ export class Solver {
2171
2201
  * the start-of-step values, and dt/a0 replaces dt.
2172
2202
  */
2173
2203
  step(dt, opts = {}) {
2204
+ this.atSteady = false;
2174
2205
  const prev = this.history[0];
2175
2206
  const w = prev ? dt / prev.dt : 0;
2176
2207
  const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
@@ -2465,6 +2496,39 @@ export class Solver {
2465
2496
  return mx;
2466
2497
  }
2467
2498
 
2499
+ // Start from another solver's state for the same device on another grid (a refined one): each
2500
+ // node's unknowns interpolated linearly in x within its region, each face's copied, and the
2501
+ // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
2502
+ _warmFrom(src) {
2503
+ const { M, u, uLo } = this, grid = this.model.grid, sg = src.model.grid;
2504
+ if (src.M !== M || sg.regionStart.length !== grid.regionStart.length) throw new Error('_warmFrom: a different device');
2505
+ const value = (b, k) => src.u[b * M + k] + src.uLo[b * M + k];
2506
+ for (let r = 0; r < grid.regionStart.length; r++) {
2507
+ let a = sg.regionStart[r];
2508
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
2509
+ const x = grid.x[g];
2510
+ while (a + 1 < sg.regionEnd[r] && sg.x[a + 1] < x) a++;
2511
+ const b = Math.min(a + 1, sg.regionEnd[r]), h = sg.x[b] - sg.x[a];
2512
+ const w = h > 0 ? Math.min(1, Math.max(0, (x - sg.x[a]) / h)) : 0;
2513
+ const ba = src.blockOfNode[a], bb = src.blockOfNode[b], bg = this.blockOfNode[g];
2514
+ for (let k = 0; k < M; k++) {
2515
+ u[bg * M + k] = (1 - w) * value(ba, k) + w * value(bb, k);
2516
+ uLo[bg * M + k] = 0;
2517
+ }
2518
+ }
2519
+ }
2520
+ for (let f = 0; f < this.nFaces; f++) {
2521
+ for (let k = 0; k < M; k++) {
2522
+ u[this.blockOfFace[f] * M + k] = value(src.blockOfFace[f], k);
2523
+ uLo[this.blockOfFace[f] * M + k] = 0;
2524
+ }
2525
+ }
2526
+ this.termV.set(src.termV);
2527
+ this.solvedV = src.solvedV;
2528
+ this.time = src.time;
2529
+ this.computeConcentrations();
2530
+ }
2531
+
2468
2532
  _snapshot() {
2469
2533
  return {
2470
2534
  u: Float64Array.from(this.u),
@@ -2472,6 +2536,7 @@ export class Solver {
2472
2536
  cOld: Float64Array.from(this.cOld),
2473
2537
  time: this.time,
2474
2538
  lastDt: this.lastDt,
2539
+ atSteady: this.atSteady,
2475
2540
  contactDStart: this.contactDStart,
2476
2541
  contactDOld: this.contactDOld,
2477
2542
  contactDEnd: this.contactDEnd,
@@ -2488,6 +2553,7 @@ export class Solver {
2488
2553
  this.cOld.set(s.cOld);
2489
2554
  this.time = s.time;
2490
2555
  this.lastDt = s.lastDt;
2556
+ this.atSteady = s.atSteady;
2491
2557
  this.contactDStart = s.contactDStart;
2492
2558
  this.contactDOld = s.contactDOld;
2493
2559
  this.contactDEnd = s.contactDEnd;
@@ -2863,7 +2929,15 @@ export class Solver {
2863
2929
  * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
2864
2930
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
2865
2931
  */
2932
+ // Steady state from the present one; `atSteady` says whether the state is one (until a step or
2933
+ // a change of drive).
2866
2934
  solveSteady(opts = {}) {
2935
+ const r = this._steadyFromHere(opts);
2936
+ this.atSteady = r.converged;
2937
+ return r;
2938
+ }
2939
+
2940
+ _steadyFromHere(opts) {
2867
2941
  this.conditioning = null;
2868
2942
  this.computeConcentrations();
2869
2943
  this._captureLocal(); // immobile combinations keep, node by node, what they hold now
package/src/stack.js CHANGED
@@ -59,7 +59,7 @@ export function layer(material, length, more = {}) {
59
59
 
60
60
  // A drive: a number is a held voltage, a waveform { t, values } a voltage waveform, and an
61
61
  // object with V, I or R is the drive itself.
62
- function drive(d, path) {
62
+ export function drive(d, path) {
63
63
  if (d === undefined) return { V: 0 };
64
64
  if (typeof d === 'number') return { V: d };
65
65
  if (isObject(d) && Array.isArray(d.t)) return { V: d };
package/src/types.js CHANGED
@@ -201,7 +201,9 @@
201
201
  * @property {{ left?: { V: number, D: number, charge: number }, right?: { V: number, D: number, charge: number } }} gates
202
202
  * @property {{ name: string, V: number, flux: Record<string, number>, current: number }[]} ports what each
203
203
  * internal port brings into the device
204
- * @property {{ dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
204
+ * @property {{ left: string, right: string, dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
205
+ * @property {{ rate: Float64Array, regions: number[], total: number }[]} bulkReactions each bulk reaction's
206
+ * forward rate per node (mol/(m³·s), NaN where it doesn't run), and per region and in all (mol/(m²·s))
205
207
  * @property {number} charge total charge in the device, C/m²
206
208
  * @property {{ species: string, regions: number[], spectator: boolean, connected: boolean, reactive: boolean,
207
209
  * amount: number, reference: number, intake: number, drift: number }[]} conservation
@@ -209,6 +211,7 @@
209
211
  * @property {boolean} converged
210
212
  * @property {number} iterations
211
213
  * @property {number} time s
214
+ * @property {boolean} steady whether this is a converged steady state (solve()), not a transient's
212
215
  * @property {boolean} [done] advance(): whether tEnd was reached (false if the frame budget ran out)
213
216
  * @property {number} [rejected] advance(): steps rejected by error control or Newton failure
214
217
  * @property {{ t: number[], current: number[], voltage: number[], probes?: number[][] }} [trace] advance():
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @typedef {object} CheckItem
3
+ * @property {string} name what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
4
+ * @property {boolean | null} ok whether it passed (null: look at it, a warning or a check that
5
+ * couldn't be made)
6
+ * @property {string} summary one line
7
+ * @property {object} [details] the numbers behind it
8
+ */
9
+ /**
10
+ * Check a device's present state (pass the solution solve() or advance() returned, for its
11
+ * convergence and warnings):
12
+ * - **converged**: the last solve converged.
13
+ * - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
14
+ * parts) and the definition's likely unit slips.
15
+ * - **balance** (a steady state): each species' ledger, what comes in through each terminal and
16
+ * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
17
+ * species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
18
+ * and it says where every carrier went.
19
+ * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
20
+ * what came in.
21
+ * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
22
+ * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
23
+ * region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
24
+ * wants less). The discretisation is second order, so the change estimates this grid's error
25
+ * (about 3/4 of it).
26
+ * @param {import('./index.js').Device} device
27
+ * @param {object} [sol] the solution to check (default: the device's present state)
28
+ * @param {{ refine?: boolean, tol?: number }} [opts]
29
+ * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
30
+ * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
31
+ * @returns {{ ok: boolean, items: CheckItem[], text: string }}
32
+ */
33
+ export function check(device: import("./index.js").Device, sol?: object, { refine, tol }?: {
34
+ refine?: boolean;
35
+ tol?: number;
36
+ }): {
37
+ ok: boolean;
38
+ items: CheckItem[];
39
+ text: string;
40
+ };
41
+ export type CheckItem = {
42
+ /**
43
+ * what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
44
+ */
45
+ name: string;
46
+ /**
47
+ * whether it passed (null: look at it, a warning or a check that
48
+ * couldn't be made)
49
+ */
50
+ ok: boolean | null;
51
+ /**
52
+ * one line
53
+ */
54
+ summary: string;
55
+ /**
56
+ * the numbers behind it
57
+ */
58
+ details?: object;
59
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * IonMonger's default planar perovskite solar cell: TiO₂ (100 nm) | MAPbI₃ (400 nm) with mobile
3
+ * iodide vacancies over an equal immobile background | spiro-OMeTAD (200 nm), at 298 K, lit
4
+ * through the TiO₂. Species e⁻, h⁺ and the vacancies 'V+'. Recombination is SRH at both faces
5
+ * (IonMonger's interface law is driftlet's face SRH, with n₁ from the TiO₂'s electron density)
6
+ * and, with `bulkSRH`, SRH through midgap traps in the perovskite (τₙ = 3 ns, τₚ = 300 ns).
7
+ * Light is 1.4e21 photons/(m²·s) at α = 1.3e7 /m (IonMonger's one sun), times `light`. The left
8
+ * contact is the TiO₂'s, at 0 V; the right is the spiro's, at `V`, so photocurrent is positive.
9
+ *
10
+ * `PEROVSKITE_SCANS` holds IonMonger's own results for this cell, scan by scan.
11
+ * @param {{ V?: import('./types.js').Source | { V?: import('./types.js').Source, I?: import('./types.js').Source, R?: number }, light?: number, bulkSRH?: boolean }} [opts]
12
+ * @returns {import('./types.js').DeviceDefinition}
13
+ */
14
+ export function perovskiteCell({ V, light, bulkSRH }?: {
15
+ V?: import("./types.js").Source | {
16
+ V?: import("./types.js").Source;
17
+ I?: import("./types.js").Source;
18
+ R?: number;
19
+ };
20
+ light?: number;
21
+ bulkSRH?: boolean;
22
+ }): import("./types.js").DeviceDefinition;
23
+ /**
24
+ * A J–V loop's figures of merit, from a transient's trace (`{ t, voltage, current }`, as a
25
+ * recorder or advance() gives it): the loop splits at time `turn` into the sweep before (`rev`)
26
+ * and after (`fwd`). Each sweep's maximum power V·I (W/m², the largest among the trace's points)
27
+ * and its open-circuit voltage (where I changes sign, interpolated linearly); and the
28
+ * hysteresis index (Pmax,rev − Pmax,fwd)/Pmax,rev. Power out is positive, so a cell whose
29
+ * current runs the other way should have it negated first.
30
+ * @param {{ t: ArrayLike<number>, voltage: ArrayLike<number>, current: ArrayLike<number> }} trace
31
+ * @param {number} turn s
32
+ */
33
+ export function hysteresis(trace: {
34
+ t: ArrayLike<number>;
35
+ voltage: ArrayLike<number>;
36
+ current: ArrayLike<number>;
37
+ }, turn: number): {
38
+ hi: number;
39
+ };
40
+ /**
41
+ * IonMonger's J–V scans of `perovskiteCell()` (IonMonger 2.0, commit 1ae4f9e, run in GNU Octave):
42
+ * steady state at 1.2 V under light, then 1.2 → 0 → 1.2 V at `rate` (V/s). `full` is the cell
43
+ * as given (bulk SRH on); `noBulkSRH` is `perovskiteCell({ bulkSRH: false })`. Per scan: J0, the
44
+ * current at 0 V (A/m²); each sweep's maximum power Pmax (W/m², the largest V·J among IonMonger's
45
+ * 201 output points per loop) and Voc (V, interpolated); and the hysteresis index
46
+ * hi = (Pmax,rev − Pmax,fwd)/Pmax,rev. `hysteresis()` reads the same from a run's trace.
47
+ * driftlet reproduces these to 0.15 % in hi, 0.5 W/m² in Pmax and 2 mV in Voc (its test suite).
48
+ */
49
+ export const PEROVSKITE_SCANS: Readonly<{
50
+ source: "IonMonger 2.0 (github.com/PerovskiteSCModelling/IonMonger, commit 1ae4f9e), default parameters";
51
+ full: {
52
+ rate: number;
53
+ J0: number;
54
+ rev: {
55
+ Pmax: number;
56
+ Voc: number;
57
+ };
58
+ fwd: {
59
+ Pmax: number;
60
+ Voc: number;
61
+ };
62
+ hi: number;
63
+ }[];
64
+ noBulkSRH: {
65
+ rate: number;
66
+ J0: number;
67
+ rev: {
68
+ Pmax: number;
69
+ Voc: number;
70
+ };
71
+ fwd: {
72
+ Pmax: number;
73
+ Voc: number;
74
+ };
75
+ hi: number;
76
+ }[];
77
+ }>;
package/types/kit.d.ts CHANGED
@@ -34,6 +34,8 @@ export function vacuumDipole(def: object, left: VacuumSide, right: VacuumSide):
34
34
  */
35
35
  export function vacuumZeroCharge(def: object, workFunction: number, inside: VacuumSide): number;
36
36
  export { live } from "./live.js";
37
+ export { recorder } from "./recorder.js";
38
+ export { check } from "./checks.js";
37
39
  /**
38
40
  * One material seen from vacuum: a level inside it (the anchor) and how far above that level,
39
41
  * in electron energy, the vacuum level just outside lies (the offset, V). So
@@ -59,4 +61,5 @@ export { parseEquation, stoichiometry, half, SHE, level } from "./equation.js";
59
61
  export { traces, speciesRole, typeset } from "./traces.js";
60
62
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from "./sources.js";
61
63
  export { describe, unitWarnings } from "./describe.js";
64
+ export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from "./devices.js";
62
65
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from "./data.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Record a device's transient. Frames are whole solutions: at the start, then at each of
3
+ * `times` (or every `every` seconds from the start), which `advance()` lands on exactly; with
4
+ * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
5
+ * `probes` if given) runs from the start through every accepted step of every call.
6
+ * @param {import('./index.js').Device} device
7
+ * @param {{ every?: number, times?: number[], probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
8
+ */
9
+ export function recorder(device: import("./index.js").Device, opts?: {
10
+ every?: number;
11
+ times?: number[];
12
+ probes?: {
13
+ x: number;
14
+ species: string;
15
+ quantity?: "c" | "V";
16
+ region?: string | number;
17
+ }[];
18
+ }): {
19
+ advance: (tEnd: number, aopts?: object) => import("./types.js").Solution;
20
+ frame: (t: number) => import("./types.js").Solution;
21
+ frames: import("./types.js").Solution[];
22
+ trace: {
23
+ t: number[];
24
+ current: number[];
25
+ voltage: number[];
26
+ };
27
+ };
@@ -10,6 +10,7 @@ export function makeSolution(solver: any, result?: {}): {
10
10
  V: {};
11
11
  Vstd: {};
12
12
  time: any;
13
+ steady: any;
13
14
  converged: any;
14
15
  iterations: any;
15
16
  steps: any;