driftlet 0.3.0 → 0.4.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 -5
- package/docs/device.md +9 -3
- package/docs/kit.md +11 -0
- package/llms.txt +11 -9
- package/package.json +1 -1
- package/src/describe.js +2 -2
- package/src/device.js +26 -15
- package/src/kit.js +1 -1
- package/src/solver.js +37 -6
- package/src/sources.js +36 -0
- package/src/types.js +8 -2
- package/types/kit.d.ts +1 -1
- package/types/sources.d.ts +30 -0
- package/types/types.d.ts +11 -6
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The same equations go by different names in different fields. If your problem is
|
|
|
31
31
|
| If you work on | you may call it | start from | checked against |
|
|
32
32
|
|---|---|---|---|
|
|
33
33
|
| Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
|
|
34
|
-
| Solar cells | photogeneration, radiative and SRH recombination | [solar
|
|
34
|
+
| Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) and [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) demos, [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH runs through explicit trap species | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
|
|
35
35
|
| Electrochemistry | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html) and [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg |
|
|
36
36
|
| Batteries, intercalation | OCV, insertion hosts, chemical diffusion | [insertion demo](https://markblundeberg.github.io/driftlet/demos/insertion.html), [`statistics`](test/statistics.test.js) test | composition vs OCV, π²D/4L² relaxation |
|
|
37
37
|
| Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
|
|
@@ -55,7 +55,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Or straight from a CDN in a page, pinned to a version:
|
|
58
|
-
`https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
58
|
+
`https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
|
|
59
59
|
TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
|
|
60
60
|
[`llms.txt`](llms.txt).
|
|
61
61
|
|
|
@@ -146,7 +146,7 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
|
|
|
146
146
|
| **Cyclic voltammetry**: a Fermi level against a redox level | **Daniell cell**: a real salt bridge, leaking, ion by ion | **Saturation**: Ag \| AgNO₃ \| Ag under a square wave, up to its limiting current |
|
|
147
147
|
| [](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [](https://markblundeberg.github.io/driftlet/demos/double-layer.html) |
|
|
148
148
|
| **Impedance**: the Warburg arc, and the cell's small-signal response inside | **Ion-exchange membrane**: Donnan steps, ion by ion, against TMS theory | **Double layer**: dilute vs crowded ions, against closed forms |
|
|
149
|
-
| [](https://markblundeberg.github.io/driftlet/demos/insertion.html) |
|
|
149
|
+
| [](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [](https://markblundeberg.github.io/driftlet/demos/organic.html) | |
|
|
150
150
|
| **Intercalation host**: cycling between cutoffs against the OCV | | |
|
|
151
151
|
|
|
152
152
|
## How to think about it
|
|
@@ -205,8 +205,9 @@ current. Supported physics:
|
|
|
205
205
|
COMSOL.
|
|
206
206
|
- **Cross-diffusion.** Each species moves down its own μ̄ with its own D (the statistics can be
|
|
207
207
|
non-ideal); there are no Stefan–Maxwell or Onsager cross terms beyond eddy mixing.
|
|
208
|
-
- **Heat and optics.** Temperature is uniform. Generation is a reaction,
|
|
209
|
-
|
|
208
|
+
- **Heat and optics.** Temperature is uniform. Generation is a reaction, with Beer–Lambert
|
|
209
|
+
absorption as its profile at most; there's no optical model (reflection, interference,
|
|
210
|
+
photon recycling). For those, see
|
|
210
211
|
[Driftfusion](https://github.com/barnesgroupICL/Driftfusion),
|
|
211
212
|
[SIMsalabim](https://github.com/kostergroup/SIMsalabim) or
|
|
212
213
|
[PyBaMM](https://github.com/pybamm-team/PyBaMM).
|
|
@@ -232,6 +233,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
232
233
|
| Long pn diode with recombination | Shockley J–V incl. depletion recombination | 2e-3 |
|
|
233
234
|
| Haynes–Shockley pulse | an injected hole packet's drift, spread and decay at the ambipolar μ*, D*, 1/τ*; nothing lost while it goes in | 2e-4; 1e-2; 2e-3 |
|
|
234
235
|
| A packet laid down as a `c0` profile | the same rates from a Gaussian at t = 0: μ*E t, σ₀² + 2D*t, e^(−t/τ*) | 3e-4; 1e-2; 1e-3 |
|
|
236
|
+
| Beer–Lambert photogeneration in an n⁺p cell | J_sc = qΦ∫αe^(−αx)η(x)dx with the emitter's, depletion layer's and base's collection, α from 1e3 to 1e7 /m on one coarse grid | 5e-4 (3e-3 in the emitter) |
|
|
237
|
+
| Organic bilayer: excitons | J_sc from excitons diffusing to the donor/acceptor interface (Beer–Lambert, a blocking anode, the splitting reaction's finite velocity); V_oc from charge-transfer detailed balance | 5e-4; 1e-6 V |
|
|
235
238
|
| Illuminated long pn diode | `J_sc = qG(L_n + L_p + W)` from a cold start; superposition at low injection | 5e-3; 1e-2 |
|
|
236
239
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
237
240
|
| Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
|
package/docs/device.md
CHANGED
|
@@ -103,7 +103,7 @@ regions: [
|
|
|
103
103
|
reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
|
|
104
104
|
logarithmic in them); to have none of a species in a region, leave it out of that region's
|
|
105
105
|
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
106
|
-
An entry can instead be a profile, `{ x: [...],
|
|
106
|
+
An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
|
|
107
107
|
the region's own), piecewise linear between its points and constant beyond its ends, so a
|
|
108
108
|
transient can start from a state it didn't simulate reaching: a packet already injected, a
|
|
109
109
|
gradient laid down. Skip `solve()`, which goes straight to the steady state, and `advance()`
|
|
@@ -256,14 +256,20 @@ those materials.
|
|
|
256
256
|
**Generation** is a reaction from a reservoir: photogeneration is
|
|
257
257
|
$`\text{photon} \to \mathrm{e}^- + \mathrm{h}^+`$, with the photons a fixed participant whose $`\mu`$
|
|
258
258
|
sits well above the gap, so that $`e^{-A/RT}`$ is negligible and the rate is `kf` itself, the
|
|
259
|
-
generation rate (mol/(m³·s)).
|
|
260
|
-
absorption profile). The photons' $`\mu`$ is the honest part: light is a reservoir far from the
|
|
259
|
+
generation rate (mol/(m³·s)). The photons' $`\mu`$ is the honest part: light is a reservoir far from the
|
|
261
260
|
device's temperature, and the rate still vanishes at $`A = 0`$.
|
|
262
261
|
|
|
263
262
|
```js nocheck
|
|
264
263
|
bulkReactions: [{ equation: 'photon = e- + h+', fixed: { photon: units.eV(3) }, kf: { Si: 0.1 } }]
|
|
265
264
|
```
|
|
266
265
|
|
|
266
|
+
A rate constant can also vary with position: give it as a profile `{ x: [...], values: [...] }`
|
|
267
|
+
against the device's x (the same tables as `c0`), piecewise linear and constant beyond its ends,
|
|
268
|
+
used only at that material's nodes. Each node takes the profile's mean over its box (not its
|
|
269
|
+
value at the node), so the total rate is exact on any grid: light absorbed within a fraction of
|
|
270
|
+
a cell still generates what it should. The kit's `photogeneration()` writes Beer–Lambert
|
|
271
|
+
absorption this way.
|
|
272
|
+
|
|
267
273
|
## Contacts
|
|
268
274
|
|
|
269
275
|
A contact is an interface whose far side is an **outside phase with known levels**: think of it as
|
package/docs/kit.md
CHANGED
|
@@ -131,6 +131,17 @@ Helpers that write sources as plain data:
|
|
|
131
131
|
- **`recombination({ material, tau, majority })`**: band-to-band recombination e⁻ + h⁺ ⇌ 0 as
|
|
132
132
|
a bulk reaction, from the low-injection minority lifetime it gives at that majority density,
|
|
133
133
|
$`k_f = 1/(\tau \cdot n_{\mathrm{maj}})`$.
|
|
134
|
+
- **`photogeneration({ material, flux, alpha, mu, from, to, makes })`**: light absorbed as it goes in
|
|
135
|
+
(Beer–Lambert). A photon flux (mol/(m²·s); a power P at photon energy E is
|
|
136
|
+
$`P/(E N_A)`$) enters the material at x = `from` heading toward +x and generates pairs at
|
|
137
|
+
$`G(x) = \Phi\alpha e^{-\alpha(x - \mathrm{from})}`$ until `to`, the material's far end. It's
|
|
138
|
+
photon ⇌ e⁻ + h⁺ from a photon reservoir at $`\mu`$ = `mu` (J/mol, well above the gap, e.g.
|
|
139
|
+
`units.eV(3)`), with `kf` a profile against x. `makes` replaces the right-hand side: in an
|
|
140
|
+
organic semiconductor, light makes neutral excitons, `makes: 'X'`. The table generates exactly what the light
|
|
141
|
+
loses, $`\Phi(1 - e^{-\alpha(\mathrm{to} - \mathrm{from})})`$, and the solver averages it
|
|
142
|
+
over each node's box, so the total stays right on a grid coarser than $`1/\alpha`$ (where
|
|
143
|
+
carriers start within a cell still needs the grid). Light through several materials is one
|
|
144
|
+
call per material, each with the flux that reaches it.
|
|
134
145
|
|
|
135
146
|
Read a transient at points inside the device with `advance(t, { probes })` (see the
|
|
136
147
|
[device reference](device.md#using-a-device)). And `units` converts back out of SI for display:
|
package/llms.txt
CHANGED
|
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
|
|
|
14
14
|
From a CDN, pin the version:
|
|
15
15
|
|
|
16
16
|
```js nocheck
|
|
17
|
-
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
18
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
19
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
17
|
+
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -45,7 +45,7 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
|
45
45
|
`EPS0`, `BOLTZMANN`, `ELEMENTARY_CHARGE`, `AVOGADRO` (there's no thermal-voltage constant:
|
|
46
46
|
V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
|
|
47
47
|
`level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
|
|
48
|
-
`injector`, `recombination`), the data library (`aqueous`, `semiconductor`,
|
|
48
|
+
`injector`, `recombination`, `photogeneration`), the data library (`aqueous`, `semiconductor`,
|
|
49
49
|
`metal`, `IONS`, …) and the vacuum-level helpers. `driftlet/plot`: `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`).
|
|
50
50
|
- **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
|
|
51
51
|
`new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
|
|
@@ -53,7 +53,9 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
|
53
53
|
with c in mol/m³ (and 1e16 cm⁻³ is only 0.0166 mol/m³). Band-to-band recombination,
|
|
54
54
|
`e- + h+ = 0`, gives a minority lifetime τ = 1/(kf·N) with N the majority density in mol/m³:
|
|
55
55
|
`kf: 1e8` is τ ≈ 0.6 µs at 1e16 cm⁻³. Generation is a reaction from a reservoir, rate `kf` in
|
|
56
|
-
mol/(m³·s) (`units.perCm3` converts cm⁻³·s⁻¹ too)
|
|
56
|
+
mol/(m³·s) (`units.perCm3` converts cm⁻³·s⁻¹ too); light absorbed as it goes in is
|
|
57
|
+
`photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })` (flux in
|
|
58
|
+
mol/(m²·s), α in 1/m), a `kf` that varies with x. A face reaction's `k0` is in mol/(m²·s);
|
|
57
59
|
its exchange current density is F·k0 per electron at the reference concentrations.
|
|
58
60
|
|
|
59
61
|
## Template: a semiconductor junction
|
|
@@ -221,7 +223,7 @@ console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPer
|
|
|
221
223
|
|
|
222
224
|
To start from a state instead of simulating how it got there (a packet already injected, a
|
|
223
225
|
salt gradient laid down), give a region's `c0` a profile against the device's x:
|
|
224
|
-
`c0: { 'h+': { x: [...],
|
|
226
|
+
`c0: { 'h+': { x: [...], values: [...] } }`, piecewise linear. A given `c0` is the starting state,
|
|
225
227
|
even for a species a contact feeds; species without one start at their contact's level, with φ
|
|
226
228
|
made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
|
|
227
229
|
state.
|
|
@@ -243,9 +245,9 @@ a Web Worker.
|
|
|
243
245
|
<span id="readout"></span>
|
|
244
246
|
<div id="figure"></div>
|
|
245
247
|
<script type="module">
|
|
246
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
247
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
248
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
248
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/index.js';
|
|
249
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/kit.js';
|
|
250
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.4.0/src/plot.js';
|
|
249
251
|
|
|
250
252
|
const def = build({
|
|
251
253
|
T: 300,
|
package/package.json
CHANGED
package/src/describe.js
CHANGED
|
@@ -78,7 +78,7 @@ export function unitWarnings(def) {
|
|
|
78
78
|
conc(`${path}.c0.${sname}`, c);
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
|
-
if (Array.isArray(c.
|
|
81
|
+
if (Array.isArray(c.values)) conc(`${path}.c0.${sname}.values`, Math.max(...c.values));
|
|
82
82
|
// A profile is against the device's x, not the region's own.
|
|
83
83
|
const x0 = regionX0[r], x1 = x0 + reg.length;
|
|
84
84
|
if (Array.isArray(c.x) && c.x.length > 0 && (c.x.at(-1) < x0 || c.x[0] > x1)) {
|
|
@@ -144,7 +144,7 @@ export function describe(def) {
|
|
|
144
144
|
species.forEach((sp, i) => {
|
|
145
145
|
if (!mat.present[i]) return;
|
|
146
146
|
if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
|
|
147
|
-
else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].
|
|
147
|
+
else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].values);
|
|
148
148
|
});
|
|
149
149
|
if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
|
|
150
150
|
const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
|
package/src/device.js
CHANGED
|
@@ -55,6 +55,21 @@ function positive(v, path) {
|
|
|
55
55
|
return v;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A profile against the device's x: { x, values }, piecewise linear between its points and
|
|
59
|
+
// constant beyond its ends (read with profileAt).
|
|
60
|
+
function profile(v, path, what, ok, okText) {
|
|
61
|
+
fields(v, path, ['x', 'values']);
|
|
62
|
+
need(
|
|
63
|
+
Array.isArray(v.x) && Array.isArray(v.values) && v.x.length > 0 && v.x.length === v.values.length,
|
|
64
|
+
`${path} must be a profile { x, values }: two arrays of the same length, positions (m, the device's x) and ${what}`,
|
|
65
|
+
);
|
|
66
|
+
v.x.forEach((xk, k) => {
|
|
67
|
+
need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${path}.x must be finite and strictly increasing, but x[${k}] is ${JSON.stringify(xk)}`);
|
|
68
|
+
});
|
|
69
|
+
v.values.forEach((y, k) => need(isFiniteNumber(y) && ok(y), `${path}.values[${k}] must be ${okText}, got ${JSON.stringify(y)}`));
|
|
70
|
+
return { x: Float64Array.from(v.x), values: Float64Array.from(v.values) };
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
function nonNegative(v, path) {
|
|
59
74
|
need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
|
|
60
75
|
return v;
|
|
@@ -179,8 +194,7 @@ export function normalizeDevice(def) {
|
|
|
179
194
|
);
|
|
180
195
|
}
|
|
181
196
|
// Initial composition: the starting state, and the conserved amount of any spectator. Each
|
|
182
|
-
// entry is a number
|
|
183
|
-
// beyond its ends), which is the starting state even for a species a contact feeds.
|
|
197
|
+
// entry is a number or a profile { x, values } against the device's x.
|
|
184
198
|
const c0 = new Float64Array(nSpecies).fill(NaN);
|
|
185
199
|
const c0Profile = new Array(nSpecies).fill(null);
|
|
186
200
|
if (reg.c0 !== undefined) {
|
|
@@ -192,19 +206,10 @@ export function normalizeDevice(def) {
|
|
|
192
206
|
need(mat.present[i], `${at}: '${sname}' is absent from material '${mat.name}'`);
|
|
193
207
|
const none = `; to have none of '${sname}' here, leave it out of material '${mat.name}' (a region of another material without it, if it's present elsewhere)`;
|
|
194
208
|
if (isObject(v)) {
|
|
195
|
-
|
|
196
|
-
need(
|
|
197
|
-
Array.isArray(v.x) && Array.isArray(v.c) && v.x.length > 0 && v.x.length === v.c.length,
|
|
198
|
-
`${at} must be a profile { x, c }: two arrays of the same length, positions (m, the device's x) and concentrations (mol/m³)`,
|
|
199
|
-
);
|
|
200
|
-
v.x.forEach((xk, k) => {
|
|
201
|
-
need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${at}.x must be finite and strictly increasing, but x[${k}] is ${JSON.stringify(xk)}`);
|
|
202
|
-
});
|
|
203
|
-
v.c.forEach((ck, k) => need(isFiniteNumber(ck) && ck > 0, `${at}.c[${k}] must be a concentration > 0 (mol/m³), got ${JSON.stringify(ck)}${none}`));
|
|
204
|
-
c0Profile[i] = { x: Float64Array.from(v.x), c: Float64Array.from(v.c) };
|
|
209
|
+
c0Profile[i] = profile(v, at, 'concentrations (mol/m³)', (ck) => ck > 0, `a concentration > 0 (mol/m³)${none}`);
|
|
205
210
|
continue;
|
|
206
211
|
}
|
|
207
|
-
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x,
|
|
212
|
+
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, values }, got ${JSON.stringify(v)}${none}`);
|
|
208
213
|
c0[i] = v;
|
|
209
214
|
}
|
|
210
215
|
}
|
|
@@ -326,11 +331,16 @@ function normalizeReaction(rdef, path, species, speciesIndex, materials, materia
|
|
|
326
331
|
need(st.list.length > 0, `${path}: no mobile participants`);
|
|
327
332
|
need(st.charge === 0, `${path}: charge is not balanced (Σ ν z = ${st.charge})`);
|
|
328
333
|
need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
|
|
329
|
-
|
|
334
|
+
// Each a number, or a profile { x, values } against the device's x (e.g. absorption); kf[m]
|
|
335
|
+
// is then its largest value, which says whether the reaction runs in that material at all.
|
|
336
|
+
const kf = new Float64Array(materials.length), kfProfile = new Array(materials.length).fill(null);
|
|
330
337
|
for (const [mname, v] of Object.entries(rdef.kf)) {
|
|
331
338
|
need(materialIndex.has(mname), `${path}.kf.${mname}: unknown material`);
|
|
332
339
|
const m = materialIndex.get(mname);
|
|
333
|
-
|
|
340
|
+
if (isObject(v)) {
|
|
341
|
+
kfProfile[m] = profile(v, `${path}.kf.${mname}`, 'rate constants', (k) => k >= 0, 'a rate constant ≥ 0');
|
|
342
|
+
kf[m] = Math.max(...kfProfile[m].values);
|
|
343
|
+
} else kf[m] = nonNegative(v, `${path}.kf.${mname}`);
|
|
334
344
|
for (const { i } of st.list) {
|
|
335
345
|
need(materials[m].present[i], `${path}.kf.${mname}: '${species[i].name}' is absent from material '${mname}'`);
|
|
336
346
|
}
|
|
@@ -342,6 +352,7 @@ function normalizeReaction(rdef, path, species, speciesIndex, materials, materia
|
|
|
342
352
|
products,
|
|
343
353
|
fixedA: st.fixedA, // fixed participants' share of A/RT
|
|
344
354
|
kf,
|
|
355
|
+
kfProfile,
|
|
345
356
|
// Species made only from (or turned only into) fixed reservoirs, such as photogeneration
|
|
346
357
|
// from a photon reservoir: a source that can hold the device far from equilibrium.
|
|
347
358
|
generation: st.nFixed > 0 && (reactants.length === 0 || products.length === 0),
|
package/src/kit.js
CHANGED
|
@@ -9,7 +9,7 @@ export { build, combine, layer, ohmic, bath } from './stack.js';
|
|
|
9
9
|
export { parseEquation, stoichiometry, half, SHE, level } from './equation.js';
|
|
10
10
|
export { traces, speciesRole, typeset } from './traces.js';
|
|
11
11
|
export { live } from './live.js';
|
|
12
|
-
export { pulse, square, triangle, ramp, injector, recombination } from './sources.js';
|
|
12
|
+
export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
|
|
13
13
|
export { describe, unitWarnings } from './describe.js';
|
|
14
14
|
export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
|
|
15
15
|
|
package/src/solver.js
CHANGED
|
@@ -148,8 +148,8 @@ import { nextBreakpoint, sourceAt } from './device.js';
|
|
|
148
148
|
import { DeviceError } from './errors.js';
|
|
149
149
|
import { powi, powr } from './pow.js';
|
|
150
150
|
|
|
151
|
-
// A
|
|
152
|
-
function profileAt({ x, c }, at) {
|
|
151
|
+
// A profile's value at x (c0, kf): piecewise linear, constant beyond its ends.
|
|
152
|
+
function profileAt({ x, values: c }, at) {
|
|
153
153
|
if (at <= x[0]) return c[0];
|
|
154
154
|
const last = x.length - 1;
|
|
155
155
|
if (at >= x[last]) return c[last];
|
|
@@ -162,6 +162,23 @@ function profileAt({ x, c }, at) {
|
|
|
162
162
|
return c[lo] + ((c[hi] - c[lo]) * (at - x[lo])) / (x[hi] - x[lo]);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// A profile's mean over [a, b], exactly (its integral is piecewise quadratic).
|
|
166
|
+
function profileMean(p, a, b) {
|
|
167
|
+
if (!(b > a)) return profileAt(p, a);
|
|
168
|
+
const { x, values: v } = p, last = x.length - 1;
|
|
169
|
+
// ∫ from x[0] to t.
|
|
170
|
+
const integral = (t) => {
|
|
171
|
+
if (t <= x[0]) return v[0] * (t - x[0]);
|
|
172
|
+
let sum = 0;
|
|
173
|
+
for (let k = 0; k < last; k++) {
|
|
174
|
+
if (t <= x[k + 1]) return sum + ((v[k] + profileAt(p, t)) / 2) * (t - x[k]);
|
|
175
|
+
sum += ((v[k] + v[k + 1]) / 2) * (x[k + 1] - x[k]);
|
|
176
|
+
}
|
|
177
|
+
return sum + v[last] * (t - x[last]);
|
|
178
|
+
};
|
|
179
|
+
return (integral(b) - integral(a)) / (b - a);
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
export class SolverError extends Error {
|
|
166
183
|
constructor(message, details) {
|
|
167
184
|
super(message);
|
|
@@ -339,8 +356,16 @@ export class Solver {
|
|
|
339
356
|
this.dtNext = undefined;
|
|
340
357
|
|
|
341
358
|
// Bulk reactions as flat participant lists: reactants with +ν, products with −ν.
|
|
359
|
+
// Each has its rate constant at every node: its material's, or its profile's mean over the
|
|
360
|
+
// node's box, so that what a sharply varying profile (strongly absorbed light) generates
|
|
361
|
+
// in all is exact on any grid.
|
|
362
|
+
const { x: gx, segLength } = model.grid;
|
|
342
363
|
this.rxs = model.reactions.map((rx) => ({
|
|
343
364
|
kf: rx.kf,
|
|
365
|
+
kfNode: Float64Array.from(gx, (x, g) => {
|
|
366
|
+
const m = this.nodeMaterial[g], p = rx.kfProfile[m];
|
|
367
|
+
return p ? profileMean(p, x - (g > 0 ? segLength[g - 1] : 0) / 2, x + (g < segLength.length ? segLength[g] : 0) / 2) : rx.kf[m];
|
|
368
|
+
}),
|
|
344
369
|
generation: rx.generation,
|
|
345
370
|
fixedA: rx.fixedA,
|
|
346
371
|
sp: Int32Array.from([...rx.reactants, ...rx.products], (p) => p.i),
|
|
@@ -765,7 +790,9 @@ export class Solver {
|
|
|
765
790
|
u.fill(0);
|
|
766
791
|
this.uLo.fill(0);
|
|
767
792
|
const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
|
|
768
|
-
|
|
793
|
+
// φ̂ runs from the left: from a pinned (or gated) left contact's φ at its zero charge, else 0.
|
|
794
|
+
const leftPhi = model.contacts.left.phi;
|
|
795
|
+
let phiHat = leftPhi.type === 'pinned' || leftPhi.type === 'capacitive' ? (this.termV[0] - leftPhi.zeroCharge) / this.VT : 0;
|
|
769
796
|
for (let r = 0; r < regions.length; r++) {
|
|
770
797
|
if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
|
|
771
798
|
const reg = regions[r], mat = materials[reg.material];
|
|
@@ -797,7 +824,8 @@ export class Solver {
|
|
|
797
824
|
} else if (!st.contactFed) {
|
|
798
825
|
if (!(reg.c0[i] > 0)) {
|
|
799
826
|
throw new SolverError(
|
|
800
|
-
`regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration
|
|
827
|
+
`regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration` +
|
|
828
|
+
(st.connected ? ' (reactions make and consume it, so this is only where the solve starts, not an amount it keeps: a tiny value will do)' : ', which fixes the amount it conserves'),
|
|
801
829
|
);
|
|
802
830
|
}
|
|
803
831
|
mode[i] = 2;
|
|
@@ -880,6 +908,9 @@ export class Solver {
|
|
|
880
908
|
let lo = ph - 1, hi = ph + 1;
|
|
881
909
|
while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
|
|
882
910
|
while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
|
|
911
|
+
// Out of reach (every responding carrier has one sign and nothing balances it, as in an
|
|
912
|
+
// undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
|
|
913
|
+
if (charge(lo) < 0 || charge(hi) > 0) return ph;
|
|
883
914
|
for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
|
|
884
915
|
const m = 0.5 * (lo + hi);
|
|
885
916
|
if (charge(m) > 0) lo = m;
|
|
@@ -1269,11 +1300,11 @@ export class Solver {
|
|
|
1269
1300
|
// (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
|
|
1270
1301
|
_bulkReactions(g, rxs) {
|
|
1271
1302
|
const { n, M, u, uLo, res, c } = this, R = this.rix;
|
|
1272
|
-
const b = this.blockOfNode[g], v = this.model.grid.vol[g]
|
|
1303
|
+
const b = this.blockOfNode[g], v = this.model.grid.vol[g];
|
|
1273
1304
|
const dr = this.dA;
|
|
1274
1305
|
for (let x = 0; x < rxs.length; x++) {
|
|
1275
1306
|
const rx = rxs[x];
|
|
1276
|
-
let P = rx.generation ? rx.
|
|
1307
|
+
let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
|
|
1277
1308
|
dr.fill(0); // ∂ ln P / ∂slot
|
|
1278
1309
|
// Participants: reactants with ν > 0, then products with ν < 0.
|
|
1279
1310
|
for (let p = 0; p < rx.sp.length; p++) {
|
package/src/sources.js
CHANGED
|
@@ -101,3 +101,39 @@ export function recombination({ material, tau, majority } = {}) {
|
|
|
101
101
|
need(num(majority) && majority > 0, `recombination: majority must be the majority carrier density > 0 (mol/m³; units.perCm3 converts), got ${majority}`);
|
|
102
102
|
return { equation: 'e- + h+ = 0', kf: { [material]: 1 / (tau * majority) } };
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Photogeneration by light absorbed in a material (Beer–Lambert): a photon flux entering at x =
|
|
107
|
+
* `from` and travelling toward +x, generating pairs at G(x) = flux · α · e^{−α(x − from)} up to
|
|
108
|
+
* x = `to`. Written as photon ⇌ e⁻ + h⁺ (or whatever `makes` says, such as an exciton 'X') from a
|
|
109
|
+
* photon reservoir at μ = `mu` (J/mol; well above what it makes, so the rate is G itself), with
|
|
110
|
+
* kf as a profile against the device's x. The table is
|
|
111
|
+
* exact in what it generates in all (each segment's mean is the exponential's), and the solver
|
|
112
|
+
* averages it over each node's box, so the total is right on any grid.
|
|
113
|
+
* @param {{ material: string, flux: number, alpha: number, mu: number, from?: number, to: number, makes?: string }} opts
|
|
114
|
+
* material name; photons absorbed, mol/(m²·s); absorption coefficient, 1/m; the photons' μ,
|
|
115
|
+
* J/mol; where the light enters and where the material ends (m, the device's x); the
|
|
116
|
+
* reaction's right-hand side (default 'e- + h+')
|
|
117
|
+
* @returns {{ equation: string, fixed: Record<string, number>, kf: Record<string, { x: number[], values: number[] }> }} a bulk reaction
|
|
118
|
+
*/
|
|
119
|
+
export function photogeneration({ material, flux, alpha, mu, from = 0, to, makes = 'e- + h+' } = {}) {
|
|
120
|
+
need(typeof material === 'string', 'photogeneration: give the material, by name');
|
|
121
|
+
need(num(flux) && flux >= 0, `photogeneration: flux must be the photon flux ≥ 0 entering the material (mol/(m²·s)), got ${flux}`);
|
|
122
|
+
need(num(alpha) && alpha > 0, `photogeneration: alpha must be the absorption coefficient > 0 (1/m; 1/cm × 100), got ${alpha}`);
|
|
123
|
+
need(num(mu), `photogeneration: mu must be the photons' μ (J/mol), well above the gap: units.eV(3), say`);
|
|
124
|
+
need(typeof makes === 'string' && makes.trim() !== '', `photogeneration: makes must be what the light makes, as an equation's side ('e- + h+', 'X'), got ${makes}`);
|
|
125
|
+
need(num(from) && num(to) && to > from, `photogeneration: from and to must be positions with to > from (m, the device's x), got ${from} and ${to}`);
|
|
126
|
+
// Evenly spaced points, at most 0.05 absorption lengths apart, out to 50 of them (beyond,
|
|
127
|
+
// nothing is left), then `to`.
|
|
128
|
+
const depth = alpha * (to - from), reach = Math.min(depth, 50), n = Math.ceil(reach / 0.05), step = reach / n;
|
|
129
|
+
const taus = Array.from({ length: n + 1 }, (_, k) => k * step);
|
|
130
|
+
if (depth > 50) taus.push(depth);
|
|
131
|
+
// A straight line between two points of e^{−τ} encloses (Δτ/2)·coth(Δτ/2) times too much: each
|
|
132
|
+
// value is scaled back by that, so the total is the exponential's own (to ~Δτ⁴).
|
|
133
|
+
const fix = (step / 2) / Math.tanh(step / 2);
|
|
134
|
+
return {
|
|
135
|
+
equation: `photon = ${makes}`,
|
|
136
|
+
fixed: { photon: mu },
|
|
137
|
+
kf: { [material]: { x: taus.map((t) => from + t / alpha), values: taus.map((t) => (flux * alpha * Math.exp(-t)) / fix) } },
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/types.js
CHANGED
|
@@ -51,13 +51,18 @@
|
|
|
51
51
|
* @property {number} [hRight] right-end cell size, overriding hmin
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A table against the device's x (m): piecewise linear between its points, constant beyond them.
|
|
56
|
+
* @typedef {{ x: number[], values: number[] }} Profile
|
|
57
|
+
*/
|
|
58
|
+
|
|
54
59
|
/**
|
|
55
60
|
* @typedef {object} RegionDef
|
|
56
61
|
* @property {string} material material name
|
|
57
62
|
* @property {number} length m
|
|
58
63
|
* @property {string} [name]
|
|
59
64
|
* @property {number} [fixedCharge] immobile charge density, C/m³
|
|
60
|
-
* @property {Record<string, number |
|
|
65
|
+
* @property {Record<string, number | Profile>} [c0] initial concentrations,
|
|
61
66
|
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
62
67
|
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
63
68
|
* @property {number} [velocity] imposed flow toward +x, m/s, carrying every mobile species
|
|
@@ -99,7 +104,8 @@
|
|
|
99
104
|
* @property {Record<string, number>} [nu] signed stoichiometric coefficients (ν < 0 consumed)
|
|
100
105
|
* @property {string} [equation] the reaction as an equation, e.g. 'e- + h+ = 0' (instead of nu)
|
|
101
106
|
* @property {Record<string, number>} [fixed] μ (J/mol) of fixed-activity participants that aren't species
|
|
102
|
-
* @property {Record<string, number>} kf forward rate constant per material name
|
|
107
|
+
* @property {Record<string, number | Profile>} kf forward rate constant per material name, or a
|
|
108
|
+
* profile of it against the device's x (an absorption profile, for photogeneration)
|
|
103
109
|
*/
|
|
104
110
|
|
|
105
111
|
/**
|
package/types/kit.d.ts
CHANGED
|
@@ -57,6 +57,6 @@ export type VacuumSide = {
|
|
|
57
57
|
export { build, combine, layer, ohmic, bath } from "./stack.js";
|
|
58
58
|
export { parseEquation, stoichiometry, half, SHE, level } from "./equation.js";
|
|
59
59
|
export { traces, speciesRole, typeset } from "./traces.js";
|
|
60
|
-
export { pulse, square, triangle, ramp, injector, recombination } from "./sources.js";
|
|
60
|
+
export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from "./sources.js";
|
|
61
61
|
export { describe, unitWarnings } from "./describe.js";
|
|
62
62
|
export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from "./data.js";
|
package/types/sources.d.ts
CHANGED
|
@@ -100,3 +100,33 @@ export function recombination({ material, tau, majority }?: {
|
|
|
100
100
|
equation: string;
|
|
101
101
|
kf: Record<string, number>;
|
|
102
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* Photogeneration by light absorbed in a material (Beer–Lambert): a photon flux entering at x =
|
|
105
|
+
* `from` and travelling toward +x, generating pairs at G(x) = flux · α · e^{−α(x − from)} up to
|
|
106
|
+
* x = `to`. Written as photon ⇌ e⁻ + h⁺ (or whatever `makes` says, such as an exciton 'X') from a
|
|
107
|
+
* photon reservoir at μ = `mu` (J/mol; well above what it makes, so the rate is G itself), with
|
|
108
|
+
* kf as a profile against the device's x. The table is
|
|
109
|
+
* exact in what it generates in all (each segment's mean is the exponential's), and the solver
|
|
110
|
+
* averages it over each node's box, so the total is right on any grid.
|
|
111
|
+
* @param {{ material: string, flux: number, alpha: number, mu: number, from?: number, to: number, makes?: string }} opts
|
|
112
|
+
* material name; photons absorbed, mol/(m²·s); absorption coefficient, 1/m; the photons' μ,
|
|
113
|
+
* J/mol; where the light enters and where the material ends (m, the device's x); the
|
|
114
|
+
* reaction's right-hand side (default 'e- + h+')
|
|
115
|
+
* @returns {{ equation: string, fixed: Record<string, number>, kf: Record<string, { x: number[], values: number[] }> }} a bulk reaction
|
|
116
|
+
*/
|
|
117
|
+
export function photogeneration({ material, flux, alpha, mu, from, to, makes }?: {
|
|
118
|
+
material: string;
|
|
119
|
+
flux: number;
|
|
120
|
+
alpha: number;
|
|
121
|
+
mu: number;
|
|
122
|
+
from?: number;
|
|
123
|
+
to: number;
|
|
124
|
+
makes?: string;
|
|
125
|
+
}): {
|
|
126
|
+
equation: string;
|
|
127
|
+
fixed: Record<string, number>;
|
|
128
|
+
kf: Record<string, {
|
|
129
|
+
x: number[];
|
|
130
|
+
values: number[];
|
|
131
|
+
}>;
|
|
132
|
+
};
|
package/types/types.d.ts
CHANGED
|
@@ -115,6 +115,13 @@ export type GridOptions = {
|
|
|
115
115
|
*/
|
|
116
116
|
hRight?: number;
|
|
117
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* A table against the device's x (m): piecewise linear between its points, constant beyond them.
|
|
120
|
+
*/
|
|
121
|
+
export type Profile = {
|
|
122
|
+
x: number[];
|
|
123
|
+
values: number[];
|
|
124
|
+
};
|
|
118
125
|
export type RegionDef = {
|
|
119
126
|
/**
|
|
120
127
|
* material name
|
|
@@ -134,10 +141,7 @@ export type RegionDef = {
|
|
|
134
141
|
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
135
142
|
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
136
143
|
*/
|
|
137
|
-
c0?: Record<string, number |
|
|
138
|
-
x: number[];
|
|
139
|
-
c: number[];
|
|
140
|
-
}>;
|
|
144
|
+
c0?: Record<string, number | Profile>;
|
|
141
145
|
/**
|
|
142
146
|
* imposed flow toward +x, m/s, carrying every mobile species
|
|
143
147
|
*/
|
|
@@ -239,9 +243,10 @@ export type BulkReactionDef = {
|
|
|
239
243
|
*/
|
|
240
244
|
fixed?: Record<string, number>;
|
|
241
245
|
/**
|
|
242
|
-
* forward rate constant per material name
|
|
246
|
+
* forward rate constant per material name, or a
|
|
247
|
+
* profile of it against the device's x (an absorption profile, for photogeneration)
|
|
243
248
|
*/
|
|
244
|
-
kf: Record<string, number>;
|
|
249
|
+
kf: Record<string, number | Profile>;
|
|
245
250
|
};
|
|
246
251
|
/**
|
|
247
252
|
* Contacts use the same laws as internal faces; the outside is a phase with known levels
|