driftlet 0.14.0 → 0.16.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/llms.txt CHANGED
@@ -28,9 +28,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
28
28
  From a CDN, pin the version you tested against:
29
29
 
30
30
  ```js nocheck
31
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/index.js';
32
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/kit.js';
33
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/plot.js';
31
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
32
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
33
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
34
34
  ```
35
35
 
36
36
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -73,14 +73,15 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
73
73
  - **The library** is pieces with `species` and `materials`: `semiconductor('Si')` (Si, Ge, GaAs, at
74
74
  300 K), `aqueous(names)` (H⁺ Li⁺ Na⁺ K⁺ Ag⁺ Cu²⁺ Zn²⁺ Fe²⁺ Fe³⁺ OH⁻ Cl⁻ NO₃⁻ SO₄²⁻ in water, at 25
75
75
  °C; `{ epsr: 0 }` makes it strictly neutral, with no double layers), `metal('Ag')` (Ag Cu Au Al Zn
76
- Li Pt). A semiconductor's textbook numbers are in `SEMICONDUCTORS.Si` (`Eg` and `chi` in eV, `Nc`
77
- and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the material, e⁻'s and h⁺'s `cRef` are
78
- N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0), so n_i = √(N_c N_v) e^(−E_g/2RT),
79
- 6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists (`z: 0` for a neutral one):
80
- `lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`, `lib.materials.water.species['A-'] = { D,
81
- mu0 }`. Only μ° differences within a reaction matter: for a redox couple μ°_red − μ°_ox = −F·E°
82
- (against SHE, the library's μ°_H⁺ being 0). An insulator is a material with no species: `{
83
- species: [], materials: { SiO2: { epsr: 3.9, species: {} } } }`.
76
+ Li Pt), all from `driftlet/kit`. A semiconductor's textbook numbers are in `SEMICONDUCTORS.Si`
77
+ (`Eg` and `chi` in eV, `Nc` and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the
78
+ material, e⁻'s and h⁺'s `cRef` are N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0),
79
+ so n_i = √(N_c N_v) e^(−E_g/2RT), 6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists
80
+ (`z: 0` for a neutral one): `lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`,
81
+ `lib.materials.water.species['A-'] = { D, mu0 }`. Only μ° differences within a reaction matter:
82
+ for a redox couple μ°_red − μ°_ox = −F·E° (against SHE, the library's μ°_H⁺ being 0). An
83
+ insulator is a material with no species: `{ species: [], materials: { SiO2: { epsr: 3.9, species:
84
+ {} } } }`.
84
85
  - **Faces.** Between two different non-metals, give an alignment, `{ dipole }` or `{ step: {
85
86
  species: 'e-', value } }` (J/mol), or `phi: 'neutral'`. `vacuumDipole(def, { material, anchor:
86
87
  'e-', offset: χ }, …)` estimates a dipole from electron affinities (Anderson's rule); it reads
@@ -111,8 +112,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
111
112
  { tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
112
113
  srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
113
114
  injection. Light is a reaction from a reservoir (`photon = e- + h+`, uniform), or absorbed as
114
- it goes in,
115
- `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`.
115
+ it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`. To switch
116
+ it during a run, `set()` the device's `bulkReactions` (the state and the step size carry over).
116
117
  - **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
117
118
  at cRef: i₀ = nF·k0 there, and k0 = k°·cRef for a textbook k° in m/s (1 cm/s at 1 M is
118
119
  `k0: 10`).
@@ -145,6 +146,14 @@ const tK = D['K+'] / (D['K+'] + D['Cl-']), VT = (GAS_CONSTANT * 298.15) / FARADA
145
146
  console.log(`EMF ${(sol.terminals.right.V * 1e3).toFixed(3)} mV, 2t₊V_T ln 10 = ${(2 * tK * VT * Math.log(10) * 1e3).toFixed(3)} mV`); // 58.048, 58.047
