driftlet 1.2.2 → 1.2.3
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/device.md +5 -0
- package/docs/internals.md +8 -1
- package/docs/reliability.md +41 -3
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/lawRows.js +2 -0
- package/src/solver.js +47 -0
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.2.
|
|
59
|
+
`https://cdn.jsdelivr.net/npm/driftlet@1.2.3/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/device.md
CHANGED
|
@@ -110,6 +110,11 @@ regions: [
|
|
|
110
110
|
reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
|
|
111
111
|
logarithmic in them); to have none of a species in a region, leave it out of that region's
|
|
112
112
|
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
113
|
+
Where two strictly neutral regions meet at a face that holds ions in equilibrium and their
|
|
114
|
+
`c0` leaves it out of equilibrium (a different fixed charge or immobile ion on each side), the
|
|
115
|
+
two nodes at the face start in it, as the face reaches it at $`t = 0^+`$: the ions it passes
|
|
116
|
+
shared between them so their $`\bar\mu`$ is equal across it and each side neutral, each
|
|
117
|
+
one's amount over the two nodes kept, everything else as `c0` has it.
|
|
113
118
|
An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
|
|
114
119
|
the region's own), piecewise linear between its points and constant beyond its ends, so a
|
|
115
120
|
transient can start from a state it didn't simulate reaching: a packet already injected, a
|
package/docs/internals.md
CHANGED
|
@@ -83,4 +83,11 @@ The rest of `src/solver.js` is to be split along these lines, each piece with it
|
|
|
83
83
|
A review of one module reads its contract, its code and its tests, and nothing else unless the
|
|
84
84
|
contract points there. Each claim cites `file:line` with the line quoted, so it can be checked
|
|
85
85
|
mechanically, and a finding comes with the device or script that shows it. A claim about code
|
|
86
|
-
not in view is a question, not a finding.
|
|
86
|
+
not in view is a question, not a finding. Two instruments check a change to the core beyond the tests'
|
|
87
|
+
answers: `test/jacobian.test.js` (every row's derivatives against finite differences of its
|
|
88
|
+
residual) and, for a move or refactor meant to change nothing, `tools/fingerprint.js`: a hash of
|
|
89
|
+
the solver's state after every solve in the suite (on a fixed clock, so runs stopped by a time
|
|
90
|
+
budget repeat), recorded before and after and compared. `tools/check-hook.js`, loaded into any run (`node --import
|
|
91
|
+
./tools/check-hook.js …`: the tests, the law sweep, a fuzzer), checks the laws' cheap invariants
|
|
92
|
+
as the solver goes: no row taken twice, none inactive, no stray ∂/∂V in a law's row, findLaws'
|
|
93
|
+
structure unwritten.
|
package/docs/reliability.md
CHANGED
|
@@ -171,9 +171,47 @@ and in a time step's reading of its current), none of them in the sweep's device
|
|
|
171
171
|
`test/laws.test.js`. On the code before those fixes the second family has 14 devices that
|
|
172
172
|
disagree (a port's voltage at 0 for −1.42 V, θ 0.05 for 0.015, a contact 24 mV off); after, none,
|
|
173
173
|
26 agree and 34 transients fail (see the roadmap). The first family's laws come out unchanged by
|
|
174
|
-
the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices,
|
|
175
|
-
same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a
|
|
176
|
-
capacitor where the transient loses K⁺.
|
|
174
|
+
the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices,
|
|
175
|
+
gives the same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a
|
|
176
|
+
membrane capacitor where the transient loses K⁺. Since a face out of equilibrium in `c0` starts
|
|
177
|
+
in it (1.2.3), 607 agree and 115 fail, the same 13 differing.
|
|
178
|
+
|
|
179
|
+
## Consistent: the derivatives
|
|
180
|
+
|
|
181
|
+
A Jacobian entry that's wrong still converges to the right answer, its residual being right, so
|
|
182
|
+
no test of answers sees it: it shows only as Newton taking longer, or failing on a harder device.
|
|
183
|
+
`test/jacobian.test.js` compares the whole Newton system, column by column, with central
|
|
184
|
+
differences of its own residual, on ten devices that between them write every law's row (flat
|
|
185
|
+
stretches, affinities, amounts kept in balance rows and in circuit rows with an electrode's or a
|
|
186
|
+
contact's charge, node-by-node laws with a surface, metals' charges), crowded statistics, face
|
|
187
|
+
and port reactions and floating terminals. It checks inside a real `solve()` and a real
|
|
188
|
+
`advance()`, at their second assembly, each in the unknowns Newton solves for there (a time step's
|
|
189
|
+
charge rows included). It came from a cold review of `src/lawRows.js`, which found a node-by-node
|
|
190
|
+
law's row keeping a port reaction's ∂/∂V (an extra Newton iteration, the answer the same); broken
|
|
191
|
+
once each way (that entry back, a storage term 0.1% off in either time-step path), it fails.
|
|
192
|
+
|
|
193
|
+
## Measured: how much the tests see
|
|
194
|
+
|
|
195
|
+
A mutation run makes one small slip at a time in a module (an operator flipped, a comparison
|
|
196
|
+
loosened, a statement dropped, a 0 for a 1), runs the tests on it, and counts the slips no test
|
|
197
|
+
fails. On 18 Oct 2026, 120 of them, sampled from `src/laws.js` and `src/lawRows.js`'s 1420: 69
|
|
198
|
+
failed a test, and 51 got through. Each of the 51 was then looked at:
|
|
199
|
+
- In `src/laws.js` (29): 20 change no law on any of the law sweep's 735 devices, 2 only a
|
|
200
|
+
workspace's size, and 6 change laws on 12 to 635 of them while every answer stays right (the
|
|
201
|
+
islands', which Newton seldom needs; a λ read only by long steps). One changed the answers: it
|
|
202
|
+
held a capacitive contact's charge in the wrong law, and 85 of the sweep's devices came out
|
|
203
|
+
wrong, with all 470 tests passing. Its device is now in `test/laws.test.js`.
|
|
204
|
+
- In `src/lawRows.js` (22): the Jacobian test had never seen islands' rows, a long step that
|
|
205
|
+
reads a law by its change, or a law with a surface or a capacitive electrode in a time step.
|
|
206
|
+
With them in (a charged pore, three of the sweep's devices), 6 more fail. Of the 16 left, 6
|
|
207
|
+
change nothing (a bound the loop never reaches), 1 only a heuristic's estimate, 2 the pivoting
|
|
208
|
+
of the small dense solve (robustness only), and 7 are narrow paths no device here takes: two
|
|
209
|
+
floating electrodes whose law counts the other's charge, an equilibrium face inside a flat
|
|
210
|
+
stretch, a contact's law read by its change.
|
|
211
|
+
|
|
212
|
+
A slip that gives the right answer by another route isn't a gap; what the run found that was,
|
|
213
|
+
was one wrong answer the sweep sees and the suite didn't, and a test that had never looked at
|
|
214
|
+
two of the paths it claimed.
|
|
177
215
|
|
|
178
216
|
## Hard cases
|
|
179
217
|
|
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.2.
|
|
34
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
35
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
33
|
+
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
|
|
34
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
|
|
35
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/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.2.
|
|
223
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
224
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
222
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
|
|
223
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
|
|
224
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/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.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"smoke": "node tools/smoke.js",
|
|
35
35
|
"shots": "node tools/shots.js",
|
|
36
36
|
"lawsweep": "node tools/lawsweep.js",
|
|
37
|
+
"fingerprint": "node tools/fingerprint.js",
|
|
37
38
|
"prepublishOnly": "npm test && npm run types",
|
|
38
39
|
"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"
|
|
39
40
|
},
|
package/src/lawRows.js
CHANGED
|
@@ -349,6 +349,8 @@ function lawAmount(s, cs) {
|
|
|
349
349
|
cs.w[len++] = -f * cap * s.VT;
|
|
350
350
|
});
|
|
351
351
|
}
|
|
352
|
+
// (A typed array drops writes past its end without a word: the law's derivative would lose them.)
|
|
353
|
+
if (len > cs.idx.length) throw new Error(`internal: a law's derivative needs ${len} entries, its workspace holds ${cs.idx.length}`);
|
|
352
354
|
cs.len = len;
|
|
353
355
|
return { amount, reference, dV, gross }; // (dV by terminal)
|
|
354
356
|
}
|
package/src/solver.js
CHANGED
|
@@ -1152,6 +1152,53 @@ export class Solver {
|
|
|
1152
1152
|
}
|
|
1153
1153
|
}
|
|
1154
1154
|
}
|
|
1155
|
+
// Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
|
|
1156
|
+
// c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
|
|
1157
|
+
// c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
|
|
1158
|
+
// first step made to take that jump met an exactly singular pivot. Start there: the ions the
|
|
1159
|
+
// face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
|
|
1160
|
+
// amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
|
|
1161
|
+
// in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
|
|
1162
|
+
// the two nodes' charge summing to what it was, zero.
|
|
1163
|
+
for (let f = 0; f < interfaces.length; f++) {
|
|
1164
|
+
const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
|
|
1165
|
+
const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
|
|
1166
|
+
if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
|
|
1167
|
+
const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = this.blockOfNode[gL] * M, bR = this.blockOfNode[gR] * M;
|
|
1168
|
+
const pass = [];
|
|
1169
|
+
for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
|
|
1170
|
+
if (!pass.some((i) => z[i] !== 0)) continue;
|
|
1171
|
+
const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
|
|
1172
|
+
const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
|
|
1173
|
+
const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
|
|
1174
|
+
let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
|
|
1175
|
+
for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
|
|
1176
|
+
// At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
|
|
1177
|
+
const split = (D) =>
|
|
1178
|
+
pass.map((i, j) => {
|
|
1179
|
+
const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
|
|
1180
|
+
return [K * cR, cR];
|
|
1181
|
+
});
|
|
1182
|
+
const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
|
|
1183
|
+
const D0 = u[bR] - u[bL];
|
|
1184
|
+
let lo = D0 - 1, hi = D0 + 1;
|
|
1185
|
+
while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
|
|
1186
|
+
while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
|
|
1187
|
+
if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
|
|
1188
|
+
for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
|
|
1189
|
+
const m = 0.5 * (lo + hi);
|
|
1190
|
+
if (qL(m) < 0) lo = m;
|
|
1191
|
+
else hi = m;
|
|
1192
|
+
}
|
|
1193
|
+
const D = 0.5 * (lo + hi), cs = split(D);
|
|
1194
|
+
const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
|
|
1195
|
+
u[bR] = u[bL] + D;
|
|
1196
|
+
for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
|
|
1197
|
+
pass.forEach((i, j) => {
|
|
1198
|
+
u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
|
|
1199
|
+
u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1155
1202
|
// An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
|
|
1156
1203
|
// splits between its ion and its electrons. Start its ion level with the same ion's beside it
|
|
1157
1204
|
// across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
|