driftlet 1.1.1 → 1.2.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/README.md CHANGED
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
@@ -133,8 +133,8 @@ A contact is an interface with an **outside phase** whose levels are known, like
133
133
  with a fully known node. The outside phase's charged levels form a rigid ladder,
134
134
  $`V_i = V + \mathtt{offset}_i`$, which the external circuit slides by the **terminal voltage** $`V`$
135
135
  (the voltage of a named terminal species). The laws joining it to the device (equilibrium,
136
- blocked, conductance; and for $`\phi`$, bulk, neutral, capacitive, dipole) are the same as at
137
- internal faces. Reactions sit at faces between regions: an electrode is a conductor region with
136
+ blocked, conductance, exchange, velocity; and for $`\phi`$, bulk, neutral, capacitive, pinned)
137
+ are those of internal faces, with the outside phase as the other side. Reactions sit at faces between regions: an electrode is a conductor region with
138
138
  reactions at its face.
139
139
 
140
140
  - **Species in equilibrium** with the outside phase sit at $`V_i = V + \mathtt{offset}_i`$. The
package/docs/data.md CHANGED
@@ -25,7 +25,10 @@ The data are 25 °C's, and the material says so (`T: 298.15`): a device at anoth
25
25
  warns that they don't hold there. Its μ° (so equilibrium constants and electrode potentials),
26
26
  permittivity and diffusivities would all move: $`K_w`$ by ~6× between 6 and 25 °C. Scaling them