146
147
  ```
147
148
 
149
+ An ion-exchange membrane is a layer whose material has its ions slower, with a `fixedCharge` (−X·F
150
+ for X mol/m³ of fixed anions), between layers of water. A bath against the membrane itself names
151
+ its own material, `bath(c, 'Cl-', 0, { material: 'water' })`, and meets it through a Donnan step.
152
+ An exchange or partition constant between the membrane and the water is a difference of μ°:
153
+ give the membrane's ions μ° shifted from the water's by s_i, and K = e^(−(s_Na − s_H)/RT) for
154
+ Na⁺ against H⁺ (a large K draws in salt too, unless the co-ion's s is raised). The membrane
155
+ demo's page script is a worked example.
156
+
148
157
  ## Template: a semiconductor junction
149
158
 
150
159
  ```js
@@ -193,9 +202,9 @@ a CDN needs a long virtual-time budget, 30 s.)
193
202
  <span id="readout"></span>
194
203
  <div id="figure"></div>
195
204
  <script type="module">
196
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/index.js';
197
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/kit.js';
198
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/plot.js';
205
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/index.js';
206
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/kit.js';
207
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.2/src/plot.js';
199
208
 
200
209
  const def = build({
201
210
  T: 300,
@@ -220,17 +229,15 @@ see "Reading level diagrams" below.
220
229
 
221
230
  For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
222
231
  mol/m² of a species, hopping at rates taken from the solution: on average their density is its
223
- concentration and their net crossing its flux. A friendly first figure for teaching, as long as
224
- the page says it samples the continuum solution and how much a dot is. Where a dot would be lost
225
- among millions (majority carriers, a bath), a cell over `cap` dots is a sea: shade it by
226
- `expected(name)`. Each frame: `swarm.step(dt)` in the device's time (about h²/D per hop, h a
227
- cell), then draw `swarm.dots` (`{ species, x }`) and `swarm.events` (`'unmade'` is a
228
- recombination; `'cross'` an ion through a membrane between two seas); on a new steady solution,
229
- `swarm.update(sol, { resample: true })`. A membrane's permeability (1e-8 m/s) is tiny beside
230
- D/h in the water (0.02 m/s for 100 nm cells), so an ion beside it hops ~1e6 times per crossing:
231
- a membrane page shows seas and crossing events, not walking ions.
232
- The pn demo's page script draws a diode's carriers on its band diagram this way, and the axon
233
- demo's the ions crossing its membrane (`cap: 0`, every cell a sea, so only the crossings).
232
+ concentration and their net crossing its flux. Say on the page that it samples the continuum
233
+ solution, and how much a dot is. Where a dot would be lost among millions (majority carriers, a
234
+ bath), a cell over `cap` dots is a sea: shade it by `expected(name)`. Each frame,
235
+ `swarm.step(dt)` in the device's time (about h²/D per hop, h a cell), then draw `swarm.dots`
236
+ (`{ species, x }`) and `swarm.events` (`'unmade'` is a recombination, `'cross'` an ion through a
237
+ membrane between two seas); on a new steady solution, `swarm.update(sol, { resample: true })`.
238
+ An ion beside a cell membrane hops ~1e6 times per crossing, so a membrane page shows seas and
239
+ crossing events, not walking ions. The demos' page scripts do this for a diode's carriers (pn),
240
+ ions crossing a nerve membrane (axon, `cap: 0`) and Donnan exclusion (membrane).
234
241
 
235
242
  ## Template: a solar cell
236
243
 
@@ -263,6 +270,15 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
263
270
  console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
264
271
  ```
265
272
 
273
+ In an organic cell, light makes excitons: a neutral species `X` in the donor, made by
274
+ `photogeneration({ ..., makes: 'X' })` and decaying by `{ equation: 'X = 0', kf: { donor: 1 / tau } }`.
275
+ They split at the donor/acceptor face by a reaction there, `X = h+(left) + e-(right)` with e⁻ and
276
+ h⁺ blocked at the face (the reaction moves them), and its reverse is charge-transfer
277
+ recombination; the face's `step` sets E_CT. With `alpha: 0` the splitting is a velocity
278
+ k0/cRef, a Robin condition on the exciton profile; with α > 0 it's k0/cRef · e^(α(E_X − E_CT)/RT).
279
+ The organic demo's page script is a worked example (J_sc against exciton diffusion theory, V_oc
280
+ against detailed balance).
281
+
266
282
  For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
267
283
  default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
268
284
  and `hysteresis(trace, turn)` to read the same figures from yours.
@@ -308,11 +324,14 @@ for (const Vg of [-2, -0.95, 0, 0.4, 2]) {
308
324
  console.log(`V_G ${Vg} V: C/C_ox ${(C / Cox).toFixed(3)}`); // 0.949, 0.701 (flat band), 0.267, 0.375, 0.973
309
325
  }
310
326
  // At flat band the silicon is a capacitor of ε/L_D, its Debye length's.
311
- const LD = Math.sqrt((11.7 * EPS0 * GAS_CONSTANT * 300) / (FARADAY * FARADAY * NA));
312
- console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.7 * EPS0))).toFixed(3)}`); // 0.699
327
+ const LD = Math.sqrt((11.9 * EPS0 * GAS_CONSTANT * 300) / (FARADAY * FARADAY * NA)); // (the kit's Si: ε 11.9)
328
+ console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.9 * EPS0))).toFixed(3)}`); // 0.701
313
329
  ```
