driftlet 1.0.1 → 1.1.1

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/data.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // only. How levels line up at an interface, and a surface's work function, belong to that
4
4
  // interface or surface and are never in here.
5
5
 
6
- import { DeviceError, checkOptions } from './errors.js';
6
+ import { DeviceError, checkOptions, shown } from './errors.js';
7
7
  import { FARADAY, GAS_CONSTANT, AVOGADRO } from './constants.js';
8
+ import { powr } from './pow.js';
8
9
 
9
10
  const fail = (message) => {
10
11
  throw new DeviceError(message);
@@ -43,7 +44,7 @@ export const WATER_EPSR = 78.3;
43
44
 
44
45
  /**
45
46
  * Semiconductors at 300 K (Sze, Physics of Semiconductor Devices, 2nd ed., 1981, appendix):
46
- * relative permittivity, band gap Eg (eV), electron affinity chi (V), effective densities of
47
+ * relative permittivity, band gap Eg (eV), electron affinity chi (eV), effective densities of
47
48
  * states Nc and Nv (cm⁻³), and drift mobilities of lightly doped material mun and mup
48
49
  * (cm²/(V·s)).
49
50
  */
@@ -65,7 +66,8 @@ export const METALS = freeze(
65
66
 
66
67
  /**
67
68
  * Water with the named ions, as a library piece for `build()`: each ion as a species
68
- * (c_ref = 1 M) and the material `water` holding them, with D and μ° from `IONS`.
69
+ * (c_ref = 1 M) and the material `water` holding them, with D and μ° from `IONS`. The data are
70
+ * 25 °C's, and the material says so (T: 298.15): a device at another temperature warns.
69
71
  * @param {string[]} names ions from `IONS`
70
72
  * @param {{ epsr?: number, material?: string }} [opts] permittivity (default that of water;
71
73
  * 0 makes the solution strictly neutral, without double layers), and the material's name
@@ -81,24 +83,53 @@ export function aqueous(names, { epsr = WATER_EPSR, material = 'water' } = {}) {
81
83
  species.push({ name, z: ion.z, cRef: 1000 });
82
84
  entries[name] = { D: ion.D, mu0: ion.mu0 };
83
85
  }
84
- return { species, materials: { [material]: { epsr, species: entries } } };
86
+ // (25 °C data: a device at another temperature warns)
87
+ return { species, materials: { [material]: { epsr, species: entries, T: 298.15 } } };
85
88
  }
86
89
 
90
+ // How the semiconductors' data move with temperature, for semiconductor(name, { T }):
91
+ // - the gap by Varshni's form, Eg(T) = Eg0 − αT²/(T + β) (Y. P. Varshni, Physica 34, 149, 1967),
92
+ // with Thurmond's parameters (C. D. Thurmond, J. Electrochem. Soc. 122, 1133, 1975, as
93
+ // tabulated in Sze, 2nd ed.): Eg0 (eV), alpha (eV/K), beta (K). Only the shift from 300 K is
94
+ // used, on the table's Eg, so T = 300 K is the table: Varshni's own Eg(300) differs from it by
95
+ // 4.5 meV (Si, 1.1245), 3.4 meV (Ge, 0.6634) and 1.5 meV (GaAs, 1.4225).
96
+ // - Nc and Nv ∝ T^1.5 (parabolic bands, effective masses held fixed).
97
+ // - mobilities ∝ T^−gn and T^−gp, the lattice-limited power laws for lightly doped material
98
+ // (Sze, 2nd ed., ch. 1): approximate, and wrong where impurity scattering matters (heavy
99
+ // doping, or cold). D follows from μ and T by Einstein's relation, D = μkT/q.
100
+ // - the electron affinity chi isn't moved.
101
+ const WITH_T = {
102
+ Si: { Eg0: 1.17, alpha: 4.73e-4, beta: 636, gn: 2.42, gp: 2.2 },
103
+ Ge: { Eg0: 0.7437, alpha: 4.774e-4, beta: 235, gn: 1.66, gp: 2.33 },
104
+ GaAs: { Eg0: 1.519, alpha: 5.405e-4, beta: 204, gn: 1.0, gp: 2.1 },
105
+ };
106
+ const T_RANGE = [150, 500];
107
+
87
108
  /**
88
109
  * A semiconductor from `SEMICONDUCTORS`, as a library piece: electrons `e-` and holes `h+`, with
89
- * c_ref = Nc and Nv, D from the mobilities by the Einstein relation at 300 K, and μ°_e⁻ = 0 (φ
90
- * anchored at the conduction band) and μ°_h⁺ = Eg·F. The data are for 300 K, so use `T: 300`.
110
+ * c_ref = Nc and Nv, D from the mobilities by the Einstein relation at T, and μ°_e⁻ = 0 (φ
111
+ * anchored at the conduction band) and μ°_h⁺ = Eg·F. The data are for 300 K; another T (150 to
112
+ * 500 K) moves them: the gap by Varshni's shift from 300 K, Nc and Nv as T^1.5, the mobilities by
113
+ * lattice-limited power laws (light doping). The material carries its `T`, and a device at
114
+ * another temperature warns: give the device the same T.
91
115
  * Statistics are ideal (Boltzmann); add Fermi–Dirac to the material for degenerate doping.
92
116
  * @param {string} name 'Si', 'Ge' or 'GaAs'
93
- * @param {{ material?: string }} [opts]
117
+ * @param {{ material?: string, T?: number }} [opts] the material's name, and the temperature
118
+ * (K, default 300, the table's)
94
119
  * @returns {{ species: object[], materials: Record<string, object> }}
95
120
  */
96
- export function semiconductor(name, { material = name } = {}) {
97
- if (arguments[1]?.T !== undefined) fail('semiconductor: the data are for 300 K and take no T: give the device T: 300, or write your own material for another temperature (SEMICONDUCTORS has the numbers)');
98
- checkOptions('semiconductor', arguments[1], ['material']);
121
+ export function semiconductor(name, { material = name, T = 300 } = {}) {
122
+ checkOptions('semiconductor', arguments[1], ['material', 'T']);
99
123
  const sc = SEMICONDUCTORS[name];
100
124
  if (!sc) fail(`semiconductor: no data for '${name}' (the library has ${Object.keys(SEMICONDUCTORS).join(', ')})`);
101
- const VT = (GAS_CONSTANT * 300) / FARADAY;
125
+ if (typeof T !== 'number') fail(`semiconductor: T must be a number, the temperature in K, got ${shown(T)}`);
126
+ if (!(Number.isFinite(T) && T >= T_RANGE[0] && T <= T_RANGE[1])) {
127
+ fail(`semiconductor: T must be the temperature in K, from ${T_RANGE[0]} to ${T_RANGE[1]} (the range its temperature laws are good for), got ${T}`);
128
+ }
129
+ const { alpha, beta, gn, gp } = WITH_T[name];
130
+ const t = T / 300, varshni = (T) => (alpha * T * T) / (T + beta);
131
+ const Eg = sc.Eg - (varshni(T) - varshni(300));
132
+ const VT = (GAS_CONSTANT * T) / FARADAY;
102
133
  const perCm3 = (n) => (n * 1e6) / AVOGADRO;
103
134
  return {
104
135
  species: [
@@ -107,16 +138,76 @@ export function semiconductor(name, { material = name } = {}) {
107
138
  ],
108
139
  materials: {
109
140
  [material]: {
141
+ T, // the temperature the data are for: a device at another warns
110
142
  epsr: sc.epsr,
111
143
  species: {
112
- 'e-': { D: sc.mun * 1e-4 * VT, mu0: 0, cRef: perCm3(sc.Nc) },
113
- 'h+': { D: sc.mup * 1e-4 * VT, mu0: sc.Eg * FARADAY, cRef: perCm3(sc.Nv) },
144
+ 'e-': { D: sc.mun * powr(t, -gn) * 1e-4 * VT, mu0: 0, cRef: perCm3(sc.Nc * powr(t, 1.5)) },
145
+ 'h+': { D: sc.mup * powr(t, -gp) * 1e-4 * VT, mu0: Eg * FARADAY, cRef: perCm3(sc.Nv * powr(t, 1.5)) },
114
146
  },
115
147
  },
116
148
  },
117
149
  };
118
150
  }
119
151
 
152
+ /**
153
+ * A trap level in a semiconductor that holds charge: its two charge states as immobile species
154
+ * (D = 0), filled and emptied by capturing carriers, e⁻ + X⁰ = X⁻ and X⁻ + h⁺ = X⁰ (acceptor-like)
155
+ * or X⁺ + e⁻ = X⁰ and X⁰ + h⁺ = X⁺ (donor-like). In steady state it recombines at exactly the
156
+ * `srh` law's rate with the same τ_n, τ_p and n₁, and its charge is in Poisson's equation, which
157
+ * the `srh` law (the trap's occupancy eliminated) leaves out. Each node keeps its own total.
158
+ * @param {{ species: object[], materials: Record<string, object> }} lib a library entry with e⁻
159
+ * and h⁺ (semiconductor()), returned with the trap's states added
160
+ * @param {{ density: number, tauN: number, tauP: number, n1?: number, charge?: 'acceptor' | 'donor',
161
+ * name?: string, material?: string, T?: number }} opts density N_t (mol/m³); τ_n = 1/(c_n N_t)
162
+ * and τ_p = 1/(c_p N_t) (s) at that density; n₁, the electron density that half-fills it
163
+ * (mol/m³; default n_i, midgap); T (K, default the material's T, else 300)
164
+ * @returns {{ library: object, bulkReactions: object[], c0: Record<string, number> }} the library
165
+ * for build(), the capture reactions, and c0 for each layer holding the trap (its total N_t)
166
+ */
167
+ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name = 'X', material, T } = {}) {
168
+ checkOptions('trap', arguments[1], ['density', 'tauN', 'tauP', 'n1', 'charge', 'name', 'material', 'T']);
169
+ if (!lib || typeof lib !== 'object' || typeof lib.materials !== 'object') fail('trap: give the library entry first, semiconductor(\'Si\') say, then { density, tauN, tauP }');
170
+ const names = Object.keys(lib.materials);
171
+ const m = material ?? (names.length === 1 ? names[0] : fail(`trap: the library has materials ${names.join(', ')}: say which, with material`));
172
+ const mat = lib.materials[m];
173
+ if (!mat) fail(`trap: no material '${m}' in the library (it has ${names.join(', ')})`);
174
+ const el = mat.species?.['e-'], ho = mat.species?.['h+'];
175
+ if (!el || !ho) fail(`trap: material '${m}' needs e- and h+ (a semiconductor)`);
176
+ const pos = (v, what) => (Number.isFinite(v) && v > 0 ? v : fail(`trap: ${what}, got ${shown(v)}`));
177
+ pos(density, 'density must be the trap density N_t > 0 (mol/m³; units.perCm3(1e15), say)');
178
+ pos(tauN, 'tauN must be the electron lifetime τ_n = 1/(c_n N_t) > 0 (s)');
179
+ pos(tauP, 'tauP must be the hole lifetime τ_p = 1/(c_p N_t) > 0 (s)');
180
+ // (the material's own temperature, where it gives one: semiconductor()'s does)
181
+ if (T === undefined) T = mat.T ?? 300;
182
+ pos(T, 'T must be the temperature in K');
183
+ if (mat.T !== undefined && Math.abs(T - mat.T) > 0.01) fail(`trap: T is ${T} K, but material '${m}' is for ${mat.T} K; leave T out (the trap takes the material's), or remake the material for ${T} K`);
184
+ if (charge !== 'acceptor' && charge !== 'donor') fail(`trap: charge must be 'acceptor' (0/−) or 'donor' (+/0), got ${JSON.stringify(charge)}`);
185
+ if (typeof name !== 'string' || !/^[A-Za-z][\w()]*$/.test(name)) fail(`trap: name must be a plain name for the trap (its states are name0 and name- or name+), got ${JSON.stringify(name)}`);
186
+ const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
187
+ const cRefH = ho.cRef ?? lib.species.find((sp) => sp.name === 'h+')?.cRef;
188
+ const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 - el.mu0) / RT));
189
+ const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
190
+ // The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
191
+ const [empty, full] = charge === 'acceptor' ? [`${name}0`, `${name}-`] : [`${name}+`, `${name}0`];
192
+ const zEmpty = charge === 'acceptor' ? 0 : 1;
193
+ const states = {
194
+ [empty]: { D: 0, mu0: 0, cRef: density },
195
+ [full]: { D: 0, mu0: el.mu0 + RT * Math.log(half / cRefE), cRef: density },
196
+ };
197
+ return {
198
+ library: {
199
+ species: [...lib.species, { name: empty, z: zEmpty }, { name: full, z: zEmpty - 1 }],
200
+ materials: { ...lib.materials, [m]: { ...mat, species: { ...mat.species, ...states } } },
201
+ },
202
+ bulkReactions: [
203
+ { equation: `e- + ${empty} = ${full}`, kf: { [m]: 1 / (tauN * density) } },
204
+ { equation: `${full} + h+ = ${empty}`, kf: { [m]: 1 / (tauP * density) } },
205
+ ],
206
+ // (each node keeps the total, X⁰ + X⁻ say; the split is the solve's to find)
207
+ c0: { [empty]: density / 2, [full]: density / 2 },
208
+ };
209
+ }
210
+
120
211
  /**
121
212
  * A metal from `METALS` as a conductor material, its carrier `e-`.
122
213
  * @param {string} name
package/src/describe.js CHANGED
@@ -138,7 +138,7 @@ export function describe(def) {
138
138
  const { species, materials, regions, grid } = model;
139
139
  const RT = GAS_CONSTANT * model.T;
140
140
  const lines = [];
141
- const warnings = unitWarnings(def);
141
+ const warnings = [...model.warnings, ...unitWarnings(def)]; // (the definition's own: a material's data at another T, …)
142
142
  const total = grid.x[grid.x.length - 1] - grid.x[0];
143
143
  lines.push(`${regions.length} region${regions.length > 1 ? 's' : ''}, ${si(total, 'm')}, ${grid.x.length} nodes, T = ${model.T} K`);
144
144
  const geo = model.geometry;
package/src/device.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { buildGrid, applyGeometry } from './grid.js';
10
10
  import { FARADAY, GAS_CONSTANT } from './constants.js';
11
11
  import { normalizeStatistics } from './statistics.js';
12
- import { DeviceError } from './errors.js';
12
+ import { DeviceError, shown } from './errors.js';
13
13
  import { stoichiometry as equationStoichiometry, faceSides, parseEquation } from './equation.js';
14
14
 
15
15
  export { DeviceError };
@@ -47,12 +47,12 @@ function fields(obj, path, allowed) {
47
47
  }
48
48
 
49
49
  function finite(v, path) {
50
- need(isFiniteNumber(v), `${path} must be a finite number, got ${JSON.stringify(v)}`);
50
+ need(isFiniteNumber(v), `${path} must be a finite number, got ${shown(v)}`);
51
51
  return v;
52
52
  }
53
53
 
54
54
  function positive(v, path) {
55
- need(isFiniteNumber(v) && v > 0, `${path} must be a positive number, got ${JSON.stringify(v)}`);
55
+ need(isFiniteNumber(v) && v > 0, `${path} must be a positive number, got ${shown(v)}`);
56
56
  return v;
57
57
  }
58
58
 
@@ -108,9 +108,9 @@ function profile(v, path, what, ok, okText) {
108
108
  `${path} must be a profile { x, values }: two arrays of the same length, positions (m, the device's x) and ${what}`,
109
109
  );
110
110
  v.x.forEach((xk, k) => {
111
- need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${path}.x must be finite and strictly increasing, but x[${k}] is ${JSON.stringify(xk)}`);
111
+ need(isFiniteNumber(xk) && (k === 0 || xk > v.x[k - 1]), `${path}.x must be finite and strictly increasing, but x[${k}] is ${shown(xk)}`);
112
112
  });
113
- v.values.forEach((y, k) => need(isFiniteNumber(y) && ok(y), `${path}.values[${k}] must be ${okText}, got ${JSON.stringify(y)}`));
113
+ v.values.forEach((y, k) => need(isFiniteNumber(y) && ok(y), `${path}.values[${k}] must be ${okText}, got ${shown(y)}`));
114
114
  return { x: Float64Array.from(v.x), values: Float64Array.from(v.values) };
115
115
  }
116
116
 
@@ -138,7 +138,7 @@ function srhLaw(sdef, path, list, species, timeKeys) {
138
138
  }
139
139
 
140
140
  function nonNegative(v, path) {
141
- need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${JSON.stringify(v)}`);
141
+ need(isFiniteNumber(v) && v >= 0, `${path} must be a non-negative number, got ${shown(v)}`);
142
142
  return v;
143
143
  }
144
144
 
@@ -151,7 +151,7 @@ export function normalizeDevice(def) {
151
151
  need(isObject(def), 'device definition must be an object');
152
152
  fields(def, 'device', ['T', 'species', 'materials', 'regions', 'interfaces', 'bulkReactions', 'contacts', 'ports', 'grid', 'geometry']);
153
153
 
154
- const T = def.T === undefined ? 298.15 : positive(def.T, 'T');
154
+ const T = def.T === undefined ? 298.15 : positive(def.T, 'T (the temperature, K)');
155
155
  const RT = GAS_CONSTANT * T;
156
156
 
157
157
  // --- species
@@ -164,7 +164,7 @@ export function normalizeDevice(def) {
164
164
  fields(sp, path, ['name', 'z', 'cRef']);
165
165
  need(typeof sp.name === 'string' && sp.name.length > 0, `${path}.name must be a non-empty string`);
166
166
  need(!speciesIndex.has(sp.name), `${path}.name: duplicate species '${sp.name}'`);
167
- need(Number.isInteger(sp.z), `${path}.z (charge number) must be an integer, got ${JSON.stringify(sp.z)}`);
167
+ need(Number.isInteger(sp.z), `${path}.z (charge number) must be an integer, got ${shown(sp.z)}`);
168
168
  const cRef = sp.cRef === undefined ? undefined : positive(sp.cRef, `${path}.cRef`);
169
169
  speciesIndex.set(sp.name, i);
170
170
  species.push({ name: sp.name, z: sp.z, cRef });
@@ -179,13 +179,15 @@ export function normalizeDevice(def) {
179
179
  for (const [mname, mat] of Object.entries(def.materials)) {
180
180
  const path = `materials.${mname}`;
181
181
  need(isObject(mat), `${path} must be an object`);
182
+ // The temperature a material's data are for, where it says (semiconductor()'s do).
183
+ const dataT = mat.T === undefined ? undefined : positive(mat.T, `${path}.T (the temperature its data are for, K)`);
182
184
  if (mat.conductor !== undefined) {
183
185
  materialIndex.set(mname, materials.length);
184
- materials.push(normalizeConductor(mat, mname, path, species, speciesIndex));
186
+ materials.push({ ...normalizeConductor(mat, mname, path, species, speciesIndex), dataT });
185
187
  continue;
186
188
  }
187
189
  // ε = 0 makes the material strictly neutral: Poisson becomes local neutrality there.
188
- fields(mat, path, ['epsr', 'species', 'statistics']);
190
+ fields(mat, path, ['epsr', 'species', 'statistics', 'T']);
189
191
  const epsr = nonNegative(mat.epsr, `${path}.epsr`);
190
192
  need(isObject(mat.species), `${path}.species must be an object mapping species names to parameters`);
191
193
  const present = new Uint8Array(nSpecies);
@@ -205,7 +207,7 @@ export function normalizeDevice(def) {
205
207
  need(c !== undefined, `${spath}.cRef: no reference concentration (give it here or on the species)`);
206
208
  cRef[i] = positive(c, `${spath}.cRef`);
207
209
  }
208
- const m = { name: mname, epsr, present, D, mu0, cRef };
210
+ const m = { name: mname, epsr, present, D, mu0, cRef, dataT };
209
211
  const st = normalizeStatistics({ ...m, statistics: mat.statistics }, path, species, speciesIndex, RT, { need, finite, positive });
210
212
  m.models = st.models;
211
213
  m.modelOf = st.modelOf;
@@ -285,7 +287,7 @@ export function normalizeDevice(def) {
285
287
  c0Profile[i] = profile(v, at, 'concentrations (mol/m³)', (ck) => ck > 0, `a concentration > 0 (mol/m³)${none}`);
286
288
  continue;
287
289
  }
288
- need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, values }, got ${JSON.stringify(v)}${none}`);
290
+ need(isFiniteNumber(v) && v > 0, `${at} must be a concentration > 0 (mol/m³) or a profile { x, values }, got ${shown(v)}${none}`);
289
291
  c0[i] = v;
290
292
  }
291
293
  }
@@ -306,6 +308,23 @@ export function normalizeDevice(def) {
306
308
  };
307
309
  });
308
310
 
311
+ // A material whose data are for another temperature than the device's is silently wrong there
312
+ // (a semiconductor's n_i, gap and mobilities; water's μ°, ε and D): say so (for each material a
313
+ // region uses).
314
+ materials.forEach((m, k) => {
315
+ if (m.dataT === undefined || Math.abs(m.dataT - T) <= 0.01 || !regions.some((reg) => reg.material === k)) return;
316
+ const own = Object.keys(def.materials[m.name].species ?? {}), semi = own.includes('e-') && own.includes('h+');
317
+ warnings.push(
318
+ `materials.${m.name}: its data are for ${m.dataT} K, but the device is at ${T} K${def.T === undefined ? ' (the default, as it gives no T)' : ''}, ` +
319
+ (semi
320
+ ? `so they don't hold there (a semiconductor's n_i, gap and mobilities are ${m.dataT} K's): give the device T: ${m.dataT}, ` +
321
+ `or remake the material for ${T} K (semiconductor(name, { T: ${T} }))`
322
+ : `so they don't hold there: its μ° (so equilibrium constants and electrode potentials, Kw's by ~6× between 6 and 25 °C), ` +
323
+ `permittivity and diffusivities are ${m.dataT} K's. Give the device T: ${m.dataT}, or give the material data for ${T} K ` +
324
+ `and its T (aqueous() has none at other temperatures yet)`),
325
+ );
326
+ });
327
+
309
328
  // --- interfaces (one per face between consecutive regions)
310
329
  const nFaces = regions.length - 1;
311
330
  const idefs = def.interfaces ?? [];
@@ -490,7 +509,7 @@ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
490
509
  const list = [];
491
510
  let fixedA = 0, charge = 0, nFixed = 0;
492
511
  for (const [name, nu] of Object.entries(map)) {
493
- need(Number.isInteger(nu) && nu !== 0, `${path}.${name} must be a non-zero integer, got ${JSON.stringify(nu)}`);
512
+ need(Number.isInteger(nu) && nu !== 0, `${path}.${name} must be a non-zero integer, got ${shown(nu)}`);
494
513
  if (speciesIndex.has(name)) {
495
514
  const i = speciesIndex.get(name);
496
515
  list.push({ i, nu });
@@ -1048,7 +1067,7 @@ function gatedBy(raw, gates, lpath, where) {
1048
1067
  return Object.entries(raw).map(([gname, p]) => {
1049
1068
  const q = gates.findIndex((gt) => gt.name === gname);
1050
1069
  need(q >= 0, `${lpath}.gates.${gname}: no such gate${gates.length ? ` (there are ${gates.map((gt) => gt.name).join(', ')})` : ` (define it in ${where}.gates)`}`);
1051
- need(Number.isInteger(p) && p >= 1 && p <= 8, `${lpath}.gates.${gname}: the exponent must be a whole number from 1 to 8, got ${JSON.stringify(p)}`);
1070
+ need(Number.isInteger(p) && p >= 1 && p <= 8, `${lpath}.gates.${gname}: the exponent must be a whole number from 1 to 8, got ${shown(p)}`);
1052
1071
  return [q, p];
1053
1072
  });
1054
1073
  }
@@ -1081,6 +1100,8 @@ function normalizeConductor(mat, mname, path, species, speciesIndex) {
1081
1100
  for (const k of ['epsr', 'species', 'statistics']) {
1082
1101
  need(mat[k] === undefined, `${path}.${k}: a conductor takes only { conductor: { species, conductivity } }`);
1083
1102
  }
1103
+ fields(mat, path, ['conductor', 'T']);
1104
+ fields(mdef, `${path}.conductor`, ['species', 'conductivity']);
1084
1105
  need(speciesIndex.has(mdef.species), `${path}.conductor.species: unknown species ${JSON.stringify(mdef.species)}${known(speciesIndex)}`);
1085
1106
  const i = speciesIndex.get(mdef.species);
1086
1107
  need(species[i].z !== 0, `${path}.conductor.species: the conductor's carrier must be charged`);
@@ -1175,7 +1196,7 @@ function defaultInterfaceLinks(matL, matR, species) {
1175
1196
 
1176
1197
  function transferCoefficient(v, path) {
1177
1198
  if (v === undefined) return 0.5;
1178
- need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
1199
+ need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${shown(v)}`);
1179
1200
  return v;
1180
1201
  }
1181
1202
 
package/src/difference.js CHANGED
@@ -8,13 +8,19 @@
8
8
  // storage and space charge depend on δη_i − z_i δφ̂ alone; the displacement on δφ̂_R − δφ̂_L. A
9
9
  // uniform shift of a region (z_i s on each η_i, s on φ̂) then gives exactly zero, as it must.
10
10
  //
11
+ // Non-ideal statistics couple the species: c_i depends on every ζ_j, so a node's storage of i
12
+ // has a term in each δη_j − z_j δφ̂ (K_ij), and a flux has side terms in those at either end (its
13
+ // c_L and the excess potentials in its drift). Each is a term in the same differences, so the
14
+ // shift still gives exactly zero.
15
+ //
11
16
  // Indices are compact (the solver's rix), with the sink (index N) for an unknown that isn't one:
12
17
  // vectors passed in are read there as zero.
13
18
  export class DifferenceTerms {
14
- constructor(N, nodes, segments) {
19
+ constructor(N, nodes, segments, sides = 0) {
15
20
  this.N = N;
16
21
  this.dead = new Uint8Array(N + 1); // rows replaced since (a contact's level, a held stretch)
17
- this.nI = new Int32Array(nodes); // node term: η column (= its balance row), φ̂ column (= Gauss row)
22
+ this.nR = new Int32Array(nodes); // node term: its balance row, η column, φ̂ column (= Gauss row)
23
+ this.nI = new Int32Array(nodes);
18
24
  this.nP = new Int32Array(nodes);
19
25
  this.nZ = new Float64Array(nodes);
20
26
  this.nMass = new Float64Array(nodes); // storage per unit (δη − z δφ̂), times 1/dt (or iω)
@@ -24,6 +30,9 @@ export class DifferenceTerms {
24
30
  this.sC = new Float64Array(3 * segments); // a, b, d
25
31
  this.dI = new Int32Array(2 * segments); // displacement: φ̂_L, φ̂_R
26
32
  this.dK = new Float64Array(segments);
33
+ this.xI = new Int32Array(4 * sides); // a flux's side term: its two rows, an η column, its φ̂ column
34
+ this.xZ = new Float64Array(sides);
35
+ this.xC = new Float64Array(sides);
27
36
  this.combos = []; // [rk, ri, w]: row rk += w × row ri (strictly neutral nodes' charge rows)
28
37
  this.t = new Float64Array(N + 1);
29
38
  this.x = new Float64Array(N + 1);
@@ -33,13 +42,16 @@ export class DifferenceTerms {
33
42
  // Afresh for an assembly; `rate` multiplies storage in captured rows (1/dt).
34
43
  reset(rate = 0) {
35
44
  this.rate = rate;
36
- this.nn = this.ns = this.nd = 0;
45
+ this.nn = this.ns = this.nd = this.nx = 0;
37
46
  this.dead.fill(0);
38
47
  this.combos.length = 0;
39
48
  }
40
49
 
41
- node(i, p, z, mass, charge) {
50
+ // Storage in row r (mass) and charge in Gauss row p, per unit δη_i − z δφ̂ (row r = i, unless
51
+ // non-ideal statistics couple the species).
52
+ node(i, p, z, mass, charge, r = i) {
42
53
  const k = this.nn++;
54
+ this.nR[k] = r;
43
55
  this.nI[k] = i;
44
56
  this.nP[k] = p;
45
57
  this.nZ[k] = z;
@@ -59,6 +71,17 @@ export class DifferenceTerms {
59
71
  this.sC[3 * k + 2] = d;
60
72
  }
61
73
 
74
+ // A flux from row rL to row rR, per unit δη_i − z δφ̂ at one end.
75
+ side(rL, rR, i, p, z, w) {
76
+ const k = this.nx++;
77
+ this.xI[4 * k] = rL;
78
+ this.xI[4 * k + 1] = rR;
79
+ this.xI[4 * k + 2] = i;
80
+ this.xI[4 * k + 3] = p;
81
+ this.xZ[k] = z;
82
+ this.xC[k] = w;
83
+ }
84
+
62
85
  displacement(pL, pR, k) {
63
86
  const q = this.nd++;
64
87
  this.dI[2 * q] = pL;
@@ -83,7 +106,7 @@ export class DifferenceTerms {
83
106
  for (let k = 0; k < this.nn; k++) {
84
107
  const i = this.nI[k], p = this.nP[k], z = this.nZ[k];
85
108
  let f = 0;
86
- if (row === i) f += mass * this.nMass[k];
109
+ if (row === this.nR[k]) f += mass * this.nMass[k];
87
110
  if (row === p) f += this.nCharge[k];
88
111
  if (f === 0) continue;
89
112
  add(i, f);
@@ -99,6 +122,12 @@ export class DifferenceTerms {
99
122
  add(this.sI[4 * k + 2], s * (-z * a - d));
100
123
  add(this.sI[4 * k + 3], s * d);
101
124
  }
125
+ for (let k = 0; k < this.nx; k++) {
126
+ const s = row === this.xI[4 * k] ? 1 : row === this.xI[4 * k + 1] ? -1 : 0;
127
+ if (s === 0) continue;
128
+ add(this.xI[4 * k + 2], s * this.xC[k]);
129
+ add(this.xI[4 * k + 3], -s * this.xZ[k] * this.xC[k]);
130
+ }
102
131
  for (let q = 0; q < this.nd; q++) {
103
132
  const pL = this.dI[2 * q], pR = this.dI[2 * q + 1], s = row === pL ? 1 : row === pR ? -1 : 0;
104
133
  if (s === 0) continue;
@@ -114,10 +143,10 @@ export class DifferenceTerms {
114
143
  for (let j = 0; j < N; j++) x[j] = v[j];
115
144
  x[N] = 0;
116
145
  t.fill(0);
117
- const { nI, nP, nZ, nMass, nCharge } = this;
146
+ const { nR, nI, nP, nZ, nMass, nCharge } = this;
118
147
  for (let k = 0; k < this.nn; k++) {
119
148
  const i = nI[k], p = nP[k], w = x[i] - nZ[k] * x[p];
120
- t[i] += mass * nMass[k] * w;
149
+ t[nR[k]] += mass * nMass[k] * w;
121
150
  t[p] += steady * nCharge[k] * w;
122
151
  }
123
152
  if (steady !== 0) {
@@ -128,6 +157,12 @@ export class DifferenceTerms {
128
157
  t[iL] += dN;
129
158
  t[iR] -= dN;
130
159
  }
160
+ const { xI, xZ, xC } = this;
161
+ for (let k = 0; k < this.nx; k++) {
162
+ const dN = steady * xC[k] * (x[xI[4 * k + 2]] - xZ[k] * x[xI[4 * k + 3]]);
163
+ t[xI[4 * k]] += dN;
164
+ t[xI[4 * k + 1]] -= dN;
165
+ }
131
166
  for (let q = 0; q < this.nd; q++) {
132
167
  const pL = dI[2 * q], pR = dI[2 * q + 1], dD = -steady * dK[q] * (x[pR] - x[pL]);
133
168
  t[pL] += dD;
package/src/errors.js CHANGED
@@ -39,3 +39,21 @@ export function checkOptions(where, opts, allowed, Kind = DeviceError) {
39
39
  throw new Kind(`${where}: no option '${k}'${near ? ` (did you mean '${near}'?)` : ''}; the options are ${allowed.join(', ')}`);
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * A value as an error message shows it: a number as itself (NaN and Infinity too, which JSON
45
+ * would print as null), a string quoted and called one ("350", a string, where a number was
46
+ * wanted).
47
+ * @param {unknown} v
48
+ * @returns {string}
49
+ */
50
+ export function shown(v) {
51
+ if (typeof v === 'number' || typeof v === 'bigint' || v === undefined) return String(v);
52
+ if (typeof v === 'string') return `${JSON.stringify(v)} (a string)`;
53
+ if (typeof v === 'function') return 'a function';
54
+ try {
55
+ return JSON.stringify(v, (k, x) => (typeof x === 'number' && !Number.isFinite(x) ? String(x) : x)) ?? String(v);
56
+ } catch {
57
+ return String(v);
58
+ }
59
+ }
package/src/index.js CHANGED
@@ -187,7 +187,9 @@ export class Device {
187
187
  // A solve that fails reports the state it failed in, and leaves the device as it was: a
188
188
  // device with no steady state (a host filling at a current) can still advance() from there.
189
189
  const snap = this.solver._snapshot();
190
- const sol = makeSolution(this.solver, this.solver.solveSteady(opts));
190
+ const result = this.solver.solveSteady(opts);
191
+ // (a failed solve's state isn't a solution: its accuracy warnings are left out)
192
+ const sol = makeSolution(this.solver, result.converged ? result : { ...result, unsolved: true });
191
193
  if (!sol.converged) this.solver._restore(snap);
192
194
  return sol;
193
195
  }
