driftlet 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ The same equations go by different names in different fields. If your problem is
30
30
 
31
31
  | If you work on | you may call it | start from | checked against |
32
32
  |---|---|---|---|
33
- | Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
33
+ | Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions; thin-film transistors (gradual channel) | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
34
34
  | Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH as a rate law, in the bulk and at faces | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
35
35
  | Electrochemistry, corrosion | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions, mixed potentials, electrodes under a film | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js), [`ports`](test/ports.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc; Wagner–Traud |
36
36
  | Batteries, intercalation | OCV, insertion hosts, chemical diffusion | [insertion demo](https://markblundeberg.github.io/driftlet/demos/insertion.html), [`statistics`](test/statistics.test.js) test | composition vs OCV, π²D/4L² relaxation |
@@ -55,7 +55,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
55
55
  ```
56
56
 
57
57
  Or straight from a CDN in a page, pinned to a version:
58
- `https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
58
+ `https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
59
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
60
60
  [`llms.txt`](llms.txt).
61
61
 
@@ -262,6 +262,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
262
262
  | Metal regions | ohmic; Schottky face and MOS gate equal their contact forms; Ag \| AgNO₃ \| Ag with metal electrodes; bipolar electrode at V/2 | 1e-12; 1e-9; 1e-9; 1e-9 |
263
263
  | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel | 1e-4; 1e-9; 2e-3 |
264
264
  | Cross-sections (spherical, cylindrical, any A(x)) | steady diffusion to a sphere and a cylinder (exact on any grid); Cottrell with the spherical term; uptake by a sphere filling from its surface; Debye–Hückel around a charged sphere, potential and charge | 1e-10; 3e-4; 2e-4; 1e-3, 3e-4 |
265
+ | A capacitance through a port | a thin-film transistor against the charge-sheet model (below threshold to saturation, second order); de Levie impedance; gate impedance against dQ/dV | 5e-4; 1e-3; 1e-6 |
266
+ | Electrode surfaces (coverage) | Langmuir isotherm against potential; the charge to fill a surface; the active–passive curve, blocking exactly (1 − θ) | 1e-9; 1e-3; 1e-9 |
265
267
  | Electrodes spread through a port | transmission line with the reaction's linear kinetics as the conductance (O(h²)), rates along it; Wagner–Traud mixed potential of two Butler–Volmer couples, floating | 1e-4, 2e-3; 1e-6 V |
266
268
  | Impedance | Macdonald blocking-electrode spectrum, 100 Hz–1 GHz; finite-length Warburg (Ag \| AgNO₃ \| Ag); DC limit = differential resistance | 3e-4; 3e-5; 1e-6 |
267
269
  | Conservation | per step, and against time-integrated contact fluxes | 1e-11 relative |
package/docs/device.md CHANGED
@@ -504,8 +504,11 @@ a terminal like any other. Held at a voltage, it's a potentiostat. At `I: 0` (or
504
504
  else to carry current), the reactions settle at the mixed potential, the corrosion potential where
505
505
  anodic and cathodic currents cancel over the window, while each node can be a net anode or a net
506
506
  cathode. A floating one starts there, for the start's composition. Each solution reports the
507
- window's nodes and every reaction's rate at each, `ports[k].x` and `ports[k].rates`
508
- (mol/(m²·s) of electrode, forward), with `ports[k].area`.
507
+ window's nodes and every reaction's rate at each: `ports[k].x[j]` (m) and `ports[k].area[j]`
508
+ (m²/m³) for the window's node j, and `ports[k].rates[q][j]` for reaction q there (mol/(m²·s) of
509
+ electrode, forward), so reaction first, then node. They're typed arrays (`Float64Array`), so
510
+ `Array.from` them before mapping to anything but numbers. Per volume of the window it's
511
+ `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
509
512
 
510
513
  Validated against the transmission line (linear kinetics along a bar, test/ports.test.js) and the
511
514
  Wagner–Traud mixed potential of two Butler–Volmer couples. In 1D, a film on a metal is a slice
@@ -515,6 +518,87 @@ the film's thickness varies, give the device its [cross-section](#geometry) too,
515
518
  travelling along the film squeeze through where it thins: for a drop on a metal, seen from its
516
519
  centre, $`A = 2\pi r\,h(r)`$ with `area` $`1/h(r)`$.
517
520
 
521
+ The region must be strictly neutral ($`\varepsilon = 0`$), as in porous-electrode theory: each spot's
522
+ double layer, the metal's countercharge, is below the grid, so the reactions can't leave the
523
+ solution charged.
524
+
525
+ ### An electrode's surface: adsorbates and passive films
526
+
527
+ An electrode port can carry species on its surface, sharing its sites: an adsorbed intermediate,
528
+ or a passive film taken as a coverage. Each has a coverage $`\theta`$ at every node of the window,
529
+ Langmuir statistics, $`\mu = \mu^\circ + RT\ln(\theta/\theta_0)`$ with $`\theta_0 = 1 - \sum\theta`$ the
530
+ bare fraction, and `capacity` $`\Gamma`$, mol of sites per m² of electrode. Reactions name them like
531
+ species, and a reaction with `bare: true` runs only on bare metal: its rate is multiplied by
532
+ $`\theta_0`$, both ways, so equilibrium stays exact. Written for adsorption itself, that's Langmuir
533
+ kinetics (on at a rate ∝ $`\theta_0`$, off ∝ $`\theta`$).
534
+
535
+ ```js nocheck
536
+ ports: [{
537
+ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1 / 100e-6,
538
+ surface: { 'Fe(OH)2': { mu0: -490e3, capacity: 2e-5, theta0: 1e-6 } }, // theta0: the start
539
+ reactions: [
540
+ { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5, bare: true }, // dissolves where bare
541
+ { equation: 'Fe(s) + 2 OH- = Fe(OH)2 + 2 e-', fixed: { 'Fe(s)': 0 }, k0: 1e-6, alpha: 0.5, bare: true }, // the film forms
542
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.13e3 }, k0: 3e-9, alpha: 0.125 }, // on film or not
543
+ ],
544
+ }]
545
+ ```
546
+
547
+ A film forms where the potential and the pH favour it, through its own thermodynamics, and blocks
548
+ what needs bare metal: the dissolution current climbs with potential, peaks, and falls as the film
549
+ covers the metal, the active–passive curve. Each node's surface stores
550
+ $`\Gamma\,d\theta/dt`$ per area of electrode, so filling it passes a current, and a solution reports
551
+ `ports[k].coverage[name][j]`. It's a monolayer picture: a real passive oxide is nanometres thick,
552
+ grows, and breaks down under chloride, none of which a coverage has.
553
+
554
+ Two cautions. Keep a film's stability within reason: blocking the bare fraction to 1e-3–1e-6 is
555
+ already a passive metal, and far below that ($`\theta_0 \lesssim 10^{-10}`$) the coverage's η barely
556
+ affects anything, so Newton struggles with it. And an electrode port has no double-layer
557
+ capacitance (its solution is strictly neutral), so its potential follows its kinetics instantly.
558
+ With a passivating film, current against potential has the active–passive peak, and when the
559
+ last active patch covers over, the potential where no current flows can vanish from the active
560
+ branch and must jump to the passive one. A real electrode makes that jump quickly but
561
+ continuously, through its double layer; here it's a discontinuity, and a transient stops there.
562
+ Give the port a [capacitance](#a-capacitance-through-a-window-a-gate-along-a-channel-a-double-layer) and the jump becomes a quick, continuous swing.
563
+
564
+ Validated in test/coverage.test.js: the Langmuir isotherm against the electrode's potential (to
565
+ 1e-9), the charge to fill the surface, $`F\,\Gamma\,\Delta\theta`$ per area of electrode, and the
566
+ active–passive curve with blocking exactly $`(1-\theta)`$ times Butler–Volmer.
567
+
568
+ ### A capacitance through a window: a gate along a channel, a double layer
569
+
570
+ A port can hold charge across a capacitance spread through its window: per area of electrode,
571
+ $`\sigma = C\,(V - \mathtt{zeroCharge} - \phi)`$ on the port's side (C in F/m²), with `area` per volume
572
+ as for reactions, and the window holds the opposite. Each node's charge balance gains $`a\sigma`$:
573
+ Gauss's law averaged across the section,
574
+ $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$, and at $`\varepsilon = 0`$ neutrality
575
+ $`\rho + a\sigma = 0`$, the mobile species rearranging to supply it (counter-ions in, co-ions out).
576
+ The port passes the charging current, $`d(\int a\sigma\,dV)/dt`$, none in a steady state. Solutions
577
+ report `ports[k].sigma` at each node of the window and the total `ports[k].charge`.
578
+
579
+ ```js nocheck
580
+ // a thin-film transistor: the gate along a 10 nm channel through 1 mF/m² (35 nm of SiO₂)
581
+ ports: [{ name: 'gate', region: 'channel', V: 1.5, area: 1 / 10e-9, capacitance: { C: 1e-3, zeroCharge: 0.3 } }]
582
+ // a porous electrode's double layer, alongside its reactions
583
+ ports: [{ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1e4, capacitance: { C: 0.2, zeroCharge: -0.4 }, reactions: [/* … */] }]
584
+ ```
585
+
586
+ That's the gradual-channel approximation of a field-effect transistor, exact for a thin film
587
+ with no body (a TFT, an organic or oxide transistor) while the channel is long against the gate
588
+ dielectric, and failing where it pinches off (where the 2D field takes over). It's also the
589
+ Bernards model of an organic electrochemical transistor (a volumetric capacitance), cable theory
590
+ for an axon's membrane, and a porous electrode's double layer, which lets an electrode port's
591
+ potential move continuously: without it, a passivating film that covers the last active patch
592
+ forces a jump (see above). The gate's flat level pins the channel's standard level through C, as
593
+ a blocked spectator's flat level does in an electrolyte, and the current saturates as the
594
+ carriers run out at the drain, in both.
595
+
596
+ Validated in test/capacitance.test.js: a thin-film transistor's current against the
597
+ charge-sheet model, $`I/W = (\mu/L)[(\sigma_s^2 - \sigma_d^2)/(2C) + V_T(\sigma_s - \sigma_d)]`$, from
598
+ below threshold through saturation (second order in the grid); a porous electrode's de Levie
599
+ impedance $`\sqrt{r/y}\coth(L\sqrt{ry})`$, the double layer in series with the ions' chemical
600
+ capacitance; and the gate's low-frequency impedance against $`dQ/dV_G`$ from steady states.
601
+
518
602
  ## Terminals
519
603
 
520
604
  The two contacts and every port are the device's terminals, named `left`, `right` and by each
package/docs/kit.md CHANGED
@@ -64,7 +64,7 @@ console.log(`I = ${sol.current.toFixed(1)} A/m²`);
64
64
  (`combine()` does the merging on its own). A species or material given twice must be given
65
65
  identically.
66
66
  - Nested lists in the stack are flattened, so a helper can return several items.
67
- - Reactions pass through as written (see below).
67
+ - Reactions pass through as written (see below), and so do `T`, `grid`, `ports` and `geometry`.
68
68
 
69
69
  ## Reactions and half-reactions
70
70
 
@@ -123,7 +123,7 @@ console.log(curve.reactions[0].level, curve.current[40]); // the couple's level
123
123
  solution beside it held as `sol` has it and the metal's level set to each `V` (an electron
124
124
  voltage, the terminals' convention). It works at a face (`{ face: f }`, the face's metal side) or
125
125
  at a spot of an [electrode port](device.md#electrodes-spread-through-a-window)
126
- (`{ port: name, x }`). Each reaction comes back with its rate (mol/(m²·s), forward), its partial
126
+ (`{ port: name, x }`, with its surface's coverages there held too). Each reaction comes back with its rate (mol/(m²·s), forward), its partial
127
127
  current (A/m², anodic positive: into the solution) and its `level`, where the rate vanishes (the
128
128
  couple's redox level there, as `level()` gives it). The net current is their sum. Plotting
129
129
  log|current| against V gives the Evans diagram. The net crosses zero at the spot's open-circuit
package/llms.txt CHANGED
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
14
14
  From a CDN, pin the version:
15
15
 
16
16
  ```js nocheck
17
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -319,9 +319,9 @@ solve), as long as it settles rather than ping-pongs.
319
319
  <span id="readout"></span>
320
320
  <div id="figure"></div>
321
321
  <script type="module">
322
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/index.js';
323
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/kit.js';
324
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.8.0/src/plot.js';
322
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/index.js';
323
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/kit.js';
324
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.9.0/src/plot.js';
325
325
 
326
326
  const def = build({
327
327
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
package/src/describe.js CHANGED
@@ -216,7 +216,9 @@ export function describe(def) {
216
216
  lines.push(` port ${port.name}: ${drive(p)}, in ${regions[port.region].name}`);
217
217
  // an electrode spread through the window
218
218
  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:`);
219
- for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}`);
219
+ for (const rx of p.reactions ?? []) lines.push(` ${rx.equation}: k0 ${rx.k0} mol/(m²·s), α ${rx.alpha}${rx.bare ? ', on bare metal' : ''}`);
220
+ 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³`);
221
+ 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)`);
220
222
  });
221
223
  if ((def.bulkReactions ?? []).length) {
222
224
  lines.push('bulk reactions:');
package/src/device.js CHANGED
@@ -351,6 +351,15 @@ export function normalizeDevice(def) {
351
351
  need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
352
352
  port.nodes = Int32Array.from(nodes);
353
353
  });
354
+ // A node's surface belongs to one electrode.
355
+ const surfaced = new Int32Array(grid.nNodes).fill(-1);
356
+ ports.forEach((port, k) => {
357
+ if (port.surface.length === 0) return;
358
+ for (const g of port.nodes) {
359
+ need(surfaced[g] < 0, `ports[${k}]: its window overlaps ports[${surfaced[g]}]'s, and both have a surface; a spot of metal has one surface`);
360
+ surfaced[g] = k;
361
+ }
362
+ });
354
363
 