314
330
 
315
- `dev.impedance([f], { terminal: 'left' })` gives the same C at low frequency. Draw it with
331
+ `dev.impedance([f], { terminal: 'left' })` (C = Im(1/Z)/ω, from `{ f, Z: { re, im } }`) gives
332
+ the same C at any frequency with the port; without it, only below the minority supply's knee
333
+ (generation, or diffusion from the back contact: 0.01 Hz and below), and the high-frequency C–V
334
+ above. Draw it with
316
335
  `bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
317
336
 
318
337
  ## Template: a Schottky diode
@@ -419,10 +438,12 @@ console.log(level(sol, silver)[mid] - level(sol, SHE, { standard: true })[mid]);
419
438
 
420
439
  A working electrode, a stagnant layer, and the stirred bulk as a bath whose terminal is its
421
440
  reference species (Cl⁻: an Ag/AgCl reference). An electrode's potential against SHE is its
422
- electrons' V minus φ in the bulk (with the library's μ°). The working electrode's current is
423
- positive when oxidising. The layer's resistance is in series (uncompensated IR: keep it thin, or
424
- the supporting salt strong); a 100:1 excess of salt still leaves the limiting current about 1%
425
- off pure diffusion.
441
+ electrons' V minus φ in the bulk (with the library's μ°), and a Cl⁻ terminal sits ~1.36 V above
442
+ that, so an electrode held at 0 V beside it is strongly anodic: `describe(def)` says where SHE
443
+ sits, and an offset of that much (`bath(c, 'Cl-', 0, { offset })`) makes the terminal read SHE.
444
+ The working electrode's current is positive when oxidising. The layer's resistance is in series
445
+ (uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of salt still
446
+ leaves the limiting current about 1% off pure diffusion.
426
447
 
427
448
  ```js
428
449
  import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