package/src/kit.js CHANGED
@@ -18,7 +18,7 @@ export { check } from './checks.js';
18
18
  export { particles } from './particles.js';
19
19
  export { polarization } from './polarization.js';
20
20
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
21
- export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
21
+ export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal, trap } from './data.js';
22
22
 
23
23
  const fail = (message) => {
24
24
  throw new DeviceError(message);
package/src/solution.js CHANGED
@@ -292,6 +292,13 @@ export function makeSolution(solver, result = {}) {
292
292
  'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
293
293
  );
294
294
  }
295
+ // The warnings below judge a solution's accuracy (its grid, its double layers). Of the state a
296
+ // steady solve gave up in, which isn't a solution, they say nothing useful (a driven current
297
+ // it couldn't pass leaves the device at ±26 V, where depletion reaches the contacts), so they're
298
+ // left out there; the two about the state itself (degenerate carriers, a species denser than
299
+ // any material) stay, as possible causes, and say they're of that state. (A failed time step
300
+ // restores the last good state, which is a solution: its warnings all stand.)
301
+ const solved = !result.unsolved, stopped = solved ? '' : ' (in the state the unconverged solve stopped in)';
295
302
  const debye = (g) => {
296
303
  const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
297
304
  const s2 = solver.screening(g); // zᵀKz: Σ z² c for ideal statistics
@@ -347,7 +354,7 @@ export function makeSolution(solver, result = {}) {
347
354
  );
348
355
  };
