Electrolysis duties: schedules, the anolyte reservoir and dosing¶
process/duties.yaml (schema cheme/electrolysis-duties/v1) states how a cell of
process/cells.yaml is operated over time against the reservoir its anolyte circulates
through. cheme process duty integrates each duty and reports the pH, inventory, product,
charge, energy, dosing and bleed over time, the time to the stated pH threshold, and the time
spent outside the measured conditions. The code is
chemeascode.domains.process.electrolysis_duty (model electrolysis-duty v1) with the anolyte
models of chemeascode.domains.chemistry.anolyte; the decision is
ADR 0043. It builds on the
measured cell performance and the
membrane block of ADR 0041.
The file¶
schema: cheme/electrolysis-duties/v1
kind: electrolysis_duties
duties:
- id: D-150
title: 150 mA/cm2 on 1 L of 1 M KOH
cell: BPM-1 # an id of process/cells.yaml
cells_in_series: 1 # default: the cell's
stacks: 1 # stacks in parallel on the one reservoir
# stack: STK-1 # or: run at the arrangement that stack is sized at
schedule: {kind: constant_current, current_density: {value: 150, unit: mA/cm2}}
reservoir:
volume: {value: 1, unit: L}
composition: {hydroxide: {value: 1, unit: mol/L}} # also carbonate, bicarbonate, co2
cation: K+
model: well-mixed-carbonate # an anolyte model id; there is no default
degassing: # optional; without it CO2 degassing is off (info)
kla: {value: 2, unit: 1/h, source: "..."}
co2_partial_pressure: {value: 0.0004, unit: atm, source: "air"}
# size: to_horizon # optional: the volume that reaches the horizon
# time_margin: {value: 0.1} # with size: a fraction of the horizon, or {value, unit: h}
dosing:
kind: on_threshold # none | on_threshold | continuous
trigger_ph: 12.5
stop_ph: 13.0
concentration: {value: 5, unit: mol/L}
rate: {value: 10, unit: mL/h}
density: {value: 1.22, unit: kg/L, source: "..."} # optional: the solution's mass
bleed: {rate: {value: 2, unit: mL/h}}
makeup_water: {rate: {value: 2, unit: mL/h}}
threshold: {ph: 12, source: "the stated design criterion"}
horizon: {value: 2000, unit: h}
minimum_inventory: {volume: {value: 0.5, unit: L}} # or cation: {value, unit: mol}
tolerance: 1.0e-8 # relative, optional
operating_map: {wde: [0.5, 0.995], volume: [0.1, 10], volume_unit: L, resolution: [16, 10]}
Schedules:
| kind | gives | notes |
|---|---|---|
constant_current |
current (A per cell) or current_density |
|
piecewise |
points: [[t, I], ...] in time_unit and unit (A or A/m2, mA/cm2) |
interpolation: step (held) or linear; the first point at 0, the last value holds |
power |
points: [[t, P], ...], P of all the duty's cells (W, kW) |
P = N U(j) j A solved for j inside the measured range; outside it there is no current |
on_off |
period, duty_fraction, current when on |
start: on or off |
N is the cells in series times the stacks, all on the one reservoir. A duty a stack of
process/cells.yaml names under equipment.duty runs at the arrangement that stack is sized
at, in cheme process duty as in cheme process stack (cheme.duty-arrangement, info): its
own cells_in_series and stacks are then not used (electrolyser stacks).
A duty no stack's equipment names, such as a comparison duty on a skid's hardware, gets that
arrangement by naming the stack (stack: STK-1); it may not state other cells_in_series or
stacks (cheme.duty-arrangement, error: the duty is refused), and a duty naming a stack
that is not in process/cells.yaml, is of another cell, or whose arrangement is not given
(the stack refused; the duty run without the stack sizing) is refused too.
What is computed¶
At each instant the cell's voltage U(j), the Faradaic efficiency of its product FE(j), the
water dissociation efficiency WDE(j) and the carbon share C_x(j) of the co-ion current come
from MeasuredPerformance.at(j) for a measured cell (interpolated between the measured points,
never extrapolated) or from a specified cell's constants (voltage, faradaic_efficiency,
membrane.water_dissociation_efficiency, membrane.co_ion_crossover) within its
operating_range. Per cell, with the current I:
- OH- into the anolyte from the bipolar membrane:
WDE I / F; - OH- consumed at the anode (oxygen evolution):
I / F; - the co-ion current
(1 - WDE) I / F: the cation leaving the anolyte (share1 - C_x) and carbon entering it as HCO3- (shareC_x), or the ions and shares of the membrane block; - the product formed:
FE I nu / (n F); the powerU I.
These, times N, with the dosing (cation and OH- of the solution, its volume), the make-up
water and the bleed (at the reservoir's composition), are the fluxes the anolyte model
integrates. The cumulative hydroxide deficit is (1 - WDE) Q / F, the charge Q over all
cells.
The integration is the adaptive Dormand-Prince 5(4) method of foundation/ode.py with events:
- the stated pH threshold (ends the duty; reported as the time to the specified pH threshold, a design criterion, not a corrosion onset);
- the minimum inventory (volume or cation), or an empty reservoir;
- dosing on at
trigger_phand off atstop_ph; - the product target of a comparison.
The schedule's breakpoints and the times the current density crosses the measured range split the integration. The transient stops at the first exit from the measured range (current density or temperature): the state after it is not computed. The scheduled operating time outside the range is summed (to the horizon when the transient stopped at an exit). Time with the anolyte outside the model's validity (pH, ionic strength, temperature) is summed too.
The totals are also split by route, so that the cation and the carbon balance of the reservoir close on their own (the LCI's reconciliation needs no closing by difference):
| total | what |
|---|---|
initial_cation_mol, initial_carbon_mol, initial_volume_l |
the reservoir at the start |
dosing_cation_mol |
the cation dosed (one per hydroxide: dosing_mol) |
membrane_cation_mol |
the cation leaving the anolyte through the membrane, (1 - C_x)(1 - WDE) Q / F at constant performance |
membrane_carbon_mol |
the carbon entering through the membrane (one per HCO3- or CO3-2), C_x (1 - WDE) Q / F |
bleed_cation_mol, bleed_carbon_mol |
the cation and carbon leaving with the bleed, at the reservoir's composition |
initial + dosed - bled - membrane = final for the cation and initial + membrane - bled -
degassed = final for the carbon, to rounding (a test holds both to 1e-9 relative). They are
integrated as quadratures on the integration's own steps, outside its error control, so every
other value is as without them.
A duty is feasible when it runs to its horizon (or reaches a comparison's target) without reaching the threshold or the minimum inventory, entirely inside the measured range and the model's validity, and the integration converged.
The reservoir volume that reaches the horizon¶
reservoir.size: to_horizon asks for the reservoir volume that reaches the duty's horizon
(plus time_margin: a fraction of the horizon, or a time in h or d) at the stated pH threshold.
Without dosing, bleed, make-up water and degassing, and with the cell on at one operating point
(one WDE and co-ion split whenever it is on: constant current, on/off, or steps of a specified
cell), the reservoir's state moves along the charge Q passed, and the threshold falls at a
fixed charge per volume Q* / V0. The volume is then exact:
V = V0 Q(target) / Q*
with V0 the stated volume, Q(target) the schedule's charge up to the target and Q* the
charge at the threshold: from the run when it reaches the threshold, otherwise by following the
run's straight path in the charge (the model's speciation) to the threshold. At constant current
it is V0 H / t_threshold. The threshold is not the only limit: on the same path the
concentrations at V after the charge Q are those at V0 after Q V0 / V, and the
inventories are V/V0 n0 - Q dn/dQ. So the volume is raised, exactly, where the duty would
otherwise end first or leave the model's claim before the target: to keep a stated
minimum_inventory (absolute, in mol or L, so it does not scale with the volume:
V >= (m + Q(target) dn/dQ) V0 / n0), and to keep the anolyte inside the anolyte model's
validity (pH, ionic strength) up to the target. limited_by names the limit that sets it
(the pH threshold, the stated minimum, the model's validity). The run itself is at V0, as
without size; the volume is in required_volume of the JSON (volume_l, limited_by,
target_h, probe_volume_l, the charges and the basis) and a cheme.duty-size info finding.
A run at V0 that ends at the stated minimum inventory gives the volume too; a stated volume
already below the minimum at the start ends the duty at 0 h (not feasible) and gives none. cheme process stack --write writes it into
reservoir.volume for a duty a stack's equipment names
(electrolyser stacks), rounded up to 4 significant figures so that
the duty at the written volume reaches the target. With dosing, a bleed, make-up water or
degassing the volume does not scale exactly and size: to_horizon is refused
(cheme.duty-size, error), as for a schedule at more than one operating point, one leaving the
measured range before the target, or a run ended otherwise than by the threshold, the
horizon or the stated minimum inventory (an exhausted inventory); the duty itself still runs.
The anolyte model well-mixed-carbonate v1¶
An independent, concentration-basis implementation of the anolyte balance of Prats Vergel et al. (Nat. Chem. Eng. 2025), on conserved quantities with instantaneous equilibrium speciation instead of the authors' finite-rate kinetics:
- state: moles of the cation and of total inorganic carbon
C_T, and the volume; [H+]from the charge balance[M+] + [H+] = [OH-] + [HCO3-] + 2 [CO3-2]with Kw = 1e-14, pK1 = 6.37, pK2 = 10.32 at 25 °C, solved onlog10 [H+](bracketed Newton);- CO2 degassing
k_L a V ([CO2] - 0.034 p_CO2)above saturation only, whenk_L ais given.
Its validity: ionic strength 0-1.5 mol/L, pH 2-14.5, 293.15-303.15 K. Its limitations: an ideal solution (concentrations, not activities: at 1 mol/L ionic strength the pH is a concentration-basis pH, not an activity pH), constants at 25 °C, no other ions or solids, supersaturated CO2 stays in solution without degassing.
Because the pH is on concentrations, the time to a threshold scales exactly with the reservoir
volume at fixed fluxes. The authors' published script stops on -log10 of the H+ amount,
which is the pH only at 1 L (process-library#329 D1: at 10 L it stops at pH 13). Against the
329 reproduction (200 mA/cm2, 5 cm2, 1 M KOH, C_x 0.66, WDE 0.95) this model gives 322.30 h to¶
pH 12 at 1 L and 3223.0 h at 10 L, as the authors' model on a concentration basis
(tests/test_electrolysis_duty.py). Apart from that reproduction, the validation condition of
329 V1 (20 mL of 0.1 M KOH, 100 mA/cm2) gives 1.54 h, inside the envelope 1.31-1.87 h around¶
the measured 1.47 h.
The NaCl electrolyte route (cheme process electrolyte, electrolytes.yml) does not compute
KOH or carbonate solutions and refuses them; the anolyte model is the only place they are
handled.
Comparing duties¶
cheme process duty --compare A,B --co 0.5 (compare_duties) integrates each duty until its
cumulative product equals 0.5 kg. A duty reaches the target when it delivers it to within the
relative tolerance (--tolerance, default 1e-3 of the target); a duty whose horizon,
threshold, inventory or measured range ends it first is reported as not reaching it
(cheme.duty-target-not-reached). Per duty: the elapsed time, charge, energy, time outside
the measured range, final pH, dosing used and the cumulative hydroxide deficit. The duties
are named in --compare, not as positional ids (both is an error); --plot DIR draws the
compared transients, each up to the target.
Plots¶
--plot DIR writes per duty:
duty-<id>.svg: aligned time plots, one quantity per panel on one time axis: current, voltage, pH with the stated threshold, product delivered, hydroxide dosed; events dotted, the time after a range exit shaded "not simulated". Lines are simulated.map-<id>.svg: the operating map, time to the threshold over WDE (constant) and reservoir volume (log), with the duty's schedule and dosing; the measured WDE points of the measurement file as markers labelled "measured", the duty's own point, and the WDE outside the measured points shaded. Axis ranges come fromoperating_map:in the duty.
Findings¶
| rule | level | when |
|---|---|---|
cheme.duty-schema |
error | the file does not follow the schema, a unit or a value out of range, a dosing trigger not above the threshold, an on/off schedule of more than 100 000 periods over the horizon |
cheme.duty-cell-missing |
error | the cell is not in process/cells.yaml or lacks what the duty needs (voltage, FE, a bipolar membrane with WDE and co-ion split from the membrane block or the measurement's titration, area, product), its membrane block is invalid (co-ion shares not in 0..1 or not adding up to 1, WDE outside 0..1), or its measurement has errors |
cheme.duty-model-unknown |
error | no anolyte model of that id |
cheme.duty-model-species |
error | a co-ion or anode reaction the model does not carry, or a model whose state names no cation (mol) and volume (L) |
cheme.duty-out-of-range |
warning | operation outside the measured range, or the anolyte outside the model's validity (info: a specified cell without a current density range) |
cheme.duty-threshold-reached |
info | the time to the stated threshold |
cheme.duty-horizon-reached |
info | the threshold is not reached within the horizon |
cheme.duty-inventory-low |
warning | the minimum inventory is reached, the reservoir empties, or a conserved inventory (cation, carbon) is exhausted: the transient stops there |
cheme.duty-dosing-saturated |
warning | the threshold is reached while dosing, or dosing never reaches its stop pH |
cheme.duty-dosing-chatter |
warning | threshold dosing switched on and off more than 20 000 times (a band stop_ph - trigger_ph too narrow for the dosing rate); the transient stops there |
cheme.duty-not-converged |
error | the integration failed |
cheme.duty-degassing |
info | no CO2 degassing stated |
cheme.duty-compare |
error | compared duties form different products |
cheme.duty-target-not-reached |
warning | a compared duty ends before the target |
cheme.duty-arrangement |
info / error | the duty runs at the arrangement a stack of process/cells.yaml is sized at, not its own; error (refused): it names a stack (stack:) not in the file, of another cell, whose arrangement is not given, that another stack's equipment runs, or states cells_in_series or stacks other than the stack's |
cheme.duty-size |
info / error / warning | reservoir.size: to_horizon: the volume that reaches the horizon, keeping the stated minimum inventory and the model's validity; error: no exact volume (dosing, bleed, make-up, degassing, more than one operating point, the measured range left, a time margin not a fraction or time); warning: a time margin without size |
cheme validate reports the schema, cell and model findings without integrating: every error
cheme process duty refuses a duty on before it integrates (units, ranges, the cell and its
membrane, the anolyte model and its species, a stack: not in process/cells.yaml or of
another cell, size: to_horizon with dosing, bleed, make-up or degassing). --json records the model ids and versions (the
duty's and the anolyte model's), their assumptions and limitations, the range each duty is
checked against (measured_range: current density, temperature, the power of all cells for a
power schedule, the measured WDE), and the source revision with the sha256 of every input, the
measurement file included.