driftlet 1.2.1 → 1.2.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 +2 -2
- package/docs/internals.md +86 -0
- package/docs/numerics.md +12 -1
- package/docs/reliability.md +15 -4
- package/llms.txt +6 -6
- package/package.json +1 -1
- package/src/lawRows.js +562 -0
- package/src/laws.js +693 -0
- package/src/solver.js +63 -1006
- package/types/lawRows.d.ts +5 -0
- package/types/laws.d.ts +86 -0
- package/types/solver.d.ts +78 -43
package/src/lawRows.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// The laws in the Newton system: each law src/laws.js finds, evaluated on the state and written
|
|
2
|
+
// into the equations in place of the row it takes, and the linear solve that holds the rows too
|
|
3
|
+
// dense for the block-tridiagonal matrix aside.
|
|
4
|
+
//
|
|
5
|
+
// CONTRACT
|
|
6
|
+
//
|
|
7
|
+
// These are functions of a Solver `s` (src/solver.js), which they read and write; its layout
|
|
8
|
+
// (blocks, slots, row o = block·M + slot, its compact index R = s.rix[o], loc[o] < 0 for an
|
|
9
|
+
// inactive slot, u + uLo, terminals 0, 1, 2 + k) is in docs/internals.md. The laws are as
|
|
10
|
+
// Solver._laws() set them from findLaws: s.constraints, affinities, localConstraints, islands,
|
|
11
|
+
// timeLaws, contactLaw, and s.flatStretches, each meaning what src/laws.js's contract says. The
|
|
12
|
+
// solver's own helpers they call: s._j(rb, r, cb, c, v) adds v at row (rb, r), column (cb, c) of
|
|
13
|
+
// the block matrix s.sys; s._replaceRow(b, r) zeroes that row (all three blocks, and its terms in
|
|
14
|
+
// difference form, s.lin); s._dc(g, i, d) puts ∂c_i/∂(slot) at node g into d; s._solveLinear(rhs,
|
|
15
|
+
// out) solves the factorised s.sys; s._assembleBookkeeping(dt) sets the terminals' charges and
|
|
16
|
+
// currents (portQ, contactD, termI); s.amount(st), s.surfaceAmount(col) a stretch's or surface
|
|
17
|
+
// column's amount.
|
|
18
|
+
//
|
|
19
|
+
// The Newton system (see Solver.newton): J δ = res, the update u −= δ (and termV[k] −= δV for a
|
|
20
|
+
// floating k). It is s.sys (the blocks, residual s.res), and beside it, for each floating
|
|
21
|
+
// terminal k, its column termB[k] (∂ of every row by V_k), its circuit row termC[k], termRes[k]
|
|
22
|
+
// and its ∂/∂V's, termDI[k] (own) and termDX[k][j] (terminal j's); and s.pins.
|
|
23
|
+
//
|
|
24
|
+
// A pin: a law too dense for the block matrix, { row, idx, w, len, res } (and `col`, see below).
|
|
25
|
+
// Its row in s.sys is the identity with residual 0, and the law, Σ_j w_j δ[idx_j] = res, is
|
|
26
|
+
// solved beside it by solveBordered: the pin's multiplier is free, so whatever else that row
|
|
27
|
+
// holds (a terminal's termB entry, say) is absorbed by it. Not scaled by Solver._equilibrate,
|
|
28
|
+
// which scales s.sys's rows, s.res and termB, and runs between assembly and the solve.
|
|
29
|
+
//
|
|
30
|
+
// The functions, and when each runs:
|
|
31
|
+
// captureLaws(s) when a steady solve begins (Solver.solveSteady): s.lawRefs, the amount each
|
|
32
|
+
// constraint fed by a terminal driven by a current holds now (by its key);
|
|
33
|
+
// s.chargeRefs, the charge (portQ, or the contact's ±D) of each terminal
|
|
34
|
+
// driven by a current that passes current only by charging. (A steady solve
|
|
35
|
+
// has such a terminal only at I = 0: Solver._solveSteadyAll stops at any
|
|
36
|
+
// other.) Calls _assembleBookkeeping where it needs the charges.
|
|
37
|
+
// captureLocal(s) when a steady solve's steady system begins (Solver._steadyFromHere): each
|
|
38
|
+
// local constraint's reference[j], its sum at node nodes[j] now.
|
|
39
|
+
// snapFlat(s) in a steady assembly (dt = ∞), before the concentrations: each flat
|
|
40
|
+
// stretch's level (u and uLo) set at every node to its first node's.
|
|
41
|
+
// applyLaws(s, dt) at the end of Solver.assemble(dt), after every kernel and the terminals:
|
|
42
|
+
// dt finite: each circuit row a law of the constraints or timeLaws may read by
|
|
43
|
+
// the change of its amount (circuitLaws). Steady (s.steady and dt = ∞): the
|
|
44
|
+
// rows of the flat stretches, affinities, constraints, local constraints and,
|
|
45
|
+
// once Newton turns them on (s.islandsOn), the islands, in that order; and
|
|
46
|
+
// s.pins, those of them held aside (constraints in a balance row, affinities,
|
|
47
|
+
// islands). Otherwise s.pins is empty.
|
|
48
|
+
// solveBordered(s, rhs, delta, deltaV, pins) each Newton solve: δ (delta, compact) and the
|
|
49
|
+
// floating terminals' δV (deltaV, by s.floating's order) with s.sys
|
|
50
|
+
// factorised.
|
|
51
|
+
//
|
|
52
|
+
// What each row says, as written (the steady rows replace a row whole, with _replaceRow first):
|
|
53
|
+
// flat stretch at each of its nodes, u_o − level = 0, Jacobian 1 on itself (the level:
|
|
54
|
+
// its contact's, the terminal's ∂/∂V in termB; reaching none, its first
|
|
55
|
+
// node's own u, so that row is 0 = 0, and the stretch's amount, a constraint
|
|
56
|
+
// with `col` = its nodes' rows, moves it whole: a unit pin there shifts every
|
|
57
|
+
// row of `col`). A face inside it whose link is an equilibrium: its flux
|
|
58
|
+
// unknown = 0.
|
|
59
|
+
// affinity pin: fixedA − Σ ν η at the face's two nodes = 0, ∂ = −ν.
|
|
60
|
+
// constraint in a balance row, a pin: amount − ref = 0 (ref: its reference without
|
|
61
|
+
// terminals, else lawRefs; with no reference captured there, none, the amount
|
|
62
|
+
// held where it is), its ∂ from lawAmount. In terminal k's circuit row
|
|
63
|
+
// (in place of what Solver._terminals wrote): the same, its contacts' charges
|
|
64
|
+
// λ·D_in/F added from s.contactQ[t] (as Solver._contact captured them in this
|
|
65
|
+
// assembly), termDI and termDX from the electrodes' charges' ∂/∂V.
|
|
66
|
+
// local constraint at each node of `nodes`, Σ w c_i (+ Σ w Γ a θ_q where port k's surface is)
|
|
67
|
+
// − reference = 0, its ∂ in the node's block (a coverage's,
|
|
68
|
+
// θ_q (δ_qx − θ_x) over its surface's species).
|
|
69
|
+
// island pin: Σ weight·u over the `flux` rows + Σ the `outside` rows' residuals = 0,
|
|
70
|
+
// its ∂ the same sums of their Jacobian rows (rowInto), gathered by column.
|
|
71
|
+
// circuit law (time steps) terminal k's circuit row, where it reads by the change: F (S −
|
|
72
|
+
// S*)/(λ dt) − (its drive's current: the source, or (V_src − V)/R), with S*
|
|
73
|
+
// the amount at the step's start (lawStart), chosen once per step per law
|
|
74
|
+
// (s.lawForm) by comparing the round-off of the two readings.
|
|
75
|
+
//
|
|
76
|
+
// Invariants (each says what a reviewer should be able to check here):
|
|
77
|
+
// 1. A row is replaced only where its slot is active (loc ≥ 0), and every entry a row held
|
|
78
|
+
// before is gone: _replaceRow clears the three blocks and s.lin's; a row that isn't a pin
|
|
79
|
+
// (a flat stretch's, a local constraint's) also has its termB entries cleared, where a
|
|
80
|
+
// terminal's ∂/∂V was in it (a port reaction's, in the slots it touches).
|
|
81
|
+
// 2. Each law's residual and its derivatives are of the same function of the state: every
|
|
82
|
+
// term in amount (lawAmount, and the rows above) has its ∂ in idx/w, dV or the block row,
|
|
83
|
+
// and none is counted twice (a metal's carriers by its charged face's D, not its η).
|
|
84
|
+
// 3. lawAmount at a stretch, surface or electrode sums the same quantity lawStart sums at the
|
|
85
|
+
// step's start, and captureLaws at a solve's start: what a step's balance rows change is
|
|
86
|
+
// what a circuit law reads.
|
|
87
|
+
// 4. solveBordered solves the full system: the block rows, each pin's law, and each floating
|
|
88
|
+
// terminal's circuit row, with δV, by the Woodbury identity written at it, exact given the
|
|
89
|
+
// block solves (T⁻¹ applied by the factorised s.sys): its accuracy is theirs.
|
|
90
|
+
// 5. Nothing here writes the laws' structure (their parts, rows, nodes): only the workspace
|
|
91
|
+
// fields src/laws.js leaves to the solver (idx, w, len, res, dV, reference), the solver's
|
|
92
|
+
// maps lawRefs, chargeRefs, lawForm, s.pins and s.pinnedIslands, scratch arrays kept on s
|
|
93
|
+
// (islandRow, linRow, dWork, borderCols), and, in snapFlat alone, the state (u, uLo).
|
|
94
|
+
// Tests: test/laws.test.js, test/steady.test.js and every test that solves a steady state;
|
|
95
|
+
// tools/lawsweep.js (solve() against a settled advance()).
|
|
96
|
+
|
|
97
|
+
import { FARADAY } from './constants.js';
|
|
98
|
+
import { sourceAt } from './device.js';
|
|
99
|
+
import { carrierZ, chargeOnly } from './laws.js';
|
|
100
|
+
import { SolverError } from './solver.js';
|
|
101
|
+
|
|
102
|
+
// The laws' rows at the end of an assembly (see the contract above).
|
|
103
|
+
export function applyLaws(s, dt) {
|
|
104
|
+
if (Number.isFinite(dt) && (s.constraints.length > 0 || s.timeLaws.length > 0)) circuitLaws(s, dt);
|
|
105
|
+
const steady = s.steady && dt === Infinity;
|
|
106
|
+
if (steady) flatRows(s);
|
|
107
|
+
if (steady && s.affinities.length > 0) applyAffinities(s);
|
|
108
|
+
if (steady && s.constraints.length > 0) applyConstraints(s);
|
|
109
|
+
if (steady && s.localConstraints.length > 0) applyLocalConstraints(s);
|
|
110
|
+
s.pinnedIslands = steady && s.islandsOn ? applyIslands(s) : [];
|
|
111
|
+
s.pins = steady ? [...s.constraints.filter((cs) => cs.circuit < 0), ...s.affinities, ...s.pinnedIslands] : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What each combination fed by a terminal driven at no current holds now, which a steady solve
|
|
115
|
+
// keeps: a closed battery at open circuit stays at the charge it had.
|
|
116
|
+
export function captureLaws(s) {
|
|
117
|
+
// A terminal at no current that passes current only by charging a capacitance keeps its charge.
|
|
118
|
+
s.chargeRefs.clear();
|
|
119
|
+
const charged = s.floating.filter((k) => s.terms[k].drive.kind === 'I' && chargeOnly(s, k));
|
|
120
|
+
if (charged.length > 0) {
|
|
121
|
+
s._assembleBookkeeping(Infinity);
|
|
122
|
+
for (const k of charged) s.chargeRefs.set(k, k >= 2 ? s.portQ[k - 2] : k === 0 ? s.contactD.left : -s.contactD.right);
|
|
123
|
+
}
|
|
124
|
+
s.lawRefs.clear();
|
|
125
|
+
if (s.constraints.some((cs) => cs.contacts.length > 0 || cs.carriers.some(({ k }) => s.model.ports[k - 2].capacitance)) && charged.length === 0) s._assembleBookkeeping(Infinity);
|
|
126
|
+
for (const cs of s.constraints) {
|
|
127
|
+
if (cs.terminals.length === 0) continue;
|
|
128
|
+
let a = 0;
|
|
129
|
+
for (const { stretch, w } of cs.parts) a += w * s.amount(s.stretches[stretch]);
|
|
130
|
+
for (const { col, w } of cs.surface) a += w * s.surfaceAmount(col);
|
|
131
|
+
for (const { k, w } of cs.carriers) if (s.model.ports[k - 2].capacitance) a += (w * s.portQ[k - 2]) / (carrierZ(s, k) * FARADAY);
|
|
132
|
+
for (const { t, lambda } of cs.contacts) a += (lambda * (t === 0 ? s.contactD.left : -s.contactD.right)) / FARADAY;
|
|
133
|
+
s.lawRefs.set(cs.key, a);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Each local constraint's weighted sum at each node, as the state holds it now: what a steady
|
|
138
|
+
// solve keeps.
|
|
139
|
+
export function captureLocal(s) {
|
|
140
|
+
const { n, c, th, nSurf } = s;
|
|
141
|
+
for (const lc of s.localConstraints) {
|
|
142
|
+
lc.nodes.forEach((g, j) => {
|
|
143
|
+
let t = 0;
|
|
144
|
+
for (const { i, q, k, w } of lc.parts) {
|
|
145
|
+
if (i !== undefined) t += w * c[g * n + i];
|
|
146
|
+
else if (s.surfPort[g] === k) t += w * sites(s, g) * th[g * nSurf + q];
|
|
147
|
+
}
|
|
148
|
+
lc.reference[j] = t;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
|
|
154
|
+
// first node's row pinned in the factorised matrix and the sum bordered (solveBordered) like a
|
|
155
|
+
// conserved amount's. The pieces of a flat stretch are left as they are (its rows are flatRows').
|
|
156
|
+
function applyIslands(s) {
|
|
157
|
+
const { u, res } = s, R = s.rix, out = [];
|
|
158
|
+
for (const isl of s.islands) {
|
|
159
|
+
if (s.flatStretches.includes(isl.stretch)) continue;
|
|
160
|
+
const b0 = Math.floor(isl.row / s.M), r0 = isl.row % s.M;
|
|
161
|
+
if (s.loc[isl.row] < 0) continue;
|
|
162
|
+
// Gathered by column (the faces' u, then each outside row's entries, which cancel those u
|
|
163
|
+
// exactly where they meet), then the nonzeros kept.
|
|
164
|
+
const acc = s.islandRow ?? (s.islandRow = new Float64Array(s.sys.size + 1));
|
|
165
|
+
const cols = [];
|
|
166
|
+
const add = (col, v) => {
|
|
167
|
+
if (acc[col] === 0) cols.push(col);
|
|
168
|
+
acc[col] += v;
|
|
169
|
+
};
|
|
170
|
+
let t = 0;
|
|
171
|
+
isl.flux.forEach((o, j) => {
|
|
172
|
+
if (s.loc[o] < 0) return;
|
|
173
|
+
t += isl.weight[j] * u[o];
|
|
174
|
+
add(R[o], isl.weight[j]);
|
|
175
|
+
});
|
|
176
|
+
for (const o of isl.outside) {
|
|
177
|
+
if (s.loc[o] < 0) continue;
|
|
178
|
+
t += res[R[o]];
|
|
179
|
+
rowInto(s, o, add);
|
|
180
|
+
}
|
|
181
|
+
let len = 0;
|
|
182
|
+
for (const col of cols) {
|
|
183
|
+
if (col < s.sys.size && acc[col] !== 0) {
|
|
184
|
+
isl.idx[len] = col;
|
|
185
|
+
isl.w[len++] = acc[col];
|
|
186
|
+
}
|
|
187
|
+
acc[col] = 0;
|
|
188
|
+
}
|
|
189
|
+
isl.len = len;
|
|
190
|
+
isl.res = t;
|
|
191
|
+
s._replaceRow(b0, r0);
|
|
192
|
+
for (const B of s.termB) B[R[isl.row]] = 0;
|
|
193
|
+
s._j(b0, r0, b0, r0, 1);
|
|
194
|
+
res[R[isl.row]] = 0;
|
|
195
|
+
out.push(isl);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
|
|
201
|
+
// kernels' kept aside in difference form (see Solver._assembleDifference) when they are.
|
|
202
|
+
function rowInto(s, o, add) {
|
|
203
|
+
const { sys, M, nB } = s, b = Math.floor(o / M), l = s.loc[o];
|
|
204
|
+
const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
|
|
205
|
+
for (let k = 0; k < m; k++) if (B[offB[b] + l * m + k] !== 0) add(offX[b] + k, B[offB[b] + l * m + k]);
|
|
206
|
+
if (b > 0) for (let k = 0, mp = sizes[b - 1]; k < mp; k++) if (A[offA[b] + l * mp + k] !== 0) add(offX[b - 1] + k, A[offA[b] + l * mp + k]);
|
|
207
|
+
if (b < nB - 1) for (let k = 0, mn = sizes[b + 1]; k < mn; k++) if (C[offC[b] + l * mn + k] !== 0) add(offX[b + 1] + k, C[offC[b] + l * mn + k]);
|
|
208
|
+
if (s.lin) {
|
|
209
|
+
const into = s.linRow ?? (s.linRow = new Float64Array(sys.size + 1));
|
|
210
|
+
s.lin.capture(s.rix[o], 1, 0, into);
|
|
211
|
+
for (let k = 0; k < sys.size; k++) {
|
|
212
|
+
if (into[k] !== 0) add(k, into[k]);
|
|
213
|
+
into[k] = 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// An electrode surface's sites per volume at node g, Γ a (mol/m³): what a coverage of 1 holds,
|
|
219
|
+
// beside the concentrations of a local constraint. (Summed as θ alone, an immobile S that adsorbs
|
|
220
|
+
// as A(ads) kept S + θ_A, mol/m³ and a fraction.)
|
|
221
|
+
function sites(s, g) {
|
|
222
|
+
const k = s.surfPort[g];
|
|
223
|
+
return s.model.ports[k].surface[0].capacity * s.portArea[k][s.surfW[g]];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
|
|
227
|
+
function applyLocalConstraints(s) {
|
|
228
|
+
const { n, M, c, th, nSurf, res, dA: d } = s;
|
|
229
|
+
for (const lc of s.localConstraints) {
|
|
230
|
+
lc.nodes.forEach((g, j) => {
|
|
231
|
+
const b = s.blockOfNode[g];
|
|
232
|
+
if (s.loc[b * M + lc.row] < 0) return;
|
|
233
|
+
s._replaceRow(b, lc.row);
|
|
234
|
+
for (const B of s.termB) B[s.rix[b * M + lc.row]] = 0; // (a port reaction's ∂/∂V, where one touched this slot)
|
|
235
|
+
let t = 0;
|
|
236
|
+
for (const { i, q, k, w } of lc.parts) {
|
|
237
|
+
if (i !== undefined) {
|
|
238
|
+
t += w * c[g * n + i];
|
|
239
|
+
s._dc(g, i, d);
|
|
240
|
+
for (let r = 0; r < M; r++) if (d[r] !== 0) s._j(b, lc.row, b, r, w * d[r]);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species (where it is).
|
|
244
|
+
if (s.surfPort[g] !== k) continue;
|
|
245
|
+
const tq = th[g * nSurf + q], ns = s.model.ports[s.surfPort[g]].surface.length, ws = w * sites(s, g);
|
|
246
|
+
t += ws * tq;
|
|
247
|
+
for (let x = 0; x < ns; x++) s._j(b, lc.row, b, 1 + n + x, ws * tq * ((q === x ? 1 : 0) - th[g * nSurf + x]));
|
|
248
|
+
}
|
|
249
|
+
res[s.rix[b * M + lc.row]] = t - lc.reference[j];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Conservation rows for the spectators (steady solves only): Σ v c_i = amount over the
|
|
255
|
+
// stretch replaces the balance row of its first node, which in steady state is the negative
|
|
256
|
+
// sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
|
|
257
|
+
// (identity row) there instead, and solveBordered restores the constraint.
|
|
258
|
+
function applyConstraints(s) {
|
|
259
|
+
const { M, res } = s;
|
|
260
|
+
for (const cs of s.constraints) {
|
|
261
|
+
const { amount, reference, dV } = lawAmount(s, cs);
|
|
262
|
+
cs.res = amount - (cs.terminals.length > 0 ? (s.lawRefs.get(cs.key) ?? amount) : reference);
|
|
263
|
+
if (cs.circuit >= 0) {
|
|
264
|
+
// In place of the terminal's circuit row (its I = 0).
|
|
265
|
+
const C = s.termC[cs.circuit];
|
|
266
|
+
C.fill(0);
|
|
267
|
+
for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
|
|
268
|
+
for (const { t, lambda } of cs.contacts) {
|
|
269
|
+
// A contact's charge, D_in, as Solver._contact had it, with its derivatives.
|
|
270
|
+
const q = s.contactQ[t], f = lambda / FARADAY;
|
|
271
|
+
cs.res += f * q.D;
|
|
272
|
+
for (let j = 0; j < C.length; j++) C[j] += f * q.row[j];
|
|
273
|
+
dV[t] += f * q.dV;
|
|
274
|
+
}
|
|
275
|
+
s.termRes[cs.circuit] = cs.res;
|
|
276
|
+
s.termDI[cs.circuit] = dV[cs.circuit];
|
|
277
|
+
dV.forEach((x, k) => k !== cs.circuit && (s.termDX[cs.circuit][k] = x));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
|
|
281
|
+
s._replaceRow(b0, r0);
|
|
282
|
+
s._j(b0, r0, b0, r0, 1);
|
|
283
|
+
res[s.rix[cs.row]] = 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A conserved combination's amount as the state holds it (and its reference, closed), with its
|
|
288
|
+
// derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
|
|
289
|
+
// counts).
|
|
290
|
+
function lawAmount(s, cs) {
|
|
291
|
+
const { n, M, c, dA: d } = s;
|
|
292
|
+
const vol = s.model.grid.vol;
|
|
293
|
+
let amount = 0, reference = 0, len = 0, gross = 0;
|
|
294
|
+
for (const { stretch, w } of cs.parts) {
|
|
295
|
+
const st = s.stretches[stretch], i = st.species;
|
|
296
|
+
if (cs.terminals.length === 0) reference += w * s.referenceAmounts[stretch];
|
|
297
|
+
for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
|
|
298
|
+
amount += w * vol[g] * c[g * n + i];
|
|
299
|
+
gross += Math.abs(w * vol[g] * c[g * n + i]);
|
|
300
|
+
if (s.nodeConductor[g] >= 0) {
|
|
301
|
+
// A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
|
|
302
|
+
// face's displacement.
|
|
303
|
+
const f = s.sheetFace[g];
|
|
304
|
+
if (f >= 0 && s.loc[s.blockOfFace[f] * M] >= 0) {
|
|
305
|
+
cs.idx[len] = s.rix[s.blockOfFace[f] * M];
|
|
306
|
+
cs.w[len++] = (w * s.sheetSign[g] * s.model.grid.area[g]) / (s.z[i] * FARADAY);
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
s._dc(g, i, d);
|
|
311
|
+
const b = s.blockOfNode[g];
|
|
312
|
+
for (let r = 0; r < M; r++) {
|
|
313
|
+
if (d[r] === 0 || s.loc[b * M + r] < 0) continue;
|
|
314
|
+
cs.idx[len] = s.rix[b * M + r];
|
|
315
|
+
cs.w[len++] = w * vol[g] * d[r];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
|
|
320
|
+
for (const { col, w } of cs.surface) {
|
|
321
|
+
const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
|
|
322
|
+
if (cs.terminals.length === 0) reference += w * s.surfaceRef[col];
|
|
323
|
+
port.nodes.forEach((g, j) => {
|
|
324
|
+
const f = w * G * vol[g] * s.portArea[k][j], b = s.blockOfNode[g], tq = s.th[g * s.nSurf + q];
|
|
325
|
+
amount += f * tq;
|
|
326
|
+
gross += Math.abs(f * tq);
|
|
327
|
+
for (let x = 0; x < ns; x++) {
|
|
328
|
+
cs.idx[len] = s.rix[b * M + 1 + n + x];
|
|
329
|
+
cs.w[len++] = f * tq * ((q === x ? 1 : 0) - s.th[g * s.nSurf + x]);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
|
|
334
|
+
// Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as Solver._portCapacitance has it).
|
|
335
|
+
const dV = cs.dV ?? (cs.dV = new Float64Array(s.terms.length));
|
|
336
|
+
dV.fill(0);
|
|
337
|
+
for (const { k, w } of cs.carriers) {
|
|
338
|
+
const port = s.model.ports[k - 2];
|
|
339
|
+
if (!port.capacitance) continue;
|
|
340
|
+
const f = w / (carrierZ(s, k) * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = s.termV[k];
|
|
341
|
+
const sets = (side) => ['pinned', 'bulk'].includes(s.model.contacts[side].phi.type);
|
|
342
|
+
port.nodes.forEach((g, j) => {
|
|
343
|
+
if ((g === 0 && sets('left')) || (g === s.nNodes - 1 && sets('right')) || s.phiUndefined[g]) return;
|
|
344
|
+
const b = s.blockOfNode[g], cap = vol[g] * s.portArea[k - 2][j] * Cs;
|
|
345
|
+
amount += f * cap * (V - zeroCharge - s.VT * (s.u[b * M] + s.uLo[b * M]));
|
|
346
|
+
gross += Math.abs(f * cap * (V - zeroCharge - s.VT * (s.u[b * M] + s.uLo[b * M])));
|
|
347
|
+
dV[k] += f * cap;
|
|
348
|
+
cs.idx[len] = s.rix[b * M];
|
|
349
|
+
cs.w[len++] = -f * cap * s.VT;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
cs.len = len;
|
|
353
|
+
return { amount, reference, dV, gross }; // (dV by terminal)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The same amount at the step's start, as the storage terms have it (BDF2's c* included).
|
|
357
|
+
function lawStart(s, cs) {
|
|
358
|
+
const { n, nSurf } = s, vol = s.model.grid.vol;
|
|
359
|
+
let a = 0;
|
|
360
|
+
for (const { stretch, w } of cs.parts) {
|
|
361
|
+
const st = s.stretches[stretch], i = st.species;
|
|
362
|
+
for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * s.cOld[g * n + i];
|
|
363
|
+
}
|
|
364
|
+
for (const { col, w } of cs.surface) {
|
|
365
|
+
const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity;
|
|
366
|
+
port.nodes.forEach((g, j) => (a += w * G * vol[g] * s.portArea[k][j] * s.thOld[g * nSurf + q]));
|
|
367
|
+
}
|
|
368
|
+
for (const { k, w } of cs.carriers) if (s.model.ports[k - 2].capacitance) a += (w * s.portQStart[k - 2]) / (carrierZ(s, k) * FARADAY);
|
|
369
|
+
return a;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Time steps: a terminal driven by a current that alone feeds a conserved combination can read
|
|
373
|
+
// its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
|
|
374
|
+
// to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
|
|
375
|
+
// in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
|
|
376
|
+
// gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
|
|
377
|
+
// Over long steps the change is the better kept (a closed host at open circuit, read by its
|
|
378
|
+
// flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
|
|
379
|
+
// short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
|
|
380
|
+
// electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
|
|
381
|
+
// its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
|
|
382
|
+
// where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
|
|
383
|
+
// throughout, and where a step read by the flux fails, it's tried once more read by the change
|
|
384
|
+
// (see step).
|
|
385
|
+
function circuitLaws(s, dt) {
|
|
386
|
+
for (const cs of [...s.constraints, ...s.timeLaws]) {
|
|
387
|
+
const k = cs.circuit;
|
|
388
|
+
if (k < 0 || !s.floating.includes(k) || cs.contacts?.length > 0) continue; // (a contact's charge: its own rows)
|
|
389
|
+
const { amount, dV, gross } = lawAmount(s, cs), f = FARADAY / (cs.lambda * dt), C = s.termC[k];
|
|
390
|
+
if (!s.lawForm.has(cs)) {
|
|
391
|
+
let G = 0;
|
|
392
|
+
for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
|
|
393
|
+
s.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
|
|
394
|
+
}
|
|
395
|
+
if (!s.lawForm.get(cs)) continue;
|
|
396
|
+
C.fill(0);
|
|
397
|
+
for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
|
|
398
|
+
const d = s.terms[k].drive, src = sourceAt(d.src, s.sourceTime, s.sourceBefore);
|
|
399
|
+
s.termRes[k] = f * (amount - lawStart(s, cs)) - (d.kind === 'I' ? src : (src - s.termV[k]) / d.R);
|
|
400
|
+
s.termDI[k] = f * dV[k] + (d.kind === 'I' ? 0 : 1 / d.R);
|
|
401
|
+
dV.forEach((x, j) => j !== k && (s.termDX[k][j] = f * x));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
|
|
406
|
+
// block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
|
|
407
|
+
// the rows pinned in T: the spectators' conserved amounts (see applyConstraints) and the
|
|
408
|
+
// islands' summed balances (see applyIslands). By low-rank
|
|
409
|
+
// updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
|
|
410
|
+
// (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
|
|
411
|
+
// system for the μ (pins) and δV (terminals):
|
|
412
|
+
// pin q: Σ (W_q·Q_q′) μ_q′ − Σ (W_q·X_k) δV_k = res_q − W_q·y
|
|
413
|
+
// terminal k: Σ (C_k·Q_q) μ_q + Σ (δ_kk′ ∂I_k/∂V_k − C_k·X_k′) δV_k′ = res_k − C_k·y
|
|
414
|
+
export function solveBordered(s, rhs, delta, deltaV, pins) {
|
|
415
|
+
const N = s.sys.size, fl = s.floating, P = pins.length, K = fl.length, S = P + K;
|
|
416
|
+
s._solveLinear(rhs, delta);
|
|
417
|
+
if (S === 0) return;
|
|
418
|
+
const e = s.dWork ?? (s.dWork = new Float64Array(N + 1));
|
|
419
|
+
const cols = s.borderCols ?? (s.borderCols = []);
|
|
420
|
+
for (let a = 0; a < S; a++) {
|
|
421
|
+
if (!cols[a]) cols[a] = new Float64Array(N + 1);
|
|
422
|
+
if (a < P) {
|
|
423
|
+
// The response to a unit pin, or where the pin moves a flat stretch, to its shift.
|
|
424
|
+
const col = pins[a].col ?? [pins[a].row];
|
|
425
|
+
for (const o of col) e[s.rix[o]] = 1;
|
|
426
|
+
s._solveLinear(e, cols[a]);
|
|
427
|
+
for (const o of col) e[s.rix[o]] = 0;
|
|
428
|
+
} else s._solveLinear(s.termB[fl[a - P]], cols[a]);
|
|
429
|
+
}
|
|
430
|
+
// Row a of the small system, as a dot product with a compact vector (sparse for pins).
|
|
431
|
+
const dot = (a, v) => {
|
|
432
|
+
let t = 0;
|
|
433
|
+
if (a < P) {
|
|
434
|
+
const p = pins[a];
|
|
435
|
+
for (let j = 0; j < p.len; j++) t += p.w[j] * v[p.idx[j]];
|
|
436
|
+
} else {
|
|
437
|
+
const C = s.termC[fl[a - P]];
|
|
438
|
+
for (let j = 0; j < N; j++) t += C[j] * v[j];
|
|
439
|
+
}
|
|
440
|
+
return t;
|
|
441
|
+
};
|
|
442
|
+
const A = Array.from({ length: S }, () => new Float64Array(S + 1));
|
|
443
|
+
for (let a = 0; a < S; a++) {
|
|
444
|
+
for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
|
|
445
|
+
if (a >= P) {
|
|
446
|
+
A[a][a] += s.termDI[fl[a - P]];
|
|
447
|
+
for (let b = P; b < S; b++) if (b !== a) A[a][b] += s.termDX[fl[a - P]][fl[b - P]];
|
|
448
|
+
}
|
|
449
|
+
A[a][S] = (a < P ? pins[a].res : s.termRes[fl[a - P]]) - dot(a, delta);
|
|
450
|
+
let mx = 0;
|
|
451
|
+
for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
|
|
452
|
+
if (mx > 0) for (let b = 0; b <= S; b++) A[a][b] /= mx;
|
|
453
|
+
}
|
|
454
|
+
// Small dense solve with partial pivoting.
|
|
455
|
+
for (let col = 0; col < S; col++) {
|
|
456
|
+
let p = col;
|
|
457
|
+
for (let r = col + 1; r < S; r++) if (Math.abs(A[r][col]) > Math.abs(A[p][col])) p = r;
|
|
458
|
+
[A[col], A[p]] = [A[p], A[col]];
|
|
459
|
+
if (A[col][col] === 0) throw new SolverError('the bordered system is singular: a conserved amount or a floating terminal is not determined');
|
|
460
|
+
for (let r = col + 1; r < S; r++) {
|
|
461
|
+
const f = A[r][col] / A[col][col];
|
|
462
|
+
for (let k = col; k <= S; k++) A[r][k] -= f * A[col][k];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const x = new Float64Array(S);
|
|
466
|
+
for (let r = S - 1; r >= 0; r--) {
|
|
467
|
+
let v = A[r][S];
|
|
468
|
+
for (let k = r + 1; k < S; k++) v -= A[r][k] * x[k];
|
|
469
|
+
x[r] = v / A[r][r];
|
|
470
|
+
}
|
|
471
|
+
for (let a = 0; a < S; a++) {
|
|
472
|
+
const sa = a < P ? x[a] : -x[a], col = cols[a];
|
|
473
|
+
for (let j = 0; j < N; j++) delta[j] += sa * col[j];
|
|
474
|
+
if (a >= P) deltaV[a - P] = x[a];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
|
|
479
|
+
// none) its first node's, which its amount fixes (that row is the amount's, a pin whose
|
|
480
|
+
// response moves the whole stretch: see applyConstraints).
|
|
481
|
+
function flatLevel(s, st) {
|
|
482
|
+
const { M, u, uLo, z, VT, model } = s, i = st.species;
|
|
483
|
+
if (st.leftOpen || st.rightOpen) {
|
|
484
|
+
const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
|
|
485
|
+
return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (s.termV[k] + link.offset)) / VT, lo: 0 };
|
|
486
|
+
}
|
|
487
|
+
const o = s.blockOfNode[st.nodes[0]] * M + 1 + i;
|
|
488
|
+
return { k: -1, hi: u[o], lo: uLo[o] };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
|
|
492
|
+
// updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
|
|
493
|
+
// expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
|
|
494
|
+
// read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
|
|
495
|
+
// 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
|
|
496
|
+
export function snapFlat(s) {
|
|
497
|
+
const { M, u, uLo } = s;
|
|
498
|
+
for (const st of s.flatStretches) {
|
|
499
|
+
const f = s.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
|
|
500
|
+
for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
|
|
501
|
+
const o = s.blockOfNode[g] * M + 1 + st.species;
|
|
502
|
+
if (s.loc[o] < 0) continue;
|
|
503
|
+
u[o] = hi;
|
|
504
|
+
uLo[o] = lo;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Steady solves: each flat stretch's level held at its contact's throughout (see
|
|
510
|
+
// flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
|
|
511
|
+
function flatRows(s) {
|
|
512
|
+
const { M, u, uLo, res, z, VT, model } = s, R = s.rix;
|
|
513
|
+
for (const st of s.flatStretches) {
|
|
514
|
+
const i = st.species, open = st.leftOpen || st.rightOpen;
|
|
515
|
+
const { k, hi, lo } = flatLevel(s, st);
|
|
516
|
+
for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
|
|
517
|
+
const b = s.blockOfNode[g], o = b * M + 1 + i;
|
|
518
|
+
if (s.loc[o] < 0) continue;
|
|
519
|
+
s._replaceRow(b, 1 + i);
|
|
520
|
+
for (const B of s.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
|
|
521
|
+
s._j(b, 1 + i, b, 1 + i, 1);
|
|
522
|
+
res[R[o]] = u[o] - hi + (uLo[o] - lo); // (the big terms first: hi + lo rounds to hi)
|
|
523
|
+
if (open && z[i] !== 0) s.termB[k][R[o]] += -z[i] / VT;
|
|
524
|
+
}
|
|
525
|
+
// A face inside the stretch whose link holds the level continuous carries the species' flux
|
|
526
|
+
// as an unknown that only the edge balances, now replaced, determined: it's zero.
|
|
527
|
+
for (let f = st.regions[0]; f < st.regions[1]; f++) {
|
|
528
|
+
if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
|
|
529
|
+
const b = s.blockOfFace[f], o = b * M + 1 + i;
|
|
530
|
+
if (s.loc[o] < 0) continue;
|
|
531
|
+
s._replaceRow(b, 1 + i);
|
|
532
|
+
for (const B of s.termB) B[R[o]] = 0;
|
|
533
|
+
s._j(b, 1 + i, b, 1 + i, 1);
|
|
534
|
+
res[R[o]] = u[o] + uLo[o];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Steady solves: each reaction that alone changes a combination (see src/laws.js) held at its
|
|
540
|
+
// equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
|
|
541
|
+
// like an amount's).
|
|
542
|
+
function applyAffinities(s) {
|
|
543
|
+
const { M, u, uLo, res } = s, grid = s.model.grid;
|
|
544
|
+
for (const af of s.affinities) {
|
|
545
|
+
const b0 = Math.floor(af.row / M), r0 = af.row % M;
|
|
546
|
+
s._replaceRow(b0, r0);
|
|
547
|
+
s._j(b0, r0, b0, r0, 1);
|
|
548
|
+
res[s.rix[af.row]] = 0;
|
|
549
|
+
const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
|
|
550
|
+
let hi = af.rx.fixedA, lo = 0, len = 0;
|
|
551
|
+
for (const p of af.rx.part) {
|
|
552
|
+
const o = s.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
|
|
553
|
+
hi -= p.nu * u[o];
|
|
554
|
+
lo -= p.nu * uLo[o];
|
|
555
|
+
if (s.loc[o] < 0) continue;
|
|
556
|
+
af.idx[len] = s.rix[o];
|
|
557
|
+
af.w[len++] = -p.nu;
|
|
558
|
+
}
|
|
559
|
+
af.len = len;
|
|
560
|
+
af.res = hi + lo;
|
|
561
|
+
}
|
|
562
|
+
}
|