27
27
  (μ° by the standard entropies, ε, D by water's viscosity) is planned, not done. Where only
28
- transport between solutions of the same ions matters (a membrane's channels), μ° cancels.
28
+ transport between solutions of the same ions matters (a membrane's channels), μ° cancels; if the
29
+ D don't matter either, taking the data as another temperature's is a fair approximation, made
30
+ by setting the material's `T` (the cell and axon demos do, having checked that D scaled by
31
+ T/η moves nothing they show).
29
32
 
30
33
  | Ion | z | D (10⁻⁹ m²/s) | $`\mu^\circ`$ (kJ/mol) |
31
34
  |---|---|---|---|
package/docs/device.md CHANGED
@@ -516,8 +516,8 @@ background carriers do). A solution against a charged end region (an ion exchang
516
516
  its own material, `bath: { c, material: 'water' }`: its composition is then neutral by itself, its
517
517
  levels are that material's, and the end node takes them through a Donnan step.
518
518
 
519
- **`phi`**, required whenever any species or reaction connects at the contact (default
520
- `'neutral'`):
519
+ **`phi`**, required whenever any species connects at the contact (default `'neutral'`, or
520
+ `'bulk'` with a bath):
521
521
  - `'bulk'`: the end node is plain bulk, locally neutral, with no double layer at the contact
522
522
  (ohmic contacts, baths). The outside takes whatever surface charge that needs. A double layer
523
523
  or depletion region that reaches it is cut off there (σ 58% low for a crowded layer 5 Debye
@@ -872,7 +872,8 @@ advance it, `advance()` and `step()` do.
872
872
 
873
873
  `{ hmin, hmax, ratio = 1.2, minCells = 8 }`, per device, overridable per region. With no grid
874
874
  options at all, each region is graded from `hmin` = 1/1000 to `hmax` = 1/20 of its length. Cells grow
875
- geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly. Each
875
+ geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly
876
+ (`hLeft` and `hRight` set one end's size instead, for a region fine at one end only). Each
876
877
  region boundary becomes a pair of nodes at the same x, one per side. The grid is never refined
877
878
  automatically. If a double layer that the model resolves is coarser than the local Debye
878
879
  length, the solution's `warnings` say so. That's a floor, not an accuracy: a strongly polarised
@@ -1061,12 +1062,12 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
1061
1062
  | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
1062
1063
  | `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
1063
1064
  | `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
1064
- | `charge` | total charge in the device, C/m² (C through a cross-section) |
1065
+ | `charge` | total charge in the device, C/m² (C through a cross-section): its regions' space charge and its faces' sheet charges, not the charge on a capacitive contact's or port's plates, so under a gate's bias it's the gate's opposite |
1065
1066
  | `conservation` | per species stretch: amount, reference, intake through contacts, and drift, relative: (amount − reference − intake) over the largest of them |
1066
1067
  | `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, a material's data for another temperature, and for a failed solve, why (a driven current it can't pass, where the system is nearly singular). A failed steady solve's state isn't a solution, so the warnings about a solution's accuracy (the grid, double layers, space charge reaching a contact) are left out of it |
1067
1068
  | `converged`, `iterations`, `steps`, `substeps`, `history`, `time`, `T` | solver bookkeeping, and the temperature |
1068
1069
  | `steady` | whether this is a converged steady state (from `solve()`), not a transient's: what `check()` in the kit decides its checks by |
1069
- | `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step |
1070
+ | `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step, and `probes` if asked |
1070
1071
 
1071
1072
  A stretch is a run of regions in which a species is present and connected. Its `drift`
1072
1073
  compares its amount with the reference amount plus everything that came in through the
package/docs/numerics.md CHANGED
@@ -725,13 +725,19 @@ the terms that cancel:
725
725
  charging a capacitance (a gate, a port's capacitance) is the same, its charge in place of an
726
726
  amount; and an electrode port's reactions draw on what the electrode holds, its charge
727
727
  $`Q/(zF)`$ a column of its own under a current, so a port at no current reducing a closed
728
- M⁺ (M⁺ + e⁻ = M(s)) keeps M⁺ + Q/F, or with no capacitance, M⁺ alone. At any other current such a terminal only stores what comes in, which has no steady
729
- state, and the solve says so at once.
728
+ M⁺ (M⁺ + e⁻ = M(s)) keeps M⁺ + Q/F, or with no capacitance, M⁺ alone. Whatever such a
729
+ terminal both passes and stores is kept together, as its current counts both: a capacitive
730
+ port or contact at no current that passes K⁺ keeps $`n_K + Q/F`$ (a contact's Q its
731
+ displacement $`D_\text{in}`$), and two floating electrodes sharing a closed Fe³⁺ keep the iron
732
+ with both their charges, the law in one's circuit row moving with the other's voltage too.
733
+ At any other current such a terminal only stores what comes in, which has no steady state,
734
+ and the solve says so at once.
730
735
  - an **immobile** combination (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) keeps it
731
736
  node by node, since nothing carries it anywhere, as do an electrode's surface species
732
737
  (adsorbates that only turn into each other keep their total on every site), its row local,
733
- one per node in its own block. (The null space is taken with the immobile columns first, so
734
- that these come out apart from the combinations that move.) A combination of immobile
738
+ one per node in its own block, a coverage counted as the sites it fills per volume,
739
+ $`\Gamma a \theta`$, beside the concentrations. (The null space is taken with the immobile and
740
+ surface columns first, so that these come out apart from the combinations that move.) A combination of immobile
735
741
  stretches with a moving one (the electrons the traps hold, $`e^- - X^0`$, in a closed film) is
736
742
  an amount like any other, its immobile parts summed over their nodes;
737
743
  - a **floating metal** keeps the charge on its faces, its sheets' displacement.
@@ -796,7 +802,9 @@ the terms that cancel:
796
802
  rows, a pin like an amount's, chosen by the same elimination and ahead of the amounts: the slowest
797
803
  reaction first, and a row of one of its own species where one is left, never one picked by where
798
804
  the species are listed (the rows had differed with the species reversed, and an electrode's
799
- equilibrium solved in one listing and failed in the other). The combinations come from the null
805
+ equilibrium solved in one listing and failed in the other). A reaction that changes its
806
+ combination only through immobile species needs none: its rate already stands alone in their
807
+ balance at the face's node. The combinations come from the null
800
808
  space with that reaction's row left out, any conserved amount added to one being as good (its
801
809
  summed balance vanishes too), so that one can be found with a row left to take: silver's e⁻ − Fe³⁺
802
810
  beside a closed iron couple whose affinity had Fe³⁺'s row is e⁻ + Fe²⁺ with the iron added. Never
@@ -141,6 +141,29 @@ its true residual, blocked species held flat in steady solves, continuations for
141
141
  and for light, and Newton refining its solves where a factorisation loses a mode, with an exact
142
142
  $`J \cdot v`$.
143
143
 
144
+ ## Conserved: the law sweep
145
+
146
+ A steady solve replaces one redundant equation per conserved combination with the combination's
147
+ amount (see [numerics](numerics.md#the-steady-system)). A combination left out, or weighed wrongly,
148
+ gives a state that converges and is steady but holds the wrong amounts, which only a transient
149
+ from the same start shows. `npm run lawsweep` builds 810 small devices from the features at the
150
+ edges of that bookkeeping: a floating, resistive or held electrode, with or without a
151
+ capacitance, with adsorbates or an immobile species that adsorbs; a second floating electrode
152
+ or a membrane capacitor; a capacitive or ion-passing contact at no current; a face that blocks
153
+ an ion or makes an immobile species. Each is solved directly and run as a transient for 1e6 s,
154
+ and where the transient converged, finished and moves no further over 100× as long, the two
155
+ must agree on every species' amount, every surface's sites and every floating terminal's
156
+ voltage.
157
+
158
+ Every reaction starts at its equilibrium. On 1.2.1, 675 devices are built (135 combinations
159
+ repeat others); 579 agree, and 83 transients fail to converge. In the other 13 the transient
160
+ is the one that's off: it loses a conserved K⁺ total in proportion to its tolerance (3e-5 of it
161
+ at tol 1e-9) when a capacitive contact, a membrane capacitor and a held electrode share the
162
+ device, where the steady solve keeps it to round-off. The sweep found its first law the day it
163
+ was written (an immobile species adsorbing on an electrode over part of its region); Gemini 4
164
+ Argon's review found nine more by hand (one through a device it suggested), each now in
165
+ `test/laws.test.js`.
166
+
144
167
  ## Hard cases
145
168
 
146
169
  `npm run hard` keeps a corpus of named cases that were hard once (sharp 3 M | 1 µM junctions on
@@ -44,6 +44,7 @@ most one model.
44
44
 
45
45
  | Model | Species | $`c(\zeta)`$ | Parameters |
46
46
  |---|---|---|---|
47
+ | `ideal` | any | $`c_{\mathrm{ref}} e^\zeta`$, the default, named only to say so | none |
47
48
  | `fermi-dirac` | any, each independently | $`c_{\mathrm{ref}} \mathcal{F}_{1/2}(\zeta)`$ (3D band); $`c_{\mathrm{ref}} \ln(1 + e^\zeta)`$ with `order: 0` (2D) | `order`: 1/2 (default) or 0 |
48
49
  | `lattice` | any, sharing sites | $`c_a = c_{\mathrm{max}} w_a / (1 + \sum w)`$, $`w_a = (c_{\mathrm{ref},a}/c_{\mathrm{max}}) e^{\zeta_a}`$ | `cMax`, site density, mol/m³ |
49
50
  | `redlich-kister` | one | on its own sites: $`\zeta = \ln(x/(1-x)) + \ln(c_{\mathrm{max}}/c_{\mathrm{ref}}) + (g(x) - g(0))/RT`$, $`x = c/c_{\mathrm{max}}`$ | `cMax`; `A`, J/mol |
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.1.1/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,6 +33,7 @@
33
33
  "stress": "node bench/stress.js",
34
34
  "smoke": "node tools/smoke.js",
35
35
  "shots": "node tools/shots.js",
36
+ "lawsweep": "node tools/lawsweep.js",
36
37
  "prepublishOnly": "npm test && npm run types",
37
38
  "types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
38
39
  },
package/src/channels.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { DeviceError, checkOptions } from './errors.js';
5
5
  import { FARADAY, GAS_CONSTANT } from './constants.js';
6
+ import { bernoulli } from './bernoulli.js';
6
7
 
7
8
  const fail = (message) => {
8
9
  throw new DeviceError(message);
@@ -22,9 +23,10 @@ const HH_T = 273.15 + 6.3;
22
23
 
23
24
  // The GHK current (outward, A/m²) of an ion at unit permeability, at voltage V (inside − outside).
24
25
  export function ghkCurrent(z, inside, outside, V, T) {
26
+ // zF u (c_in − c_out e^−u)/(1 − e^−u) = zF (c_in B(−u) − c_out B(u)), B(x) = x/(eˣ − 1): finite
27
+ // at any u (the plain form was ∞/∞ past u = −710)
25
28
  const u = (z * V * FARADAY) / (GAS_CONSTANT * T);
26
- if (Math.abs(u) < 1e-9) return z * FARADAY * (inside - outside);
27
- return (z * FARADAY * u * (inside - outside * Math.exp(-u))) / -Math.expm1(-u);
29
+ return z * FARADAY * (inside * bernoulli(-u) - outside * bernoulli(u));
28
30
  }
29
31
 
30
32
  /**
@@ -59,6 +61,8 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
59
61
  // T sets the gates' rates, so it must be the device's: never defaulted (their 6.3 °C, under a body-temperature device, doubled a spike's peak, silently).
60
62
  need(num(T) && T > 0, `hodgkinHuxley: T must be the device's temperature in K (their own experiments were at 279.45 K, 6.3 °C), got ${T}`);
61
63
  need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
64
+ checkOptions('hodgkinHuxley: g', g, ['Na', 'K', 'leak']);
65
+ checkOptions('hodgkinHuxley: names', names, ['Na', 'K']);
62
66
  const G = { Na: 1200, K: 360, leak: 3, ...g };
63
67
  const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';
64
68
  const q10 = Math.exp(Math.log(3) * ((T - HH_T) / 10)); // Q₁₀ = 3
@@ -79,9 +83,10 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
79
83
  };
80
84
  }
81
85
  need(inside && outside && typeof inside === 'object' && typeof outside === 'object', 'hodgkinHuxley: give inside and outside concentrations (mol/m³) by species name (or, for a membrane port, its area)');
82
- const P = (name, z, gval, what) => {
86
+ const P = (name, z, key, what) => {
87
+ const gval = G[key];
83
88
  need(num(inside[name]) && inside[name] > 0 && num(outside[name]) && outside[name] > 0, `hodgkinHuxley: give ${name}'s concentration inside and outside (mol/m³), for its ${what}`);
84
- need(num(gval) && gval > 0, `hodgkinHuxley: g.${what} must be a conductance > 0 (S/m²), got ${gval}`);
89
+ need(num(gval) && gval > 0, `hodgkinHuxley: g.${key} must be a conductance > 0 (S/m²), got ${gval}`);
85
90
  const E = ((GAS_CONSTANT * T) / (z * FARADAY)) * Math.log(outside[name] / inside[name]);
86
91
  need(Math.abs(at - E) > 1e-4, `hodgkinHuxley: ${name}'s Nernst level, ${(E * 1e3).toFixed(1)} mV, is at the calibration voltage; choose another 'at'`);
87
92
  return (gval * (at - E)) / ghkCurrent(z, inside[name], outside[name], at, T);
@@ -90,9 +95,9 @@ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ
90
95
  return {
91
96
  gates,
92
97
  species: {
93
- [Na]: { type: 'permeability', P: P(Na, 1, G.Na, 'Na channels'), gates: { m: 3, h: 1 } },
94
- [K]: { type: 'permeability', P: P(K, 1, G.K, 'K channels'), gates: { n: 4 } },
95
- [leak]: { type: 'permeability', P: P(leak, leakZ, G.leak, 'leak') },
98
+ [Na]: { type: 'permeability', P: P(Na, 1, 'Na', 'Na channels'), gates: { m: 3, h: 1 } },
99
+ [K]: { type: 'permeability', P: P(K, 1, 'K', 'K channels'), gates: { n: 4 } },
100
+ [leak]: { type: 'permeability', P: P(leak, leakZ, 'leak', 'leak') },
96
101
  },
97
102
  };
98
103
  }
package/src/checks.js CHANGED
@@ -86,7 +86,7 @@ export function check(device, sol, { refine = true, tol = 1e-2, order = true } =
86
86
  if (steady && refine && order && model.species.length > 1) items.push(orderCheck(device, sol, ledger.details.gross));
87
87
  // A transient's answer depends on its whole history (and on drives set along the way), so
88
88
  // only a run of its own on a finer grid can check it.
89
- else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
89
+ if (!steady && refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
90
90
  }
91
91
 
92
92
  const ok = sol.converged && items.every((it) => it.ok !== false);
@@ -96,7 +96,7 @@ export function check(device, sol, { refine = true, tol = 1e-2, order = true } =
96
96
  }
97
97
 
98
98
  // The units of totals through the cross-section: per m² of a planar device, or whole.
99
- const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²' } : { rate: 'mol/s', current: 'A', charge: 'C' });
99
+ const unitsOf = (model) => (model.geometry.type === 'planar' ? { rate: 'mol/(m²·s)', current: 'A/m²', charge: 'C/m²', amount: 'mol/m²' } : { rate: 'mol/s', current: 'A', charge: 'C', amount: 'mol' });
100
100
 
101
101
  // Each species' steady ledger: in through the terminals, made by the reactions. Rates in
102
102
  // mol/(m²·s) (mol/s if not planar), positive into the device or made.
@@ -262,6 +262,8 @@ function gridCheck(device, sol, tol, gross) {
262
262
  const out = { ratio: Math.sqrt(g.ratio ?? 1.2), minCells: 2 * (g.minCells ?? 8) };
263
263
  if (g.hmin !== undefined) out.hmin = g.hmin / 2;
264
264
  if (g.hmax !== undefined) out.hmax = g.hmax / 2;
265
+ if (g.hLeft !== undefined) out.hLeft = g.hLeft / 2;
266
+ if (g.hRight !== undefined) out.hRight = g.hRight / 2;
265
267
  return { ...reg, grid: out };
266
268
  }),
267
269
  };
@@ -334,7 +336,7 @@ function gridCheck(device, sol, tol, gross) {
334
336
  summary:
335
337
  `on a grid twice as fine (${sol.x.length} → ${other.x.length} nodes), the largest change is ${worst.what}, ${sig(worst.a, 5)} → ${sig(worst.b, 5)} ${worst.unit} ` +
336
338
  `(${sig(worst.rel, 2)}${ok ? '' : `, over ${sig(tol, 1)}: refine the grid (hmin, hmax), most likely where profiles are steepest`})`,
337
- details: { diffs, nodes: [sol.x.length, other.x.length] },
339
+ details: { diffs, nodes: [sol.x.length, other.x.length], grids: fine.regions.map((reg) => reg.grid) }, // (grids: the finer one's, by region)
338
340
  };
339
341
  }
340
342
 
@@ -374,7 +376,7 @@ function orderCheck(device, sol, gross) {
374
376
  const vol = model.grid.vol, amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0);
375
377
  const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
376
378
  const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
377
- for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: 'mol/m²' });
379
+ for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: unitsOf(model).amount });
378
380
  // Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
