driftlet 1.1.1 → 1.2.2

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
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
@@ -300,7 +300,7 @@ npm run shots # retake the demos' screenshots, each caught at a set state (to
300
300
 
301
301
  Design notes and plans: [reliability](docs/reliability.md), [numerics](docs/numerics.md), [conventions](docs/conventions.md),
302
302
  [statistics](docs/statistics.md), [the kit](docs/kit.md), [reading level diagrams](docs/visualization.md),
303
- [data](docs/data.md), [roadmap](ROADMAP.md).
303
+ [data](docs/data.md), [internals](docs/internals.md) (the source, module by module), [roadmap](ROADMAP.md).
304
304
 
305
305
  ## Credits
306
306
 
@@ -133,8 +133,8 @@ A contact is an interface with an **outside phase** whose levels are known, like
133
133
  with a fully known node. The outside phase's charged levels form a rigid ladder,
134
134
  $`V_i = V + \mathtt{offset}_i`$, which the external circuit slides by the **terminal voltage** $`V`$
135
135
  (the voltage of a named terminal species). The laws joining it to the device (equilibrium,
136
- blocked, conductance; and for $`\phi`$, bulk, neutral, capacitive, dipole) are the same as at
137
- internal faces. Reactions sit at faces between regions: an electrode is a conductor region with
136
+ blocked, conductance, exchange, velocity; and for $`\phi`$, bulk, neutral, capacitive, pinned)
137
+ are those of internal faces, with the outside phase as the other side. Reactions sit at faces between regions: an electrode is a conductor region with
138
138
  reactions at its face.
139
139
 
140
140
  - **Species in equilibrium** with the outside phase sit at $`V_i = V + \mathtt{offset}_i`$. The
package/docs/data.md CHANGED
@@ -25,7 +25,10 @@ The data are 25 °C's, and the material says so (`T: 298.15`): a device at anoth
25
25
  warns that they don't hold there. Its μ° (so equilibrium constants and electrode potentials),
26
26
  permittivity and diffusivities would all move: $`K_w`$ by ~6× between 6 and 25 °C. Scaling them
