driftlet 1.1.0 → 1.2.1

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.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.2.1/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
 
@@ -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
@@ -21,6 +21,15 @@ double layers. The data are in `IONS`, and $`\mu^\circ`$ of liquid water is `H2O
21
21
  1.011e-14 at 25 °C (pK_w 13.995): neutral water holds 1.0055e-4 mol/m³ of each, which a bath
22
22
  with that reaction should hold too.
23
23
 
24
+ The data are 25 °C's, and the material says so (`T: 298.15`): a device at another temperature
25
+ warns that they don't hold there. Its μ° (so equilibrium constants and electrode potentials),
26
+ permittivity and diffusivities would all move: $`K_w`$ by ~6× between 6 and 25 °C. Scaling them
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; 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).
32
+
24
33
  | Ion | z | D (10⁻⁹ m²/s) | $`\mu^\circ`$ (kJ/mol) |
25
34
  |---|---|---|---|
26
35
  | H+ | +1 | 9.311 | 0 |
package/docs/device.md CHANGED
@@ -250,7 +250,9 @@ r = \frac{n\,p\,(1 - e^{-A/RT})}{(n + n_1)/v_p + (p + p_1)/v_n}, \qquad p_1 = \f
250
250
  with n and p each at its own side's edge node, the capture velocities $`v_n`$ and $`v_p`$ in m/s,
251
251
  and $`n_1`$ (mol/m³) the negative species' concentration with its level at the trap (default:
252
252
  $`n_1 = p_1 = n_i`$, a midgap trap). Taking $`p_1`$ from the state keeps equilibrium exact whatever
253
- lies between the two sides (a band offset, a $`\phi`$ jump). Where one carrier is plentiful the
253
+ lies between the two sides (a band offset, a $`\phi`$ jump). With Fermi–Dirac carriers, each
254
+ one's emission scales by its own $`\gamma = \mathcal{F}_{1/2}(\zeta)/e^\zeta`$, by detailed balance:
255
+ $`n_1`$ becomes $`\gamma_n n_1`$, and $`p_1`$ keeps the product. Where one carrier is plentiful the
254
256
  rate saturates at the other's capture, $`r \to v_p\,p`$: holes reaching an electron-rich layer
255
257
  recombine at $`v_p`$ however many electrons wait there, which plain mass action can't do.
256
258
 
@@ -514,8 +516,8 @@ background carriers do). A solution against a charged end region (an ion exchang
514
516
  its own material, `bath: { c, material: 'water' }`: its composition is then neutral by itself, its
515
517
  levels are that material's, and the end node takes them through a Donnan step.
516
518
 
517
- **`phi`**, required whenever any species or reaction connects at the contact (default
518
- `'neutral'`):
519
+ **`phi`**, required whenever any species connects at the contact (default `'neutral'`, or
520
+ `'bulk'` with a bath):
519
521
  - `'bulk'`: the end node is plain bulk, locally neutral, with no double layer at the contact
520
522
  (ohmic contacts, baths). The outside takes whatever surface charge that needs. A double layer
521
523
  or depletion region that reaches it is cut off there (σ 58% low for a crowded layer 5 Debye
@@ -870,7 +872,8 @@ advance it, `advance()` and `step()` do.
870
872
 
871
873
  `{ hmin, hmax, ratio = 1.2, minCells = 8 }`, per device, overridable per region. With no grid
872
874
  options at all, each region is graded from `hmin` = 1/1000 to `hmax` = 1/20 of its length. Cells grow
873
- 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
874
877
  region boundary becomes a pair of nodes at the same x, one per side. The grid is never refined
875
878
  automatically. If a double layer that the model resolves is coarser than the local Debye
876
879
  length, the solution's `warnings` say so. That's a floor, not an accuracy: a strongly polarised
@@ -1059,12 +1062,12 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
1059
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) |
1060
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) |
1061
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 |
1062
- | `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 |
1063
1066
  | `conservation` | per species stretch: amount, reference, intake through contacts, and drift, relative: (amount − reference − intake) over the largest of them |
