driftlet 0.2.0 → 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/README.md CHANGED
@@ -55,7 +55,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
55
55
  ```
56
56
 
57
57
  Or straight from a CDN in a page, pinned to a version:
58
- `https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
58
+ `https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
59
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
60
60
  [`llms.txt`](llms.txt).
61
61
 
@@ -231,9 +231,11 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
231
231
  | pn junction | exact built-in potential; depletion charge; short-diode J–V | 1e-12; 1%; 2e-3 |
232
232
  | Long pn diode with recombination | Shockley J–V incl. depletion recombination | 2e-3 |
233
233
  | Haynes–Shockley pulse | an injected hole packet's drift, spread and decay at the ambipolar μ*, D*, 1/τ*; nothing lost while it goes in | 2e-4; 1e-2; 2e-3 |
234
+ | A packet laid down as a `c0` profile | the same rates from a Gaussian at t = 0: μ*E t, σ₀² + 2D*t, e^(−t/τ*) | 3e-4; 1e-2; 1e-3 |
234
235
  | Illuminated long pn diode | `J_sc = qG(L_n + L_p + W)` from a cold start; superposition at low injection | 5e-3; 1e-2 |
235
236
  | Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
236
237
  | Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
238
+ | Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
237
239
  | Concentration polarization | `i = i_lim tanh(V/4V_T)`, incl. galvanostatic and load modes | 2e-4 |
238
240
  | Butler–Volmer electrode | Nernst equilibrium; mixed kinetic/diffusion closed form | 5e-4 |
239
241
  | Redox couple between inert electrodes | Nernst level of Fe³⁺/Fe²⁺; the couple's total conserved at equilibrium and under current | 1e-9; 1e-10 |
@@ -288,6 +290,8 @@ No dependencies. Tests use node's built-in runner (Node ≥ 22):
288
290
  npm test # the validation suite
289
291
  npm run bench # typical workloads against bench/baseline.json (--save to update it)
290
292
  npm run types # TypeScript declarations from the JSDoc, into types/ (fetches TypeScript via npx)
293
+ npm run smoke # every demo in headless Chrome, every control to its extremes (CHROME=… to choose)
294
+ npm run shots # retake the demos' screenshots, each caught at a set state (tools/shots.js)
291
295
  ```
292
296
 
293
297
  Design notes and plans: [numerics](docs/numerics.md), [conventions](docs/conventions.md),
package/docs/device.md CHANGED
@@ -97,11 +97,23 @@ regions: [
97
97
 
98
98
  - `fixedCharge`: immobile charge density (doping, ionomer), C/m³. Default 0. In an insertion
99
99
  host it's balanced by background electronic carriers.
100
- - `c0`: initial concentrations, mol/m³. Species connected to a contact start from that contact's
101
- level and don't need it. Any other species (blocked everywhere, or only made and consumed by
100
+ - `c0`: initial concentrations, mol/m³: the starting state of each species given. Species
101
+ connected to a contact don't need it (without it, they start from that contact's level). Any
102
+ other species (blocked everywhere, or only made and consumed by
102
103
  reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
103
104
  logarithmic in them); to have none of a species in a region, leave it out of that region's
104
- material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero. Where nothing else sets
105
+ material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
106
+ An entry can instead be a profile, `{ x: [...], c: [...] }`, against the device's x (m, not
107
+ the region's own), piecewise linear between its points and constant beyond its ends, so a
108
+ transient can start from a state it didn't simulate reaching: a packet already injected, a
109
+ gradient laid down. Skip `solve()`, which goes straight to the steady state, and `advance()`
110
+ from it. Species without a `c0` start from their contact's level, and $`\phi`$ is chosen node
111
+ by node for local neutrality, so a mobile counter-ion follows the profile (electrons follow an
112
+ injected hole packet); what the cold start leaves unbalanced settles within the first steps.
113
+ That fill-in is right in a quasi-neutral bulk (it's what dielectric relaxation would do), not
114
+ where the rest state carries charge: a packet laid into a junction starts with the junction
115
+ itself unformed.
116
+ For a spectator, the profile's integral is the amount it conserves. Where nothing else sets
105
117
  $`\phi`$ at the start, a region beside an electrode starts with the electrode's first reaction at
106
118
  equilibrium (the electrode at its open-circuit level); otherwise $`\phi`$ carries over from the
107
119
  left.
package/llms.txt CHANGED
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
14
14
  From a CDN, pin the version:
15
15
 
16
16
  ```js nocheck
17
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -219,6 +219,13 @@ const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
219
219
  console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPerCm3(seen[k] - rest.c['h+'][0]).toExponential(1)} cm⁻³: μ_p ≈ ${(1e-3 / (E * tArrive) * 1e4).toFixed(0)} cm²/(V·s)`); // ≈ 1950 for the 1900 put in: a spreading, decaying pulse peaks a little early
