Chemistry
Package @ferret/physics-chemistry, capability chemistry.
What it is for#
Mixtures that react: a cold pack that gets cold when its salt dissolves, a gas that burns inside a closed pot, a batch that runs away when its cooling fails, an equilibrium that shifts when you squeeze it, a catalyst that turns a slow reaction into a fast one.
The model is a well-stirred reactor. Each vessel holds one uniform mixture with a single temperature, not a field. For flames in engines and guns use combustion; this package is for the chemistry itself. Its vessels can sit inside a heat part through links.
Species and reactions#
A species has its atoms, its phase (gas, liquid, solid or aqueous), its enthalpy of formation and entropy, and a heat capacity. EXAMPLE_SPECIES has methane, oxygen, nitrogen, hydrogen, carbon dioxide, water (gas and liquid), ammonia, nitrogen oxides, calcium carbonate and oxide, ammonium nitrate and its ions, and generic A, B, C, Solvent for experiments. A game supplies its own tables for its own materials.
A reaction lists reactants and products with their coefficients and a rate that grows with temperature (the Arrhenius law, k = A·e^(−Ea/RT)). It may be reversible and may name catalysts.
A reaction that does not balance is refused. Chemistry.add checks atoms and charge on both sides, and the error says which element is missing. That makes the library safe to extend: a typo cannot turn into free matter. cap.check() audits the whole library.
Reversible reactions settle at an equilibrium that follows from the thermodynamic data, from either side. Le Chatelier follows too: squeeze a gas and the side with fewer molecules gains.
Vessels#
A Vessel holds amounts of species, a gas volume, a liquid volume and a temperature. The pressure is that of the gas moles. Energy and matter are conserved: the heat of a reaction changes the temperature through the mixture's heat capacity, and atoms are never lost. The solver adapts its steps to fast reactions, so the result does not depend on how time is cut up.
import { Chemistry, SpeciesLibrary, EXAMPLE_SPECIES, Vessel } from '@ferret/physics-chemistry';
const chem = new Chemistry(new SpeciesLibrary(EXAMPLE_SPECIES));
chem.add('a2b', { reactants: { A: 1 }, products: { B: 1 }, A: 0.5, Ea: 0, phase: 'solution' });
const vessel = new Vessel(chem, { volume: 1e-3, isothermal: true, contents: { A: 1 } });
vessel.react(4); // four seconds
vessel.amount('B');
First-order reactions decay as e^(−kt), second-order ones as 1/(1 + 2kc₀t).
Three modes: closed (the default), isothermal (isothermal: true: the temperature is held) and at constant pressure (pressure: p: the gas space moves like a piston or an open top, so the flame ends cooler than in a closed vessel).
| Vessel option | Meaning |
|---|---|
volume, liquidVolume | m³ of gas space and of solution |
T, Tenv, hA, wallC | start temperature, surroundings, loss in W/K, heat capacity of the wall in J/K |
isothermal, pressure | hold the temperature; or let the gas space move at this pressure |
contents | moles by species |
What you see in a game#
A cold pack. An endothermic reaction in a solvent cools the contents by n·ΔH / C. Run the reaction the other way and it heats. Give the vessel wallC for a heavy pot, and hA and Tenv to lose heat to the room.
A flame in a closed pot. Methane and oxygen at a high temperature burn out in a fraction of a second, the atoms and the mass are conserved, and the pressure of the hot gas rises steeply. With constant heat capacities the model overshoots (a closed flame comes out above 4000 K). FLAME_SPECIES with methaneFlame(chemistry) adds temperature-dependent heat capacities and the dissociation of CO₂ and H₂O, and brings the flame to about 2600–3000 K. Use that for anything the player sees. A reaction with a high Ea only lights when hot: at 300 K the mixture sits there indefinitely.
A catalyst or an inhibitor. catalysts: [{ species: 'Z', delta: 10e3 }] lowers the activation energy by delta J/mol while Z is present and is not used up; a negative delta inhibits. minimum is the amount below which it does nothing.
Thermal runaway. A vessel with a reaction and a cooling wall runs away above a critical ambient temperature. The capability raises chem:runaway when the temperature rises faster than runaway (50 K/s), chem:ignite above ignition (1000 K/s) and chem:burst when the pressure reaches the vessel's burstPressure. An alarm, a reactor scram or a relief valve can listen to those events.
Gas between vessels. vessel.vent(to, dt, { area, Cd?, length?, friction? }) moves gas through an orifice (choked at high pressure) or a pipe. The pressures level out, the source cools and the energy of the pair is kept. vessel.pour(to, fraction) moves a share of the contents.
In a world#
const cap = chemistry(chem);
host.register(cap);
host.enable('chemistry');
cap.addVessel('pot', { volume: 1e-3, T: 900, contents: { CH4: 0.01, O2: 0.02, N2: 0.0752 }, burstPressure: 2e6 });
Formulas#
A substance can be written as a formula; the atoms, the charge and the molar mass follow from it.
parseFormula('Ca(OH)2'); // { atoms: { Ca: 1, O: 2, H: 2 }, charge: 0 }
parseFormula('SO4^2-(aq)'); // charge -2, phase 'aq'
parseFormula('CuSO4·5H2O'); // hydrate; also * and (after a non-digit) .
formatFormula({ C: 6, H: 12, O: 6 }); // 'C6H12O6' (Hill order)
species.add('NH3', { phase: 'g', Hf: -45.9e3, cp: 35.1 }); // the name is the formula
species.add('x', { formula: 'Ca(OH)2', phase: 's', Hf: -985.2e3, cp: 87.5 });
species.find('H2O(l)'); // the name of the species with that formula and phase
A charge is a sign after the formula (NH4+, OH-) or, with a size, after ^ (SO4^2-, Fe^3+); Fe3+ is refused as ambiguous. Atoms given with a formula must agree with it. All 118 elements are known (ELEMENTS, ATOMIC_MASS). An ion's cp may be negative (a partial molar value). Every malformed formula fails with a message that names the position.
Equations#
A reaction can be written as an equation; the species are found in the library by name or formula.
chem.add('burn', 'CH4 + 2 O2 -> CO2 + 2 H2O', { A: 1e7, Ea: 100e3, orders: { CH4: 1, O2: 1 } });
chem.add('dimer', '2 NO2 <=> N2O4', { A: 1e6, Ea: 0 }); // <=> is reversible
chem.add('cold', 'NH4NO3(s) -> NH4+(aq) + NO3-(aq)', { A: 0.5, Ea: 0, phase: 'solution' });
chem.balanceEquation('C3H8 + O2 -> CO2 + H2O'); // 1, 5, 3, 4: the smallest whole coefficients, for tools; never applied silently
chem.formatEquation(chem.reactions.get('burn')!); // 'CH4 + 2 O2 → CO2 + 2 H2O'
Arrows -> → = (one way) and <=> ⇌ <-> (reversible); coefficients are integers, decimals or fractions (1/2 O2); terms are separated by + with spaces. An equation that does not balance as written is refused with the element named. balanceEquation refuses an equation that cannot be balanced or has more than one independent solution.
Standard sets and thermodynamic data#
const lib = new SpeciesLibrary(['core', 'combustion']); // only what is named is loaded
lib.loadSet('examples'); // or later, by command: chem.load { set }
const chem = new Chemistry(lib);
addFlameEquilibria(chem); // H2O and CO2 dissociation, H, O, OH, NO: a flame that lands at its real temperature
core (O2, N2, Ar, H2, H2O as gas, liquid H2O(l) and ice, CO, CO2, graphite C(s); airMoles(n) is dry air), combustion (CH4, C2H6, C3H8, C2H4, C2H2, CH3OH and H, O, OH, NO, HO2, H2O2, CH3, CH2O, HCO), examples (the old EXAMPLE_SPECIES). chem.sets lists them and what is loaded. Gases carry NASA 7-coefficient polynomials (nasa) from which cpAt, dHs and dSs follow (200-3500 K; beyond that cp stays at its end value); Hf, S and cp at 298 K come from the same polynomials. Data: the GRI-Mech 3.0 compilation of the NASA/Burcat polynomials, with the high range of water vapour refitted to NIST-JANAF (tools/fit-janaf.mjs). Against NIST-JANAF, cp, H-H298 and S of the core gases are within 0.5 % from 500 to 3000 K, and the adiabatic flame temperatures of stoichiometric methane in air (2226 K) and hydrogen in air (2382 K) come out of the species data and the equilibria alone. Condensed phases use the NBS/NIST standard values at 298 K.
Solutions: ions, pH, solubility#
const chem = new Chemistry(new SpeciesLibrary(['core', 'aqueous', 'acids-bases']));
addAqueousReactions(chem); // water, acids and bases, carbonate, phosphate, salts
const beaker = new Vessel(chem, { volume: 1e-3, liquidVolume: 1e-3, isothermal: true,
contents: { 'H2O(l)': waterMoles(1e-3), 'HCl(aq)': 0.01 } });
beaker.react(1);
beaker.pH; // 2.00
The aqueous set holds H+, OH-, Na+, K+, Ca^2+, Mg^2+, Fe^2+, Fe^3+, Cu^2+, Cl-, SO4^2-, HSO4-, NO3-, CO3^2-, HCO3-, NH4+, CH3COO-, the phosphate ions, the weak acids and bases as molecules (CH3COOH(aq), NH3(aq), CO2(aq), H3PO4(aq)) and the salts that dissolve and precipitate (NaCl(s), KCl(s), NH4NO3(s), CaCO3(s), CaSO4(s), Ca(OH)2(s), Fe(OH)3(s)). acids-bases adds the strong electrolytes as molecules that dissociate almost completely: HCl(aq), HNO3(aq), H2SO4(aq), NaOH(aq), KOH(aq). The solvent is H2O(l), a pure liquid of activity 1: add it with waterMoles(volume). Standard enthalpy, entropy and heat capacity of each ion come from the NBS/NIST tables (heat capacities marked as estimates in src/sets/aqueous.ts where no table value is at hand), and every constant follows from them: Kw 1.0e-14, pKa of acetic acid 4.76, of ammonium 9.25, carbonic 6.35 and 10.33, phosphoric 2.15, 7.2, 12.4, Ksp of calcite 4e-9. The positions of the equilibria are exact in that sense; the rate constants are game-grade (a solution is at equilibrium within milliseconds, a salt dissolves at dissolution mol/(m³·s), 1000 by default; pass { recombination, dissolution } to addAqueousReactions to change them).
vessel.pH is -log10 of the H+ concentration in mol/L, ideal dilute solution (no activity coefficients: right to about 0.02 pH for strong acids and bases up to 0.1 mol/L), or undefined when there is no H+. vessel.state returns it. A pure solid counts as present only while some of it is there: CaCO3 added to water dissolves until [Ca2+][CO3 2-] reaches the solubility product and stops, the rest stays solid; an over-saturated solution precipitates down to the same value. Dissolving ammonium nitrate takes its heat from the water (a cold pack), a strong acid and a strong base warm it by 55.8 kJ/mol.
Chemistry.method selects the integrator: trapezoid (second order, adaptive, for burns) or euler (implicit Euler, L-stable, for the stiff ionic equilibria of solutions; a step is accepted when no amount that matters changes by more than a fifth of itself, and the rise rate for the events is judged over at least 50 ms). Left alone, each vessel chooses: euler when a reaction that can run in it involves an ion, trapezoid otherwise; Vessel.method overrides it for one vessel. Species that no reaction can reach are left out of the system the solver differentiates.
Other sets and the reaction library#
const chem = new Chemistry(new SpeciesLibrary(['core', 'combustion', 'metals']));
addStandardReactions(chem); // every library reaction whose species are loaded: combustion of the fuels, rust, thermite, slaking, ...
chem.reactions.get('slaking'); // CaO(s) + H2O(l) -> Ca(OH)2(s), A, Ea, phase
nitrogen (NH3, NO, NO2, N2O, N2O4, HNO3, NH4NO3), sulfur (S, SO2, SO3, H2S, H2SO4), halogens (F2, Cl2, Br2, I2 and the hydrogen halides, NaCl, KCl), metals (Fe and its oxides and hydroxide, Al, Cu, Zn, Mg, Pb, Na, K, Ca, CaO, Ca(OH)2, CaCO3, CaSO4, MnO2, Zn^2+), organics (glucose, sucrose, the cellulose unit, urea, acetone, benzene, toluene) and energetics (ammonium perchlorate, potassium nitrate, TNT, nitroglycerin, liquid hydrogen peroxide). combustion also holds ethanol and octane as liquids. Gases with a polynomial are the NASA ones of core; the others have the NBS/NIST standard values with a constant cp (estimates marked in src/sets/families.ts).
STANDARD_REACTIONS lists the library; addStandardReactions(chem, { families, skip }) adds the ones whose species are all loaded (a game's own reaction with the same name stays). Each entry has its equation, A and Ea, a note saying where the rate comes from and how far to trust it, and dH, the tabulated heat of reaction at 298 K, which the tests hold the species data to within 1 %. The thermodynamics are exact from the species data (heat, equilibrium constants: Haber at 700 K, NO2 dimerisation at 298 and 350 K, limestone at 1100 K, the contact process each settle at the K the data give); the rates are game-grade: the right order of magnitude of the speed at the temperatures where the reaction matters. A reversible reaction keeps its stoichiometric orders so that it settles at the thermodynamic K; override a single reaction by adding your own with the same name before addStandardReactions. Without addPhaseChanges (next section) a hot product keeps its phase: a thermite runs far hotter than a real one.
Melting and boiling#
addPhaseChanges(chem, { substances?, rate?, liquidReactions?, data? }) adds, for each substance of PHASE_DATA (H2O, Li, Na, K, Ca, Mg, Al, Fe, Cu, Zn, Pb, Al2O3, ethanol, octane; CRC melting and boiling temperatures and enthalpies) whose condensed phase is in the library, the fast reversible reactions melt-X (X(s) <=> X(l)) and boil-X (X(l) <=> X(g)). The temperature of a change is not a rule of the solver: the liquid and the vapour the library lacks (Na(l), Na(g), Fe(l), Al2O3(l)...) are made with Hf and S chosen so that K = 1 exactly at the tabulated temperature and the enthalpy there is the latent heat; a condensed phase the library already has is corrected only if its entropy puts the change more than half a kelvin away (ice, mostly), and a gas is never touched. A vessel then follows from its energy balance: sodium heated through 370.87 K sits on a plateau while it melts, for as long as the latent heat divided by the heat flow; ice and water sit at 273.15 K; water in a closed vessel holds its vapour pressure at the temperature (1 bar at 372.8 K, 47 kPa at 353 K); boiling takes its latent heat from the vessel and a boiling liquid cools it. The vapour obeys the pressure: iron boils at 3134 K at 1 bar, and lower in a large volume, higher in a small closed one. Reactions of a solid are copied for its liquid (lithium-water~Li(l), thermite~Al(l)), so a molten metal still reacts. A thermite lit at 1250 K in a cubic metre stops at about 3000 K with iron boiling off instead of running to 4000 K. The vessel picks the stiff integrator when a phase change is live. phases: true (or the options) in the level block, chem.standard { phases }, and edit.chemistry.option with key phases do the same.
In a level: the physics.chemistry block#
A level can bring its own library; it is applied when the level loads and taken away when it goes (a species the level replaces comes back as the game had it):
"physics": {
"capabilities": ["chemistry"],
"chemistry": {
"sets": ["core", "metals", "aqueous"],
"species": { "Xe2": { "formula": "Xe2", "phase": "g", "Hf": 10000, "S": 200, "cp": 21 } },
"reactions": { "xenon-dimer": { "equation": "Xe + Xe <=> Xe2", "A": 1000, "Ea": 0 } },
"aqueous": true,
"standard": ["metals", "combustion"]
}
}
sets loads standard sets (a species the game already has stays), species and reactions are your own (a reaction is { equation, A, Ea, ... } or the object form), aqueous adds the reactions of solutions (true or { recombination, dissolution }), standard adds the library reactions (true or a list of families), phases adds melting and boiling (true or { substances, rate, liquidReactions }, after the rest). An error names physics.chemistry and the entry. The editor writes the block: edit.chemistry.get, edit.chemistry.option (sets, aqueous, standard, phases), edit.chemistry.species.set / remove, edit.chemistry.reaction.set / remove; it also adds chemistry to capabilities.
Commands: chem.parse { formula } (atoms, charge, phase, canonical formula, molar mass), chem.balance { equation }, chem.check (audit), chem.react { name, reaction } or { name, equation, A, Ea, ... } (add a reaction), chem.info { name, T? } (heat of reaction, free energy, equilibrium constant), vessel.state (with the resolved thresholds), vessel.set, chem.thresholds, vessel.add { name, species, mol }, vessel.remove { name, species, mol }, vessel.react { name, dt } (step one vessel by hand), vessel.heat { name, hA?, Tenv? }, chem.sets, chem.load { set }, chem.standard { families?, aqueous?, phases? }.
Events: chem:ignite, chem:runaway, chem:equilibrium, chem:burst.
One table of substances for gases and liquids#
Pneumatics, combustion and liquids read the same species as the vessel. A gas is named by formula in a pneumatic tank or a combustion chamber, and gasOf(formula) (cp, cv, u, h, gamma, s at a temperature), mixtureProps(mix, T) and fuelHeat(fuel) (the lower heating value and the oxygen per kilogram from the formation enthalpies) are the functions behind it. Henry's constants for water (HENRY_WATER, henryConstant(gas, T)) are data of the aqueous set; EXAMPLE_LIQUIDS.water.henry and the pneumatic Dissolver read them, and a Dissolver given a vessel keeps its dissolved gas as the vessel's X(aq). The set noble holds He, Ne and Kr.
Parameters#
| Value | Default | Effect |
|---|---|---|
runaway | 50 K/s | temperature rise rate that raises chem:runaway |
ignition | 1000 K/s | rise rate that raises chem:ignite |
burstPressure | none | Pa; the vessel raises chem:burst at this pressure |
The narrowest value wins: the vessel's own (addVessel(name, { runaway: 5, ignition: 200, burstPressure: 2e6 }), or vessel.set { name, runaway?, ignition?, burstPressure? }; null removes it), then the vessel's entity (entity: 'boiler', or taken from a heat part by vesselInPart), then the zone that entity stands in (a vessel with no entity uses its position), then the level, the world, and the default. Set the scopes on host.config('chemistry'):
const cfg = host.config('chemistry');
cfg.set('world', 'ignition', 800);
cfg.set('zone', 'runaway', 20, 'workshop');
cfg.set('entity', 'runaway', 10, 'boiler');
cap.thresholds('pot'); // { runaway: { value, scope }, ignition: ..., burstPressure: ... } (also `chem.thresholds`)
The rise rate that is compared is the peak over the accepted sub-steps of a world step (vessel.dTdt), not the average, so a flash is an ignition at any step length. The events carry rate (chem:ignite, chem:runaway) and entity when the vessel has one.
Works with#
heat: links vesselInPart puts a vessel inside a heat part, so the wall and the surroundings exchange heat with it. combustion: engines and guns use their own lumped chambers; use chemistry when you want species and equilibrium. explosions: a vessel at chem:burst is a pressure vessel; pass { pressure, volume } as vessel to detonate.
Cost#
An implicit solve per vessel per step; the sub-steps adapt to stiffness (up to 20 000). A handful of vessels with a few reactions is negligible. A fast flame may use many sub-steps in one world step, but only while it burns.
When it does not behave#
| Symptom | Cause |
|---|---|
reaction "x" is not balanced | atoms or charge differ between the sides; the message names the element |
no species "X" | it is not in the library; the message lists the known ones |
| Nothing reacts | the rate is tiny at this temperature (Ea too high), or the concentrations are in the wrong phase ('gas' or 'solution') |
| The flame is far too hot | species have constant heat capacity; use FLAME_SPECIES and methaneFlame |
| A reversible reaction never settles | A is tiny, so equilibrium takes longer than you run; raise A or run longer |
| Pressure does not change | the species are liquid or aqueous; only gas moles make pressure |
The vessel is always settled | nothing can react any more; a reactant is used up |
| A solution is very slow, thousands of sub-steps | the stiff ionic equilibria need the euler integrator; a vessel picks it by itself when an ion can appear, or set chem.method = 'euler' |
vessel.pH is undefined | no H+ in the vessel: load the aqueous set and add water (waterMoles) with addAqueousReactions |
A cookbook page, writing chemistry with formulas, shows the formulas, the sets and the library at work. Worked numbers (rate laws, equilibrium constant, cooling of an endothermic mix, closed methane burn) are in tests/unit/examples/physics-chemistry.test.ts.
Reference#
chemistry, ChemistryCapability, Chemistry, Vessel, SpeciesLibrary, addAqueousReactions, addStandardReactions, STANDARD_REACTIONS, SpeciesDef, ReactionDef, EXAMPLE_SPECIES, FLAME_SPECIES.