355
364
  return {
356
365
  T, RT, F: FARADAY, species, speciesIndex, materials, materialIndex, regions, interfaces, reactions, contacts, terminals, grid, warnings, ports, geometry,
@@ -649,7 +658,7 @@ function normalizeGeometry(g) {
649
658
  // the window, `area` (m²/m³) of it per volume.
650
659
  function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
651
660
  need(isObject(pdef), `${path} must be an object`);
652
- fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area']);
661
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance']);
653
662
  let r;
654
663
  if (Number.isInteger(pdef.region)) r = pdef.region;
655
664
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -666,8 +675,11 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
666
675
  need(speciesIndex.has(pdef.terminal), `${path}.terminal: unknown species ${JSON.stringify(pdef.terminal)}${known(speciesIndex)}`);
667
676
  terminal = speciesIndex.get(pdef.terminal);
668
677
  }
669
- const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT);
670
- need(pdef.species === undefined ? reactions.length > 0 : isObject(pdef.species), `${path}.species must map species names to port links`);
678
+ const surface = portSurface(pdef, path, speciesIndex);
679
+ const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
680
+ surface.forEach((sp, s) => need(reactions.some((rx) => rx.part.some((p) => p.side === 2 && p.s === s)), `${path}.surface.${sp.name}: no reaction of the port makes or uses it`));
681
+ const capacitance = portCapacitance(pdef, path, mat);
682
+ need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
671
683
  const links = species.map(() => ({ type: 'blocked' }));
