driftlet 1.1.1 → 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 +1 -1
- package/docs/conventions.md +2 -2
- package/docs/data.md +4 -1
- package/docs/device.md +6 -5
- package/docs/numerics.md +13 -5
- package/docs/reliability.md +23 -0
- package/docs/statistics.md +1 -0
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/channels.js +12 -7
- package/src/checks.js +6 -4
- package/src/data.js +1 -1
- package/src/describe.js +1 -1
- package/src/device.js +3 -4
- package/src/devices.js +9 -1
- package/src/equation.js +14 -3
- package/src/index.js +6 -3
- package/src/junction.js +9 -3
- package/src/kit.js +1 -1
- package/src/live.js +14 -9
- package/src/particles.js +4 -1
- package/src/plot.js +8 -4
- package/src/solution.js +1 -0
- package/src/solver.js +167 -44
- package/src/stack.js +5 -1
- package/src/statistics.js +31 -10
- package/src/traces.js +10 -7
- package/src/types.js +96 -29
- package/types/index.d.ts +14 -5
- package/types/kit.d.ts +1 -1
- package/types/plot.d.ts +2 -2
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +7 -1
- package/types/types.d.ts +179 -26
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.
|
|
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
|
|
package/docs/conventions.md
CHANGED
|
@@ -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,
|
|
137
|
-
internal faces. Reactions sit at faces between regions: an electrode is a conductor region with
|
|
136
|
+
blocked, conductance, exchange, velocity; and for $`\phi`$, bulk, neutral, capacitive, pinned)
|
|
137
|
+
are those of internal faces, with the outside phase as the other side. Reactions sit at faces between regions: an electrode is a conductor region with
|
|
138
138
|
reactions at its face.
|
|
139
139
|
|
|
140
140
|
- **Species in equilibrium** with the outside phase sit at $`V_i = V + \mathtt{offset}_i`$. The
|
package/docs/data.md
CHANGED
|
@@ -25,7 +25,10 @@ The data are 25 °C's, and the material says so (`T: 298.15`): a device at anoth
|
|
|
25
25
|
warns that they don't hold there. Its μ° (so equilibrium constants and electrode potentials),
|
|
26
26
|
permittivity and diffusivities would all move: $`K_w`$ by ~6× between 6 and 25 °C. Scaling them
|
|
27
27
|
(μ° by the standard entropies, ε, D by water's viscosity) is planned, not done. Where only
|
|
28
|
-
transport between solutions of the same ions matters (a membrane's channels), μ° cancels
|
|
28
|
+
transport between solutions of the same ions matters (a membrane's channels), μ° cancels; if the
|
|
29
|
+
D don't matter either, taking the data as another temperature's is a fair approximation, made
|
|
30
|
+
by setting the material's `T` (the cell and axon demos do, having checked that D scaled by
|
|
31
|
+
T/η moves nothing they show).
|
|
29
32
|
|
|
30
33
|
| Ion | z | D (10⁻⁹ m²/s) | $`\mu^\circ`$ (kJ/mol) |
|
|
31
34
|
|---|---|---|---|
|
package/docs/device.md
CHANGED
|
@@ -516,8 +516,8 @@ background carriers do). A solution against a charged end region (an ion exchang
|
|
|
516
516
|
its own material, `bath: { c, material: 'water' }`: its composition is then neutral by itself, its
|
|
517
517
|
levels are that material's, and the end node takes them through a Donnan step.
|
|
518
518
|
|
|
519
|
-
**`phi`**, required whenever any species
|
|
520
|
-
`'
|
|
519
|
+
**`phi`**, required whenever any species connects at the contact (default `'neutral'`, or
|
|
520
|
+
`'bulk'` with a bath):
|
|
521
521
|
- `'bulk'`: the end node is plain bulk, locally neutral, with no double layer at the contact
|
|
522
522
|
(ohmic contacts, baths). The outside takes whatever surface charge that needs. A double layer
|
|
523
523
|
or depletion region that reaches it is cut off there (σ 58% low for a crowded layer 5 Debye
|
|
@@ -872,7 +872,8 @@ advance it, `advance()` and `step()` do.
|
|
|
872
872
|
|
|
873
873
|
`{ hmin, hmax, ratio = 1.2, minCells = 8 }`, per device, overridable per region. With no grid
|
|
874
874
|
options at all, each region is graded from `hmin` = 1/1000 to `hmax` = 1/20 of its length. Cells grow
|
|
875
|
-
geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly
|
|
875
|
+
geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly
|
|
876
|
+
(`hLeft` and `hRight` set one end's size instead, for a region fine at one end only). Each
|
|
876
877
|
region boundary becomes a pair of nodes at the same x, one per side. The grid is never refined
|
|
877
878
|
automatically. If a double layer that the model resolves is coarser than the local Debye
|
|
878
879
|
length, the solution's `warnings` say so. That's a floor, not an accuracy: a strongly polarised
|
|
@@ -1061,12 +1062,12 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
|
|
|
1061
1062
|
| `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
|
|
1062
1063
|
| `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
|
|
1063
1064
|
| `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
|
|
1064
|
-
| `charge` | total charge in the device, C/m² (C through a cross-section) |
|
|
1065
|
+
| `charge` | total charge in the device, C/m² (C through a cross-section): its regions' space charge and its faces' sheet charges, not the charge on a capacitive contact's or port's plates, so under a gate's bias it's the gate's opposite |
|
|
1065
1066
|
| `conservation` | per species stretch: amount, reference, intake through contacts, and drift, relative: (amount − reference − intake) over the largest of them |
|
|
1066
1067
|
| `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, a material's data for another temperature, and for a failed solve, why (a driven current it can't pass, where the system is nearly singular). A failed steady solve's state isn't a solution, so the warnings about a solution's accuracy (the grid, double layers, space charge reaching a contact) are left out of it |
|
|
1067
1068
|
| `converged`, `iterations`, `steps`, `substeps`, `history`, `time`, `T` | solver bookkeeping, and the temperature |
|
|
1068
1069
|
| `steady` | whether this is a converged steady state (from `solve()`), not a transient's: what `check()` in the kit decides its checks by |
|
|
1069
|
-
| `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step |
|
|
1070
|
+
| `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step, and `probes` if asked |
|
|
1070
1071
|
|
|
1071
1072
|
A stretch is a run of regions in which a species is present and connected. Its `drift`
|
|
1072
1073
|
compares its amount with the reference amount plus everything that came in through the
|
package/docs/numerics.md
CHANGED
|
@@ -725,13 +725,19 @@ the terms that cancel:
|
|
|
725
725
|
charging a capacitance (a gate, a port's capacitance) is the same, its charge in place of an
|
|
726
726
|
amount; and an electrode port's reactions draw on what the electrode holds, its charge
|
|
727
727
|
$`Q/(zF)`$ a column of its own under a current, so a port at no current reducing a closed
|
|
728
|
-
M⁺ (M⁺ + e⁻ = M(s)) keeps M⁺ + Q/F, or with no capacitance, M⁺ alone.
|
|
729
|
-
|
|
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.
|
|
730
735
|
- an **immobile** combination (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) keeps it
|
|
731
736
|
node by node, since nothing carries it anywhere, as do an electrode's surface species
|
|
732
737
|
(adsorbates that only turn into each other keep their total on every site), its row local,
|
|
733
|
-
one per node in its own block
|
|
734
|
-
|
|
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
|
|
735
741
|
stretches with a moving one (the electrons the traps hold, $`e^- - X^0`$, in a closed film) is
|
|
736
742
|
an amount like any other, its immobile parts summed over their nodes;
|
|
737
743
|
- a **floating metal** keeps the charge on its faces, its sheets' displacement.
|
|
@@ -796,7 +802,9 @@ the terms that cancel:
|
|
|
796
802
|
rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
|
|
797
803
|
reaction first, and a row of one of its own species where one is left, never one picked by where
|
|
798
804
|
the species are listed (the rows had differed with the species reversed, and an electrode's
|
|
799
|
-
equilibrium solved in one listing and failed in the other).
|
|
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
|
|
800
808
|
space with that reaction's row left out, any conserved amount added to one being as good (its
|
|
801
809
|
summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
|
|
802
810
|
beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
|
package/docs/reliability.md
CHANGED
|
@@ -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
|
package/docs/statistics.md
CHANGED
|
@@ -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.
|
|
34
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.
|
|
35
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.
|
|
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.
|
|
223
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.
|
|
224
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.${
|
|
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,
|
|
94
|
-
[K]: { type: 'permeability', P: P(K, 1,
|
|
95
|
-
[leak]: { type: 'permeability', P: P(leak, leakZ,
|
|
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
|
-
|
|
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:
|
|
379
|
+
for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: unitsOf(model).amount });
|
|
378
380
|
// Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
|
|
379
381
|
// (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
|
|
380
382
|
// steady: look. Beyond: one of them isn't a steady state.
|
package/src/data.js
CHANGED
|
@@ -185,7 +185,7 @@ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name =
|
|
|
185
185
|
if (typeof name !== 'string' || !/^[A-Za-z][\w()]*$/.test(name)) fail(`trap: name must be a plain name for the trap (its states are name0 and name- or name+), got ${JSON.stringify(name)}`);
|
|
186
186
|
const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
|
|
187
187
|
const cRefH = ho.cRef ?? lib.species.find((sp) => sp.name === 'h+')?.cRef;
|
|
188
|
-
const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0
|
|
188
|
+
const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 + el.mu0) / RT));
|
|
189
189
|
const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
|
|
190
190
|
// The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
|
|
191
191
|
const [empty, full] = charge === 'acceptor' ? [`${name}0`, `${name}-`] : [`${name}+`, `${name}0`];
|
package/src/describe.js
CHANGED
|
@@ -265,7 +265,7 @@ export function describe(def) {
|
|
|
265
265
|
lines.push(` port ${port.name}: ${drive(p, model.geometry.type === 'planar')}, in ${regions[port.region].name}`);
|
|
266
266
|
// an electrode spread through the window
|
|
267
267
|
if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
|
|
268
|
-
|
|
268
|
+
(p.reactions ?? []).forEach((rx, j) => lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${port.reactions[j].alpha}${rx.bare ? ', on bare metal' : ''}`)); // (α as used: 0.5 if not given)
|
|
269
269
|
if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
|
|
270
270
|
if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
|
|
271
271
|
});
|
package/src/device.js
CHANGED
|
@@ -18,7 +18,7 @@ export { DeviceError };
|
|
|
18
18
|
const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange', 'velocity']);
|
|
19
19
|
const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
|
|
20
20
|
const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
|
|
21
|
-
const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
|
|
21
|
+
const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells', 'hLeft', 'hRight'];
|
|
22
22
|
// The fields of each kind of species link (to an outside phase, or across a face).
|
|
23
23
|
const LINK_FIELDS = {
|
|
24
24
|
blocked: ['type'],
|
|
@@ -704,7 +704,7 @@ export function nextBreakpoint(src, t) {
|
|
|
704
704
|
if (src.value !== undefined) return Infinity;
|
|
705
705
|
const { t: ts, repeat } = src, n = ts.length;
|
|
706
706
|
if (!repeat) {
|
|
707
|
-
for (let k = 0; k < n; k++) if (ts[k] > t
|
|
707
|
+
for (let k = 0; k < n; k++) if (ts[k] > t + 1e-12 * Math.abs(t) + 1e-300) return ts[k]; // (t·(1 + ε) is below t for t < 0)
|
|
708
708
|
return Infinity;
|
|
709
709
|
}
|
|
710
710
|
const T = ts[n - 1] - ts[0], cycle = Math.floor((t - ts[0]) / T);
|
|
@@ -1255,7 +1255,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1255
1255
|
}
|
|
1256
1256
|
if (link.type === 'conductance') {
|
|
1257
1257
|
// Ohmic exchange with an outside reservoir at V_i = V + offset: J = G·(V_out − V_i).
|
|
1258
|
-
need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
|
|
1258
|
+
need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species (a neutral one exchanges by { type: 'exchange', k, mu })`);
|
|
1259
1259
|
const offset = link.offset ?? (terminal === i ? 0 : undefined);
|
|
1260
1260
|
need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir`);
|
|
1261
1261
|
links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
|
|
@@ -1283,7 +1283,6 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1283
1283
|
links[i] = { type: 'exchange', k: positive(link.k, `${lpath}.k (mol/(m²·s))`), mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
|
|
1284
1284
|
continue;
|
|
1285
1285
|
}
|
|
1286
|
-
if (link.type === 'conductance') need(species[i].z !== 0, `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
|
|
1287
1286
|
if (link.type === 'equilibrium') {
|
|
1288
1287
|
// In equilibrium with the outside phase, whose levels are known: a Dirichlet condition.
|
|
1289
1288
|
if (species[i].z === 0) {
|
package/src/devices.js
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import { AVOGADRO, FARADAY, GAS_CONSTANT, units } from './constants.js';
|
|
6
6
|
import { photogeneration } from './sources.js';
|
|
7
7
|
import { drive } from './stack.js';
|
|
8
|
-
import { checkOptions } from './errors.js';
|
|
8
|
+
import { DeviceError, checkOptions, shown } from './errors.js';
|
|
9
|
+
|
|
10
|
+
const need = (ok, message) => {
|
|
11
|
+
if (!ok) throw new DeviceError(message);
|
|
12
|
+
};
|
|
9
13
|
|
|
10
14
|
const m3 = (x) => x / AVOGADRO, eV = units.eV;
|
|
11
15
|
|
|
@@ -35,6 +39,8 @@ const P = {
|
|
|
35
39
|
*/
|
|
36
40
|
export function perovskiteCell({ V = 1.2, light = 1, bulkSRH = true } = {}) {
|
|
37
41
|
checkOptions('perovskiteCell', arguments[0], ['V', 'light', 'bulkSRH']);
|
|
42
|
+
need(Number.isFinite(light) && light >= 0, `perovskiteCell: light must be the illumination in suns, ≥ 0, got ${shown(light)}`);
|
|
43
|
+
need(typeof bulkSRH === 'boolean', `perovskiteCell: bulkSRH must be true or false, got ${shown(bulkSRH)}`);
|
|
38
44
|
const { T } = P, VT = (GAS_CONSTANT * T) / FARADAY;
|
|
39
45
|
const ni = Math.sqrt(P.gc * P.gv) * Math.exp(-(P.Ec - P.Ev) / (2 * VT));
|
|
40
46
|
const n0 = P.gc * Math.exp((P.EcE + VT * Math.log(P.dE / P.gcE) - P.Ec) / VT); // the perovskite's n at the TiO₂'s Fermi level
|
|
@@ -124,6 +130,8 @@ export const PEROVSKITE_SCANS = Object.freeze({
|
|
|
124
130
|
* @param {number} turn s
|
|
125
131
|
*/
|
|
126
132
|
export function hysteresis(trace, turn) {
|
|
133
|
+
need(trace && trace.t?.length > 0 && trace.voltage?.length === trace.t.length && trace.current?.length === trace.t.length, 'hysteresis: give a trace { t, voltage, current } (an advance()\'s, say)');
|
|
134
|
+
need(Number.isFinite(turn) && turn > trace.t[0] && turn < trace.t[trace.t.length - 1], `hysteresis: turn must be the time the scan turns, inside the trace (${shown(trace.t[0])} to ${shown(trace.t[trace.t.length - 1])} s), got ${shown(turn)}`);
|
|
127
135
|
const out = {};
|
|
128
136
|
for (const [name, on] of [['rev', (t) => t <= turn], ['fwd', (t) => t >= turn]]) {
|
|
129
137
|
const pts = [];
|
package/src/equation.js
CHANGED
|
@@ -79,14 +79,21 @@ export function stoichiometry(text, path = 'equation') {
|
|
|
79
79
|
*/
|
|
80
80
|
export function faceSides(terms, left, right, fixed, path) {
|
|
81
81
|
const sides = { left: {}, right: {} };
|
|
82
|
-
const put = (side, name, nu) => (sides[side][name] = (sides[side][name] ?? 0) + nu);
|
|
83
82
|
const fixedSide = right.conductor && !left.conductor ? 'right' : 'left';
|
|
83
|
+
const signs = {};
|
|
84
|
+
const put = (side, name, nu) => {
|
|
85
|
+
const key = `${side}:${name}`;
|
|
86
|
+
if (signs[key] !== undefined && signs[key] !== Math.sign(nu)) fail(`${path}: '${name}' (${side}) appears on both sides of the equation; give the net reaction`);
|
|
87
|
+
signs[key] = Math.sign(nu);
|
|
88
|
+
sides[side][name] = (sides[side][name] ?? 0) + nu;
|
|
89
|
+
};
|
|
84
90
|
for (const t of terms) {
|
|
85
|
-
|
|
91
|
+
// (a species the side holds is that species, given in fixed or not, as for bulk reactions)
|
|
92
|
+
const inL = left.holds(t.name), inR = right.holds(t.name);
|
|
93
|
+
if (!inL && !inR && fixed?.[t.name] !== undefined) {
|
|
86
94
|
put(fixedSide, t.name, t.nu);
|
|
87
95
|
continue;
|
|
88
96
|
}
|
|
89
|
-
const inL = left.holds(t.name), inR = right.holds(t.name);
|
|
90
97
|
if (inL && inR) {
|
|
91
98
|
fail(
|
|
92
99
|
`${path}: '${t.name}' is in both '${left.name}' and '${right.name}', so say which side: ` +
|
|
@@ -177,6 +184,10 @@ export function level(sol, halfReaction, { standard = false } = {}) {
|
|
|
177
184
|
else fail(`level: '${t.name}' is neither a species of the solution nor in the half-reaction's fixed`);
|
|
178
185
|
}
|
|
179
186
|
if (nuE === 0) fail(`level: ${JSON.stringify(equation)} has no net electrons`);
|
|
187
|
+
// (unbalanced, its level wouldn't move with φ as a level must)
|
|
188
|
+
const z = Object.fromEntries((sol.species ?? []).map((sp) => [sp.name, sp.z]));
|
|
189
|
+
const charge = terms.reduce((q, t) => q + t.nu * (t.name === 'e-' ? -1 : fixed[t.name] !== undefined ? 0 : (z[t.name] ?? NaN)), 0); // (fixed: neutral)
|
|
190
|
+
if (sol.species && !(Math.abs(charge) < 1e-12)) fail(`level: ${JSON.stringify(equation)} isn't charge-balanced (Σ ν z = ${charge} across it)`);
|
|
180
191
|
const n = sol.mu[Object.keys(sol.mu)[0]].length;
|
|
181
192
|
const V = new Float64Array(n);
|
|
182
193
|
for (let g = 0; g < n; g++) {
|
package/src/index.js
CHANGED
|
@@ -211,11 +211,14 @@ export class Device {
|
|
|
211
211
|
* Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
|
|
212
212
|
* `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
|
|
213
213
|
* which), so an animation can call it once per frame; the step size carries over. `probes`
|
|
214
|
-
* read a species at points inside the device after every accepted step, into
|
|
215
|
-
* (one array per probe, alongside `trace.t`): what a detector at x sees.
|
|
214
|
+
* read a species (or φ) at points inside the device after every accepted step, into
|
|
215
|
+
* `trace.probes` (one array per probe, alongside `trace.t`): what a detector at x sees. At a
|
|
216
|
+
* face, `{ interface, species }` reads a species' flux through it (mol/(m²·s), toward +x), and
|
|
217
|
+
* `{ interface, gate }` a gate's fraction open.
|
|
216
218
|
* @param {number} tEnd s
|
|
217
219
|
* @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
|
|
218
|
-
* probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
|
|
220
|
+
* probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
|
|
221
|
+
* | { interface: number, species: string } | { interface: number, gate: string })[] }} [opts]
|
|
219
222
|
* @returns {Solution}
|
|
220
223
|
*/
|
|
221
224
|
advance(tEnd, opts) {
|
package/src/junction.js
CHANGED
|
@@ -29,6 +29,9 @@ function neutral(list, side, what) {
|
|
|
29
29
|
}
|
|
30
30
|
if (Math.abs(q) > 1e-9 * s) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution carries a net charge (Σ z·c = ${q.toPrecision(3)} mol/m³)`);
|
|
31
31
|
}
|
|
32
|
+
function holds(list, what) {
|
|
33
|
+
for (const side of ['cL', 'cR']) if (!list.some((p) => p[side] > 0)) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution holds no ions`);
|
|
34
|
+
}
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
|
|
@@ -44,6 +47,7 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
|
|
|
44
47
|
const list = prepare(left, right, ions, 'henderson');
|
|
45
48
|
neutral(list, 'cL', 'henderson');
|
|
46
49
|
neutral(list, 'cR', 'henderson');
|
|
50
|
+
holds(list, 'henderson');
|
|
47
51
|
let a = 0, b = 0, gL = 0, gR = 0;
|
|
48
52
|
for (const { z, D, cL, cR } of list) {
|
|
49
53
|
a += z * D * (cR - cL);
|
|
@@ -51,9 +55,10 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
|
|
|
51
55
|
gL += z * z * D * cL;
|
|
52
56
|
gR += z * z * D * cR;
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
// ln(g_R/g_L)/(g_R − g_L) = ln(1 + r)/(r g_L), r = b/g_L: 1/g_L at equal conductivities, where the
|
|
59
|
+
// plain form is 0/0.
|
|
60
|
+
const VT = (GAS_CONSTANT * T) / FARADAY, r = b / gL;
|
|
61
|
+
return (-VT * a * (Math.abs(r) < 1e-8 ? 1 - r / 2 : Math.log1p(r) / r)) / gL;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
@@ -71,6 +76,7 @@ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
|
|
|
71
76
|
const list = prepare(left, right, ions, 'planck');
|
|
72
77
|
neutral(list, 'cL', 'planck');
|
|
73
78
|
neutral(list, 'cR', 'planck');
|
|
79
|
+
holds(list, 'planck');
|
|
74
80
|
const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
|
|
75
81
|
const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
|
|
76
82
|
const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
|
package/src/kit.js
CHANGED
|
@@ -71,7 +71,7 @@ export function vacuumDipole(def, left, right) {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* The vacuum-level (Schottky–Mott-type) estimate of a `zeroCharge` where a conductor meets a
|
|
74
|
-
* material: at a conductor region's face, or a contact's capacitive or
|
|
74
|
+
* material: at a conductor region's face, or a contact's capacitive or pinned law. The conductor's
|
|
75
75
|
* vacuum level sits its work function W beyond its Fermi level, so
|
|
76
76
|
* zeroCharge = V_F − φ_edge at zero charge = W + (V_vac − φ)_inside.
|
|
77
77
|
* @param {object} def device definition
|
package/src/live.js
CHANGED
|
@@ -57,26 +57,31 @@ export class Session {
|
|
|
57
57
|
if (this.good && errors.every(Boolean)) return done(this.good.sol, { failed: true }); // nothing changed
|
|
58
58
|
const target = this.device.def;
|
|
59
59
|
try {
|
|
60
|
-
let sol = this.device.solve();
|
|
61
|
-
// Ramp from the last good state in more and more steps.
|
|
62
|
-
|
|
60
|
+
let sol = this.device.solve(), stuck = false;
|
|
61
|
+
// Ramp from the last good state in more and more steps. (Its in-between states can be
|
|
62
|
+
// invalid where the two ends aren't, a bath neutral at both but not between: then on to the
|
|
63
|
+
// fresh solve.)
|
|
64
|
+
ramping: for (let n = 2; !sol.converged && this.good && n <= this.maxRamp; n *= 2) {
|
|
63
65
|
ramp = n;
|
|
64
66
|
this.device._rollback(this.good.cp);
|
|
65
67
|
const from = this.good.cp.def;
|
|
66
68
|
for (let k = 1; k <= n; k++) {
|
|
67
|
-
|
|
69
|
+
try {
|
|
70
|
+
this.device.set(between(from, target, k / n));
|
|
71
|
+
} catch {
|
|
72
|
+
stuck = true; // (and sol, a converged step short of the target, isn't the answer)
|
|
73
|
+
break ramping;
|
|
74
|
+
}
|
|
68
75
|
sol = this.device.solve();
|
|
69
76
|
if (!sol.converged) break;
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
|
-
if (!sol.converged) {
|
|
79
|
+
if (stuck || !sol.converged) {
|
|
73
80
|
// Last, from scratch (with the solver's own continuations).
|
|
74
81
|
const fresh = new Device(target);
|
|
75
82
|
const cold = fresh.solve();
|
|
76
|
-
if (cold.converged)
|
|
77
|
-
|
|
78
|
-
sol = cold;
|
|
79
|
-
}
|
|
83
|
+
if (cold.converged) this.device = fresh;
|
|
84
|
+
if (cold.converged || stuck) sol = cold;
|
|
80
85
|
}
|
|
81
86
|
if (sol.converged) {
|
|
82
87
|
this.good = { cp: this.device._checkpoint(), sol };
|
package/src/particles.js
CHANGED
|
@@ -55,6 +55,8 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
|
|
|
55
55
|
*/
|
|
56
56
|
export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
|
|
57
57
|
checkOptions('particles', arguments[1], ['species', 'dots', 'weight', 'cap', 'cells', 'random']);
|
|
58
|
+
// (amounts per area, against fluxes that off a plane are per device: planar only)
|
|
59
|
+
if (sol.geometry !== undefined && sol.geometry !== 'planar') throw new RangeError(`particles: a planar device's solution, not a ${sol.geometry} one`);
|
|
58
60
|
const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
|
|
59
61
|
for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
|
|
60
62
|
if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
|
|
@@ -172,7 +174,8 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
|
|
|
172
174
|
}
|
|
173
175
|
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
174
176
|
const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
|
|
175
|
-
|
|
177
|
+
// (none of it where either cell is empty: an underflow to 0 makes both ψ infinite)
|
|
178
|
+
pair(k, J[A.b], N[k - 1] > 0 && N[k] > 0 ? (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD : 0);
|
|
176
179
|
} else if (A || B) {
|
|
177
180
|
// out of the cells: through a contact, or a face to a metal
|
|
178
181
|
const cl = A ?? B, g = A ? A.b : B.a;
|
package/src/plot.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { traces } from './traces.js';
|
|
16
16
|
import { powi } from './pow.js';
|
|
17
|
-
import { checkOptions } from './errors.js';
|
|
17
|
+
import { DeviceError, checkOptions } from './errors.js';
|
|
18
18
|
|
|
19
19
|
// Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
|
|
20
20
|
// dash and the legend carry identity too). A role with more members than colours cycles.
|
|
@@ -23,16 +23,20 @@ export const THEME = Object.freeze({
|
|
|
23
23
|
dark: { electron: ['#5b95d6'], cation: ['#e8584f', '#d0772a', '#d65d95', '#b88a3e'], anion: ['#219c8f', '#2aa5bb', '#3aa874'], redox: ['#8b7ff0', '#a86bc9', '#7a9ae0'] },
|
|
24
24
|
});
|
|
25
25
|
const ROLES = ['electron', 'cation', 'anion', 'redox'];
|
|
26
|
+
const MUTED = { light: '#52514e', dark: '#c3c2b7' }; // (φ's, and lines of no species)
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The colour of a trace's role and slot (a CSS colour), for drawing other charts in the same
|
|
29
30
|
* colours as the level diagrams.
|
|
30
|
-
* @param {'electron' | 'cation' | 'anion' | 'redox'} role
|
|
31
|
+
* @param {'electron' | 'cation' | 'anion' | 'redox' | 'phi'} role
|
|
31
32
|
* @param {number} slot
|
|
32
33
|
* @param {{ dark?: boolean }} [opts]
|
|
33
34
|
*/
|
|
34
35
|
export function themeColor(role, slot, { dark = false } = {}) {
|
|
35
36
|
checkOptions('themeColor', arguments[2], ['dark']);
|
|
37
|
+
if (role === 'phi') return MUTED[dark ? 'dark' : 'light'];
|
|
38
|
+
if (!ROLES.includes(role)) throw new DeviceError(`themeColor: no role '${role}' (${[...ROLES, 'phi'].join(', ')})`);
|
|
39
|
+
if (!(Number.isInteger(slot) && slot >= 0)) throw new DeviceError(`themeColor: slot must be a whole number ≥ 0, got ${slot}`);
|
|
36
40
|
const list = THEME[dark ? 'dark' : 'light'][role];
|
|
37
41
|
return list[slot % list.length];
|
|
38
42
|
}
|
|
@@ -118,8 +122,8 @@ function lengthUnit(L) {
|
|
|
118
122
|
|
|
119
123
|
const style = (id) => {
|
|
120
124
|
const vars = (mode) => ROLES.flatMap((role) => THEME[mode][role].map((c, k) => `--driftlet-${role}${role === 'electron' ? '' : `-${k + 1}`}:${c};`)).join('');
|
|
121
|
-
const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted
|
|
122
|
-
const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted
|
|
125
|
+
const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted:${MUTED.light};--driftlet-grid:#e4e3df;--driftlet-band:#f1f0ec;--driftlet-face:#a8a7a1`;
|
|
126
|
+
const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted:${MUTED.dark};--driftlet-grid:#3a3a37;--driftlet-band:#262624;--driftlet-face:#6b6a64`;
|
|
123
127
|
return (
|
|
124
128
|
`<style>` +
|
|
125
129
|
`:where(#${id}){${light}}` +
|
package/src/solution.js
CHANGED
|
@@ -27,6 +27,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
27
27
|
V: {},
|
|
28
28
|
Vstd: {},
|
|
29
29
|
T: model.T,
|
|
30
|
+
geometry: model.geometry.type, // (planar, or amounts per device rather than per area)
|
|
30
31
|
time: solver.time,
|
|
31
32
|
steady: solver.atSteady,
|
|
32
33
|
converged: result.converged ?? true,
|