driftlet 0.1.1 → 0.3.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.
package/src/describe.js CHANGED
@@ -65,13 +65,26 @@ export function unitWarnings(def) {
65
65
  if (sp.cRef !== undefined) conc(`${sp_}.cRef`, sp.cRef);
66
66
  }
67
67
  }
68
+ const regionX0 = [0];
69
+ for (const reg of def.regions ?? []) regionX0.push(regionX0.at(-1) + (isObject(reg) && reg.length > 0 ? reg.length : 0));
68
70
  (def.regions ?? []).forEach((reg, r) => {
69
71
  if (!isObject(reg)) return;
70
72
  const path = `regions[${r}]`;
71
73
  if (reg.length > 1) warn(`${path}.length`, `${num(reg.length)} m is over a metre; lengths are m (units.um, units.nm)`);
72
74
  if (reg.length > 0 && reg.length < 1e-10) warn(`${path}.length`, `${num(reg.length)} m is less than an atom; lengths are m`);
73
75
  if (Math.abs(reg.fixedCharge) > 1e10) warn(`${path}.fixedCharge`, `${num(reg.fixedCharge)} C/m³ is over 6e22 cm⁻³ of charge; fixedCharge is C/m³ (units.perCm3(N) * FARADAY)`);
74
- for (const [sname, c] of Object.entries(reg.c0 ?? {})) conc(`${path}.c0.${sname}`, c);
76
+ for (const [sname, c] of Object.entries(reg.c0 ?? {})) {
77
+ if (!isObject(c)) {
78
+ conc(`${path}.c0.${sname}`, c);
79
+ continue;
80
+ }
81
+ if (Array.isArray(c.c)) conc(`${path}.c0.${sname}.c`, Math.max(...c.c));
82
+ // A profile is against the device's x, not the region's own.
83
+ const x0 = regionX0[r], x1 = x0 + reg.length;
84
+ if (Array.isArray(c.x) && c.x.length > 0 && (c.x.at(-1) < x0 || c.x[0] > x1)) {
85
+ warn(`${path}.c0.${sname}.x`, `the profile (x from ${num(c.x[0])} to ${num(c.x.at(-1))} m) misses the region (${num(x0)} to ${num(x1)} m); x is the device's, in m`);
86
+ }
87
+ }
75
88
  });
