driftlet 0.7.0 → 0.8.0

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