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 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) and [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg |
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 | Poisson–Nernst–Planck, ion channels, membrane potentials | the electrochemistry setups, with fixed charge in the channel region | no dedicated check yet |
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.6.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
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
  | [![impedance](demos/screenshots/impedance.png)](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [![ion-exchange membrane](demos/screenshots/membrane.png)](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [![double layer](demos/screenshots/double-layer.png)](https://markblundeberg.github.io/driftlet/demos/double-layer.html) |
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
  | [![insertion host](demos/screenshots/insertion.png)](https://markblundeberg.github.io/driftlet/demos/insertion.html) | [![organic solar cell](demos/screenshots/organic.png)](https://markblundeberg.github.io/driftlet/demos/organic.html) | [![perovskite hysteresis](demos/screenshots/perovskite.png)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) |
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
+ | [![charged nanochannel](demos/screenshots/channel.png)](https://markblundeberg.github.io/driftlet/demos/channel.html) | [![resting potential](demos/screenshots/cell.png)](https://markblundeberg.github.io/driftlet/demos/cell.html) | [![liquid junctions](demos/screenshots/junction.png)](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). Any
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). A species that
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. With
504
- `probes: [{ x, species, quantity, region }]` the trace also reads a species inside the device,
505
- as `trace.probes[k]` beside `trace.t`: its concentration (`quantity: 'c'`, the default, mol/m³)
506
- or species voltage (`'V'`), linearly between the nodes around `x` (at an interface, `region`
507
- picks the side). That's what a detector at `x` sees. Probes read at accepted steps, which grow
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. Under light it's J = F(G − R), and it says where
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 species' sources and sinks sum to zero …
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.6.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/plot.js';
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, with φ
237
- made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
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.6.0/src/index.js';
269
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/kit.js';
270
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.6.0/src/plot.js';
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 semiconductors are at 300 K.** Give `T: 300`.
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
- - **Sign of a voltage across a membrane.** `sol.terminalVoltage` is V_right − V_left. Physiology
391
- quotes V_inside − V_outside, so put the inside on the right or flip the sign.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
package/src/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
- const ledgers = species.map((sp) => ({ species: sp.name, z: sp.z, terms: [] }));
84
- const add = (i, what, rate) => {
85
- if (rate !== 0 && Number.isFinite(rate)) ledgers[i].terms.push({ what, rate });
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
- sol.ports.forEach((p) => add(i, `port ${p.name}`, p.flux[sp.name]));
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 total = sol.bulkReactions[k].total, what = label(def.bulkReactions?.[k]);
94
- for (const { i, nu } of rx.reactants) add(i, what, -nu * total);
95
- for (const { i, nu } of rx.products) add(i, what, nu * total);
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
- model.interfaces.forEach((itf, f) => {
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, i) => size(l) > 1e-9 * natural[i]);
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.species}: ${shown.map((t) => `${t.what}: ${t.rate > 0 ? '+' : ''}${sig(t.rate)}${amp(t.rate)}`).join('; ')}`);
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} species' sources and sinks sum to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}; mol/(m²·s), + in or made`;
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 rx = (idef.reactions ?? []).map((r) => r.equation ?? equation(r.left, r.right));
190
- lines.push(` ${L.name} | ${R.name}: ${law}${align}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`);
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, or an ohmic interface conductance G (S/m²).
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: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
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) {
@@ -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: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
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.srh) {
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 i = species.findIndex((sp) => sp.name === p?.species);
2451
- if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2452
- const quantity = p.quantity ?? 'c';
2453
- if (quantity !== 'c' && quantity !== 'V') fail(`${path}.quantity must be 'c' (mol/m³) or 'V' (the species voltage)`);
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: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
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: string;
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";
@@ -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: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
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: string;
15
- quantity?: "c" | "V";
14
+ species?: string;
15
+ quantity?: "c" | "V" | "phi";
16
16
  region?: string | number;
17
17
  }[];
18
18
  }): {