220
220
  ```
221
221
 
222
+ To start from a state instead of simulating how it got there (a packet already injected, a
223
+ salt gradient laid down), give a region's `c0` a profile against the device's x:
224
+ `c0: { 'h+': { x: [...], c: [...] } }`, piecewise linear. A given `c0` is the starting state,
225
+ even for a species a contact feeds; species without one start at their contact's level, with φ
226
+ made neutral node by node (electrons follow the holes). Then `advance()` without `solve()`, which would go straight to the steady
227
+ state.
228
+
222
229
  Transients cost more where a carrier's density spans many decades (minority holes in silicon
223
230
  ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
224
231
 
@@ -236,9 +243,9 @@ a Web Worker.
236
243
  <span id="readout"></span>
237
244
  <div id="figure"></div>
238
245
  <script type="module">
239
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/index.js';
240
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/kit.js';
241
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/plot.js';
246
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/index.js';
247
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/kit.js';
248
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.3.0/src/plot.js';
242
249
 
243
250
  const def = build({
244
251
  T: 300,
@@ -349,5 +356,7 @@ has the long form):
349
356
  to write `2 e-`.
350
357
  - **Changing c0 with `set()`.** A carried state keeps what it holds; `c0` is only the starting
351
358
  composition. Make a new `Device` to start over from it.
359
+ - **`solve()` before advancing from a `c0` profile.** It replaces the profile with the steady
360
+ state. Advance from the new `Device` directly. A profile's x is the device's, in m.
352
361
  - **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
353
362
  Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,8 @@
29
29
  "scripts": {
30
30
  "test": "node --test",
31
31
  "bench": "node bench/bench.js",
32
+ "smoke": "node tools/smoke.js",
33
+ "shots": "node tools/shots.js",
32
34
  "prepublishOnly": "npm test && npm run types",
33
35
  "types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
34
36
  },
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/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
@@ -146,6 +146,21 @@ import { bernoulli, bernoulliDerivative } from './bernoulli.js';
146
146
  import { EPS0, FARADAY } from './constants.js';
147
147
  import { nextBreakpoint, sourceAt } from './device.js';
148
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
+ }
149
164
 
150
165
  export class SolverError extends Error {
151
166
  constructor(message, details) {
@@ -739,9 +754,9 @@ export class Solver {
739
754
  }
740
755
 
741
756
  /**
742
- * Cold start. Species connected to a contact take that contact's level; spectators take
743
- * their region's c0 (which fixes their conserved amount). φ in each region is then chosen
744
- * 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.
745
760
  */
746
761
  initFromComposition() {
747
762
  const { model, n, M, u, z } = this;
@@ -755,10 +770,15 @@ export class Solver {
755
770
  if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
756
771
  const reg = regions[r], mat = materials[reg.material];
757
772
  mode.fill(0);
773
+ const profiled = reg.c0Profile.some((p) => p !== null);
758
774
  for (let i = 0; i < n; i++) {
759
775
  if (!mat.present[i]) continue;
760
776
  const st = this.stretches[this.stretchOf[r * n + i]];
761
- 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) {
762
782
  // A conductor away from the contacts: start uncharged, with its carrier's level in
763
783
  // equilibrium with the first reaction on its left face that takes it, else at the
764
784
  // running φ.
@@ -819,7 +839,8 @@ export class Solver {
819
839
  return q;
820
840
  };
821
841
  const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
822
- if (!responds && !mat.conductor && !mat.phiFree && mat.epsr === 0) {
842
+ const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
843
+ const checkNeutral = (where) => {
823
844
  // Strictly neutral, with every charged species' amount given: its c0 must be neutral.
824
845
  let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
825
846
  for (let i = 0; i < n; i++) {
@@ -829,11 +850,12 @@ export class Solver {
829
850
  }
830
851
  if (Math.abs(q) > 1e-9 * scale) {
831
852
  throw new SolverError(
832
- `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), ` +
833
854
  'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
834
855
  );
835
856
  }
836
- }
857
+ };
858
+ if (mustBalance && !profiled) checkNeutral('');
837
859
  if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
838
860
  // Nothing here fixes φ, but an electrode on the left does: start with its first reaction
839
861
  // that takes a species from this side at equilibrium (the electrode at its open-circuit
@@ -854,8 +876,8 @@ export class Solver {
854
876
  }
855
877
  }
856
878
  }
857
- if (!mat.phiFree && responds) {
858
- let lo = phiHat - 1, hi = phiHat + 1;
879
+ const neutralPhi = (ph) => {
880
+ let lo = ph - 1, hi = ph + 1;
859
881
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
860
882
  while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
861
883
  for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
@@ -863,10 +885,17 @@ export class Solver {
863
885
  if (charge(m) > 0) lo = m;
864
886
  else hi = m;
865
887
  }
866
- phiHat = 0.5 * (lo + hi);
867
- }
888
+ return 0.5 * (lo + hi);
889
+ };
890
+ if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
868
891
  if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
869
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
+ }
870
899
  const b = this.blockOfNode[g];
871
900
  u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
872
901
  for (let i = 0; i < n; i++) {
@@ -1251,7 +1280,7 @@ export class Solver {
1251
1280
  const i = rx.sp[p], nu = rx.nu[p];
1252
1281
  if (nu > 0) {
1253
1282
  const ci = c[g * n + i];
1254
- P *= nu === 1 ? ci : ci ** nu;
1283
+ P *= nu === 1 ? ci : powi(ci, nu);
1255
1284
  this._dlnc(g, i, nu, dr);
1256
1285
  }
1257
1286
  aHi += nu * u[b * M + 1 + i];
@@ -1597,7 +1626,7 @@ export class Solver {
1597
1626
  aLo -= p.nu * uLo[o];
1598
1627
  if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1599
1628
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1600
- 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);
1601
1630
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1602
1631
  }
1603
1632
  const { g: bv, gp } = bvFactor(aHi + aLo, al);
@@ -2089,7 +2118,7 @@ export class Solver {
2089
2118
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2090
2119
  let grow = 0; // longer first steps tried after a Newton failure (see below)
2091
2120
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
2092
- 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);
2093
2122
  while (this.time < tEnd) {
2094
2123
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
2095
2124
  // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
@@ -2224,7 +2253,7 @@ export class Solver {
2224
2253
  const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
2225
2254
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
2226
2255
  const w = h / hp;
2227
- 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));
2228
2257
  const Cp = h * (h + hp) * (h + hp + hpp);
2229
2258
  return { scale: Cc / (Cc + Cp), order: 2 };
2230
2259
  }
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]
package/types/pow.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** x^n for an integer n. */
2
+ export function powi(x: any, n: any): any;
3
+ /** x^y for x ≥ 0 and any real y. */
4
+ export function powr(x: any, y: any): any;
package/types/solver.d.ts CHANGED
@@ -120,9 +120,9 @@ export class Solver {
120
120
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
121
121
  contactEta(side: any, i: any): number;
122
122
  /**
123
- * Cold start. Species connected to a contact take that contact's level; spectators take
124
- * their region's c0 (which fixes their conserved amount). φ in each region is then chosen
125
- * for local neutrality, or continued across the interface dipole if nothing there responds.
123
+ * Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
124
+ * the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
125
+ * profile varies), or continued across the interface dipole if nothing there responds.
126
126
  */
127
127
  initFromComposition(): void;
128
128
  _materialAt(mat: any, background: any, eta: any, mode: any, cFix: any, ph: any, zeta: any, cc: any): void;
package/types/types.d.ts CHANGED
@@ -130,10 +130,14 @@ export type RegionDef = {
130
130
  */
131
131
  fixedCharge?: number;
132
132
  /**
133
- * initial concentrations, mol/m³ (required for species
134
- * not connected to a contact: it fixes the amount they conserve)
133
+ * initial concentrations,
134
+ * mol/m³ (required for species not connected to a contact: it fixes the amount they conserve);
135
+ * or a profile against the device's x (m), piecewise linear; either is the starting state
135
136
  */
136
- c0?: Record<string, number>;
137
+ c0?: Record<string, number | {
138
+ x: number[];
139
+ c: number[];
140
+ }>;
137
141
  /**
138
142
  * imposed flow toward +x, m/s, carrying every mobile species
139
143
  */