1064
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 |
1065
1068
  | `converged`, `iterations`, `steps`, `substeps`, `history`, `time`, `T` | solver bookkeeping, and the temperature |
1066
1069
  | `steady` | whether this is a converged steady state (from `solve()`), not a transient's: what `check()` in the kit decides its checks by |
1067
- | `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 |
1068
1071
 
1069
1072
  A stretch is a run of regions in which a species is present and connected. Its `drift`
1070
1073
  compares its amount with the reference amount plus everything that came in through the
package/docs/numerics.md CHANGED
@@ -450,8 +450,8 @@ The assembled matrix loses what a flux's two entries share. A Scharfetter–Gumm
450
450
  $`\eta`$ at its two ends through $`G`$ and $`-G(1 + E)`$, with $`E = \mathrm{expm1}(\Delta\eta)`$;
451
451
  for a nearly uniform $`v`$ they cancel, and $`J \cdot v`$ keeps only round-off of $`G|v|`$, which in
452
452
  an inversion layer's 0.1 nm cells ($`G`$ ~ 1e11) dwarfs the flux. Where a solve needs
453
- $`J \cdot v`$ itself (GMRES, in the impedance and in Newton's refined solves), the dilute kernels'
454
- terms are kept aside instead, each evaluated on differences first:
453
+ $`J \cdot v`$ itself (GMRES, in the impedance and in Newton's refined solves), the kernels' terms
454
+ are kept aside instead, each evaluated on differences first:
455
455
 
456
456
  ```math
457
457
  \delta N = a\,(\delta\eta_L - z\,\delta\hat\phi_L) + b\,(\delta\eta_R - \delta\eta_L) + d\,(\delta\hat\phi_R - \delta\hat\phi_L),
@@ -459,8 +459,13 @@ terms are kept aside instead, each evaluated on differences first:
459
459
 
460
460
  with $`a = -GE`$ (through $`c_L`$), $`b = -G(1 + E)`$ and $`d`$ the drift's. A node's storage and space
461
461
  charge depend on $`\delta\eta_i - z_i\,\delta\hat\phi`$ alone, the displacement on
462
- $`\delta\hat\phi_R - \delta\hat\phi_L`$. Everything else (faces, contacts, ports, reactions,
463
- concentrated and metal regions) is assembled into a matrix of its own. Rows that a contact or a
462
+ $`\delta\hat\phi_R - \delta\hat\phi_L`$. Non-ideal statistics couple the species ($`c_i`$
463
+ depends on every $`\zeta_j`$), so there a node's storage of $`i`$ has a term in each
464
+ $`\delta\eta_j - z_j\,\delta\hat\phi`$, through $`K_{ij}`$, and a flux has side terms in those at
465
+ either end (through $`c_L`$ and the excess potentials in its drift); each is in the same
466
+ differences. (Until 1.1.1 they were assembled directly, and a Fermi–Dirac MOS capacitor in
467
+ inversion, barely degenerate, came out with a negative resistance.) Everything else (faces,
468
+ contacts, ports, reactions, metal regions) is assembled into a matrix of its own. Rows that a contact or a
464
469
  steady solve replaces drop the kept terms; the transformed rows of strictly neutral nodes combine
465
470
  them as they combine the matrix's. A region's uniform shift ($`z_i s`$ on each $`\eta_i`$, $`s`$ on
466
471
  $`\hat\phi`$) then gives exactly zero inside it, as it must. It replaced a central difference of
@@ -649,7 +654,7 @@ majority conductances times a $`\delta\eta`$ cancelling its neighbours' to 1e-17
649
654
  round-off reads as a constant conductance of order 1 S/m², a silicon p⁺n⁺ junction's capacitance
650
655
  9× off at 100 Hz. Between the two contacts of a device without ports, the total current
651
656
  (conduction plus displacement, $`i\omega\,\delta D`$) is the same through every cut, so it is also
652
- read across each segment of an ideal region, linearised as the Scharfetter–Gummel flux is, and
657
+ read across each segment of a region (with any statistics), linearised as its flux is, and
653
658
  the reading with the least estimated error is taken: in that junction, the depletion region's;