@@ -665,7 +686,9 @@ capacitance and gated channels to an outside given as a `bath` (the port's V is
665
686
  outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
666
687
  per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
667
688
  `ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
668
- its own port (an internode's just a small capacitance).
689
+ its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
690
+ the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
691
+ (scale h with √a). The stimulus is a current per volume, so a thinner axon needs it as 1/a.
669
692
 
670
693
  ```js
671
694
  import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
@@ -710,8 +733,15 @@ species }` (its flux) or `{ interface: f, gate }`.
710
733
 
711
734
  - **Events.** To stop at one (a cut-off voltage, a diode's storage time), advance in short calls
712
735
  and test after each: a driven current held past what the device can pass fails to converge.
713
- - **Storage.** A device that only stores what comes in (a battery host filling at a current) has
714
- no steady state: `advance()` it from its `c0`, don't `solve()` it (docs/statistics.md).
736
+ - **The first instants.** The first step is sized by error control (from 1e-4 of the interval),
737
+ so a fast start (an initial plateau, a charging spike) can fall inside it: give `dt0` (and
738
+ `dtMax`) to see it. A voltage step's first point carries the displacement that charges the bulk
739
+ at once (a spike of ~1e4 A/m² for ~1e-10 s across a cell's Stern layers): leave it out of
740
+ integrals of I²R.
741
+ - **Storage.** A device that only stores what comes in (a battery host filling at a current, a
742
+ gate charging) has no steady state: `advance()` it from its `c0`, don't `solve()` it
743
+ (docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
744
+ battery sits at the OCV of its state of charge, a floating gate keeps its charge.
715
745
  - **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
716
746
  { terminal })` the small-signal Z(f) about the steady state, in Ω·m².
717
747
 
@@ -803,7 +833,7 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
803
833
  everything floating (hold one terminal at a `V`), a species that reaches no contact without a