672
684
  for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
673
685
  const lpath = `${path}.species.${sname}`;
@@ -697,48 +709,94 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
697
709
  if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
698
710
  links[i] = { type: link.type, ...level };
699
711
  }
700
- need(links.some((l) => l.type !== 'blocked') || reactions.length > 0, `${path}.species: the port exchanges no species`);
712
+ need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
701
713
  // One that exchanges only neutral species (an O₂ supply) carries no current, so its voltage is
702
714
  // nobody's business: it can't be driven by one.
703
- const passes = reactions.length > 0 || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
715
+ const passes = reactions.length > 0 || capacitance !== null || links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
704
716
  need(passes || drive.kind === 'V', `${path}.I: this port passes no current (it exchanges only neutral species), so give it no drive`);
705
717
  let area = null;
706
- if (reactions.length > 0) {
718
+ if (reactions.length > 0 || capacitance !== null) {
707
719
  need(pdef.area !== undefined, `${path}.area: give the electrode's area per volume of the window (m²/m³, e.g. 1/h for a film of thickness h on it), a number or a profile`);
708
720
  area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
709
- } else need(pdef.area === undefined, `${path}.area: only a port with reactions has an electrode area`);
710
- return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area };
721
+ } else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
722
+ need(surface.length === 0 || reactions.length > 0, `${path}.surface: only an electrode (a port with reactions) has a surface`);
723
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance };
724
+ }
725
+
726
+ // A capacitance spread through the window: a gate along a channel, an electrode's double layer.
727
+ // Per area of electrode it holds σ = C (V − zeroCharge − φ) on the port's side, and the window
728
+ // the opposite: the region's charge balance (Gauss's law, or neutrality at ε = 0) gains aσ.
729
+ function portCapacitance(pdef, path, mat) {
730
+ if (pdef.capacitance === undefined) return null;
731
+ const cpath = `${path}.capacitance`;
732
+ need(isObject(pdef.capacitance), `${cpath} must be { C, zeroCharge }`);
733
+ fields(pdef.capacitance, cpath, ['C', 'zeroCharge']);
734
+ need(!mat.conductor && !mat.phiFree, `${cpath}: a capacitance couples to φ, which a ${mat.conductor ? 'conductor' : 'region with no free charge'} doesn't have`);
735
+ return { C: positive(pdef.capacitance.C, `${cpath}.C (F/m² of electrode)`), zeroCharge: pdef.capacitance.zeroCharge === undefined ? 0 : finite(pdef.capacitance.zeroCharge, `${cpath}.zeroCharge (V)`) };
736
+ }
737
+
738
+ // An electrode port's surface species: coverages θ of its sites (Langmuir: each takes one site,
739
+ // μ = μ° + RT ln(θ/θ₀) with θ₀ = 1 − Σθ the bare fraction), Γ mol of sites per m² of electrode.
740
+ function portSurface(pdef, path, speciesIndex) {
741
+ if (pdef.surface === undefined) return [];
742
+ need(isObject(pdef.surface), `${path}.surface must map surface species to { mu0, capacity, theta0 }`);
743
+ const list = Object.entries(pdef.surface).map(([name, d]) => {
744
+ const spath = `${path}.surface.${name}`;
745
+ need(!speciesIndex.has(name), `${spath}: '${name}' is a species of the device; a surface species is the port's own, with its own name`);
746
+ need(isObject(d), `${spath} must be { mu0, capacity, theta0 }`);
747
+ fields(d, spath, ['mu0', 'capacity', 'theta0']);
748
+ const theta0 = d.theta0 === undefined ? 1e-6 : positive(d.theta0, `${spath}.theta0`);
749
+ return { name, mu0: finite(d.mu0, `${spath}.mu0 (J/mol)`), capacity: positive(d.capacity, `${spath}.capacity (mol of sites per m² of electrode)`), theta0 };
750
+ });
751
+ need(list.length > 0, `${path}.surface: give at least one surface species, or leave it out`);
752
+ const caps = new Set(list.map((sp) => sp.capacity));
753
+ need(caps.size === 1, `${path}.surface: the species share the electrode's sites, so give them one capacity`);
754
+ need(list.reduce((t, sp) => t + sp.theta0, 0) < 1, `${path}.surface: the starting coverages (theta0) must sum to less than 1`);
755
+ return list;
711
756
  }
712
757
 
713
758
  // A port's reactions: Butler–Volmer, as at a face, between species of the port's region and the
714
759
  // port's terminal species (a metal's carrier, activity 1, at the port's level), per area of the
715
760
  // electrode: part side 0 for the region's species, side 1 for the carrier.