654
659
  in a MOS capacitor, the oxide's displacement. A reading's error is its terms' round-off, and the
655
660
  solves' error in $`\delta x`$ (their residual, relative to its largest entry) through its
@@ -720,13 +725,19 @@ the terms that cancel:
720
725
  charging a capacitance (a gate, a port's capacitance) is the same, its charge in place of an
721
726
  amount; and an electrode port's reactions draw on what the electrode holds, its charge
722
727
  $`Q/(zF)`$ a column of its own under a current, so a port at no current reducing a closed
723
- 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
724
- 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
+ At any other current such a terminal only stores what comes in, which has no steady state,
734
+ and the solve says so at once.
725
735
  - an **immobile** combination (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) keeps it
726
736
  node by node, since nothing carries it anywhere, as do an electrode's surface species
727
737
  (adsorbates that only turn into each other keep their total on every site), its row local,
728
- one per node in its own block. (The null space is taken with the immobile columns first, so
729
- that these come out apart from the combinations that move.) A combination of immobile
738
+ one per node in its own block, a coverage counted as the sites it fills per volume,
739
+ $`\Gamma a \theta`$, beside the concentrations. (The null space is taken with the immobile and
740
+ surface columns first, so that these come out apart from the combinations that move.) A combination of immobile
730
741
  stretches with a moving one (the electrons the traps hold, $`e^- - X^0`$, in a closed film) is
731
742
  an amount like any other, its immobile parts summed over their nodes;
732
743
  - a **floating metal** keeps the charge on its faces, its sheets' displacement.
@@ -791,7 +802,9 @@ the terms that cancel:
791
802
  rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
792
803
  reaction first, and a row of one of its own species where one is left, never one picked by where
793
804
  the species are listed (the rows had differed with the species reversed, and an electrode's
794
- equilibrium solved in one listing and failed in the other). The combinations come from the null
805
+ equilibrium solved in one listing and failed in the other). A reaction that changes its
806
+ combination only through immobile species needs none: its rate already stands alone in their
807
+ balance at the face's node. The combinations come from the null
795
808
  space with that reaction's row left out, any conserved amount added to one being as good (its
796
809
  summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
797
810
  beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
@@ -141,6 +141,29 @@ 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 810 small devices from 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.
157
+
158
+ Every reaction starts at its equilibrium. On 1.2.1, 675 devices are built (135 combinations
159
+ repeat others); 579 agree, and 83 transients fail to converge. In the other 13 the transient
160
+ is the one that's off: it loses a conserved K⁺ total in proportion to its tolerance (3e-5 of it
161
+ at tol 1e-9) when a capacitive contact, a membrane capacitor and a held electrode share the
162
+ device, where the steady solve keeps it to round-off. The sweep found its first law the day it
163
+ was written (an immobile species adsorbing on an electrode over part of its region); Gemini 4
164
+ Argon's review found nine more by hand (one through a device it suggested), each now in
165
+ `test/laws.test.js`.
166
+
144
167
  ## Hard cases
145
168
 
