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/README.md +1 -1
- package/docs/alignment.md +6 -3
- package/docs/data.md +3 -1
- package/docs/device.md +87 -28
- package/docs/kit.md +47 -16
- package/docs/numerics.md +193 -114
- package/docs/reliability.md +15 -9
- package/docs/statistics.md +28 -4
- package/llms.txt +68 -38
- package/package.json +1 -1
- package/src/blockTridiagonal.js +12 -6
- package/src/checks.js +11 -4
- package/src/describe.js +46 -5
- package/src/device.js +33 -10
- package/src/index.js +19 -7
- package/src/particles.js +131 -55
- package/src/solution.js +145 -8
- package/src/solver.js +708 -260
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/particles.d.ts +3 -6
- package/types/solver.d.ts +43 -29
- package/types/stack.d.ts +6 -4
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.
|
|
32
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
33
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
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)
|
|
77
|
-
and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the
|
|
78
|
-
N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0),
|
|
79
|
-
6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists
|
|
80
|
-
`lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`,
|
|
81
|
-
mu0 }`. Only μ° differences within a reaction matter:
|
|
82
|
-
(against SHE, the library's μ°_H⁺ being 0). An
|
|
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
|
-
|
|
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.
|
|
197
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
198
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
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.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
a membrane
|
|
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.
|
|
312
|
-
console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.
|
|
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' })`
|
|
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 μ°)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
- **
|
|
714
|
-
|
|
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
|
|
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
package/src/blockTridiagonal.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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` +
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1304
|
-
const level = (i) =>
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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)
|