76
89
  const capacitance = (path, phi) => {
77
90
  if (isObject(phi) && phi.C > 10) warn(`${path}.phi.C`, `${num(phi.C)} F/m² is over 1000 µF/cm²; C is F/m² (1 µF/cm² = 0.01 F/m²)`);
@@ -129,7 +142,9 @@ export function describe(def) {
129
142
  // The screening concentration: Σ z²c from the initial composition, else the fixed charge's.
130
143
  let zzc = 0;
131
144
  species.forEach((sp, i) => {
132
- if (mat.present[i] && Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
145
+ if (!mat.present[i]) return;
146
+ if (Number.isFinite(reg.c0[i])) zzc += sp.z * sp.z * reg.c0[i];
147
+ else if (reg.c0Profile[i]) zzc += sp.z * sp.z * Math.max(...reg.c0Profile[i].c);
133
148
  });
134
149
  if (zzc === 0) zzc = Math.abs(reg.fixedCharge) / FARADAY;
135
150
  const Dmax = Math.max(0, ...species.map((sp, i) => (mat.present[i] && sp.z !== 0 ? mat.D[i] : 0)));
package/src/device.js CHANGED
@@ -178,19 +178,33 @@ export function normalizeDevice(def) {
178
178
  'so it must have the opposite sign to that carrier',
179
179
  );
180
180
  }
181
- // Initial composition: the starting state, and the conserved amount of any spectator.
181
+ // Initial composition: the starting state, and the conserved amount of any spectator. Each
182
+ // entry is a number, or a profile { x, c } against the device's x (piecewise linear, constant
183
+ // beyond its ends), which is the starting state even for a species a contact feeds.
182
184
  const c0 = new Float64Array(nSpecies).fill(NaN);
185
+ const c0Profile = new Array(nSpecies).fill(null);
183
186
  if (reg.c0 !== undefined) {
184
187
  need(isObject(reg.c0), `${path}.c0 must be an object mapping species names to concentrations`);
185
188
  for (const [sname, v] of Object.entries(reg.c0)) {
186
- need(speciesIndex.has(sname), `${path}.c0.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
189
+ const at = `${path}.c0.${sname}`;
190
+ need(speciesIndex.has(sname), `${at}: unknown species '${sname}'${known(speciesIndex)}`);
187
191
  const i = speciesIndex.get(sname);
188
- need(mat.present[i], `${path}.c0.${sname}: '${sname}' is absent from material '${mat.name}'`);
189
- need(
190
- isFiniteNumber(v) && v > 0,
191
- `${path}.c0.${sname} must be a concentration > 0 (mol/m³), got ${JSON.stringify(v)}; to have none of '${sname}' here, ` +
192
- `leave it out of material '${mat.name}' (a region of another material without it, if it's present elsewhere)`,
193
- );
192
+ need(mat.present[i], `${at}: '${sname}' is absent from material '${mat.name}'`);
193
+ const none = `; to have none of '${sname}' here, leave it out of material '${mat.name}' (a region of another material without it, if it's present elsewhere)`;
194
+ if (isObject(v)) {
195
+ fields(v, at, ['x', 'c']);
196
+ need(
197
+ Array.isArray(v.x) && Array.isArray(v.c) && v.x.length > 0 && v.x.length === v.c.length,
198
+ `${at} must be a profile { x, c }: two arrays of the same length, positions (m, the device's x) and concentrations (mol/m³)`,
199
+ );
200
+ v.x.forEach((xk, k) => {
201
+ need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${at}.x must be finite and strictly increasing, but x[${k}] is ${JSON.stringify(xk)}`);
202
+ });
203
+ v.c.forEach((ck, k) => need(isFiniteNumber(ck) && ck > 0, `${at}.c[${k}] must be a concentration > 0 (mol/m³), got ${JSON.stringify(ck)}${none}`));
204
+ c0Profile[i] = { x: Float64Array.from(v.x), c: Float64Array.from(v.c) };
205
+ continue;
206
+ }
207
+ need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, c }, got ${JSON.stringify(v)}${none}`);
194
208
  c0[i] = v;
195
209
  }
196
210
  }
@@ -200,6 +214,7 @@ export function normalizeDevice(def) {
200
214
  length,
201
215
  fixedCharge,
202
216
  c0,
217
+ c0Profile,
203
218
  background,
204
219
  velocity,
205
220
  mixing,
package/src/fermi.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // −2 ≤ x ≤ 50 piecewise Chebyshev fits, built on first use from an exact quadrature
7
7
  // x > 50 Sommerfeld's asymptotic expansion (its error is ~e^{−x})
8
8
 
9
+ import { powi, powr } from './pow.js';
10
+
9
11
  const INTERVALS = [-2, 1, 5, 12, 25, 50];
10
12
  const NCHEB = 24;
11
13
  const SQRT_PI = Math.sqrt(Math.PI);
@@ -64,7 +66,7 @@ function series(j, x) {
64
66
  const e = Math.exp(x);
65
67
  let term = e, s = 0;
66
68
  for (let k = 1; k <= 40; k++) {
67
- const t = term / k ** (j + 1);
69
+ const t = term / powr(k, j + 1);
68
70
  s += k % 2 ? t : -t;
69
71
  if (t < 1e-17 * s) break;
70
72
  term *= e;
@@ -73,7 +75,7 @@ function series(j, x) {
73
75
  }
74
76
 
75
77
  // η(2k) = (1 − 2^{1−2k}) ζ(2k), the Dirichlet eta function at even arguments.
76
- const ETA = [Math.PI ** 2 / 12, (7 * Math.PI ** 4) / 720, (31 * Math.PI ** 6) / 30240, 0.99623300185264789922, 0.99903950759827156564, 0.99975768514385819085];
78
+ const ETA = [powi(Math.PI, 2) / 12, (7 * powi(Math.PI, 4)) / 720, (31 * powi(Math.PI, 6)) / 30240, 0.99623300185264789922, 0.99903950759827156564, 0.99975768514385819085];
77
79
 
78
80
  function asymptotic(j, x) {
79
81
  // 𝓕_j(x) ~ x^{j+1}/Γ(j+2) · [1 + Σ_k 2η(2k) (j+1)(j)…(j+2−2k) x^{−2k}]
@@ -86,7 +88,7 @@ function asymptotic(j, x) {
86
88
  s += 2 * ETA[k - 1] * fall * p;
87
89
  }
88
90
  const gamma = j === 0.5 ? (3 * SQRT_PI) / 4 : SQRT_PI / 2; // Γ(j + 2)
89
- return (x ** (j + 1) / gamma) * s;
91
+ return (powr(x, j + 1) / gamma) * s;
90
92
  }
91
93
 
92
94
  function fermi(j, x) {
package/src/index.js CHANGED
@@ -146,9 +146,12 @@ export class Device {
146
146
  /**
147
147
  * Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
148
148
  * `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
149
- * which), so an animation can call it once per frame; the step size carries over.
149
+ * which), so an animation can call it once per frame; the step size carries over. `probes`
150
+ * read a species at points inside the device after every accepted step, into `trace.probes`
151
+ * (one array per probe, alongside `trace.t`): what a detector at x sees.
150
152
  * @param {number} tEnd s
151
- * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be' }} [opts]
153
+ * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
154
+ * probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
152
155
  * @returns {Solution}
153
156
  */
154
157
  advance(tEnd, opts) {
package/src/kit.js CHANGED
@@ -9,6 +9,7 @@ export { build, combine, layer, ohmic, bath } from './stack.js';
9
9
  export { parseEquation, stoichiometry, half, SHE, level } from './equation.js';
10
10
  export { traces, speciesRole, typeset } from './traces.js';
11
11
  export { live } from './live.js';
12
+ export { pulse, square, triangle, ramp, injector, recombination } from './sources.js';
12
13
  export { describe, unitWarnings } from './describe.js';
13
14
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
14
15
 
package/src/plot.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // rule on the page restyles them.
14
14
 
15
15
  import { traces } from './traces.js';
16
+ import { powi } from './pow.js';
16
17
 
17
18
  // Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
18
19
  // dash and the legend carry identity too). A role with more members than colours cycles.
@@ -83,7 +84,7 @@ const span = (label) => labelParts(label).reduce((n, p) => n + (p.sub ? 0.75 : 1
83
84
 
84
85
  function ticks(a, b, n) {
85
86
  const step0 = (b - a) / n;
86
- const mag = 10 ** Math.floor(Math.log10(step0));
87
+ const mag = powi(10, Math.floor(Math.log10(step0)));
87
88
  const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= step0) ?? 10 * mag;
88
89
  const out = [];
89
90
  for (let t = Math.ceil(a / step) * step; t <= b + 1e-9 * step; t += step) out.push(Math.abs(t) < 1e-9 * step ? 0 : t);
package/src/pow.js ADDED
@@ -0,0 +1,24 @@
1
+ // Powers that come out the same on every JavaScript engine. Math.pow (and **) doesn't: V8's
2
+ // changed between Node 22 and 24 by an ulp here and there, even for integer exponents, which was
3
+ // enough to flip a few accept/reject decisions in long adaptive transients. Integer exponents
4
+ // go by repeated squaring, half-integers add a correctly rounded square root, and anything else
5
+ // goes through exp and log (which agree across engine versions).
6
+
7
+ /** x^n for an integer n. */
8
+ export function powi(x, n) {
9
+ if (n < 0) return 1 / powi(x, -n);
10
+ let r = 1, b = x;
11
+ while (n > 0) {
12
+ if (n & 1) r *= b;
13
+ b *= b;
14
+ n >>= 1;
15
+ }
16
+ return r;
17
+ }
18
+
19
+ /** x^y for x ≥ 0 and any real y. */
20
+ export function powr(x, y) {
21
+ if (Number.isInteger(y)) return powi(x, y);
22
+ if (Number.isInteger(2 * y)) return powi(x, (2 * y - 1) / 2) * Math.sqrt(x);
23
+ return x === 0 ? (y > 0 ? 0 : Infinity) : Math.exp(y * Math.log(x));
24
+ }
package/src/solver.js CHANGED
@@ -145,6 +145,22 @@ import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js
145
145
  import { bernoulli, bernoulliDerivative } from './bernoulli.js';
146
146
  import { EPS0, FARADAY } from './constants.js';
147
147
  import { nextBreakpoint, sourceAt } from './device.js';
148
+ import { DeviceError } from './errors.js';
149
+ import { powi, powr } from './pow.js';
150
+
151
+ // A c0 profile's value at x: piecewise linear, constant beyond its ends.
152
+ function profileAt({ x, c }, at) {
153
+ if (at <= x[0]) return c[0];
154
+ const last = x.length - 1;
155
+ if (at >= x[last]) return c[last];
156
+ let lo = 0, hi = last;
157
+ while (hi - lo > 1) {
158
+ const mid = (lo + hi) >> 1;
159
+ if (x[mid] <= at) lo = mid;
160
+ else hi = mid;
161
+ }
162
+ return c[lo] + ((c[hi] - c[lo]) * (at - x[lo])) / (x[hi] - x[lo]);
163
+ }
148
164
 
149
165
  export class SolverError extends Error {
150
166
  constructor(message, details) {
@@ -738,9 +754,9 @@ export class Solver {
738
754
  }
739
755
 
740
756
  /**
741
- * Cold start. Species connected to a contact take that contact's level; spectators take
742
- * their region's c0 (which fixes their conserved amount). φ in each region is then chosen
743
- * for local neutrality, or continued across the interface dipole if nothing there responds.
757
+ * Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
758
+ * the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
759
+ * profile varies), or continued across the interface dipole if nothing there responds.
744
760
  */
745
761
  initFromComposition() {
746
762
  const { model, n, M, u, z } = this;
@@ -754,10 +770,15 @@ export class Solver {
754
770
  if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
755
771
  const reg = regions[r], mat = materials[reg.material];
756
772
  mode.fill(0);
773
+ const profiled = reg.c0Profile.some((p) => p !== null);
757
774
  for (let i = 0; i < n; i++) {
758
775
  if (!mat.present[i]) continue;
759
776
  const st = this.stretches[this.stretchOf[r * n + i]];
760
- if (!st.contactFed && mat.conductor) {
777
+ if (reg.c0Profile[i] || reg.c0[i] > 0) {
778
+ // A given c0 is the starting state, even where a contact feeds the species.
779
+ mode[i] = 2;
780
+ cFix[i] = reg.c0Profile[i] ? profileAt(reg.c0Profile[i], grid.x[grid.regionStart[r]]) : reg.c0[i];
781
+ } else if (!st.contactFed && mat.conductor) {
761
782
  // A conductor away from the contacts: start uncharged, with its carrier's level in
762
783
  // equilibrium with the first reaction on its left face that takes it, else at the
763
784
  // running φ.
@@ -818,7 +839,8 @@ export class Solver {
818
839
  return q;
819
840
  };
820
841
  const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
821
- if (!responds && !mat.conductor && !mat.phiFree && mat.epsr === 0) {
842
+ const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
843
+ const checkNeutral = (where) => {
822
844
  // Strictly neutral, with every charged species' amount given: its c0 must be neutral.
823
845
  let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
824
846
  for (let i = 0; i < n; i++) {
@@ -828,11 +850,12 @@ export class Solver {
828
850
  }
829
851
  if (Math.abs(q) > 1e-9 * scale) {
830
852
  throw new SolverError(
831
- `regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³ (Σ z·c0, with any fixed charge), ` +
853
+ `regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³${where} (Σ z·c0, with any fixed charge), ` +
832
854
  'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
833
855
  );
834
856
  }
835
- }
857
+ };
858
+ if (mustBalance && !profiled) checkNeutral('');
836
859
  if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
837
860
  // Nothing here fixes φ, but an electrode on the left does: start with its first reaction
838
861
  // that takes a species from this side at equilibrium (the electrode at its open-circuit
@@ -853,8 +876,8 @@ export class Solver {
853
876
  }
854
877
  }
855
878
  }
856
- if (!mat.phiFree && responds) {
857
- let lo = phiHat - 1, hi = phiHat + 1;
879
+ const neutralPhi = (ph) => {
880
+ let lo = ph - 1, hi = ph + 1;
858
881
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
859
882
  while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
860
883
  for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
@@ -862,10 +885,17 @@ export class Solver {
862
885
  if (charge(m) > 0) lo = m;
863
886
  else hi = m;
864
887
  }
865
- phiHat = 0.5 * (lo + hi);
866
- }
888
+ return 0.5 * (lo + hi);
889
+ };
890
+ if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
867
891
  if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
868
892
  for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
893
+ if (profiled) {
894
+ for (let i = 0; i < n; i++) if (reg.c0Profile[i]) cFix[i] = profileAt(reg.c0Profile[i], grid.x[g]);
895
+ if (mustBalance) checkNeutral(` at x = ${grid.x[g].toPrecision(4)} m`);
896
+ if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
897
+ if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
898
+ }
869
899
  const b = this.blockOfNode[g];
870
900
  u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
871
901
  for (let i = 0; i < n; i++) {
@@ -1250,7 +1280,7 @@ export class Solver {
1250
1280
  const i = rx.sp[p], nu = rx.nu[p];
1251
1281
  if (nu > 0) {
1252
1282
  const ci = c[g * n + i];
1253
- P *= nu === 1 ? ci : ci ** nu;
1283
+ P *= nu === 1 ? ci : powi(ci, nu);
1254
1284
  this._dlnc(g, i, nu, dr);
1255
1285
  }
1256
1286
  aHi += nu * u[b * M + 1 + i];
@@ -1596,7 +1626,7 @@ export class Solver {
1596
1626
  aLo -= p.nu * uLo[o];
1597
1627
  if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1598
1628
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1599
- pref *= (c[g * n + p.i] / this.cRef[g * n + p.i]) ** e;
1629
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1600
1630
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1601
1631
  }
1602
1632
  const { g: bv, gp } = bvFactor(aHi + aLo, al);
@@ -1846,7 +1876,7 @@ export class Solver {
1846
1876
  }
1847
1877
 
1848
1878
  // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
1849
- _maxPotentialStep(delta, deltaV = this.deltaV) {
1879
+ _maxPotentialStep(delta, deltaV = this.deltaV) { // (deltaV null: the device's potentials only)
1850
1880
  const { n, M } = this;
1851
1881
  const R = this.rix; // (the sink entry of delta is 0)
1852
1882
  let mx = 0;
@@ -1868,7 +1898,7 @@ export class Solver {
1868
1898
  newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10 } = {}) {
1869
1899
  const { delta, res } = this;
1870
1900
  const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
1871
- const history = [];
1901
+ const history = [], ownHistory = [];
1872
1902
  // A steady solve that fails: record how nearly singular the system was, and where.
1873
1903
  const fail = (r) => {
1874
1904
  if (dt === Infinity) this._noteConditioning();
@@ -1899,15 +1929,22 @@ export class Solver {
1899
1929
  if (!(err instanceof SolverError)) throw err;
1900
1930
  return fail({ converged: false, iterations: it, history, error: err.message });
1901
1931
  }
1932
+ // Convergence counts every unknown, floating terminal voltages too. Damping and the
1933
+ // divergence check count only the device's own potentials: a terminal voltage enters
1934
+ // linearly (conductance links, held levels, a capacitive face), so a large swing in one is
1935
+ // safe, and damping it would throttle the whole update (a current-driven port switching
1936
+ // off, its voltage collapsing by hundreds of volts, crawled 10 thermal units at a time).
1902
1937
  const step = this._maxPotentialStep(delta, deltaV);
1938
+ const own = this._maxPotentialStep(delta, null);
1903
1939
  history.push(step);
1940
+ ownHistory.push(own);
1904
1941
  if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
1905
1942
  // Give up early on clear divergence; the caller will take a smaller step instead.
1906
- if (step > 1e4 || (it > 6 && step > 10 * history[0])) {
1943
+ if (own > 1e4 || (it > 6 && own > 10 * ownHistory[0])) {
1907
1944
  this.computeConcentrations();
1908
1945
  return fail({ converged: false, iterations: it, history, error: 'diverging' });
1909
1946
  }
1910
- const alpha = step > maxStep ? maxStep / step : 1;
1947
+ const alpha = own > maxStep ? maxStep / own : 1;
1911
1948
  this._addToState(delta, -alpha);
1912
1949
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
1913
1950
  if (alpha === 1 && step < tol) {
@@ -2071,16 +2108,17 @@ export class Solver {
2071
2108
  const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
2072
2109
  const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
2073
2110
  const start = clock();
2074
- const trace = { t: [], current: [], voltage: [] };
2111
+ const sample = this._probes(opts.probes);
2112
+ const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
2075
2113
  let steps = 0, rejected = 0, iterations = 0, failed = false;
2076
2114
  // Already there (an animation frame with no time to add): nothing to do, and the step size
2077
2115
  // carried to the next call stays as it was.
2078
- if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace: { t: [], current: [], voltage: [] } };
2116
+ if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
2079
2117
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2080
2118
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2081
2119
  let grow = 0; // longer first steps tried after a Newton failure (see below)
2082
2120
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
2083
- const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * (tol / err) ** (1 / (p + 1)))) : 2);
2121
+ const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
2084
2122
  while (this.time < tEnd) {
2085
2123
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
2086
2124
  // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
@@ -2113,7 +2151,7 @@ export class Solver {
2113
2151
  this._restore(snap);
2114
2152
  const a = this.step(h / 2);
2115
2153
  // The first half step is kept too, so it goes in the trace (a jump's charge is in it).
2116
- half = a.converged ? { t: this.time, current: this._terminalCurrent(), voltage: this.termV[1] - this.termV[0] } : null;
2154
+ half = a.converged ? { t: this.time, current: this._terminalCurrent(), voltage: this.termV[1] - this.termV[0], probes: sample?.read() } : null;
2117
2155
  const b = a.converged ? this.step(h / 2) : a;
2118
2156
  iterations += a.iterations + (b === a ? 0 : b.iterations);
2119
2157
  if (b.converged) {
@@ -2165,10 +2203,12 @@ export class Solver {
2165
2203
  trace.t.push(half.t);
2166
2204
  trace.current.push(half.current);
2167
2205
  trace.voltage.push(half.voltage);
2206
+ if (sample) sample.push(half.probes);
2168
2207
  }
2169
2208
  trace.t.push(this.time);
2170
2209
  trace.current.push(this._terminalCurrent());
2171
2210
  trace.voltage.push(this.termV[1] - this.termV[0]);
2211
+ if (sample) sample.push(sample.read());
2172
2212
  }
2173
2213
  this.dtNext = dt;
2174
2214
  this.landing = undefined;
@@ -2213,7 +2253,7 @@ export class Solver {
2213
2253
  const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
2214
2254
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
2215
2255
  const w = h / hp;
2216
- const Cc = (h ** 3 * (1 + w) ** 2) / (w * (1 + 2 * w));
2256
+ const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
2217
2257
  const Cp = h * (h + hp) * (h + hp + hpp);
2218
2258
  return { scale: Cc / (Cc + Cp), order: 2 };
2219
2259
  }
@@ -2224,6 +2264,54 @@ export class Solver {
2224
2264
  return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
2225
2265
  }
2226
2266
 
2267
+ // Probes for a transient's trace: { x, species, quantity: 'c' | 'V', region } read after every
2268
+ // accepted step, linearly between the two nodes of x's region around it. At an interface x
2269
+ // belongs to both regions, so `region` (a name or index) picks the side; it defaults to the
2270
+ // first region that holds x.
2271
+ _probes(probes) {
2272
+ if (probes === undefined) return null;
2273
+ const { grid, regions, species } = this.model;
2274
+ const fail = (m) => {
2275
+ throw new DeviceError(m);
2276
+ };
2277
+ if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
2278
+ const plan = probes.map((p, k) => {
2279
+ const path = `advance: probes[${k}]`;
2280
+ const i = species.findIndex((sp) => sp.name === p?.species);
2281
+ if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2282
+ const quantity = p.quantity ?? 'c';
2283
+ if (quantity !== 'c' && quantity !== 'V') fail(`${path}.quantity must be 'c' (mol/m³) or 'V' (the species voltage)`);
2284
+ if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
2285
+ const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
2286
+ let r;
2287
+ if (p.region !== undefined) {
2288
+ r = typeof p.region === 'number' ? p.region : regions.findIndex((reg) => reg.name === p.region);
2289
+ if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
2290
+ if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
2291
+ } else {
2292
+ r = regions.findIndex((_, rr) => inside(rr));
2293
+ if (!(Number.isFinite(p.x) && r >= 0)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
2294
+ }
2295
+ let g = grid.regionStart[r];
2296
+ while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
2297
+ const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
2298
+ return { i, g, w, quantity };
2299
+ });
2300
+ const out = plan.map(() => []);
2301
+ const { n, M } = this;
2302
+ const at = (q, g) => {
2303
+ if (!this.present[g * n + q.i]) return NaN;
2304
+ if (q.quantity === 'c') return this.c[g * n + q.i];
2305
+ const o = this.blockOfNode[g] * M + 1 + q.i;
2306
+ return (this.VT * (this.u[o] + this.uLo[o])) / this.z[q.i];
2307
+ };
2308
+ return {
2309
+ out,
2310
+ read: () => plan.map((q) => (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1)),
2311
+ push: (values) => values.forEach((v, k) => out[k].push(v)),
2312
+ };
2313
+ }
2314
+
2227
2315
  // Largest difference from ref among the potentials that carry state, in thermal units.
2228
2316
  _errorNorm(ref) {
2229
2317
  const { n, M, u } = this;
package/src/sources.js ADDED
@@ -0,0 +1,103 @@
1
+ // driftlet/kit: sources as plain definition data. Waveforms for a terminal's V or I, an injector
2
+ // port, and band-to-band recombination from a lifetime. Each returns an ordinary spec value.
3
+
4
+ import { DeviceError } from './errors.js';
5
+
6
+ const fail = (message) => {
7
+ throw new DeviceError(message);
8
+ };
9
+ const need = (ok, message) => ok || fail(message);
10
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
11
+
12
+ /**
13
+ * A rectangular pulse, as a waveform for a terminal's `V` or `I`: `base` until `start`, then
14
+ * `base + amplitude` for `width`, then `base` again. With a `rise` > 0 its edges are ramps of
15
+ * that length, placed so the area stays `amplitude · width`; without one they're jumps, which
16
+ * the time stepping handles exactly (so a pulse with no rise must start after t = 0, the time a
17
+ * device starts at and solve() reads: it's off then).
18
+ * @param {{ start?: number, width: number, amplitude: number, rise?: number, base?: number }} opts s, s, V or A/m², s, V or A/m²
19
+ * @returns {{ t: number[], values: number[] }}
20
+ */
21
+ export function pulse({ start = 0, width, amplitude, rise = 0, base = 0 } = {}) {
22
+ need(num(width) && width > 0, `pulse: width must be > 0 (s), got ${width}`);
23
+ need(num(amplitude), `pulse: amplitude must be a finite number (V or A/m²), got ${amplitude}`);
24
+ need(num(start) && start >= 0, `pulse: start must be ≥ 0 (s), got ${start}`);
25
+ need(num(rise) && rise >= 0 && rise <= width, `pulse: rise must be between 0 and the width (s), got ${rise}`);
26
+ need(num(base), `pulse: base must be a finite number, got ${base}`);
27
+ need(rise > 0 || start > 0, 'pulse: with no rise, start after t = 0, so the device starts (and solve() reads it) before the pulse');
28
+ const top = base + amplitude;
29
+ return { t: [start, start + rise, start + width, start + width + rise], values: [base, top, top, base] };
30
+ }
31
+
32
+ /**
33
+ * A square wave, repeating: `high` for `duty · period`, then `low`, from t = 0.
34
+ * @param {{ high: number, low: number, period: number, duty?: number }} opts
35
+ * @returns {{ t: number[], values: number[], repeat: true }}
36
+ */
37
+ export function square({ high, low, period, duty = 0.5 } = {}) {
38
+ need(num(high) && num(low), `square: high and low must be finite numbers, got ${high} and ${low}`);
39
+ need(num(period) && period > 0, `square: period must be > 0 (s), got ${period}`);
40
+ need(num(duty) && duty > 0 && duty < 1, `square: duty must be between 0 and 1, got ${duty}`);
41
+ const on = duty * period;
42
+ return { t: [0, on, on, period], values: [high, high, low, low], repeat: true };
43
+ }
44
+
45
+ /**
46
+ * A triangle wave, repeating: from `from` to `to` and back in each `period`, from t = 0 (a
47
+ * cyclic voltammogram's sweep, from its starting potential).
48
+ * @param {{ from: number, to: number, period: number }} opts
49
+ * @returns {{ t: number[], values: number[], repeat: true }}
50
+ */
51
+ export function triangle({ from, to, period } = {}) {
52
+ need(num(from) && num(to), `triangle: from and to must be finite numbers, got ${from} and ${to}`);
53
+ need(num(period) && period > 0, `triangle: period must be > 0 (s), got ${period}`);
54
+ return { t: [0, period / 2, period], values: [from, to, from], repeat: true };
55
+ }
56
+
57
+ /**
58
+ * A ramp from `from` to `to` over `duration`, starting at `start`, constant on either side.
59
+ * @param {{ from: number, to: number, duration: number, start?: number }} opts
60
+ * @returns {{ t: number[], values: number[] }}
61
+ */
62
+ export function ramp({ from, to, duration, start = 0 } = {}) {
63
+ need(num(from) && num(to), `ramp: from and to must be finite numbers, got ${from} and ${to}`);
64
+ need(num(duration) && duration > 0, `ramp: duration must be > 0 (s), got ${duration}`);
65
+ need(num(start) && start >= 0, `ramp: start must be ≥ 0 (s), got ${start}`);
66
+ return { t: [start, start + duration], values: [from, to] };
67
+ }
68
+
69
+ /**
70
+ * A port that injects a species into a window of a region at a driven current `I` (A/m², a
71
+ * number or a waveform), spread evenly over the window. It's an ordinary port: driven by `I`,
72
+ * its terminal the species, linked by a conductance small enough that at the peak current the
73
+ * port sits `headroom` volts above the window's level of that species, so every node of the
74
+ * window takes the same share (like a current source). At I = 0 it still links the window,
75
+ * weakly.
76
+ * @param {{ name?: string, region: string | number, from: number, to: number, species: string,
77
+ * I: number | { t: number[], values: number[], repeat?: boolean }, headroom?: number }} opts
78
+ * @returns {object} a port, for the definition's `ports`
79
+ */
80
+ export function injector({ name = 'injector', region, from, to, species, I, headroom = 2.5 } = {}) {
81
+ need(region !== undefined, 'injector: give the region (its name or index)');
82
+ need(num(from) && num(to) && to > from, `injector: from and to must bound the window, in m from the region's left end (got ${from}, ${to})`);
83
+ need(typeof species === 'string', 'injector: give the species it injects, e.g. species: \'h+\'');
84
+ need(num(headroom) && headroom > 0, `injector: headroom must be > 0 (V), got ${headroom}`);
85
+ const peak = num(I) ? Math.abs(I) : Array.isArray(I?.values) && I.values.every(num) ? Math.max(...I.values.map(Math.abs)) : NaN;
86
+ need(Number.isFinite(peak), 'injector: I must be a current (A/m²) or a waveform { t, values }');
87
+ const G = peak > 0 ? peak / ((to - from) * headroom) : 1; // S/m³
88
+ return { name, region, from, to, terminal: species, species: { [species]: { type: 'conductance', G } }, I };
89
+ }
90
+
91
+ /**
92
+ * Band-to-band recombination e⁻ + h⁺ ⇌ 0 in a material, from the minority-carrier lifetime it
93
+ * should give at a majority density: kf = 1/(τ · majority). (The rate is kf (np − n_i²), so τ is
94
+ * the low-injection lifetime of whichever carrier is the minority.)
95
+ * @param {{ material: string, tau: number, majority: number }} opts material name, s, mol/m³
96
+ * @returns {{ equation: string, kf: Record<string, number> }} a bulk reaction
97
+ */
98
+ export function recombination({ material, tau, majority } = {}) {
99
+ need(typeof material === 'string', 'recombination: give the material, by name');
100
+ need(num(tau) && tau > 0, `recombination: tau must be > 0 (s), got ${tau}`);
101
+ need(num(majority) && majority > 0, `recombination: majority must be the majority carrier density > 0 (mol/m³; units.perCm3 converts), got ${majority}`);
102
+ return { equation: 'e- + h+ = 0', kf: { [material]: 1 / (tau * majority) } };
103
+ }
package/src/statistics.js CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { FARADAY, EPS0, AVOGADRO } from './constants.js';
18
18
  import { fermiHalf, fermiMinusHalf } from './fermi.js';
19
+ import { powi } from './pow.js';
19
20
  import { SolverError } from './solver.js';
20
21
 
21
22
  const MODEL_TYPES = ['ideal', 'fermi-dirac', 'lattice', 'redlich-kister', 'debye-huckel', 'insertion', 'custom'];
@@ -189,7 +190,7 @@ function fermiDiracModel(idx, cRef, order) {
189
190
  const r = target[a] / cRef[a];
190
191
  // ln 𝓕(ζ) is increasing with slope 𝓕′/𝓕 ∈ (0, 1]
191
192
  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
+ const guess = r < 1 ? Math.log(r) : order === 0.5 ? Math.cbrt(powi(0.75 * Math.sqrt(Math.PI) * r, 2)) : r;
193
194
  zeta[a] = solveIncreasing(f, Math.log(r), Math.log(r) - 1, Math.max(guess, Math.log(r)) + 10, guess);
194
195
  }
195
196
  },
@@ -264,7 +265,7 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
264
265
  let q = 0;
265
266
  for (let b = 0; b < k; b++) q += s[b] * c[b];
266
267
  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
+ const A = kappa > 0 ? (lB * FARADAY * FARADAY) / (4 * eps * RT * kappa * (1 + kappa * a) * (1 + kappa * a)) : 0;
268
269
  let s2c = 0;
269
270
  for (let b = 0; b < k; b++) s2c += s[b] * s[b] * c[b];
270
271
  for (let b = 0; b < k; b++) lng[b] = (-s[b] * lB * kappa) / (2 * (1 + kappa * a));
@@ -425,7 +426,7 @@ function choleskyOk(K, k) {
425
426
  const L = Float64Array.from(K);
426
427
  for (let j = 0; j < k; j++) {
427
428
  let d = L[j * k + j];
428
- for (let p = 0; p < j; p++) d -= L[j * k + p] ** 2;
429
+ for (let p = 0; p < j; p++) d -= L[j * k + p] * L[j * k + p];
429
430
  if (!(d > 0)) return false;
430
431
  d = Math.sqrt(d);
431
432
  L[j * k + j] = d;
package/src/types.js CHANGED
@@ -57,8 +57,9 @@
57
57
  * @property {number} length m
58
58
  * @property {string} [name]
59
59
  * @property {number} [fixedCharge] immobile charge density, C/m³
60
- * @property {Record<string, number>} [c0] initial concentrations, mol/m³ (required for species
61
- * not connected to a contact: it fixes the amount they conserve)
60
+ * @property {Record<string, number | { x: number[], c: number[] }>} [c0] initial concentrations,
61
+ * mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
62
+ * or a profile against the device's x (m), piecewise linear; either is the starting state
62
63
  * @property {number} [velocity] imposed flow toward +x, m/s, carrying every mobile species
63
64
  * @property {number} [mixing] eddy (turbulent) mixing diffusivity, m²/s; current-free
64
65
  * @property {GridOptions} [grid]
@@ -204,8 +205,9 @@
204
205
  * @property {number} time s
205
206
  * @property {boolean} [done] advance(): whether tEnd was reached (false if the frame budget ran out)
206
207
  * @property {number} [rejected] advance(): steps rejected by error control or Newton failure
207
- * @property {{ t: number[], current: number[], voltage: number[] }} [trace] advance(): terminal
208
- * current (A/m², toward +x) and voltage after each accepted step
208
+ * @property {{ t: number[], current: number[], voltage: number[], probes?: number[][] }} [trace] advance():
209
+ * terminal current (A/m², toward +x) and voltage after each accepted step, and each probe's
210
+ * reading (mol/m³, or V for quantity 'V'), interpolated between the nodes around its x
209
211
  */
210
212
 
211
213
  /**
@@ -30,4 +30,14 @@ export const units: Readonly<{
30
30
  um: (x: any) => number;
31
31
  /** cm²/s → m²/s */
32
32
  cm2PerS: (x: any) => number;
33
+ /** mol/m³ → mol/L */
34
+ toMolar: (x: any) => number;
35
+ /** mol/m³ → particles per cm³ */
36
+ toPerCm3: (x: any) => number;
37
+ /** m → nm */
38
+ toNm: (x: any) => number;
39
+ /** m → µm */
40
+ toUm: (x: any) => number;
41
+ /** m²/s → cm²/s */
42
+ toCm2PerS: (x: any) => number;
33
43
  }>;