27
27
  (μ° by the standard entropies, ε, D by water's viscosity) is planned, not done. Where only
28
- transport between solutions of the same ions matters (a membrane's channels), μ° cancels.
28
+ transport between solutions of the same ions matters (a membrane's channels), μ° cancels; if the
29
+ D don't matter either, taking the data as another temperature's is a fair approximation, made
30
+ by setting the material's `T` (the cell and axon demos do, having checked that D scaled by
31
+ T/η moves nothing they show).
29
32
 
30
33
  | Ion | z | D (10⁻⁹ m²/s) | $`\mu^\circ`$ (kJ/mol) |
31
34
  |---|---|---|---|
package/docs/device.md CHANGED
@@ -516,8 +516,8 @@ background carriers do). A solution against a charged end region (an ion exchang
516
516
  its own material, `bath: { c, material: 'water' }`: its composition is then neutral by itself, its
517
517
  levels are that material's, and the end node takes them through a Donnan step.
518
518
 
519
- **`phi`**, required whenever any species or reaction connects at the contact (default
520
- `'neutral'`):
519
+ **`phi`**, required whenever any species connects at the contact (default `'neutral'`, or
520
+ `'bulk'` with a bath):
521
521
  - `'bulk'`: the end node is plain bulk, locally neutral, with no double layer at the contact
522
522
  (ohmic contacts, baths). The outside takes whatever surface charge that needs. A double layer
523
523
  or depletion region that reaches it is cut off there (σ 58% low for a crowded layer 5 Debye
@@ -872,7 +872,8 @@ advance it, `advance()` and `step()` do.
872
872
 
873
873
  `{ hmin, hmax, ratio = 1.2, minCells = 8 }`, per device, overridable per region. With no grid
874
874
  options at all, each region is graded from `hmin` = 1/1000 to `hmax` = 1/20 of its length. Cells grow
875
- geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly. Each
875
+ geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly
876
+ (`hLeft` and `hRight` set one end's size instead, for a region fine at one end only). Each
876
877
  region boundary becomes a pair of nodes at the same x, one per side. The grid is never refined
877
878
  automatically. If a double layer that the model resolves is coarser than the local Debye
878
879
  length, the solution's `warnings` say so. That's a floor, not an accuracy: a strongly polarised
@@ -1061,12 +1062,12 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
1061
1062
  | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
1062
1063
  | `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
1063
1064
  | `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), 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 |
1064
- | `charge` | total charge in the device, C/m² (C through a cross-section) |
1065
+ | `charge` | total charge in the device, C/m² (C through a cross-section): its regions' space charge and its faces' sheet charges, not the charge on a capacitive contact's or port's plates, so under a gate's bias it's the gate's opposite |
1065
1066
  | `conservation` | per species stretch: amount, reference, intake through contacts, and drift, relative: (amount − reference − intake) over the largest of them |
1066
1067
  | `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, a material's data for another temperature, and for a failed solve, why (a driven current it can't pass, where the system is nearly singular). A failed steady solve's state isn't a solution, so the warnings about a solution's accuracy (the grid, double layers, space charge reaching a contact) are left out of it |
1067
1068
  | `converged`, `iterations`, `steps`, `substeps`, `history`, `time`, `T` | solver bookkeeping, and the temperature |
1068
1069
  | `steady` | whether this is a converged steady state (from `solve()`), not a transient's: what `check()` in the kit decides its checks by |
1069
- | `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step |
1070
+ | `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step, and `probes` if asked |
1070
1071
 
1071
1072
  A stretch is a run of regions in which a species is present and connected. Its `drift`
1072
1073
  compares its amount with the reference amount plus everything that came in through the
@@ -0,0 +1,86 @@
1
+ # Internals
2
+
3
+ A map of the source for anyone reading or reviewing it: what each module is for, the layout of
4
+ the solver's state that the rest assumes, and which modules state a contract. The user-facing
5
+ reference is the other docs; this one is about the code.
6
+
7
+ ## The rule: a module states its contract
8
+
9
+ A module whose correctness can't be seen from its call sites says at its head what it reads,
10
+ what it returns or writes, and what must hold of the result, so that it can be checked on its
11
+ own, without the rest of the library in mind. Where the contract and the code disagree, one of
12
+ them is wrong: fix both, with a test, in the same commit.
13
+
14
+ | Module | Contract |
15
+ | --- | --- |
16
+ | `src/laws.js` | yes: the steady system's conservation laws |
17
+ | `src/lawRows.js` | yes: the laws written into the Newton system, and the bordered solve |
18
+ | `src/difference.js` | header: the dilute kernels in difference form |
19
+ | `src/blockTridiagonal.js` | header: the block Thomas solver |
20
+ | `src/statistics.js` | header: each model's `evaluate` / `invert` |
21
+ | `src/solver.js` | not yet (below) |
22
+
23
+ ## From a definition to a solution
24
+
25
+ - `src/device.js`: `normalizeDevice(def)` validates a plain definition and returns the
26
+ normalised `model` (species, materials, regions, interfaces, contacts, ports, terminals, the
27
+ grid from `src/grid.js`). Every later stage reads the model and never the definition.
28
+ - `src/index.js`: `Device`, the public object. It holds the model, builds a `Solver` on first
29
+ use, and turns solver states into `Solution`s (`src/solution.js`).
30
+ - `src/solver.js`: `Solver`, the numerical core (below).
31
+ - The kit (`src/kit.js` and what it re-exports: stack, data, sources, channels, checks,
32
+ describe, traces, live, recorder, particles, polarization, junction, devices) works on
33
+ definitions and solutions only, never on a solver's internals. `src/plot.js` draws
34
+ solutions.
35
+
36
+ ## The solver's state
37
+
38
+ - **Blocks.** One per grid node and one per face between regions: node g is block
39
+ `blockOfNode[g]`, face f's flux block is `blockOfFace[f]`, in order along x.
40
+ - **Slots.** Each block has `M = 1 + n + extra` slots: slot 0 is φ̂ (at a face its
41
+ displacement D, at a metal's node its carriers' segment flux J), slots `1 … n` the species'
42
+ η = μ̄/RT, then a face's reaction rates and gates' fractions, or at an electrode port's nodes
43
+ its surface's coverages (as η) and a membrane port's gates. Row and unknown `o = b·M + slot`.
44
+ - **Compact indices.** Only active slots enter the linear system: `loc[o]` is a slot's place in
45
+ its block (−1: inactive), `rix[o]` its index in compact vectors, with `rix = N` (the sink) for
46
+ inactive ones. `res`, `delta` and the Jacobian (`sys`, a `BlockTridiagonal`) are compact.
47
+ - **Unknowns** are `u + uLo`, a double-double, so that differences of η keep their digits; the
48
+ Jacobian and solves are plain doubles. Concentrations `c[g·n + i]` and coverages
49
+ `th[g·nSurf + q]` follow from `u` (`computeConcentrations`).
50
+ - **Terminals:** 0 the left contact, 1 the right, `2 + k` port k. A floating terminal (driven by
51
+ a current, or behind a resistance) has its voltage as an extra unknown and its circuit row
52
+ beside the block system, solved by bordering (`solveBordered` in `src/lawRows.js`: `termB`
53
+ its column, `termC` its row, `termDI` and `termDX` its own and other terminals' ∂/∂V).
54
+ - **Stretches:** each connected run of regions where a species is present
55
+ (`_findStretches`), the unit that conservation counts in.
56
+
57
+ ## The solver's sections
58
+
59
+ By method, in file order:
60
+
61
+ - **Setup:** the constructor (layout, per-node data, φ-undefined regions, terminals),
62
+ `_activeSlots`, `_findStretches` (stretches, flat stretches), `_laws` and `_structure` (the
63
+ laws, from `src/laws.js`), `_directSteady`.
64
+ - **Applying the laws** is `src/lawRows.js` (`captureLaws`, `captureLocal`, `snapFlat`,
65
+ `applyLaws`, `solveBordered`), called from `assemble`, `newton`, `_refine` and the steady
66
+ solves; `_replaceRow` and `_j`, which it uses, stay in the solver.
67
+ - **Starting state:** `initFromComposition`, `_surfaceStart`.
68
+ - **Statistics and assembly:** `computeConcentrations`, `_nodeStatistics`, `assemble` and the
69
+ kernels it calls (nodes, segments, faces, bulk reactions, ports, contacts, charge rows),
70
+ `_assembleDifference` (with `src/difference.js`).
71
+ - **Newton:** `newton`, `_refine`, `_equilibrate`, `_kirchhoff`, `_noteConditioning`.
72
+ - **Time steps:** `advance`, `step`, `integrate` (BDF with error control, breakpoints from the
73
+ sources), `_predict`, `_errorNorm`, `_snapshot` / `_restore`.
74
+ - **Small signal:** `impedance` and the helpers after it.
75
+ - **Bookkeeping:** `segmentFluxes`, `faceOneWay`, `_assembleBookkeeping`, the contacts' intake.
76
+ - **Steady solves:** `solveSteady`, `_steadyFromHere`, the continuations (floating, generation,
77
+ a terminal's level), `_steadySteps`.
78
+
79
+ The rest of `src/solver.js` is to be split along these lines, each piece with its contract.
80
+
81
+ ## Reviewing one module
82
+
83
+ A review of one module reads its contract, its code and its tests, and nothing else unless the
84
+ contract points there. Each claim cites `file:line` with the line quoted, so it can be checked
85
+ mechanically, and a finding comes with the device or script that shows it. A claim about code
86
+ not in view is a question, not a finding.
package/docs/numerics.md CHANGED
@@ -725,17 +725,34 @@ the terms that cancel:
725
725
  charging a capacitance (a gate, a port's capacitance) is the same, its charge in place of an
726
726
  amount; and an electrode port's reactions draw on what the electrode holds, its charge
727
727
  $`Q/(zF)`$ a column of its own under a current, so a port at no current reducing a closed
728
- M⁺ (M⁺ + e⁻ = M(s)) keeps M⁺ + Q/F, or with no capacitance, M⁺ alone. At any other current such a terminal only stores what comes in, which has no steady
729
- state, and the solve says so at once.
728
+ M⁺ (M⁺ + e⁻ = M(s)) keeps M⁺ + Q/F, or with no capacitance, M⁺ alone. Whatever such a
729
+ terminal both passes and stores is kept together, as its current counts both: a capacitive
730
+ port or contact at no current that passes K⁺ keeps $`n_K + Q/F`$ (a contact's Q its
731
+ displacement $`D_\text{in}`$), and two floating electrodes sharing a closed Fe³⁺ keep the iron
732
+ with both their charges, the law in one's circuit row moving with the other's voltage too.
733
+ A charge is kept only in a circuit row, once: the first combination through it takes one,
734
+ and the rest have its multiple taken off (a capacitor passing K⁺ under K⁺ + L⁻ = KL keeps
735
+ $`n_K - n_L + D/F`$ there, and $`n_L + n_{KL}`$ in a balance row).
736
+ At any other current such a terminal only stores what comes in, which has no steady state,
737
+ and the solve says so at once.
730
738
  - an **immobile** combination (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) keeps it
731
739
  node by node, since nothing carries it anywhere, as do an electrode's surface species
732
740
  (adsorbates that only turn into each other keep their total on every site), its row local,
733
- one per node in its own block. (The null space is taken with the immobile columns first, so
734
- that these come out apart from the combinations that move.) A combination of immobile
741
+ one per node in its own block, a coverage counted as the sites it fills per volume,
742
+ $`\Gamma a \theta`$, beside the concentrations. Each node's rows are chosen by elimination
743
+ over the slots there, as below. Immobile means D = 0 in one region with no terminal feeding
744
+ it: a species linked across a face is carried between the face's two nodes, and one a
745
+ port links is carried between the port's nodes, kept with the port's charge in its circuit
746
+ row. (The null space is taken with the immobile and
747
+ surface columns first, so that these come out apart from the combinations that move.) A combination of immobile
735
748
  stretches with a moving one (the electrons the traps hold, $`e^- - X^0`$, in a closed film) is
736
749
  an amount like any other, its immobile parts summed over their nodes;
737
750
  - a **floating metal** keeps the charge on its faces, its sheets' displacement.
738
751
 
752
+ A combination that none of these rows can keep (through a stretch immobile in part only, a
753
+ face reaction joining two immobile stretches at a face, a charge with no circuit row left) is
754
+ set aside, and the steady solve then goes by time steps, which keep every amount themselves.
755
+
739
756
  A closed stretch keeps the amount it was given (its `c0`, or what it held when `set()`
740
757
  closed it); a combination behind a terminal at no current keeps what the state holds when the
741
758
  solve begins. The amount's row is dense, so it's bordered (see [terminals](#terminals)): the
@@ -796,7 +813,9 @@ the terms that cancel:
796
813
  rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
797
814
  reaction first, and a row of one of its own species where one is left, never one picked by where
798
815
  the species are listed (the rows had differed with the species reversed, and an electrode's
799
- equilibrium solved in one listing and failed in the other). The combinations come from the null
816
+ equilibrium solved in one listing and failed in the other). A reaction that changes its
817
+ combination only through immobile species needs none: its rate already stands alone in their
818
+ balance at the face's node. The combinations come from the null
800
819
  space with that reaction's row left out, any conserved amount added to one being as good (its
801
820
  summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
802
821
  beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
@@ -141,6 +141,40 @@ its true residual, blocked species held flat in steady solves, continuations for
141
141
  and for light, and Newton refining its solves where a factorisation loses a mode, with an exact
142
142
  $`J \cdot v`$.
143
143
 
144
+ ## Conserved: the law sweep
145
+
146
+ A steady solve replaces one redundant equation per conserved combination with the combination's
147
+ amount (see [numerics](numerics.md#the-steady-system)). A combination left out, or weighed wrongly,
148
+ gives a state that converges and is steady but holds the wrong amounts, which only a transient
149
+ from the same start shows. `npm run lawsweep` builds 870 small devices in two families. The first, 810, combine the features at the
150
+ edges of that bookkeeping: a floating, resistive or held electrode, with or without a
151
+ capacitance, with adsorbates or an immobile species that adsorbs; a second floating electrode
152
+ or a membrane capacitor; a capacitive or ion-passing contact at no current; a face that blocks
153
+ an ion or makes an immobile species. Each is solved directly and run as a transient for 1e6 s,
154
+ and where the transient converged, finished and moves no further over 100× as long, the two
155
+ must agree on every species' amount, every surface's sites and every floating terminal's
156
+ voltage. The second, 60, put an immobile S⁺ beside what carries it (a face that passes it, a port
157
+ at I = 0 that links it, an electrode it adsorbs on) with a capacitive contact passing K⁺ (at
158
+ I = 0 or behind a resistance) and K⁺'s complex with a closed L⁻, each where a cold review of
159
+ `src/laws.js` found a law kept wrongly.
160
+
161
+ Every reaction starts at its equilibrium. On 1.2.1, 675 devices are built (135 combinations
162
+ repeat others); 579 agree, and 83 transients fail to converge. In the other 13 the transient
163
+ is the one that's off: it loses a conserved K⁺ total in proportion to its tolerance (3e-5 of it
164
+ at tol 1e-9) when a capacitive contact, a membrane capacitor and a held electrode share the
165
+ device, where the steady solve keeps it to round-off. The sweep found its first law the day it
166
+ was written (an immobile species adsorbing on an electrode over part of its region); Gemini 4
167
+ Argon's review found nine more by hand (one through a device it suggested), and a cold review
168
+ of `src/laws.js` against its contract alone five more (an immobile species linked across a face,
169
+ or by a port; two node-by-node laws sharing a row; a capacitive contact's charge in a second law,
170
+ and in a time step's reading of its current), none of them in the sweep's devices. Each is now in
171
+ `test/laws.test.js`. On the code before those fixes the second family has 14 devices that
172
+ disagree (a port's voltage at 0 for −1.42 V, θ 0.05 for 0.015, a contact 24 mV off); after, none,
173
+ 26 agree and 34 transients fail (see the roadmap). The first family's laws come out unchanged by
174
+ the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices, gives the
175
+ same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a membrane
176
+ capacitor where the transient loses K⁺.
177
+
144
178
  ## Hard cases
145
179
 
146
180
  `npm run hard` keeps a corpus of named cases that were hard once (sharp 3 M | 1 µM junctions on
@@ -44,6 +44,7 @@ most one model.
44
44
 
45
45
  | Model | Species | $`c(\zeta)`$ | Parameters |
46
46
  |---|---|---|---|
47
+ | `ideal` | any | $`c_{\mathrm{ref}} e^\zeta`$, the default, named only to say so | none |
47
48
  | `fermi-dirac` | any, each independently | $`c_{\mathrm{ref}} \mathcal{F}_{1/2}(\zeta)`$ (3D band); $`c_{\mathrm{ref}} \ln(1 + e^\zeta)`$ with `order: 0` (2D) | `order`: 1/2 (default) or 0 |
48
49
  | `lattice` | any, sharing sites | $`c_a = c_{\mathrm{max}} w_a / (1 + \sum w)`$, $`w_a = (c_{\mathrm{ref},a}/c_{\mathrm{max}}) e^{\zeta_a}`$ | `cMax`, site density, mol/m³ |
49
50
  | `redlich-kister` | one | on its own sites: $`\zeta = \ln(x/(1-x)) + \ln(c_{\mathrm{max}}/c_{\mathrm{ref}}) + (g(x) - g(0))/RT`$, $`x = c/c_{\mathrm{max}}`$ | `cMax`; `A`, J/mol |
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.1.1",
3
+ "version": "1.2.2",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,6 +33,7 @@
33
33
  "stress": "node bench/stress.js",
34
34
  "smoke": "node tools/smoke.js",
35
35
  "shots": "node tools/shots.js",
36
+ "lawsweep": "node tools/lawsweep.js",
36
37
  "prepublishOnly": "npm test && npm run types",
37
38
  "types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
38
39
  },
package/src/channels.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { DeviceError, checkOptions } from './errors.js';
5
5
  import { FARADAY, GAS_CONSTANT } from './constants.js';
6
+ import { bernoulli } from './bernoulli.js';
6
7
 
7
8
  const fail = (message) => {
8
9
  throw new DeviceError(message);
@@ -22,9 +23,10 @@ const HH_T = 273.15 + 6.3;
22
23
 
23
24
  // The GHK current (outward, A/m²) of an ion at unit permeability, at voltage V (inside − outside).
24
25
  export function ghkCurrent(z, inside, outside, V, T) {
26
+ // zF u (c_in − c_out e^−u)/(1 − e^−u) = zF (c_in B(−u) − c_out B(u)), B(x) = x/(eˣ − 1): finite
27
+ // at any u (the plain form was ∞/∞ past u = −710)
25
28
  const u = (z * V * FARADAY) / (GAS_CONSTANT * T);
26
- if (Math.abs(u) < 1e-9) return z * FARADAY * (inside - outside);
27
- return (z * FARADAY * u * (inside - outside * Math.exp(-u))) / -Math.expm1(-u);
29
+ return z * FARADAY * (inside * bernoulli(-u) - outside * bernoulli(u));
28
30
  }
29
31
 
30
32
  /**
@@ -59,6 +61,8 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
59
61
  // T sets the gates' rates, so it must be the device's: never defaulted (their 6.3 °C, under a body-temperature device, doubled a spike's peak, silently).
60
62
  need(num(T) && T > 0, `hodgkinHuxley: T must be the device's temperature in K (their own experiments were at 279.45 K, 6.3 °C), got ${T}`);
61
63
  need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
64
+ checkOptions('hodgkinHuxley: g', g, ['Na', 'K', 'leak']);
65
+ checkOptions('hodgkinHuxley: names', names, ['Na', 'K']);
62
66
  const G = { Na: 1200, K: 360, leak: 3, ...g };
63
67
  const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';
64
68
  const q10 = Math.exp(Math.log(3) * ((T - HH_T) / 10)); // Q₁₀ = 3
@@ -79,9 +83,10 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
79
83
  };
80
84
  }
81
85
  need(inside && outside && typeof inside === 'object' && typeof outside === 'object', 'hodgkinHuxley: give inside and outside concentrations (mol/m³) by species name (or, for a membrane port, its area)');
82
- const P = (name, z, gval, what) => {
86
+ const P = (name, z, key, what) => {
87
+ const gval = G[key];
83
88
  need(num(inside[name]) && inside[name] > 0 && num(outside[name]) && outside[name] > 0, `hodgkinHuxley: give ${name}'s concentration inside and outside (mol/m³), for its ${what}`);
84
- need(num(gval) && gval > 0, `hodgkinHuxley: g.${what} must be a conductance > 0 (S/m²), got ${gval}`);
89
+ need(num(gval) && gval > 0, `hodgkinHuxley: g.${key} must be a conductance > 0 (S/m²), got ${gval}`);
85
90
  const E = ((GAS_CONSTANT * T) / (z * FARADAY)) * Math.log(outside[name] / inside[name]);
86
91
  need(Math.abs(at - E) > 1e-4, `hodgkinHuxley: ${name}'s Nernst level, ${(E * 1e3).toFixed(1)} mV, is at the calibration voltage; choose another 'at'`);
87
92
  return (gval * (at - E)) / ghkCurrent(z, inside[name], outside[name], at, T);
@@ -90,9 +95,9 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
90
95
  return {
91
96
  gates,
92
97
  species: {
93
- [Na]: { type: 'permeability', P: P(Na, 1, G.Na, 'Na channels'), gates: { m: 3, h: 1 } },
94
- [K]: { type: 'permeability', P: P(K, 1, G.K, 'K channels'), gates: { n: 4 } },
95
- [leak]: { type: 'permeability', P: P(leak, leakZ, G.leak, 'leak') },
98
+ [Na]: { type: 'permeability', P: P(Na, 1, 'Na', 'Na channels'), gates: { m: 3, h: 1 } },
99
+ [K]: { type: 'permeability', P: P(K, 1, 'K', 'K channels'), gates: { n: 4 } },
100
+ [leak]: { type: 'permeability', P: P(leak, leakZ, 'leak', 'leak') },
96
101
  },
97
102
  };
98
103
  }
package/src/checks.js CHANGED
@@ -86,7 +86,7 @@ export function check(device, sol, { refine = true, tol = 1e-2, order = true } =
86
86
  if (steady && refine && order && model.species.length > 1) items.push(orderCheck(device, sol, ledger.details.gross));
87
87
  // A transient's answer depends on its whole history (and on drives set along the way), so
88
88
  // only a run of its own on a finer grid can check it.
89
- else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
89
+ if (!steady && refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
90
90
  }
91
91
 
92
92
  const ok = sol.converged && items.every((it) => it.ok !== false);
@@ -96,7 +96,7 @@ export function check(device, sol, { refine = true, tol = 1e-2, order = true } =
96
96
  }
97
97
 
98
98
  // The units of totals through the cross-section: per m² of a planar device, or whole.
99
- const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²' } : { rate: 'mol/s', current: 'A', charge: 'C' });
99
+ const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²', amount: 'mol/m²' } : { rate: 'mol/s', current: 'A', charge: 'C', amount: 'mol' });
100
100
 
101
101
  // Each species' steady ledger: in through the terminals, made by the reactions. Rates in
102
102
  // mol/(m²·s) (mol/s if not planar), positive into the device or made.
@@ -262,6 +262,8 @@ function gridCheck(device, sol, tol, gross) {
262
262
  const out = { ratio: Math.sqrt(g.ratio ?? 1.2), minCells: 2 * (g.minCells ?? 8) };
263
263
  if (g.hmin !== undefined) out.hmin = g.hmin / 2;
264
264
  if (g.hmax !== undefined) out.hmax = g.hmax / 2;
265
+ if (g.hLeft !== undefined) out.hLeft = g.hLeft / 2;
266
+ if (g.hRight !== undefined) out.hRight = g.hRight / 2;
265
267
  return { ...reg, grid: out };
266
268
  }),
267
269
  };
@@ -334,7 +336,7 @@ function gridCheck(device, sol, tol, gross) {
334
336
  summary:
335
337
  `on a grid twice as fine (${sol.x.length} → ${other.x.length} nodes), the largest change is ${worst.what}, ${sig(worst.a, 5)} → ${sig(worst.b, 5)} ${worst.unit} ` +
336
338
  `(${sig(worst.rel, 2)}${ok ? '' : `, over ${sig(tol, 1)}: refine the grid (hmin, hmax), most likely where profiles are steepest`})`,
337
- details: { diffs, nodes: [sol.x.length, other.x.length] },
339
+ details: { diffs, nodes: [sol.x.length, other.x.length], grids: fine.regions.map((reg) => reg.grid) }, // (grids: the finer one's, by region)
338
340
  };
339
341
  }
