driftlet 0.4.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 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](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 |
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.4.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
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
  | [![impedance](demos/screenshots/impedance.png)](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [![ion-exchange membrane](demos/screenshots/membrane.png)](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [![double layer](demos/screenshots/double-layer.png)](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
- | [![insertion host](demos/screenshots/insertion.png)](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [![organic solar cell](demos/screenshots/organic.png)](https://markblundeberg.github.io/driftlet/demos/organic.html) | |
149
+ | [![insertion host](demos/screenshots/insertion.png)](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [![organic solar cell](demos/screenshots/organic.png)](https://markblundeberg.github.io/driftlet/demos/organic.html) | [![perovskite hysteresis](demos/screenshots/perovskite.png)](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
@@ -235,6 +235,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
235
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
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
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² |
238
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 |
239
241
  | Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
240
242
  | Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
package/docs/device.md CHANGED
@@ -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) between 0 and 1. A conductor's carrier has
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,6 +269,17 @@ $`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
@@ -486,7 +513,8 @@ const now = dev.solution(); // snapshot of the current state
486
513
  be the one measured: the resistance belongs to the external circuit. With `profiles: true`, each
487
514
  frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
488
515
  excitation.
489
- - `set(patch)` merges plain objects deeply (arrays are replaced). A patch that changes only the
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
490
518
  terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
491
519
  Otherwise the device is rebuilt: the current state carries over while the grid and species
492
520
  are unchanged, or restarts from the regions' `c0`. A carried state keeps what it holds: a
@@ -535,7 +563,7 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
535
563
  | `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
536
564
  | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
537
565
  | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
538
- | `interfaces[f]` | `{ dipole, sheetCharge, D, N: {name}, rates }`: what crosses each face by its links, and each face reaction's rate (mol/(m²·s)) |
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)) |
539
567
  | `charge` | total charge in the device, C/m² |
540
568
  | `conservation` | per species stretch: amount, reference, intake through contacts, drift |
541
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
@@ -189,6 +189,12 @@ a level diagram:
189
189
  shown (`speciesRole(sol, name)` gives a species' own). Undefined values are `NaN` (break the line
190
190
  there), and doubled interface nodes share an x, so steps draw as vertical lines. It also gives
191
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)).
192
198
  - `bandDiagram(sol, opts)` in `driftlet/plot` draws them as an SVG string, with no DOM needed, in
193
199
  the style [reading level diagrams](visualization.md) explains: species voltages thick and solid, standard levels thin and solid, redox
194
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
- - **Otherwise** (immobile spectators, which conserve node by node, or a combination that
549
- includes a floating conductor), backward-Euler steps at a huge $`dt`$ (10⁶ × the slowest
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
@@ -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.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';
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`.
@@ -52,7 +52,10 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
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⁻³. Generation is a reaction from a reservoir, rate `kf` in
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
56
59
  mol/(m³·s) (`units.perCm3` converts cm⁻³·s⁻¹ too); light absorbed as it goes in is
57
60
  `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })` (flux in
58
61
  mol/(m²·s), α in 1/m), a `kf` that varies with x. A face reaction's `k0` is in mol/(m²·s);
@@ -245,9 +248,9 @@ a Web Worker.
245
248
  <span id="readout"></span>
246
249
  <div id="figure"></div>
247
250
  <script type="module">
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';
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';
251
254
 
252
255
  const def = build({
253
256
  T: 300,
@@ -259,7 +262,7 @@ const def = build({
259
262
  const slider = document.getElementById('V');
260
263
  const dev = live(def, {
261
264
  onsolution(sol, info) {
262
- 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` });
263
266
  document.getElementById('readout').textContent = info.failed ? 'no solution here' : `I = ${sol.current.toExponential(2)} A/m²`;
264
267
  },
265
268
  });
