driftlet 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  */
@@ -84,21 +85,49 @@ export function aqueous(names, { epsr = WATER_EPSR, material = 'water' } = {}) {
84
85
  return { species, materials: { [material]: { epsr, species: entries } } };
85
86
  }
86
87
 
88
+ // How the semiconductors' data move with temperature, for semiconductor(name, { T }):
89
+ // - the gap by Varshni's form, Eg(T) = Eg0 − αT²/(T + β) (Y. P. Varshni, Physica 34, 149, 1967),
90
+ // with Thurmond's parameters (C. D. Thurmond, J. Electrochem. Soc. 122, 1133, 1975, as
91
+ // tabulated in Sze, 2nd ed.): Eg0 (eV), alpha (eV/K), beta (K). Only the shift from 300 K is
92
+ // used, on the table's Eg, so T = 300 K is the table: Varshni's own Eg(300) differs from it by
93
+ // 4.5 meV (Si, 1.1245), 3.4 meV (Ge, 0.6634) and 1.5 meV (GaAs, 1.4225).
94
+ // - Nc and Nv ∝ T^1.5 (parabolic bands, effective masses held fixed).
95
+ // - mobilities ∝ T^−gn and T^−gp, the lattice-limited power laws for lightly doped material
96
+ // (Sze, 2nd ed., ch. 1): approximate, and wrong where impurity scattering matters (heavy
97
+ // doping, or cold). D follows from μ and T by Einstein's relation, D = μkT/q.
98
+ // - the electron affinity chi isn't moved.
99
+ const WITH_T = {
100
+ Si: { Eg0: 1.17, alpha: 4.73e-4, beta: 636, gn: 2.42, gp: 2.2 },
101
+ Ge: { Eg0: 0.7437, alpha: 4.774e-4, beta: 235, gn: 1.66, gp: 2.33 },
102
+ GaAs: { Eg0: 1.519, alpha: 5.405e-4, beta: 204, gn: 1.0, gp: 2.1 },
103
+ };
104
+ const T_RANGE = [150, 500];
105
+
87
106
  /**
88
107
  * 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`.
108
+ * c_ref = Nc and Nv, D from the mobilities by the Einstein relation at T, and μ°_e⁻ = 0 (φ
109
+ * anchored at the conduction band) and μ°_h⁺ = Eg·F. The data are for 300 K; another T (150 to
110
+ * 500 K) moves them: the gap by Varshni's shift from 300 K, Nc and Nv as T^1.5, the mobilities by
111
+ * lattice-limited power laws (light doping). The material carries its `T`, and a device at
112
+ * another temperature warns: give the device the same T.
91
113
  * Statistics are ideal (Boltzmann); add Fermi–Dirac to the material for degenerate doping.
92
114
  * @param {string} name 'Si', 'Ge' or 'GaAs'
93
- * @param {{ material?: string }} [opts]
115
+ * @param {{ material?: string, T?: number }} [opts] the material's name, and the temperature
116
+ * (K, default 300, the table's)
94
117
  * @returns {{ species: object[], materials: Record<string, object> }}
95
118
  */
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']);
119
+ export function semiconductor(name, { material = name, T = 300 } = {}) {
120
+ checkOptions('semiconductor', arguments[1], ['material', 'T']);
99
121
  const sc = SEMICONDUCTORS[name];
100
122
  if (!sc) fail(`semiconductor: no data for '${name}' (the library has ${Object.keys(SEMICONDUCTORS).join(', ')})`);
101
- const VT = (GAS_CONSTANT * 300) / FARADAY;
123
+ if (typeof T !== 'number') fail(`semiconductor: T must be a number, the temperature in K, got ${shown(T)}`);
124
+ if (!(Number.isFinite(T) && T >= T_RANGE[0] && T <= T_RANGE[1])) {
125
+ 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}`);
126
+ }
127
+ const { alpha, beta, gn, gp } = WITH_T[name];
128
+ const t = T / 300, varshni = (T) => (alpha * T * T) / (T + beta);
129
+ const Eg = sc.Eg - (varshni(T) - varshni(300));
130
+ const VT = (GAS_CONSTANT * T) / FARADAY;
102
131
  const perCm3 = (n) => (n * 1e6) / AVOGADRO;
103
132
  return {
104
133
  species: [
@@ -107,16 +136,76 @@ export function semiconductor(name, { material = name } = {}) {
107
136
  ],
108
137
  materials: {
109
138
  [material]: {
139
+ T, // the temperature the data are for: a device at another warns
110
140
  epsr: sc.epsr,
111
141
  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) },
142
+ 'e-': { D: sc.mun * powr(t, -gn) * 1e-4 * VT, mu0: 0, cRef: perCm3(sc.Nc * powr(t, 1.5)) },
143
+ 'h+': { D: sc.mup * powr(t, -gp) * 1e-4 * VT, mu0: Eg * FARADAY, cRef: perCm3(sc.Nv * powr(t, 1.5)) },
114
144
  },
115
145
  },
116
146
  },
117
147
  };
118
148
  }
119
149
 
150
+ /**
151
+ * A trap level in a semiconductor that holds charge: its two charge states as immobile species
152
+ * (D = 0), filled and emptied by capturing carriers, e⁻ + X⁰ = X⁻ and X⁻ + h⁺ = X⁰ (acceptor-like)
153
+ * or X⁺ + e⁻ = X⁰ and X⁰ + h⁺ = X⁺ (donor-like). In steady state it recombines at exactly the
154
+ * `srh` law's rate with the same τ_n, τ_p and n₁, and its charge is in Poisson's equation, which
155
+ * the `srh` law (the trap's occupancy eliminated) leaves out. Each node keeps its own total.
156
+ * @param {{ species: object[], materials: Record<string, object> }} lib a library entry with e⁻
157
+ * and h⁺ (semiconductor()), returned with the trap's states added
158
+ * @param {{ density: number, tauN: number, tauP: number, n1?: number, charge?: 'acceptor' | 'donor',
159
+ * name?: string, material?: string, T?: number }} opts density N_t (mol/m³); τ_n = 1/(c_n N_t)
160
+ * and τ_p = 1/(c_p N_t) (s) at that density; n₁, the electron density that half-fills it
161
+ * (mol/m³; default n_i, midgap); T (K, default the material's T, else 300)
162
+ * @returns {{ library: object, bulkReactions: object[], c0: Record<string, number> }} the library
163
+ * for build(), the capture reactions, and c0 for each layer holding the trap (its total N_t)
164
+ */
165
+ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name = 'X', material, T } = {}) {
166
+ checkOptions('trap', arguments[1], ['density', 'tauN', 'tauP', 'n1', 'charge', 'name', 'material', 'T']);
167
+ if (!lib || typeof lib !== 'object' || typeof lib.materials !== 'object') fail('trap: give the library entry first, semiconductor(\'Si\') say, then { density, tauN, tauP }');
168
+ const names = Object.keys(lib.materials);
169
+ const m = material ?? (names.length === 1 ? names[0] : fail(`trap: the library has materials ${names.join(', ')}: say which, with material`));
170
+ const mat = lib.materials[m];
171
+ if (!mat) fail(`trap: no material '${m}' in the library (it has ${names.join(', ')})`);
172
+ const el = mat.species?.['e-'], ho = mat.species?.['h+'];
173
+ if (!el || !ho) fail(`trap: material '${m}' needs e- and h+ (a semiconductor)`);
174
+ const pos = (v, what) => (Number.isFinite(v) && v > 0 ? v : fail(`trap: ${what}, got ${shown(v)}`));
175
+ pos(density, 'density must be the trap density N_t > 0 (mol/m³; units.perCm3(1e15), say)');
176
+ pos(tauN, 'tauN must be the electron lifetime τ_n = 1/(c_n N_t) > 0 (s)');
177
+ pos(tauP, 'tauP must be the hole lifetime τ_p = 1/(c_p N_t) > 0 (s)');
178
+ // (the material's own temperature, where it gives one: semiconductor()'s does)
179
+ if (T === undefined) T = mat.T ?? 300;
180
+ pos(T, 'T must be the temperature in K');
181
+ 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`);
182
+ if (charge !== 'acceptor' && charge !== 'donor') fail(`trap: charge must be 'acceptor' (0/−) or 'donor' (+/0), got ${JSON.stringify(charge)}`);
183
+ 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)}`);
184
+ const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
185
+ const cRefH = ho.cRef ?? lib.species.find((sp) => sp.name === 'h+')?.cRef;
186
+ const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 - el.mu0) / RT));
187
+ const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
188
+ // The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
189
+ const [empty, full] = charge === 'acceptor' ? [`${name}0`, `${name}-`] : [`${name}+`, `${name}0`];
190
+ const zEmpty = charge === 'acceptor' ? 0 : 1;
191
+ const states = {
192
+ [empty]: { D: 0, mu0: 0, cRef: density },
193
+ [full]: { D: 0, mu0: el.mu0 + RT * Math.log(half / cRefE), cRef: density },
194
+ };
195
+ return {
196
+ library: {
197
+ species: [...lib.species, { name: empty, z: zEmpty }, { name: full, z: zEmpty - 1 }],
198
+ materials: { ...lib.materials, [m]: { ...mat, species: { ...mat.species, ...states } } },
199
+ },
200
+ bulkReactions: [
201
+ { equation: `e- + ${empty} = ${full}`, kf: { [m]: 1 / (tauN * density) } },
202
+ { equation: `${full} + h+ = ${empty}`, kf: { [m]: 1 / (tauP * density) } },
203
+ ],
204
+ // (each node keeps the total, X⁰ + X⁻ say; the split is the solve's to find)
205
+ c0: { [empty]: density / 2, [full]: density / 2 },
206
+ };
207
+ }
208
+
120
209
  /**
121
210
  * A metal from `METALS` as a conductor material, its carrier `e-`.
122
211
  * @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,17 @@ export function normalizeDevice(def) {
306
308
  };
307
309
  });
308
310
 
311
+ // A material whose data are for another temperature than the device's gives the wrong n_i, gap
312
+ // and mobilities, silently: say so (for each material a region uses).
313
+ materials.forEach((m, k) => {
314
+ if (m.dataT === undefined || Math.abs(m.dataT - T) <= 0.01 || !regions.some((reg) => reg.material === k)) return;
315
+ warnings.push(
316
+ `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)' : ''}, ` +
317
+ `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}, ` +
318
+ `or remake the material for ${T} K (semiconductor(name, { T: ${T} }))`,
319
+ );
320
+ });
321
+
309
322
  // --- interfaces (one per face between consecutive regions)