804
834
  `c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
805
835
  `'2 e-'`, a current drive of the wrong sign. `interfaces[f]` is the stack's f-th face.
806
- - **A gate as a contact on an oxide.** It fails as `'e-' is blocked at this contact`: make the
836
+ - **A gate as a contact on an oxide.** It fails as `'e-' is absent from the end material 'SiO2'`: make the
807
837
  gate a metal layer with a capacitive face, as in the MOS template.
808
838
  - **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
809
839
  - **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.14.0",
3
+ "version": "0.16.2",
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": {
@@ -110,12 +110,17 @@ export class BlockTridiagonal {
110
110
  if (i === 0 || sizes[i - 1] === 0) {
111
111
  for (let k = 0; k < m * m; k++) lu[o + k] = B[o + k];
112
112
  } else {
113
+ // Row by row, skipping A's zeros (a gate or a local unknown has none to its neighbours):
114
+ // the same sums, subtracted in the same order.
113
115
  const mp = sizes[i - 1], oa = offA[i], op = offC[i - 1]; // C'_{i−1}: mp × m
114
116
  for (let r = 0; r < m; r++) {
115
- for (let c = 0; c < m; c++) {
116
- let s = B[o + r * m + c];
117
- for (let k = 0; k < mp; k++) s -= A[oa + r * mp + k] * cp[op + k * m + c];
118
- lu[o + r * m + c] = s;
117
+ const or = o + r * m;
118
+ for (let c = 0; c < m; c++) lu[or + c] = B[or + c];
119
+ for (let k = 0; k < mp; k++) {
120
+ const a = A[oa + r * mp + k];
121
+ if (a === 0) continue;
122
+ const ok = op + k * m;
123
+ for (let c = 0; c < m; c++) lu[or + c] -= a * cp[ok + c];
119
124
  }
120
125
  }
121
126
  }
@@ -130,8 +135,9 @@ export class BlockTridiagonal {
130
135
  if (i < n - 1) {
131
136
  const mn = sizes[i + 1], oc = offC[i];
132
137
  for (let c = 0; c < mn; c++) {
133
- for (let r = 0; r < m; r++) tmp[r] = C[oc + r * mn + c];
134
- luSolve(lu, o, piv, offX[i], m, tmp);
138
+ let any = false;
139
+ for (let r = 0; r < m; r++) if ((tmp[r] = C[oc + r * mn + c]) !== 0) any = true;
140
+ if (any) luSolve(lu, o, piv, offX[i], m, tmp); // (a zero column stays zero)
135
141
  for (let r = 0; r < m; r++) cp[oc + r * mn + c] = tmp[r];
136
142
  }
137
143
  }
package/src/checks.js CHANGED
@@ -110,9 +110,12 @@ function balance(device, sol) {
110
110
  else ledgers[l].terms.push({ what, rate });
111
111
  };
112
112
  const last = regions.length - 1;
113
+ // (A contact that blocks a species is no source of it: what its flux reads there is the end
114
+ // box's own imbalance, which the ledger must show, not balance.)
115
+ const through = (side, i) => model.contacts[side].species[i].type !== 'blocked';
113
116
  species.forEach((sp, i) => {
114
- add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
115
- add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
117
+ if (through('left', i)) add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
118
+ if (through('right', i)) add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
116
119
  model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
117
120
  });
118
121
  model.reactions.forEach((rx, k) => {
@@ -259,8 +262,12 @@ function gridCheck(device, sol, tol, gross) {
259
262
  const diffs = [];
260
263
  // The current, against the largest current any species carries (so at open circuit, or in
261
264
  // equilibrium, round-off isn't read as a change).
262
- const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
263
- if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
265
+ // A current too small for the ledgers to call moving (a diode at low bias: 1e-4 A/m² against
266
+ // majority carriers that could carry 6e8) is compared still, above a floor at 1e-16 of what the
267
+ // species could carry, above what solves resolve (~1e-19 of it in a GaAs junction).
268
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
269
+ const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross, floorI);
270
+ if (gross > 0 || Math.max(Math.abs(sol.current), Math.abs(other.current)) > floorI) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
264
271
  // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
265
272
  const VT = model.RT / FARADAY;
266
273
  model.terminals.forEach((t, k) => {
package/src/describe.js CHANGED
@@ -49,6 +49,10 @@ export function unitWarnings(def) {
49
49
  if (c > 1e6) warn(path, `${num(c)} mol/m³ is over 1000 M; concentrations are mol/m³ (units.molar, and units.perCm3 for carriers)`);
50
50
  };
51
51
  (def.species ?? []).forEach((sp, i) => sp?.cRef !== undefined && conc(`species[${i}].cRef`, sp.cRef));
52
+ // SRH's n₁ is a concentration too (a value per cm³ typed in is 6e5 times too large)
53
+ (def.bulkReactions ?? []).forEach((rx, k) => {
54
+ for (const [m, law] of Object.entries(isObject(rx?.srh) ? rx.srh : {})) if (isObject(law) && law.n1 !== undefined) conc(`bulkReactions[${k}].srh.${m}.n1`, law.n1);
55
+ });
52
56
  for (const [mname, mat] of Object.entries(def.materials ?? {})) {
53
57
  if (!isObject(mat)) continue;
54
58
  const path = `materials.${mname}`;
@@ -92,13 +96,19 @@ export function unitWarnings(def) {
92
96
  };
93
97
  const kinetics = (path, reactions) => {
94
98
  (reactions ?? []).forEach((rx, k) => {
95
- if (rx?.k0 > 10) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
99
+ // (an exchange current only where electrons take part: an ion transfer's k0 can be large)
100
+ const said = rx?.equation ?? (rx?.left || rx?.right ? JSON.stringify([rx.left, rx.right]) : null);
101
+ const electrons = said === null || /(^|[^\w])e-($|[^\w])/.test(said);
102
+ if (rx?.k0 > 10 && electrons) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
96
103
  });
97
104
  };
98
105
  (def.interfaces ?? []).forEach((f, k) => {
99
106
  if (!isObject(f)) return;
100
107
  capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
101
- kinetics(`interfaces[${k}]`, f.reactions);
108
+ // (an exchange current is an electrode's: a metal on one side, or sides not yet known; a
109
+ // splitting exciton's k0, between two semiconductors, is a velocity times cRef)
110
+ const sides = [def.regions?.[k], def.regions?.[k + 1]].map((r) => (isObject(r) ? def.materials?.[r.material] : undefined));
111
+ if (sides.some((m) => m === undefined || isObject(m?.conductor))) kinetics(`interfaces[${k}]`, f.reactions);
102
112
  });
103
113
  (def.ports ?? []).forEach((p, k) => {
104
114
  if (!isObject(p)) return;
@@ -149,7 +159,12 @@ export function describe(def) {
149
159
  return;
150
160
  }
151
161
  const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
152
- if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
162
+ // (donors and acceptors where there are electrons; an ion exchanger's charge in mol/m³)
163
+ const doped = species.some((sp, i) => mat.present[i] && (sp.name === 'e-' || sp.name === 'h+'));
164
+ if (reg.fixedCharge !== 0) {
165
+ const X = Math.abs(reg.fixedCharge) / FARADAY;
166
+ parts.push(doped ? `fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(X)}` : `fixed charge ${reg.fixedCharge > 0 ? '+' : '−'}${num(X)} mol/m³`);
167
+ }
153
168
  // The screening concentration: Σ z²c from the initial composition, plus the carriers that
154
169
  // balance the fixed charge (doping), which c0 doesn't list.
155
170
  let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
@@ -216,8 +231,34 @@ export function describe(def) {
216
231
  const cdef = def.contacts?.[side];
217
232
  const ct = model.contacts[side];
218
233
  const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
219
- const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
220
- lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
234
+ // (a bath without a reference species: its V is its φ)
235
+ const term = ct.she && !ct.she.reference ? ' (terminal: the bath φ)' : ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
236
+ // A bath read through a reference species: where the SHE level sits against its terminal,
237
+ // so a held electrode's V reads on the familiar scale (a Cl⁻ reference on table μ° puts it
238
+ // 1.37 V below, a surprise to anyone expecting 0 V to mean SHE).
239
+ let she = '';
240
+ if (ct.she?.reference) {
241
+ const d = ct.she.shift, at = Math.abs(d) < 5e-4 ? 'V' : `V ${d < 0 ? '−' : '+'} ${Math.abs(d).toFixed(3)} V`;
242
+ she = ct.she.exact ? `; the bath's SHE level is at ${at}` : `; the bath's φ is at ${at} (on the usual tables' μ°, where μ°_H⁺ = 0, that's its SHE level)`;
243
+ }
244
+ lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}${she}`);
245
+ // An outside phase out of equilibrium with a reaction that runs at the contact: the reaction
246
+ // then runs there for ever, a current at zero bias (H⁺ and OH⁻ given off Kw in a bath, say).
247
+ const end = regions[side === 'left' ? 0 : regions.length - 1].material;
248
+ model.reactions.forEach((rx, k) => {
249
+ if (!(rx.kf[end] > 0) || rx.kfProfile[end]) return; // (light, a profile, is meant to drive)
250
+ const parts = [...rx.reactants.map(({ i, nu }) => [i, -nu]), ...rx.products.map(({ i, nu }) => [i, nu])];
251
+ const level = (i) => {
252
+ const l = ct.species[i];
253
+ return l.type !== 'equilibrium' ? NaN : species[i].z === 0 ? l.mu : l.offset === undefined ? NaN : species[i].z * FARADAY * l.offset;
254
+ };
255
+ // A/RT from the outside's levels (the terminal voltage cancels: Σ ν z = 0)
256
+ const a = rx.fixedA - parts.reduce((sum, [i, nu]) => sum + nu * level(i), 0) / RT;
257
+ if (Math.abs(a) > 1e-3) {
258
+ const rdef = def.bulkReactions[k];
259
+ warnings.push(`contacts.${side}: the outside isn't in equilibrium with bulkReactions[${k}] (${rdef.equation ?? equation(rdef.nu)}; A = ${num(a)} RT there), so it runs at the contact for ever, a current even at zero bias. Unless that's meant (a source), give the outside a composition in equilibrium with it (H⁺ and OH⁻ at √K_w, say)`);
260
+ }
261
+ });
221
262
  }