@@ -272,6 +275,11 @@ slider.addEventListener('input', () => dev.set({ contacts: { right: { V: +slider
272
275
  What `bandDiagram()` and `traces()` draw, and how to explain it to a reader (docs/visualization.md
273
276
  has the long form):
274
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
+
275
283
  - **Fermi level** V_e⁻ = −μ̄_e⁻/F: the circuit voltage, what a voltmeter reads. A cell's voltage is
276
284
  the difference between its terminals' V_e⁻.
277
285
  - **Redox level** of Ox + z e⁻ ⇌ Red, `level(sol, half)`: V_e⁻(Ox/Red) = (μ̄_Ox − μ̄_Red)/(zF), where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {
package/src/describe.js CHANGED
@@ -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, else the fixed charge's.
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
148
  else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].values);
148
149
  });
149
- if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
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
@@ -70,6 +70,29 @@ function profile(v, path, what, ok, okText) {
70
70
  return { x: Float64Array.from(v.x), values: Float64Array.from(v.values) };
71
71
  }
72
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
+
73
96
  function nonNegative(v, path) {
74
97
  need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
75
98
  return v;
@@ -324,12 +347,27 @@ export function normalizeDevice(def) {
324
347
  // reaction runs only in those materials).
325
348
  function normalizeReaction(rdef, path, species, speciesIndex, materials, materialIndex, regions, RT) {
326
349
  need(isObject(rdef), `${path} must be an object`);
327
- fields(rdef, path, ['nu', 'equation', 'fixed', 'kf']);
350
+ fields(rdef, path, ['nu', 'equation', 'fixed', 'kf', 'srh']);
328
351
  need((rdef.nu === undefined) !== (rdef.equation === undefined), `${path}: give the reaction as nu or as an equation, one of them`);
329
352
  const nu = rdef.nu ?? equationStoichiometry(rdef.equation, `${path}.equation`);
330
353
  const st = stoichiometry(nu, rdef.equation === undefined ? `${path}.nu` : `${path}.equation`, rdef.fixed, `${path}.fixed`, species, speciesIndex, RT);
331
354
  need(st.list.length > 0, `${path}: no mobile participants`);
332
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
+ }
333
371
  need(isObject(rdef.kf), `${path}.kf must map material names to forward rate constants`);
334
372
  // Each a number, or a profile { x, values } against the device's x (e.g. absorption); kf[m]
335
373
  // is then its largest value, which says whether the reaction runs in that material at all.
@@ -400,7 +438,7 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
400
438
  return (idef.reactions ?? []).map((rdef, k) => {
401
439
  const rpath = `${where}.reactions[${k}]`;
402
440
  need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
403
- fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha']);
441
+ fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh']);
404
442
  let sides = rdef;
405
443
  if (rdef.equation !== undefined) {
406
444
  need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
@@ -420,6 +458,15 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
420
458
  }
421
459
  need(part.length > 0, `${rpath}: no species participate`);
422
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
+ }
423
470
  return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
424
471
  });
425
472
  }
@@ -820,7 +867,7 @@ function defaultInterfaceLinks(matL, matR, species) {
820
867
 
821
868
  function transferCoefficient(v, path) {
822
869
  if (v === undefined) return 0.5;
823
- need(isFiniteNumber(v) && v > 0 && v < 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
870
+ need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
824
871
  return v;
825
872
  }
826
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 change to the terminals' drives alone (V, I,
67
- * R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
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/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. Options are those of `traces()` and `levelChart()`.
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
@@ -362,6 +362,7 @@ export class Solver {
362
362
  const { x: gx, segLength } = model.grid;
363
363
  this.rxs = model.reactions.map((rx) => ({
364
364
  kf: rx.kf,
365
+ srh: rx.srh, // per material: SRH kinetics in place of mass action, or null
365
366
  kfNode: Float64Array.from(gx, (x, g) => {
366
367
  const m = this.nodeMaterial[g], p = rx.kfProfile[m];
367
368
  return p ? profileMean(p, x - (g > 0 ? segLength[g - 1] : 0) / 2, x + (g < segLength.length ? segLength[g] : 0) / 2) : rx.kf[m];
@@ -563,9 +564,63 @@ export class Solver {
563
564
  // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
564
565
  add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
565
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
+ }
566
589
  this.constrained = false;
567
590
  }
568
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
+
569
624
  // A basis of the conserved combinations of stretch amounts: the null space of the
570
625
  // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
571
626
  // for each stretch fed from outside. Exact integer data, so plain elimination will do.
@@ -1115,6 +1170,7 @@ export class Solver {
1115
1170
  // flux), then contacts, then the circuit rows of the floating ones.
1116
1171
  this._terminals(dt);
1117
1172
  if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1173
+ if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
1118
1174
  this.transformed = this.combining && dt !== Infinity;
1119
1175
  if (this.transformed) this._chargeRows(dt);
1120
1176
  }
@@ -1304,6 +1360,26 @@ export class Solver {
1304
1360
  const dr = this.dA;
1305
1361
  for (let x = 0; x < rxs.length; x++) {
1306
1362
  const rx = rxs[x];
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
+ }
1307
1383
  let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
1308
1384
  dr.fill(0); // ∂ ln P / ∂slot
1309
1385
  // Participants: reactants with ν > 0, then products with ν < 0.
@@ -1639,6 +1715,25 @@ export class Solver {
1639
1715
  d[0] = -this._Kz(g, k);
1640
1716
  }
1641
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
+
1642
1737
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
1643
1738
  // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1644
1739
  // there when ν > 0). The rate couples the two edge nodes only through the face block between
@@ -1650,24 +1745,34 @@ export class Solver {
1650
1745
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1651
1746
  const al = rx.alpha;
1652
1747
  const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
1653
- let pref = rx.k0, aHi = rx.fixedA, aLo = 0;
1748
+ let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
1654
1749
  for (const p of rx.part) {
1655
1750
  const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
1656
1751
  aHi -= p.nu * u[o];
1657
1752
  aLo -= p.nu * uLo[o];
1658
- 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
1659
1754
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1660
1755
  pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1661
1756
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1662
1757
  }
1663
- const { g: bv, gp } = bvFactor(aHi + aLo, al);
1664
- const rate = pref * bv;
1665
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
1666
- for (let s = 0; s < M; s++) {
1667
- dL[s] *= rate;
1668
- dR[s] *= rate;
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;
1669
1775
  }
1670
- for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
1671
1776
  const slot = 1 + n + k, o = bf * M + slot;
1672
1777
  res[R[o]] = u[o] - rate;
1673
1778
  this._j(bf, slot, bf, slot, 1);
@@ -2148,6 +2253,9 @@ export class Solver {
2148
2253
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2149
2254
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2150
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);
2151
2259
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
2152
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);
2153
2261
  while (this.time < tEnd) {
@@ -2204,7 +2312,7 @@ export class Solver {
2204
2312
  grow = 3;
2205
2313
  dt = h / 4;
2206
2314
  }
2207
- if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2315
+ if (dt < floor) { failed = true; break; }
2208
2316
  continue;
2209
2317
  }
2210
2318
  } else {
@@ -2213,7 +2321,7 @@ export class Solver {
2213
2321
  if (!r.converged) {
2214
2322
  rejected++;
2215
2323
  dt = h / 4;
2216
- if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2324
+ if (dt < floor) { failed = true; break; }
2217
2325
  continue;
2218
2326
  }
2219
2327
  p = this._predict(pred, r.bdf);
@@ -2734,6 +2842,17 @@ export class Solver {
2734
2842
  return Number.isFinite(dMin) ? (L * L) / dMin : 1;
2735
2843
  }
2736
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
+
2737
2856
  /**
2738
2857
  * Steady state.
2739
2858
  * - If every species stretch is fed by a contact, nothing is conserved on its own, so the
@@ -2746,6 +2865,8 @@ export class Solver {
2746
2865
  */
2747
2866
  solveSteady(opts = {}) {
2748
2867
  this.conditioning = null;
2868
+ this.computeConcentrations();
2869
+ this._captureLocal(); // immobile combinations keep, node by node, what they hold now
2749
2870
  this.sourceTime = this.time;
2750
2871
  // Continuation applies where the right terminal is held at a voltage, against a held left one.
2751
2872
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
@@ -2921,8 +3042,10 @@ export class Solver {
2921
3042
  history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
2922
3043
  }
2923
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).
2924
3047
  dt = dt >= giant ? tau * 1e-6 : dt / 4;
2925
- if (dt < tau * 1e-15) break;
3048
+ if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
2926
3049
  continue;
2927
3050
  }
2928
3051
  if (dt === Infinity) {
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
- * @param {{ species?: string[], standard?: boolean, phi?: boolean | string[],
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 shown = species ?? names.filter((name) => finite(sol.V[name]));
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 ? y.map((v) => v + shift) : y);
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)} V`;
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/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 change to the terminals' drives alone (V, I,
17
- * R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
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/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. Options are those of `traces()` and `levelChart()`.
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/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
- * @param {{ species?: string[], standard?: boolean, phi?: boolean | string[],
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;