340
342
 
@@ -374,7 +376,7 @@ function orderCheck(device, sol, gross) {
374
376
  const vol = model.grid.vol, amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0);
375
377
  const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
376
378
  const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
377
- for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: 'mol/m²' });
379
+ for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: unitsOf(model).amount });
378
380
  // Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
379
381
  // (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
380
382
  // steady: look. Beyond: one of them isn't a steady state.
package/src/data.js CHANGED
@@ -185,7 +185,7 @@ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name =
185
185
  if (typeof name !== 'string' || !/^[A-Za-z][\w()]*$/.test(name)) fail(`trap: name must be a plain name for the trap (its states are name0 and name- or name+), got ${JSON.stringify(name)}`);
186
186
  const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
187
187
  const cRefH = ho.cRef ?? lib.species.find((sp) => sp.name === 'h+')?.cRef;
188
- const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 - el.mu0) / RT));
188
+ const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 + el.mu0) / RT));
189
189
  const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
190
190
  // The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
191
191
  const [empty, full] = charge === 'acceptor' ? [`${name}0`, `${name}-`] : [`${name}+`, `${name}0`];
package/src/describe.js CHANGED
@@ -265,7 +265,7 @@ export function describe(def) {
265
265
  lines.push(` port ${port.name}: ${drive(p, model.geometry.type === 'planar')}, in ${regions[port.region].name}`);
266
266
  // an electrode spread through the window
267
267
  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:`);
268
- for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
268
+ (p.reactions ?? []).forEach((rx, j) => lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${port.reactions[j].alpha}${rx.bare ? ', on bare metal' : ''}`)); // (α as used: 0.5 if not given)
269
269
  if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