310
323
  const nFaces = regions.length - 1;
311
324
  const idefs = def.interfaces ?? [];
@@ -355,8 +368,8 @@ export function normalizeDevice(def) {
355
368
  // A contact that passes nothing can't be driven by a current.
356
369
  for (const side of ['left', 'right']) {
357
370
  const ct = contacts[side];
358
- const passes = ct.species.some((l) => l.type !== 'blocked') || ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
359
- need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
371
+ const passes = ct.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0) || ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
372
+ need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked charged species, no gate)`);
360
373
  ct.passes = passes;
361
374
  }
362
375
  checkLevel(terminals.map((t) => t.drive), terminals.map((t) => (t.kind === 'port' ? ports[t.index] : contacts[t.side])));
@@ -490,7 +503,7 @@ function stoichiometry(map, path, fixed, fixedPath, species, speciesIndex, RT) {
490
503
  const list = [];
491
504
  let fixedA = 0, charge = 0, nFixed = 0;
492
505
  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)}`);
506
+ need(Number.isInteger(nu) && nu !== 0, `${path}.${name} must be a non-zero integer, got ${shown(nu)}`);
494
507
  if (speciesIndex.has(name)) {
495
508
  const i = speciesIndex.get(name);
496
509
  list.push({ i, nu });
@@ -889,7 +902,13 @@ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, sur
889
902
  need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
890
903
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
891
904
  need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
892
- return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
905
+ const alpha = transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare = rdef.bare === true;
906
+ // Langmuir sites: the reaction fills Δ of them going forward, and its transition state holds
907
+ // m bare ones (those it fills, or one if it runs only on bare metal), so the forward rate
908
+ // goes as θ₀^m and the backward as θ₀^(m−Δ): the prefactor's θ₀^(m(1−α)+(m−Δ)α), with each
909
+ // surface species at its coverage θ.
910
+ const fills = part.reduce((t, p) => t + (p.side === 2 ? p.nu : 0), 0), m = Math.max(fills, 0, bare ? 1 : 0);
911
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha, bare, sites: m * (1 - alpha) + (m - fills) * alpha };
893
912
  });
