driftlet 0.3.0 → 0.5.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 +10 -5
- package/docs/device.md +40 -6
- package/docs/kit.md +17 -0
- package/docs/numerics.md +6 -2
- package/docs/visualization.md +9 -0
- package/llms.txt +21 -11
- package/package.json +1 -1
- package/src/describe.js +5 -4
- package/src/device.js +76 -18
- package/src/index.js +11 -2
- package/src/kit.js +1 -1
- package/src/plot.js +3 -2
- package/src/solution.js +1 -1
- package/src/solver.js +172 -18
- package/src/sources.js +36 -0
- package/src/traces.js +23 -10
- package/src/types.js +8 -2
- package/types/index.d.ts +3 -2
- package/types/kit.d.ts +1 -1
- package/types/plot.d.ts +2 -1
- package/types/solver.d.ts +10 -0
- package/types/sources.d.ts +30 -0
- package/types/traces.d.ts +7 -2
- 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) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`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.5.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) | [](https://markblundeberg.github.io/driftlet/demos/perovskite.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,10 @@ 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 |
|
|
238
|
+
| SRH recombination | bulk: G = R_SRH(n, p) with n = N_D + p, midgap and shallow traps, low to high injection; at a face: zero in the dark, the law between the two edge nodes, every pair collected or recombined | 1e-6; 1e-9 |
|
|
239
|
+
| Perovskite J–V hysteresis, against IonMonger | an independent code (finite elements, Octave): mobile iodide vacancies, interface SRH, scans from 1 mV/s to 1 kV/s — hysteresis index, P_max, V_oc, the whole loop | 1.5e-3; 0.05 mW/cm²; 2 mV; 0.3 mA/cm² |
|
|
235
240
|
| 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
241
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
237
242
|
| 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()`
|
|
@@ -203,13 +203,29 @@ Butler–Volmer,
|
|
|
203
203
|
r = k_0 \prod_{\nu<0} (c/c_{\mathrm{ref}})^{|\nu|(1-\alpha)} \prod_{\nu>0} (c/c_{\mathrm{ref}})^{\nu\alpha} \left(e^{\alpha a} - e^{-(1-\alpha)a}\right)
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
with $`k_0`$ in mol/(m²·s) and $`\alpha`$ (default 0.5)
|
|
206
|
+
with $`k_0`$ in mol/(m²·s) and $`\alpha`$ (default 0.5) from 0 to 1 inclusive ($`\alpha = 0`$ makes the
|
|
207
|
+
forward rate plain mass action, independent of the energetics). A conductor's carrier has
|
|
207
208
|
activity 1 and no factor. That's mass action with rate constants that depend on the electrical part
|
|
208
209
|
of the affinity: exactly zero at $`A = 0`$, whatever $`k_0`$ and $`\alpha`$. Each participant is made or
|
|
209
210
|
consumed at its side's edge node, just behind any Stern layer, so Frumkin effects arise by
|
|
210
211
|
themselves. A face can carry several reactions; a conductor coupled to two couples settles at their
|
|
211
212
|
mixed potential. Each solution reports the rates as `interfaces[f].rates`.
|
|
212
213
|
|
|
214
|
+
**Interface recombination through traps** (SRH at a face) replaces `k0` and `alpha` with
|
|
215
|
+
`srh: { vn, vp, n1 }`, on a reaction consuming one negative and one positive species and making
|
|
216
|
+
none, such as `'e-(left) + h+(right) = 0'`:
|
|
217
|
+
|
|
218
|
+
```math
|
|
219
|
+
r = \frac{n\,p\,(1 - e^{-A/RT})}{(n + n_1)/v_p + (p + p_1)/v_n}, \qquad p_1 = \frac{n\,p\,e^{-A/RT}}{n_1}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
with n and p each at its own side's edge node, the capture velocities $`v_n`$ and $`v_p`$ in m/s,
|
|
223
|
+
and $`n_1`$ (mol/m³) the negative species' concentration with its level at the trap (default:
|
|
224
|
+
$`n_1 = p_1 = n_i`$, a midgap trap). Taking $`p_1`$ from the state keeps equilibrium exact whatever
|
|
225
|
+
lies between the two sides (a band offset, a $`\phi`$ jump). Where one carrier is plentiful the
|
|
226
|
+
rate saturates at the other's capture, $`r \to v_p\,p`$: holes reaching an electron-rich layer
|
|
227
|
+
recombine at $`v_p`$ however many electrons wait there, which plain mass action can't do.
|
|
228
|
+
|
|
213
229
|
### Faces next to a conductor
|
|
214
230
|
|
|
215
231
|
A conductor has no $`\phi`$, so a face beside it can't take a `dipole` or `step` alignment. Its $`\phi`$
|
|
@@ -253,17 +269,34 @@ $`A = 0`$. `kf` maps material
|
|
|
253
269
|
names to forward rate constants (units making r mol/(m³·s)), and the reaction runs only in
|
|
254
270
|
those materials.
|
|
255
271
|
|
|
272
|
+
**SRH (trap-assisted) recombination** replaces `kf` with `srh`, a map from material names to
|
|
273
|
+
`{ tauN, tauP, n1 }`, on a reaction consuming one negative and one positive species and making
|
|
274
|
+
none (`'e- + h+ = 0'`):
|
|
275
|
+
$`r = n p (1 - e^{-A/RT}) / (\tau_p (n + n_1) + \tau_n (p + p_1))`$, with lifetimes in s and $`n_1`$ as
|
|
276
|
+
for [interface SRH](#interfaces) (default midgap). It's what explicit trap species reduce to when
|
|
277
|
+
the traps are few and fast, without them.
|
|
278
|
+
|
|
279
|
+
```js nocheck
|
|
280
|
+
bulkReactions: [{ equation: 'e- + h+ = 0', srh: { Si: { tauN: 1e-6, tauP: 1e-6 } } }]
|
|
281
|
+
```
|
|
282
|
+
|
|
256
283
|
**Generation** is a reaction from a reservoir: photogeneration is
|
|
257
284
|
$`\text{photon} \to \mathrm{e}^- + \mathrm{h}^+`$, with the photons a fixed participant whose $`\mu`$
|
|
258
285
|
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
|
|
286
|
+
generation rate (mol/(m³·s)). The photons' $`\mu`$ is the honest part: light is a reservoir far from the
|
|
261
287
|
device's temperature, and the rate still vanishes at $`A = 0`$.
|
|
262
288
|
|
|
263
289
|
```js nocheck
|
|
264
290
|
bulkReactions: [{ equation: 'photon = e- + h+', fixed: { photon: units.eV(3) }, kf: { Si: 0.1 } }]
|
|
265
291
|
```
|
|
266
292
|
|
|
293
|
+
A rate constant can also vary with position: give it as a profile `{ x: [...], values: [...] }`
|
|
294
|
+
against the device's x (the same tables as `c0`), piecewise linear and constant beyond its ends,
|
|
295
|
+
used only at that material's nodes. Each node takes the profile's mean over its box (not its
|
|
296
|
+
value at the node), so the total rate is exact on any grid: light absorbed within a fraction of
|
|
297
|
+
a cell still generates what it should. The kit's `photogeneration()` writes Beer–Lambert
|
|
298
|
+
absorption this way.
|
|
299
|
+
|
|
267
300
|
## Contacts
|
|
268
301
|
|
|
269
302
|
A contact is an interface whose far side is an **outside phase with known levels**: think of it as
|
|
@@ -480,7 +513,8 @@ const now = dev.solution(); // snapshot of the current state
|
|
|
480
513
|
be the one measured: the resistance belongs to the external circuit. With `profiles: true`, each
|
|
481
514
|
frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
|
|
482
515
|
excitation.
|
|
483
|
-
- `set(patch)` merges plain objects deeply (arrays are replaced)
|
|
516
|
+
- `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
|
|
517
|
+
or `I` drops the other: `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A patch that changes only the
|
|
484
518
|
terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
|
|
485
519
|
Otherwise the device is rebuilt: the current state carries over while the grid and species
|
|
486
520
|
are unchanged, or restarts from the regions' `c0`. A carried state keeps what it holds: a
|
|
@@ -529,7 +563,7 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
|
|
|
529
563
|
| `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
|
|
530
564
|
| `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
|
|
531
565
|
| `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
|
|
532
|
-
| `interfaces[f]` | `{ dipole, sheetCharge, D, N: {name}, rates }`: what crosses
|
|
566
|
+
| `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates }`: the names of the regions the face joins, what crosses it by its links, and each face reaction's rate (mol/(m²·s)) |
|
|
533
567
|
| `charge` | total charge in the device, C/m² |
|
|
534
568
|
| `conservation` | per species stretch: amount, reference, intake through contacts, drift |
|
|
535
569
|
| `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, and for a failed solve, where the system is nearly singular |
|
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:
|
|
@@ -178,6 +189,12 @@ a level diagram:
|
|
|
178
189
|
shown (`speciesRole(sol, name)` gives a species' own). Undefined values are `NaN` (break the line
|
|
179
190
|
there), and doubled interface nodes share an x, so steps draw as vertical lines. It also gives
|
|
180
191
|
the regions (`{ name, material, x0, x1 }`), the faces' positions and a suggested range.
|
|
192
|
+
- `energy: true` (in `traces` and `bandDiagram`) gives the familiar band diagram instead, energy
|
|
193
|
+
up: electrons and holes as electron energies in eV (E_c and E_v from the standard levels, E_Fn
|
|
194
|
+
and E_Fp, or E_F where there are no holes, from the levels), with redox levels and $`\phi`$ (as
|
|
195
|
+
$`-q\phi`$) turned over too and shifts in eV. Ions have no band, so asking for one is an error.
|
|
196
|
+
It's the right picture for semiconductor devices; species voltages earn their place where ions
|
|
197
|
+
share the diagram (see [which to use](visualization.md)).
|
|
181
198
|
- `bandDiagram(sol, opts)` in `driftlet/plot` draws them as an SVG string, with no DOM needed, in
|
|
182
199
|
the style [reading level diagrams](visualization.md) explains: species voltages thick and solid, standard levels thin and solid, redox
|
|
183
200
|
levels thick and dashed, standard redox levels thin and dashed, $`\phi`$ thin, dotted and grey;
|
package/docs/numerics.md
CHANGED
|
@@ -545,8 +545,12 @@ take about twice as long as before.
|
|
|
545
545
|
larger by $`D \cdot dt/(hL)`$, around 1e12 for a micron-scale cell (see
|
|
546
546
|
[metal regions](#metal-regions) for the same cancellation). Newton then needs dozens of
|
|
547
547
|
iterations, or fails.
|
|
548
|
-
- **
|
|
549
|
-
|
|
548
|
+
- **Immobile combinations** (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) conserve
|
|
549
|
+
node by node, since nothing carries them anywhere. At each node the combination's weighted sum
|
|
550
|
+
replaces one of its balance rows, kept at what it was when the solve began. The row is local
|
|
551
|
+
to that node's block, so nothing is bordered, and the solve goes direct.
|
|
552
|
+
- **Otherwise** (an immobile spectator on its own, or a combination that includes a floating
|
|
553
|
+
conductor or mixes mobile and immobile stretches), backward-Euler steps at a huge $`dt`$ (10⁶ × the slowest
|
|
550
554
|
diffusion time) keep the storage term, which pins each conserved amount exactly: sum a
|
|
551
555
|
species' rows and the fluxes cancel. $`dt`$ grows ×10 (capped) while the state still moves.
|
|
552
556
|
Before each huge step, each spectator's level is shifted uniformly to restore its amount
|
package/docs/visualization.md
CHANGED
|
@@ -7,6 +7,15 @@ electronic levels, Fermi levels and redox levels, which connect directly to elec
|
|
|
7
7
|
and other familiar quantities. The second half covers the species voltages of every charged
|
|
8
8
|
species, ions included. For how to draw them, see [the kit's plotting helpers](kit.md#plotting).
|
|
9
9
|
|
|
10
|
+
**Which to use.** For electrons and holes alone (a pn junction, a solar cell, a MOS capacitor),
|
|
11
|
+
draw the familiar band diagram, energy up: `bandDiagram(sol, { energy: true })` gives E_c and
|
|
12
|
+
E_v (thin) and the quasi-Fermi levels E_Fn and E_Fp (thick), in eV, with the conduction band on
|
|
13
|
+
top. Species voltages are the same lines in volts, turned over, and don't add anything there.
|
|
14
|
+
They earn their place where the familiar picture can't go: ions and other charged species that
|
|
15
|
+
have no band (a vacancy's level drifting out of flat as the ions lag a voltage sweep), and
|
|
16
|
+
questions of equilibrium between species (an electrode against a redox couple). Bring them in
|
|
17
|
+
there, as an extra tool, not in place of the band diagram.
|
|
18
|
+
|
|
10
19
|
## Electron levels
|
|
11
20
|
|
|
12
21
|
### The Fermi level
|
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.5.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -45,15 +45,20 @@ 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-`).
|
|
52
52
|
- **Rate constants carry units.** A bulk reaction's rate is `kf · Π c_reactants` in mol/(m³·s),
|
|
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
|
-
`kf: 1e8` is τ ≈ 0.6 µs at 1e16 cm⁻³.
|
|
56
|
-
|
|
55
|
+
`kf: 1e8` is τ ≈ 0.6 µs at 1e16 cm⁻³. Trap-assisted (SRH) recombination is
|
|
56
|
+
`{ equation: 'e- + h+ = 0', srh: { Si: { tauN, tauP } } }` (lifetimes in s), and at a face
|
|
57
|
+
`{ equation: 'e-(left) + h+(right) = 0', srh: { vn, vp } }` (velocities in m/s); both default to a
|
|
58
|
+
midgap trap. Generation is a reaction from a reservoir, rate `kf` in
|
|
59
|
+
mol/(m³·s) (`units.perCm3` converts cm⁻³·s⁻¹ too); light absorbed as it goes in is
|
|
60
|
+
`photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })` (flux in
|
|
61
|
+
mol/(m²·s), α in 1/m), a `kf` that varies with x. A face reaction's `k0` is in mol/(m²·s);
|
|
57
62
|
its exchange current density is F·k0 per electron at the reference concentrations.
|
|
58
63
|
|
|
59
64
|
## Template: a semiconductor junction
|
|
@@ -221,7 +226,7 @@ console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPer
|
|
|
221
226
|
|
|
222
227
|
To start from a state instead of simulating how it got there (a packet already injected, a
|
|
223
228
|
salt gradient laid down), give a region's `c0` a profile against the device's x:
|
|
224
|
-
`c0: { 'h+': { x: [...],
|
|
229
|
+
`c0: { 'h+': { x: [...], values: [...] } }`, piecewise linear. A given `c0` is the starting state,
|
|
225
230
|
even for a species a contact feeds; species without one start at their contact's level, with φ
|
|
226
231
|
made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
|
|
227
232
|
state.
|
|
@@ -243,9 +248,9 @@ a Web Worker.
|
|
|
243
248
|
<span id="readout"></span>
|
|
244
249
|
<div id="figure"></div>
|
|
245
250
|
<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.
|
|
251
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/index.js';
|
|
252
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/kit.js';
|
|
253
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.5.0/src/plot.js';
|
|
249
254
|
|
|
250
255
|
const def = build({
|
|
251
256
|
T: 300,
|
|
@@ -257,7 +262,7 @@ const def = build({
|
|
|
257
262
|
const slider = document.getElementById('V');
|
|
258
263
|
const dev = live(def, {
|
|
259
264
|
onsolution(sol, info) {
|
|
260
|
-
document.getElementById('figure').innerHTML = bandDiagram(sol, { title: `V = ${sol.terminals.right.V.toFixed(2)} V` });
|
|
265
|
+
document.getElementById('figure').innerHTML = bandDiagram(sol, { energy: true, title: `V = ${sol.terminals.right.V.toFixed(2)} V` });
|
|
261
266
|
document.getElementById('readout').textContent = info.failed ? 'no solution here' : `I = ${sol.current.toExponential(2)} A/m²`;
|
|
262
267
|
},
|
|
263
268
|
});
|
|
@@ -270,6 +275,11 @@ slider.addEventListener('input', () => dev.set({ contacts: { right: { V: +slider
|
|
|
270
275
|
What `bandDiagram()` and `traces()` draw, and how to explain it to a reader (docs/visualization.md
|
|
271
276
|
has the long form):
|
|
272
277
|
|
|
278
|
+
- **Which picture.** For a device of electrons and holes only, draw the familiar band diagram,
|
|
279
|
+
energy up: `bandDiagram(sol, { energy: true })` (E_c, E_v, E_Fn, E_Fp in eV). The species
|
|
280
|
+
voltages below are the same lines in volts, turned over; use them where ions share the diagram,
|
|
281
|
+
or for equilibrium between species, and don't present them in place of the band diagram.
|
|
282
|
+
|
|
273
283
|
- **Fermi level** V_e⁻ = −μ̄_e⁻/F: the circuit voltage, what a voltmeter reads. A cell's voltage is
|
|
274
284
|
the difference between its terminals' V_e⁻.
|
|
275
285
|
- **Redox level** of Ox + z e⁻ ⇌ Red, `level(sol, half)`: V_e⁻(Ox/Red) = (μ̄_Ox − μ̄_Red)/(zF), where
|
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)) {
|
|
@@ -139,14 +139,15 @@ export function describe(def) {
|
|
|
139
139
|
}
|
|
140
140
|
const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
|
|
141
141
|
if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
|
|
142
|
-
// The screening concentration: Σ z²c from the initial composition,
|
|
142
|
+
// The screening concentration: Σ z²c from the initial composition, plus the carriers that
|
|
143
|
+
// balance the fixed charge (doping), which c0 doesn't list.
|
|
143
144
|
let zzc = 0;
|
|
144
145
|
species.forEach((sp, i) => {
|
|
145
146
|
if (!mat.present[i]) return;
|
|
146
147
|
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].
|
|
148
|
+
else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].values);
|
|
148
149
|
});
|
|
149
|
-
|
|
150
|
+
zzc += Math.abs(reg.fixedCharge) / FARADAY; // the mobile charge that balances the doping
|
|
150
151
|
const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
|
|
151
152
|
const Dmin = Math.min(...species.map((sp, i) => (mat.present[i] && mat.D[i] > 0 ? mat.D[i] : Infinity)));
|
|
152
153
|
if (mat.epsr > 0 && zzc > 0) {
|
package/src/device.js
CHANGED
|
@@ -55,6 +55,44 @@ 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
|
+
|
|
73
|
+
// SRH (trap-assisted) kinetics in place of mass action, for a reaction that consumes exactly one
|
|
74
|
+
// negative and one positive species and makes none, e⁻ + h⁺ = 0:
|
|
75
|
+
// r = n p (1 − e^{−A/RT}) / (t_p (n + n₁) + t_n (p + p₁)), p₁ = n p e^{−A/RT} / n₁,
|
|
76
|
+
// with t = τ (s) in the bulk, 1/v (m/s) at a face, and n₁ the negative species' concentration
|
|
77
|
+
// with its level at the trap (default n_i, a midgap trap). p₁ from the state keeps detailed
|
|
78
|
+
// balance exact, whatever lies between the two (a band offset, a φ jump).
|
|
79
|
+
function srhLaw(sdef, path, list, species, timeKeys) {
|
|
80
|
+
const [kn, kp] = timeKeys;
|
|
81
|
+
need(isObject(sdef), `${path} must be { ${kn}, ${kp}, n1 }`);
|
|
82
|
+
fields(sdef, path, [kn, kp, 'n1']);
|
|
83
|
+
const reactants = list.filter((p) => p.nu < 0);
|
|
84
|
+
need(
|
|
85
|
+
list.length === 2 && reactants.length === 2 && reactants.every((p) => p.nu === -1) && species[list[0].i].z * species[list[1].i].z < 0,
|
|
86
|
+
`${path}: SRH kinetics needs a reaction consuming one negative and one positive species and making none, such as 'e- + h+ = 0'`,
|
|
87
|
+
);
|
|
88
|
+
const neg = species[list[0].i].z < 0 ? list[0] : list[1], pos = neg === list[0] ? list[1] : list[0];
|
|
89
|
+
const time = (key) => {
|
|
90
|
+
const v = positive(sdef[key], `${path}.${key}`);
|
|
91
|
+
return key.startsWith('v') ? 1 / v : v; // a velocity's reciprocal is the time it stands for
|
|
92
|
+
};
|
|
93
|
+
return { n: neg, p: pos, tn: time(kn), tp: time(kp), n1: sdef.n1 === undefined ? NaN : positive(sdef.n1, `${path}.n1`) };
|
|
94
|
+
}
|
|
95
|
+
|
|
58
96
|
function nonNegative(v, path) {
|
|
59
97
|
need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
|
|
60
98
|
return v;
|
|
@@ -179,8 +217,7 @@ export function normalizeDevice(def) {
|
|
|
179
217
|
);
|
|
180
218
|
}
|
|
181
219
|
// 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.
|
|
220
|
+
// entry is a number or a profile { x, values } against the device's x.
|
|
184
221
|
const c0 = new Float64Array(nSpecies).fill(NaN);
|
|
185
222
|
const c0Profile = new Array(nSpecies).fill(null);
|
|
186
223
|
if (reg.c0 !== undefined) {
|
|
@@ -192,19 +229,10 @@ export function normalizeDevice(def) {
|
|
|
192
229
|
need(mat.present[i], `${at}: '${sname}' is absent from material '${mat.name}'`);
|
|
193
230
|
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
231
|
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) };
|
|
232
|
+
c0Profile[i] = profile(v, at, 'concentrations (mol/m³)', (ck) => ck > 0, `a concentration > 0 (mol/m³)${none}`);
|
|
205
233
|
continue;
|
|
206
234
|
}
|
|
207
|
-
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x,
|
|
235
|
+
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, values }, got ${JSON.stringify(v)}${none}`);
|
|
208
236
|
c0[i] = v;
|
|
209
237
|
}
|
|
210
238
|
}
|
|
@@ -319,18 +347,38 @@ export function normalizeDevice(def) {
|
|
|
319
347
|
// reaction runs only in those materials).
|
|
320
348
|
function normalizeReaction(rdef, path, species, speciesIndex, materials, materialIndex, regions, RT) {
|
|
321
349
|
need(isObject(rdef), `${path} must be an object`);
|
|
322
|
-
fields(rdef, path, ['nu', 'equation', 'fixed', 'kf']);
|
|
350
|
+
fields(rdef, path, ['nu', 'equation', 'fixed', 'kf', 'srh']);
|
|
323
351
|
need((rdef.nu === undefined) !== (rdef.equation === undefined), `${path}: give the reaction as nu or as an equation, one of them`);
|
|
324
352
|
const nu = rdef.nu ?? equationStoichiometry(rdef.equation, `${path}.equation`);
|
|
325
353
|
const st = stoichiometry(nu, rdef.equation === undefined ? `${path}.nu` : `${path}.equation`, rdef.fixed, `${path}.fixed`, species, speciesIndex, RT);
|
|
326
354
|
need(st.list.length > 0, `${path}: no mobile participants`);
|
|
327
355
|
need(st.charge === 0, `${path}: charge is not balanced (Σ ν z = ${st.charge})`);
|
|
356
|
+
// SRH kinetics instead: srh maps material names to { tauN, tauP, n1 }.
|
|
357
|
+
need((rdef.kf === undefined) !== (rdef.srh === undefined), `${path}: give kf (mass action) or srh (trap-assisted), one of them`);
|
|
358
|
+
if (rdef.srh !== undefined) {
|
|
359
|
+
need(isObject(rdef.srh), `${path}.srh must map material names to { tauN, tauP, n1 }`);
|
|
360
|
+
const srh = new Array(materials.length).fill(null), kf = new Float64Array(materials.length), kfProfile = new Array(materials.length).fill(null);
|
|
361
|
+
for (const [mname, v] of Object.entries(rdef.srh)) {
|
|
362
|
+
need(materialIndex.has(mname), `${path}.srh.${mname}: unknown material`);
|
|
363
|
+
const m = materialIndex.get(mname);
|
|
364
|
+
srh[m] = srhLaw(v, `${path}.srh.${mname}`, st.list, species, ['tauN', 'tauP']);
|
|
365
|
+
for (const { i } of st.list) need(materials[m].present[i], `${path}.srh.${mname}: '${species[i].name}' is absent from material '${mname}'`);
|
|
366
|
+
kf[m] = 1; // (runs here)
|
|
367
|
+
}
|
|
368
|
+
const reactants = st.list.map(({ i, nu }) => ({ i, nu: -nu }));
|
|
369
|
+
return { reactants, products: [], fixedA: st.fixedA, kf, kfProfile, srh, generation: false };
|
|
370
|
+
}
|
|
328
371
|
need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
|
|
329
|
-
|
|
372
|
+
// Each a number, or a profile { x, values } against the device's x (e.g. absorption); kf[m]
|
|
373
|
+
// is then its largest value, which says whether the reaction runs in that material at all.
|
|
374
|
+
const kf = new Float64Array(materials.length), kfProfile = new Array(materials.length).fill(null);
|
|
330
375
|
for (const [mname, v] of Object.entries(rdef.kf)) {
|
|
331
376
|
need(materialIndex.has(mname), `${path}.kf.${mname}: unknown material`);
|
|
332
377
|
const m = materialIndex.get(mname);
|
|
333
|
-
|
|
378
|
+
if (isObject(v)) {
|
|
379
|
+
kfProfile[m] = profile(v, `${path}.kf.${mname}`, 'rate constants', (k) => k >= 0, 'a rate constant ≥ 0');
|
|
380
|
+
kf[m] = Math.max(...kfProfile[m].values);
|
|
381
|
+
} else kf[m] = nonNegative(v, `${path}.kf.${mname}`);
|
|
334
382
|
for (const { i } of st.list) {
|
|
335
383
|
need(materials[m].present[i], `${path}.kf.${mname}: '${species[i].name}' is absent from material '${mname}'`);
|
|
336
384
|
}
|
|
@@ -342,6 +390,7 @@ function normalizeReaction(rdef, path, species, speciesIndex, materials, materia
|
|
|
342
390
|
products,
|
|
343
391
|
fixedA: st.fixedA, // fixed participants' share of A/RT
|
|
344
392
|
kf,
|
|
393
|
+
kfProfile,
|
|
345
394
|
// Species made only from (or turned only into) fixed reservoirs, such as photogeneration
|
|
346
395
|
// from a photon reservoir: a source that can hold the device far from equilibrium.
|
|
347
396
|
generation: st.nFixed > 0 && (reactants.length === 0 || products.length === 0),
|
|
@@ -389,7 +438,7 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
|
|
|
389
438
|
return (idef.reactions ?? []).map((rdef, k) => {
|
|
390
439
|
const rpath = `${where}.reactions[${k}]`;
|
|
391
440
|
need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
|
|
392
|
-
fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha']);
|
|
441
|
+
fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh']);
|
|
393
442
|
let sides = rdef;
|
|
394
443
|
if (rdef.equation !== undefined) {
|
|
395
444
|
need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
|
|
@@ -409,6 +458,15 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
|
|
|
409
458
|
}
|
|
410
459
|
need(part.length > 0, `${rpath}: no species participate`);
|
|
411
460
|
need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
|
|
461
|
+
if (rdef.srh !== undefined) {
|
|
462
|
+
need(rdef.k0 === undefined && rdef.alpha === undefined, `${rpath}: give srh or k0 (and alpha), not both`);
|
|
463
|
+
const srh = srhLaw(rdef.srh, `${rpath}.srh`, part, species, ['vn', 'vp']);
|
|
464
|
+
for (const p of part) {
|
|
465
|
+
const mat = p.side ? matR : matL;
|
|
466
|
+
need(!mat.conductor, `${rpath}.srh: SRH kinetics is for carriers in semiconductors, not a metal's ${species[p.i].name}`);
|
|
467
|
+
}
|
|
468
|
+
return { part, fixedA, srh };
|
|
469
|
+
}
|
|
412
470
|
return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
|
|
413
471
|
});
|
|
414
472
|
}
|
|
@@ -809,7 +867,7 @@ function defaultInterfaceLinks(matL, matR, species) {
|
|
|
809
867
|
|
|
810
868
|
function transferCoefficient(v, path) {
|
|
811
869
|
if (v === undefined) return 0.5;
|
|
812
|
-
need(isFiniteNumber(v) && v
|
|
870
|
+
need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
|
|
813
871
|
return v;
|
|
814
872
|
}
|
|
815
873
|
|
package/src/index.js
CHANGED
|
@@ -63,13 +63,22 @@ export class Device {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Change part of the definition (deep-merged). A
|
|
67
|
-
*
|
|
66
|
+
* Change part of the definition (deep-merged). A contact given V or I drops the other, so
|
|
67
|
+
* `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A change to the terminals'
|
|
68
|
+
* drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
|
|
68
69
|
* the warm start when the grid and species are unchanged (else restarting from the regions' c0).
|
|
69
70
|
* @param {object} patch a partial device definition, merged into the current one
|
|
70
71
|
* @returns {this}
|
|
71
72
|
*/
|
|
72
73
|
set(patch) {
|
|
74
|
+
// A contact given a new kind of drive drops the old one: { I: 0 } replaces a held V.
|
|
75
|
+
for (const side of ['left', 'right']) {
|
|
76
|
+
const c = patch?.contacts?.[side];
|
|
77
|
+
if (c && typeof c === 'object') {
|
|
78
|
+
if ('I' in c && !('V' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, V: undefined } } };
|
|
79
|
+
else if ('V' in c && !('I' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, I: undefined } } };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
73
82
|
const def = merge(this.def, patch);
|
|
74
83
|
// Only the terminals' drives changed: update them in place, keeping the solver and its state
|
|
75
84
|
// (a step in a source restarts the time stepping's order, as at any discontinuity).
|
package/src/kit.js
CHANGED
|
@@ -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/plot.js
CHANGED
|
@@ -257,12 +257,13 @@ export function levelChart(tr, { width = 640, height = 360, ylabel = 'voltage (V
|
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
259
|
* A solution's level diagram as an SVG string: each charged species' voltage (thick) and
|
|
260
|
-
* standard level (thin), regions as bands
|
|
260
|
+
* standard level (thin), regions as bands; or with `energy: true`, the familiar energy-up band
|
|
261
|
+
* diagram of electrons and holes. Options are those of `traces()` and `levelChart()`.
|
|
261
262
|
* @param {import('./types.js').Solution} sol
|
|
262
263
|
* @param {Parameters<typeof traces>[1] & Parameters<typeof levelChart>[1]} [opts]
|
|
263
264
|
* @returns {string}
|
|
264
265
|
*/
|
|
265
266
|
export function bandDiagram(sol, opts = {}) {
|
|
266
|
-
const { width, height, ylabel, range, xlim, title, ytick, ...pick } = opts;
|
|
267
|
+
const { width, height, ylabel = opts.energy ? 'electron energy (eV)' : undefined, range, xlim, title, ytick, ...pick } = opts;
|
|
267
268
|
return levelChart(traces(sol, pick), { width, height, ylabel, range, xlim, title, ytick });
|
|
268
269
|
}
|
package/src/solution.js
CHANGED
|
@@ -121,7 +121,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
121
121
|
const N = {};
|
|
122
122
|
for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
|
|
123
123
|
const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
|
|
124
|
-
return { dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
|
|
124
|
+
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
125
|
});
|
|
126
126
|
|
|
127
127
|
// Total charge per area in the device (space charge plus sheet charges).
|
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,17 @@ 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
|
+
srh: rx.srh, // per material: SRH kinetics in place of mass action, or null
|
|
366
|
+
kfNode: Float64Array.from(gx, (x, g) => {
|
|
367
|
+
const m = this.nodeMaterial[g], p = rx.kfProfile[m];
|
|
368
|
+
return p ? profileMean(p, x - (g > 0 ? segLength[g - 1] : 0) / 2, x + (g < segLength.length ? segLength[g] : 0) / 2) : rx.kf[m];
|
|
369
|
+
}),
|
|
344
370
|
generation: rx.generation,
|
|
345
371
|
fixedA: rx.fixedA,
|
|
346
372
|
sp: Int32Array.from([...rx.reactants, ...rx.products], (p) => p.i),
|
|
@@ -538,9 +564,63 @@ export class Solver {
|
|
|
538
564
|
// The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
|
|
539
565
|
add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
|
|
540
566
|
}
|
|
567
|
+
// A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
|
|
568
|
+
// node by node, since nothing carries it anywhere: at each node its weighted sum stays what
|
|
569
|
+
// it was, a row local to that node's block, in place of the balance row of one of them.
|
|
570
|
+
this.localConstraints = [];
|
|
571
|
+
const immobile = (st) => {
|
|
572
|
+
for (let q = st.regions[0]; q <= st.regions[1]; q++) if (materials[regions[q].material].D[st.species] > 0) return false;
|
|
573
|
+
return true;
|
|
574
|
+
};
|
|
575
|
+
for (const w of this.moieties) {
|
|
576
|
+
const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
|
|
577
|
+
const sts = parts.map((p) => this.stretches[p.stretch]);
|
|
578
|
+
if (sts.some((st) => st.conserved || !immobile(st) || conductor(st))) continue;
|
|
579
|
+
if (sts.some((st) => st.nodes[0] !== sts[0].nodes[0] || st.nodes[1] !== sts[0].nodes[1])) continue; // (side by side only)
|
|
580
|
+
for (const st of sts) st.conserved = true;
|
|
581
|
+
const row = parts.find((p) => p.w === 1) ?? parts[0];
|
|
582
|
+
this.localConstraints.push({
|
|
583
|
+
parts: parts.map((p) => ({ i: this.stretches[p.stretch].species, w: p.w })),
|
|
584
|
+
row: 1 + this.stretches[row.stretch].species,
|
|
585
|
+
nodes: sts[0].nodes,
|
|
586
|
+
reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
541
589
|
this.constrained = false;
|
|
542
590
|
}
|
|
543
591
|
|
|
592
|
+
// Each local constraint's weighted sum at each node, as the state holds it now: what a steady
|
|
593
|
+
// solve keeps.
|
|
594
|
+
_captureLocal() {
|
|
595
|
+
const { n, c } = this;
|
|
596
|
+
for (const lc of this.localConstraints) {
|
|
597
|
+
for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
|
|
598
|
+
let t = 0;
|
|
599
|
+
for (const { i, w } of lc.parts) t += w * c[g * n + i];
|
|
600
|
+
lc.reference[g - lc.nodes[0]] = t;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
|
|
606
|
+
_applyLocalConstraints() {
|
|
607
|
+
const { n, M, c, res, dA: d } = this;
|
|
608
|
+
for (const lc of this.localConstraints) {
|
|
609
|
+
for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
|
|
610
|
+
const b = this.blockOfNode[g];
|
|
611
|
+
if (this.loc[b * M + lc.row] < 0) continue;
|
|
612
|
+
this._replaceRow(b, lc.row);
|
|
613
|
+
let t = 0;
|
|
614
|
+
for (const { i, w } of lc.parts) {
|
|
615
|
+
t += w * c[g * n + i];
|
|
616
|
+
this._dc(g, i, d);
|
|
617
|
+
for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
|
|
618
|
+
}
|
|
619
|
+
res[this.rix[b * M + lc.row]] = t - lc.reference[g - lc.nodes[0]];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
544
624
|
// A basis of the conserved combinations of stretch amounts: the null space of the
|
|
545
625
|
// stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
|
|
546
626
|
// for each stretch fed from outside. Exact integer data, so plain elimination will do.
|
|
@@ -765,7 +845,9 @@ export class Solver {
|
|
|
765
845
|
u.fill(0);
|
|
766
846
|
this.uLo.fill(0);
|
|
767
847
|
const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
|
|
768
|
-
|
|
848
|
+
// φ̂ runs from the left: from a pinned (or gated) left contact's φ at its zero charge, else 0.
|
|
849
|
+
const leftPhi = model.contacts.left.phi;
|
|
850
|
+
let phiHat = leftPhi.type === 'pinned' || leftPhi.type === 'capacitive' ? (this.termV[0] - leftPhi.zeroCharge) / this.VT : 0;
|
|
769
851
|
for (let r = 0; r < regions.length; r++) {
|
|
770
852
|
if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
|
|
771
853
|
const reg = regions[r], mat = materials[reg.material];
|
|
@@ -797,7 +879,8 @@ export class Solver {
|
|
|
797
879
|
} else if (!st.contactFed) {
|
|
798
880
|
if (!(reg.c0[i] > 0)) {
|
|
799
881
|
throw new SolverError(
|
|
800
|
-
`regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration
|
|
882
|
+
`regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration` +
|
|
883
|
+
(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
884
|
);
|
|
802
885
|
}
|
|
803
886
|
mode[i] = 2;
|
|
@@ -880,6 +963,9 @@ export class Solver {
|
|
|
880
963
|
let lo = ph - 1, hi = ph + 1;
|
|
881
964
|
while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
|
|
882
965
|
while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
|
|
966
|
+
// Out of reach (every responding carrier has one sign and nothing balances it, as in an
|
|
967
|
+
// undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
|
|
968
|
+
if (charge(lo) < 0 || charge(hi) > 0) return ph;
|
|
883
969
|
for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
|
|
884
970
|
const m = 0.5 * (lo + hi);
|
|
885
971
|
if (charge(m) > 0) lo = m;
|
|
@@ -1084,6 +1170,7 @@ export class Solver {
|
|
|
1084
1170
|
// flux), then contacts, then the circuit rows of the floating ones.
|
|
1085
1171
|
this._terminals(dt);
|
|
1086
1172
|
if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
|
|
1173
|
+
if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
|
|
1087
1174
|
this.transformed = this.combining && dt !== Infinity;
|
|
1088
1175
|
if (this.transformed) this._chargeRows(dt);
|
|
1089
1176
|
}
|
|
@@ -1269,11 +1356,31 @@ export class Solver {
|
|
|
1269
1356
|
// (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
|
|
1270
1357
|
_bulkReactions(g, rxs) {
|
|
1271
1358
|
const { n, M, u, uLo, res, c } = this, R = this.rix;
|
|
1272
|
-
const b = this.blockOfNode[g], v = this.model.grid.vol[g]
|
|
1359
|
+
const b = this.blockOfNode[g], v = this.model.grid.vol[g];
|
|
1273
1360
|
const dr = this.dA;
|
|
1274
1361
|
for (let x = 0; x < rxs.length; x++) {
|
|
1275
1362
|
const rx = rxs[x];
|
|
1276
|
-
|
|
1363
|
+
const law = rx.srh?.[this.nodeMaterial[g]];
|
|
1364
|
+
if (law) {
|
|
1365
|
+
// SRH: dr is ∂rate/∂slot directly, from its sensitivities to ln n, ln p and a.
|
|
1366
|
+
let aHi = rx.fixedA, aLo = 0;
|
|
1367
|
+
for (let p = 0; p < rx.sp.length; p++) {
|
|
1368
|
+
aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
|
|
1369
|
+
aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
|
|
1370
|
+
}
|
|
1371
|
+
const { rate, Cn, Cp, Ca } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo);
|
|
1372
|
+
dr.fill(0);
|
|
1373
|
+
this._dlnc(g, law.n.i, Cn, dr);
|
|
1374
|
+
this._dlnc(g, law.p.i, Cp, dr);
|
|
1375
|
+
for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Ca * rx.nu[p];
|
|
1376
|
+
for (let p = 0; p < rx.sp.length; p++) {
|
|
1377
|
+
const row = 1 + rx.sp[p], w = v * rx.nu[p];
|
|
1378
|
+
res[R[b * M + row]] += w * rate;
|
|
1379
|
+
for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
|
|
1380
|
+
}
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
|
|
1277
1384
|
dr.fill(0); // ∂ ln P / ∂slot
|
|
1278
1385
|
// Participants: reactants with ν > 0, then products with ν < 0.
|
|
1279
1386
|
for (let p = 0; p < rx.sp.length; p++) {
|
|
@@ -1608,6 +1715,25 @@ export class Solver {
|
|
|
1608
1715
|
d[0] = -this._Kz(g, k);
|
|
1609
1716
|
}
|
|
1610
1717
|
|
|
1718
|
+
// SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
|
|
1719
|
+
// a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
|
|
1720
|
+
_srhRate(law, cn, cp, a) {
|
|
1721
|
+
const { tn, tp } = law, lnnp = Math.log(cn) + Math.log(cp);
|
|
1722
|
+
let n1, kN = 0, kP = 0, kA = 0; // ln n₁ = kN ln n + kP ln p + kA a + const
|
|
1723
|
+
if (Number.isNaN(law.n1)) {
|
|
1724
|
+
n1 = Math.exp((lnnp - a) / 2);
|
|
1725
|
+
kN = kP = 0.5;
|
|
1726
|
+
kA = -0.5;
|
|
1727
|
+
} else n1 = law.n1;
|
|
1728
|
+
const p1 = Math.exp(lnnp - a - Math.log(n1)), np = cn * cp;
|
|
1729
|
+
const den = tp * (cn + n1) + tn * (cp + p1), f = -Math.expm1(-a);
|
|
1730
|
+
const rate = (np * f) / den;
|
|
1731
|
+
const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
|
|
1732
|
+
const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
|
|
1733
|
+
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 };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1611
1737
|
// A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
|
|
1612
1738
|
// with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
|
|
1613
1739
|
// there when ν > 0). The rate couples the two edge nodes only through the face block between
|
|
@@ -1619,24 +1745,34 @@ export class Solver {
|
|
|
1619
1745
|
const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
|
|
1620
1746
|
const al = rx.alpha;
|
|
1621
1747
|
const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
|
|
1622
|
-
let pref = rx.k0, aHi = rx.fixedA, aLo = 0;
|
|
1748
|
+
let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
|
|
1623
1749
|
for (const p of rx.part) {
|
|
1624
1750
|
const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
|
|
1625
1751
|
aHi -= p.nu * u[o];
|
|
1626
1752
|
aLo -= p.nu * uLo[o];
|
|
1627
|
-
if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1753
|
+
if (rx.srh || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1628
1754
|
const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
|
|
1629
1755
|
pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
|
|
1630
1756
|
this._dlnc(g, p.i, e, p.side ? dR : dL);
|
|
1631
1757
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1758
|
+
if (rx.srh) {
|
|
1759
|
+
// SRH, with n and p each at its own side's edge node.
|
|
1760
|
+
const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
|
|
1761
|
+
const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
|
|
1762
|
+
rate = s.rate;
|
|
1763
|
+
this._dlnc(gn, pn.i, s.Cn, pn.side ? dR : dL);
|
|
1764
|
+
this._dlnc(gq, pp.i, s.Cp, pp.side ? dR : dL);
|
|
1765
|
+
for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= s.Ca * p.nu;
|
|
1766
|
+
} else {
|
|
1767
|
+
const { g: bv, gp } = bvFactor(aHi + aLo, al);
|
|
1768
|
+
rate = pref * bv;
|
|
1769
|
+
// ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
|
|
1770
|
+
for (let s = 0; s < M; s++) {
|
|
1771
|
+
dL[s] *= rate;
|
|
1772
|
+
dR[s] *= rate;
|
|
1773
|
+
}
|
|
1774
|
+
for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
|
|
1638
1775
|
}
|
|
1639
|
-
for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
|
|
1640
1776
|
const slot = 1 + n + k, o = bf * M + slot;
|
|
1641
1777
|
res[R[o]] = u[o] - rate;
|
|
1642
1778
|
this._j(bf, slot, bf, slot, 1);
|
|
@@ -2117,6 +2253,9 @@ export class Solver {
|
|
|
2117
2253
|
let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
|
|
2118
2254
|
if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
|
|
2119
2255
|
let grow = 0; // longer first steps tried after a Newton failure (see below)
|
|
2256
|
+
// The shortest step worth trying: round-off relative to the time, or the device's fastest
|
|
2257
|
+
// time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
|
|
2258
|
+
const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
|
|
2120
2259
|
const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
|
|
2121
2260
|
const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
|
|
2122
2261
|
while (this.time < tEnd) {
|
|
@@ -2173,7 +2312,7 @@ export class Solver {
|
|
|
2173
2312
|
grow = 3;
|
|
2174
2313
|
dt = h / 4;
|
|
2175
2314
|
}
|
|
2176
|
-
if (dt <
|
|
2315
|
+
if (dt < floor) { failed = true; break; }
|
|
2177
2316
|
continue;
|
|
2178
2317
|
}
|
|
2179
2318
|
} else {
|
|
@@ -2182,7 +2321,7 @@ export class Solver {
|
|
|
2182
2321
|
if (!r.converged) {
|
|
2183
2322
|
rejected++;
|
|
2184
2323
|
dt = h / 4;
|
|
2185
|
-
if (dt <
|
|
2324
|
+
if (dt < floor) { failed = true; break; }
|
|
2186
2325
|
continue;
|
|
2187
2326
|
}
|
|
2188
2327
|
p = this._predict(pred, r.bdf);
|
|
@@ -2703,6 +2842,17 @@ export class Solver {
|
|
|
2703
2842
|
return Number.isFinite(dMin) ? (L * L) / dMin : 1;
|
|
2704
2843
|
}
|
|
2705
2844
|
|
|
2845
|
+
// The fastest diffusion time: across the smallest cell, at the largest D.
|
|
2846
|
+
fastestTime() {
|
|
2847
|
+
let dMax = 0;
|
|
2848
|
+
for (const mat of this.model.materials) {
|
|
2849
|
+
for (let i = 0; i < this.n; i++) if (mat.present[i]) dMax = Math.max(dMax, mat.D[i]);
|
|
2850
|
+
}
|
|
2851
|
+
let h = Infinity;
|
|
2852
|
+
for (const s of this.model.grid.segLength) if (s > 0) h = Math.min(h, s);
|
|
2853
|
+
return dMax > 0 && Number.isFinite(h) ? (h * h) / dMax : this.slowestTime();
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2706
2856
|
/**
|
|
2707
2857
|
* Steady state.
|
|
2708
2858
|
* - If every species stretch is fed by a contact, nothing is conserved on its own, so the
|
|
@@ -2715,6 +2865,8 @@ export class Solver {
|
|
|
2715
2865
|
*/
|
|
2716
2866
|
solveSteady(opts = {}) {
|
|
2717
2867
|
this.conditioning = null;
|
|
2868
|
+
this.computeConcentrations();
|
|
2869
|
+
this._captureLocal(); // immobile combinations keep, node by node, what they hold now
|
|
2718
2870
|
this.sourceTime = this.time;
|
|
2719
2871
|
// Continuation applies where the right terminal is held at a voltage, against a held left one.
|
|
2720
2872
|
const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
|
|
@@ -2890,8 +3042,10 @@ export class Solver {
|
|
|
2890
3042
|
history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
|
|
2891
3043
|
}
|
|
2892
3044
|
if (!r.converged) {
|
|
3045
|
+
// Pseudo-transient continuation: down from a slow time scale until a step converges,
|
|
3046
|
+
// as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
|
|
2893
3047
|
dt = dt >= giant ? tau * 1e-6 : dt / 4;
|
|
2894
|
-
if (dt < tau * 1e-15) break;
|
|
3048
|
+
if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
|
|
2895
3049
|
continue;
|
|
2896
3050
|
}
|
|
2897
3051
|
if (dt === Infinity) {
|
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/traces.js
CHANGED
|
@@ -62,7 +62,11 @@ export function typeset(name, z) {
|
|
|
62
62
|
* its standard level V°_i (`standard`, the band edge for e⁻ and h⁺), optionally φ and
|
|
63
63
|
* half-reactions' levels. Doubled interface nodes share an x, so steps draw as vertical lines.
|
|
64
64
|
* @param {import('./types.js').Solution} sol
|
|
65
|
-
*
|
|
65
|
+
* With `energy: true` it's the familiar band diagram instead, energy up: electrons and holes as
|
|
66
|
+
* electron energies in eV (E = −V: E_c and E_v from the standard levels, E_Fn and E_Fp, or E_F
|
|
67
|
+
* where only electrons are present, from the levels), with any redox levels and φ (as −qφ) turned
|
|
68
|
+
* over too. Only electrons and holes have bands; ions are refused (draw them as species voltages).
|
|
69
|
+
* @param {{ species?: string[], standard?: boolean, phi?: boolean | string[], energy?: boolean,
|
|
66
70
|
* levels?: { half: { equation: string, fixed?: Record<string, number> }, label?: string, standard?: boolean }[],
|
|
67
71
|
* labels?: Record<string, string>, shifts?: Record<string, number> }} [opts]
|
|
68
72
|
* species to show (default: every charged one), whether to show standard levels (default true)
|
|
@@ -73,31 +77,40 @@ export function typeset(name, z) {
|
|
|
73
77
|
* @returns {{ x: Float64Array, series: Trace[], regions: { name: string, material: string, x0: number, x1: number }[],
|
|
74
78
|
* faces: number[], range: [number, number] }}
|
|
75
79
|
*/
|
|
76
|
-
export function traces(sol, { species, standard = true, phi = false, levels = [], labels = {}, shifts = {} } = {}) {
|
|
80
|
+
export function traces(sol, { species, standard = true, phi = false, energy = false, levels = [], labels = {}, shifts = {} } = {}) {
|
|
77
81
|
const names = Object.keys(sol.V);
|
|
78
82
|
const role = (name) => speciesRole(sol, name).role;
|
|
79
83
|
const slotOf = (name) => speciesRole(sol, name).slot;
|
|
80
|
-
const
|
|
84
|
+
const carriers = ['e-', 'h+'];
|
|
85
|
+
const shown = species ?? names.filter((name) => finite(sol.V[name]) && (!energy || carriers.includes(name)));
|
|
86
|
+
if (energy) {
|
|
87
|
+
for (const name of shown) {
|
|
88
|
+
if (!carriers.includes(name)) throw new Error(`traces: '${name}' has no band to draw as an energy; draw it as a species voltage (energy: false)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const sign = energy ? -1 : 1, unit = energy ? 'eV' : 'V';
|
|
92
|
+
const bipolar = names.includes('h+');
|
|
93
|
+
const energyLabels = { 'V:e-': bipolar ? 'E_{Fn}' : 'E_F', 'Vstd:e-': 'E_c', 'V:h+': 'E_{Fp}', 'Vstd:h+': 'E_v' };
|
|
81
94
|
const zOf = Object.fromEntries(sol.species.map((sp) => [sp.name, sp.z]));
|
|
82
95
|
const pretty = (name) => (name in zOf ? typeset(name, zOf[name]) : name);
|
|
83
96
|
const series = [];
|
|
84
97
|
for (const name of shown) {
|
|
85
98
|
if (!names.includes(name)) throw new Error(`traces: no charged species '${name}' in the solution`);
|
|
86
99
|
const shift = shifts[name] ?? 0;
|
|
87
|
-
const at = (y) => (shift ?
|
|
100
|
+
const at = (y) => (shift || energy ? Array.from(y, (v) => sign * v + shift) : y);
|
|
88
101
|
const common = { species: name, role: role(name), slot: slotOf(name), ...(shift ? { shift } : {}) };
|
|
89
|
-
if (finite(sol.V[name])) series.push({ id: `V:${name}`, label: `V_{${pretty(name)}}`, kind: 'level', ...common, y: at(sol.V[name]) });
|
|
90
|
-
if (standard && finite(sol.Vstd[name])) series.push({ id: `Vstd:${name}`, label: `V°_{${pretty(name)}}`, kind: 'standard', ...common, y: at(sol.Vstd[name]) });
|
|
102
|
+
if (finite(sol.V[name])) series.push({ id: `V:${name}`, label: energy ? energyLabels[`V:${name}`] : `V_{${pretty(name)}}`, kind: 'level', ...common, y: at(sol.V[name]) });
|
|
103
|
+
if (standard && finite(sol.Vstd[name])) series.push({ id: `Vstd:${name}`, label: energy ? energyLabels[`Vstd:${name}`] : `V°_{${pretty(name)}}`, kind: 'standard', ...common, y: at(sol.Vstd[name]) });
|
|
91
104
|
}
|
|
92
105
|
if (Array.isArray(phi)) {
|
|
93
106
|
const known = sol.regions.flatMap((r) => [r.name, r.material]);
|
|
94
107
|
for (const name of phi) if (!known.includes(name)) throw new Error(`traces: no region or material '${name}' to draw φ in (${[...new Set(known)].join(', ')})`);
|
|
95
108
|
const inside = sol.regions.map((r) => phi.includes(r.name) || phi.includes(r.material));
|
|
96
|
-
series.push({ id: 'phi', label: 'φ', kind: 'phi', role: 'phi', slot: 0, y: Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? v : NaN)) });
|
|
97
|
-
} else if (phi) series.push({ id: 'phi', label: 'φ', kind: 'phi', role: 'phi', slot: 0, y: sol.phi });
|
|
109
|
+
series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? sign * v : NaN)) });
|
|
110
|
+
} else if (phi) series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: energy ? Array.from(sol.phi, (v) => -v) : sol.phi });
|
|
98
111
|
const couples = [...new Set(levels.map((lv) => lv.half.equation))];
|
|
99
112
|
levels.forEach((lv, k) => {
|
|
100
|
-
const y = level(sol, lv.half, { standard: lv.standard });
|
|
113
|
+
const y = energy ? Array.from(level(sol, lv.half, { standard: lv.standard }), (v) => -v) : level(sol, lv.half, { standard: lv.standard });
|
|
101
114
|
series.push({
|
|
102
115
|
id: `level:${k}`,
|
|
103
116
|
label: lv.label ?? `${lv.standard ? 'V° ' : ''}${lv.half.equation.split(' ').map(pretty).join(' ')}`,
|
|
@@ -109,7 +122,7 @@ export function traces(sol, { species, standard = true, phi = false, levels = []
|
|
|
109
122
|
});
|
|
110
123
|
for (const s of series) {
|
|
111
124
|
if (labels[s.id] !== undefined) s.label = labels[s.id];
|
|
112
|
-
if (s.shift) s.label += ` ⌇${s.shift > 0 ? '+' : '−'}${Math.abs(s.shift)}
|
|
125
|
+
if (s.shift) s.label += ` ⌇${s.shift > 0 ? '+' : '−'}${Math.abs(s.shift)} ${unit}`;
|
|
113
126
|
}
|
|
114
127
|
let lo = Infinity, hi = -Infinity;
|
|
115
128
|
for (const s of series) {
|
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/index.d.ts
CHANGED
|
@@ -13,8 +13,9 @@ export class Device {
|
|
|
13
13
|
get solver(): any;
|
|
14
14
|
get grid(): any;
|
|
15
15
|
/**
|
|
16
|
-
* Change part of the definition (deep-merged). A
|
|
17
|
-
*
|
|
16
|
+
* Change part of the definition (deep-merged). A contact given V or I drops the other, so
|
|
17
|
+
* `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A change to the terminals'
|
|
18
|
+
* drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
|
|
18
19
|
* the warm start when the grid and species are unchanged (else restarting from the regions' c0).
|
|
19
20
|
* @param {object} patch a partial device definition, merged into the current one
|
|
20
21
|
* @returns {this}
|
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/plot.d.ts
CHANGED
|
@@ -37,7 +37,8 @@ export function levelChart(tr: ReturnType<typeof traces>, { width, height, ylabe
|
|
|
37
37
|
}): string;
|
|
38
38
|
/**
|
|
39
39
|
* A solution's level diagram as an SVG string: each charged species' voltage (thick) and
|
|
40
|
-
* standard level (thin), regions as bands
|
|
40
|
+
* standard level (thin), regions as bands; or with `energy: true`, the familiar energy-up band
|
|
41
|
+
* diagram of electrons and holes. Options are those of `traces()` and `levelChart()`.
|
|
41
42
|
* @param {import('./types.js').Solution} sol
|
|
42
43
|
* @param {Parameters<typeof traces>[1] & Parameters<typeof levelChart>[1]} [opts]
|
|
43
44
|
* @returns {string}
|
package/types/solver.d.ts
CHANGED
|
@@ -103,7 +103,10 @@ export class Solver {
|
|
|
103
103
|
stretchOf: Int32Array<ArrayBuffer>;
|
|
104
104
|
moieties: Float64Array<ArrayBuffer>[];
|
|
105
105
|
constraints: any[];
|
|
106
|
+
localConstraints: any[];
|
|
106
107
|
constrained: boolean;
|
|
108
|
+
_captureLocal(): void;
|
|
109
|
+
_applyLocalConstraints(): void;
|
|
107
110
|
_conservedMoieties(): Float64Array<ArrayBuffer>[];
|
|
108
111
|
_directSteady(): boolean;
|
|
109
112
|
_applyConstraints(): void;
|
|
@@ -152,6 +155,12 @@ export class Solver {
|
|
|
152
155
|
_segmentMixing(s: any, bL: any, bR: any, Dm: any, h: any, mat: any): void;
|
|
153
156
|
mixWork: Float64Array<any>[];
|
|
154
157
|
_dc(g: any, k: any, d: any): void;
|
|
158
|
+
_srhRate(law: any, cn: any, cp: any, a: any): {
|
|
159
|
+
rate: number;
|
|
160
|
+
Cn: number;
|
|
161
|
+
Cp: number;
|
|
162
|
+
Ca: number;
|
|
163
|
+
};
|
|
155
164
|
_faceReaction(rx: any, k: any, f: any, bf: any, bL: any, bR: any): void;
|
|
156
165
|
_terminals(dt: any): void;
|
|
157
166
|
_circuit(k: any): void;
|
|
@@ -288,6 +297,7 @@ export class Solver {
|
|
|
288
297
|
_assembleBookkeeping(dt: any): void;
|
|
289
298
|
_accumulateBoundaryIntake(dt: any, cN: any): void;
|
|
290
299
|
slowestTime(): number;
|
|
300
|
+
fastestTime(): number;
|
|
291
301
|
/**
|
|
292
302
|
* Steady state.
|
|
293
303
|
* - If every species stretch is fed by a contact, nothing is conserved on its own, so the
|
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/traces.d.ts
CHANGED
|
@@ -40,7 +40,11 @@ export function typeset(name: string, z: number): string;
|
|
|
40
40
|
* its standard level V°_i (`standard`, the band edge for e⁻ and h⁺), optionally φ and
|
|
41
41
|
* half-reactions' levels. Doubled interface nodes share an x, so steps draw as vertical lines.
|
|
42
42
|
* @param {import('./types.js').Solution} sol
|
|
43
|
-
*
|
|
43
|
+
* With `energy: true` it's the familiar band diagram instead, energy up: electrons and holes as
|
|
44
|
+
* electron energies in eV (E = −V: E_c and E_v from the standard levels, E_Fn and E_Fp, or E_F
|
|
45
|
+
* where only electrons are present, from the levels), with any redox levels and φ (as −qφ) turned
|
|
46
|
+
* over too. Only electrons and holes have bands; ions are refused (draw them as species voltages).
|
|
47
|
+
* @param {{ species?: string[], standard?: boolean, phi?: boolean | string[], energy?: boolean,
|
|
44
48
|
* levels?: { half: { equation: string, fixed?: Record<string, number> }, label?: string, standard?: boolean }[],
|
|
45
49
|
* labels?: Record<string, string>, shifts?: Record<string, number> }} [opts]
|
|
46
50
|
* species to show (default: every charged one), whether to show standard levels (default true)
|
|
@@ -51,10 +55,11 @@ export function typeset(name: string, z: number): string;
|
|
|
51
55
|
* @returns {{ x: Float64Array, series: Trace[], regions: { name: string, material: string, x0: number, x1: number }[],
|
|
52
56
|
* faces: number[], range: [number, number] }}
|
|
53
57
|
*/
|
|
54
|
-
export function traces(sol: import("./types.js").Solution, { species, standard, phi, levels, labels, shifts }?: {
|
|
58
|
+
export function traces(sol: import("./types.js").Solution, { species, standard, phi, energy, levels, labels, shifts }?: {
|
|
55
59
|
species?: string[];
|
|
56
60
|
standard?: boolean;
|
|
57
61
|
phi?: boolean | string[];
|
|
62
|
+
energy?: boolean;
|
|
58
63
|
levels?: {
|
|
59
64
|
half: {
|
|
60
65
|
equation: string;
|
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
|