716
- function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT) {
761
+ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface = []) {
717
762
  need(pdef.reactions === undefined || Array.isArray(pdef.reactions), `${path}.reactions must be an array`);
718
763
  const list = pdef.reactions ?? [];
719
764
  if (list.length === 0) return [];
720
765
  need(!mat.conductor, `${path}.reactions: a port on a conductor is a wire; reactions belong on a face, or on a port in the solution`);
766
+ // The metal's countercharge (each spot's double layer) is below the grid, so the solution
767
+ // beside it must be neutral, as in porous-electrode theory; with ε > 0 the reactions would
768
+ // leave a net charge with nothing to balance it.
769
+ need(mat.epsr === 0 || mat.phiFree, `${path}.reactions: an electrode spread through a window needs its region strictly neutral (ε = 0): its double layers are below the grid`);
721
770
  need(terminal !== null && species[terminal].z !== 0, `${path}.terminal: a port with reactions needs its electrode's carrier as its terminal species (e.g. 'e-')`);
722
771
  need(!mat.present[terminal], `${path}.terminal: '${species[terminal].name}' is in the port's region too, so a reaction can't tell the electrode's from the region's`);
723
772
  const carrier = species[terminal].name;
724
773
  return list.map((rdef, k) => {
725
774
  const rpath = `${path}.reactions[${k}]`;
726
775
  need(isObject(rdef) && typeof rdef.equation === 'string', `${rpath} must be { equation, fixed, k0, alpha }`);
727
- fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha']);
776
+ fields(rdef, rpath, ['equation', 'fixed', 'k0', 'alpha', 'bare']);
728
777
  const holds = (name) => speciesIndex.has(name) && Boolean(mat.present[speciesIndex.get(name)]);
729
- const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier, conductor: true }, rdef.fixed, `${rpath}.equation`);
778
+ const onSurface = (name) => surface.findIndex((sp) => sp.name === name);
779
+ const sides = faceSides(parseEquation(rdef.equation, `${rpath}.equation`), { name: mat.name, holds, conductor: false }, { name: 'the electrode', holds: (name) => name === carrier || onSurface(name) >= 0, conductor: true }, rdef.fixed, `${rpath}.equation`);
730
780
  const part = [];
731
781
  let fixedA = 0, charge = 0;
732
- for (const [key, side] of [['left', 0], ['right', 1]]) {
733
- const st = stoichiometry(sides[key], `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
782
+ // The electrode's side: its carrier, and its surface species (side 2, neutral, by index s).
783
+ const electrode = {};
784
+ for (const [name, nu] of Object.entries(sides.right)) {
785
+ const s = onSurface(name);
786
+ if (s >= 0) part.push({ s, nu, side: 2 });
787
+ else electrode[name] = nu;
788
+ }
789
+ for (const [map, side] of [[sides.left, 0], [electrode, 1]]) {
790
+ const st = stoichiometry(map, `${rpath}.equation`, rdef.fixed, `${rpath}.fixed`, species, speciesIndex, RT);
734
791
  for (const { i, nu } of st.list) part.push({ i, nu, side });
735
792
  fixedA += st.fixedA;
736
793
  charge += st.charge;
737
794
  }
738
795
  need(part.some((p) => p.side === 1), `${rpath}: no '${carrier}' from the electrode takes part`);
739
- need(part.some((p) => p.side === 0), `${rpath}: no species of the port's region takes part`);
796
+ need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
740
797
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
741
- return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`) };
798
+ need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
799
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
742
800
  });
743
801
  }
744
802
 
@@ -762,7 +820,7 @@ function checkAnchors(regions, materials, interfaces, contacts, species, ports =
762
820
  const anchored = new Set();
763
821
  if (anchors(contacts.left)) anchored.add(find(0));
764
822
  if (anchors(contacts.right)) anchored.add(find(nR - 1));
765
- for (const port of ports) if (port.reactions.length > 0 || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
823
+ for (const port of ports) if (port.reactions.length > 0 || port.capacitance || port.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0)) anchored.add(find(port.region));
766
824
  for (let r = 0; r < nR; r++) {
767
825
  const mat = materials[regions[r].material];
768
826
  const charged = species.some((sp, i) => mat.present[i] && sp.z !== 0);
package/src/index.js CHANGED
@@ -100,6 +100,7 @@ export class Device {
100
100
  solver.computeConcentrations();
101
101
  solver.time = old.time;
102
102
  solver.contactDEnd = old.contactDEnd;
103
+ if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
103
104
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
104
105
  // Floating terminals keep their voltages (a good start), where the terminals match.
105
106
  if (old.terms.length === solver.terms.length) {
@@ -29,7 +29,7 @@ const fail = (message) => {
29
29
  export function polarization(device, sol, where, V) {
30
30
  const model = device.model, { species, grid, RT } = model, solver = device.solver;
31
31
  const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
32
- let reactions, nodeOf, carrier, names;
32
+ let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
33
33
  if (where?.face !== undefined) {
34
34
  const f = where.face, itf = model.interfaces[f];
35
35
  if (!itf) fail(`polarization: no face ${f} (the device has ${model.interfaces.length})`);
@@ -49,6 +49,10 @@ export function polarization(device, sol, where, V) {
49
49
  if (where.x !== undefined) for (const h of port.nodes) if (Math.abs(grid.x[h] - where.x) < Math.abs(grid.x[g] - where.x)) g = h;
50
50
  carrier = { side: 1, i: port.terminal };
51
51
  nodeOf = () => g;
52
+ if (port.surface.length > 0) {
53
+ const w = port.nodes.indexOf(g), theta = port.surface.map((sp) => sol.ports[k].coverage[sp.name][w]);
54
+ surface = { sp: port.surface, theta, bare: sol.ports[k].bare[w] };
55
+ }
52
56
  reactions = port.reactions;
53
57
  names = device.def.ports?.[k]?.reactions;
54
58
  } else fail('polarization: say where, { face: f } or { port: name, x }');
@@ -59,6 +63,13 @@ export function polarization(device, sol, where, V) {
59
63
  // a = A/RT = a0 + s·V: everything but the carrier at its level, and the carrier's part.
60
64
  let a0 = rx.fixedA, s = 0, q = 0, pref = rx.vmax ? rx.vmax : rx.k0;
61
65
  rx.part.forEach((p, x) => {
66
+ if (p.side === 2) {
67
+ // a surface species, at activity θ/θ₀ (μ = μ° + RT ln(θ/θ₀))
68
+ const ln = Math.log(surface.theta[p.s] / surface.bare), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
69
+ a0 -= (p.nu * (surface.sp[p.s].mu0 / RT + ln));
70
+ pref *= Math.exp(e * ln);
71
+ return;
72
+ }
62
73
  if (p.side === carrier.side && p.i === carrier.i) {
63
74
  s -= (p.nu * species[p.i].z * FARADAY) / RT;
64
75
  return;
@@ -72,6 +83,7 @@ export function polarization(device, sol, where, V) {
72
83
  } else pref *= powr(c / solver.cRef[g * species.length + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
73
84
  });
74
85
  if (s === 0) fail(`polarization: reaction ${k} there takes no ${species[carrier.i].name} from the metal`);
86
+ if (rx.bare) pref *= surface ? surface.bare : 1; // on bare metal only
75
87
  const rate = Float64Array.from(Vs, (v) => {
76
88
  const a = a0 + s * v;
77
89
  return rx.vmax ? pref * -Math.expm1(-a) : pref * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
package/src/solution.js CHANGED
@@ -106,12 +106,30 @@ export function makeSolution(solver, result = {}) {
106
106
  flux[species[i].name] = solver.portFlux[k][i];
107
107
  current += F * species[i].z * solver.portFlux[k][i];
108
108
  }
109
+ if (port.capacitance) {
110
+ // a capacitance's charging current, and its charge (the port's side), like a gate's
111
+ const dt = solver.lastDt;
112
+ current += Number.isFinite(dt) ? (solver.portQ[k] - solver.portQOld[k]) / dt : 0;
113
+ }
109
114
  const out = { name: port.name, V: solver.termV[2 + k], flux, current };
110
- if (port.reactions.length > 0) {
111
- // An electrode spread through the window: where it sits, and each reaction's rate there.
115
+ if (port.capacitance) out.charge = solver.portQ[k];
116
+ if (port.capacitance) {
117
+ // its charge per area of electrode at each node, σ = C (V − zeroCharge − φ)
118
+ const { C: Cs, zeroCharge } = port.capacitance;
119
+ out.sigma = Float64Array.from(port.nodes, (g) => Cs * (solver.termV[2 + k] - zeroCharge - phi[g]));
120
+ }
121
+ if (port.area !== null) {
112
122
  out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
113
123
  out.area = Float64Array.from(solver.portArea[k]);
124
+ }
125
+ if (port.reactions.length > 0) {
126
+ // An electrode spread through the window: each reaction's rate at its nodes.
114
127
  out.rates = solver.portRates[k].map((r) => Float64Array.from(r)); // mol/(m²·s), forward
128
+ // its surface: each species' coverage θ at each node of the window
129
+ if (port.surface.length > 0) {
130
+ out.coverage = Object.fromEntries(port.surface.map((sp, q) => [sp.name, Float64Array.from(port.nodes, (g) => solver.th[g * solver.nSurf + q])]));
131
+ out.bare = Float64Array.from(port.nodes, (g) => solver.th0[g]); // θ₀, to full precision however covered
132
+ }
115
133
  }
116
134
  return out;
117
135
  });
package/src/solver.js CHANGED
@@ -195,13 +195,16 @@ export class Solver {
195
195
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
196
196
  // one per face reaction (its rate), as many as the busiest face has.
197
197
  const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
198
- const M = n + 1 + nRx;
198
+ // (and at an electrode port's nodes, one per surface species: its coverage's η)
199
+ const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
200
+ const M = n + 1 + Math.max(nRx, nSurf);
199
201
  const nNodes = grid.nNodes;
200
202
  const nFaces = regions.length - 1;
201
203
  const nB = nNodes + nFaces;
202
204
  this.n = n;
203
205
  this.M = M;
204
206
  this.nRx = nRx;
207
+ this.nSurf = nSurf;
205
208
  this.nNodes = nNodes;
206
209
  this.nFaces = nFaces;
207
210
  this.nB = nB;
@@ -214,6 +217,18 @@ export class Solver {
214
217
  this.blockOfFace = new Int32Array(nFaces);
215
218
  for (let f = 0; f < nFaces; f++) this.blockOfFace[f] = grid.regionEnd[f] + f + 1;
216
219
 
220
+ // Electrode ports' surfaces: each node's port (−1: none) and its index in the port's window;
221
+ // the coverages there, θ [g·nSurf + s], now and at the step's start.
222
+ this.surfPort = new Int32Array(nNodes).fill(-1);
223
+ this.surfW = new Int32Array(nNodes);
224
+ model.ports.forEach((port, k) => {
225
+ if (port.surface.length > 0) port.nodes.forEach((g, w) => ((this.surfPort[g] = k), (this.surfW[g] = w)));
226
+ });
227
+ this.th = new Float64Array(nNodes * nSurf);
228
+ this.thOld = new Float64Array(nNodes * nSurf);
229
+ this.th0 = new Float64Array(nNodes); // the bare fraction θ₀, directly (1 − Σθ cancels as θ → 1)
230
+ this.th0Old = new Float64Array(nNodes);
231
+
217
232
  // Per-node material data, flattened [g·n + i].
218
233
  this.present = new Uint8Array(nNodes * n);
219
234
  this.cRef = new Float64Array(nNodes * n);
@@ -327,6 +342,12 @@ export class Solver {
327
342
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
328
343
  this.portFlux = model.ports.map(() => new Float64Array(n)); // into the device, mol/(m²·s)
329
344
  this.contactD = { left: 0, right: 0 };
345
+ // A capacitive port's charge (its side's, Σ vol·a·σ over its window), as the contacts' D: now,
346
+ // at the step's start and at the previous step's end.
347
+ this.portQ = new Float64Array(model.ports.length);
348
+ this.portQStart = new Float64Array(model.ports.length);
349
+ this.portQOld = new Float64Array(model.ports.length);
350
+ this.portQEnd = null;
330
351
  this.contactDOld = { left: 0, right: 0 };
331
352
  this.contactDStart = { left: 0, right: 0 };
332
353
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
@@ -453,6 +474,8 @@ export class Solver {
453
474
  }
454
475
  active[b * M] = this.phiUndefined[g] ? 0 : 1;
455
476
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
477
+ const k = this.surfPort[g];
478
+ if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
456
479
  }
457
480
  model.interfaces.forEach((itf, f) => {
458
481
  const bf = this.blockOfFace[f];
@@ -1055,6 +1078,13 @@ export class Solver {
1055
1078
  const shift = level(t.side) - level(this.terms[held].side);
1056
1079
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1057
1080
  }
1081
+ // Electrode surfaces at their starting coverages: η = μ°/RT + ln(θ/θ₀).
1082
+ for (let g = 0; g < this.nNodes; g++) {
1083
+ const k = this.surfPort[g];
1084
+ if (k < 0) continue;
1085
+ const surf = model.ports[k].surface, b = this.blockOfNode[g], bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1086
+ surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1087
+ }
1058
1088
  this.computeConcentrations();
1059
1089
  // A floating electrode spread through a port starts where its reactions pass the current it's
1060
1090
  // set (none, behind a resistance): at its mixed potential in the start's composition, not
@@ -1088,16 +1118,18 @@ export class Solver {
1088
1118
 
1089
1119
  // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
1090
1120
  _portRate(port, rx, g) {
1091
- const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1121
+ const { n, M, u, uLo, c, nSurf, th } = this, b = this.blockOfNode[g], RT = this.model.RT;
1092
1122
  let pref = rx.k0, a = rx.fixedA;
1093
1123
  for (const p of rx.part) {
1094
1124
  if (p.side === 1) {
1095
1125
  a -= p.nu * this.portEta(port, p.i);
1096
1126
  continue;
1097
1127
  }
1098
- a -= p.nu * (u[b * M + 1 + p.i] + uLo[b * M + 1 + p.i]);
1099
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha);
1128
+ 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;
1129
+ a -= p.nu * (u[o] + uLo[o]);
1130
+ 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);
1100
1131
  }
1132
+ if (rx.bare) pref *= this.th0[g];
1101
1133
  // (far from equilibrium, the plain difference: bvFactor's product would give 0·∞ there)
1102
1134
  return pref * (Math.abs(a) > 50 ? Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a) : bvFactor(a, rx.alpha).g);
1103
1135
  }
@@ -1128,6 +1160,23 @@ export class Solver {
1128
1160
  }
1129
1161
  }
1130
1162
 
1163
+ // Each electrode surface's coverages from its η: Langmuir on shared sites, θ_s = e^{ζ_s}/(1 + Σ e^{ζ}),
1164
+ // ζ = η − μ°/RT (so θ_s/θ₀ = e^{ζ_s}), at every node of a surfaced window.
1165
+ _coverages() {
1166
+ const { n, M, u, uLo, nSurf, th } = this, RT = this.model.RT;
1167
+ for (let g = 0; g < this.nNodes; g++) {
1168
+ const k = this.surfPort[g];
1169
+ if (k < 0) continue;
1170
+ const surf = this.model.ports[k].surface, b = this.blockOfNode[g];
1171
+ let m = 0;
1172
+ for (let q = 0; q < surf.length; q++) m = Math.max(m, u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT);
1173
+ let sum = Math.exp(-m);
1174
+ for (let q = 0; q < surf.length; q++) sum += (th[g * nSurf + q] = Math.exp(u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT - m));
1175
+ for (let q = 0; q < surf.length; q++) th[g * nSurf + q] /= sum;
1176
+ this.th0[g] = Math.exp(-m) / sum;
1177
+ }
1178
+ }
1179
+
1131
1180
  computeConcentrations() {
1132
1181
  const { n, M, u, uLo, c, z } = this;
1133
1182
  for (let g = 0; g < this.nNodes; g++) {
@@ -1150,6 +1199,7 @@ export class Solver {
1150
1199
  c[k] = this.present[k] ? this.cRef[k] * Math.exp(u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat) : 0;
1151
1200
  }
1152
1201
  }
1202
+ this._coverages();
1153
1203
  }
1154
1204
 
1155
1205
  _work(md) {
@@ -1969,7 +2019,7 @@ export class Solver {
1969
2019
  this.termDI[k] = 0;
1970
2020
  this.termI[k] = 0;
1971
2021
  }
1972
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k));
2022
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt));
1973
2023
  this._contact('left', dt);
1974
2024
  this._contact('right', dt);
1975
2025
  for (const k of this.floating) this._circuit(k);
@@ -2002,7 +2052,7 @@ export class Solver {
2002
2052
  // An internal port (terminal k): its exchange with each node of its window, as a source per
2003
2053
  // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
2004
2054
  // row's residual, read just before. Its current into the device is Σ z F × the sources.
2005
- _port(port, flux, k) {
2055
+ _port(port, flux, k, dt) {
2006
2056
  const R = this.rix;
2007
2057
  const { n, M, u, uLo, res, z, VT } = this;
2008
2058
  const F = FARADAY, vol = this.model.grid.vol;
@@ -2038,6 +2088,8 @@ export class Solver {
2038
2088
  }
2039
2089
  }
2040
2090
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2091
+ if (port.capacitance) this._portCapacitance(port, k, dt);
2092
+ if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2041
2093
  for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2042
2094
  }
2043
2095
 
@@ -2046,49 +2098,111 @@ export class Solver {
2046
2098
  // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
2047
2099
  // species it makes and consumes, and the port's current.
2048
2100
  _portReaction(port, rx, area, rates, flux, k) {
2049
- const { n, M, u, uLo, c, res, z, VT } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol;
2050
- const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA;
2101
+ const { n, M, u, uLo, c, res, z, VT, nSurf, th } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol, RT = this.model.RT;
2102
+ const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA, surf = port.surface;
2051
2103
  // The carrier's part of the affinity, and how it moves with the port's voltage.
2052
2104
  let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
2053
2105
  for (const p of rx.part) {
2054
2106
  if (p.side === 1) {
2055
2107
  aV -= p.nu * this.portEta(port, p.i);
2056
2108
  daV -= (p.nu * z[p.i]) / VT;
2057
- } else q += p.nu * z[p.i];
2109
+ } else if (p.side === 0) q += p.nu * z[p.i];
2058
2110
  }
2111
+ const slot = (p) => (p.side === 2 ? 1 + n + p.s : 1 + p.i);
2059
2112
  port.nodes.forEach((g, w) => {
2060
2113
  const b = this.blockOfNode[g], s = vol[g] * area[w];
2061
2114
  d.fill(0);
2062
2115
  let pref = rx.k0, aHi = aV, aLo = 0;
2063
2116
  for (const p of rx.part) {
2064
2117
  if (p.side === 1) continue;
2065
- const o = b * M + 1 + p.i;
2118
+ const o = b * M + slot(p);
2066
2119
  aHi -= p.nu * u[o];
2067
2120
  aLo -= p.nu * uLo[o];
2068
2121
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
2069
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2070
- this._dlnc(g, p.i, e, d);
2122
+ if (p.side === 2) {
2123
+ // a surface species at activity θ/θ₀ = e^ζ
2124
+ pref *= Math.exp(e * (u[o] + uLo[o] - surf[p.s].mu0 / RT));
2125
+ d[1 + n + p.s] += e;
2126
+ } else {
2127
+ pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2128
+ this._dlnc(g, p.i, e, d);
2129
+ }
2130
+ }
2131
+ if (rx.bare) {
2132
+ // on bare metal only: the free fraction θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
2133
+ for (let x = 0; x < surf.length; x++) d[1 + n + x] -= th[g * nSurf + x];
2134
+ pref *= this.th0[g];
2071
2135
  }
2072
2136
  const { g: bv, gp } = bvFactor(aHi + aLo, al);
2073
2137
  const rate = pref * bv;
2074
2138
  rates[w] = rate;
2075
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
2139
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η = −ν
2076
2140
  for (let t = 0; t < M; t++) d[t] *= rate;
2077
- for (const p of rx.part) if (p.side === 0) d[1 + p.i] -= pref * gp * p.nu;
2141
+ for (const p of rx.part) if (p.side !== 1) d[slot(p)] -= pref * gp * p.nu;
2078
2142
  const dV = pref * gp * daV;
2079
2143
  for (const p of rx.part) {
2080
2144
  if (p.side === 1) continue;
2081
- const row = 1 + p.i, o = b * M + row;
2082
- res[R[o]] -= s * p.nu * rate;
2083
- flux[p.i] += s * p.nu * rate;
2084
- for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -s * p.nu * d[t]);
2085
- B[R[o]] -= s * p.nu * dV;
2145
+ // the region's species per volume of the window; a surface's per area of electrode
2146
+ const row = slot(p), o = b * M + row, f = p.side === 2 ? 1 : s;
2147
+ res[R[o]] -= f * p.nu * rate;
2148
+ if (p.side === 0) flux[p.i] += s * p.nu * rate;
2149
+ for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -f * p.nu * d[t]);
2150
+ B[R[o]] -= f * p.nu * dV;
2086
2151
  }
2087
2152
  for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
2088
2153
  this.termDI[k] += q * F * s * dV;
2089
2154
  });
2090
2155
  }
2091
2156
 
2157
+ // A capacitance spread through a port's window: per area of electrode, σ = C (V − zeroCharge − φ)
2158
+ // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2159
+ // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2160
+ // contact sets is left to the contact.
2161
+ _portCapacitance(port, k, dt) {
2162
+ const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2163
+ const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2164
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
2165
+ let Q = 0, dQdV = 0;
2166
+ port.nodes.forEach((g, w) => {
2167
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2168
+ const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
2169
+ const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2170
+ Q += q;
2171
+ dQdV += s;
2172
+ res[R[b * M]] -= q;
2173
+ this._j(b, 0, b, 0, s * VT);
2174
+ B[R[b * M]] -= s;
2175
+ if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
2176
+ });
2177
+ this.portQ[k - 2] = Q;
2178
+ if (Number.isFinite(dt)) {
2179
+ this.termI[k] += (Q - this.portQStart[k - 2]) / dt;
2180
+ this.termDI[k] += dQdV / dt;
2181
+ }
2182
+ }
2183
+
2184
+ // An electrode surface's storage, per area of electrode: Γ (θ_s − θ_s,old)/dt in each surface
2185
+ // species' row, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
2186
+ _surfaceStorage(port, dt) {
2187
+ const { n, M, res, nSurf, th, thOld } = this, R = this.rix, surf = port.surface, G = surf[0].capacity;
2188
+ for (const g of port.nodes) {
2189
+ const b = this.blockOfNode[g];
2190
+ // Near full coverage θ − θ_old cancels (both ~1, the change ~θ₀): the dominant species'
2191
+ // change comes from the small ones instead, Δθ = −Δθ₀ − Σ_others Δθ.
2192
+ let top = 0;
2193
+ for (let q = 1; q < surf.length; q++) if (th[g * nSurf + q] > th[g * nSurf + top]) top = q;
2194
+ for (let q = 0; q < surf.length; q++) {
2195
+ const tq = th[g * nSurf + q];
2196
+ let change = tq - thOld[g * nSurf + q];
2197
+ if (q === top && tq > 0.5) {
2198
+ change = -(this.th0[g] - this.th0Old[g]);
2199
+ for (let x = 0; x < surf.length; x++) if (x !== q) change -= th[g * nSurf + x] - thOld[g * nSurf + x];
2200
+ }
2201
+ res[R[b * M + 1 + n + q]] += (G * change) / dt;
2202
+ for (let x = 0; x < surf.length; x++) this._j(b, 1 + n + q, b, 1 + n + x, ((G * tq) / dt) * ((q === x ? 1 : 0) - th[g * nSurf + x]));
2203
+ }
2204
+ }
2205
+ }
2092
2206
 
2093
2207
  // One contact (terminal 0 or 1): record the flux through its outer face and the current into
2094
2208
  // the device (read from the end box before anything here is added: whatever the box needs
@@ -2245,6 +2359,8 @@ export class Solver {
2245
2359
  for (let i = 0; i < n; i++) {
2246
2360
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + i]]));
2247
2361
  }
2362
+ const k = this.surfPort[g]; // an electrode surface's coverages, as η
2363
+ if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2248
2364
  }
2249
2365
  return mx;
2250
2366
  }
@@ -2401,14 +2517,15 @@ export class Solver {
2401
2517
  this.uPrevLo.set(this.uLo);
2402
2518
  this.termVPrev.set(this.termV);
2403
2519
  this.computeConcentrations();
2404
- const cN = Float64Array.from(this.c);
2520
+ const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
2405
2521
  // Contact displacement before the step: as it was at the end of the previous step (under
2406
2522
  // the parameters then), so a gate-voltage change shows up as displacement current.
2407
- if (!this.contactDEnd) {
2523
+ if (!this.contactDEnd || !this.portQEnd) {
2408
2524
  this._assembleBookkeeping(dt);
2409
2525
  this.contactDEnd = { ...this.contactD };
2526
+ this.portQEnd = Float64Array.from(this.portQ);
2410
2527
  }
2411
- const DN = { ...this.contactDEnd };
2528
+ const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
2412
2529
  if (opts.guess) {
2413
2530
  // Start Newton from a predicted state (e.g. extrapolated from the history).
2414
2531
  this.u.set(opts.guess);
@@ -2419,11 +2536,17 @@ export class Solver {
2419
2536
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
2420
2537
  const { cOld } = this;
2421
2538
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
2539
+ for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
2540
+ for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
2422
2541
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
2542
+ this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
2423
2543
  dtEff = dt / a0;
2424
2544
  } else {
2425
2545
  this.cOld.set(cN);
2546
+ this.thOld.set(thN);
2547
+ this.th0Old.set(th0N);
2426
2548
  this.contactDStart = DN;
2549
+ this.portQStart = QN;
2427
2550
  }
2428
2551
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
2429
2552
  // A step landing on a breakpoint ends on it exactly.
@@ -2439,11 +2562,12 @@ export class Solver {
2439
2562
  }
2440
2563
  result.bdf = bdf;
2441
2564
  if (result.converged) {
2442
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, D: DN });
2565
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
2443
2566
  if (this.history.length > 3) this.history.length = 3;
2444
2567
  this.time = tEnd;
2445
2568
  this.lastDt = dtEff;
2446
2569
  this.contactDOld = this.contactDStart;
2570
+ this.portQOld = this.portQStart;
2447
2571
  this._accumulateBoundaryIntake(dtEff, cN);
2448
2572
  } else {
2449
2573
  this.u.set(this.uPrev);
@@ -2727,12 +2851,17 @@ export class Solver {
2727
2851
  u: Float64Array.from(this.u),
2728
2852
  uLo: Float64Array.from(this.uLo),
2729
2853
  cOld: Float64Array.from(this.cOld),
2854
+ thOld: Float64Array.from(this.thOld),
2855
+ th0Old: Float64Array.from(this.th0Old),
2730
2856
  time: this.time,
2731
2857
  lastDt: this.lastDt,
2732
2858
  atSteady: this.atSteady,
2733
2859
  contactDStart: this.contactDStart,
2734
2860
  contactDOld: this.contactDOld,
2735
2861
  contactDEnd: this.contactDEnd,
2862
+ portQStart: this.portQStart,
2863
+ portQOld: this.portQOld,
2864
+ portQEnd: this.portQEnd,
2736
2865
  boundaryIntake: Float64Array.from(this.boundaryIntake),
2737
2866
  history: this.history.slice(),
2738
2867
  termV: Float64Array.from(this.termV),
@@ -2744,12 +2873,17 @@ export class Solver {
2744
2873
  this.u.set(s.u);
2745
2874
  this.uLo.set(s.uLo);
2746
2875
  this.cOld.set(s.cOld);
2876
+ this.thOld.set(s.thOld);
2877
+ this.th0Old.set(s.th0Old);
2747
2878
  this.time = s.time;
2748
2879
  this.lastDt = s.lastDt;
2749
2880
  this.atSteady = s.atSteady;
2750
2881
  this.contactDStart = s.contactDStart;
2751
2882
  this.contactDOld = s.contactDOld;
2752
2883
  this.contactDEnd = s.contactDEnd;
2884
+ this.portQStart = s.portQStart;
2885
+ this.portQOld = s.portQOld;
2886
+ this.portQEnd = s.portQEnd;
2753
2887
  this.boundaryIntake.set(s.boundaryIntake);
2754
2888
  this.history = s.history.slice();
2755
2889
  this.termV.set(s.termV);
@@ -2786,8 +2920,11 @@ export class Solver {
2786
2920
  this.sourceTime = this.time;
2787
2921
  this.computeConcentrations();
2788
2922
  this.cOld.set(this.c);
2923
+ this.thOld.set(this.th);
2924
+ this.th0Old.set(this.th0);
2789
2925
  this.assemble(Infinity);
2790
2926
  this.contactDStart = { ...this.contactD };
2927
+ this.portQStart = Float64Array.from(this.portQ);
2791
2928
  this.assemble(Infinity);
2792
2929
  // The steady parts, then the storage parts from an assembly at a tiny dt.
2793
2930
  const J = { A: sys.A.slice(), B: sys.B.slice(), C: sys.C.slice() };
@@ -3075,6 +3212,7 @@ export class Solver {
3075
3212
  _accumulateBoundaryIntake(dt, cN) {
3076
3213
  this._assembleBookkeeping(dt);
3077
3214
  this.contactDEnd = { ...this.contactD };
3215
+ this.portQEnd = Float64Array.from(this.portQ);
3078
3216
  if (!Number.isFinite(dt)) return;
3079
3217
  const last = this.model.regions.length - 1;
3080
3218
  const { n, cOld } = this, vol = this.model.grid.vol;
package/src/stack.js CHANGED
@@ -100,7 +100,7 @@ export function bath(c, reference, d = 0, { offset } = {}) {
100
100
  return { ...drive(d, 'bath'), bath: { c: { ...c }, reference, ...(offset === undefined ? {} : { offset }) } };
101
101
  }
102
102
 
103
- const STACK_FIELDS = ['T', 'library', 'species', 'materials', 'stack', 'bulkReactions', 'ports', 'grid'];
103
+ const STACK_FIELDS = ['T', 'library', 'species', 'materials', 'stack', 'bulkReactions', 'ports', 'grid', 'geometry'];
104
104
 
105
105
  /**
106
106
  * Build a plain device definition from a stack:
@@ -112,7 +112,7 @@ const STACK_FIELDS = ['T', 'library', 'species', 'materials', 'stack', 'bulkReac
112
112
  * between them get the default face. Nested lists are flattened.
113
113
  * - Layers may give doping as `donors` and `acceptors` (mol/m³) in place of `fixedCharge`.
114
114
  * - `library`: pieces with species and materials, merged with `species` and `materials`.
115
- * - `T`, `grid`, `ports` pass through.
115
+ * - `T`, `grid`, `ports`, `geometry` pass through.
116
116
  * @param {object} def
117
117
  * @returns {object} a device definition for `new Device`
118
118
  */
@@ -168,5 +168,6 @@ export function build(def) {
168
168
  if (def.bulkReactions !== undefined) out.bulkReactions = def.bulkReactions;
169
169
  if (def.ports !== undefined) out.ports = def.ports;
170
170
  if (def.grid !== undefined) out.grid = def.grid;
171
+ if (def.geometry !== undefined) out.geometry = def.geometry;
171
172
  return out;
172
173
  }
package/src/types.js CHANGED
@@ -157,10 +157,16 @@
157
157
  * @property {string} [terminal] species whose offset defaults to 0; with reactions, the electrode's carrier
158
158
  * @property {Record<string, 'blocked' | 'equilibrium' | { type: 'equilibrium', offset?: number, mu?: number }
159
159
  * | { type: 'conductance', G: number, offset?: number } | { type: 'exchange', k: number, mu: number }>} [species]
160
- * @property {{ equation: string, fixed?: Record<string, number>, k0: number, alpha: number }[]} [reactions] an
160
+ * @property {{ equation: string, fixed?: Record<string, number>, k0: number, alpha: number, bare?: boolean }[]} [reactions] an
161
161
  * electrode spread through the window: Butler–Volmer per area (k0 in mol/(m²·s)) between the region's
162
162
  * species and the terminal species at the port's level
163
- * @property {number | { x: number[], values: number[] }} [area] with reactions: electrode area per volume,
163
+ * @property {Record<string, { mu0: number, capacity: number, theta0?: number }>} [surface] with reactions: species
164
+ * on the electrode's sites, Langmuir (μ = μ° + RT ln(θ/θ₀)), capacity in mol of sites per m² of electrode,
165
+ * shared; a reaction with `bare: true` runs only on the bare fraction θ₀ = 1 − Σθ
166
+ * @property {{ C: number, zeroCharge?: number }} [capacitance] a capacitance spread through the window (F/m² of
167
+ * electrode): σ = C (V − zeroCharge − φ) on the port's side, aσ in each node's charge balance; a gate
168
+ * along a channel, a double layer
169
+ * @property {number | { x: number[], values: number[] }} [area] with reactions or a capacitance: electrode area per volume,
164
170
  * m²/m³ (1/h for a film of thickness h), or a profile against the device's x
165
171
  */
166
172
 
@@ -209,7 +215,8 @@
209
215
  * @property {{ left: ContactResult, right: ContactResult }} contacts
210
216
  * @property {{ left?: { V: number, D: number, charge: number }, right?: { V: number, D: number, charge: number } }} gates
211
217
  * @property {{ name: string, V: number, flux: Record<string, number>, current: number, x?: Float64Array,
212
- * area?: Float64Array, rates?: Float64Array[] }[]} ports what each internal port brings into the device;
218
+ * area?: Float64Array, rates?: Float64Array[], coverage?: Record<string, Float64Array>, bare?: Float64Array,
219
+ * sigma?: Float64Array, charge?: number }[]} ports what each internal port brings into the device;
213
220
  * with reactions, also its window's nodes (x, m), area per volume there, and each reaction's rate per area
214
221
  * at each node (mol/(m²·s), forward)
215
222
  * @property {{ left: string, right: string, dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
package/types/solver.d.ts CHANGED
@@ -8,12 +8,19 @@ export class Solver {
8
8
  n: any;
9
9
  M: any;
10
10
  nRx: any;
11
+ nSurf: any;
11
12
  nNodes: any;
12
13
  nFaces: number;
13
14
  nB: any;
14
15
  VT: number;
15
16
  blockOfNode: Int32Array<any>;
16
17
  blockOfFace: Int32Array<ArrayBuffer>;
18
+ surfPort: Int32Array<any>;
19
+ surfW: Int32Array<any>;
20
+ th: Float64Array<ArrayBuffer>;
21
+ thOld: Float64Array<ArrayBuffer>;
22
+ th0: Float64Array<any>;
23
+ th0Old: Float64Array<any>;
17
24
  present: Uint8Array<ArrayBuffer>;
18
25
  cRef: Float64Array<ArrayBuffer>;
19
26
  mu0hat: Float64Array<ArrayBuffer>;
@@ -54,6 +61,10 @@ export class Solver {
54
61
  left: number;
55
62
  right: number;
56
63
  };
64
+ portQ: Float64Array<any>;
65
+ portQStart: Float64Array<any>;
66
+ portQOld: Float64Array<any>;
67
+ portQEnd: any;
57
68
  contactDOld: {
58
69
  left: number;
59
70
  right: number;
@@ -133,6 +144,7 @@ export class Solver {
133
144
  initFromComposition(): void;
134
145
  _portRate(port: any, rx: any, g: any): number;
135
146
  _materialAt(mat: any, background: any, eta: any, mode: any, cFix: any, ph: any, zeta: any, cc: any): void;
147
+ _coverages(): void;
136
148
  computeConcentrations(): void;
137
149
  _work(md: any): any;
138
150
  _nodeStatistics(g: any, b: any, phiHat: any): void;
@@ -172,8 +184,10 @@ export class Solver {
172
184
  _terminals(dt: any): void;
173
185
  _circuit(k: any): void;
174
186
  _captureRow(b: any, s: any, w: any, into: any): number;
175
- _port(port: any, flux: any, k: any): void;
187
+ _port(port: any, flux: any, k: any, dt: any): void;
176
188
  _portReaction(port: any, rx: any, area: any, rates: any, flux: any, k: any): void;
189
+ _portCapacitance(port: any, k: any, dt: any): void;
190
+ _surfaceStorage(port: any, dt: any): void;
177
191
  _contact(side: any, dt: any): void;
178
192
  _replaceRow(b: any, rs: any): void;
179
193
  _equilibrate(): void;
@@ -257,6 +271,8 @@ export class Solver {
257
271
  u: Float64Array<ArrayBuffer>;
258
272
  uLo: Float64Array<ArrayBuffer>;
259
273
  cOld: Float64Array<ArrayBuffer>;
274
+ thOld: Float64Array<ArrayBuffer>;
275
+ th0Old: Float64Array<ArrayBuffer>;
260
276
  time: number;
261
277
  lastDt: number;
262
278
  atSteady: boolean;
@@ -269,6 +285,9 @@ export class Solver {
269
285
  right: number;
270
286
  };
271
287
  contactDEnd: any;
288
+ portQStart: Float64Array<any>;
289
+ portQOld: Float64Array<any>;
290
+ portQEnd: any;
272
291
  boundaryIntake: Float64Array<ArrayBuffer>;
273
292
  history: any[];
274
293
  termV: Float64Array<ArrayBuffer>;
package/types/stack.d.ts CHANGED
@@ -53,7 +53,7 @@ export function bath(c: Record<string, number>, reference: string, d?: number, {
53
53
  * between them get the default face. Nested lists are flattened.
54
54
  * - Layers may give doping as `donors` and `acceptors` (mol/m³) in place of `fixedCharge`.
55
55
  * - `library`: pieces with species and materials, merged with `species` and `materials`.
56
- * - `T`, `grid`, `ports` pass through.
56
+ * - `T`, `grid`, `ports`, `geometry` pass through.
57
57
  * @param {object} def
58
58
  * @returns {object} a device definition for `new Device`
59
59
  */
package/types/types.d.ts CHANGED
@@ -380,9 +380,29 @@ export type PortDef = {
380
380
  fixed?: Record<string, number>;
381
381
  k0: number;
382
382
  alpha: number;
383
+ bare?: boolean;
383
384
  }[];
384
385
  /**
385
- * with reactions: electrode area per volume,
386
+ * with reactions: species
387
+ * on the electrode's sites, Langmuir (μ = μ° + RT ln(θ/θ₀)), capacity in mol of sites per m² of electrode,
388
+ * shared; a reaction with `bare: true` runs only on the bare fraction θ₀ = 1 − Σθ
389
+ */
390
+ surface?: Record<string, {
391
+ mu0: number;
392
+ capacity: number;
393
+ theta0?: number;
394
+ }>;
395
+ /**
396
+ * a capacitance spread through the window (F/m² of
397
+ * electrode): σ = C (V − zeroCharge − φ) on the port's side, aσ in each node's charge balance; a gate
398
+ * along a channel, a double layer
399
+ */
400
+ capacitance?: {
401
+ C: number;
402
+ zeroCharge?: number;
403
+ };
404
+ /**
405
+ * with reactions or a capacitance: electrode area per volume,
386
406
  * m²/m³ (1/h for a film of thickness h), or a profile against the device's x
387
407
  */
388
408
  area?: number | {
@@ -545,6 +565,10 @@ export type Solution = {
545
565
  x?: Float64Array;
546
566
  area?: Float64Array;
547
567
  rates?: Float64Array[];
568
+ coverage?: Record<string, Float64Array>;
569
+ bare?: Float64Array;
570
+ sigma?: Float64Array;
571
+ charge?: number;
548
572
  }[];
549
573
  interfaces: {
550
574
  left: string;