349
356
  interfaces.forEach((itf, f) => {
350
- if (itf.phi.type === 'neutral') return;
357
+ if (itf.phi.type === 'neutral' || !solved) return;
351
358
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
352
359
  check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
353
360
  check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
@@ -357,11 +364,11 @@ export function makeSolution(solver, result = {}) {
357
364
  boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`, sigma, reacting(1));
358
365
  });
359
366
  const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
360
- if (resolves(contacts.left)) {
367
+ if (solved && resolves(contacts.left)) {
361
368
  check(0, grid.segLength[0], 'contacts.left');
362
369
  boxed(0, grid.segLength[0], contacts.left.phi, -1, 'contacts.left');
363
370
  }
364
- if (resolves(contacts.right)) {
371
+ if (solved && resolves(contacts.right)) {
365
372
  check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
366
373
  boxed(nNodes - 1, grid.segLength[nNodes - 2], contacts.right.phi, -1, 'contacts.right');
367
374
  }
@@ -380,7 +387,7 @@ export function makeSolution(solver, result = {}) {
380
387
  }
381
388
  if (top > mat.cRef[i]) {
382
389
  sol.warnings.push(
383
- `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, above its cRef (${mat.cRef[i].toExponential(2)}, the band's effective density of states), where Boltzmann statistics no longer hold: ` +
390
+ `${reg.name}${stopped}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, above its cRef (${mat.cRef[i].toExponential(2)}, the band's effective density of states), where Boltzmann statistics no longer hold: ` +
384
391
  'give the material Fermi–Dirac statistics (docs/statistics.md), or ask whether this state is physical (a surface driven past what can carry its carriers away).',
385
392
  );
386
393
  }
@@ -410,7 +417,7 @@ export function makeSolution(solver, result = {}) {
410
417
  }
411
418
  if (top > 1e6) {
412
419
  sol.warnings.push(
413
- `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, denser than any material: ` +
420
+ `${reg.name}${stopped}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, denser than any material: ` +
414
421
  'a level set where the device cannot follow it. Check what holds it there (the V or bath of a contact, its mu0 and cRef) with describe() from driftlet/kit.',
415
422
  );
416
423
  }
@@ -421,7 +428,7 @@ export function makeSolution(solver, result = {}) {
421
428
  // ρ λ_D at the node beside it; against the region's own space charge (half Σ|ρ| over it, the
422
429
  // two signs of a double layer), warn past 1e-3.
423
430
  for (const side of ['left', 'right']) {
424
- if (contacts[side].phi.type !== 'bulk') continue;
431
+ if (contacts[side].phi.type !== 'bulk' || !solved) continue;
425
432
  const r = side === 'left' ? 0 : model.regions.length - 1, a = grid.regionStart[r], b = grid.regionEnd[r];
426
433
  const mat = model.materials[model.regions[r].material];
427
434
  if (!(mat.epsr > 0) || mat.conductor || b - a < 3) continue;
@@ -458,7 +465,7 @@ export function makeSolution(solver, result = {}) {
458
465
  // a concentration ratio r across it). Flag a region's end cell whose field is well above its
459
466
  // neighbour's while its concentration changes steeply, for species carrying the current. (A
460
467
  // pn depletion region's field changes smoothly from cell to cell, which SG handles.)
461
- if (Number.isFinite(sol.current)) {
468
+ if (solved && Number.isFinite(sol.current)) {
462
469
  const share = species.map((sp, i) =>
463
470
  Math.max(Math.abs(sp.z * (sol.contacts.left.flux[sp.name] ?? 0)), Math.abs(sp.z * (sol.contacts.right.flux[sp.name] ?? 0))),
464
471
  );