222
263
  (def.ports ?? []).forEach((p, k) => {
223
264
  const port = model.ports[k];
package/src/device.js CHANGED
@@ -219,9 +219,12 @@ export function normalizeDevice(def) {
219
219
  const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
220
220
  species.forEach((sp, i) => {
221
221
  const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
222
+ // (a port naming it as its terminal without reactions: a capacitance alone needs none)
223
+ const named = (def.ports ?? []).find((p) => p?.terminal === sp.name);
222
224
  need(
223
225
  materials.some((m) => m.present[i]) || outside(sp),
224
- `species '${sp.name}' is not present in any material` + (reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
226
+ `species '${sp.name}' is not present in any material` +
227
+ (named ? `: it is the terminal of port ${JSON.stringify(named.name ?? '')}, which can hold a carrier from outside the device only with reactions. A port with only a capacitance needs no terminal: drop it (and the species)` : reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
225
228
  );
226
229
  });
227
230
 
@@ -1158,8 +1161,10 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
1158
1161
  return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
1159
1162
  }
1160
1163
 
1164
+ // A species present on both sides crosses freely; one that can't move on either (D = 0, trap
1165
+ // states, say) is blocked: held level, its two edge boxes would pass whatever flux that takes.
1161
1166
  function defaultInterfaceLinks(matL, matR, species) {
1162
- return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] ? 'equilibrium' : 'blocked' }));
1167
+ return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] && (matL.D[i] > 0 || matR.D[i] > 0) ? 'equilibrium' : 'blocked' }));
1163
1168
  }
