driftlet 0.1.0

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.
Files changed (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
@@ -0,0 +1,593 @@
1
+ // Statistics: how a material's concentrations follow from its species' potentials.
2
+ //
3
+ // Every species i in a material has a reduced chemical potential
4
+ // ζ_i = (μ̄_i − z_i F φ − μ°_i) / RT,
5
+ // and a statistics model maps the ζ of the species it covers to their concentrations c(ζ) and
6
+ // the Jacobian K = ∂c/∂ζ (the chemical capacitance matrix, per RT). K is the Hessian of a
7
+ // convex potential, so it's symmetric and positive definite. Ideal (dilute) statistics are
8
+ // c = c_ref e^ζ, K = diag(c), and every model here reduces to them in the dilute limit, so μ°
9
+ // and c_ref keep one meaning (the dilute, Henry's-law reference) whatever the model.
10
+ //
11
+ // A model object covers `idx` (device species indices, k of them) and provides
12
+ // evaluate(zeta, c, K, bg) fill c (k) and K (k×k, row-major) at ζ (k)
13
+ // invert(zeta, fixed, target, bg) for each a with fixed[a], set ζ_a so that c_a = target[a],
14
+ // the other ζ held
15
+ // `bg` is the per-node background carrier density of an insertion host (0 elsewhere).
16
+
17
+ import { FARADAY, EPS0, AVOGADRO } from './constants.js';
18
+ import { fermiHalf, fermiMinusHalf } from './fermi.js';
19
+ import { SolverError } from './solver.js';
20
+
21
+ const MODEL_TYPES = ['ideal', 'fermi-dirac', 'lattice', 'redlich-kister', 'debye-huckel', 'insertion', 'custom'];
22
+
23
+ const logistic = (y) => (y >= 0 ? 1 / (1 + Math.exp(-y)) : Math.exp(y) / (1 + Math.exp(y)));
24
+
25
+ // Safeguarded Newton for an increasing function: f(y) = target on [lo, hi].
26
+ function solveIncreasing(f, target, lo, hi, guess) {
27
+ let y = Math.min(hi, Math.max(lo, guess));
28
+ for (let it = 0; it < 200; it++) {
29
+ const { v, d } = f(y);
30
+ const r = v - target;
31
+ if (r > 0) hi = y;
32
+ else lo = y;
33
+ let next = y - r / d;
34
+ if (!(next > lo && next < hi)) next = 0.5 * (lo + hi);
35
+ if (Math.abs(next - y) <= 1e-15 * Math.max(1, Math.abs(y)) || hi - lo <= 1e-15 * Math.max(1, Math.abs(y))) return next;
36
+ y = next;
37
+ }
38
+ return y;
39
+ }
40
+
41
+ // --- occupancy of a single set of sites: x ∈ (0, 1) against a reduced potential ζ
42
+ //
43
+ // ζ(x) is strictly increasing. RK: ζ = ln(x/(1−x)) + ln(c_max/c_ref) + (g(x) − g(0))/RT, with
44
+ // g the Redlich–Kister excess chemical potential. Table: ζ(x) interpolated from data.
45
+
46
+ function redlichKisterOccupancy(A, cMax, cRef, RT, fail) {
47
+ // Excess Gibbs energy per site x(1−x) Σ A_k u^k with u = 1 − 2x; g = its x-derivative.
48
+ const P = (u, d) => {
49
+ // d-th derivative of Σ A_k u^k
50
+ let s = 0;
51
+ for (let k = A.length - 1; k >= d; k--) {
52
+ let coef = A[k];
53
+ for (let q = 0; q < d; q++) coef *= k - q;
54
+ s = s * u + coef;
55
+ }
56
+ // Horner over the shifted polynomial above builds Σ coef_k u^{k−d}
57
+ return s;
58
+ };
59
+ const g = (x) => {
60
+ const u = 1 - 2 * x;
61
+ return u * P(u, 0) - ((1 - u * u) / 2) * P(u, 1);
62
+ };
63
+ const gp = (x) => {
64
+ const u = 1 - 2 * x;
65
+ return -2 * P(u, 0) - 4 * u * P(u, 1) + (1 - u * u) * P(u, 2);
66
+ };
67
+ const g0 = g(0), c0 = Math.log(cMax / cRef);
68
+ // Convexity: dζ/dy = 1 + x(1−x) g′(x)/RT > 0 on (0, 1), where y = ln(x/(1−x)).
69
+ let gmin = 0, gmax = 0;
70
+ for (let k = 0; k <= 4000; k++) {
71
+ const x = k / 4000;
72
+ const slope = 1 + (x * (1 - x) * gp(x)) / RT;
73
+ if (!(slope > 1e-9)) {
74
+ fail(
75
+ `the free energy is not convex near x = ${x.toFixed(3)} (a miscibility gap). Phase separation needs a ` +
76
+ 'Cahn–Hilliard model, which is on the roadmap',
77
+ );
78
+ }
79
+ const e = (g(x) - g0) / RT;
80
+ gmin = Math.min(gmin, e);
81
+ gmax = Math.max(gmax, e);
82
+ }
83
+ return {
84
+ zeta(x) {
85
+ return Math.log(x / (1 - x)) + c0 + (g(x) - g0) / RT;
86
+ },
87
+ // x(ζ), 1 − x, and dx/dζ
88
+ x(zeta) {
89
+ const f = (y) => {
90
+ const x = logistic(y);
91
+ const omx = logistic(-y);
92
+ return { v: y + (g(x) - g0) / RT, d: 1 + (x * omx * gp(x)) / RT };
93
+ };
94
+ const t = zeta - c0;
95
+ const y = A.length === 0 ? t : solveIncreasing(f, t, t - gmax - 1, t - gmin + 1, t);
96
+ const x = logistic(y), omx = logistic(-y);
97
+ return { x, omx, dxdz: (x * omx) / f(y).d };
98
+ },
99
+ };
100
+ }
101
+
102
+ // Tabulated ζ(x), as ζ = ln(x/(1−x)) + r(x): the residual r (the non-ideality) is a
103
+ // shape-preserving cubic (PCHIP) through the data, continued linearly beyond the ends. Ideal
104
+ // and regular-solution tables are reproduced exactly, and x stays within (0, 1) for any ζ.
105
+ function tableOccupancy(xs, zs, fail) {
106
+ const N = xs.length;
107
+ const logit = (x) => Math.log(x / (1 - x));
108
+ const rs = xs.map((x, k) => zs[k] - logit(x));
109
+ const h = [], del = [];
110
+ for (let k = 0; k < N - 1; k++) {
111
+ h.push(xs[k + 1] - xs[k]);
112
+ del.push((rs[k + 1] - rs[k]) / h[k]);
113
+ }
114
+ const d = new Float64Array(N);
115
+ for (let k = 1; k < N - 1; k++) {
116
+ if (del[k - 1] * del[k] <= 0) continue; // local extremum: flat
117
+ const w1 = 2 * h[k] + h[k - 1], w2 = h[k] + 2 * h[k - 1];
118
+ d[k] = (w1 + w2) / (w1 / del[k - 1] + w2 / del[k]);
119
+ }
120
+ const end = (dl, dn, hl, hn) => {
121
+ if (N === 2) return dl;
122
+ const s = ((2 * hl + hn) * dl - hl * dn) / (hl + hn);
123
+ if (s * dl <= 0) return 0;
124
+ if (dl * dn <= 0 && Math.abs(s) > 3 * Math.abs(dl)) return 3 * dl;
125
+ return s;
126
+ };
127
+ d[0] = end(del[0], del[1], h[0], h[1]);
128
+ d[N - 1] = end(del[N - 2], del[N - 3], h[N - 2], h[N - 3]);
129
+ // r(x) and r′(x)
130
+ const r = (x) => {
131
+ if (x <= xs[0]) return { v: rs[0] + d[0] * (x - xs[0]), d: d[0] };
132
+ if (x >= xs[N - 1]) return { v: rs[N - 1] + d[N - 1] * (x - xs[N - 1]), d: d[N - 1] };
133
+ let lo = 0, hi = N - 2;
134
+ while (lo < hi) {
135
+ const m = (lo + hi + 1) >> 1;
136
+ if (xs[m] <= x) lo = m;
137
+ else hi = m - 1;
138
+ }
139
+ const k = lo, t = (x - xs[k]) / h[k], t2 = t * t, t3 = t2 * t;
140
+ return {
141
+ v: (2 * t3 - 3 * t2 + 1) * rs[k] + (t3 - 2 * t2 + t) * h[k] * d[k] + (-2 * t3 + 3 * t2) * rs[k + 1] + (t3 - t2) * h[k] * d[k + 1],
142
+ d: ((6 * t2 - 6 * t) * rs[k] + (3 * t2 - 4 * t + 1) * h[k] * d[k] + (-6 * t2 + 6 * t) * rs[k + 1] + (3 * t2 - 2 * t) * h[k] * d[k + 1]) / h[k],
143
+ };
144
+ };
145
+ // In y = ln(x/(1−x)): ζ = y + r(x), dζ/dy = 1 + x(1−x) r′(x), which must stay positive.
146
+ const f = (y) => {
147
+ const x = logistic(y), q = r(x);
148
+ return { v: y + q.v, d: 1 + x * logistic(-y) * q.d };
149
+ };
150
+ let rmin = Infinity, rmax = -Infinity;
151
+ for (let k = 0; k <= 4000; k++) {
152
+ const x = k / 4000;
153
+ const q = r(x);
154
+ if (x > 0 && x < 1 && !(1 + x * (1 - x) * q.d > 1e-9)) {
155
+ fail(`ocv: the interpolated curve isn't monotone near x = ${x.toFixed(3)} (E must fall steadily with x; add points or smooth the data)`);
156
+ }
157
+ rmin = Math.min(rmin, q.v);
158
+ rmax = Math.max(rmax, q.v);
159
+ }
160
+ return {
161
+ zeta: (x) => logit(x) + r(x).v,
162
+ x(zeta) {
163
+ const y = solveIncreasing(f, zeta, zeta - rmax - 1, zeta - rmin + 1, zeta - r(0.5).v);
164
+ const x = logistic(y), omx = logistic(-y);
165
+ return { x, omx, dxdz: (x * omx) / f(y).d };
166
+ },
167
+ };
168
+ }
169
+
170
+ // --- models
171
+
172
+ function fermiDiracModel(idx, cRef, order) {
173
+ const F = order === 0.5 ? fermiHalf : (x) => (x > 0 ? x + Math.log1p(Math.exp(-x)) : Math.log1p(Math.exp(x)));
174
+ const dF = order === 0.5 ? fermiMinusHalf : logistic;
175
+ const k = idx.length;
176
+ return {
177
+ type: 'fermi-dirac',
178
+ idx,
179
+ evaluate(zeta, c, K) {
180
+ K.fill(0);
181
+ for (let a = 0; a < k; a++) {
182
+ c[a] = cRef[a] * F(zeta[a]);
183
+ K[a * k + a] = cRef[a] * dF(zeta[a]);
184
+ }
185
+ },
186
+ invert(zeta, fixed, target) {
187
+ for (let a = 0; a < k; a++) {
188
+ if (!fixed[a]) continue;
189
+ const r = target[a] / cRef[a];
190
+ // ln 𝓕(ζ) is increasing with slope 𝓕′/𝓕 ∈ (0, 1]
191
+ const f = (y) => ({ v: Math.log(F(y)), d: dF(y) / F(y) });
192
+ const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt((0.75 * Math.sqrt(Math.PI) * r) ** 2) : r;
193
+ zeta[a] = solveIncreasing(f, Math.log(r), Math.log(r) - 1, Math.max(guess, Math.log(r)) + 10, guess);
194
+ }
195
+ },
196
+ };
197
+ }
198
+
199
+ function latticeModel(idx, cRef, cMax) {
200
+ const k = idx.length;
201
+ const lref = cRef.map((c) => Math.log(c / cMax));
202
+ return {
203
+ type: 'lattice',
204
+ idx,
205
+ // c_a = c_max w_a / (1 + Σ w), w_a = (c_ref,a / c_max) e^{ζ_a}; K = diag(c) − c cᵀ/c_max.
206
+ evaluate(zeta, c, K) {
207
+ let m = 0;
208
+ for (let a = 0; a < k; a++) m = Math.max(m, zeta[a] + lref[a]);
209
+ let S = Math.exp(-m);
210
+ for (let a = 0; a < k; a++) {
211
+ c[a] = Math.exp(zeta[a] + lref[a] - m);
212
+ S += c[a];
213
+ }
214
+ for (let a = 0; a < k; a++) c[a] *= cMax / S;
215
+ for (let a = 0; a < k; a++) {
216
+ for (let b = 0; b < k; b++) K[a * k + b] = (a === b ? c[a] : 0) - (c[a] * c[b]) / cMax;
217
+ }
218
+ },
219
+ invert(zeta, fixed, target) {
220
+ let T = 0, m = 0;
221
+ for (let a = 0; a < k; a++) {
222
+ if (fixed[a]) T += target[a] / cMax;
223
+ else m = Math.max(m, zeta[a] + lref[a]);
224
+ }
225
+ if (!(T < 1)) throw new SolverError(`lattice: concentrations exceed the site density ${cMax}`);
226
+ let s = Math.exp(-m);
227
+ for (let a = 0; a < k; a++) if (!fixed[a]) s += Math.exp(zeta[a] + lref[a] - m);
228
+ const ln1W = m + Math.log(s); // ln(1 + Σ_unfixed w)
229
+ for (let a = 0; a < k; a++) if (fixed[a]) zeta[a] = Math.log(target[a] / cRef[a]) + ln1W - Math.log1p(-T);
230
+ },
231
+ };
232
+ }
233
+
234
+ // Single species on its own sites, with a Redlich–Kister excess (Langmuir when A is empty).
235
+ function redlichKisterModel(idx, cRef, cMax, occ) {
236
+ return {
237
+ type: 'redlich-kister',
238
+ idx,
239
+ evaluate(zeta, c, K) {
240
+ const o = occ.x(zeta[0]);
241
+ c[0] = cMax * o.x;
242
+ K[0] = cMax * o.dxdz;
243
+ },
244
+ invert(zeta, fixed, target) {
245
+ if (!fixed[0]) return;
246
+ const x = target[0] / cMax;
247
+ if (!(x > 0 && x < 1)) throw new SolverError(`redlich-kister: concentration ${target[0]} is outside (0, c_max)`);
248
+ zeta[0] = occ.zeta(x);
249
+ },
250
+ };
251
+ }
252
+
253
+ // Extended Debye–Hückel: ln γ_i = −z_i² ℓ_B κ / (2(1 + κa)), with κ² = F² Σ z² c / (ε RT) and
254
+ // c_i = c_ref,i e^{ζ_i} / γ_i solved by Newton in ln c. The excess Hessian is rank one, so
255
+ // K = diag(c) + A (s∘c)(s∘c)ᵀ / (1 − A Σ s² c), with s = z² and
256
+ // A = ℓ_B F² / (4 ε RT κ (1 + κa)²). Convex while 1 − A Σ s² c > 0.
257
+ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
258
+ const k = idx.length;
259
+ const eps = epsr * EPS0;
260
+ const lB = (FARADAY * FARADAY) / (4 * Math.PI * eps * AVOGADRO * RT);
261
+ const s = z.map((zi) => zi * zi);
262
+ const y = new Float64Array(k), cc = new Float64Array(k), lng = new Float64Array(k);
263
+ const state = (c) => {
264
+ let q = 0;
265
+ for (let b = 0; b < k; b++) q += s[b] * c[b];
266
+ const kappa = Math.sqrt((FARADAY * FARADAY * q) / (eps * RT));
267
+ const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a) ** 2) : 0;
268
+ let s2c = 0;
269
+ for (let b = 0; b < k; b++) s2c += s[b] * s[b] * c[b];
270
+ for (let b = 0; b < k; b++) lng[b] = (-s[b] * lB * kappa) / (2 * (1 + kappa * a));
271
+ const denom = 1 - A * s2c;
272
+ if (!(denom > 0)) {
273
+ throw new SolverError(
274
+ `debye-huckel: the activity model is not convex at κ = ${kappa.toExponential(3)} /m (ionic strength too high for Debye–Hückel)`,
275
+ );
276
+ }
277
+ return { A, denom };
278
+ };
279
+ return {
280
+ type: 'debye-huckel',
281
+ idx,
282
+ evaluate(zeta, c, K) {
283
+ for (let b = 0; b < k; b++) y[b] = zeta[b] + Math.log(cRef[b]);
284
+ let st;
285
+ for (let it = 0; it < 100; it++) {
286
+ for (let b = 0; b < k; b++) cc[b] = Math.exp(y[b]);
287
+ st = state(cc);
288
+ // R = y + ln γ − ζ − ln c_ref; J = I − A s (s∘c)ᵀ; J⁻¹R = R + A s ((s∘c)·R) / denom
289
+ let dot = 0;
290
+ const R = c; // scratch
291
+ for (let b = 0; b < k; b++) {
292
+ R[b] = y[b] + lng[b] - zeta[b] - Math.log(cRef[b]);
293
+ dot += s[b] * cc[b] * R[b];
294
+ }
295
+ let mx = 0;
296
+ for (let b = 0; b < k; b++) {
297
+ let dy = R[b] + (st.A * s[b] * dot) / st.denom;
298
+ if (dy > 1) dy = 1;
299
+ else if (dy < -1) dy = -1;
300
+ y[b] -= dy;
301
+ mx = Math.max(mx, Math.abs(dy));
302
+ }
303
+ if (mx < 1e-14) break;
304
+ }
305
+ for (let b = 0; b < k; b++) c[b] = Math.exp(y[b]);
306
+ st = state(c);
307
+ const f = st.A / st.denom;
308
+ for (let p = 0; p < k; p++) {
309
+ for (let q = 0; q < k; q++) K[p * k + q] = (p === q ? c[p] : 0) + f * s[p] * c[p] * s[q] * c[q];
310
+ }
311
+ },
312
+ invert(zeta, fixed, target) {
313
+ for (let b = 0; b < k; b++) cc[b] = fixed[b] ? target[b] : cRef[b] * Math.exp(zeta[b]);
314
+ for (let it = 0; it < 500; it++) {
315
+ state(cc);
316
+ let change = 0;
317
+ for (let b = 0; b < k; b++) {
318
+ if (fixed[b]) continue;
319
+ const next = cRef[b] * Math.exp(zeta[b] - lng[b]);
320
+ if (cc[b] > 0) change = Math.max(change, Math.abs(next / cc[b] - 1));
321
+ cc[b] = next;
322
+ }
323
+ if (!(change > 1e-15)) break;
324
+ }
325
+ state(cc);
326
+ for (let b = 0; b < k; b++) if (fixed[b]) zeta[b] = Math.log(target[b] / cRef[b]) + lng[b];
327
+ },
328
+ };
329
+ }
330
+
331
+ // A neutral combination ion + ν carrier in a strictly neutral host (e.g. Li⁺ + e⁻). The host's
332
+ // content s depends only on ζ_ion + ν ζ_carrier (the combination's chemical potential, i.e. the
333
+ // OCV curve); the carrier is s ν plus the host's own background carriers.
334
+ // c_ion = s, c_carrier = ν s + bg, K = s′ [1 ν; ν ν²]
335
+ function insertionModel(idx, nu, cMax, occ) {
336
+ return {
337
+ type: 'insertion',
338
+ idx,
339
+ evaluate(zeta, c, K, bg) {
340
+ const o = occ.x(zeta[0] + nu * zeta[1]);
341
+ const s = cMax * o.x, ds = cMax * o.dxdz;
342
+ c[0] = s;
343
+ c[1] = nu * s + bg;
344
+ K[0] = ds;
345
+ K[1] = K[2] = nu * ds;
346
+ K[3] = nu * nu * ds;
347
+ },
348
+ invert(zeta, fixed, target, bg) {
349
+ if (fixed[0]) {
350
+ const x = target[0] / cMax;
351
+ if (!(x > 0 && x < 1)) throw new SolverError(`insertion: concentration ${target[0]} is outside (0, c_max)`);
352
+ const zc = occ.zeta(x);
353
+ if (fixed[1] && Math.abs(target[1] - (nu * target[0] + bg)) > 1e-9 * target[1]) {
354
+ throw new SolverError(`insertion: carrier concentration ${target[1]} is inconsistent with ${nu}·${target[0]} + background ${bg}`);
355
+ }
356
+ zeta[0] = zc - nu * zeta[1];
357
+ } else if (fixed[1]) {
358
+ const x = (target[1] - bg) / (nu * cMax);
359
+ if (!(x > 0 && x < 1)) throw new SolverError(`insertion: carrier concentration ${target[1]} is outside the host's range`);
360
+ zeta[1] = (occ.zeta(x) - zeta[0]) / nu;
361
+ }
362
+ },
363
+ };
364
+ }
365
+
366
+ // User function: evaluate(zeta) → { c: number[], dcdzeta: number[][] }.
367
+ function customModel(idx, fn, fail) {
368
+ const k = idx.length;
369
+ const call = (zeta, c, K) => {
370
+ const out = fn(Array.from(zeta));
371
+ for (let a = 0; a < k; a++) {
372
+ c[a] = out.c[a];
373
+ for (let b = 0; b < k; b++) K[a * k + b] = out.dcdzeta[a][b];
374
+ }
375
+ };
376
+ // Construction check at ζ = 0: finite, positive, symmetric, positive definite.
377
+ const c = new Float64Array(k), K = new Float64Array(k * k);
378
+ let ok = true;
379
+ try {
380
+ call(new Float64Array(k), c, K);
381
+ } catch (err) {
382
+ fail(`evaluate failed at ζ = 0: ${err.message}`);
383
+ }
384
+ for (let a = 0; a < k; a++) {
385
+ if (!(c[a] > 0 && Number.isFinite(c[a]))) ok = false;
386
+ for (let b = 0; b < k; b++) {
387
+ if (!Number.isFinite(K[a * k + b])) ok = false;
388
+ if (Math.abs(K[a * k + b] - K[b * k + a]) > 1e-8 * (Math.abs(K[a * k + a]) + Math.abs(K[b * k + b]))) {
389
+ fail("evaluate: ∂c/∂ζ must be symmetric (it's the Hessian of a potential)");
390
+ }
391
+ }
392
+ }
393
+ if (!ok) fail('evaluate must return positive, finite c and a finite dcdzeta');
394
+ if (!choleskyOk(K, k)) fail('evaluate: ∂c/∂ζ must be positive definite (the potential must be convex)');
395
+ const r = new Float64Array(k), J = new Float64Array(k * k), dz = new Float64Array(k);
396
+ return {
397
+ type: 'custom',
398
+ idx,
399
+ evaluate: call,
400
+ invert(zeta, fixed, target) {
401
+ const cc = new Float64Array(k), KK = new Float64Array(k * k);
402
+ const f = [];
403
+ for (let a = 0; a < k; a++) if (fixed[a]) f.push(a);
404
+ if (f.length === 0) return;
405
+ for (let it = 0; it < 200; it++) {
406
+ call(zeta, cc, KK);
407
+ const m = f.length;
408
+ for (let p = 0; p < m; p++) {
409
+ r[p] = cc[f[p]] - target[f[p]];
410
+ for (let q = 0; q < m; q++) J[p * m + q] = KK[f[p] * k + f[q]];
411
+ }
412
+ denseSolve(J, r, dz, m);
413
+ let mx = 0;
414
+ for (let p = 0; p < m; p++) mx = Math.max(mx, Math.abs(dz[p]));
415
+ const scale = mx > 2 ? 2 / mx : 1;
416
+ for (let p = 0; p < m; p++) zeta[f[p]] -= scale * dz[p];
417
+ if (mx < 1e-14) return;
418
+ }
419
+ throw new SolverError('custom statistics: could not find ζ for the given concentrations');
420
+ },
421
+ };
422
+ }
423
+
424
+ function choleskyOk(K, k) {
425
+ const L = Float64Array.from(K);
426
+ for (let j = 0; j < k; j++) {
427
+ let d = L[j * k + j];
428
+ for (let p = 0; p < j; p++) d -= L[j * k + p] ** 2;
429
+ if (!(d > 0)) return false;
430
+ d = Math.sqrt(d);
431
+ L[j * k + j] = d;
432
+ for (let i = j + 1; i < k; i++) {
433
+ let v = L[i * k + j];
434
+ for (let p = 0; p < j; p++) v -= L[i * k + p] * L[j * k + p];
435
+ L[i * k + j] = v / d;
436
+ }
437
+ }
438
+ return true;
439
+ }
440
+
441
+ // Gaussian elimination with partial pivoting (small dense systems).
442
+ function denseSolve(A, b, x, m) {
443
+ const M = Float64Array.from(A.subarray(0, m * m)), v = Float64Array.from(b.subarray(0, m));
444
+ for (let c = 0; c < m; c++) {
445
+ let p = c;
446
+ for (let r = c + 1; r < m; r++) if (Math.abs(M[r * m + c]) > Math.abs(M[p * m + c])) p = r;
447
+ if (p !== c) {
448
+ for (let q = 0; q < m; q++) [M[c * m + q], M[p * m + q]] = [M[p * m + q], M[c * m + q]];
449
+ [v[c], v[p]] = [v[p], v[c]];
450
+ }
451
+ for (let r = c + 1; r < m; r++) {
452
+ const f = M[r * m + c] / M[c * m + c];
453
+ for (let q = c; q < m; q++) M[r * m + q] -= f * M[c * m + q];
454
+ v[r] -= f * v[c];
455
+ }
456
+ }
457
+ for (let r = m - 1; r >= 0; r--) {
458
+ let s = v[r];
459
+ for (let q = r + 1; q < m; q++) s -= M[r * m + q] * x[q];
460
+ x[r] = s / M[r * m + r];
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Validate a material's `statistics` list and build its models.
466
+ * @returns {{ models: object[], modelOf: Int32Array, warnings: string[], phiFree: boolean }}
467
+ */
468
+ export function normalizeStatistics(mat, path, species, speciesIndex, RT, h) {
469
+ const { need, finite, positive } = h;
470
+ const n = species.length;
471
+ const modelOf = new Int32Array(n).fill(-1);
472
+ const models = [];
473
+ const warnings = [];
474
+ const list = mat.statistics ?? [];
475
+ need(Array.isArray(list), `${path}.statistics must be an array of statistics models`);
476
+ list.forEach((sdef, m) => {
477
+ const sp = `${path}.statistics[${m}]`;
478
+ const fail = (msg) => need(false, `${sp}: ${msg}`);
479
+ need(sdef !== null && typeof sdef === 'object' && !Array.isArray(sdef), `${sp} must be an object`);
480
+ need(MODEL_TYPES.includes(sdef.type), `${sp}.type must be one of ${MODEL_TYPES.join(', ')}`);
481
+ const allowed = {
482
+ 'fermi-dirac': ['order'],
483
+ lattice: ['cMax'],
484
+ 'redlich-kister': ['cMax', 'A'],
485
+ 'debye-huckel': ['epsr', 'a'],
486
+ insertion: ['cMax', 'A', 'ocv'],
487
+ custom: ['evaluate'],
488
+ }[sdef.type];
489
+ for (const k of Object.keys(sdef)) need(k === 'type' || k === 'species' || allowed.includes(k), `${sp}.${k}: not a field of a ${sdef.type} model (type, species, ${allowed.join(', ')})`);
490
+ need(Array.isArray(sdef.species) && sdef.species.length > 0, `${sp}.species must be a non-empty array of species names`);
491
+ const idx = sdef.species.map((name, a) => {
492
+ need(speciesIndex.has(name), `${sp}.species[${a}]: unknown species ${JSON.stringify(name)}`);
493
+ const i = speciesIndex.get(name);
494
+ need(mat.present[i], `${sp}.species[${a}]: '${name}' is absent from this material`);
495
+ need(modelOf[i] === -1, `${sp}.species[${a}]: '${name}' already belongs to another statistics model`);
496
+ modelOf[i] = models.length;
497
+ return i;
498
+ });
499
+ const cRef = idx.map((i) => mat.cRef[i]);
500
+ const one = () => need(idx.length === 1, `${sp}.species: a ${sdef.type} model covers exactly one species`);
501
+ let model;
502
+ switch (sdef.type) {
503
+ case 'ideal':
504
+ idx.forEach((i) => (modelOf[i] = -1));
505
+ return;
506
+ case 'fermi-dirac': {
507
+ const order = sdef.order ?? 0.5;
508
+ need(order === 0.5 || order === 0, `${sp}.order must be 1/2 (3D parabolic band) or 0 (2D)`);
509
+ model = fermiDiracModel(idx, cRef, order);
510
+ break;
511
+ }
512
+ case 'lattice':
513
+ model = latticeModel(idx, cRef, positive(sdef.cMax, `${sp}.cMax`));
514
+ break;
515
+ case 'redlich-kister': {
516
+ one();
517
+ const cMax = positive(sdef.cMax, `${sp}.cMax`);
518
+ const A = sdef.A ?? [];
519
+ need(Array.isArray(A), `${sp}.A must be an array of Redlich–Kister coefficients (J/mol)`);
520
+ A.forEach((v, q) => finite(v, `${sp}.A[${q}]`));
521
+ model = redlichKisterModel(idx, cRef, cMax, redlichKisterOccupancy(A, cMax, cRef[0], RT, fail));
522
+ break;
523
+ }
524
+ case 'debye-huckel': {
525
+ idx.forEach((i) => need(species[i].z !== 0, `${sp}.species: '${species[i].name}' is neutral, so Debye–Hückel doesn't apply`));
526
+ const epsr = positive(sdef.epsr ?? (mat.epsr > 0 ? mat.epsr : undefined), `${sp}.epsr (the solvent permittivity for the theory)`);
527
+ const a = sdef.a === undefined ? 0 : finite(sdef.a, `${sp}.a`);
528
+ need(a >= 0, `${sp}.a (distance of closest approach, m) must be non-negative`);
529
+ if (mat.epsr > 0) {
530
+ warnings.push(
531
+ `${sp}: Debye–Hückel activity coefficients split single-ion activities by convention, but a material ` +
532
+ 'with ε > 0 resolves single-ion potentials; this mixes the two. Prefer it in ε = 0 materials.',
533
+ );
534
+ }
535
+ model = debyeHuckelModel(idx, cRef, idx.map((i) => species[i].z), epsr, a, RT);
536
+ break;
537
+ }
538
+ case 'insertion': {
539
+ need(idx.length === 2, `${sp}.species must be [ion, carrier]: the inserted ion and its compensating electronic carrier`);
540
+ const [ion, car] = idx;
541
+ const zi = species[ion].z, zc = species[car].z;
542
+ const nu = -zi / zc;
543
+ need(zi !== 0 && zc !== 0 && Number.isInteger(nu) && nu > 0, `${sp}.species: the ion's charge must be a positive multiple of the carrier's, with opposite sign`);
544
+ need(mat.epsr === 0, `${sp}: insertion statistics describe a neutral combination, so the material needs epsr: 0`);
545
+ need(
546
+ species.every((s, i) => !mat.present[i] || s.z === 0 || i === ion || i === car),
547
+ `${sp}: the host's only charged species must be the inserted ion and its carrier`,
548
+ );
549
+ const cMax = positive(sdef.cMax, `${sp}.cMax`);
550
+ let occ;
551
+ if (sdef.ocv !== undefined) {
552
+ need(sdef.A === undefined, `${sp}: give either A (Redlich–Kister) or ocv (a table), not both`);
553
+ const t = sdef.ocv;
554
+ need(t !== null && typeof t === 'object' && Array.isArray(t.x) && Array.isArray(t.E), `${sp}.ocv must be { x: [...], E: [...], muRef }`);
555
+ for (const k of Object.keys(t)) need(['x', 'E', 'muRef'].includes(k), `${sp}.ocv.${k}: not a field (x, E, muRef)`);
556
+ need(t.x.length >= 2 && t.x.length === t.E.length, `${sp}.ocv: x and E need the same length, at least 2`);
557
+ t.x.forEach((v, q) => {
558
+ finite(v, `${sp}.ocv.x[${q}]`);
559
+ need(v > 0 && v < 1, `${sp}.ocv.x[${q}] must be a site fraction in (0, 1)`);
560
+ need(q === 0 || v > t.x[q - 1], `${sp}.ocv.x must be strictly increasing`);
561
+ });
562
+ t.E.forEach((v, q) => {
563
+ finite(v, `${sp}.ocv.E[${q}]`);
564
+ need(q === 0 || v < t.E[q - 1], `${sp}.ocv.E must be strictly decreasing with x (plateaus are phase separation, not yet supported)`);
565
+ });
566
+ const muRef = finite(t.muRef, `${sp}.ocv.muRef (the combination's chemical potential in the reference electrode, J/mol)`);
567
+ // μ_ion + ν μ_carrier = muRef − z_ion F E, and ζ_comb = that minus the standard potentials.
568
+ const mu0 = mat.mu0[ion] + nu * mat.mu0[car];
569
+ occ = tableOccupancy(t.x, t.E.map((E) => (muRef - zi * FARADAY * E - mu0) / RT), fail);
570
+ } else {
571
+ const A = sdef.A ?? [];
572
+ need(Array.isArray(A), `${sp}.A must be an array of Redlich–Kister coefficients (J/mol)`);
573
+ A.forEach((v, q) => finite(v, `${sp}.A[${q}]`));
574
+ occ = redlichKisterOccupancy(A, cMax, cRef[0], RT, fail);
575
+ }
576
+ model = insertionModel(idx, nu, cMax, occ);
577
+ model.carrier = car;
578
+ break;
579
+ }
580
+ case 'custom':
581
+ need(typeof sdef.evaluate === 'function', `${sp}.evaluate must be a function (zeta) => ({ c, dcdzeta })`);
582
+ model = customModel(idx, sdef.evaluate, fail);
583
+ break;
584
+ }
585
+ models.push(model);
586
+ });
587
+ // Which ζ positions the models' species occupy, and remap modelOf to the kept models.
588
+ const charged = species.map((s, i) => mat.present[i] && s.z !== 0);
589
+ const inCombination = (i) => modelOf[i] >= 0 && models[modelOf[i]].type === 'insertion';
590
+ // Strictly neutral, and every charged species in a neutral combination: nothing depends on φ.
591
+ const phiFree = mat.epsr === 0 && charged.some(Boolean) && charged.every((ch, i) => !ch || inCombination(i));
592
+ return { models, modelOf, warnings, phiFree };
593
+ }