270
270
  if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
271
271
  });
package/src/device.js CHANGED
@@ -18,7 +18,7 @@ export { DeviceError };
18
18
  const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange', 'velocity']);
19
19
  const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
20
20
  const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
21
- const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
21
+ const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells', 'hLeft', 'hRight'];
22
22
  // The fields of each kind of species link (to an outside phase, or across a face).
23
23
  const LINK_FIELDS = {
24
24
  blocked: ['type'],
@@ -704,7 +704,7 @@ export function nextBreakpoint(src, t) {
704
704
  if (src.value !== undefined) return Infinity;
705
705
  const { t: ts, repeat } = src, n = ts.length;
706
706
  if (!repeat) {
707
- for (let k = 0; k < n; k++) if (ts[k] > t * (1 + 1e-12) + 1e-300) return ts[k];
707
+ for (let k = 0; k < n; k++) if (ts[k] > t + 1e-12 * Math.abs(t) + 1e-300) return ts[k]; // (t·(1 + ε) is below t for t < 0)
708
708
  return Infinity;
709
709
  }
710
710
  const T = ts[n - 1] - ts[0], cycle = Math.floor((t - ts[0]) / T);
@@ -1255,7 +1255,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1255
1255
  }
1256
1256
  if (link.type === 'conductance') {
1257
1257
  // Ohmic exchange with an outside reservoir at V_i = V + offset: J = G·(V_out − V_i).
1258
- need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
1258
+ need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species (a neutral one exchanges by { type: 'exchange', k, mu })`);
1259
1259
  const offset = link.offset ?? (terminal === i ? 0 : undefined);
1260
1260
  need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir`);
1261
1261
  links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
@@ -1283,7 +1283,6 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1283
1283
  links[i] = { type: 'exchange', k: positive(link.k, `${lpath}.k (mol/(m²·s))`), mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
1284
1284
  continue;
1285
1285
  }
1286
- if (link.type === 'conductance') need(species[i].z !== 0, `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
1287
1286
  if (link.type === 'equilibrium') {
1288
1287
  // In equilibrium with the outside phase, whose levels are known: a Dirichlet condition.
1289
1288
  if (species[i].z === 0) {
package/src/devices.js CHANGED
@@ -5,7 +5,11 @@
5
5
  import { AVOGADRO, FARADAY, GAS_CONSTANT, units } from './constants.js';
6
6
  import { photogeneration } from './sources.js';
7
7
  import { drive } from './stack.js';
8
- import { checkOptions } from './errors.js';
8
+ import { DeviceError, checkOptions, shown } from './errors.js';
9
+
10
+ const need = (ok, message) => {
11
+ if (!ok) throw new DeviceError(message);
12
+ };
9
13
 
10
14
  const m3 = (x) => x / AVOGADRO, eV = units.eV;
11
15
 
@@ -35,6 +39,8 @@ const P = {
35
39
  */
36
40
  export function perovskiteCell({ V = 1.2, light = 1, bulkSRH = true } = {}) {
37
41
  checkOptions('perovskiteCell', arguments[0], ['V', 'light', 'bulkSRH']);
42
+ need(Number.isFinite(light) && light >= 0, `perovskiteCell: light must be the illumination in suns, ≥ 0, got ${shown(light)}`);
43
+ need(typeof bulkSRH === 'boolean', `perovskiteCell: bulkSRH must be true or false, got ${shown(bulkSRH)}`);
38
44
  const { T } = P, VT = (GAS_CONSTANT * T) / FARADAY;
39
45
  const ni = Math.sqrt(P.gc * P.gv) * Math.exp(-(P.Ec - P.Ev) / (2 * VT));
40
46
  const n0 = P.gc * Math.exp((P.EcE + VT * Math.log(P.dE / P.gcE) - P.Ec) / VT); // the perovskite's n at the TiO₂'s Fermi level
@@ -124,6 +130,8 @@ export const PEROVSKITE_SCANS = Object.freeze({
124
130
  * @param {number} turn s
125
131
  */
126
132
  export function hysteresis(trace, turn) {
133
+ need(trace && trace.t?.length > 0 && trace.voltage?.length === trace.t.length && trace.current?.length === trace.t.length, 'hysteresis: give a trace { t, voltage, current } (an advance()\'s, say)');
134
+ need(Number.isFinite(turn) && turn > trace.t[0] && turn < trace.t[trace.t.length - 1], `hysteresis: turn must be the time the scan turns, inside the trace (${shown(trace.t[0])} to ${shown(trace.t[trace.t.length - 1])} s), got ${shown(turn)}`);
127
135
  const out = {};
128
136
  for (const [name, on] of [['rev', (t) => t <= turn], ['fwd', (t) => t >= turn]]) {
129
137
  const pts = [];
package/src/equation.js CHANGED
@@ -79,14 +79,21 @@ export function stoichiometry(text, path = 'equation') {
79
79
  */
80
80
  export function faceSides(terms, left, right, fixed, path) {
81
81
  const sides = { left: {}, right: {} };
82
- const put = (side, name, nu) => (sides[side][name] = (sides[side][name] ?? 0) + nu);
83
82
  const fixedSide = right.conductor && !left.conductor ? 'right' : 'left';
83
+ const signs = {};
84
+ const put = (side, name, nu) => {
85
+ const key = `${side}:${name}`;
86
+ if (signs[key] !== undefined && signs[key] !== Math.sign(nu)) fail(`${path}: '${name}' (${side}) appears on both sides of the equation; give the net reaction`);
87
+ signs[key] = Math.sign(nu);
88
+ sides[side][name] = (sides[side][name] ?? 0) + nu;
89
+ };
84
90
  for (const t of terms) {
85
- if (fixed?.[t.name] !== undefined) {
91
+ // (a species the side holds is that species, given in fixed or not, as for bulk reactions)
92
+ const inL = left.holds(t.name), inR = right.holds(t.name);
93
+ if (!inL && !inR && fixed?.[t.name] !== undefined) {
86
94
  put(fixedSide, t.name, t.nu);
87
95
  continue;
88
96
  }
89
- const inL = left.holds(t.name), inR = right.holds(t.name);
90
97
  if (inL && inR) {
91
98
  fail(
92
99
  `${path}: '${t.name}' is in both '${left.name}' and '${right.name}', so say which side: ` +
@@ -177,6 +184,10 @@ export function level(sol, halfReaction, { standard = false } = {}) {
177
184
  else fail(`level: '${t.name}' is neither a species of the solution nor in the half-reaction's fixed`);
178
185
  }
179
186
  if (nuE === 0) fail(`level: ${JSON.stringify(equation)} has no net electrons`);
187
+ // (unbalanced, its level wouldn't move with φ as a level must)
188
+ const z = Object.fromEntries((sol.species ?? []).map((sp) => [sp.name, sp.z]));
189
+ const charge = terms.reduce((q, t) => q + t.nu * (t.name === 'e-' ? -1 : fixed[t.name] !== undefined ? 0 : (z[t.name] ?? NaN)), 0); // (fixed: neutral)
190
+ if (sol.species && !(Math.abs(charge) < 1e-12)) fail(`level: ${JSON.stringify(equation)} isn't charge-balanced (Σ ν z = ${charge} across it)`);
180
191
  const n = sol.mu[Object.keys(sol.mu)[0]].length;
181
192
  const V = new Float64Array(n);
182
193
  for (let g = 0; g < n; g++) {
package/src/index.js CHANGED
@@ -211,11 +211,14 @@ export class Device {
211
211
  * Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
212
212
  * `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
213
213
  * which), so an animation can call it once per frame; the step size carries over. `probes`
214
- * read a species at points inside the device after every accepted step, into `trace.probes`
215
- * (one array per probe, alongside `trace.t`): what a detector at x sees.
214
+ * read a species (or φ) at points inside the device after every accepted step, into
215
+ * `trace.probes` (one array per probe, alongside `trace.t`): what a detector at x sees. At a
216
+ * face, `{ interface, species }` reads a species' flux through it (mol/(m²·s), toward +x), and
217
+ * `{ interface, gate }` a gate's fraction open.
216
218
  * @param {number} tEnd s
217
219
  * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
218
- * probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
220
+ * probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
221
+ * | { interface: number, species: string } | { interface: number, gate: string })[] }} [opts]
219
222
  * @returns {Solution}
220
223
  */
221
224
  advance(tEnd, opts) {
package/src/junction.js CHANGED
@@ -29,6 +29,9 @@ function neutral(list, side, what) {
29
29
  }
30
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
31
  }
32
+ function holds(list, what) {
33
+ for (const side of ['cL', 'cR']) if (!list.some((p) => p[side] > 0)) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution holds no ions`);
34
+ }
32
35
 
33
36
  /**
34
37
  * Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
@@ -44,6 +47,7 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
44
47
  const list = prepare(left, right, ions, 'henderson');
45
48
  neutral(list, 'cL', 'henderson');
46
49
  neutral(list, 'cR', 'henderson');
50
+ holds(list, 'henderson');
47
51
  let a = 0, b = 0, gL = 0, gR = 0;
48
52
  for (const { z, D, cL, cR } of list) {
49
53
  a += z * D * (cR - cL);
@@ -51,9 +55,10 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
51
55
  gL += z * z * D * cL;
52
56
  gR += z * z * D * cR;
53
57
  }
54
- const VT = (GAS_CONSTANT * T) / FARADAY;
55
- if (b === 0) return 0;
56
- return (-VT * a * Math.log(gR / gL)) / b;
58
+ // ln(g_R/g_L)/(g_R − g_L) = ln(1 + r)/(r g_L), r = b/g_L: 1/g_L at equal conductivities, where the
59
+ // plain form is 0/0.
60
+ const VT = (GAS_CONSTANT * T) / FARADAY, r = b / gL;
61
+ return (-VT * a * (Math.abs(r) < 1e-8 ? 1 - r / 2 : Math.log1p(r) / r)) / gL;
57
62
  }
58
63
 
59
64
  /**
@@ -71,6 +76,7 @@ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
71
76
  const list = prepare(left, right, ions, 'planck');
72
77
  neutral(list, 'cL', 'planck');
73
78
  neutral(list, 'cR', 'planck');
79
+ holds(list, 'planck');
74
80
  const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
75
81
  const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
76
82
  const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
package/src/kit.js CHANGED
@@ -71,7 +71,7 @@ export function vacuumDipole(def, left, right) {
71
71
 
72
72
  /**
73
73
  * The vacuum-level (Schottky–Mott-type) estimate of a `zeroCharge` where a conductor meets a
74
- * material: at a conductor region's face, or a contact's capacitive or dipole law. The conductor's
74
+ * material: at a conductor region's face, or a contact's capacitive or pinned law. The conductor's
75
75
  * vacuum level sits its work function W beyond its Fermi level, so
76
76
  * zeroCharge = V_F − φ_edge at zero charge = W + (V_vac − φ)_inside.
77
77
  * @param {object} def device definition