1164
1169
 
1165
1170
  function transferCoefficient(v, path) {
@@ -1197,6 +1202,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1197
1202
  const links = species.map(() => ({ type: 'blocked' }));
1198
1203
  let phi = { type: 'neutral' };
1199
1204
  let terminal = null;
1205
+ let she = null; // a bath's SHE level against V
1200
1206
  if (cdef === undefined || cdef === null) return { drive: normalizeDrive({}, path), species: links, phi, terminal };
1201
1207
  need(isObject(cdef), `${path} must be an object`);
1202
1208
  fields(cdef, path, ['V', 'I', 'R', 'terminal', 'species', 'bath', 'phi', 'zeroCharge']);
@@ -1274,7 +1280,13 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1274
1280
  need(cdef.species === undefined, `${path}: give either bath or species links, not both`);
1275
1281
  const bath = cdef.bath;
1276
1282
  need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
1277
- fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
1283
+ fields(bath, `${path}.bath`, ['c', 'reference', 'offset', 'material']);
1284
+ // The bath is the end material at that composition (balancing any fixed charge there), unless
1285
+ // it names a material of its own: a solution outside an ion exchanger, a doped or host region,
1286
+ // whose levels it sets there through a Donnan step at the end node.
1287
+ const own = bath.material !== undefined;
1288
+ const bm = own ? materials.find((m) => m.name === bath.material) : mat;
1289
+ need(bm && !bm.conductor && !bm.phiFree, `${path}.bath.material: ${JSON.stringify(bath.material)} is not a material with ions in it (one of ${materials.filter((m) => !m.conductor).map((m) => m.name).join(', ')})`);
1278
1290
  // Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
1279
1291
  // convention), and the terminal is the first charged species in it.
1280
1292
  const byPhi = bath.reference === undefined;
@@ -1288,24 +1300,35 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1288
1300
  need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
1289
1301
  need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
1290
1302
  terminal = byPhi && terminal !== null ? terminal : r;
1291
- let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
1303
+ const fixed = own ? 0 : region.fixedCharge / FARADAY;
1304
+ let charge = fixed, scale = Math.abs(charge);
1292
1305
  const cb = new Float64Array(species.length);
1293
1306
  for (const [sname, v] of Object.entries(bath.c)) {
1294
1307
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
1295
1308
  const i = speciesIndex.get(sname);
1296
- need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from the end material '${mat.name}'`);
1309
+ need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
1297
1310
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
1298
1311
  charge += species[i].z * cb[i];
1299
1312
  scale += Math.abs(species[i].z) * cb[i];
1300
1313
  }
1301
- need(Math.abs(charge) <= 1e-9 * scale, `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`);
1314
+ need(
1315
+ Math.abs(charge) <= 1e-9 * scale,
1316
+ fixed !== 0
1317
+ ? `${path}.bath: the bath is the end material '${mat.name}' at this composition, and with its fixed charge (${fixed} mol/m³ of charge) it is not neutral (net ${charge}). ` +
1318
+ `For a solution outside a charged region, give the solution's material (bath.material), whose composition is then neutral on its own`
1319
+ : `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`,
1320
+ );
1302
1321
  // The reference species pins the bath's φ; every other species follows from composition.
1303
- const zb = bathZeta(mat, cb, region.background);
1304
- const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1322
+ const zb = bathZeta(bm, cb, own ? 0 : region.background);
1323
+ const level = (i) => bm.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1305
1324
  const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
1306
1325
  const beta = byPhi ? 0 : refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
1326
+ // Where the bath's SHE level sits against V (for describe()): φ + μ°_H⁺/F with H⁺ in the
1327
+ // bath, else φ on the usual tables' convention (μ°_H⁺ = 0), which then is only a reading.
1328
+ const h = speciesIndex.get('H+');
1329
+ she = { shift: beta + (h !== undefined && bm.present[h] ? bm.mu0[h] / FARADAY : 0), exact: h !== undefined && bm.present[h], reference: !byPhi };
1307
1330
  for (let i = 0; i < species.length; i++) {
1308
- if (!(cb[i] > 0)) continue;
1331
+ if (!(cb[i] > 0) || !mat.present[i]) continue; // (a bath of its own may hold species the end lacks)
1309
1332
  links[i] =
1310
1333
  species[i].z === 0
1311
1334
  ? { type: 'equilibrium', mu: level(i) }
@@ -1343,5 +1366,5 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1343
1366
  if (terminal !== null) {
1344
1367
  need(links[terminal].type !== 'blocked', `${path}.terminal: '${species[terminal].name}' is blocked at this contact`);
1345
1368
  }
1346
- return { drive, species: links, phi, terminal };
1369
+ return { drive, species: links, phi, terminal, ...(she ? { she } : {}) };
1347
1370
  }
package/src/index.js CHANGED
@@ -106,15 +106,27 @@ export class Device {
106
106
  this.def = def;
107
107
  this.model = model;
108
108
  this._solver = solver;
109
- if (old && old.u.length === solver.u.length && old.n === solver.n) {
110
- solver.u.set(old.u);
111
- solver.uLo.set(old.uLo);
112
- // A node newly on an electrode's surface (a window moved, a surface added) starts at its
113
- // starting coverages, not at whatever its slots held.
114
- const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
115
- for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
109
+ // The state carries over where the unknowns line up; on a new grid (the same regions, faces
110
+ // and terminals), interpolated onto it. (Started cold instead, a floating gate lost its
111
+ // charge to a refined grid.)
112
+ const sameUnknowns = old && old.u.length === solver.u.length && old.n === solver.n;
113
+ const regridded = old && !sameUnknowns && old.M === solver.M && old.n === solver.n && old.nFaces === solver.nFaces && old.terms.length === solver.terms.length && old.model.grid.regionStart.length === model.grid.regionStart.length;
114
+ if (sameUnknowns || regridded) {
115
+ if (sameUnknowns) {
116
+ solver.u.set(old.u);
117
+ solver.uLo.set(old.uLo);
118
+ // A node newly on an electrode's surface (a window moved, a surface added) starts at its
119
+ // starting coverages, not at whatever its slots held.
120
+ const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
121
+ for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
122
+ } else solver._warmFrom(old);
116
123
  solver.computeConcentrations();
117
124
  solver.time = old.time;
125
+ // Held sources read at the same time as before, and an adaptive transient goes on at the
126
+ // step size it had reached (its order restarts, as at any change of the equations).
127
+ solver.sourceTime = old.sourceTime;
128
+ solver._refreshSources();
129
+ solver.dtNext = old.dtNext;
118
130
  solver.contactDEnd = old.contactDEnd;
119
131
  if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
120
132
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)