379
381
  // (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
380
382
  // steady: look. Beyond: one of them isn't a steady state.
package/src/data.js CHANGED
@@ -185,7 +185,7 @@ export function trap(lib, { density, tauN, tauP, n1, charge = 'acceptor', name =
185
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
186
  const RT = GAS_CONSTANT * T, cRefE = el.cRef ?? lib.species.find((sp) => sp.name === 'e-')?.cRef;
187
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));
188
+ const ni = Math.sqrt(cRefE * cRefH * Math.exp(-(ho.mu0 + el.mu0) / RT));
189
189
  const half = n1 === undefined ? ni : pos(n1, 'n1 must be the electron density that half-fills the trap > 0 (mol/m³)');
190
190
  // The filled state's μ° sits an electron's μ at n₁ above the empty one's: half full at n = n₁.
191
191
  const [empty, full] = charge === 'acceptor' ? [`${name}0`, `${name}-`] : [`${name}+`, `${name}0`];
package/src/describe.js CHANGED
@@ -265,7 +265,7 @@ export function describe(def) {
265
265
  lines.push(` port ${port.name}: ${drive(p, model.geometry.type === 'planar')}, in ${regions[port.region].name}`);
266
266
  // an electrode spread through the window
267
267
  if (port.reactions.length) lines.push(` an electrode (its ${species[port.terminal].name} at the port's level), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} of it per m³, reacting:`);
268
- for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
268
+ (p.reactions ?? []).forEach((rx, j) => lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${port.reactions[j].alpha}${rx.bare ? ', on bare metal' : ''}`)); // (α as used: 0.5 if not given)
269
269
  if (port.capacitance) lines.push(` a capacitance of ${num(port.capacitance.C)} F/m² through the window (zero charge at ${num(port.capacitance.zeroCharge)} V), ${typeof p.area === 'number' ? `${p.area} m²` : 'a profile of m²'} per m³`);
270
270
  if (port.surface.length) lines.push(` its surface: ${port.surface.map((sp) => sp.name).join(', ')} on ${num(port.surface[0].capacity)} mol/m² of sites (Langmuir)`);
271
271
  });
package/src/device.js CHANGED
@@ -18,7 +18,7 @@ export { DeviceError };
18
18
  const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange', 'velocity']);
19
19
  const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
20
20
  const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
21
- const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
21
+ const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells', 'hLeft', 'hRight'];
22
22
  // The fields of each kind of species link (to an outside phase, or across a face).
23
23
  const LINK_FIELDS = {
24
24
  blocked: ['type'],
@@ -704,7 +704,7 @@ export function nextBreakpoint(src, t) {
704
704
  if (src.value !== undefined) return Infinity;
705
705
  const { t: ts, repeat } = src, n = ts.length;
706
706
  if (!repeat) {
707
- for (let k = 0; k < n; k++) if (ts[k] > t * (1 + 1e-12) + 1e-300) return ts[k];
707
+ for (let k = 0; k < n; k++) if (ts[k] > t + 1e-12 * Math.abs(t) + 1e-300) return ts[k]; // (t·(1 + ε) is below t for t < 0)
708
708
  return Infinity;
709
709
  }
710
710
  const T = ts[n - 1] - ts[0], cycle = Math.floor((t - ts[0]) / T);
@@ -1255,7 +1255,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1255
1255
  }
1256
1256
  if (link.type === 'conductance') {
1257
1257
  // Ohmic exchange with an outside reservoir at V_i = V + offset: J = G·(V_out − V_i).
1258
- need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
1258
+ need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species (a neutral one exchanges by { type: 'exchange', k, mu })`);
1259
1259
  const offset = link.offset ?? (terminal === i ? 0 : undefined);
1260
1260
  need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir`);
1261
1261
  links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
@@ -1283,7 +1283,6 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1283
1283
  links[i] = { type: 'exchange', k: positive(link.k, `${lpath}.k (mol/(m²·s))`), mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
1284
1284
  continue;
1285
1285
  }
1286
- if (link.type === 'conductance') need(species[i].z !== 0, `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
1287
1286
  if (link.type === 'equilibrium') {
1288
1287
  // In equilibrium with the outside phase, whose levels are known: a Dirichlet condition.
1289
1288
  if (species[i].z === 0) {
package/src/devices.js CHANGED
@@ -5,7 +5,11 @@
5
5
  import { AVOGADRO, FARADAY, GAS_CONSTANT, units } from './constants.js';
6
6
  import { photogeneration } from './sources.js';
7
7
  import { drive } from './stack.js';
8
- import { checkOptions } from './errors.js';
8
+ import { DeviceError, checkOptions, shown } from './errors.js';
9
+
10
+ const need = (ok, message) => {
11
+ if (!ok) throw new DeviceError(message);
12
+ };
9
13
 
10
14
  const m3 = (x) => x / AVOGADRO, eV = units.eV;
11
15
 
@@ -35,6 +39,8 @@ const P = {
35
39
  */
36
40
  export function perovskiteCell({ V = 1.2, light = 1, bulkSRH = true } = {}) {
37
41
  checkOptions('perovskiteCell', arguments[0], ['V', 'light', 'bulkSRH']);
42
+ need(Number.isFinite(light) && light >= 0, `perovskiteCell: light must be the illumination in suns, ≥ 0, got ${shown(light)}`);
43
+ need(typeof bulkSRH === 'boolean', `perovskiteCell: bulkSRH must be true or false, got ${shown(bulkSRH)}`);
38
44
  const { T } = P, VT = (GAS_CONSTANT * T) / FARADAY;
39
45
  const ni = Math.sqrt(P.gc * P.gv) * Math.exp(-(P.Ec - P.Ev) / (2 * VT));
40
46
  const n0 = P.gc * Math.exp((P.EcE + VT * Math.log(P.dE / P.gcE) - P.Ec) / VT); // the perovskite's n at the TiO₂'s Fermi level
@@ -124,6 +130,8 @@ export const PEROVSKITE_SCANS = Object.freeze({
124
130
  * @param {number} turn s
125
131
  */
126
132
  export function hysteresis(trace, turn) {
133
+ need(trace && trace.t?.length > 0 && trace.voltage?.length === trace.t.length && trace.current?.length === trace.t.length, 'hysteresis: give a trace { t, voltage, current } (an advance()\'s, say)');
134
+ need(Number.isFinite(turn) && turn > trace.t[0] && turn < trace.t[trace.t.length - 1], `hysteresis: turn must be the time the scan turns, inside the trace (${shown(trace.t[0])} to ${shown(trace.t[trace.t.length - 1])} s), got ${shown(turn)}`);
127
135
  const out = {};
128
136
  for (const [name, on] of [['rev', (t) => t <= turn], ['fwd', (t) => t >= turn]]) {
129
137
  const pts = [];
package/src/equation.js CHANGED
@@ -79,14 +79,21 @@ export function stoichiometry(text, path = 'equation') {
79
79
  */
80
80
  export function faceSides(terms, left, right, fixed, path) {
81
81
  const sides = { left: {}, right: {} };
82
- const put = (side, name, nu) => (sides[side][name] = (sides[side][name] ?? 0) + nu);
83
82
  const fixedSide = right.conductor && !left.conductor ? 'right' : 'left';
83
+ const signs = {};
84
+ const put = (side, name, nu) => {
85
+ const key = `${side}:${name}`;
86
+ if (signs[key] !== undefined && signs[key] !== Math.sign(nu)) fail(`${path}: '${name}' (${side}) appears on both sides of the equation; give the net reaction`);
87
+ signs[key] = Math.sign(nu);
88
+ sides[side][name] = (sides[side][name] ?? 0) + nu;
89
+ };
84
90
  for (const t of terms) {
85
- if (fixed?.[t.name] !== undefined) {
91
+ // (a species the side holds is that species, given in fixed or not, as for bulk reactions)
92
+ const inL = left.holds(t.name), inR = right.holds(t.name);
93
+ if (!inL && !inR && fixed?.[t.name] !== undefined) {
86
94
  put(fixedSide, t.name, t.nu);
87
95
  continue;
88
96
  }
89
- const inL = left.holds(t.name), inR = right.holds(t.name);
90
97
  if (inL && inR) {
91
98
  fail(
92
99
  `${path}: '${t.name}' is in both '${left.name}' and '${right.name}', so say which side: ` +
@@ -177,6 +184,10 @@ export function level(sol, halfReaction, { standard = false } = {}) {
177
184
  else fail(`level: '${t.name}' is neither a species of the solution nor in the half-reaction's fixed`);
178
185
  }
179
186
  if (nuE === 0) fail(`level: ${JSON.stringify(equation)} has no net electrons`);
187
+ // (unbalanced, its level wouldn't move with φ as a level must)
188
+ const z = Object.fromEntries((sol.species ?? []).map((sp) => [sp.name, sp.z]));
189
+ const charge = terms.reduce((q, t) => q + t.nu * (t.name === 'e-' ? -1 : fixed[t.name] !== undefined ? 0 : (z[t.name] ?? NaN)), 0); // (fixed: neutral)
190
+ if (sol.species && !(Math.abs(charge) < 1e-12)) fail(`level: ${JSON.stringify(equation)} isn't charge-balanced (Σ ν z = ${charge} across it)`);
180
191
  const n = sol.mu[Object.keys(sol.mu)[0]].length;
181
192
  const V = new Float64Array(n);
182
193
  for (let g = 0; g < n; g++) {
package/src/index.js CHANGED
@@ -211,11 +211,14 @@ export class Device {
211
211
  * Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
212
212
  * `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
213
213
  * which), so an animation can call it once per frame; the step size carries over. `probes`
214
- * read a species at points inside the device after every accepted step, into `trace.probes`
215
- * (one array per probe, alongside `trace.t`): what a detector at x sees.
214
+ * read a species (or φ) at points inside the device after every accepted step, into
215
+ * `trace.probes` (one array per probe, alongside `trace.t`): what a detector at x sees. At a
216
+ * face, `{ interface, species }` reads a species' flux through it (mol/(m²·s), toward +x), and
217
+ * `{ interface, gate }` a gate's fraction open.
216
218
  * @param {number} tEnd s
217
219
  * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
218
- * probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
220
+ * probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
221
+ * | { interface: number, species: string } | { interface: number, gate: string })[] }} [opts]
219
222
  * @returns {Solution}
220
223
  */
221
224
  advance(tEnd, opts) {
package/src/junction.js CHANGED
@@ -29,6 +29,9 @@ function neutral(list, side, what) {
29
29
  }
30
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
31
  }
32
+ function holds(list, what) {
33
+ for (const side of ['cL', 'cR']) if (!list.some((p) => p[side] > 0)) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution holds no ions`);
34
+ }
32
35
 
33
36
  /**
34
37
  * Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
@@ -44,6 +47,7 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
44
47
  const list = prepare(left, right, ions, 'henderson');
45
48
  neutral(list, 'cL', 'henderson');
46
49
  neutral(list, 'cR', 'henderson');
50
+ holds(list, 'henderson');
47
51
  let a = 0, b = 0, gL = 0, gR = 0;
48
52
  for (const { z, D, cL, cR } of list) {
49
53
  a += z * D * (cR - cL);
@@ -51,9 +55,10 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
51
55
  gL += z * z * D * cL;
52
56
  gR += z * z * D * cR;
53
57
  }
54
- const VT = (GAS_CONSTANT * T) / FARADAY;
55
- if (b === 0) return 0;
56
- return (-VT * a * Math.log(gR / gL)) / b;
58
+ // ln(g_R/g_L)/(g_R − g_L) = ln(1 + r)/(r g_L), r = b/g_L: 1/g_L at equal conductivities, where the
59
+ // plain form is 0/0.
60
+ const VT = (GAS_CONSTANT * T) / FARADAY, r = b / gL;
61
+ return (-VT * a * (Math.abs(r) < 1e-8 ? 1 - r / 2 : Math.log1p(r) / r)) / gL;
57
62
  }
58
63
 
59
64
  /**
@@ -71,6 +76,7 @@ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
71
76
  const list = prepare(left, right, ions, 'planck');
72
77
  neutral(list, 'cL', 'planck');
73
78
  neutral(list, 'cR', 'planck');
79
+ holds(list, 'planck');
74
80
  const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
75
81
  const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
76
82
  const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
package/src/kit.js CHANGED
@@ -71,7 +71,7 @@ export function vacuumDipole(def, left, right) {
71
71
 
72
72
  /**
73
73
  * The vacuum-level (Schottky–Mott-type) estimate of a `zeroCharge` where a conductor meets a
74
- * material: at a conductor region's face, or a contact's capacitive or dipole law. The conductor's
74
+ * material: at a conductor region's face, or a contact's capacitive or pinned law. The conductor's
75
75
  * vacuum level sits its work function W beyond its Fermi level, so
76
76
  * zeroCharge = V_F − φ_edge at zero charge = W + (V_vac − φ)_inside.
77
77
  * @param {object} def device definition
package/src/live.js CHANGED
@@ -57,26 +57,31 @@ export class Session {
57
57
  if (this.good && errors.every(Boolean)) return done(this.good.sol, { failed: true }); // nothing changed
58
58
  const target = this.device.def;
59
59
  try {
60
- let sol = this.device.solve();
61
- // Ramp from the last good state in more and more steps.
62
- for (let n = 2; !sol.converged && this.good && n <= this.maxRamp; n *= 2) {
60
+ let sol = this.device.solve(), stuck = false;
61
+ // Ramp from the last good state in more and more steps. (Its in-between states can be
62
+ // invalid where the two ends aren't, a bath neutral at both but not between: then on to the
63
+ // fresh solve.)
64
+ ramping: for (let n = 2; !sol.converged && this.good && n <= this.maxRamp; n *= 2) {
63
65
  ramp = n;
64
66
  this.device._rollback(this.good.cp);
65
67
  const from = this.good.cp.def;
66
68
  for (let k = 1; k <= n; k++) {
67
- this.device.set(between(from, target, k / n));
69
+ try {
70
+ this.device.set(between(from, target, k / n));
71
+ } catch {
72
+ stuck = true; // (and sol, a converged step short of the target, isn't the answer)
73
+ break ramping;
74
+ }
68
75
  sol = this.device.solve();
69
76
  if (!sol.converged) break;
70
77
  }
71
78
  }
72
- if (!sol.converged) {
79
+ if (stuck || !sol.converged) {
73
80
  // Last, from scratch (with the solver's own continuations).
74
81
  const fresh = new Device(target);
75
82
  const cold = fresh.solve();
76
- if (cold.converged) {
77
- this.device = fresh;
78
- sol = cold;
79
- }
83
+ if (cold.converged) this.device = fresh;
84
+ if (cold.converged || stuck) sol = cold;
80
85
  }
81
86
  if (sol.converged) {
82
87
  this.good = { cp: this.device._checkpoint(), sol };
package/src/particles.js CHANGED
@@ -55,6 +55,8 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
55
55
  */
56
56
  export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
57
57
  checkOptions('particles', arguments[1], ['species', 'dots', 'weight', 'cap', 'cells', 'random']);
58
+ // (amounts per area, against fluxes that off a plane are per device: planar only)
59
+ if (sol.geometry !== undefined && sol.geometry !== 'planar') throw new RangeError(`particles: a planar device's solution, not a ${sol.geometry} one`);
58
60
  const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
59
61
  for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
60
62
  if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
@@ -172,7 +174,8 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
172
174
  }
173
175
  [left[k], right[k]] = [side(k - 1), side(k)];
174
176
  const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
175
- pair(k, J[A.b], (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD);
177
+ // (none of it where either cell is empty: an underflow to 0 makes both ψ infinite)
178
+ pair(k, J[A.b], N[k - 1] > 0 && N[k] > 0 ? (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD : 0);
176
179
  } else if (A || B) {
177
180
  // out of the cells: through a contact, or a face to a metal
178
181
  const cl = A ?? B, g = A ? A.b : B.a;
package/src/plot.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { traces } from './traces.js';
16
16
  import { powi } from './pow.js';
17
- import { checkOptions } from './errors.js';
17
+ import { DeviceError, checkOptions } from './errors.js';
18
18
 
19
19
  // Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
20
20
  // dash and the legend carry identity too). A role with more members than colours cycles.
@@ -23,16 +23,20 @@ export const THEME = Object.freeze({
23
23
  dark: { electron: ['#5b95d6'], cation: ['#e8584f', '#d0772a', '#d65d95', '#b88a3e'], anion: ['#219c8f', '#2aa5bb', '#3aa874'], redox: ['#8b7ff0', '#a86bc9', '#7a9ae0'] },
24
24
  });
25
25
  const ROLES = ['electron', 'cation', 'anion', 'redox'];
26
+ const MUTED = { light: '#52514e', dark: '#c3c2b7' }; // (φ's, and lines of no species)
26
27
 
27
28
  /**
28
29
  * The colour of a trace's role and slot (a CSS colour), for drawing other charts in the same
29
30
  * colours as the level diagrams.
30
- * @param {'electron' | 'cation' | 'anion' | 'redox'} role
31
+ * @param {'electron' | 'cation' | 'anion' | 'redox' | 'phi'} role
31
32
  * @param {number} slot
32
33
  * @param {{ dark?: boolean }} [opts]
33
34
  */
34
35
  export function themeColor(role, slot, { dark = false } = {}) {
35
36
  checkOptions('themeColor', arguments[2], ['dark']);
37
+ if (role === 'phi') return MUTED[dark ? 'dark' : 'light'];
38
+ if (!ROLES.includes(role)) throw new DeviceError(`themeColor: no role '${role}' (${[...ROLES, 'phi'].join(', ')})`);
39
+ if (!(Number.isInteger(slot) && slot >= 0)) throw new DeviceError(`themeColor: slot must be a whole number ≥ 0, got ${slot}`);
36
40
  const list = THEME[dark ? 'dark' : 'light'][role];
37
41
  return list[slot % list.length];
38
42
  }
@@ -118,8 +122,8 @@ function lengthUnit(L) {
118
122
 
119
123
  const style = (id) => {
120
124
  const vars = (mode) => ROLES.flatMap((role) => THEME[mode][role].map((c, k) => `--driftlet-${role}${role === 'electron' ? '' : `-${k + 1}`}:${c};`)).join('');
121
- const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted:#52514e;--driftlet-grid:#e4e3df;--driftlet-band:#f1f0ec;--driftlet-face:#a8a7a1`;
122
- const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted:#c3c2b7;--driftlet-grid:#3a3a37;--driftlet-band:#262624;--driftlet-face:#6b6a64`;
125
+ const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted:${MUTED.light};--driftlet-grid:#e4e3df;--driftlet-band:#f1f0ec;--driftlet-face:#a8a7a1`;
126
+ const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted:${MUTED.dark};--driftlet-grid:#3a3a37;--driftlet-band:#262624;--driftlet-face:#6b6a64`;
123
127
  return (
124
128
  `<style>` +
125
129
  `:where(#${id}){${light}}` +
package/src/solution.js CHANGED
@@ -27,6 +27,7 @@ export function makeSolution(solver, result = {}) {
27
27
  V: {},
28
28
  Vstd: {},
29
29
  T: model.T,
30
+ geometry: model.geometry.type, // (planar, or amounts per device rather than per area)
30
31
  time: solver.time,
31
32
  steady: solver.atSteady,
32
33
  converged: result.converged ?? true,