894
913
  }
895
914
 
@@ -1042,7 +1061,7 @@ function gatedBy(raw, gates, lpath, where) {
1042
1061
  return Object.entries(raw).map(([gname, p]) => {
1043
1062
  const q = gates.findIndex((gt) => gt.name === gname);
1044
1063
  need(q >= 0, `${lpath}.gates.${gname}: no such gate${gates.length ? ` (there are ${gates.map((gt) => gt.name).join(', ')})` : ` (define it in ${where}.gates)`}`);
1045
- 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)}`);
1064
+ 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)}`);
1046
1065
  return [q, p];
1047
1066
  });
1048
1067
  }
@@ -1075,6 +1094,8 @@ function normalizeConductor(mat, mname, path, species, speciesIndex) {
1075
1094
  for (const k of ['epsr', 'species', 'statistics']) {
1076
1095
  need(mat[k] === undefined, `${path}.${k}: a conductor takes only { conductor: { species, conductivity } }`);
1077
1096
  }
1097
+ fields(mat, path, ['conductor', 'T']);
1098
+ fields(mdef, `${path}.conductor`, ['species', 'conductivity']);
1078
1099
  need(speciesIndex.has(mdef.species), `${path}.conductor.species: unknown species ${JSON.stringify(mdef.species)}${known(speciesIndex)}`);
1079
1100
  const i = speciesIndex.get(mdef.species);
1080
1101
  need(species[i].z !== 0, `${path}.conductor.species: the conductor's carrier must be charged`);
@@ -1169,7 +1190,7 @@ function defaultInterfaceLinks(matL, matR, species) {
1169
1190
 
1170
1191
  function transferCoefficient(v, path) {
1171
1192
  if (v === undefined) return 0.5;
1172
- need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${JSON.stringify(v)}`);
1193
+ need(isFiniteNumber(v) && v >= 0 && v <= 1, `${path} must be between 0 and 1, got ${shown(v)}`);
1173
1194
  return v;
1174
1195
  }
1175
1196
 
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);
@@ -115,10 +115,10 @@ export function polarization(device, sol, where, V, opts = {}) {
115
115
  const out = parts.map(({ rx, k, base, s, q, pref, surf }) => {
116
116
  const rate = Float64Array.from(Vs, (v, j) => {
117
117
  const { lnA, bare } = at[j];
118
- let a = base + s * v, p = pref * (rx.bare ? bare : 1);
118
+ let a = base + s * v, p = pref * (rx.sites ? powr(bare, rx.sites) : 1);
119
119
  for (const u of surf) {
120
120
  a -= u.nu * lnA[u.s];
121
- p *= Math.exp(u.e * lnA[u.s]);
121
+ p *= Math.exp(u.e * lnA[u.s]) * powr(bare, u.e); // its coverage θ = θ₀ a
122
122
  }
123
123
  return rx.vmax ? p * -Math.expm1(-a) : p * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
124
124
  });
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
  );
package/src/solver.js CHANGED
@@ -373,7 +373,8 @@ export class Solver {
373
373
  // φ is undefined in a conductor or an insertion host, and in a charge-free region (no
374
374
  // charged species) unless its electrostatic cluster, the regions joined to it by faces that
375
375
  // aren't neutral, reaches something that fixes φ: a region with charged species, a gate or
376
- // pinned contact, or a capacitive face to a conductor. (Else φ there is a free constant.)
376
+ // pinned contact, or a face that isn't neutral to a conductor or to a strictly neutral region
377
+ // with ions. (Else φ there is a free constant.)
377
378
  this.phiUndefined = new Uint8Array(nNodes);
378
379
  const charged = (mat) => species.some((sp, i) => mat.present[i] && sp.z !== 0);
379
380
  const pins = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
@@ -385,8 +386,11 @@ export class Solver {
385
386
  while (r1 + 1 < regions.length && model.interfaces[r1].phi.type !== 'neutral' && !free(r1 + 1)) r1++;
386
387
  let anchor = (r0 === 0 && pins(contacts.left)) || (r1 === regions.length - 1 && pins(contacts.right));
387
388
  for (let r = r0; r <= r1; r++) if (charged(materials[regions[r].material])) anchor = true;
388
- if (r0 > 0 && model.interfaces[r0 - 1].phi.type !== 'neutral' && materials[regions[r0 - 1].material].conductor) anchor = true;
389
- if (r1 < regions.length - 1 && model.interfaces[r1].phi.type !== 'neutral' && materials[regions[r1 + 1].material].conductor) anchor = true;
389
+ // A neighbour across a face that isn't neutral: a conductor (a gate), or a strictly neutral
390
+ // region whose ions fix its φ.
391
+ const fixes = (r) => materials[regions[r].material].conductor || (!materials[regions[r].material].phiFree && charged(materials[regions[r].material]));
392
+ if (r0 > 0 && model.interfaces[r0 - 1].phi.type !== 'neutral' && fixes(r0 - 1)) anchor = true;
393
+ if (r1 < regions.length - 1 && model.interfaces[r1].phi.type !== 'neutral' && fixes(r1 + 1)) anchor = true;
390
394
  for (let r = r0; r <= r1; r++) anchored[r] = anchor ? 1 : 2;
391
395
  r0 = r1;
392
396
  }
@@ -585,6 +589,16 @@ export class Solver {
585
589
  const mat = materials[regions[grid.nodeRegion[g]].material], law = contacts[k === 0 ? 'left' : 'right'].phi.type;
586
590
  if (!mat.conductor && mat.epsr === 0 && (law === 'bulk' || law === 'neutral') && !this.chargeNode[g] && !this.phiUndefined[g] && this.nodeIdeal[g]) this.lateStorage[g] = 1;
587
591
  });
592
+ // An end node's species held by a port (its contact blocks it, so the level is the port's):
593
+ // its row is the port's, storage and all, and the contact passes none of it.
594
+ this.portHeldEnd = new Uint8Array(2 * n);
595
+ model.ports.forEach((port) => {
596
+ [0, nNodes - 1].forEach((g, k) => {
597
+ if (!port.nodes.includes(g)) return;
598
+ const ct = contacts[k === 0 ? 'left' : 'right'];
599
+ for (let i = 0; i < n; i++) if (port.species[i].type === 'equilibrium' && ct.species[i].type === 'blocked') this.portHeldEnd[k * n + i] = 1;
600
+ });
601
+ });
588
602
  this.combining = false;
589
603
  this.bookkeeping = regions.map((_, r) => {
590
604
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], nodes = [], segs = [];
@@ -1895,6 +1909,14 @@ export class Solver {
1895
1909
  // toward their mixed potential.
1896
1910
  for (const k of this.floating) {
1897
1911
  const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1912
+ const gate = t.kind === 'contact' ? model.contacts[t.side].phi : null;
1913
+ if (gate?.type === 'capacitive') {
1914
+ // A gate contact likewise: at its zero-charge level from the φ beside it (a pinned
1915
+ // one's charge is the field's, already zero against a neutral start).
1916
+ const g = t.side === 'left' ? 0 : this.nNodes - 1;
1917
+ if (!this.phiUndefined[g]) this.termV[k] = gate.zeroCharge + this.VT * u[this.blockOfNode[g] * M];
1918
+ continue;
1919
+ }
1898
1920
  if (!port?.capacitance) continue;
1899
1921
  let w = 0, sum = 0;
1900
1922
  port.nodes.forEach((g, j) => {
@@ -1968,9 +1990,9 @@ export class Solver {
1968
1990
  }
1969
1991
  const o = b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
1970
1992
  a -= p.nu * (u[o] + uLo[o]);
1971
- pref *= p.side === 2 ? Math.exp(e * (u[o] + uLo[o] - port.surface[p.s].mu0 / RT)) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1993
+ pref *= p.side === 2 ? powr(th[g * nSurf + p.s], e) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1972
1994
  }
1973
- if (rx.bare) pref *= this.th0[g];
1995
+ if (rx.sites) pref *= powr(this.th0[g], rx.sites);
1974
1996
  return pref * bvFactor(a, rx.alpha).g;
1975
1997
  }
1976
1998
 
@@ -2193,6 +2215,11 @@ export class Solver {
2193
2215
  if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, (this.sheetSign[g] * this.model.grid.area[g]) / (z[im] * FARADAY * dt));
2194
2216
  }
2195
2217
 
2218
+ // Whether a port holds species i at node g, a device end node (see portHeldEnd).
2219
+ _portHeldAt(g, i) {
2220
+ return g === 0 ? this.portHeldEnd[i] === 1 : g === this.nNodes - 1 && this.portHeldEnd[this.n + i] === 1;
2221
+ }
2222
+
2196
2223
  // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
2197
2224
  // charge in the Gauss row, written straight into the diagonal blocks by local index. A
2198
2225
  // dielectric is the case with no species.
@@ -2212,7 +2239,7 @@ export class Solver {
2212
2239
  for (let i = 0; i < n; i++) {
2213
2240
  const k = g * n + i;
2214
2241
  if (!present[k]) continue;
2215
- const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0);
2242
+ const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0 && !this._portHeldAt(g, i));
2216
2243
  if (stores) res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
2217
2244
  q += F * z[i] * ck;
2218
2245
  dq += F * z[i] * z[i] * ck;
@@ -3093,18 +3120,19 @@ export class Solver {
3093
3120
  aLo -= p.nu * uLo[o];
3094
3121
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
3095
3122
  if (p.side === 2) {
3096
- // a surface species at activity θ/θ₀ = e^ζ
3097
- pref *= Math.exp(e * (u[o] + uLo[o] - surf[p.s].mu0 / RT));
3123
+ // a surface species at its coverage θ = θ₀e^ζ, with ∂ln θ_s/∂η_x = δ_sx − θ_x
3124
+ pref *= powr(th[g * nSurf + p.s], e);
3098
3125
  d[1 + n + p.s] += e;
3126
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= e * th[g * nSurf + x];
3099
3127
  } else {
3100
3128
  pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
3101
3129
  this._dlnc(g, p.i, e, d);
3102
3130
  }
3103
3131
  }
3104
- if (rx.bare) {
3105
- // on bare metal only: the free fraction θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
3106
- for (let x = 0; x < surf.length; x++) d[1 + n + x] -= th[g * nSurf + x];
3107
- pref *= this.th0[g];
3132
+ if (rx.sites) {
3133
+ // the bare sites its transition state holds: θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
3134
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= rx.sites * th[g * nSurf + x];
3135
+ pref *= powr(this.th0[g], rx.sites);
3108
3136
  }
3109
3137
  const { g: bv, gp } = bvFactor(aHi + aLo, al);
3110
3138
  const rate = pref * bv;
@@ -3236,8 +3264,9 @@ export class Solver {
3236
3264
  // passes, so their flux is zero.)
3237
3265
  const closedIons = this.combining && dyn && this.chargeNode[g] === 1;
3238
3266
  let I = 0;
3267
+ const held = this.portHeldEnd.subarray(k * n, (k + 1) * n);
3239
3268
  for (let i = 0; i < n; i++) {
3240
- const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0);
3269
+ const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0) && !held[i];
3241
3270
  const nIn = active ? res[R[b * M + 1 + i]] : 0;
3242
3271
  flux[i] = sgn * nIn; // (toward +x)
3243
3272
  if (active && z[i] !== 0) {
@@ -3250,7 +3279,7 @@ export class Solver {
3250
3279
  if (this.combining && dyn && this.lateStorage[g] === 1) {
3251
3280
  const v = model.grid.vol[g], { cOld } = this;
3252
3281
  for (let i = 0; i < n; i++) {
3253
- if (z[i] === 0 || this.loc[b * M + 1 + i] < 0) continue;
3282
+ if (z[i] === 0 || this.loc[b * M + 1 + i] < 0 || held[i]) continue;
3254
3283
  const ci = c[g * n + i], st = (v * (ci - cOld[g * n + i])) / dt;
3255
3284
  res[R[b * M + 1 + i]] += st;
3256
3285
  flux[i] += sgn * st;
@@ -4866,6 +4895,11 @@ export class Solver {
4866
4895
  }
4867
4896
  mu[name].re[g] = RT * xr[b * M + 1 + i];
4868
4897
  mu[name].im[g] = RT * xi[b * M + 1 + i];
4898
+ if (this.nodeConductor[g] >= 0) {
4899
+ // a metal has a Fermi level but no concentration (as a solution reports it)
4900
+ c[name].re[g] = c[name].im[g] = NaN;
4901
+ continue;
4902
+ }
4869
4903
  // δc_i = Σ_j K_ij (δη_j − z_j δφ̂)
4870
4904
  let sr = 0, si = 0;
4871
4905
  for (let j = 0; j < n; j++) {
package/src/stack.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // that you didn't write: it only moves things to where the definition keeps them, and converts
5
5
  // the kit's doping shorthand (donors and acceptors).
6
6
 
7
- import { DeviceError, checkOptions } from './errors.js';
7
+ import { DeviceError, checkOptions, shown } from './errors.js';
8
8
  import { FARADAY } from './constants.js';
9
9
 
10
10
  const fail = (message) => {
@@ -131,6 +131,7 @@ const STACK_FIELDS = ['T', 'library', 'species', 'materials', 'stack', 'bulkReac
131
131
  export function build(def) {
132
132
  if (!isObject(def)) fail('build: give { library, stack, … }');
133
133
  fields(def, 'build', STACK_FIELDS);
134
+ if (def.T !== undefined && !(typeof def.T === 'number' && Number.isFinite(def.T) && def.T > 0)) fail(`build.T must be the temperature in K, a positive number, got ${shown(def.T)}`);
134
135
  if (def.library !== undefined && !Array.isArray(def.library)) fail('build.library must be a list of { species, materials }');
135
136
  const { species, materials } = combine(...(def.library ?? []), { species: def.species ?? [], materials: def.materials ?? {} });
136
137