driftlet 0.2.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 +12 -5
- package/docs/device.md +23 -5
- package/docs/kit.md +11 -0
- package/llms.txt +19 -8
- package/package.json +3 -1
- package/src/describe.js +17 -2
- package/src/device.js +36 -10
- package/src/fermi.js +5 -3
- package/src/kit.js +1 -1
- package/src/plot.js +2 -1
- package/src/pow.js +24 -0
- package/src/solver.js +79 -19
- package/src/sources.js +36 -0
- package/src/statistics.js +4 -3
- package/src/types.js +10 -3
- package/types/kit.d.ts +1 -1
- package/types/pow.d.ts +4 -0
- package/types/solver.d.ts +3 -3
- package/types/sources.d.ts +30 -0
- package/types/types.d.ts +14 -5
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).
|
|
@@ -231,9 +232,13 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
231
232
|
| pn junction | exact built-in potential; depletion charge; short-diode J–V | 1e-12; 1%; 2e-3 |
|
|
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 |
|
|
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 |
|
|
234
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 |
|
|
235
239
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
236
240
|
| Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
|
|
241
|
+
| Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
|
|
237
242
|
| Concentration polarization | `i = i_lim tanh(V/4V_T)`, incl. galvanostatic and load modes | 2e-4 |
|
|
238
243
|
| Butler–Volmer electrode | Nernst equilibrium; mixed kinetic/diffusion closed form | 5e-4 |
|
|
239
244
|
| Redox couple between inert electrodes | Nernst level of Fe³⁺/Fe²⁺; the couple's total conserved at equilibrium and under current | 1e-9; 1e-10 |
|
|
@@ -288,6 +293,8 @@ No dependencies. Tests use node's built-in runner (Node ≥ 22):
|
|
|
288
293
|
npm test # the validation suite
|
|
289
294
|
npm run bench # typical workloads against bench/baseline.json (--save to update it)
|
|
290
295
|
npm run types # TypeScript declarations from the JSDoc, into types/ (fetches TypeScript via npx)
|
|
296
|
+
npm run smoke # every demo in headless Chrome, every control to its extremes (CHROME=… to choose)
|
|
297
|
+
npm run shots # retake the demos' screenshots, each caught at a set state (tools/shots.js)
|
|
291
298
|
```
|
|
292
299
|
|
|
293
300
|
Design notes and plans: [numerics](docs/numerics.md), [conventions](docs/conventions.md),
|
package/docs/device.md
CHANGED
|
@@ -97,11 +97,23 @@ regions: [
|
|
|
97
97
|
|
|
98
98
|
- `fixedCharge`: immobile charge density (doping, ionomer), C/m³. Default 0. In an insertion
|
|
99
99
|
host it's balanced by background electronic carriers.
|
|
100
|
-
- `c0`: initial concentrations, mol/m
|
|
101
|
-
|
|
100
|
+
- `c0`: initial concentrations, mol/m³: the starting state of each species given. Species
|
|
101
|
+
connected to a contact don't need it (without it, they start from that contact's level). Any
|
|
102
|
+
other species (blocked everywhere, or only made and consumed by
|
|
102
103
|
reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
|
|
103
104
|
logarithmic in them); to have none of a species in a region, leave it out of that region's
|
|
104
|
-
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
105
|
+
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
106
|
+
An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
|
|
107
|
+
the region's own), piecewise linear between its points and constant beyond its ends, so a
|
|
108
|
+
transient can start from a state it didn't simulate reaching: a packet already injected, a
|
|
109
|
+
gradient laid down. Skip `solve()`, which goes straight to the steady state, and `advance()`
|
|
110
|
+
from it. Species without a `c0` start from their contact's level, and $`\phi`$ is chosen node
|
|
111
|
+
by node for local neutrality, so a mobile counter-ion follows the profile (electrons follow an
|
|
112
|
+
injected hole packet); what the cold start leaves unbalanced settles within the first steps.
|
|
113
|
+
That fill-in is right in a quasi-neutral bulk (it's what dielectric relaxation would do), not
|
|
114
|
+
where the rest state carries charge: a packet laid into a junction starts with the junction
|
|
115
|
+
itself unformed.
|
|
116
|
+
For a spectator, the profile's integral is the amount it conserves. Where nothing else sets
|
|
105
117
|
$`\phi`$ at the start, a region beside an electrode starts with the electrode's first reaction at
|
|
106
118
|
equilibrium (the electrode at its open-circuit level); otherwise $`\phi`$ carries over from the
|
|
107
119
|
left.
|
|
@@ -244,14 +256,20 @@ those materials.
|
|
|
244
256
|
**Generation** is a reaction from a reservoir: photogeneration is
|
|
245
257
|
$`\text{photon} \to \mathrm{e}^- + \mathrm{h}^+`$, with the photons a fixed participant whose $`\mu`$
|
|
246
258
|
sits well above the gap, so that $`e^{-A/RT}`$ is negligible and the rate is `kf` itself, the
|
|
247
|
-
generation rate (mol/(m³·s)).
|
|
248
|
-
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
|
|
249
260
|
device's temperature, and the rate still vanishes at $`A = 0`$.
|
|
250
261
|
|
|
251
262
|
```js nocheck
|
|
252
263
|
bulkReactions: [{ equation: 'photon = e- + h+', fixed: { photon: units.eV(3) }, kf: { Si: 0.1 } }]
|
|
253
264
|
```
|
|
254
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
|
+
|
|
255
273
|
## Contacts
|
|
256
274
|
|
|
257
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
|
|
@@ -219,6 +221,13 @@ const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
|
|
|
219
221
|
console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPerCm3(seen[k] - rest.c['h+'][0]).toExponential(1)} cm⁻³: μ_p ≈ ${(1e-3 / (E * tArrive) * 1e4).toFixed(0)} cm²/(V·s)`); // ≈ 1950 for the 1900 put in: a spreading, decaying pulse peaks a little early
|
|
220
222
|
```
|
|
221
223
|
|
|
224
|
+
To start from a state instead of simulating how it got there (a packet already injected, a
|
|
225
|
+
salt gradient laid down), give a region's `c0` a profile against the device's x:
|
|
226
|
+
`c0: { 'h+': { x: [...], values: [...] } }`, piecewise linear. A given `c0` is the starting state,
|
|
227
|
+
even for a species a contact feeds; species without one start at their contact's level, with φ
|
|
228
|
+
made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
|
|
229
|
+
state.
|
|
230
|
+
|
|
222
231
|
Transients cost more where a carrier's density spans many decades (minority holes in silicon
|
|
223
232
|
ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
|
|
224
233
|
|
|
@@ -236,9 +245,9 @@ a Web Worker.
|
|
|
236
245
|
<span id="readout"></span>
|
|
237
246
|
<div id="figure"></div>
|
|
238
247
|
<script type="module">
|
|
239
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
240
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
241
|
-
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';
|
|
242
251
|
|
|
243
252
|
const def = build({
|
|
244
253
|
T: 300,
|
|
@@ -349,5 +358,7 @@ has the long form):
|
|
|
349
358
|
to write `2 e-`.
|
|
350
359
|
- **Changing c0 with `set()`.** A carried state keeps what it holds; `c0` is only the starting
|
|
351
360
|
composition. Make a new `Device` to start over from it.
|
|
361
|
+
- **`solve()` before advancing from a `c0` profile.** It replaces the profile with the steady
|
|
362
|
+
state. Advance from the new `Device` directly. A profile's x is the device's, in m.
|
|
352
363
|
- **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
|
|
353
364
|
Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driftlet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --test",
|
|
31
31
|
"bench": "node bench/bench.js",
|
|
32
|
+
"smoke": "node tools/smoke.js",
|
|
33
|
+
"shots": "node tools/shots.js",
|
|
32
34
|
"prepublishOnly": "npm test && npm run types",
|
|
33
35
|
"types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
|
|
34
36
|
},
|
package/src/describe.js
CHANGED
|
@@ -65,13 +65,26 @@ export function unitWarnings(def) {
|
|
|
65
65
|
if (sp.cRef !== undefined) conc(`${sp_}.cRef`, sp.cRef);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
const regionX0 = [0];
|
|
69
|
+
for (const reg of def.regions ?? []) regionX0.push(regionX0.at(-1) + (isObject(reg) && reg.length > 0 ? reg.length : 0));
|
|
68
70
|
(def.regions ?? []).forEach((reg, r) => {
|
|
69
71
|
if (!isObject(reg)) return;
|
|
70
72
|
const path = `regions[${r}]`;
|
|
71
73
|
if (reg.length > 1) warn(`${path}.length`, `${num(reg.length)} m is over a metre; lengths are m (units.um, units.nm)`);
|
|
72
74
|
if (reg.length > 0 && reg.length < 1e-10) warn(`${path}.length`, `${num(reg.length)} m is less than an atom; lengths are m`);
|
|
73
75
|
if (Math.abs(reg.fixedCharge) > 1e10) warn(`${path}.fixedCharge`, `${num(reg.fixedCharge)} C/m³ is over 6e22 cm⁻³ of charge; fixedCharge is C/m³ (units.perCm3(N) * FARADAY)`);
|
|
74
|
-
for (const [sname, c] of Object.entries(reg.c0 ?? {}))
|
|
76
|
+
for (const [sname, c] of Object.entries(reg.c0 ?? {})) {
|
|
77
|
+
if (!isObject(c)) {
|
|
78
|
+
conc(`${path}.c0.${sname}`, c);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(c.values)) conc(`${path}.c0.${sname}.values`, Math.max(...c.values));
|
|
82
|
+
// A profile is against the device's x, not the region's own.
|
|
83
|
+
const x0 = regionX0[r], x1 = x0 + reg.length;
|
|
84
|
+
if (Array.isArray(c.x) && c.x.length > 0 && (c.x.at(-1) < x0 || c.x[0] > x1)) {
|
|
85
|
+
warn(`${path}.c0.${sname}.x`, `the profile (x from ${num(c.x[0])} to ${num(c.x.at(-1))} m) misses the region (${num(x0)} to ${num(x1)} m); x is the device's, in m`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
75
88
|
});
|
|
76
89
|
const capacitance = (path, phi) => {
|
|
77
90
|
if (isObject(phi) && phi.C > 10) warn(`${path}.phi.C`, `${num(phi.C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
|
|
@@ -129,7 +142,9 @@ export function describe(def) {
|
|
|
129
142
|
// The screening concentration: Σ z²c from the initial composition, else the fixed charge's.
|
|
130
143
|
let zzc = 0;
|
|
131
144
|
species.forEach((sp, i) => {
|
|
132
|
-
if (mat.present[i]
|
|
145
|
+
if (!mat.present[i]) return;
|
|
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].values);
|
|
133
148
|
});
|
|
134
149
|
if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
|
|
135
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;
|
|
@@ -178,19 +193,23 @@ export function normalizeDevice(def) {
|
|
|
178
193
|
'so it must have the opposite sign to that carrier',
|
|
179
194
|
);
|
|
180
195
|
}
|
|
181
|
-
// Initial composition: the starting state, and the conserved amount of any spectator.
|
|
196
|
+
// Initial composition: the starting state, and the conserved amount of any spectator. Each
|
|
197
|
+
// entry is a number or a profile { x, values } against the device's x.
|
|
182
198
|
const c0 = new Float64Array(nSpecies).fill(NaN);
|
|
199
|
+
const c0Profile = new Array(nSpecies).fill(null);
|
|
183
200
|
if (reg.c0 !== undefined) {
|
|
184
201
|
need(isObject(reg.c0), `${path}.c0 must be an object mapping species names to concentrations`);
|
|
185
202
|
for (const [sname, v] of Object.entries(reg.c0)) {
|
|
186
|
-
|
|
203
|
+
const at = `${path}.c0.${sname}`;
|
|
204
|
+
need(speciesIndex.has(sname), `${at}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
187
205
|
const i = speciesIndex.get(sname);
|
|
188
|
-
need(mat.present[i], `${
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
206
|
+
need(mat.present[i], `${at}: '${sname}' is absent from material '${mat.name}'`);
|
|
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)`;
|
|
208
|
+
if (isObject(v)) {
|
|
209
|
+
c0Profile[i] = profile(v, at, 'concentrations (mol/m³)', (ck) => ck > 0, `a concentration > 0 (mol/m³)${none}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, values }, got ${JSON.stringify(v)}${none}`);
|
|
194
213
|
c0[i] = v;
|
|
195
214
|
}
|
|
196
215
|
}
|
|
@@ -200,6 +219,7 @@ export function normalizeDevice(def) {
|
|
|
200
219
|
length,
|
|
201
220
|
fixedCharge,
|
|
202
221
|
c0,
|
|
222
|
+
c0Profile,
|
|
203
223
|
background,
|
|
204
224
|
velocity,
|
|
205
225
|
mixing,
|
|
@@ -311,11 +331,16 @@ function normalizeReaction(rdef, path, species, speciesIndex, materials, materia
|
|
|
311
331
|
need(st.list.length > 0, `${path}: no mobile participants`);
|
|
312
332
|
need(st.charge === 0, `${path}: charge is not balanced (Σ ν z = ${st.charge})`);
|
|
313
333
|
need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
|
|
314
|
-
|
|
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);
|
|
315
337
|
for (const [mname, v] of Object.entries(rdef.kf)) {
|
|
316
338
|
need(materialIndex.has(mname), `${path}.kf.${mname}: unknown material`);
|
|
317
339
|
const m = materialIndex.get(mname);
|
|
318
|
-
|
|
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}`);
|
|
319
344
|
for (const { i } of st.list) {
|
|
320
345
|
need(materials[m].present[i], `${path}.kf.${mname}: '${species[i].name}' is absent from material '${mname}'`);
|
|
321
346
|
}
|
|
@@ -327,6 +352,7 @@ function normalizeReaction(rdef, path, species, speciesIndex, materials, materia
|
|
|
327
352
|
products,
|
|
328
353
|
fixedA: st.fixedA, // fixed participants' share of A/RT
|
|
329
354
|
kf,
|
|
355
|
+
kfProfile,
|
|
330
356
|
// Species made only from (or turned only into) fixed reservoirs, such as photogeneration
|
|
331
357
|
// from a photon reservoir: a source that can hold the device far from equilibrium.
|
|
332
358
|
generation: st.nFixed > 0 && (reactants.length === 0 || products.length === 0),
|
package/src/fermi.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// −2 ≤ x ≤ 50 piecewise Chebyshev fits, built on first use from an exact quadrature
|
|
7
7
|
// x > 50 Sommerfeld's asymptotic expansion (its error is ~e^{−x})
|
|
8
8
|
|
|
9
|
+
import { powi, powr } from './pow.js';
|
|
10
|
+
|
|
9
11
|
const INTERVALS = [-2, 1, 5, 12, 25, 50];
|
|
10
12
|
const NCHEB = 24;
|
|
11
13
|
const SQRT_PI = Math.sqrt(Math.PI);
|
|
@@ -64,7 +66,7 @@ function series(j, x) {
|
|
|
64
66
|
const e = Math.exp(x);
|
|
65
67
|
let term = e, s = 0;
|
|
66
68
|
for (let k = 1; k <= 40; k++) {
|
|
67
|
-
const t = term / k
|
|
69
|
+
const t = term / powr(k, j + 1);
|
|
68
70
|
s += k % 2 ? t : -t;
|
|
69
71
|
if (t < 1e-17 * s) break;
|
|
70
72
|
term *= e;
|
|
@@ -73,7 +75,7 @@ function series(j, x) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// η(2k) = (1 − 2^{1−2k}) ζ(2k), the Dirichlet eta function at even arguments.
|
|
76
|
-
const ETA = [Math.PI
|
|
78
|
+
const ETA = [powi(Math.PI, 2) / 12, (7 * powi(Math.PI, 4)) / 720, (31 * powi(Math.PI, 6)) / 30240, 0.99623300185264789922, 0.99903950759827156564, 0.99975768514385819085];
|
|
77
79
|
|
|
78
80
|
function asymptotic(j, x) {
|
|
79
81
|
// 𝓕_j(x) ~ x^{j+1}/Γ(j+2) · [1 + Σ_k 2η(2k) (j+1)(j)…(j+2−2k) x^{−2k}]
|
|
@@ -86,7 +88,7 @@ function asymptotic(j, x) {
|
|
|
86
88
|
s += 2 * ETA[k - 1] * fall * p;
|
|
87
89
|
}
|
|
88
90
|
const gamma = j === 0.5 ? (3 * SQRT_PI) / 4 : SQRT_PI / 2; // Γ(j + 2)
|
|
89
|
-
return (x
|
|
91
|
+
return (powr(x, j + 1) / gamma) * s;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function fermi(j, x) {
|
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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// rule on the page restyles them.
|
|
14
14
|
|
|
15
15
|
import { traces } from './traces.js';
|
|
16
|
+
import { powi } from './pow.js';
|
|
16
17
|
|
|
17
18
|
// Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
|
|
18
19
|
// dash and the legend carry identity too). A role with more members than colours cycles.
|
|
@@ -83,7 +84,7 @@ const span = (label) => labelParts(label).reduce((n, p) => n + (p.sub ? 0.75 : 1
|
|
|
83
84
|
|
|
84
85
|
function ticks(a, b, n) {
|
|
85
86
|
const step0 = (b - a) / n;
|
|
86
|
-
const mag = 10
|
|
87
|
+
const mag = powi(10, Math.floor(Math.log10(step0)));
|
|
87
88
|
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= step0) ?? 10 * mag;
|
|
88
89
|
const out = [];
|
|
89
90
|
for (let t = Math.ceil(a / step) * step; t <= b + 1e-9 * step; t += step) out.push(Math.abs(t) < 1e-9 * step ? 0 : t);
|
package/src/pow.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Powers that come out the same on every JavaScript engine. Math.pow (and **) doesn't: V8's
|
|
2
|
+
// changed between Node 22 and 24 by an ulp here and there, even for integer exponents, which was
|
|
3
|
+
// enough to flip a few accept/reject decisions in long adaptive transients. Integer exponents
|
|
4
|
+
// go by repeated squaring, half-integers add a correctly rounded square root, and anything else
|
|
5
|
+
// goes through exp and log (which agree across engine versions).
|
|
6
|
+
|
|
7
|
+
/** x^n for an integer n. */
|
|
8
|
+
export function powi(x, n) {
|
|
9
|
+
if (n < 0) return 1 / powi(x, -n);
|
|
10
|
+
let r = 1, b = x;
|
|
11
|
+
while (n > 0) {
|
|
12
|
+
if (n & 1) r *= b;
|
|
13
|
+
b *= b;
|
|
14
|
+
n >>= 1;
|
|
15
|
+
}
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** x^y for x ≥ 0 and any real y. */
|
|
20
|
+
export function powr(x, y) {
|
|
21
|
+
if (Number.isInteger(y)) return powi(x, y);
|
|
22
|
+
if (Number.isInteger(2 * y)) return powi(x, (2 * y - 1) / 2) * Math.sqrt(x);
|
|
23
|
+
return x === 0 ? (y > 0 ? 0 : Infinity) : Math.exp(y * Math.log(x));
|
|
24
|
+
}
|
package/src/solver.js
CHANGED
|
@@ -146,6 +146,38 @@ import { bernoulli, bernoulliDerivative } from './bernoulli.js';
|
|
|
146
146
|
import { EPS0, FARADAY } from './constants.js';
|
|
147
147
|
import { nextBreakpoint, sourceAt } from './device.js';
|
|
148
148
|
import { DeviceError } from './errors.js';
|
|
149
|
+
import { powi, powr } from './pow.js';
|
|
150
|
+
|
|
151
|
+
// A profile's value at x (c0, kf): piecewise linear, constant beyond its ends.
|
|
152
|
+
function profileAt({ x, values: c }, at) {
|
|
153
|
+
if (at <= x[0]) return c[0];
|
|
154
|
+
const last = x.length - 1;
|
|
155
|
+
if (at >= x[last]) return c[last];
|
|
156
|
+
let lo = 0, hi = last;
|
|
157
|
+
while (hi - lo > 1) {
|
|
158
|
+
const mid = (lo + hi) >> 1;
|
|
159
|
+
if (x[mid] <= at) lo = mid;
|
|
160
|
+
else hi = mid;
|
|
161
|
+
}
|
|
162
|
+
return c[lo] + ((c[hi] - c[lo]) * (at - x[lo])) / (x[hi] - x[lo]);
|
|
163
|
+
}
|
|
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
|
+
}
|
|
149
181
|
|
|
150
182
|
export class SolverError extends Error {
|
|
151
183
|
constructor(message, details) {
|
|
@@ -324,8 +356,16 @@ export class Solver {
|
|
|
324
356
|
this.dtNext = undefined;
|
|
325
357
|
|
|
326
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;
|
|
327
363
|
this.rxs = model.reactions.map((rx) => ({
|
|
328
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
|
+
}),
|
|
329
369
|
generation: rx.generation,
|
|
330
370
|
fixedA: rx.fixedA,
|
|
331
371
|
sp: Int32Array.from([...rx.reactants, ...rx.products], (p) => p.i),
|
|
@@ -739,9 +779,9 @@ export class Solver {
|
|
|
739
779
|
}
|
|
740
780
|
|
|
741
781
|
/**
|
|
742
|
-
* Cold start.
|
|
743
|
-
*
|
|
744
|
-
*
|
|
782
|
+
* Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
|
|
783
|
+
* the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
|
|
784
|
+
* profile varies), or continued across the interface dipole if nothing there responds.
|
|
745
785
|
*/
|
|
746
786
|
initFromComposition() {
|
|
747
787
|
const { model, n, M, u, z } = this;
|
|
@@ -750,15 +790,22 @@ export class Solver {
|
|
|
750
790
|
u.fill(0);
|
|
751
791
|
this.uLo.fill(0);
|
|
752
792
|
const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
|
|
753
|
-
|
|
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;
|
|
754
796
|
for (let r = 0; r < regions.length; r++) {
|
|
755
797
|
if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
|
|
756
798
|
const reg = regions[r], mat = materials[reg.material];
|
|
757
799
|
mode.fill(0);
|
|
800
|
+
const profiled = reg.c0Profile.some((p) => p !== null);
|
|
758
801
|
for (let i = 0; i < n; i++) {
|
|
759
802
|
if (!mat.present[i]) continue;
|
|
760
803
|
const st = this.stretches[this.stretchOf[r * n + i]];
|
|
761
|
-
if (
|
|
804
|
+
if (reg.c0Profile[i] || reg.c0[i] > 0) {
|
|
805
|
+
// A given c0 is the starting state, even where a contact feeds the species.
|
|
806
|
+
mode[i] = 2;
|
|
807
|
+
cFix[i] = reg.c0Profile[i] ? profileAt(reg.c0Profile[i], grid.x[grid.regionStart[r]]) : reg.c0[i];
|
|
808
|
+
} else if (!st.contactFed && mat.conductor) {
|
|
762
809
|
// A conductor away from the contacts: start uncharged, with its carrier's level in
|
|
763
810
|
// equilibrium with the first reaction on its left face that takes it, else at the
|
|
764
811
|
// running φ.
|
|
@@ -777,7 +824,8 @@ export class Solver {
|
|
|
777
824
|
} else if (!st.contactFed) {
|
|
778
825
|
if (!(reg.c0[i] > 0)) {
|
|
779
826
|
throw new SolverError(
|
|
780
|
-
`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'),
|
|
781
829
|
);
|
|
782
830
|
}
|
|
783
831
|
mode[i] = 2;
|
|
@@ -819,7 +867,8 @@ export class Solver {
|
|
|
819
867
|
return q;
|
|
820
868
|
};
|
|
821
869
|
const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
|
|
822
|
-
|
|
870
|
+
const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
|
|
871
|
+
const checkNeutral = (where) => {
|
|
823
872
|
// Strictly neutral, with every charged species' amount given: its c0 must be neutral.
|
|
824
873
|
let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
|
|
825
874
|
for (let i = 0; i < n; i++) {
|
|
@@ -829,11 +878,12 @@ export class Solver {
|
|
|
829
878
|
}
|
|
830
879
|
if (Math.abs(q) > 1e-9 * scale) {
|
|
831
880
|
throw new SolverError(
|
|
832
|
-
`regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m
|
|
881
|
+
`regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³${where} (Σ z·c0, with any fixed charge), ` +
|
|
833
882
|
'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
|
|
834
883
|
);
|
|
835
884
|
}
|
|
836
|
-
}
|
|
885
|
+
};
|
|
886
|
+
if (mustBalance && !profiled) checkNeutral('');
|
|
837
887
|
if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
|
|
838
888
|
// Nothing here fixes φ, but an electrode on the left does: start with its first reaction
|
|
839
889
|
// that takes a species from this side at equilibrium (the electrode at its open-circuit
|
|
@@ -854,19 +904,29 @@ export class Solver {
|
|
|
854
904
|
}
|
|
855
905
|
}
|
|
856
906
|
}
|
|
857
|
-
|
|
858
|
-
let lo =
|
|
907
|
+
const neutralPhi = (ph) => {
|
|
908
|
+
let lo = ph - 1, hi = ph + 1;
|
|
859
909
|
while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
|
|
860
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;
|
|
861
914
|
for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
|
|
862
915
|
const m = 0.5 * (lo + hi);
|
|
863
916
|
if (charge(m) > 0) lo = m;
|
|
864
917
|
else hi = m;
|
|
865
918
|
}
|
|
866
|
-
|
|
867
|
-
}
|
|
919
|
+
return 0.5 * (lo + hi);
|
|
920
|
+
};
|
|
921
|
+
if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
|
|
868
922
|
if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
|
|
869
923
|
for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
|
|
924
|
+
if (profiled) {
|
|
925
|
+
for (let i = 0; i < n; i++) if (reg.c0Profile[i]) cFix[i] = profileAt(reg.c0Profile[i], grid.x[g]);
|
|
926
|
+
if (mustBalance) checkNeutral(` at x = ${grid.x[g].toPrecision(4)} m`);
|
|
927
|
+
if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
|
|
928
|
+
if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
|
|
929
|
+
}
|
|
870
930
|
const b = this.blockOfNode[g];
|
|
871
931
|
u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
|
|
872
932
|
for (let i = 0; i < n; i++) {
|
|
@@ -1240,18 +1300,18 @@ export class Solver {
|
|
|
1240
1300
|
// (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
|
|
1241
1301
|
_bulkReactions(g, rxs) {
|
|
1242
1302
|
const { n, M, u, uLo, res, c } = this, R = this.rix;
|
|
1243
|
-
const b = this.blockOfNode[g], v = this.model.grid.vol[g]
|
|
1303
|
+
const b = this.blockOfNode[g], v = this.model.grid.vol[g];
|
|
1244
1304
|
const dr = this.dA;
|
|
1245
1305
|
for (let x = 0; x < rxs.length; x++) {
|
|
1246
1306
|
const rx = rxs[x];
|
|
1247
|
-
let P = rx.generation ? rx.
|
|
1307
|
+
let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
|
|
1248
1308
|
dr.fill(0); // ∂ ln P / ∂slot
|
|
1249
1309
|
// Participants: reactants with ν > 0, then products with ν < 0.
|
|
1250
1310
|
for (let p = 0; p < rx.sp.length; p++) {
|
|
1251
1311
|
const i = rx.sp[p], nu = rx.nu[p];
|
|
1252
1312
|
if (nu > 0) {
|
|
1253
1313
|
const ci = c[g * n + i];
|
|
1254
|
-
P *= nu === 1 ? ci : ci
|
|
1314
|
+
P *= nu === 1 ? ci : powi(ci, nu);
|
|
1255
1315
|
this._dlnc(g, i, nu, dr);
|
|
1256
1316
|
}
|
|
1257
1317
|
aHi += nu * u[b * M + 1 + i];
|
|
@@ -1597,7 +1657,7 @@ export class Solver {
|
|
|
1597
1657
|
aLo -= p.nu * uLo[o];
|
|
1598
1658
|
if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1599
1659
|
const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
|
|
1600
|
-
pref *= (c[g * n + p.i] / this.cRef[g * n + p.i]
|
|
1660
|
+
pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
|
|
1601
1661
|
this._dlnc(g, p.i, e, p.side ? dR : dL);
|
|
1602
1662
|
}
|
|
1603
1663
|
const { g: bv, gp } = bvFactor(aHi + aLo, al);
|
|
@@ -2089,7 +2149,7 @@ export class Solver {
|
|
|
2089
2149
|
if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
|
|
2090
2150
|
let grow = 0; // longer first steps tried after a Newton failure (see below)
|
|
2091
2151
|
const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
|
|
2092
|
-
const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * (tol / err)
|
|
2152
|
+
const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
|
|
2093
2153
|
while (this.time < tEnd) {
|
|
2094
2154
|
if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
|
|
2095
2155
|
// Within round-off of the end (a target a hair past a breakpoint just landed on, say):
|
|
@@ -2224,7 +2284,7 @@ export class Solver {
|
|
|
2224
2284
|
const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
|
|
2225
2285
|
for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
|
|
2226
2286
|
const w = h / hp;
|
|
2227
|
-
const Cc = (h
|
|
2287
|
+
const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
|
|
2228
2288
|
const Cp = h * (h + hp) * (h + hp + hpp);
|
|
2229
2289
|
return { scale: Cc / (Cc + Cp), order: 2 };
|
|
2230
2290
|
}
|
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/statistics.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { FARADAY, EPS0, AVOGADRO } from './constants.js';
|
|
18
18
|
import { fermiHalf, fermiMinusHalf } from './fermi.js';
|
|
19
|
+
import { powi } from './pow.js';
|
|
19
20
|
import { SolverError } from './solver.js';
|
|
20
21
|
|
|
21
22
|
const MODEL_TYPES = ['ideal', 'fermi-dirac', 'lattice', 'redlich-kister', 'debye-huckel', 'insertion', 'custom'];
|
|
@@ -189,7 +190,7 @@ function fermiDiracModel(idx, cRef, order) {
|
|
|
189
190
|
const r = target[a] / cRef[a];
|
|
190
191
|
// ln 𝓕(ζ) is increasing with slope 𝓕′/𝓕 ∈ (0, 1]
|
|
191
192
|
const f = (y) => ({ v: Math.log(F(y)), d: dF(y) / F(y) });
|
|
192
|
-
const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt((0.75 * Math.sqrt(Math.PI) * r
|
|
193
|
+
const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt(powi(0.75 * Math.sqrt(Math.PI) * r, 2)) : r;
|
|
193
194
|
zeta[a] = solveIncreasing(f, Math.log(r), Math.log(r) - 1, Math.max(guess, Math.log(r)) + 10, guess);
|
|
194
195
|
}
|
|
195
196
|
},
|
|
@@ -264,7 +265,7 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
|
|
|
264
265
|
let q = 0;
|
|
265
266
|
for (let b = 0; b < k; b++) q += s[b] * c[b];
|
|
266
267
|
const kappa = Math.sqrt((FARADAY * FARADAY * q) / (eps * RT));
|
|
267
|
-
const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a)
|
|
268
|
+
const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a) * (1 + kappa * a)) : 0;
|
|
268
269
|
let s2c = 0;
|
|
269
270
|
for (let b = 0; b < k; b++) s2c += s[b] * s[b] * c[b];
|
|
270
271
|
for (let b = 0; b < k; b++) lng[b] = (-s[b] * lB * kappa) / (2 * (1 + kappa * a));
|
|
@@ -425,7 +426,7 @@ function choleskyOk(K, k) {
|
|
|
425
426
|
const L = Float64Array.from(K);
|
|
426
427
|
for (let j = 0; j < k; j++) {
|
|
427
428
|
let d = L[j * k + j];
|
|
428
|
-
for (let p = 0; p < j; p++) d -= L[j * k + p]
|
|
429
|
+
for (let p = 0; p < j; p++) d -= L[j * k + p] * L[j * k + p];
|
|
429
430
|
if (!(d > 0)) return false;
|
|
430
431
|
d = Math.sqrt(d);
|
|
431
432
|
L[j * k + j] = d;
|
package/src/types.js
CHANGED
|
@@ -51,14 +51,20 @@
|
|
|
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>} [c0] initial concentrations,
|
|
61
|
-
* not connected to a contact: it fixes the amount they conserve)
|
|
65
|
+
* @property {Record<string, number | Profile>} [c0] initial concentrations,
|
|
66
|
+
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
67
|
+
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
62
68
|
* @property {number} [velocity] imposed flow toward +x, m/s, carrying every mobile species
|
|
63
69
|
* @property {number} [mixing] eddy (turbulent) mixing diffusivity, m²/s; current-free
|
|
64
70
|
* @property {GridOptions} [grid]
|
|
@@ -98,7 +104,8 @@
|
|
|
98
104
|
* @property {Record<string, number>} [nu] signed stoichiometric coefficients (ν < 0 consumed)
|
|
99
105
|
* @property {string} [equation] the reaction as an equation, e.g. 'e- + h+ = 0' (instead of nu)
|
|
100
106
|
* @property {Record<string, number>} [fixed] μ (J/mol) of fixed-activity participants that aren't species
|
|
101
|
-
* @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)
|
|
102
109
|
*/
|
|
103
110
|
|
|
104
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/pow.d.ts
ADDED
package/types/solver.d.ts
CHANGED
|
@@ -120,9 +120,9 @@ export class Solver {
|
|
|
120
120
|
/** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
|
|
121
121
|
contactEta(side: any, i: any): number;
|
|
122
122
|
/**
|
|
123
|
-
* Cold start.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
123
|
+
* Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
|
|
124
|
+
* the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
|
|
125
|
+
* profile varies), or continued across the interface dipole if nothing there responds.
|
|
126
126
|
*/
|
|
127
127
|
initFromComposition(): void;
|
|
128
128
|
_materialAt(mat: any, background: any, eta: any, mode: any, cFix: any, ph: any, zeta: any, cc: any): void;
|
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
|
|
@@ -130,10 +137,11 @@ export type RegionDef = {
|
|
|
130
137
|
*/
|
|
131
138
|
fixedCharge?: number;
|
|
132
139
|
/**
|
|
133
|
-
* initial concentrations,
|
|
134
|
-
* not connected to a contact: it fixes the amount they conserve)
|
|
140
|
+
* initial concentrations,
|
|
141
|
+
* mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
|
|
142
|
+
* or a profile against the device's x (m), piecewise linear; either is the starting state
|
|
135
143
|
*/
|
|
136
|
-
c0?: Record<string, number>;
|
|
144
|
+
c0?: Record<string, number | Profile>;
|
|
137
145
|
/**
|
|
138
146
|
* imposed flow toward +x, m/s, carrying every mobile species
|
|
139
147
|
*/
|
|
@@ -235,9 +243,10 @@ export type BulkReactionDef = {
|
|
|
235
243
|
*/
|
|
236
244
|
fixed?: Record<string, number>;
|
|
237
245
|
/**
|
|
238
|
-
* 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)
|
|
239
248
|
*/
|
|
240
|
-
kf: Record<string, number>;
|
|
249
|
+
kf: Record<string, number | Profile>;
|
|
241
250
|
};
|
|
242
251
|
/**
|
|
243
252
|
* Contacts use the same laws as internal faces; the outside is a phase with known levels
|