driftlet 0.6.0 → 0.8.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.
@@ -0,0 +1,164 @@
1
+ // Liquid-junction potentials from formulas, for driftlet/kit: the diffusion potential φ_R − φ_L
2
+ // between two neutral solutions, as the classical theories give it. A device simulates the actual
3
+ // junction (steady between two baths, it is Planck's; stepping from a sharp boundary, a free
4
+ // diffusion junction); these are for comparison, and for quick estimates.
5
+
6
+ import { DeviceError } from './errors.js';
7
+ import { FARADAY, GAS_CONSTANT } from './constants.js';
8
+
9
+ const fail = (message) => {
10
+ throw new DeviceError(message);
11
+ };
12
+
13
+ // The ions both solutions name, with their charge and diffusivity, and both concentrations.
14
+ function prepare(left, right, ions, what) {
15
+ const names = [...new Set([...Object.keys(left), ...Object.keys(right)])];
16
+ return names.map((name) => {
17
+ const ion = ions[name];
18
+ if (!ion || !Number.isFinite(ion.z) || !(ion.D > 0)) fail(`${what}: no { z, D } for '${name}' in the ions given`);
19
+ const cL = left[name] ?? 0, cR = right[name] ?? 0;
20
+ if (!(cL >= 0 && cR >= 0)) fail(`${what}: concentrations must be ≥ 0 (mol/m³), got ${name} ${cL} and ${cR}`);
21
+ return { name, z: ion.z, D: ion.D, cL, cR };
22
+ });
23
+ }
24
+ function neutral(list, side, what) {
25
+ let q = 0, s = 0;
26
+ for (const p of list) {
27
+ q += p.z * p[side];
28
+ s += Math.abs(p.z * p[side]);
29
+ }
30
+ if (Math.abs(q) > 1e-9 * s) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution carries a net charge (Σ z·c = ${q.toPrecision(3)} mol/m³)`);
31
+ }
32
+
33
+ /**
34
+ * Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
35
+ * mixed in one proportion at every point (every ion's mixing fraction the same curve). Exact for a single binary salt, a close estimate otherwise (it's what
36
+ * JPCalc and most electrophysiology corrections use).
37
+ * @param {Record<string, number>} left mol/m³ by ion
38
+ * @param {Record<string, number>} right mol/m³ by ion
39
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
40
+ * @param {{ T?: number }} [opts] K (default 298.15)
41
+ */
42
+ export function henderson(left, right, ions, { T = 298.15 } = {}) {
43
+ const list = prepare(left, right, ions, 'henderson');
44
+ neutral(list, 'cL', 'henderson');
45
+ neutral(list, 'cR', 'henderson');
46
+ let a = 0, b = 0, gL = 0, gR = 0;
47
+ for (const { z, D, cL, cR } of list) {
48
+ a += z * D * (cR - cL);
49
+ b += z * z * D * (cR - cL);
50
+ gL += z * z * D * cL;
51
+ gR += z * z * D * cR;
52
+ }
53
+ const VT = (GAS_CONSTANT * T) / FARADAY;
54
+ if (b === 0) return 0;
55
+ return (-VT * a * Math.log(gR / gL)) / b;
56
+ }
57
+
58
+ /**
59
+ * Planck's liquid-junction potential, φ_R − φ_L (V): the steady state of constrained diffusion,
60
+ * a junction zone held between the two solutions, electroneutral, carrying no current. Solved by
61
+ * shooting on the Nernst–Planck equations across it (its length drops out). Exact for that
62
+ * junction; equal to Henderson's for a single binary salt.
63
+ * @param {Record<string, number>} left mol/m³ by ion
64
+ * @param {Record<string, number>} right mol/m³ by ion
65
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
66
+ * @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
67
+ */
68
+ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
69
+ const list = prepare(left, right, ions, 'planck');
70
+ neutral(list, 'cL', 'planck');
71
+ neutral(list, 'cR', 'planck');
72
+ const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
73
+ const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
74
+ const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
75
+ // Across the zone (x from 0 to 1, D's scale dropping out with its length), each ion's flux J_i is
76
+ // constant: c_i' = −J_i/D_i − z_i c_i ψ', with ψ = Fφ/RT, and neutrality fixes the field,
77
+ // ψ' = −Σ(z_i J_i/D_i)/Σ(z_i² c_i). Integrated from the left; the fluxes are chosen so the
78
+ // right comes out as given and no current flows.
79
+ const deriv = (c, J, out) => {
80
+ let num = 0, den = 0;
81
+ for (let i = 0; i < n; i++) {
82
+ num += (z[i] * J[i]) / D[i];
83
+ den += z[i] * z[i] * Math.max(c[i], 0);
84
+ }
85
+ const dpsi = -num / den;
86
+ for (let i = 0; i < n; i++) out[i] = -J[i] / D[i] - z[i] * c[i] * dpsi;
87
+ return dpsi;
88
+ };
89
+ const shoot = (J) => {
90
+ const c = cL.slice(), k1 = new Float64Array(n), k2 = new Float64Array(n), k3 = new Float64Array(n), k4 = new Float64Array(n), t = new Float64Array(n);
91
+ const h = 1 / steps;
92
+ let psi = 0;
93
+ for (let s = 0; s < steps; s++) {
94
+ const p1 = deriv(c, J, k1);
95
+ for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k1[i];
96
+ const p2 = deriv(t, J, k2);
97
+ for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k2[i];
98
+ const p3 = deriv(t, J, k3);
99
+ for (let i = 0; i < n; i++) t[i] = c[i] + h * k3[i];
100
+ const p4 = deriv(t, J, k4);
101
+ for (let i = 0; i < n; i++) c[i] += (h / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]);
102
+ psi += (h / 6) * (p1 + 2 * p2 + 2 * p3 + p4);
103
+ }
104
+ return { c, psi };
105
+ };
106
+ // Residuals: n − 1 ions reaching the right (the last follows by neutrality), and no current.
107
+ const residual = (J) => {
108
+ const { c, psi } = shoot(J), r = new Float64Array(n);
109
+ for (let i = 0; i < n - 1; i++) r[i] = c[i] - cR[i];
110
+ let I = 0;
111
+ for (let i = 0; i < n; i++) I += z[i] * J[i];
112
+ r[n - 1] = I / Dmax; // scaled like the concentrations
113
+ return { r, psi };
114
+ };
115
+ let J = list.map((_, i) => -D[i] * (cR[i] - cL[i])); // plain diffusion to start
116
+ let { r, psi } = residual(J);
117
+ for (let it = 0; it < 50; it++) {
118
+ const norm = Math.max(...r.map(Math.abs));
119
+ if (norm < 1e-12) break;
120
+ // Newton with a finite-difference Jacobian.
121
+ const Jac = [];
122
+ for (let k = 0; k < n; k++) {
123
+ const dJ = 1e-7 * Math.max(Math.abs(J[k]), 1e-3 * D[k]), Jp = J.slice();
124
+ Jp[k] += dJ;
125
+ const rp = residual(Jp).r;
126
+ Jac.push(Array.from(rp, (v, i) => (v - r[i]) / dJ));
127
+ }
128
+ const step = solveDense(Jac, r, n);
129
+ let lambda = 1, next;
130
+ for (;;) {
131
+ const Jn = J.map((v, k) => v - lambda * step[k]);
132
+ next = residual(Jn);
133
+ if (Math.max(...next.r.map(Math.abs)) < norm || lambda < 1e-3) {
134
+ J = Jn;
135
+ break;
136
+ }
137
+ lambda /= 2;
138
+ }
139
+ ({ r, psi } = next);
140
+ }
141
+ if (!(Math.max(...r.map(Math.abs)) < 1e-8)) fail('planck: the shooting did not converge');
142
+ return ((GAS_CONSTANT * T) / FARADAY) * psi;
143
+ }
144
+
145
+ // Solves Σ_k A[k][i] x_k = b_i (A given column by column), by Gaussian elimination with pivoting.
146
+ function solveDense(cols, b, n) {
147
+ const M = Array.from({ length: n }, (_, i) => [...cols.map((col) => col[i]), b[i]]);
148
+ for (let p = 0; p < n; p++) {
149
+ let best = p;
150
+ for (let i = p + 1; i < n; i++) if (Math.abs(M[i][p]) > Math.abs(M[best][p])) best = i;
151
+ [M[p], M[best]] = [M[best], M[p]];
152
+ for (let i = p + 1; i < n; i++) {
153
+ const f = M[i][p] / M[p][p];
154
+ for (let j = p; j <= n; j++) M[i][j] -= f * M[p][j];
155
+ }
156
+ }
157
+ const x = new Array(n).fill(0);
158
+ for (let i = n - 1; i >= 0; i--) {
159
+ let s = M[i][n];
160
+ for (let j = i + 1; j < n; j++) s -= M[i][j] * x[j];
161
+ x[i] = s / M[i][i];
162
+ }
163
+ return x;
164
+ }
package/src/kit.js CHANGED
@@ -12,7 +12,9 @@ export { live } from './live.js';
12
12
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
13
13
  export { describe, unitWarnings } from './describe.js';
14
14
  export { recorder } from './recorder.js';
15
+ export { henderson, planck } from './junction.js';
15
16
  export { check } from './checks.js';
17
+ export { polarization } from './polarization.js';
16
18
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
17
19
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
18
20
 
@@ -0,0 +1,84 @@
1
+ // Polarization curves at an electrode, for driftlet/kit: each reaction's rate with the solution
2
+ // beside the electrode held as it is and the metal's level moved, which is what an Evans diagram
3
+ // plots. Where the net current crosses zero is the spot's open-circuit (mixed) potential.
4
+
5
+ import { DeviceError } from './errors.js';
6
+ import { FARADAY } from './constants.js';
7
+ import { powi, powr } from './pow.js';
8
+
9
+ const fail = (message) => {
10
+ throw new DeviceError(message);
11
+ };
12
+
13
+ /**
14
+ * Each electrode reaction's rate at a face (a metal's) or at one spot of an electrode port, with
15
+ * the composition beside it as in `sol` and the metal's carrier at each level in `V` (in the
16
+ * terminals' convention, V = μ̄/(zF); against SHE, subtract φ for the usual tables' ions). The
17
+ * rates are the solver's own law, so at the electrode's actual level they are
18
+ * `sol.interfaces[f].rates` (or `sol.ports[k].rates` at that node).
19
+ * @param {import('./index.js').Device} device
20
+ * @param {object} sol a solution of it
21
+ * @param {{ face: number } | { port: string | number, x?: number }} where a face's index, or a port
22
+ * (name or index) and the position in its window (m, the device's x; default its first node)
23
+ * @param {number | ArrayLike<number>} V the metal's level(s), V
24
+ * @returns {{ V: Float64Array, current: Float64Array, reactions: { equation: string, level: number,
25
+ * rate: Float64Array, current: Float64Array }[] }} per reaction: its equilibrium level here (where
26
+ * its rate is zero), its rate (mol/(m²·s) of electrode, forward) and its current (A/m², anodic
27
+ * positive: into the solution); and their sum
28
+ */
29
+ export function polarization(device, sol, where, V) {
30
+ const model = device.model, { species, grid, RT } = model, solver = device.solver;
31
+ const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
32
+ let reactions, nodeOf, carrier, names;
33
+ if (where?.face !== undefined) {
34
+ const f = where.face, itf = model.interfaces[f];
35
+ if (!itf) fail(`polarization: no face ${f} (the device has ${model.interfaces.length})`);
36
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
37
+ const metal = [gL, gR].findIndex((g) => solver.nodeConductor[g] >= 0);
38
+ if (metal < 0) fail(`polarization: face ${f} has no metal on either side`);
39
+ carrier = { side: metal, i: solver.nodeConductor[metal ? gR : gL] };
40
+ nodeOf = (p) => (p.side ? gR : gL);
41
+ reactions = itf.reactions;
42
+ names = device.def.interfaces?.[f]?.reactions;
43
+ } else if (where?.port !== undefined) {
44
+ const k = typeof where.port === 'number' ? where.port : model.ports.findIndex((p) => p.name === where.port);
45
+ const port = model.ports[k];
46
+ if (!port) fail(`polarization: no port ${JSON.stringify(where.port)}`);
47
+ if (port.reactions.length === 0) fail(`polarization: port ${port.name} has no reactions`);
48
+ let g = port.nodes[0];
49
+ if (where.x !== undefined) for (const h of port.nodes) if (Math.abs(grid.x[h] - where.x) < Math.abs(grid.x[g] - where.x)) g = h;
50
+ carrier = { side: 1, i: port.terminal };
51
+ nodeOf = () => g;
52
+ reactions = port.reactions;
53
+ names = device.def.ports?.[k]?.reactions;
54
+ } else fail('polarization: say where, { face: f } or { port: name, x }');
55
+ if (reactions.length === 0) fail('polarization: no reactions there');
56
+
57
+ const out = reactions.map((rx, k) => {
58
+ if (rx.srh) fail('polarization: SRH kinetics has no electrode level');
59
+ // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
60
+ let a0 = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
61
+ rx.part.forEach((p, x) => {
62
+ if (p.side === carrier.side && p.i === carrier.i) {
63
+ s -= (p.nu * species[p.i].z * FARADAY) / RT;
64
+ return;
65
+ }
66
+ const g = nodeOf(p), name = species[p.i].name;
67
+ a0 -= (p.nu * sol.mu[name][g]) / RT;
68
+ q += p.side === carrier.side ? 0 : p.nu * species[p.i].z; // charge the forward reaction puts into the solution
69
+ const c = sol.c[name][g];
70
+ if (rx.vmax) {
71
+ if (p.nu < 0) pref *= powi(c / (c + rx.K[x]), -p.nu);
72
+ } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
73
+ });
74
+ if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
75
+ const rate = Float64Array.from(Vs, (v) => {
76
+ const a = a0 + s * v;
77
+ return rx.vmax ? pref * -Math.expm1(-a) : pref * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
78
+ });
79
+ return { equation: names?.[k]?.equation ?? `reaction ${k}`, level: -a0 / s, rate, current: rate.map((r) => q * FARADAY * r) };
80
+ });
81
+ const current = new Float64Array(Vs.length);
82
+ for (const r of out) for (let j = 0; j < Vs.length; j++) current[j] += r.current[j];
83
+ return { V: Vs, current, reactions: out };
84
+ }
package/src/recorder.js CHANGED
@@ -15,7 +15,7 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
15
15
  * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
16
16
  * `probes` if given) runs from the start through every accepted step of every call.
17
17
  * @param {import('./index.js').Device} device
18
- * @param {{ every?: number, times?: number[], probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
18
+ * @param {{ every?: number, times?: number[], probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
19
19
  */
20
20
  export function recorder(device, opts = {}) {
21
21
  const { every, times, probes } = opts;
package/src/solution.js CHANGED
@@ -91,7 +91,8 @@ export function makeSolution(solver, result = {}) {
91
91
  const V = solver.termV[side === 'left' ? 0 : 1];
92
92
  sol.contacts[side] = { V, flux, D, current: conduction + displacement };
93
93
  if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
94
- // Charge per area on the gate (or metal) plate: +D at the left, −D at the right.
94
+ // Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
95
+ // at the right.
95
96
  sol.gates[side] = { V, D, charge: side === 'left' ? D : -D };
96
97
  }
97
98
  }
@@ -105,7 +106,14 @@ export function makeSolution(solver, result = {}) {
105
106
  flux[species[i].name] = solver.portFlux[k][i];
106
107
  current += F * species[i].z * solver.portFlux[k][i];
107
108
  }
108
- return { name: port.name, V: solver.termV[2 + k], flux, current };
109
+ const out = { name: port.name, V: solver.termV[2 + k], flux, current };
110
+ if (port.reactions.length > 0) {
111
+ // An electrode spread through the window: where it sits, and each reaction's rate there.
112
+ out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
113
+ out.area = Float64Array.from(solver.portArea[k]);
114
+ out.rates = solver.portRates[k].map((r) => Float64Array.from(r)); // mol/(m²·s), forward
115
+ }
116
+ return out;
109
117
  });
110
118
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
111
119
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
@@ -142,14 +150,14 @@ export function makeSolution(solver, result = {}) {
142
150
  return { rate, regions, total };
143
151
  });
144
152
 
145
- // Total charge per area in the device (space charge plus sheet charges).
153
+ // Total charge in the device (space charge plus sheet charges), per area if it's planar.
146
154
  let q = 0;
147
155
  for (let g = 0; g < nNodes; g++) {
148
156
  let rho = solver.rhoFixed[g];
149
157
  for (let i = 0; i < n; i++) rho += F * species[i].z * c[g * n + i];
150
158
  q += grid.vol[g] * rho;
151
159
  }
152
- for (const itf of interfaces) q += itf.sheetCharge;
160
+ interfaces.forEach((itf, f) => (q += itf.sheetCharge * grid.area[grid.regionEnd[f]]));
153
161
  sol.charge = q;
154
162
 
155
163
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,