driftlet 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/docs/device.md +79 -3
- package/docs/kit.md +26 -2
- package/docs/numerics.md +16 -0
- package/llms.txt +14 -9
- package/package.json +1 -1
- package/src/checks.js +10 -9
- package/src/describe.js +12 -1
- package/src/device.js +95 -13
- package/src/grid.js +73 -0
- package/src/kit.js +1 -0
- package/src/polarization.js +84 -0
- package/src/solution.js +12 -4
- package/src/solver.js +180 -41
- package/src/types.js +15 -4
- package/types/grid.d.ts +29 -0
- package/types/kit.d.ts +1 -0
- package/types/polarization.d.ts +31 -0
- package/types/solver.d.ts +6 -2
- package/types/types.d.ts +41 -4
package/README.md
CHANGED
|
@@ -26,13 +26,13 @@ minor versions.
|
|
|
26
26
|
## Is it for your problem?
|
|
27
27
|
|
|
28
28
|
The same equations go by different names in different fields. If your problem is one-dimensional
|
|
29
|
-
(planar), it's probably here:
|
|
29
|
+
(planar, or radial: spheres and cylinders, or any cross-section varying along x), it's probably here:
|
|
30
30
|
|
|
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
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 as a rate law, in the bulk and at faces | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
|
|
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), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc |
|
|
35
|
+
| Electrochemistry, corrosion | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions, mixed potentials, electrodes under a film | [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), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js), [`ports`](test/ports.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc; Wagner–Traud |
|
|
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 |
|
|
38
38
|
| Membranes, desalination | Donnan, ion exchange, liquid junctions, water dissociation | [membrane demo](https://markblundeberg.github.io/driftlet/demos/membrane.html), [`equilibrium`](test/equilibrium.test.js), [`neutral`](test/neutral.test.js) tests | Donnan partition, Planck EMF |
|
|
@@ -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.8.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
|
|
|
@@ -204,7 +204,9 @@ current. Supported physics:
|
|
|
204
204
|
|
|
205
205
|
## What it doesn't do
|
|
206
206
|
|
|
207
|
-
- **More than one dimension.**
|
|
207
|
+
- **More than one dimension.** 1D only: planar, or a cross-section varying along x (spheres,
|
|
208
|
+
cylinders, a thinning film), so no porous electrodes with diffusion inside their particles
|
|
209
|
+
(P2D models). For 2D/3D,
|
|
208
210
|
see [ChargeTransport.jl](https://github.com/WIAS-PDELib/ChargeTransport.jl), TCAD tools or
|
|
209
211
|
COMSOL.
|
|
210
212
|
- **Cross-diffusion.** Each species moves down its own μ̄ with its own D (the statistics can be
|
|
@@ -259,6 +261,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
259
261
|
| Advection and eddy mixing | exact convection–diffusion profile; `D + D_mix`; junction EMF unchanged by mixing | 1e-12; 1e-12; 1e-5 |
|
|
260
262
|
| Metal regions | ohmic; Schottky face and MOS gate equal their contact forms; Ag \| AgNO₃ \| Ag with metal electrodes; bipolar electrode at V/2 | 1e-12; 1e-9; 1e-9; 1e-9 |
|
|
261
263
|
| Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel | 1e-4; 1e-9; 2e-3 |
|
|
264
|
+
| Cross-sections (spherical, cylindrical, any A(x)) | steady diffusion to a sphere and a cylinder (exact on any grid); Cottrell with the spherical term; uptake by a sphere filling from its surface; Debye–Hückel around a charged sphere, potential and charge | 1e-10; 3e-4; 2e-4; 1e-3, 3e-4 |
|
|
265
|
+
| Electrodes spread through a port | transmission line with the reaction's linear kinetics as the conductance (O(h²)), rates along it; Wagner–Traud mixed potential of two Butler–Volmer couples, floating | 1e-4, 2e-3; 1e-6 V |
|
|
262
266
|
| Impedance | Macdonald blocking-electrode spectrum, 100 Hz–1 GHz; finite-length Warburg (Ag \| AgNO₃ \| Ag); DC limit = differential resistance | 3e-4; 3e-5; 1e-6 |
|
|
263
267
|
| Conservation | per step, and against time-integrated contact fluxes | 1e-11 relative |
|
|
264
268
|
| Strictly neutral limit (ε = 0) | Planck EMF; polarization with no overlimiting; Donnan at neutral faces | 1e-5; 1e-4; 1e-9 |
|
package/docs/device.md
CHANGED
|
@@ -24,6 +24,7 @@ too (`FARADAY`, `GAS_CONSTANT`, `EPS0`, …).
|
|
|
24
24
|
| `contacts` | `{ left, right }` |
|
|
25
25
|
| `ports` | internal ports: outside phases exchanging with a window of nodes |
|
|
26
26
|
| `grid` | default spacing for every region |
|
|
27
|
+
| `geometry` | the cross-section $`A(x)`$: planar (default), spherical or cylindrical shells, or a profile (see [geometry](#geometry)) |
|
|
27
28
|
|
|
28
29
|
## Species
|
|
29
30
|
|
|
@@ -445,7 +446,16 @@ contact: held at a voltage, behind a resistance, or driven by a current (a refer
|
|
|
445
446
|
is a port at `I: 0`). Each solution reports `ports[k]`, with `{ name, V, flux, current }`: what
|
|
446
447
|
the port brings into the device.
|
|
447
448
|
|
|
448
|
-
A held (`'equilibrium'`) level leaves the device's two end nodes to their contacts.
|
|
449
|
+
A held (`'equilibrium'`) level leaves the device's two end nodes to their contacts. A port that
|
|
450
|
+
exchanges only neutral species carries no current, so its voltage means nothing: give it no drive.
|
|
451
|
+
|
|
452
|
+
An exchange link's source is linear in $`\mu_{out} - \mu`$, so it grows without bound as the
|
|
453
|
+
species runs out ($`\ln c`$). A supply limited by diffusion through a film above the window, such as O₂
|
|
454
|
+
reaching a thin layer of water from the air, is linear in $`c`$ instead: $`k(c_{sat} - c)`$. For that,
|
|
455
|
+
use a [bulk reaction](#bulk-reactions) with the outside phase as a fixed participant, e.g.
|
|
456
|
+
`{ equation: 'Air = O2', fixed: { Air: muSat }, kf: { water: profile } }`. Its rate is
|
|
457
|
+
$`k_f (1 - c/c_{sat})`$, and a profile gives $`k_f`$ its spatial variation (a film of thickness $`h(x)`$:
|
|
458
|
+
$`D c_{sat}/h^2`$).
|
|
449
459
|
|
|
450
460
|
A port driven by a current (`I`, or a waveform) floats to whatever voltage delivers it: with a
|
|
451
461
|
conductance `G` per volume over a window of width $`w`$, it sits about $`I/(G w)`$ above the
|
|
@@ -467,6 +477,44 @@ steady state can be computed there. A port holding the electrons beside the oxid
|
|
|
467
477
|
channel's potential anchors it, as source and drain would, and the device then shows the
|
|
468
478
|
low-frequency C–V.
|
|
469
479
|
|
|
480
|
+
### Electrodes spread through a window
|
|
481
|
+
|
|
482
|
+
A port can also hold an electrode: a metal whose carrier sits at the port's level, reacting all
|
|
483
|
+
through the window rather than at one face. That's the floor under a thin film of electrolyte, the
|
|
484
|
+
walls of a crevice or a pit, or the matrix of a porous electrode, wherever the metal conducts well
|
|
485
|
+
enough to be one level. Give the port its `reactions` (Butler–Volmer, as at a face), the carrier as
|
|
486
|
+
its `terminal` (the species need be in no material), and the electrode's `area` per volume of the
|
|
487
|
+
window:
|
|
488
|
+
|
|
489
|
+
```js nocheck
|
|
490
|
+
ports: [{
|
|
491
|
+
name: 'iron', region: 'film', terminal: 'e-', V: 0,
|
|
492
|
+
area: 1 / 100e-6, // m²/m³: a film 100 µm thick on the metal; or a profile against x
|
|
493
|
+
reactions: [
|
|
494
|
+
{ equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
|
|
495
|
+
{ equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.13e3 }, k0: 3e-9, alpha: 0.125 },
|
|
496
|
+
],
|
|
497
|
+
}]
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Each node's rate per area is the face's law, with the region's species at that node and the
|
|
501
|
+
carrier at activity 1 and level $`V`$. Times the area per volume, it makes and consumes the
|
|
502
|
+
region's species there, and the electrons it takes or gives are the port's current. The port is
|
|
503
|
+
a terminal like any other. Held at a voltage, it's a potentiostat. At `I: 0` (or with nothing
|
|
504
|
+
else to carry current), the reactions settle at the mixed potential, the corrosion potential where
|
|
505
|
+
anodic and cathodic currents cancel over the window, while each node can be a net anode or a net
|
|
506
|
+
cathode. A floating one starts there, for the start's composition. Each solution reports the
|
|
507
|
+
window's nodes and every reaction's rate at each, `ports[k].x` and `ports[k].rates`
|
|
508
|
+
(mol/(m²·s) of electrode, forward), with `ports[k].area`.
|
|
509
|
+
|
|
510
|
+
Validated against the transmission line (linear kinetics along a bar, test/ports.test.js) and the
|
|
511
|
+
Wagner–Traud mixed potential of two Butler–Volmer couples. In 1D, a film on a metal is a slice
|
|
512
|
+
along the metal: the film's thickness enters through `area`, and through a bulk reaction for
|
|
513
|
+
anything that reaches the film from above (O₂ from the air, see the exchange link above). Where
|
|
514
|
+
the film's thickness varies, give the device its [cross-section](#geometry) too, so that ions
|
|
515
|
+
travelling along the film squeeze through where it thins: for a drop on a metal, seen from its
|
|
516
|
+
centre, $`A = 2\pi r\,h(r)`$ with `area` $`1/h(r)`$.
|
|
517
|
+
|
|
470
518
|
## Terminals
|
|
471
519
|
|
|
472
520
|
The two contacts and every port are the device's terminals, named `left`, `right` and by each
|
|
@@ -521,6 +569,34 @@ cells exceeds the limiting current by 1.4% at 0.5 V, while `{ hmin: 10e-9, hmax:
|
|
|
521
569
|
within 0.1% with as many nodes. Where a species carrying the current is steep across a region's
|
|
522
570
|
end cells in this way, the solution's `warnings` say so, with a rough estimate of the excess.
|
|
523
571
|
|
|
572
|
+
## Geometry
|
|
573
|
+
|
|
574
|
+
A device is planar unless it says otherwise: a slab whose cross-section $`A`$ is 1 m², so every
|
|
575
|
+
current, flux, amount and charge reads per m². With `geometry`, the cross-section varies along x,
|
|
576
|
+
and the conservation laws hold for totals through it: a box holds $`\int A\,dx`$, and what crosses
|
|
577
|
+
a face is the flux there times $`A`$ there.
|
|
578
|
+
|
|
579
|
+
```js nocheck
|
|
580
|
+
geometry: { type: 'spherical', r0: 10e-6 }, // shells, r = r0 + x: a microelectrode of radius r0
|
|
581
|
+
geometry: { type: 'cylindrical', r0: 0 }, // a wire or fibre from its axis (per metre of length)
|
|
582
|
+
geometry: { area: { x: [0, 1e-3], values: [3e-6, 1e-7] } }, // A (m²) against x: a film thinning, a pit
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
This is how radial problems become 1D, with the right $`1/r^2`$ (or $`1/r`$) in every divergence and in
|
|
586
|
+
Gauss's law, without writing them out. The finite volumes integrate over shells, so conservation
|
|
587
|
+
is exact as in a slab. Each segment's transport is weighted by its length over $`\int dx/A`$, which
|
|
588
|
+
makes steady diffusion between two nodes exact for any $`A(x)`$ (the $`1/r`$ and $`\ln r`$ profiles
|
|
589
|
+
on any grid). Where $`A`$ vanishes at an end (a sphere's centre, `r0: 0`), the segment takes $`A`$ at
|
|
590
|
+
its middle, and nothing passes the end. Only an end may have $`A = 0`$. Flow (`velocity`) is for
|
|
591
|
+
planar devices.
|
|
592
|
+
|
|
593
|
+
With a cross-section, the solution's currents, contact and port fluxes, terminal currents and
|
|
594
|
+
`charge` are totals (A, mol/s; per metre for cylinders), and so are `I` drives. Face laws,
|
|
595
|
+
rates, `interfaces[f].N` and `D`, and concentrations stay per area or per volume. Validated against
|
|
596
|
+
steady diffusion to a sphere and a cylinder (exact), Cottrell's transient with the spherical term,
|
|
597
|
+
uptake by a sphere filling from its surface, and Debye–Hückel screening around a charged sphere
|
|
598
|
+
(test/geometry.test.js).
|
|
599
|
+
|
|
524
600
|
## Using a device
|
|
525
601
|
|
|
526
602
|
```js nocheck
|
|
@@ -610,14 +686,14 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
|
|
|
610
686
|
| `phi` | bookkeeping $`\phi`$, V (`NaN` where undefined) |
|
|
611
687
|
| `c[name]`, `mu[name]`, `muStd[name]` | concentration, $`\bar\mu`$, standard level $`\mu^\circ + zF\phi`$ (`NaN` where absent) |
|
|
612
688
|
| `V[name]`, `Vstd[name]` | species voltage $`\bar\mu/(zF)`$ and standard level as a voltage (charged species) |
|
|
613
|
-
| `current`, `terminalVoltage` | current toward +x through the device (A/m
|
|
689
|
+
| `current`, `terminalVoltage` | current toward +x through the device (A/m², or A through a [cross-section](#geometry)) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
|
|
614
690
|
| `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
|
|
615
691
|
| `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
|
|
616
692
|
| `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
|
|
617
693
|
| `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
|
|
618
694
|
| `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)) |
|
|
619
695
|
| `bulkReactions[k]` | `{ rate, regions, total }`: each bulk reaction's forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
|
|
620
|
-
| `charge` | total charge in the device, C/m² |
|
|
696
|
+
| `charge` | total charge in the device, C/m² (C through a cross-section) |
|
|
621
697
|
| `conservation` | per species stretch: amount, reference, intake through contacts, drift |
|
|
622
698
|
| `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, and for a failed solve, where the system is nearly singular |
|
|
623
699
|
| `converged`, `iterations`, `steps`, `substeps`, `history`, `time` | solver bookkeeping |
|
package/docs/kit.md
CHANGED
|
@@ -82,7 +82,7 @@ $`\mu_{\mathrm{H_2}} = 0`$, so its standard level is $`\phi + \mu^\circ_{\mathrm
|
|
|
82
82
|
|
|
83
83
|
```js
|
|
84
84
|
import { Device } from 'driftlet';
|
|
85
|
-
import { build, layer, ohmic, half, level } from 'driftlet/kit';
|
|
85
|
+
import { build, layer, ohmic, half, level, polarization } from 'driftlet/kit';
|
|
86
86
|
|
|
87
87
|
const silver = half('Ag+ + e- = Ag(s)', { 'Ag(s)': 0 });
|
|
88
88
|
const stern = { phi: { type: 'capacitive', C: 0.2 }, zeroCharge: 0.1 }; // an assumed pzc
|
|
@@ -108,11 +108,35 @@ const def = build({
|
|
|
108
108
|
grid: { hmin: 0.1e-9, hmax: 100e-9, ratio: 1.15 },
|
|
109
109
|
});
|
|
110
110
|
console.log(JSON.stringify(def.interfaces[0].reactions[0])); // { left: { e-: -1, Ag(s): 1 }, right: { Ag+: -1 }, … }
|
|
111
|
-
const
|
|
111
|
+
const dev = new Device(def), sol = dev.solve();
|
|
112
112
|
const redox = level(sol, silver); // V_Ag⁺(x) in the solution
|
|
113
113
|
console.log(`redox level at mid-cell: ${redox[sol.x.length >> 1].toFixed(4)} V`);
|
|
114
|
+
// The left electrode's polarization curve, 0.2 V either side of its own level (see below).
|
|
115
|
+
const metalSide = sol.V['e-'][0];
|
|
116
|
+
const curve = polarization(dev, sol, { face: 0 }, Array.from({ length: 81 }, (_, j) => metalSide - 0.2 + j * 0.005));
|
|
117
|
+
console.log(curve.reactions[0].level, curve.current[40]); // the couple's level here, and the current at the metal's own
|
|
114
118
|
```
|
|
115
119
|
|
|
120
|
+
### Polarization curves: an Evans diagram
|
|
121
|
+
|
|
122
|
+
`polarization(device, sol, where, V)` evaluates every electrode reaction at one spot with the
|
|
123
|
+
solution beside it held as `sol` has it and the metal's level set to each `V` (an electron
|
|
124
|
+
voltage, the terminals' convention). It works at a face (`{ face: f }`, the face's metal side) or
|
|
125
|
+
at a spot of an [electrode port](device.md#electrodes-spread-through-a-window)
|
|
126
|
+
(`{ port: name, x }`). Each reaction comes back with its rate (mol/(m²·s), forward), its partial
|
|
127
|
+
current (A/m², anodic positive: into the solution) and its `level`, where the rate vanishes (the
|
|
128
|
+
couple's redox level there, as `level()` gives it). The net current is their sum. Plotting
|
|
129
|
+
log|current| against V gives the Evans diagram. The net crosses zero at the spot's open-circuit
|
|
130
|
+
potential, between the couples' levels, and the metal's actual level is where the solve put it.
|
|
131
|
+
The law is the solver's own, so at that level the rates are the solution's. Against SHE, subtract
|
|
132
|
+
the SHE level (`sol.phi` with the usual tables' ions).
|
|
133
|
+
|
|
134
|
+
The example above ends with the left electrode's curve.
|
|
135
|
+
|
|
136
|
+
Concentrations are held, so these are the curves of that instant (for a mixed potential, the
|
|
137
|
+
kinetic picture). Away from the actual level the composition beside the electrode would change,
|
|
138
|
+
which is the transport the full solve accounts for.
|
|
139
|
+
|
|
116
140
|
## Sources
|
|
117
141
|
|
|
118
142
|
Helpers that write sources as plain data:
|
package/docs/numerics.md
CHANGED
|
@@ -117,6 +117,22 @@ Grids grade geometrically from `hmin` at each region end up to `hmax`. They're n
|
|
|
117
117
|
automatically, to avoid performance surprises. Instead, solutions warn when a double layer the
|
|
118
118
|
model is meant to resolve is coarser than the local Debye length.
|
|
119
119
|
|
|
120
|
+
### A cross-section
|
|
121
|
+
|
|
122
|
+
With a [`geometry`](device.md#geometry), the device has a cross-section $`A(x)`$ (spherical
|
|
123
|
+
shells $`4\pi r^2`$, cylindrical $`2\pi r`$ with $`r = r_0 + x`$, or a profile), and every balance
|
|
124
|
+
is for totals through it:
|
|
125
|
+
$`\partial_t (A c_i) + \partial_x (A N_i) = A \sum_r \nu_{ir} r_r`$, and Gauss's law
|
|
126
|
+
$`-\partial_x (A\varepsilon\,\partial_x\phi) = A\rho`$. The box method carries this without writing out any
|
|
127
|
+
$`1/r`$ terms: a box's volume becomes $`\int A\,dx`$ over it (exactly, for each shape), and what a
|
|
128
|
+
segment, a face or a contact passes is multiplied by the area it passes through. A segment's
|
|
129
|
+
$`1/h`$ becomes $`1/\int dx/A`$ over it, its resistance per unit conductivity, which is the exact
|
|
130
|
+
weighting for steady diffusion (a constant total flux $`AN`$, so $`\Delta c = AN\int dx/A\,/D`$).
|
|
131
|
+
Scharfetter–Gummel keeps its form with that weighting, and so does the displacement. Where $`A`$
|
|
132
|
+
vanishes at an end (a sphere's centre), $`\int dx/A`$ diverges, so that segment takes $`A`$ at its
|
|
133
|
+
middle. A flux node's unknowns stay per area, entering each side's balance times the face's
|
|
134
|
+
area. A planar device has $`A = 1`$ and every factor is exactly 1, so it's unchanged to the last bit.
|
|
135
|
+
|
|
120
136
|
### What lives where
|
|
121
137
|
|
|
122
138
|
| Place | Holds |
|
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.8.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -50,7 +50,7 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
|
50
50
|
V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
|
|
51
51
|
`level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
|
|
52
52
|
`injector`, `recombination`, `photogeneration`), the data library (`aqueous`, `semiconductor`,
|
|
53
|
-
`metal`, `IONS`, …), `check` (a solution's checks), `henderson` and `planck` (liquid-junction potentials), `recorder` (a transient's trace and frames across calls), the curated
|
|
53
|
+
`metal`, `IONS`, …), `check` (a solution's checks), `polarization` (an electrode's reactions at hypothetical metal levels, with the solution held: an Evans diagram), `henderson` and `planck` (liquid-junction potentials), `recorder` (a transient's trace and frames across calls), the curated
|
|
54
54
|
`perovskiteCell` with IonMonger's `PEROVSKITE_SCANS` and `hysteresis`, and the vacuum-level helpers. `driftlet/plot` (a quick look; `traces()` is the data for your own charts): `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`), `themeColor`.
|
|
55
55
|
- **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
|
|
56
56
|
`new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
|
|
@@ -319,9 +319,9 @@ solve), as long as it settles rather than ping-pongs.
|
|
|
319
319
|
<span id="readout"></span>
|
|
320
320
|
<div id="figure"></div>
|
|
321
321
|
<script type="module">
|
|
322
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
323
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
324
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
322
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/index.js';
|
|
323
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
|
|
324
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
|
|
325
325
|
|
|
326
326
|
const def = build({
|
|
327
327
|
T: 300,
|
|
@@ -405,11 +405,16 @@ has the long form):
|
|
|
405
405
|
do. It checks the setup: a solver's output looks authoritative even when an input is wrong, and
|
|
406
406
|
a mismatch is the cheapest way to catch it. Where the two should differ (high injection, a
|
|
407
407
|
finite pulse), say why.
|
|
408
|
-
- **Stay honestly inside 1D.** driftlet models
|
|
408
|
+
- **Stay honestly inside 1D.** driftlet models devices along one coordinate: planar, or with a
|
|
409
|
+
cross-section A(x) (`geometry`: spherical or cylindrical shells, or a profile), in which case
|
|
410
|
+
currents and fluxes are totals through it. Where the real device isn't one,
|
|
409
411
|
say so and pick the slice that still tells the true story: a MOSFET's gate stack, read as the
|
|
410
412
|
MOS capacitor its channel sits on (pinch-off along the channel is 2D); a BJT along its
|
|
411
413
|
emitter–base–collector axis; one particle of a porous battery electrode, or a thin dense film,
|
|
412
|
-
rather than the whole porous electrode (that's a P2D model).
|
|
414
|
+
rather than the whole porous electrode with diffusion inside its particles (that's a P2D model).
|
|
415
|
+
A metal that reacts all along a film of electrolyte (corroding iron under a drop, a crevice's
|
|
416
|
+
walls, a well-conducting porous matrix) is a port with `reactions` and an `area` per volume:
|
|
417
|
+
an electrode spread through a window, not a face at each end.
|
|
413
418
|
- **Check `sol.converged` and `sol.warnings`** on every solve, and pin the CDN version you
|
|
414
419
|
tested against.
|
|
415
420
|
|
package/package.json
CHANGED
package/src/checks.js
CHANGED
|
@@ -65,7 +65,7 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
|
|
|
65
65
|
// The rest checks a solution, which an unconverged state isn't.
|
|
66
66
|
if (sol.converged) {
|
|
67
67
|
const ledger = steady ? balance(device, sol) : null;
|
|
68
|
-
items.push(ledger ?? conservation(sol));
|
|
68
|
+
items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
|
|
69
69
|
if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -164,26 +164,27 @@ function balance(device, sol) {
|
|
|
164
164
|
// carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
|
|
165
165
|
// flows are round-off of these.
|
|
166
166
|
function naturalScales(device, sol) {
|
|
167
|
-
const { model, solver } = device, { species } = model;
|
|
168
|
-
|
|
167
|
+
const { model, solver } = device, { species, grid } = model;
|
|
168
|
+
// (totals through the cross-section, as the ledgers are: its largest in each region)
|
|
169
|
+
const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
|
|
169
170
|
return species.map((sp, i) => {
|
|
170
171
|
let S = 0;
|
|
171
172
|
model.regions.forEach((reg, r) => {
|
|
172
173
|
const mat = model.materials[reg.material], L = reg.length;
|
|
173
174
|
if (mat.conductor) {
|
|
174
|
-
if (mat.conductor.i === i) S = Math.max(S, (mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
|
|
175
|
+
if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
|
|
175
176
|
return;
|
|
176
177
|
}
|
|
177
178
|
if (!mat.present[i] || !(mat.D[i] > 0)) return;
|
|
178
179
|
let cmax = 0;
|
|
179
180
|
for (let g = 0; g < sol.x.length; g++) if (sol.region[g] === r && Number.isFinite(sol.c[sp.name][g])) cmax = Math.max(cmax, sol.c[sp.name][g]);
|
|
180
|
-
S = Math.max(S, (mat.D[i] * cmax) / L);
|
|
181
|
+
S = Math.max(S, (area[r] * mat.D[i] * cmax) / L);
|
|
181
182
|
});
|
|
182
183
|
solver.rxs.forEach((rx) => {
|
|
183
184
|
const p = rx.sp.indexOf(i);
|
|
184
185
|
if (p < 0) return;
|
|
185
186
|
let gross = 0;
|
|
186
|
-
for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross +=
|
|
187
|
+
for (let g = 0; g < sol.x.length; g++) if (rx.kf[solver.nodeMaterial[g]] > 0) gross += grid.vol[g] * solver.bulkOneWay(rx, g);
|
|
187
188
|
S = Math.max(S, Math.abs(rx.nu[p]) * gross);
|
|
188
189
|
});
|
|
189
190
|
return S;
|
|
@@ -191,12 +192,12 @@ function naturalScales(device, sol) {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
// Closed, unreacting stretches keep their amounts in a transient.
|
|
194
|
-
function conservation(sol) {
|
|
195
|
+
function conservation(sol, area = 1) {
|
|
195
196
|
const closed = sol.conservation.filter((st) => !st.connected && !st.reactive);
|
|
196
197
|
if (closed.length === 0) return { name: 'conservation', ok: true, summary: 'nothing closed to conserve (every species reaches a terminal or reacts)' };
|
|
197
198
|
// Relative to the amount, but a drift below 1e-20 mol/m² (1e-15 C/m²) is nothing: a floating
|
|
198
|
-
// metal's excess electrons are themselves ~0.
|
|
199
|
-
const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 ? 0 : Math.abs(st.drift))));
|
|
199
|
+
// metal's excess electrons are themselves ~0. (Amounts are totals through the cross-section.)
|
|
200
|
+
const worst = Math.max(...closed.map((st) => (Math.abs(st.amount - st.reference - st.intake) < 1e-20 * area ? 0 : Math.abs(st.drift))));
|
|
200
201
|
return {
|
|
201
202
|
name: 'conservation',
|
|
202
203
|
ok: worst < 1e-8,
|
package/src/describe.js
CHANGED
|
@@ -125,6 +125,11 @@ export function describe(def) {
|
|
|
125
125
|
const warnings = unitWarnings(def);
|
|
126
126
|
const total = grid.x[grid.x.length - 1] - grid.x[0];
|
|
127
127
|
lines.push(`${regions.length} region${regions.length > 1 ? 's' : ''}, ${si(total, 'm')}, ${grid.x.length} nodes, T = ${model.T} K`);
|
|
128
|
+
const geo = model.geometry;
|
|
129
|
+
if (geo.type !== 'planar') {
|
|
130
|
+
const shape = geo.type === 'profile' ? `a cross-section from ${num(Math.min(...grid.area))} to ${num(Math.max(...grid.area))} m²` : `${geo.type} shells, r = ${si(geo.r0, 'm')} + x${geo.type === 'cylindrical' ? ' (per metre of length)' : ''}`;
|
|
131
|
+
lines.push(`geometry: ${shape}; currents and fluxes are totals through it (A, mol/s)`);
|
|
132
|
+
}
|
|
128
133
|
lines.push(`species: ${species.map((sp) => `${sp.name} (z = ${sp.z > 0 ? '+' : ''}${sp.z})`).join(', ')}`);
|
|
129
134
|
|
|
130
135
|
lines.push('regions:');
|
|
@@ -206,7 +211,13 @@ export function describe(def) {
|
|
|
206
211
|
const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
|
|
207
212
|
lines.push(` ${side}: ${drive(cdef)}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
|
|
208
213
|
}
|
|
209
|
-
(def.ports ?? []).forEach((p, k) =>
|
|
214
|
+
(def.ports ?? []).forEach((p, k) => {
|
|
215
|
+
const port = model.ports[k];
|
|
216
|
+
lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
|
|
217
|
+
// an electrode spread through the window
|
|
218
|
+
if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
|
|
219
|
+
for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}`);
|
|
220
|
+
});
|
|
210
221
|
if ((def.bulkReactions ?? []).length) {
|
|
211
222
|
lines.push('bulk reactions:');
|
|
212
223
|
for (const rx of def.bulkReactions) lines.push(` ${rx.equation ?? equation(rx.nu)}${rx.srh ? ' (SRH)' : ''} in ${Object.keys(rx.kf ?? rx.srh ?? {}).join(', ')}`);
|
package/src/device.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// defaults for physically meaningful choices: interface alignments, reference concentrations
|
|
7
7
|
// and standard potentials must all be given.
|
|
8
8
|
|
|
9
|
-
import { buildGrid } from './grid.js';
|
|
9
|
+
import { buildGrid, applyGeometry } from './grid.js';
|
|
10
10
|
import { FARADAY, GAS_CONSTANT } from './constants.js';
|
|
11
11
|
import { normalizeStatistics } from './statistics.js';
|
|
12
12
|
import { DeviceError } from './errors.js';
|
|
@@ -105,7 +105,7 @@ function nonNegative(v, path) {
|
|
|
105
105
|
*/
|
|
106
106
|
export function normalizeDevice(def) {
|
|
107
107
|
need(isObject(def), 'device definition must be an object');
|
|
108
|
-
fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid']);
|
|
108
|
+
fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid', 'geometry']);
|
|
109
109
|
|
|
110
110
|
const T = def.T === undefined ? 298.15 : positive(def.T, 'T');
|
|
111
111
|
const RT = GAS_CONSTANT * T;
|
|
@@ -171,8 +171,14 @@ export function normalizeDevice(def) {
|
|
|
171
171
|
materialIndex.set(mname, materials.length);
|
|
172
172
|
materials.push(m);
|
|
173
173
|
}
|
|
174
|
+
// (an electrode's carrier can live outside the device: a reacting port's terminal species)
|
|
175
|
+
const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
|
|
174
176
|
species.forEach((sp, i) => {
|
|
175
|
-
|
|
177
|
+
const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
|
|
178
|
+
need(
|
|
179
|
+
materials.some((m) => m.present[i]) || outside(sp),
|
|
180
|
+
`species '${sp.name}' is not present in any material` + (reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
|
|
181
|
+
);
|
|
176
182
|
});
|
|
177
183
|
|
|
178
184
|
// --- regions
|
|
@@ -280,7 +286,7 @@ export function normalizeDevice(def) {
|
|
|
280
286
|
|
|
281
287
|
// --- internal ports (outside phases attached over windows of interior nodes)
|
|
282
288
|
need(def.ports === undefined || Array.isArray(def.ports), 'ports must be an array');
|
|
283
|
-
const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex));
|
|
289
|
+
const ports = (def.ports ?? []).map((pdef, k) => normalizePort(pdef, `ports[${k}]`, regions, materials, species, speciesIndex, RT));
|
|
284
290
|
|
|
285
291
|
// Every electrostatically coupled cluster of regions needs an anchor, or its φ (and every
|
|
286
292
|
// charged level with it) floats: shifting φ by s and each η_i by z_i s changes nothing.
|
|
@@ -322,6 +328,17 @@ export function normalizeDevice(def) {
|
|
|
322
328
|
} catch (err) {
|
|
323
329
|
throw new DeviceError(`grid: ${err.message}`);
|
|
324
330
|
}
|
|
331
|
+
const geometry = normalizeGeometry(def.geometry);
|
|
332
|
+
if (geometry.type !== 'planar') {
|
|
333
|
+
regions.forEach((reg, r) => need(reg.velocity === 0, `regions[${r}].velocity: flow is for a planar device (a uniform velocity through a varying cross-section wouldn't conserve the liquid)`));
|
|
334
|
+
}
|
|
335
|
+
applyGeometry(grid, geometry);
|
|
336
|
+
// (zero only at an end, as at a sphere's centre: nothing passes there)
|
|
337
|
+
const inside = (x) => x > 0 && x < grid.length;
|
|
338
|
+
need(
|
|
339
|
+
grid.area.every((a, g) => a > 0 || g === 0 || g === grid.nNodes - 1) && (geometry.type !== 'profile' || geometry.values.every((a, k) => a > 0 || !inside(geometry.x[k]))),
|
|
340
|
+
'geometry: the cross-section vanishes inside the device; only an end may have A = 0',
|
|
341
|
+
);
|
|
325
342
|
|
|
326
343
|
// Port windows: the nodes of the port's region within [from, to] of its left end.
|
|
327
344
|
ports.forEach((port, k) => {
|
|
@@ -336,7 +353,7 @@ export function normalizeDevice(def) {
|
|
|
336
353
|
});
|
|
337
354
|
|
|
338
355
|
return {
|
|
339
|
-
T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports,
|
|
356
|
+
T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
|
|
340
357
|
};
|
|
341
358
|
}
|
|
342
359
|
|
|
@@ -555,6 +572,7 @@ export function normalizeDrives(def, model) {
|
|
|
555
572
|
for (const [k, side] of [[0, 'left'], [1, 'right']]) {
|
|
556
573
|
need(model.contacts[side].passes || drives[k].kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
|
|
557
574
|
}
|
|
575
|
+
model.ports.forEach((port, k) => need(port.passes || drives[2 + k].kind === 'V', `ports[${k}].I: this port passes no current (it exchanges only neutral species), so give it no drive`));
|
|
558
576
|
need(drives.some((d) => d.kind === 'V'), "every terminal is driven by a current, so the device's overall level floats: hold at least one at a voltage V");
|
|
559
577
|
return drives;
|
|
560
578
|
}
|
|
@@ -601,15 +619,37 @@ export function nextBreakpoint(src, t) {
|
|
|
601
619
|
return Infinity;
|
|
602
620
|
}
|
|
603
621
|
|
|
622
|
+
// The device's cross-section A(x): planar (the default, A = 1 m², so currents read as densities),
|
|
623
|
+
// spherical or cylindrical about a centre r0 to the left of x = 0 (r = r0 + x), or a profile
|
|
624
|
+
// { x, values } of A in m². Every flux, current and amount is then a total through A.
|
|
625
|
+
function normalizeGeometry(g) {
|
|
626
|
+
if (g === undefined || g === 'planar') return { type: 'planar' };
|
|
627
|
+
if (g === 'spherical' || g === 'cylindrical') g = { type: g };
|
|
628
|
+
need(isObject(g), "geometry must be 'planar', 'spherical', 'cylindrical', { type, r0 } or { area: { x, values } }");
|
|
629
|
+
if (g.area !== undefined) {
|
|
630
|
+
fields(g, 'geometry', ['area']);
|
|
631
|
+
const p = profile(g.area, 'geometry.area', 'cross-sections (m²)', (a) => a >= 0, 'a cross-section ≥ 0 (m²)');
|
|
632
|
+
need([...p.values].some((a) => a > 0), 'geometry.area: the cross-section is zero everywhere');
|
|
633
|
+
return { type: 'profile', x: p.x, values: p.values };
|
|
634
|
+
}
|
|
635
|
+
fields(g, 'geometry', ['type', 'r0']);
|
|
636
|
+
need(['planar', 'spherical', 'cylindrical'].includes(g.type), "geometry.type must be 'planar', 'spherical' or 'cylindrical'");
|
|
637
|
+
if (g.type === 'planar') return { type: 'planar' };
|
|
638
|
+
const r0 = g.r0 === undefined ? 0 : nonNegative(g.r0, 'geometry.r0 (m, the radius at x = 0)');
|
|
639
|
+
return { type: g.type, r0 };
|
|
640
|
+
}
|
|
641
|
+
|
|
604
642
|
// An internal port: an outside phase with known levels (V_i = V + offset_i, or μ for neutral
|
|
605
643
|
// species, as at a contact), exchanging with every node in a window of one region.
|
|
606
644
|
// Links per species: 'equilibrium' (μ̄ held at the outside level throughout the window),
|
|
607
645
|
// { type: 'conductance', G } for charged species (G in S/m³: a source G (V_out − V_i)/(zF) per
|
|
608
646
|
// volume), { type: 'exchange', k, mu } for neutral ones (k in mol/(m³·s): a source
|
|
609
|
-
// k (μ_out − μ)/RT per volume), or 'blocked' (the default).
|
|
610
|
-
|
|
647
|
+
// k (μ_out − μ)/RT per volume), or 'blocked' (the default). And reactions with the port's
|
|
648
|
+
// terminal species, a metal's carrier at the port's level: an electrode surface spread through
|
|
649
|
+
// the window, `area` (m²/m³) of it per volume.
|
|
650
|
+
function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
|
|
611
651
|
need(isObject(pdef), `${path} must be an object`);
|
|
612
|
-
fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species']);
|
|
652
|
+
fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area']);
|
|
613
653
|
let r;
|
|
614
654
|
if (Number.isInteger(pdef.region)) r = pdef.region;
|
|
615
655
|
else r = regions.findIndex((reg) => reg.name === pdef.region);
|
|
@@ -626,9 +666,10 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
|
|
|
626
666
|
need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
|
|
627
667
|
terminal = speciesIndex.get(pdef.terminal);
|
|
628
668
|
}
|
|
629
|
-
|
|
669
|
+
const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT);
|
|
670
|
+
need(pdef.species === undefined ? reactions.length > 0 : isObject(pdef.species), `${path}.species must map species names to port links`);
|
|
630
671
|
const links = species.map(() => ({ type: 'blocked' }));
|
|
631
|
-
for (const [sname, raw] of Object.entries(pdef.species)) {
|
|
672
|
+
for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
|
|
632
673
|
const lpath = `${path}.species.${sname}`;
|
|
633
674
|
need(speciesIndex.has(sname), `${lpath}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
634
675
|
const i = speciesIndex.get(sname);
|
|
@@ -656,8 +697,49 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex) {
|
|
|
656
697
|
if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
|
|
657
698
|
links[i] = { type: link.type, ...level };
|
|
658
699
|
}
|
|
659
|
-
need(links.some((l) => l.type !== 'blocked'), `${path}.species: the port exchanges no species`);
|
|
660
|
-
|
|
700
|
+
need(links.some((l) => l.type !== 'blocked') || reactions.length > 0, `${path}.species: the port exchanges no species`);
|
|
701
|
+
// One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
|
|
702
|
+
// nobody's business: it can't be driven by one.
|
|
703
|
+
const passes = reactions.length > 0 || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
|
|
704
|
+
need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
|
|
705
|
+
let area = null;
|
|
706
|
+
if (reactions.length > 0) {
|
|
707
|
+
need(pdef.area !== undefined, `${path}.area: give the electrode's area per volume of the window (m²/m³, e.g. 1/h for a film of thickness h on it), a number or a profile`);
|
|
708
|
+
area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
|
|
709
|
+
} else need(pdef.area === undefined, `${path}.area: only a port with reactions has an electrode area`);
|
|
710
|
+
return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
|
|
714
|
+
// port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
|
|
715
|
+
// electrode: part side 0 for the region's species, side 1 for the carrier.
|
|
716
|
+
function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT) {
|
|
717
|
+
need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
|
|
718
|
+
const list = pdef.reactions ?? [];
|
|
719
|
+
if (list.length === 0) return [];
|
|
720
|
+
need(!mat.conductor, `${path}.reactions: a port on a conductor is a wire; reactions belong on a face, or on a port in the solution`);
|
|
721
|
+
need(terminal !== null && species[terminal].z !== 0, `${path}.terminal: a port with reactions needs its electrode's carrier as its terminal species (e.g. 'e-')`);
|
|
722
|
+
need(!mat.present[terminal], `${path}.terminal: '${species[terminal].name}' is in the port's region too, so a reaction can't tell the electrode's from the region's`);
|
|
723
|
+
const carrier = species[terminal].name;
|
|
724
|
+
return list.map((rdef, k) => {
|
|
725
|
+
const rpath = `${path}.reactions[${k}]`;
|
|
726
|
+
need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
|
|
727
|
+
fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha']);
|
|
728
|
+
const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
|
|
729
|
+
const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier, conductor: true }, rdef.fixed, `${rpath}.equation`);
|
|
730
|
+
const part = [];
|
|
731
|
+
let fixedA = 0, charge = 0;
|
|
732
|
+
for (const [key, side] of [['left', 0], ['right', 1]]) {
|
|
733
|
+
const st = stoichiometry(sides[key], `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
|
|
734
|
+
for (const { i, nu } of st.list) part.push({ i, nu, side });
|
|
735
|
+
fixedA += st.fixedA;
|
|
736
|
+
charge += st.charge;
|
|
737
|
+
}
|
|
738
|
+
need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
|
|
739
|
+
need(part.some((p) => p.side === 0), `${rpath}: no species of the port's region takes part`);
|
|
740
|
+
need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
|
|
741
|
+
return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
|
|
742
|
+
});
|
|
661
743
|
}
|
|
662
744
|
|
|
663
745
|
function checkAnchors(regions, materials, interfaces, contacts, species, ports = []) {
|
|
@@ -680,7 +762,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
|
|
|
680
762
|
const anchored = new Set();
|
|
681
763
|
if (anchors(contacts.left)) anchored.add(find(0));
|
|
682
764
|
if (anchors(contacts.right)) anchored.add(find(nR - 1));
|
|
683
|
-
for (const port of ports) if (port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
|
|
765
|
+
for (const port of ports) if (port.reactions.length > 0 || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
|
|
684
766
|
for (let r = 0; r < nR; r++) {
|
|
685
767
|
const mat = materials[regions[r].material];
|
|
686
768
|
const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
|