Skip to content

fix!: reaction orders, molecularity and symmetry entropies from reactant coefficients - #859

Open
schneiderfelipe wants to merge 93 commits into
mainfrom
fix/reaction-orders
Open

schneiderfelipe wants to merge 93 commits into
mainfrom
fix/reaction-orders

Conversation

@schneiderfelipe

@schneiderfelipe schneiderfelipe commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Closes #838.

Scheme stored only net stoichiometry, so species on both sides of a reaction lost their reactant coefficients. For example, 2 B -> B + C was simulated as first order in B. Scheme now stores reactants, products, transition states and equilibrium pairs, and derives everything else from them.

Fixes

  • Reaction orders: get_dydt uses reactant coefficients, so 2 B -> B + C is second order in B (Wrong reaction order in an autocatalytic reaction #838).
  • Molecularity: sums reactant coefficients instead of counting species, so 2 A -> B is bimolecular.
  • Symmetry entropies: count molecules on each side, so 2 B -> B + C loses R ln 2.
  • Repeated compounds: add up on a side, so B + B -> C is 2 B -> C (it used to be first order). Zero coefficients are dropped.
  • Equilibria are paired explicitly: Scheme.equilibria stores (forward, reverse) index pairs, instead of get_k assuming consecutive half-equilibria. A -> B followed by A <=> B used to leave an unpaired half that get_k paired with the wrong reaction; now an equilibrium absorbs plain reactions between its two sides.
  • Equilibrium rate constants come from equilibrium constants: get_k sets each pair from the forward half's equilibrium constant and runs Eyring only on reactions outside equilibria, so reverse halves no longer need a zero-activation special case. The resulting rate constants are unchanged.
  • Symmetry entropies can't be forgotten: Scheme.reaction_freeenergies and Scheme.activation_freeenergies include them, instead of every caller subtracting temperature times symmetry entropies by hand.
  • Transition states must link reactants to products: chained transition states (A -> A‡ -> A'‡ -> B), incomplete ones (A -> A‡), and transition states sharing a side or with a coefficient (C + T‡ -> D, 2 T‡) used to be silently dropped or misread; they now raise.
  • Invalid schemes and inputs now raise:
    • equilibria through transition states;
    • in get_k, a reverse half of an equilibrium whose free energy is not minus the forward one, within 1e-5 J/mol;
    • negative or non-integer coefficients, and repeated compound names;
    • in the CLI and Scheme.fix, a concentration for a name that is not a compound (transition states included), a compound given both free and fixed concentrations, or a concentration without a colon; and plotting a name that is not a compound, now checked before simulating.
  • Compiled models (.jk): compounds are listed in the order of Scheme.names, so models compiled by older versions stay aligned.
  • Compounds are matched by name: get_k and get_kappa take values from the compounds dict by name, so a dict in any order gives the same rate constants.
  • Rate constants don't depend on how free energies are given: get_k(scheme, compounds) forced every compound to the 1 M standard state, while get_freeenergies keeps gas-phase compounds at the reference pressure. Passing those free energies as delta_freeenergies gave bimolecular gas-phase rate constants 24.5 times too small (RT/p° in L/mol). get_k now converts gas-phase free energies to 1 M itself, so both paths agree, and bias now also applies with delta_freeenergies.
  • Each compound keeps its own environment: get_entropies used the first compound's environment for all compounds when none was given, so mixed gas and solution models put gases at 1 M.
  • Gas-phase conversions go the documented way: eyring(molecularity=) and equilibrium_constant(delta_moles=) converted from 1 M to gas-phase data, the opposite of what their docs say, off by (RT/p°)² for bimolecular reactions. get_k(volume=) was ignored; it is now the molar volume of gases.
  • Unit conversions: atm-1 s-1 used RT/p instead of RT at reference pressures other than 1 atm, and unknown units passed silently for unimolecular reactions.
  • Solvation models work after import overreact: solvation methods in overreact.thermo raised AttributeError unless something else had imported their module first.
  • Clear errors: get_k without compounds or free energies, and solvation methods without atoms, raise TypeError instead of AssertionError. get_dydt rejects rate constants that are not one per reaction, and get_bias rejects arrays of temperatures or pressures.

Breaking changes (version bumped to 2.0.0)

Removed or made private Use instead
parse_reactions(text) Scheme.parse(text)
unparse_reactions(scheme) str(scheme)
get_delta(scheme.A, x) / get_delta(scheme.B, x) scheme.reaction_deltas(x) / scheme.activation_deltas(x)
get_reaction_entropies(transform) scheme.reaction_entropies / scheme.activation_entropies
get_fixed_scheme(scheme, k, c) scheme, factors = scheme.fix(c), then k * factors
get_transition_states(A, B, is_half_equilibrium) scheme.transition_states
thermo.get_molecularity(scheme.A) scheme.molecularity
Scheme(compounds, reactions, is_half_equilibrium, A, B) Scheme(compounds, reactants, products, transition_states, equilibria)
scheme.B scheme.activation_deltas
scheme.A scheme.products - scheme.reactants
scheme.is_half_equilibrium scheme.equilibria
is_transition_state(name) scheme.transition_states
io.InterfaceFormatter none (internal to the CLI)
rx.change_reference_state rx.thermo.change_reference_state
get_kappa(scheme, compounds, method=...) get_kappa(scheme, compounds, tunneling=...)
tunneling="none" tunneling=None (the CLI keeps --tunneling=none)
get_bias(..., y0=[...]) in compound order get_bias(..., y0={"A": ...}) by name
get_dydt(scheme, k, ef=...) get_dydt(scheme, k, equilibrium_factor=...)
scheme.activation_deltas(g) - T * scheme.activation_entropies (and the reaction analogue) scheme.activation_freeenergies(g, T) / scheme.reaction_freeenergies(g, T) (not removed, but no longer needed)

Transition states are stored apart from compounds:

  • scheme.compounds no longer lists transition states, so coefficient matrices, concentration vectors (y0, get_y results) and CLI plots and tables have no transition-state entries.
  • scheme.transition_states holds names (('A‡', None)) instead of indices into compounds.
  • Per-name values, such as energies passed to the delta methods or bias in get_k, follow the new scheme.names: compounds, then transition states. parse_model orders model.compounds the same way, so get_freeenergies(model.compounds) stays aligned.

Scheme is now a validated frozen dataclass. Its coefficient matrices and derived data are read-only arrays, computed once and cached. Compounds are numbered in order of appearance in the resulting reactions, so Scheme.parse(str(scheme)) == scheme.

With 2-D values, the delta methods take names on the last axis, where get_delta took them on the first. get_kappa returns one row per condition instead of a flattened array.

eyring, equilibrium_constant and eckart return floats for scalar inputs, as NumPy does, instead of arrays of shape (1,) or (). The volume of equilibrium_constant and eyring is in m³/mol, as in change_reference_state.

Free energies passed to get_k follow the standard states of get_freeenergies: unsuffixed names, such as A, are gases at the reference pressure, and names such as A(w) are in solution at 1 M. Rate constants from compounds are unchanged, but those from delta_freeenergies change by a factor of RT/p°, about 24.5 L/mol, for each gas-phase molecule a step consumes, such as the second reactant of A + B -> C. To give solution free energies, name compounds with a solvent suffix.

Arrays of conditions

For example, get_k(scheme, compounds, temperature=[[300.0], [400.0]]) returns one row of rate constants per temperature. Values per compound, name or reaction lie along the last axis, and conditions broadcast against them: a scalar applies everywhere, shape (n,) gives one value per item, and shape (m, 1) adds m conditions.

  • get_k takes one temperature per reaction with delta_freeenergies, or batches of shape (m, 1). It rejects temperatures that differ between compounds or between the halves of an equilibrium.
  • get_internal_energies, get_enthalpies, get_entropies, get_freeenergies and get_kappa broadcast over conditions.
  • The Scheme delta and free-energy methods and Scheme.fix broadcast over leading axes. The new Scheme.concentrations builds concentration arrays from a dict, by name.
  • tunnel.eckart takes arrays of barriers and falls back to Wigner per element.
  • get_dydt takes several states as columns, as scipy.integrate.solve_ivp does, so get_y's rate function evaluates many times in one call.
  • format_numbers formats arrays with more than one dimension.
  • Functions in overreact.thermo that only take scalar temperatures or pressures are documented as such.

Other changes

  • Empty sides denote sources and sinks (-> A, A ->). Molecularity counts reactant molecules exactly, so sources have molecularity 0 (it used to be clamped to 1), which changes their rate constants on scales other than l mol-1 s-1.
  • The CLI's activation table shows each reverse half-equilibrium's own values instead of zeros.
  • Symmetry entropies and fixed concentrations are logged.
  • get_dydt compiles its Jacobian once with JIT, about 100 times faster per call. get_y still doesn't pass it to the solver; re-enabling it for implicit solvers is the subject of Diffrax integration #817.
  • Property-based tests with hypothesis, checked against independent oracles (invariants, round trips, detailed balance, ideal gas laws, finite differences, literature values) instead of re-deriving the code under test. Non-default tolerances are set to the measured errors.
  • The coords, io, _cli and _misc modules no longer build intermediate collections or copies they don't need. Van der Waals radii, molecular volumes, the Eckart transform, Eckart quadratures and spectrum broadening use NumPy operations instead of Python loops.

Notes

…cheme

Scheme is now a validated frozen dataclass holding reactant and product
coefficients, transition states and half-equilibrium flags. Reaction
strings and the A and B matrices are derived from them.

- Remove get_transition_states in favour of Scheme.transition_states.
- Repeated compounds on one side add up ("B + B -> C" is "2 B -> C").
- Empty sides denote sources and sinks ("-> A", "A ->").
- Reactions without compounds raise.
- Half-equilibria without their reverse, or with transition states, raise.
Species on both sides of a reaction no longer lower its order, e.g. "2 B -> B + C" is second order in B.

Closes #838
Add Scheme.molecularity and remove thermo.get_molecularity, which counted reacting species instead of molecules ("2 A -> B" was unimolecular).
get_reaction_entropies now takes initial and final coefficients, so molecules on both sides of a reaction count in each state ("2 B -> B + C" loses R ln 2). Its temperature and pressure arguments had no effect and are removed.
Split the ORCA reference check into its own test.
- parse_reactions and unparse_reactions become Scheme.parse and str(scheme).
- get_delta and get_reaction_entropies become Scheme.reaction_deltas,
  Scheme.activation_deltas, Scheme.reaction_entropies and
  Scheme.activation_entropies.
- get_fixed_scheme becomes Scheme.fix, returning rate constant factors.
- Add Scheme.equilibria for pairs of half-equilibria.
- Make the activated state private and drop the B matrix.
- Name the offending reaction when a half-equilibrium lacks its reverse.
Scheme.equilibria() now yields index pairs, derived arrays are computed once on first use and read-only, and str(scheme) is built from a generator.
It is reused and cheap to keep, so share it instead of handing out one-shot generators.
Keep validated arrays from construction, build reaction strings directly, mask fixed compounds, stack CLI quantities into single delta calls, share number formatting and stop logging activation entropies twice.
Reject non-integer coefficients and fixed transition states, compute rate constant factors in floating point, normalize fields before validating them, and accept reactions left without compounds after fixing, as get_fixed_scheme did.
…action

- Scheme stores equilibria as (forward, reverse) index pairs instead of
  half-equilibrium flags, which are now derived.
- An equilibrium absorbs plain reactions between its two sides, so
  "A -> B" followed by "A <=> B" is the equilibrium alone.
- Compounds are numbered in order of appearance in the resulting
  reactions, so parsing str(scheme) gives back the same scheme.
…tants

get_k sets each equilibrium pair from the equilibrium constant of its
forward half, ignoring the reverse value of delta_freeenergies. The
activated state is now the transition state, or the products, for every
reaction, so reverse halves of equilibria report their own activation.
Fixing every compound of a scheme left get_dydt with an unshaped order matrix.
Replace tests that re-derived the code under test with invariants (state functions, reversal, rate scaling, detailed balance, ideal gas pressure and volume laws) and ground truth from generated inputs.
…ard one

get_k used to ignore the reverse entry of delta_freeenergies for equilibria; it now raises when it is not minus the forward entry.
…ducts

Chained transition states and transition state lines that never complete into a reaction used to be dropped silently, as did compounds only named there. Scheme also rejects transition states as reactants or products.
- Store reactant and product coefficients once, as read-only arrays,
  which also removes the reshape in get_dydt.
- Derive the equilibrium flag of parser records from their keys.
- Share one helper for reaction and activation deltas in the CLI, and
  read reaction sides and transition states without regular expressions.
- Trim the Scheme.parse docstring to what it does.
Arrays of concentrations used to give wrong factors; factors now have one row per condition.
The Wigner fallback for non-positive barriers applies element-wise.
…onstant and eyring

delta_moles and molecularity converted the wrong way, giving K_p and atm-based
rate constants instead of the documented K_c and molar ones. volume is now in
m³/mol, as in change_reference_state.
… free energies

get_k forced the 1 M standard state when computing free energies from
compounds, but took given free energies as 1 M too, while get_freeenergies
puts gas-phase compounds at the reference pressure. get_k now takes free
energies in the standard state of each compound's environment, converts gas
ones to 1 M, and uses volume for that. Its body now calls local functions for
free energies from compounds and for tunneling coefficients.
…off only with None

get_k and get_kappa no longer accept the string "none", and get_kappa
rejects unknown corrections even for schemes without transition states. The
CLI keeps --tunneling=none.
… reject arrays of conditions

get_bias takes y0 by name, as it takes data, instead of an array in the order
of scheme.compounds. It now raises a clear error for arrays of temperatures
or pressures, and is documented as taking floats.
…t and eckart

As in NumPy, scalar inputs now give scalars, instead of arrays of shape (1,)
or ().
change_reference_state moves to overreact.thermo, next to
equilibrium_constant, as eyring and eckart are in their own modules.
overreact.io now lists parse_compounds, which overreact exports.
convert_rate_constant took atm-based rate constants at RT/p instead of RT
when the reference pressure was not 1 atm, and skipped checking units when
molecularity was one. Its unused pressure argument is gone.
get_k silently ignored bias with delta_freeenergies.
…dcasting loop

get_internal_energies, get_enthalpies and get_entropies each define the
quantity of one compound as a local function, which _per_compound evaluates
along the last axis.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong reaction order in an autocatalytic reaction

2 participants