146
169
  `npm run hard` keeps a corpus of named cases that were hard once (sharp 3 M | 1 µM junctions on
@@ -173,9 +196,6 @@ so a change that makes one worse shows.
173
196
  - **A surface covered to a bare fraction below ~1e-10** barely affects anything any more, and
174
197
  the solver struggles with it (the hard corpus's passivating drop without a double layer stops
175
198
  partway through its hour); the solution warns, and suggests a less stable film.
176
- - **A double layer across more than ~350 thermal voltages** (between 9 and 9.25 V at 25 °C, in a
177
- crowded lattice-gas electrolyte) doesn't solve, from a cold start or warm, and its warning
178
- doesn't say why. Ionic-liquid windows (a few volts) are well inside it.
179
199
  - **Currents below ~0.1 nA/m²** (a GaAs junction's leakage without recombination) aren't resolved
180
200
  between solves; such a current is zero for any practical purpose.
181
201
  - **A failed solve says so:** `converged: false`, with a warning naming where the system lost
@@ -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.0/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.0/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.0/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/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.0/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.0/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.0/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/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.0",
3
+ "version": "1.2.1",
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
@@ -66,7 +66,8 @@ export const METALS = freeze(
66
66
 
67
67
  /**
68
68
  * Water with the named ions, as a library piece for `build()`: each ion as a species
69
- * (c_ref = 1 M) and the material `water` holding them, with D and μ° from `IONS`.
69
+ * (c_ref = 1 M) and the material `water` holding them, with D and μ° from `IONS`. The data are
70
+ * 25 °C's, and the material says so (T: 298.15): a device at another temperature warns.
70
71
  * @param {string[]} names ions from `IONS`
71
72
  * @param {{ epsr?: number, material?: string }} [opts] permittivity (default that of water;
72
73
  * 0 makes the solution strictly neutral, without double layers), and the material's name
@@ -82,7 +83,8 @@ export function aqueous(names, { epsr = WATER_EPSR, material = 'water' } = {}) {
82
83
  species.push({ name, z: ion.z, cRef: 1000 });
83
84
  entries[name] = { D: ion.D, mu0: ion.mu0 };
84
85
  }
85
- return { species, materials: { [material]: { epsr, species: entries } } };
86
+ // (25 °C data: a device at another temperature warns)
87
+ return { species, materials: { [material]: { epsr, species: entries, T: 298.15 } } };
86
88
  }
87
89
 
88
90
  // How the semiconductors' data move with temperature, for semiconductor(name, { T }):
@@ -183,7 +185,7 @@ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name =
183
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)}`);
184
186
  const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
185
187
  const cRefH = ho.cRef ?? lib.species.find((sp) => sp.name === 'h+')?.cRef;
186
- 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));
187
189
  const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
188
190
  // The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
189
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'],
@@ -308,14 +308,20 @@ export function normalizeDevice(def) {
308
308
  };
309
309
  });
310
310
 
311
- // A material whose data are for another temperature than the device's gives the wrong n_i, gap
312
- // and mobilities, silently: say so (for each material a region uses).
311
+ // A material whose data are for another temperature than the device's is silently wrong there
312
+ // (a semiconductor's n_i, gap and mobilities; water's μ°, ε and D): say so (for each material a
313
+ // region uses).
313
314
  materials.forEach((m, k) => {
314
315
  if (m.dataT === undefined || Math.abs(m.dataT - T) <= 0.01 || !regions.some((reg) => reg.material === k)) return;
316
+ const own = Object.keys(def.materials[m.name].species ?? {}), semi = own.includes('e-') && own.includes('h+');
315
317
  warnings.push(
316
318
  `materials.${m.name}: its data are for ${m.dataT} K, but the device is at ${T} K${def.T === undefined ? ' (the default, as it gives no T)' : ''}, ` +
317
- `so they don't hold there (a semiconductor's n_i, gap and mobilities are ${m.dataT} K's): give the device T: ${m.dataT}, ` +
318
- `or remake the material for ${T} K (semiconductor(name, { T: ${T} }))`,
319
+ (semi
320
+ ? `so they don't hold there (a semiconductor's n_i, gap and mobilities are ${m.dataT} K's): give the device T: ${m.dataT}, ` +
321
+ `or remake the material for ${T} K (semiconductor(name, { T: ${T} }))`
322
+ : `so they don't hold there: its μ° (so equilibrium constants and electrode potentials, Kw's by ~6× between 6 and 25 °C), ` +
323
+ `permittivity and diffusivities are ${m.dataT} K's. Give the device T: ${m.dataT}, or give the material data for ${T} K ` +
324
+ `and its T (aqueous() has none at other temperatures yet)`),
319
325
  );
320
326
  });
321
327
 
@@ -698,7 +704,7 @@ export function nextBreakpoint(src, t) {
698
704
  if (src.value !== undefined) return Infinity;
699
705
  const { t: ts, repeat } = src, n = ts.length;
700
706
  if (!repeat) {
701
- 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)
702
708
  return Infinity;
703
709
  }
704
710
  const T = ts[n - 1] - ts[0], cycle = Math.floor((t - ts[0]) / T);
@@ -1249,7 +1255,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1249
1255
  }
1250
1256
  if (link.type === 'conductance') {
1251
1257
  // Ohmic exchange with an outside reservoir at V_i = V + offset: J = G·(V_out − V_i).
1252
- 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 })`);
1253
1259
  const offset = link.offset ?? (terminal === i ? 0 : undefined);
1254
1260
  need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir`);
1255
1261
  links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
@@ -1277,7 +1283,6 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1277
1283
  links[i] = { type: 'exchange', k: positive(link.k, `${lpath}.k (mol/(m²·s))`), mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
1278
1284
  continue;
1279
1285
  }
1280
- if (link.type === 'conductance') need(species[i].z !== 0, `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
1281
1286
  if (link.type === 'equilibrium') {
1282
1287
  // In equilibrium with the outside phase, whose levels are known: a Dirichlet condition.
1283
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/difference.js CHANGED
@@ -8,13 +8,19 @@
8
8
  // storage and space charge depend on δη_i − z_i δφ̂ alone; the displacement on δφ̂_R − δφ̂_L. A
9
9
  // uniform shift of a region (z_i s on each η_i, s on φ̂) then gives exactly zero, as it must.
10
10
  //
11
+ // Non-ideal statistics couple the species: c_i depends on every ζ_j, so a node's storage of i
12
+ // has a term in each δη_j − z_j δφ̂ (K_ij), and a flux has side terms in those at either end (its
13
+ // c_L and the excess potentials in its drift). Each is a term in the same differences, so the
14
+ // shift still gives exactly zero.
15
+ //
11
16
  // Indices are compact (the solver's rix), with the sink (index N) for an unknown that isn't one:
12
17
  // vectors passed in are read there as zero.
13
18
  export class DifferenceTerms {
14
- constructor(N, nodes, segments) {
19
+ constructor(N, nodes, segments, sides = 0) {
15
20
  this.N = N;
16
21
  this.dead = new Uint8Array(N + 1); // rows replaced since (a contact's level, a held stretch)
17
- this.nI = new Int32Array(nodes); // node term: η column (= its balance row), φ̂ column (= Gauss row)
22
+ this.nR = new Int32Array(nodes); // node term: its balance row, η column, φ̂ column (= Gauss row)
23
+ this.nI = new Int32Array(nodes);
18
24
  this.nP = new Int32Array(nodes);
19
25
  this.nZ = new Float64Array(nodes);
20
26
  this.nMass = new Float64Array(nodes); // storage per unit (δη − z δφ̂), times 1/dt (or iω)
@@ -24,6 +30,9 @@ export class DifferenceTerms {
24
30
  this.sC = new Float64Array(3 * segments); // a, b, d
25
31
  this.dI = new Int32Array(2 * segments); // displacement: φ̂_L, φ̂_R
26
32
  this.dK = new Float64Array(segments);
33
+ this.xI = new Int32Array(4 * sides); // a flux's side term: its two rows, an η column, its φ̂ column
34
+ this.xZ = new Float64Array(sides);
35
+ this.xC = new Float64Array(sides);
27
36
  this.combos = []; // [rk, ri, w]: row rk += w × row ri (strictly neutral nodes' charge rows)
28
37
  this.t = new Float64Array(N + 1);
29
38
  this.x = new Float64Array(N + 1);
@@ -33,13 +42,16 @@ export class DifferenceTerms {
33
42
  // Afresh for an assembly; `rate` multiplies storage in captured rows (1/dt).
34
43
  reset(rate = 0) {
35
44
  this.rate = rate;
36
- this.nn = this.ns = this.nd = 0;
45
+ this.nn = this.ns = this.nd = this.nx = 0;
37
46
  this.dead.fill(0);
38
47
  this.combos.length = 0;
39
48
  }
40
49
 
41
- node(i, p, z, mass, charge) {
50
+ // Storage in row r (mass) and charge in Gauss row p, per unit δη_i − z δφ̂ (row r = i, unless
51
+ // non-ideal statistics couple the species).
52
+ node(i, p, z, mass, charge, r = i) {
42
53
  const k = this.nn++;
54
+ this.nR[k] = r;
43
55
  this.nI[k] = i;
44
56
  this.nP[k] = p;
45
57
  this.nZ[k] = z;
@@ -59,6 +71,17 @@ export class DifferenceTerms {
59
71
  this.sC[3 * k + 2] = d;
60
72
  }
61
73
 
74
+ // A flux from row rL to row rR, per unit δη_i − z δφ̂ at one end.
75
+ side(rL, rR, i, p, z, w) {
76
+ const k = this.nx++;
77
+ this.xI[4 * k] = rL;
78
+ this.xI[4 * k + 1] = rR;
79
+ this.xI[4 * k + 2] = i;
80
+ this.xI[4 * k + 3] = p;
81
+ this.xZ[k] = z;
82
+ this.xC[k] = w;
83
+ }
84
+
62
85
  displacement(pL, pR, k) {
63
86
  const q = this.nd++;
64
87
  this.dI[2 * q] = pL;
@@ -83,7 +106,7 @@ export class DifferenceTerms {
83
106
  for (let k = 0; k < this.nn; k++) {
84
107
  const i = this.nI[k], p = this.nP[k], z = this.nZ[k];
85
108
  let f = 0;
86
- if (row === i) f += mass * this.nMass[k];
109
+ if (row === this.nR[k]) f += mass * this.nMass[k];
87
110
  if (row === p) f += this.nCharge[k];
88
111
  if (f === 0) continue;
89
112
  add(i, f);
@@ -99,6 +122,12 @@ export class DifferenceTerms {
99
122
  add(this.sI[4 * k + 2], s * (-z * a - d));
100
123
  add(this.sI[4 * k + 3], s * d);
101
124
  }
125
+ for (let k = 0; k < this.nx; k++) {
126
+ const s = row === this.xI[4 * k] ? 1 : row === this.xI[4 * k + 1] ? -1 : 0;
127
+ if (s === 0) continue;
128
+ add(this.xI[4 * k + 2], s * this.xC[k]);
129
+ add(this.xI[4 * k + 3], -s * this.xZ[k] * this.xC[k]);
130
+ }
102
131
  for (let q = 0; q < this.nd; q++) {
103
132
  const pL = this.dI[2 * q], pR = this.dI[2 * q + 1], s = row === pL ? 1 : row === pR ? -1 : 0;
104
133
  if (s === 0) continue;
@@ -114,10 +143,10 @@ export class DifferenceTerms {
114
143
  for (let j = 0; j < N; j++) x[j] = v[j];
115
144
  x[N] = 0;
116
145
  t.fill(0);
117
- const { nI, nP, nZ, nMass, nCharge } = this;
146
+ const { nR, nI, nP, nZ, nMass, nCharge } = this;
118
147
  for (let k = 0; k < this.nn; k++) {
119
148
  const i = nI[k], p = nP[k], w = x[i] - nZ[k] * x[p];
120
- t[i] += mass * nMass[k] * w;
149
+ t[nR[k]] += mass * nMass[k] * w;
121
150
  t[p] += steady * nCharge[k] * w;
122
151
  }
123
152
  if (steady !== 0) {
@@ -128,6 +157,12 @@ export class DifferenceTerms {
128
157
  t[iL] += dN;
129
158
  t[iR] -= dN;
130
159
  }
160
+ const { xI, xZ, xC } = this;
161
+ for (let k = 0; k < this.nx; k++) {
162
+ const dN = steady * xC[k] * (x[xI[4 * k + 2]] - xZ[k] * x[xI[4 * k + 3]]);
163
+ t[xI[4 * k]] += dN;
164
+ t[xI[4 * k + 1]] -= dN;
165
+ }
131
166
  for (let q = 0; q < this.nd; q++) {
132
167
  const pL = dI[2 * q], pR = dI[2 * q + 1], dD = -steady * dK[q] * (x[pR] - x[pL]);
133
168
  t[pL] += dD;