driftlet 0.6.0 → 0.7.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 +9 -4
- package/docs/device.md +59 -7
- package/docs/kit.md +32 -2
- package/llms.txt +71 -13
- package/package.json +1 -1
- package/src/checks.js +49 -15
- package/src/describe.js +9 -2
- package/src/device.js +31 -4
- package/src/index.js +1 -1
- package/src/junction.js +164 -0
- package/src/kit.js +1 -0
- package/src/recorder.js +1 -1
- package/src/solver.js +60 -6
- package/types/index.d.ts +3 -3
- package/types/junction.d.ts +32 -0
- package/types/kit.d.ts +1 -0
- package/types/recorder.d.ts +3 -3
package/README.md
CHANGED
|
@@ -32,12 +32,12 @@ The same equations go by different names in different fields. If your problem is
|
|
|
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)
|
|
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 |
|
|
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 |
|
|
39
39
|
| Solid-state ionics | mixed ionic–electronic conduction, defect chemistry, mobile ions | [`statistics`](test/statistics.test.js), [`reactions`](test/reactions.test.js) tests | mass action from standard potentials |
|
|
40
|
-
| Biophysics |
|
|
40
|
+
| Biophysics, nanofluidics | charged nanochannels and pores, resting potentials, Goldman–Hodgkin–Katz, Nernst, Donnan, pumps and leaks | [charged-nanochannel](https://markblundeberg.github.io/driftlet/demos/channel.html), [resting-potential](https://markblundeberg.github.io/driftlet/demos/cell.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) (a patch pipette's) demos, [`pore`](test/pore.test.js) test; a membrane as a face: a capacitor with ion permeabilities, and the Na⁺/K⁺ pump as a reaction on it ([`membrane`](test/membrane.test.js) test) | GHK potential and current–voltage curve; a resolved lipid layer; Mullins–Noda; the pump's static head; Donnan |
|
|
41
41
|
|
|
42
42
|
The tests are worked setups, each with its analytic check, so they double as recipes. The
|
|
43
43
|
[validation table](#validation) lists them all.
|
|
@@ -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.7.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
|
|
|
@@ -149,7 +149,9 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
|
|
|
149
149
|
| [](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [](https://markblundeberg.github.io/driftlet/demos/double-layer.html) |
|
|
150
150
|
| **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 |
|
|
151
151
|
| [](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [](https://markblundeberg.github.io/driftlet/demos/organic.html) | [](https://markblundeberg.github.io/driftlet/demos/perovskite.html) |
|
|
152
|
-
| **Intercalation host**: cycling between cutoffs against the OCV |
|
|
152
|
+
| **Intercalation host**: cycling between cutoffs against the OCV | **Organic solar cell**: excitons diffusing to a donor/acceptor interface, against theory | **Perovskite hysteresis**: mobile ions and scan rate, against IonMonger |
|
|
153
|
+
| [](https://markblundeberg.github.io/driftlet/demos/channel.html) | [](https://markblundeberg.github.io/driftlet/demos/cell.html) | [](https://markblundeberg.github.io/driftlet/demos/junction.html) |
|
|
154
|
+
| **Charged nanochannel**: selectivity, overlapping Donnan layers and rectification, against Teorell–Meyer–Sievers | **Resting potential**: leaks and the Na⁺/K⁺ pump, against Mullins–Noda; the run-down to Donnan | **Liquid junctions**: a patch pipette's against Henderson, Planck and LJPcalc; a charged frit |
|
|
153
155
|
|
|
154
156
|
## How to think about it
|
|
155
157
|
|
|
@@ -241,6 +243,9 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
|
|
|
241
243
|
| 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 (as current where it's gentle, as a voltage offset near V_oc, where it falls at up to 1100 mA/cm² per volt) | 1.5e-3; 0.05 mW/cm²; 2 mV; 0.03 mA/cm² and 1 mV (0.010 and 0.31 mV found) |
|
|
242
244
|
| Illuminated long pn diode | `J_sc = qG(L_n + L_p + W)` from a cold start; superposition at low injection | 5e-3; 1e-2 |
|
|
243
245
|
| Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
|
|
246
|
+
| Charged pore or nanochannel (resolved Donnan layers at each mouth) | conductance against salt, down to its plateau, as Teorell–Meyer–Sievers in a long pore (short ones short by end layers ∝ 1/L); the zero-current voltage across a salt gradient; a symmetric pore's I–V odd, rectification with charge on one side or bipolar | 1%; 5e-3 to 1e-4; 1e-9 |
|
|
247
|
+
| Cell membrane (a capacitive face with permeabilities) | GHK resting potential (neutral or resolved solutions) and current–voltage curve; the same as a resolved 5 nm lipid layer; charging at I/C; with the Na⁺/K⁺ pump in a closed cell, Mullins–Noda, the static head 3Δμ̄_Na − 2Δμ̄_K = ΔG_ATP, and Donnan with the pump off | 1e-5 V; 3e-3; 2e-5 V; 1%; 1e-9 |
|
|
248
|
+
| Liquid junctions in mixtures | Henderson's formula against JPCalc's published values; driftlet's steady junction against Planck by shooting and the stationary Nernst–Planck codes LJPcalc and JLJP, a patch pipette's K-gluconate among them; a free-diffusion junction constant in time, between the two | 0.015 mV; 2e-4 and 0.02 mV; 5e-3 mV |
|
|
244
249
|
| Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
|
|
245
250
|
| Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
|
|
246
251
|
| Concentration polarization | `i = i_lim tanh(V/4V_T)`, incl. galvanostatic and load modes | 2e-4 |
|
package/docs/device.md
CHANGED
|
@@ -98,7 +98,8 @@ regions: [
|
|
|
98
98
|
- `fixedCharge`: immobile charge density (doping, ionomer), C/m³. Default 0. In an insertion
|
|
99
99
|
host it's balanced by background electronic carriers.
|
|
100
100
|
- `c0`: initial concentrations, mol/m³: the starting state of each species given. Species
|
|
101
|
-
connected to a contact don't need it (without it, they start from that contact's level
|
|
101
|
+
connected to a contact don't need it (without it, they start from that contact's level: the
|
|
102
|
+
left contact's if the species reaches it, else the right's, else a port's). Any
|
|
102
103
|
other species (blocked everywhere, or only made and consumed by
|
|
103
104
|
reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
|
|
104
105
|
logarithmic in them); to have none of a species in a region, leave it out of that region's
|
|
@@ -169,7 +170,8 @@ defaults to no dipole.
|
|
|
169
170
|
|
|
170
171
|
**Species laws** `species` (default: local equilibrium, $`\bar\mu`$ continuous, where the species is
|
|
171
172
|
present on both sides; blocked otherwise): `'equilibrium'`, `'blocked'`, or
|
|
172
|
-
`{ type: 'conductance', G }` ($`J = G \cdot (V_L - V_R)`$, G in S/m², charged species)
|
|
173
|
+
`{ type: 'conductance', G }` ($`J = G \cdot (V_L - V_R)`$, G in S/m², charged species), or
|
|
174
|
+
`{ type: 'permeability', P }` (P in m/s; see [membranes](#membranes)). A species that
|
|
173
175
|
takes part in a reaction at the face and exists on both sides has no default: give its link
|
|
174
176
|
(`'blocked'` if it crosses only through the reaction), since free crossing alongside would
|
|
175
177
|
short-circuit the kinetics.
|
|
@@ -226,6 +228,54 @@ lies between the two sides (a band offset, a $`\phi`$ jump). Where one carrier i
|
|
|
226
228
|
rate saturates at the other's capture, $`r \to v_p\,p`$: holes reaching an electron-rich layer
|
|
227
229
|
recombine at $`v_p`$ however many electrons wait there, which plain mass action can't do.
|
|
228
230
|
|
|
231
|
+
**Saturating kinetics** (a transporter, a pump, an enzyme: something that turns over at most so
|
|
232
|
+
fast) replaces `k0` and `alpha` with `vmax` (mol/(m²·s)) and `K`, a half-saturation
|
|
233
|
+
concentration (mol/m³) for every species the forward reaction consumes:
|
|
234
|
+
|
|
235
|
+
```math
|
|
236
|
+
r = v_{\max} \prod_{\nu<0} \left(\frac{c}{c + K}\right)^{|\nu|} \left(1 - e^{-A/RT}\right)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Michaelis–Menten in each substrate, it runs at $`v_{\max}`$ when they're plentiful and the driving
|
|
240
|
+
force is large, and it's still exactly zero at $`A = 0`$, so a pump stalls where the free energy it
|
|
241
|
+
spends is used up: its static head. The backward rate is the one detailed balance implies, and
|
|
242
|
+
isn't itself saturating, so this is for reactions that run forward.
|
|
243
|
+
|
|
244
|
+
### Membranes
|
|
245
|
+
|
|
246
|
+
A membrane thin against everything else (a lipid bilayer, 4–5 nm) is a face. Give it a
|
|
247
|
+
capacitive $`\phi`$ law, its capacitance (about 0.01 F/m², 1 µF/cm²), and a species law
|
|
248
|
+
`{ type: 'permeability', P }` for each ion that crosses it, through channels or carriers. The
|
|
249
|
+
flux is electrodiffusion across the membrane in a constant field (Goldman–Hodgkin–Katz),
|
|
250
|
+
|
|
251
|
+
```math
|
|
252
|
+
N = P\,\frac{z u\,(c_L - c_R\,e^{z u})}{e^{z u} - 1}, \qquad u = \frac{\phi_R - \phi_L}{V_T}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
toward +x, with $`c_L`$ and $`c_R`$ at the face's two edge nodes and $`\phi_R - \phi_L`$ the capacitor's
|
|
256
|
+
voltage. (It's Scharfetter–Gummel across the face, with $`D/h \to P`$; where the two sides are
|
|
257
|
+
different materials, the step in standard level between them counts with $`z\Delta\phi`$, so the flux
|
|
258
|
+
is still exactly zero where $`\bar\mu`$ is level.) The solutions on either side can be strictly
|
|
259
|
+
neutral ($`\varepsilon = 0`$), the membrane's charge then held in each edge node's box, or resolve
|
|
260
|
+
their diffuse layers ($`\varepsilon > 0`$); either way the membrane potential comes out the same, to
|
|
261
|
+
a few microvolts. Pumps and other
|
|
262
|
+
transporters go on the same face as reactions with saturating kinetics, for example the
|
|
263
|
+
Na⁺/K⁺-ATPase between the outside (left) and the cytoplasm (right):
|
|
264
|
+
|
|
265
|
+
```js nocheck
|
|
266
|
+
{
|
|
267
|
+
phi: { type: 'capacitive', C: 0.01 },
|
|
268
|
+
species: { 'K+': { type: 'permeability', P: 1e-8 }, 'Na+': { type: 'permeability', P: 4e-10 }, 'Cl-': { type: 'permeability', P: 4.5e-9 }, 'A-': 'blocked' },
|
|
269
|
+
reactions: [{ equation: '3 Na+(right) + 2 K+(left) + ATP = 3 Na+(left) + 2 K+(right) + ADP', fixed: { ATP: 50e3, ADP: 0 }, vmax: 5e-7, K: { 'Na+': 10, 'K+': 1.5 } }],
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`fixed` gives ATP's hydrolysis free energy (here 50 kJ/mol, with ADP and phosphate taken
|
|
274
|
+
together). Membrane potentials are $`\phi`$ differences between the two solutions, $`\phi_{\mathrm{in}} - \phi_{\mathrm{out}}`$,
|
|
275
|
+
read from `sol.phi` (or a `'phi'` probe in a transient). A bath contact's terminal voltage is
|
|
276
|
+
its reference species' level instead, what an electrode reversible to that species would read,
|
|
277
|
+
so between two different solutions it differs from $`\Delta\phi`$ by that species' Nernst term.
|
|
278
|
+
|
|
229
279
|
### Faces next to a conductor
|
|
230
280
|
|
|
231
281
|
A conductor has no $`\phi`$, so a face beside it can't take a `dipole` or `step` alignment. Its $`\phi`$
|
|
@@ -500,11 +550,13 @@ const now = dev.solution(); // snapshot of the current state
|
|
|
500
550
|
`dt0` (first step), `dtMax`, `budgetMs` (return after this much wall time, with
|
|
501
551
|
`done: false`), `maxSteps`, `method`. The step size carries over between calls, so an
|
|
502
552
|
animation can call `advance(tNext, { budgetMs })` once per frame. The solution adds `done`,
|
|
503
|
-
`rejected`, and a `trace` of terminal current and voltage after every accepted step
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
553
|
+
`rejected`, and a `trace` of terminal current and voltage after every accepted step (the
|
|
554
|
+
voltage is the right contact's minus the left's: what a meter between the terminals reads,
|
|
555
|
+
which at a bath is its reference species' level, not $`\phi`$). With
|
|
556
|
+
`probes: [{ x, species, quantity, region }]` the trace also reads inside the device,
|
|
557
|
+
as `trace.probes[k]` beside `trace.t`: a species' concentration (`quantity: 'c'`, the default,
|
|
558
|
+
mol/m³) or species voltage (`'V'`), or $`\phi`$ itself (`'phi'`, no species), linearly between the
|
|
559
|
+
nodes around `x` (at an interface, `region` picks the side). That's what a detector at `x` sees. Probes read at accepted steps, which grow
|
|
508
560
|
as a transient slows, so give `dtMax` to resolve a signal in time.
|
|
509
561
|
- `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
|
|
510
562
|
it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
|
package/docs/kit.md
CHANGED
|
@@ -169,6 +169,34 @@ const seen = run.trace.probes[0], k = seen.indexOf(Math.max(...seen));
|
|
|
169
169
|
console.log(`the pulse passes at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ≈ ${units.toPerCm3(seen[k]).toExponential(1)} cm⁻³`);
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
+
## Liquid junctions
|
|
173
|
+
|
|
174
|
+
`henderson(left, right, ions, { T })` and `planck(left, right, ions, { T })` give the junction
|
|
175
|
+
potential $`\phi_R - \phi_L`$ (V) between two neutral solutions (mol/m³ by ion), with `ions` their
|
|
176
|
+
`{ z, D }` (the data library's `IONS` has that shape):
|
|
177
|
+
|
|
178
|
+
- **Henderson's** formula takes the junction to be the two solutions mixed in one proportion at
|
|
179
|
+
every point (so every ion's mixing fraction follows the same curve). It's
|
|
180
|
+
a closed form, exact for one salt, and what JPCalc and most electrophysiology corrections use.
|
|
181
|
+
- **Planck's** is the steady state of diffusion through a zone held between the two solutions,
|
|
182
|
+
electroneutral and carrying no current, solved by shooting on the Nernst–Planck equations. It's
|
|
183
|
+
exact for that junction, and equal to driftlet's own steady state between two baths.
|
|
184
|
+
|
|
185
|
+
A junction growing freely from first contact is neither: simulate it, as a transient from a
|
|
186
|
+
`c0` step. For one salt all three agree; for mixtures they differ by up to a millivolt or so.
|
|
187
|
+
Validated against JPCalc (Henderson), and LJPcalc and JLJP (stationary Nernst–Planck), in
|
|
188
|
+
test/junction.test.js.
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
import { henderson, planck, IONS } from 'driftlet/kit';
|
|
192
|
+
|
|
193
|
+
// 50 mM NaCl against 50 mM KCl, and a K-gluconate pipette (gluconate's D from its relative mobility, 0.33 of K⁺'s).
|
|
194
|
+
const ions = { ...IONS, 'gluconate-': { z: -1, D: 0.33 * IONS['K+'].D } };
|
|
195
|
+
console.log(1000 * henderson({ 'Na+': 50, 'Cl-': 50 }, { 'K+': 50, 'Cl-': 50 }, ions), 'mV');
|
|
196
|
+
const pipette = { 'K+': 140, 'gluconate-': 130, 'Cl-': 10 }, bath = { 'Na+': 145, 'K+': 5, 'Cl-': 150 };
|
|
197
|
+
console.log(`Henderson ${(1000 * henderson(pipette, bath, ions)).toFixed(2)} mV, Planck ${(1000 * planck(pipette, bath, ions)).toFixed(2)} mV`);
|
|
198
|
+
```
|
|
199
|
+
|
|
172
200
|
## Recording a transient
|
|
173
201
|
|
|
174
202
|
`recorder(device, { every, times, probes })` runs a transient in pieces and keeps what a demo
|
|
@@ -339,7 +367,9 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
|
|
|
339
367
|
prompt to look, and don't fail the check.
|
|
340
368
|
- **balance**, in a steady state: each species' ledger, what comes in through each terminal and
|
|
341
369
|
what each reaction (bulk, or at a face) makes or uses, in mol/(m²·s) and for a charged species
|
|
342
|
-
as a current. The terms must sum to zero.
|
|
370
|
+
as a current. The terms must sum to zero. A species that a face lets through only by a law (a
|
|
371
|
+
membrane's permeability, a conductance) or by a reaction has a ledger on each side, with what
|
|
372
|
+
crosses as a term: in a cell, Na⁺ leaks into the cytoplasm as fast as the pump puts it out. Under light it's J = F(G − R), and it says where
|
|
343
373
|
every carrier went: collected, or recombined in the bulk or at which face. A species at rest
|
|
344
374
|
(in equilibrium, flows only of round-off against what it could carry) isn't listed.
|
|
345
375
|
- **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
|
|
@@ -373,7 +403,7 @@ const dev = new Device(
|
|
|
373
403
|
);
|
|
374
404
|
const report = check(dev, dev.solve());
|
|
375
405
|
console.log(report.text);
|
|
376
|
-
// ok balance: 2
|
|
406
|
+
// ok balance: 2 ledgers sum to zero …
|
|
377
407
|
// e-: photon = e- + h+: +2.99e-3 (289 A/m²); left contact: -2.23e-3 (-215 A/m²); …
|
|
378
408
|
// FAIL grid: on a grid twice as fine (172 → 339 nodes), the largest change is region 0 (Si)
|
|
379
409
|
// charge, … (0.021, over 0.01: refine the grid …): the current is fine (1e-4), but the
|
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.7.0/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.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), `recorder` (a transient's trace and frames across calls), the curated
|
|
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
|
|
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-`).
|
|
@@ -160,6 +160,57 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
|
|
|
160
160
|
console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
## Template: a cell membrane
|
|
164
|
+
|
|
165
|
+
A lipid bilayer is a face: a capacitor (about 1 µF/cm², 0.01 F/m²) that ions cross through
|
|
166
|
+
channels, each with a permeability (`{ type: 'permeability', P }`, m/s: electrodiffusion in a
|
|
167
|
+
constant field, so Goldman–Hodgkin–Katz comes out of it), and that pumps cross as reactions with
|
|
168
|
+
saturating kinetics (`vmax`, `K`). The solutions on either side can be strictly neutral. The
|
|
169
|
+
membrane potential is φ_in − φ_out from `sol.phi` (a bath's terminal voltage reads its reference
|
|
170
|
+
species' level instead, which differs from Δφ by that ion's Nernst term).
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
|
|
174
|
+
import { build, layer, bath, aqueous, check } from 'driftlet/kit';
|
|
175
|
+
|
|
176
|
+
const T = 310.15, VT = (GAS_CONSTANT * T) / FARADAY;
|
|
177
|
+
const out = { 'Na+': 145, 'K+': 5, 'Cl-': 110, 'A-': 40 }; // mM = mol/m³; A⁻: anions that don't cross
|
|
178
|
+
const inside = { 'Na+': 12, 'K+': 140, 'Cl-': 10, 'A-': 142 };
|
|
179
|
+
const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 }); // strictly neutral solutions
|
|
180
|
+
water.species.push({ name: 'A-', z: -1, cRef: 1000 });
|
|
181
|
+
water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
|
|
182
|
+
const P = { 'K+': 1e-8, 'Na+': 0.04e-8, 'Cl-': 0.45e-8 }; // m/s, Hodgkin and Katz's ratios
|
|
183
|
+
const membrane = {
|
|
184
|
+
phi: { type: 'capacitive', C: 0.01 },
|
|
185
|
+
species: { 'K+': { type: 'permeability', P: P['K+'] }, 'Na+': { type: 'permeability', P: P['Na+'] }, 'Cl-': { type: 'permeability', P: P['Cl-'] }, 'A-': 'blocked' },
|
|
186
|
+
// The Na⁺/K⁺ pump (outside on the left): 3 Na⁺ out, 2 K⁺ in, per ATP (50 kJ/mol); vmax ≈ 3000
|
|
187
|
+
// pumps per µm² at 100 cycles/s.
|
|
188
|
+
reactions: [{ equation: '3 Na+(right) + 2 K+(left) + ATP = 3 Na+(left) + 2 K+(right) + ADP', fixed: { ATP: 50e3, ADP: 0 }, vmax: 5e-7, K: { 'Na+': 10, 'K+': 1.5 } }],
|
|
189
|
+
};
|
|
190
|
+
const cell = build({
|
|
191
|
+
T,
|
|
192
|
+
library: [water],
|
|
193
|
+
stack: [
|
|
194
|
+
bath(out, 'Cl-'), // the extracellular fluid, held
|
|
195
|
+
layer('water', 1e-6, { name: 'outside', c0: out }),
|
|
196
|
+
membrane,
|
|
197
|
+
layer('water', 5e-6, { name: 'cytoplasm', c0: inside }), // a closed cell: its contents set by leaks and the pump
|
|
198
|
+
{ species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
|
|
199
|
+
],
|
|
200
|
+
grid: { hmin: 50e-9, hmax: 200e-9 },
|
|
201
|
+
});
|
|
202
|
+
const dev = new Device(cell), sol = dev.solve();
|
|
203
|
+
const g = sol.x.length - 1, ci = (s) => sol.c[s][g], Vm = sol.phi[g] - sol.phi[0];
|
|
204
|
+
// Mullins–Noda: in steady state the pump's 3:2 sets the ratio of the Na⁺ and K⁺ leaks.
|
|
205
|
+
const mn = VT * Math.log((1.5 * P['K+'] * out['K+'] + P['Na+'] * out['Na+']) / (1.5 * P['K+'] * ci('K+') + P['Na+'] * ci('Na+')));
|
|
206
|
+
console.log(`V_m = ${(Vm * 1e3).toFixed(2)} mV (Mullins–Noda ${(mn * 1e3).toFixed(2)}), inside Na⁺ ${ci('Na+').toFixed(1)} K⁺ ${ci('K+').toFixed(1)} mM`);
|
|
207
|
+
console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cytoplasm as fast as it's pumped out
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
|
|
211
|
+
current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium. driftlet
|
|
212
|
+
has no voltage-gated channels; a permeability can be changed with `set()` between steps.
|
|
213
|
+
|
|
163
214
|
## Template: an electrode in a stirred solution
|
|
164
215
|
|
|
165
216
|
One working electrode, a stagnant layer of solution, and the well-stirred bulk as a bath. The
|
|
@@ -233,8 +284,9 @@ console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPer
|
|
|
233
284
|
To start from a state instead of simulating how it got there (a packet already injected, a
|
|
234
285
|
salt gradient laid down), give a region's `c0` a profile against the device's x:
|
|
235
286
|
`c0: { 'h+': { x: [...], values: [...] } }`, piecewise linear. A given `c0` is the starting state,
|
|
236
|
-
even for a species a contact feeds; species without one start at their contact's level
|
|
237
|
-
|
|
287
|
+
even for a species a contact feeds; species without one start at their contact's level (the
|
|
288
|
+
left's if they reach it, else the right's), with φ made neutral node by node (electrons follow
|
|
289
|
+
the holes). Between two different solutions, give every region its `c0`. Then `advance()` without `solve()`, which would go straight to the steady
|
|
238
290
|
state.
|
|
239
291
|
|
|
240
292
|
Transients cost more where a carrier's density spans many decades (minority holes in silicon
|
|
@@ -255,7 +307,9 @@ The definition is plain data: print it, edit what your demo varies.
|
|
|
255
307
|
|
|
256
308
|
`live()` merges slider moves made during a solve, warm-starts each solve and keeps the last
|
|
257
309
|
good solution. `bandDiagram()` returns an SVG string. Pass `worker: true` to `live()` to solve in
|
|
258
|
-
a Web Worker.
|
|
310
|
+
a Web Worker (it works with the library loaded from a CDN: a same-origin stub imports it). Calling
|
|
311
|
+
`set()` from `onsolution` is fine, for a quantity the page solves for itself (it queues the next
|
|
312
|
+
solve), as long as it settles rather than ping-pongs.
|
|
259
313
|
|
|
260
314
|
```html
|
|
261
315
|
<!doctype html>
|
|
@@ -265,9 +319,9 @@ a Web Worker.
|
|
|
265
319
|
<span id="readout"></span>
|
|
266
320
|
<div id="figure"></div>
|
|
267
321
|
<script type="module">
|
|
268
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
269
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
270
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
322
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/index.js';
|
|
323
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/kit.js';
|
|
324
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.7.0/src/plot.js';
|
|
271
325
|
|
|
272
326
|
const def = build({
|
|
273
327
|
T: 300,
|
|
@@ -384,11 +438,15 @@ has the long form):
|
|
|
384
438
|
- **A steady solve of a closed cell where every ion can cross.** Its steady state is complete
|
|
385
439
|
mixing. To watch a junction or a salt bridge, step in time from the unmixed `c0` (`advance`).
|
|
386
440
|
- **Everything floating.** At least one terminal must be held at a voltage `V`.
|
|
387
|
-
- **The library's
|
|
441
|
+
- **The library's data are for 25 °C** (its semiconductors for 300 K: give `T: 300`). At body
|
|
442
|
+
temperature, `T: 310.15` with the same μ° and D is a fair start; D rises about 2% per kelvin
|
|
443
|
+
in water.
|
|
388
444
|
- **Sign of the current.** `sol.current` is positive toward +x: a forward-biased diode with its
|
|
389
445
|
p side on the right carries negative current. A terminal's current is into the device.
|
|
390
|
-
- **
|
|
391
|
-
|
|
446
|
+
- **A membrane potential read from the terminals.** Physiology's V_m is φ_inside − φ_outside:
|
|
447
|
+
read it from `sol.phi` (or a `{ x, quantity: 'phi' }` probe in a transient), with the inside on
|
|
448
|
+
the right. `sol.terminalVoltage` (and a trace's `voltage`) is V_right − V_left of the contacts,
|
|
449
|
+
which at baths are their reference species' levels: it differs from Δφ by that ion's Nernst term.
|
|
392
450
|
- **A coefficient without its space.** `'2e- + …'` reads `2e-` as a species name; the error says
|
|
393
451
|
to write `2 e-`.
|
|
394
452
|
- **Changing c0 with `set()`.** A carried state keeps what it holds; `c0` is only the starting
|
package/package.json
CHANGED
package/src/checks.js
CHANGED
|
@@ -78,33 +78,67 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
|
|
|
78
78
|
// Each species' steady ledger: in through the terminals, made by the reactions. Rates in
|
|
79
79
|
// mol/(m²·s), positive into the device or made.
|
|
80
80
|
function balance(device, sol) {
|
|
81
|
-
const { model, def } = device, { species } = model;
|
|
81
|
+
const { model, def } = device, { species, regions, interfaces } = model;
|
|
82
82
|
const label = (rdef) => rdef?.equation ?? JSON.stringify(rdef?.nu ?? rdef);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
// Each species' compartments: runs of regions it's present in, joined by faces it crosses
|
|
84
|
+
// freely (in equilibrium). A face it crosses only by a law (a permeability, a conductance) or
|
|
85
|
+
// by a reaction divides them, and what crosses there is a term in each side's ledger.
|
|
86
|
+
const ledgers = [], home = species.map(() => new Int32Array(regions.length).fill(-1));
|
|
87
|
+
species.forEach((sp, i) => {
|
|
88
|
+
let open = null;
|
|
89
|
+
regions.forEach((reg, r) => {
|
|
90
|
+
if (!model.materials[reg.material].present[i]) return void (open = null);
|
|
91
|
+
if (open && interfaces[r - 1].links[i].type === 'equilibrium') open.regions.push(r);
|
|
92
|
+
else ledgers.push((open = { species: sp.name, z: sp.z, i, regions: [r], terms: [] }));
|
|
93
|
+
home[i][r] = ledgers.length - 1;
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const add = (i, r, what, rate) => {
|
|
97
|
+
const l = home[i][r];
|
|
98
|
+
if (!(l >= 0 && rate !== 0 && Number.isFinite(rate))) return;
|
|
99
|
+
const same = ledgers[l].terms.find((t) => t.what === what); // a bulk reaction, region by region
|
|
100
|
+
if (same) same.rate += rate;
|
|
101
|
+
else ledgers[l].terms.push({ what, rate });
|
|
86
102
|
};
|
|
103
|
+
const last = regions.length - 1;
|
|
87
104
|
species.forEach((sp, i) => {
|
|
88
|
-
add(i, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
89
|
-
add(i, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
90
|
-
|
|
105
|
+
add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
|
|
106
|
+
add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
|
|
107
|
+
model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
|
|
91
108
|
});
|
|
92
109
|
model.reactions.forEach((rx, k) => {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
const what = label(def.bulkReactions?.[k]);
|
|
111
|
+
sol.bulkReactions[k].regions.forEach((total, r) => {
|
|
112
|
+
for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total);
|
|
113
|
+
for (const { i, nu } of rx.products) add(i, r, what, nu * total);
|
|
114
|
+
});
|
|
96
115
|
});
|
|
97
|
-
|
|
116
|
+
interfaces.forEach((itf, f) => {
|
|
117
|
+
// Across the face, where it divides a species' compartments.
|
|
118
|
+
species.forEach((sp, i) => {
|
|
119
|
+
const type = itf.links[i].type;
|
|
120
|
+
if (type === 'equilibrium' || type === 'blocked') return;
|
|
121
|
+
const N = sol.interfaces[f].N[sp.name];
|
|
122
|
+
add(i, f, `across face ${f} (${type})`, -N);
|
|
123
|
+
add(i, f + 1, `across face ${f} (${type})`, N);
|
|
124
|
+
});
|
|
98
125
|
itf.reactions.forEach((rx, k) => {
|
|
99
126
|
const rate = sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
|
|
100
|
-
for (const { i, nu } of rx.part) add(i, what, nu * rate);
|
|
127
|
+
for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
|
|
101
128
|
});
|
|
102
129
|
});
|
|
130
|
+
// A species in one compartment is named alone; one in several, by where.
|
|
131
|
+
const several = species.map((_, i) => ledgers.filter((l) => l.i === i).length > 1);
|
|
132
|
+
for (const l of ledgers) {
|
|
133
|
+
const names = l.regions.map((r) => sol.regions[r].name);
|
|
134
|
+
l.compartment = names.length === 1 ? names[0] : `${names[0]} to ${names.at(-1)}`;
|
|
135
|
+
l.title = several[l.i] ? `${l.species} in ${l.compartment}` : l.species;
|
|
136
|
+
}
|
|
103
137
|
|
|
104
138
|
// A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
|
|
105
139
|
const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
|
|
106
140
|
const natural = naturalScales(device, sol);
|
|
107
|
-
const moving = ledgers.filter((l
|
|
141
|
+
const moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
|
|
108
142
|
let worst = 0;
|
|
109
143
|
const lines = [];
|
|
110
144
|
for (const l of moving) {
|
|
@@ -114,13 +148,13 @@ function balance(device, sol) {
|
|
|
114
148
|
// Terms that matter, largest first; a charged species' as a current too.
|
|
115
149
|
const shown = l.terms.filter((t) => Math.abs(t.rate) > 1e-6 * size(l)).sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate));
|
|
116
150
|
const amp = (r) => (l.z !== 0 ? ` (${sig(FARADAY * Math.abs(l.z) * r)} A/m²)` : '');
|
|
117
|
-
lines.push(`${l.
|
|
151
|
+
lines.push(`${l.title}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
|
|
118
152
|
}
|
|
119
153
|
const ok = worst < 1e-6;
|
|
120
154
|
const summary =
|
|
121
155
|
moving.length === 0
|
|
122
156
|
? 'nothing flows or reacts (equilibrium)'
|
|
123
|
-
: `${moving.length
|
|
157
|
+
: `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, mol/(m²·s), + in or made`;
|
|
124
158
|
// The largest current any charged species carries anywhere: the scale a net current is read against.
|
|
125
159
|
const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
|
|
126
160
|
return { name: 'balance', ok, summary, details: { ledgers, worst, lines, gross } };
|
package/src/describe.js
CHANGED
|
@@ -186,8 +186,15 @@ export function describe(def) {
|
|
|
186
186
|
const law = itf.phi.type === 'capacitive' ? `capacitive, C = ${num(itf.phi.C)} F/m²` : itf.phi.type;
|
|
187
187
|
const align = itf.conductor ? (itf.phi.type === 'capacitive' ? `, zeroCharge ${num(itf.zeroCharge)} V` : '') : itf.phi.type === 'neutral' ? '' : `, dipole ${num(itf.dipole)} V`;
|
|
188
188
|
const blocked = species.filter((sp, i) => itf.links[i].type === 'blocked' && materials[L.material].present[i] && materials[R.material].present[i]).map((sp) => sp.name);
|
|
189
|
-
const
|
|
190
|
-
|
|
189
|
+
const kinetics = (r) => (r.vmax !== undefined ? ` (saturating, vmax ${num(r.vmax)} mol/(m²·s))` : r.srh ? ' (SRH)' : '');
|
|
190
|
+
const rx = (idef.reactions ?? []).map((r) => (r.equation ?? equation(r.left, r.right)) + kinetics(r));
|
|
191
|
+
const laws = species.flatMap((sp, i) => {
|
|
192
|
+
const l = itf.links[i];
|
|
193
|
+
return l.type === 'permeability' ? [`${sp.name} ${num(l.P)} m/s`] : l.type === 'conductance' ? [`${sp.name} ${num(l.G)} S/m²`] : [];
|
|
194
|
+
});
|
|
195
|
+
lines.push(
|
|
196
|
+
` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`,
|
|
197
|
+
);
|
|
191
198
|
});
|
|
192
199
|
}
|
|
193
200
|
|
package/src/device.js
CHANGED
|
@@ -16,7 +16,7 @@ export { DeviceError };
|
|
|
16
16
|
|
|
17
17
|
// Contacts use the same laws as internal faces: the outside is a phase with known levels.
|
|
18
18
|
const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange']);
|
|
19
|
-
const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance']);
|
|
19
|
+
const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
|
|
20
20
|
const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
|
|
21
21
|
const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
|
|
22
22
|
// The fields of each kind of species link (to an outside phase, or across a face).
|
|
@@ -433,12 +433,17 @@ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
|
|
|
433
433
|
// r = k0 Π_{ν<0} (c/c_ref)^{|ν|(1−α)} Π_{ν>0} (c/c_ref)^{να} (e^{αa} − e^{−(1−α)a})
|
|
434
434
|
// (a conductor's carrier has activity 1, so no factor). That's mass action with rate constants
|
|
435
435
|
// that depend on the electrical part of the affinity, and exactly zero at A = 0.
|
|
436
|
+
// Or saturating (a transporter or an enzyme that turns over at most vmax per area): with K a
|
|
437
|
+
// half-saturation concentration for each species the forward reaction consumes,
|
|
438
|
+
// r = vmax Π_{ν<0} (c/(c + K))^{|ν|} (1 − e^{−a}),
|
|
439
|
+
// Michaelis–Menten in each substrate, and still exactly zero at A = 0 (the backward rate is the
|
|
440
|
+
// one detailed balance implies).
|
|
436
441
|
function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT) {
|
|
437
442
|
need(idef.reactions === undefined || Array.isArray(idef.reactions), `${where}.reactions must be an array`);
|
|
438
443
|
return (idef.reactions ?? []).map((rdef, k) => {
|
|
439
444
|
const rpath = `${where}.reactions[${k}]`;
|
|
440
445
|
need(isObject(rdef), `${rpath} must be { equation, fixed, k0, alpha } or { left, right, fixed, k0, alpha }`);
|
|
441
|
-
fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh']);
|
|
446
|
+
fields(rdef, rpath, ['equation', 'left', 'right', 'fixed', 'k0', 'alpha', 'srh', 'vmax', 'K']);
|
|
442
447
|
let sides = rdef;
|
|
443
448
|
if (rdef.equation !== undefined) {
|
|
444
449
|
need(rdef.left === undefined && rdef.right === undefined, `${rpath}: give the reaction as an equation or as left and right, not both`);
|
|
@@ -458,6 +463,22 @@ function normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex,
|
|
|
458
463
|
}
|
|
459
464
|
need(part.length > 0, `${rpath}: no species participate`);
|
|
460
465
|
need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
|
|
466
|
+
if (rdef.vmax !== undefined || rdef.K !== undefined) {
|
|
467
|
+
need(rdef.k0 === undefined && rdef.alpha === undefined && rdef.srh === undefined, `${rpath}: give vmax and K (saturating), srh, or k0 and alpha (Butler–Volmer), one of them`);
|
|
468
|
+
const vmax = positive(rdef.vmax, `${rpath}.vmax (mol/(m²·s))`);
|
|
469
|
+
need(isObject(rdef.K), `${rpath}.K must map each species the forward reaction consumes to its half-saturation concentration (mol/m³)`);
|
|
470
|
+
for (const name of Object.keys(rdef.K)) {
|
|
471
|
+
need(part.some((p) => p.nu < 0 && species[p.i].name === name), `${rpath}.K.${name}: not a species the forward reaction consumes`);
|
|
472
|
+
positive(rdef.K[name], `${rpath}.K.${name} (mol/m³)`);
|
|
473
|
+
}
|
|
474
|
+
for (const p of part) {
|
|
475
|
+
if (p.nu >= 0) continue;
|
|
476
|
+
const mat = p.side ? matR : matL;
|
|
477
|
+
need(!mat.conductor, `${rpath}: saturating kinetics is for dissolved substrates, not a metal's ${species[p.i].name}`);
|
|
478
|
+
need(rdef.K[species[p.i].name] !== undefined, `${rpath}.K.${species[p.i].name}: give a half-saturation concentration for every species the forward reaction consumes`);
|
|
479
|
+
}
|
|
480
|
+
return { part, fixedA, vmax, K: part.map((p) => (p.nu < 0 ? rdef.K[species[p.i].name] : 0)) };
|
|
481
|
+
}
|
|
461
482
|
if (rdef.srh !== undefined) {
|
|
462
483
|
need(rdef.k0 === undefined && rdef.alpha === undefined, `${rpath}: give srh or k0 (and alpha), not both`);
|
|
463
484
|
const srh = srhLaw(rdef.srh, `${rpath}.srh`, part, species, ['vn', 'vp']);
|
|
@@ -741,7 +762,8 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
741
762
|
const sheetCharge = idef.sheetCharge === undefined ? 0 : finite(idef.sheetCharge, `${where}.sheetCharge`);
|
|
742
763
|
|
|
743
764
|
// Per-species laws across the face: local equilibrium (default where present on both sides),
|
|
744
|
-
// blocked,
|
|
765
|
+
// blocked, an ohmic interface conductance G (S/m²), or a permeability P (m/s): electrodiffusion
|
|
766
|
+
// through a thin membrane in a constant field, Goldman–Hodgkin–Katz.
|
|
745
767
|
const links = defaultInterfaceLinks(matL, matR, species);
|
|
746
768
|
if (idef.species !== undefined) {
|
|
747
769
|
need(isObject(idef.species), `${where}.species must map species names to interface links`);
|
|
@@ -751,12 +773,16 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
751
773
|
const i = speciesIndex.get(sname);
|
|
752
774
|
const link = typeof raw === 'string' ? { type: raw } : raw;
|
|
753
775
|
need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
|
|
754
|
-
fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
|
|
776
|
+
fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : link.type === 'permeability' ? ['type', 'P'] : ['type']);
|
|
755
777
|
if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
|
|
756
778
|
if (link.type === 'conductance') {
|
|
757
779
|
need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
|
|
758
780
|
positive(link.G, `${lpath}.G`);
|
|
759
781
|
}
|
|
782
|
+
if (link.type === 'permeability') {
|
|
783
|
+
positive(link.P, `${lpath}.P (m/s)`);
|
|
784
|
+
need(matL.modelOf[i] < 0 && matR.modelOf[i] < 0, `${lpath}: a permeability link needs ideal (dilute) statistics for '${sname}' on both sides`);
|
|
785
|
+
}
|
|
760
786
|
links[i] = { ...link };
|
|
761
787
|
}
|
|
762
788
|
}
|
|
@@ -847,6 +873,7 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
|
|
|
847
873
|
const i = speciesIndex.get(sname);
|
|
848
874
|
const link = typeof raw === 'string' ? { type: raw } : raw;
|
|
849
875
|
need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
|
|
876
|
+
need(link.type !== 'permeability', `${lpath}: a permeability link is for a membrane between two solutions, not at a conductor`);
|
|
850
877
|
fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : ['type']);
|
|
851
878
|
if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
|
|
852
879
|
if (link.type === 'conductance') {
|
package/src/index.js
CHANGED
|
@@ -160,7 +160,7 @@ export class Device {
|
|
|
160
160
|
* (one array per probe, alongside `trace.t`): what a detector at x sees.
|
|
161
161
|
* @param {number} tEnd s
|
|
162
162
|
* @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
|
|
163
|
-
* probes?: { x: number, species
|
|
163
|
+
* probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
|
|
164
164
|
* @returns {Solution}
|
|
165
165
|
*/
|
|
166
166
|
advance(tEnd, opts) {
|
package/src/junction.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Liquid-junction potentials from formulas, for driftlet/kit: the diffusion potential φ_R − φ_L
|
|
2
|
+
// between two neutral solutions, as the classical theories give it. A device simulates the actual
|
|
3
|
+
// junction (steady between two baths, it is Planck's; stepping from a sharp boundary, a free
|
|
4
|
+
// diffusion junction); these are for comparison, and for quick estimates.
|
|
5
|
+
|
|
6
|
+
import { DeviceError } from './errors.js';
|
|
7
|
+
import { FARADAY, GAS_CONSTANT } from './constants.js';
|
|
8
|
+
|
|
9
|
+
const fail = (message) => {
|
|
10
|
+
throw new DeviceError(message);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// The ions both solutions name, with their charge and diffusivity, and both concentrations.
|
|
14
|
+
function prepare(left, right, ions, what) {
|
|
15
|
+
const names = [...new Set([...Object.keys(left), ...Object.keys(right)])];
|
|
16
|
+
return names.map((name) => {
|
|
17
|
+
const ion = ions[name];
|
|
18
|
+
if (!ion || !Number.isFinite(ion.z) || !(ion.D > 0)) fail(`${what}: no { z, D } for '${name}' in the ions given`);
|
|
19
|
+
const cL = left[name] ?? 0, cR = right[name] ?? 0;
|
|
20
|
+
if (!(cL >= 0 && cR >= 0)) fail(`${what}: concentrations must be ≥ 0 (mol/m³), got ${name} ${cL} and ${cR}`);
|
|
21
|
+
return { name, z: ion.z, D: ion.D, cL, cR };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function neutral(list, side, what) {
|
|
25
|
+
let q = 0, s = 0;
|
|
26
|
+
for (const p of list) {
|
|
27
|
+
q += p.z * p[side];
|
|
28
|
+
s += Math.abs(p.z * p[side]);
|
|
29
|
+
}
|
|
30
|
+
if (Math.abs(q) > 1e-9 * s) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution carries a net charge (Σ z·c = ${q.toPrecision(3)} mol/m³)`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
|
|
35
|
+
* mixed in one proportion at every point (every ion's mixing fraction the same curve). Exact for a single binary salt, a close estimate otherwise (it's what
|
|
36
|
+
* JPCalc and most electrophysiology corrections use).
|
|
37
|
+
* @param {Record<string, number>} left mol/m³ by ion
|
|
38
|
+
* @param {Record<string, number>} right mol/m³ by ion
|
|
39
|
+
* @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
|
|
40
|
+
* @param {{ T?: number }} [opts] K (default 298.15)
|
|
41
|
+
*/
|
|
42
|
+
export function henderson(left, right, ions, { T = 298.15 } = {}) {
|
|
43
|
+
const list = prepare(left, right, ions, 'henderson');
|
|
44
|
+
neutral(list, 'cL', 'henderson');
|
|
45
|
+
neutral(list, 'cR', 'henderson');
|
|
46
|
+
let a = 0, b = 0, gL = 0, gR = 0;
|
|
47
|
+
for (const { z, D, cL, cR } of list) {
|
|
48
|
+
a += z * D * (cR - cL);
|
|
49
|
+
b += z * z * D * (cR - cL);
|
|
50
|
+
gL += z * z * D * cL;
|
|
51
|
+
gR += z * z * D * cR;
|
|
52
|
+
}
|
|
53
|
+
const VT = (GAS_CONSTANT * T) / FARADAY;
|
|
54
|
+
if (b === 0) return 0;
|
|
55
|
+
return (-VT * a * Math.log(gR / gL)) / b;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Planck's liquid-junction potential, φ_R − φ_L (V): the steady state of constrained diffusion,
|
|
60
|
+
* a junction zone held between the two solutions, electroneutral, carrying no current. Solved by
|
|
61
|
+
* shooting on the Nernst–Planck equations across it (its length drops out). Exact for that
|
|
62
|
+
* junction; equal to Henderson's for a single binary salt.
|
|
63
|
+
* @param {Record<string, number>} left mol/m³ by ion
|
|
64
|
+
* @param {Record<string, number>} right mol/m³ by ion
|
|
65
|
+
* @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
|
|
66
|
+
* @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
|
|
67
|
+
*/
|
|
68
|
+
export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
|
|
69
|
+
const list = prepare(left, right, ions, 'planck');
|
|
70
|
+
neutral(list, 'cL', 'planck');
|
|
71
|
+
neutral(list, 'cR', 'planck');
|
|
72
|
+
const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
|
|
73
|
+
const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
|
|
74
|
+
const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
|
|
75
|
+
// Across the zone (x from 0 to 1, D's scale dropping out with its length), each ion's flux J_i is
|
|
76
|
+
// constant: c_i' = −J_i/D_i − z_i c_i ψ', with ψ = Fφ/RT, and neutrality fixes the field,
|
|
77
|
+
// ψ' = −Σ(z_i J_i/D_i)/Σ(z_i² c_i). Integrated from the left; the fluxes are chosen so the
|
|
78
|
+
// right comes out as given and no current flows.
|
|
79
|
+
const deriv = (c, J, out) => {
|
|
80
|
+
let num = 0, den = 0;
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
num += (z[i] * J[i]) / D[i];
|
|
83
|
+
den += z[i] * z[i] * Math.max(c[i], 0);
|
|
84
|
+
}
|
|
85
|
+
const dpsi = -num / den;
|
|
86
|
+
for (let i = 0; i < n; i++) out[i] = -J[i] / D[i] - z[i] * c[i] * dpsi;
|
|
87
|
+
return dpsi;
|
|
88
|
+
};
|
|
89
|
+
const shoot = (J) => {
|
|
90
|
+
const c = cL.slice(), k1 = new Float64Array(n), k2 = new Float64Array(n), k3 = new Float64Array(n), k4 = new Float64Array(n), t = new Float64Array(n);
|
|
91
|
+
const h = 1 / steps;
|
|
92
|
+
let psi = 0;
|
|
93
|
+
for (let s = 0; s < steps; s++) {
|
|
94
|
+
const p1 = deriv(c, J, k1);
|
|
95
|
+
for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k1[i];
|
|
96
|
+
const p2 = deriv(t, J, k2);
|
|
97
|
+
for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k2[i];
|
|
98
|
+
const p3 = deriv(t, J, k3);
|
|
99
|
+
for (let i = 0; i < n; i++) t[i] = c[i] + h * k3[i];
|
|
100
|
+
const p4 = deriv(t, J, k4);
|
|
101
|
+
for (let i = 0; i < n; i++) c[i] += (h / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]);
|
|
102
|
+
psi += (h / 6) * (p1 + 2 * p2 + 2 * p3 + p4);
|
|
103
|
+
}
|
|
104
|
+
return { c, psi };
|
|
105
|
+
};
|
|
106
|
+
// Residuals: n − 1 ions reaching the right (the last follows by neutrality), and no current.
|
|
107
|
+
const residual = (J) => {
|
|
108
|
+
const { c, psi } = shoot(J), r = new Float64Array(n);
|
|
109
|
+
for (let i = 0; i < n - 1; i++) r[i] = c[i] - cR[i];
|
|
110
|
+
let I = 0;
|
|
111
|
+
for (let i = 0; i < n; i++) I += z[i] * J[i];
|
|
112
|
+
r[n - 1] = I / Dmax; // scaled like the concentrations
|
|
113
|
+
return { r, psi };
|
|
114
|
+
};
|
|
115
|
+
let J = list.map((_, i) => -D[i] * (cR[i] - cL[i])); // plain diffusion to start
|
|
116
|
+
let { r, psi } = residual(J);
|
|
117
|
+
for (let it = 0; it < 50; it++) {
|
|
118
|
+
const norm = Math.max(...r.map(Math.abs));
|
|
119
|
+
if (norm < 1e-12) break;
|
|
120
|
+
// Newton with a finite-difference Jacobian.
|
|
121
|
+
const Jac = [];
|
|
122
|
+
for (let k = 0; k < n; k++) {
|
|
123
|
+
const dJ = 1e-7 * Math.max(Math.abs(J[k]), 1e-3 * D[k]), Jp = J.slice();
|
|
124
|
+
Jp[k] += dJ;
|
|
125
|
+
const rp = residual(Jp).r;
|
|
126
|
+
Jac.push(Array.from(rp, (v, i) => (v - r[i]) / dJ));
|
|
127
|
+
}
|
|
128
|
+
const step = solveDense(Jac, r, n);
|
|
129
|
+
let lambda = 1, next;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const Jn = J.map((v, k) => v - lambda * step[k]);
|
|
132
|
+
next = residual(Jn);
|
|
133
|
+
if (Math.max(...next.r.map(Math.abs)) < norm || lambda < 1e-3) {
|
|
134
|
+
J = Jn;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
lambda /= 2;
|
|
138
|
+
}
|
|
139
|
+
({ r, psi } = next);
|
|
140
|
+
}
|
|
141
|
+
if (!(Math.max(...r.map(Math.abs)) < 1e-8)) fail('planck: the shooting did not converge');
|
|
142
|
+
return ((GAS_CONSTANT * T) / FARADAY) * psi;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Solves Σ_k A[k][i] x_k = b_i (A given column by column), by Gaussian elimination with pivoting.
|
|
146
|
+
function solveDense(cols, b, n) {
|
|
147
|
+
const M = Array.from({ length: n }, (_, i) => [...cols.map((col) => col[i]), b[i]]);
|
|
148
|
+
for (let p = 0; p < n; p++) {
|
|
149
|
+
let best = p;
|
|
150
|
+
for (let i = p + 1; i < n; i++) if (Math.abs(M[i][p]) > Math.abs(M[best][p])) best = i;
|
|
151
|
+
[M[p], M[best]] = [M[best], M[p]];
|
|
152
|
+
for (let i = p + 1; i < n; i++) {
|
|
153
|
+
const f = M[i][p] / M[p][p];
|
|
154
|
+
for (let j = p; j <= n; j++) M[i][j] -= f * M[p][j];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const x = new Array(n).fill(0);
|
|
158
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
159
|
+
let s = M[i][n];
|
|
160
|
+
for (let j = i + 1; j < n; j++) s -= M[i][j] * x[j];
|
|
161
|
+
x[i] = s / M[i][i];
|
|
162
|
+
}
|
|
163
|
+
return x;
|
|
164
|
+
}
|
package/src/kit.js
CHANGED
|
@@ -12,6 +12,7 @@ export { live } from './live.js';
|
|
|
12
12
|
export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
|
|
13
13
|
export { describe, unitWarnings } from './describe.js';
|
|
14
14
|
export { recorder } from './recorder.js';
|
|
15
|
+
export { henderson, planck } from './junction.js';
|
|
15
16
|
export { check } from './checks.js';
|
|
16
17
|
export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
|
|
17
18
|
export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
|
package/src/recorder.js
CHANGED
|
@@ -15,7 +15,7 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
|
|
|
15
15
|
* neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
|
|
16
16
|
* `probes` if given) runs from the start through every accepted step of every call.
|
|
17
17
|
* @param {import('./index.js').Device} device
|
|
18
|
-
* @param {{ every?: number, times?: number[], probes?: { x: number, species
|
|
18
|
+
* @param {{ every?: number, times?: number[], probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
|
|
19
19
|
*/
|
|
20
20
|
export function recorder(device, opts = {}) {
|
|
21
21
|
const { every, times, probes } = opts;
|
package/src/solver.js
CHANGED
|
@@ -995,6 +995,22 @@ export class Solver {
|
|
|
995
995
|
}
|
|
996
996
|
}
|
|
997
997
|
}
|
|
998
|
+
// A floating contact facing a held one starts where the start's own composition puts it: the
|
|
999
|
+
// held voltage plus the difference between the two terminal species' levels beside each
|
|
1000
|
+
// contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
|
|
1001
|
+
// that a transient's first reading means something.
|
|
1002
|
+
const level = (side) => {
|
|
1003
|
+
const ct = model.contacts[side], i = ct.terminal;
|
|
1004
|
+
if (i === null || ct.species[i].type !== 'equilibrium' || z[i] === 0) return NaN;
|
|
1005
|
+
const b = this.blockOfNode[side === 'left' ? 0 : grid.nNodes - 1];
|
|
1006
|
+
return (this.VT * u[b * M + 1 + i]) / z[i] - ct.species[i].offset;
|
|
1007
|
+
};
|
|
1008
|
+
for (const k of this.floating) {
|
|
1009
|
+
const t = this.terms[k], held = k === 0 ? 1 : 0;
|
|
1010
|
+
if (t.kind === 'port' || held >= this.terms.length || this.floating.includes(held)) continue;
|
|
1011
|
+
const shift = level(t.side) - level(this.terms[held].side);
|
|
1012
|
+
if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
|
|
1013
|
+
}
|
|
998
1014
|
this.computeConcentrations();
|
|
999
1015
|
}
|
|
1000
1016
|
|
|
@@ -1538,6 +1554,27 @@ export class Solver {
|
|
|
1538
1554
|
res[R[o]] = -deta; // μ̄ continuous
|
|
1539
1555
|
this._j(bf, r, bR, r, 1);
|
|
1540
1556
|
this._j(bf, r, bL, r, -1);
|
|
1557
|
+
} else if (type === 'permeability') {
|
|
1558
|
+
// Electrodiffusion through a thin membrane in a constant field: Scharfetter–Gummel across
|
|
1559
|
+
// the face with D/h → P, its drift taken over the face's whole jump in the standard level
|
|
1560
|
+
// zφ̂ + μ°/RT − ln c_ref (the φ jump of a capacitive face, and any step between the two
|
|
1561
|
+
// materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
|
|
1562
|
+
// N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
|
|
1563
|
+
// and it's exactly zero where μ̄ is level.
|
|
1564
|
+
const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
|
|
1565
|
+
const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
|
|
1566
|
+
const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
|
|
1567
|
+
const E = Math.expm1(-deta); // η_R − η_L
|
|
1568
|
+
const gBc = P * bernoulli(d) * this.c[kl];
|
|
1569
|
+
const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
|
|
1570
|
+
res[R[o]] = u[o] + gBc * E;
|
|
1571
|
+
this._j(bf, r, bf, r, 1);
|
|
1572
|
+
this._j(bf, r, bL, r, -gBc);
|
|
1573
|
+
this._j(bf, r, bR, r, gBc * (E + 1));
|
|
1574
|
+
if (zi !== 0) {
|
|
1575
|
+
this._j(bf, r, bL, 0, zi * dNdd - zi * gBc * E);
|
|
1576
|
+
this._j(bf, r, bR, 0, -zi * dNdd);
|
|
1577
|
+
}
|
|
1541
1578
|
} else {
|
|
1542
1579
|
// conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
|
|
1543
1580
|
const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
|
|
@@ -1780,12 +1817,28 @@ export class Solver {
|
|
|
1780
1817
|
const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
|
|
1781
1818
|
aHi -= p.nu * u[o];
|
|
1782
1819
|
aLo -= p.nu * uLo[o];
|
|
1783
|
-
if (rx.srh || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1820
|
+
if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
|
|
1784
1821
|
const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
|
|
1785
1822
|
pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
|
|
1786
1823
|
this._dlnc(g, p.i, e, p.side ? dR : dL);
|
|
1787
1824
|
}
|
|
1788
|
-
if (rx.
|
|
1825
|
+
if (rx.vmax) {
|
|
1826
|
+
// Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
|
|
1827
|
+
let S = 1;
|
|
1828
|
+
rx.part.forEach((p, q) => {
|
|
1829
|
+
if (p.nu >= 0) return;
|
|
1830
|
+
const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
|
|
1831
|
+
S *= powi(cc / (cc + K), -p.nu);
|
|
1832
|
+
this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
|
|
1833
|
+
});
|
|
1834
|
+
const a = aHi + aLo, back = Math.exp(-a);
|
|
1835
|
+
rate = rx.vmax * S * -Math.expm1(-a);
|
|
1836
|
+
for (let s = 0; s < M; s++) {
|
|
1837
|
+
dL[s] *= rate;
|
|
1838
|
+
dR[s] *= rate;
|
|
1839
|
+
}
|
|
1840
|
+
for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
|
|
1841
|
+
} else if (rx.srh) {
|
|
1789
1842
|
// SRH, with n and p each at its own side's edge node.
|
|
1790
1843
|
const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
|
|
1791
1844
|
const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
|
|
@@ -2447,10 +2500,10 @@ export class Solver {
|
|
|
2447
2500
|
if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
|
|
2448
2501
|
const plan = probes.map((p, k) => {
|
|
2449
2502
|
const path = `advance: probes[${k}]`;
|
|
2450
|
-
const
|
|
2451
|
-
if (
|
|
2452
|
-
const
|
|
2453
|
-
if (quantity !== '
|
|
2503
|
+
const quantity = p?.quantity ?? 'c';
|
|
2504
|
+
if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
|
|
2505
|
+
const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
|
|
2506
|
+
if (quantity !== 'phi' && i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
|
|
2454
2507
|
if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
|
|
2455
2508
|
const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
|
|
2456
2509
|
let r;
|
|
@@ -2470,6 +2523,7 @@ export class Solver {
|
|
|
2470
2523
|
const out = plan.map(() => []);
|
|
2471
2524
|
const { n, M } = this;
|
|
2472
2525
|
const at = (q, g) => {
|
|
2526
|
+
if (q.quantity === 'phi') return this.phiUndefined[g] ? NaN : this.VT * (this.u[this.blockOfNode[g] * M] + this.uLo[this.blockOfNode[g] * M]);
|
|
2473
2527
|
if (!this.present[g * n + q.i]) return NaN;
|
|
2474
2528
|
if (q.quantity === 'c') return this.c[g * n + q.i];
|
|
2475
2529
|
const o = this.blockOfNode[g] * M + 1 + q.i;
|
package/types/index.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export class Device {
|
|
|
51
51
|
* (one array per probe, alongside `trace.t`): what a detector at x sees.
|
|
52
52
|
* @param {number} tEnd s
|
|
53
53
|
* @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
|
|
54
|
-
* probes?: { x: number, species
|
|
54
|
+
* probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
|
|
55
55
|
* @returns {Solution}
|
|
56
56
|
*/
|
|
57
57
|
advance(tEnd: number, opts?: {
|
|
@@ -63,8 +63,8 @@ export class Device {
|
|
|
63
63
|
method?: "bdf2" | "be";
|
|
64
64
|
probes?: {
|
|
65
65
|
x: number;
|
|
66
|
-
species
|
|
67
|
-
quantity?: "c" | "V";
|
|
66
|
+
species?: string;
|
|
67
|
+
quantity?: "c" | "V" | "phi";
|
|
68
68
|
region?: string | number;
|
|
69
69
|
}[];
|
|
70
70
|
}): Solution;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
|
|
3
|
+
* mixed in one proportion at every point (every ion's mixing fraction the same curve). Exact for a single binary salt, a close estimate otherwise (it's what
|
|
4
|
+
* JPCalc and most electrophysiology corrections use).
|
|
5
|
+
* @param {Record<string, number>} left mol/m³ by ion
|
|
6
|
+
* @param {Record<string, number>} right mol/m³ by ion
|
|
7
|
+
* @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
|
|
8
|
+
* @param {{ T?: number }} [opts] K (default 298.15)
|
|
9
|
+
*/
|
|
10
|
+
export function henderson(left: Record<string, number>, right: Record<string, number>, ions: Record<string, {
|
|
11
|
+
z: number;
|
|
12
|
+
D: number;
|
|
13
|
+
}>, { T }?: {
|
|
14
|
+
T?: number;
|
|
15
|
+
}): number;
|
|
16
|
+
/**
|
|
17
|
+
* Planck's liquid-junction potential, φ_R − φ_L (V): the steady state of constrained diffusion,
|
|
18
|
+
* a junction zone held between the two solutions, electroneutral, carrying no current. Solved by
|
|
19
|
+
* shooting on the Nernst–Planck equations across it (its length drops out). Exact for that
|
|
20
|
+
* junction; equal to Henderson's for a single binary salt.
|
|
21
|
+
* @param {Record<string, number>} left mol/m³ by ion
|
|
22
|
+
* @param {Record<string, number>} right mol/m³ by ion
|
|
23
|
+
* @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
|
|
24
|
+
* @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
|
|
25
|
+
*/
|
|
26
|
+
export function planck(left: Record<string, number>, right: Record<string, number>, ions: Record<string, {
|
|
27
|
+
z: number;
|
|
28
|
+
D: number;
|
|
29
|
+
}>, { T, steps }?: {
|
|
30
|
+
T?: number;
|
|
31
|
+
steps?: number;
|
|
32
|
+
}): number;
|
package/types/kit.d.ts
CHANGED
|
@@ -61,5 +61,6 @@ export { parseEquation, stoichiometry, half, SHE, level } from "./equation.js";
|
|
|
61
61
|
export { traces, speciesRole, typeset } from "./traces.js";
|
|
62
62
|
export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from "./sources.js";
|
|
63
63
|
export { describe, unitWarnings } from "./describe.js";
|
|
64
|
+
export { henderson, planck } from "./junction.js";
|
|
64
65
|
export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from "./devices.js";
|
|
65
66
|
export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from "./data.js";
|
package/types/recorder.d.ts
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
|
|
5
5
|
* `probes` if given) runs from the start through every accepted step of every call.
|
|
6
6
|
* @param {import('./index.js').Device} device
|
|
7
|
-
* @param {{ every?: number, times?: number[], probes?: { x: number, species
|
|
7
|
+
* @param {{ every?: number, times?: number[], probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
|
|
8
8
|
*/
|
|
9
9
|
export function recorder(device: import("./index.js").Device, opts?: {
|
|
10
10
|
every?: number;
|
|
11
11
|
times?: number[];
|
|
12
12
|
probes?: {
|
|
13
13
|
x: number;
|
|
14
|
-
species
|
|
15
|
-
quantity?: "c" | "V";
|
|
14
|
+
species?: string;
|
|
15
|
+
quantity?: "c" | "V" | "phi";
|
|
16
16
|
region?: string | number;
|
|
17
17
|
}[];
|
|
18
18
|
}): {
|