Edit on GitHub

overreact

---

PyPI Python Versions CI Coverage License

User guide GitHub Discussions GitHub issues

downloads/month total downloads

DOI DOI

Made in Brazil 🇧🇷

overreact

---

overreact is a library and a command-line tool for building and analyzing homogeneous microkinetic models from first-principles calculations:

In [1]: from overreact import api  # the api

In [2]: api.get_k("S -> E‡ -> S",  # your model
   ...:           {"S": "data/ethane/B97-3c/staggered.out",  # your data
   ...:            "E‡": "data/ethane/B97-3c/eclipsed.out"})
Out[2]: array([8.16880917e+10])  # your results

The user specifies a set of elementary reactions that are believed to be relevant for the overall chemical phenomena. overreact offers a hopefully complete but simple environment for hypothesis testing in first-principles chemical kinetics.

🤔 What is microkinetic modeling?

Microkinetic modeling is a technique used to predict the outcome of complex chemical reactions. It can be used to investigate the catalytic transformations of molecules. overreact makes it easy to create and analyze microkinetic models built from computational chemistry data.


🧐 What do you mean by first-principles calculations?

We use the term first-principles calculations to refer to calculations performed using quantum chemical modern methods such as Wavefunction and Density Functional theories. For instance, the three-line example code above calculates the rate of methyl rotation in ethane (at B97-3c). (Rather surprisingly, the error found is less than 2% when compared to available experimental results.)


overreact uses precise thermochemical partition funtions, tunneling corrections and data is parsed directly from computational chemistry output files thanks to cclib (see the list of its supported programs).

Installation

overreact is a Python package, so you can easily install it with pip:

$ pip install "overreact[cli,fast]"

See the installation guide for more details.

🚀 Where to go from here? Take a look at the short introduction. Or see below for more guidance.

Citing overreact

If you use overreact in your research, please cite:

Schneider, F. S. S.; Caramori, G. F. _Overreact, an in Silico Lab: Automative Quantum Chemical Microkinetic Simulations for Complex Chemical Reactions_. Journal of Computational Chemistry 2022, 44 (3), 209–217. doi:10.1002/jcc.26861.

Here's the reference in BibTeX format:

@article{overreact_paper2022,
  title         = {Overreact, an in silico lab: Automative quantum chemical microkinetic simulations for complex chemical reactions},
  author        = {Schneider, Felipe S. S. and Caramori, Giovanni F.},
  year          = {2022},
  month         = {Apr},
  journal       = {Journal of Computational Chemistry},
  publisher     = {Wiley},
  volume        = {44},
  number        = {3},
  pages         = {209–217},
  doi           = {10.1002/jcc.26861},
  issn          = {1096-987x},
  url           = {http://dx.doi.org/10.1002/jcc.26861},
}
@software{overreact_software2021,
  title         = {geem-lab/overreact: v1.2.0 \vert{} Zenodo},
  author        = {Felipe S. S. Schneider and Let\'{\i}cia M. P. Madureira and  Giovanni F. Caramori},
  year          = {2023},
  month         = {Jan},
  publisher     = {Zenodo},
  doi           = {10.5281/zenodo.7865357},
  url           = {https://doi.org/10.5281/zenodo.7865357},
  version       = {v1.2.0},
  howpublished  = {\url{https://github.com/geem-lab/overreact}},
}

License

overreact is open-source, released under the permissive MIT license. See the LICENSE agreement.

Funding

This project was developed at the GEEM lab (Federal University of Santa Catarina, Brazil), and was partially funded by the Brazilian National Council for Scientific and Technological Development (CNPq), grant number 140485/2017-1.

 1""".. include:: ../README.md"""  # noqa: D400
 2
 3from __future__ import annotations
 4
 5__docformat__ = "restructuredtext"
 6
 7import pkg_resources as _pkg_resources
 8
 9from overreact.api import (
10    get_enthalpies,
11    get_entropies,
12    get_freeenergies,
13    get_internal_energies,
14    get_k,
15    get_kappa,
16)
17from overreact.core import (
18    Scheme,
19    get_transition_states,
20    is_transition_state,
21    parse_reactions,
22    unparse_reactions,
23)
24from overreact.io import parse_compounds, parse_model
25from overreact.simulate import get_bias, get_dydt, get_fixed_scheme, get_y
26from overreact.thermo import change_reference_state, get_delta, get_reaction_entropies
27
28__all__ = [
29    "Scheme",
30    "change_reference_state",
31    "get_bias",
32    "get_delta",
33    "get_dydt",
34    "get_enthalpies",
35    "get_entropies",
36    "get_fixed_scheme",
37    "get_freeenergies",
38    "get_internal_energies",
39    "get_k",
40    "get_kappa",
41    "get_reaction_entropies",
42    "get_transition_states",
43    "get_y",
44    "is_transition_state",
45    "parse_compounds",
46    "parse_model",
47    "parse_reactions",
48    "unparse_reactions",
49]
50
51__version__ = _pkg_resources.get_distribution(__name__).version
52__license__ = "MIT"  # I'm too lazy to get it from setup.py...
53
54__headline__ = "📈 Create and analyze chemical microkinetic models built from computational chemistry data."
55
56__url_repo__ = "https://github.com/geem-lab/overreact"
57__url_issues__ = f"{__url_repo__}/issues"
58__url_discussions__ = f"{__url_repo__}/discussions"
59__url_pypi__ = "https://pypi.org/project/overreact/"
60__url_guide__ = "https://geem-lab.github.io/overreact-guide/"
61
62__doi__ = "10.1002/jcc.26861"
63__zenodo_doi__ = "10.5281/zenodo.7865357"
64__citations__ = (  # TODO(schneiderfelipe): read from CITATION.bib
65    r"""
66    @article{overreact_paper2022,
67  title         = {Overreact, an in silico lab: Automative quantum chemical microkinetic simulations for complex chemical reactions},
68  author        = {Schneider, Felipe S. S. and Caramori, Giovanni F.},
69  year          = {2022},
70  month         = {Apr},
71  journal       = {Journal of Computational Chemistry},
72  publisher     = {Wiley},
73  volume        = {44},
74  number        = {3},
75  pages         = {209-217},
76  doi           = {DOI_PLACEHOLDER},
77  issn          = {1096-987x},
78  url           = {http://dx.doi.org/DOI_PLACEHOLDER},
79}
80@software{overreact_software2021,
81  title         = {geem-lab/overreact: vVERSION_PLACEHOLDER \vert{} Zenodo},
82  author        = {Felipe S. S. Schneider and Let\'{\i}cia M. P. Madureira and  Giovanni F. Caramori},
83  year          = {2023},
84  month         = {Jan},
85  publisher     = {Zenodo},
86  doi           = {ZENODO_DOI_PLACEHOLDER},
87  url           = {https://doi.org/ZENODO_DOI_PLACEHOLDER},
88  version       = {vVERSION_PLACEHOLDER},
89  howpublished  = {\url{URL_REPO_PLACEHOLDER}},
90}
91""".replace(
92        "ZENODO_DOI_PLACEHOLDER",
93        __zenodo_doi__,
94    )
95    .replace("DOI_PLACEHOLDER", __doi__)
96    .replace("URL_REPO_PLACEHOLDER", __url_repo__)
97    .replace("VERSION_PLACEHOLDER", __version__)
98)
class Scheme(typing.NamedTuple):
19class Scheme(NamedTuple):
20    """
21    A descriptor of a chemical reaction network.
22
23    Mostly likely, this comes from a parsed input file.
24    See `overreact.io.parse_model`.
25    """
26
27    compounds: list[str]
28    """A descriptor of compounds."""
29    reactions: list[str]
30    """A descriptor of reactions."""
31    is_half_equilibrium: list[bool]
32    """An indicator of whether a reaction is half-equilibrium."""
33    A: np.ndarray
34    """A matrix of stoichiometric coefficients between reactants and products."""
35    B: np.ndarray
36    """A matrix of stoichiometric coefficients between reactants and transition states."""

A descriptor of a chemical reaction network.

Mostly likely, this comes from a parsed input file. See parse_model.

Scheme( compounds: list[str], reactions: list[str], is_half_equilibrium: list[bool], A: ForwardRef('np.ndarray'), B: ForwardRef('np.ndarray'))

Create new instance of Scheme(compounds, reactions, is_half_equilibrium, A, B)

compounds: list[str]

A descriptor of compounds.

reactions: list[str]

A descriptor of reactions.

is_half_equilibrium: list[bool]

An indicator of whether a reaction is half-equilibrium.

A: numpy.ndarray

A matrix of stoichiometric coefficients between reactants and products.

B: numpy.ndarray

A matrix of stoichiometric coefficients between reactants and transition states.

Inherited Members
builtins.tuple
index
count
def change_reference_state( new_reference: float = 1000.0, old_reference: float | None = None, sign: int = 1, temperature: float | numpy.ndarray = 298.15, pressure: float = 101325.0, volume: float | None = None):
759def change_reference_state(
760    new_reference: float = 1.0 / constants.liter,
761    old_reference: float | None = None,
762    sign: int = 1,
763    temperature: float | np.ndarray = 298.15,
764    pressure: float = constants.atm,
765    volume: float | None = None,
766):
767    r"""Calculate an additive entropy correction to a change in reference states.
768
769    .. math::
770        \Delta G_\text{corr} =
771            R T \ln \left( \frac{\chi_\text{new}}{\chi_\text{old}} \right)
772
773    The value returned can be directly multiplied by temperature and summed to
774    the old reference free energies to obtain free energies with respect to a
775    new reference. See notes below.
776
777    For instance, the concentration correction to Gibbs free energy for a
778    gas-to-liquid standard state change is simply
779    (:math:`c^\circ = \frac{\text{1 atm}}{R T}`),
780
781    .. math::
782        \Delta G_\text{conc} =
783            R T \ln \left( \frac{\text{1 M}}{c^\circ} \right)
784
785    Parameters
786    ----------
787    new_reference : array-like, optional
788        New reference state. Default value corresponds to 1 mol/liter.
789    old_reference : array-like, optional
790        Old reference state. Default value corresponds to the concentration of
791        an ideal gas at the given temperature and 1 atm.
792    sign : float, optional
793        Sign of the change in reference state. Default value is 1. This only
794        multiplies the final result.
795    temperature : array-like, optional
796        Absolute temperature in Kelvin.
797    pressure : array-like, optional
798        Reference gas pressure.
799    volume : float, optional
800        Molar volume.
801
802    Returns
803    -------
804    correction : array-like
805        Entropy correction in J/mol·K.
806
807    Notes
808    -----
809    This function can be used to add any entropy correction in the form above.
810    The only drawback is that, sometimes, those corrections are written with a
811    minus sign in front of them (this implies switching the roles of
812    `old_reference` and `new_reference`). The easiest way to accomplish this is
813    by using ``sign=-1`` or multiplying the result by ``-1``.
814
815    Examples
816    --------
817    By default, the correction returns a change in concentration from the gas
818    phase standard concentration to the solvated-state standard concentration:
819
820    >>> -rx.change_reference_state() / constants.calorie
821    -6.4
822    >>> 298.15 * rx.change_reference_state() / constants.kcal
823    1.89
824    >>> 273.15 * rx.change_reference_state(temperature=273.15) / constants.kcal
825    1.69
826
827    But this function can also be used to adjust symmetry effects from C1
828    calculations (symmetry number equals to one). For D7h, for instance, the
829    symmetry number is 14:
830
831    >>> -298.15 * rx.change_reference_state(14, 1) / constants.kcal
832    -1.56
833
834    >>> rx.change_reference_state(sign=-1) == -rx.change_reference_state()
835    True
836
837    """
838    temperature = np.asarray(temperature)
839
840    if old_reference is None:
841        if volume is None:
842            volume = molar_volume(temperature=temperature, pressure=pressure)
843        old_reference = 1.0 / volume
844
845    return sign * constants.R * np.log(new_reference / old_reference)

Calculate an additive entropy correction to a change in reference states.

$$\Delta G_\text{corr} = R T \ln \left( \frac{\chi_\text{new}}{\chi_\text{old}} \right)$$

The value returned can be directly multiplied by temperature and summed to the old reference free energies to obtain free energies with respect to a new reference. See notes below.

For instance, the concentration correction to Gibbs free energy for a gas-to-liquid standard state change is simply (\( c^\circ = \frac{\text{1 atm}}{R T} \)),

$$\Delta G_\text{conc} = R T \ln \left( \frac{\text{1 M}}{c^\circ} \right)$$

Parameters

new_reference : array-like, optional New reference state. Default value corresponds to 1 mol/liter. old_reference : array-like, optional Old reference state. Default value corresponds to the concentration of an ideal gas at the given temperature and 1 atm. sign : float, optional Sign of the change in reference state. Default value is 1. This only multiplies the final result. temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure. volume : float, optional Molar volume.

Returns

correction : array-like Entropy correction in J/mol·K.

Notes

This function can be used to add any entropy correction in the form above. The only drawback is that, sometimes, those corrections are written with a minus sign in front of them (this implies switching the roles of old_reference and new_reference). The easiest way to accomplish this is by using sign=-1 or multiplying the result by -1.

Examples

By default, the correction returns a change in concentration from the gas phase standard concentration to the solvated-state standard concentration:

>>> -rx.change_reference_state() / constants.calorie
-6.4
>>> 298.15 * rx.change_reference_state() / constants.kcal
1.89
>>> 273.15 * rx.change_reference_state(temperature=273.15) / constants.kcal
1.69

But this function can also be used to adjust symmetry effects from C1 calculations (symmetry number equals to one). For D7h, for instance, the symmetry number is 14:

>>> -298.15 * rx.change_reference_state(14, 1) / constants.kcal
-1.56
>>> rx.change_reference_state(sign=-1) == -rx.change_reference_state()
True
def get_bias( scheme, compounds, data, y0, tunneling='eckart', qrrho=True, temperature=298.15, pressure=101325.0, method='RK23', rtol=0.001, atol=1e-06):
578def get_bias(
579    scheme,
580    compounds,
581    data,
582    y0,
583    tunneling="eckart",
584    qrrho=True,
585    temperature=298.15,
586    pressure=constants.atm,
587    method="RK23",
588    rtol=1e-3,
589    atol=1e-6,
590):
591    r"""Estimate a energy bias for a given set of reference data points.
592
593    Parameters
594    ----------
595    scheme : Scheme
596        A descriptor of the reaction scheme.
597        Mostly likely, this comes from a parsed model input file.
598        See `overreact.io.parse_model`.
599    compounds : dict-like
600        A descriptor of the compounds.
601        Mostly likely, this comes from a parsed model input file.
602        See `overreact.io.parse_model`.
603    data : dict-like of array-like
604    y0: array-like
605    tunneling : str or None, optional
606        Choose between "eckart", "wigner" or None (or "none").
607    qrrho : bool or tuple-like, optional
608        Apply both the quasi-rigid rotor harmonic oscillator (QRRHO)
609        approximations of M. Head-Gordon and others (enthalpy correction, see
610        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r)) and S. Grimme (entropy correction, see
611        [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497)) on top of the classical RRHO.
612    temperature : array-like, optional
613        Absolute temperature in Kelvin.
614    pressure : array-like, optional
615        Reference gas pressure.
616    delta_freeenergies : array-like, optional
617        Use this instead of obtaining delta free energies from the compounds.
618    molecularity : array-like, optional
619        Reaction order, i.e., number of molecules that come together to react.
620        If set, this is used to calculate `delta_moles` for
621        `equilibrium_constant`, which effectively calculates a solution
622        equilibrium constant between reactants and the transition state for
623        gas phase data. You should set this to `None` if your free energies
624        were already adjusted for solution Gibbs free energies.
625    volume : float, optional
626        Molar volume.
627
628    Returns
629    -------
630    array-like
631
632    Examples
633    --------
634    >>> model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.jk")
635
636    The following are some estimates on actual atmospheric concentrations:
637
638    >>> y0 = [4.8120675684099e-5,
639    ...       2.8206357713029e-5,
640    ...       0.0,
641    ...       0.0,
642    ...       2.7426565371219e-5]
643    >>> data = {"t": [1.276472128376942246e-6,
644    ...               1.446535794555581743e-4,
645    ...               1.717069678525567564e-2],
646    ...         "CH3·": [9.694916853338366211e-9,
647    ...                  1.066033349343709026e-6,
648    ...                  2.632179124780495175e-5]}
649    >>> get_bias(model.scheme, model.compounds, data, y0) / constants.kcal
650    -1.4
651    """
652    max_time = np.max(data["t"])
653
654    def f(bias):
655        k = rx.get_k(
656            scheme,
657            compounds,
658            bias=bias,
659            tunneling=tunneling,
660            qrrho=qrrho,
661            temperature=temperature,
662            pressure=pressure,
663        )
664
665        # TODO(schneiderfelipe): support schemes with fixed concentrations
666        dydt = rx.get_dydt(scheme, k)
667        y, _ = rx.get_y(
668            dydt,
669            y0=y0,
670            method=method,
671            rtol=rtol,
672            atol=atol,
673            max_time=max_time,
674        )
675
676        yhat = y(data["t"])
677        return np.sum(
678            [
679                (yhat[i] - data[name]) ** 2
680                for (i, name) in enumerate(compounds)
681                if name in data
682            ],
683        )
684
685    res = minimize_scalar(f)
686    return res.x

Estimate a energy bias for a given set of reference data points.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed model input file. See parse_model. compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed model input file. See parse_model. data : dict-like of array-like y0: array-like tunneling : str or None, optional Choose between "eckart", "wigner" or None (or "none"). qrrho : bool or tuple-like, optional Apply both the quasi-rigid rotor harmonic oscillator (QRRHO) approximations of M. Head-Gordon and others (enthalpy correction, see J. Phys. Chem. C 2015, 119, 4, 1840-1850) and S. Grimme (entropy correction, see Theory. Chem. Eur. J., 2012, 18: 9955-9964) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure. delta_freeenergies : array-like, optional Use this instead of obtaining delta free energies from the compounds. molecularity : array-like, optional Reaction order, i.e., number of molecules that come together to react. If set, this is used to calculate delta_moles for equilibrium_constant, which effectively calculates a solution equilibrium constant between reactants and the transition state for gas phase data. You should set this to None if your free energies were already adjusted for solution Gibbs free energies. volume : float, optional Molar volume.

Returns

array-like

Examples

>>> model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.jk")

The following are some estimates on actual atmospheric concentrations:

>>> y0 = [4.8120675684099e-5,
...       2.8206357713029e-5,
...       0.0,
...       0.0,
...       2.7426565371219e-5]
>>> data = {"t": [1.276472128376942246e-6,
...               1.446535794555581743e-4,
...               1.717069678525567564e-2],
...         "CH3·": [9.694916853338366211e-9,
...                  1.066033349343709026e-6,
...                  2.632179124780495175e-5]}
>>> get_bias(model.scheme, model.compounds, data, y0) / constants.kcal
-1.4
def get_delta(transform, property):
572def get_delta(transform, property):
573    """Calculate deltas according to reactions.
574
575    Delta properties are differences in a property between the final and
576    initial state of a chemical transformation. They are calculated from
577    matrices representing the transformation and the absolute properties.
578    Transformation matrices are expected to have column-wise transformation
579    defined.
580
581    Very useful for the calculation of reaction and activation free energies
582    from absolute free energies of compounds. Matrices ``A`` and ``B`` of a
583    `Scheme` represent the transformations associated with reaction and
584    activation free energies, respectively.
585
586    Parameters
587    ----------
588    transform : array-like
589    property : array-like
590
591    Returns
592    -------
593    delta_property : array-like
594
595    Examples
596    --------
597    >>> get_delta([-1, 1], [-10, 10])
598    20
599
600    You must ensure the transformation is properly defined, as no test is made
601    to ensure, e.g., conservation of matter:
602
603    >>> get_delta([-1, 0], [-10, 20])
604    10
605
606    Normally, transformations are given as columns in a matrix:
607
608    >>> get_delta([[-1, -2],
609    ...            [ 1,  3]], [-5, 12])
610    array([17, 46])
611    """
612    return np.asarray(transform).T @ np.asarray(property)

Calculate deltas according to reactions.

Delta properties are differences in a property between the final and initial state of a chemical transformation. They are calculated from matrices representing the transformation and the absolute properties. Transformation matrices are expected to have column-wise transformation defined.

Very useful for the calculation of reaction and activation free energies from absolute free energies of compounds. Matrices A and B of a Scheme represent the transformations associated with reaction and activation free energies, respectively.

Parameters

transform : array-like property : array-like

Returns

delta_property : array-like

Examples

>>> get_delta([-1, 1], [-10, 10])
20

You must ensure the transformation is properly defined, as no test is made to ensure, e.g., conservation of matter:

>>> get_delta([-1, 0], [-10, 20])
10

Normally, transformations are given as columns in a matrix:

>>> get_delta([[-1, -2],
...            [ 1,  3]], [-5, 12])
array([17, 46])
def get_dydt(scheme, k, ef=8.246247365264608):
200def get_dydt(scheme, k, ef=EF):
201    """Generate a rate function that models a reaction scheme.
202
203    Parameters
204    ----------
205    scheme : Scheme
206        A descriptor of the reaction scheme.
207        Mostly likely, this comes from a parsed model input file.
208        See `overreact.io.parse_model`.
209    k : array-like
210        Reaction rate constant(s). Units match the concentration units given to
211        the returned function ``dydt``.
212    ef : float, optional
213        Equilibrium factor. This is a parameter that can be used to scale the
214        reaction rates associated to half-equilibrium reactions such that they
215        are faster than the other reactions.
216
217    Returns
218    -------
219    dydt : callable
220        Reaction rate function. The actual reaction rate constants employed
221        are stored in the attribute `k` of the returned function. If JAX is
222        available, the attribute `jac` will hold the Jacobian function of
223        `dydt`.
224
225    Notes
226    -----
227    The returned function is suited to be used by ODE solvers such as
228    `scipy.integrate.solve_ivp` or the older `scipy.integrate.ode` (see
229    examples below). This is actually what the function `get_y` from the
230    current module does.
231
232    Examples
233    --------
234    >>> import numpy as np
235    >>> import overreact as rx
236
237    >>> scheme = rx.parse_reactions("A <=> B")
238    >>> dydt = get_dydt(scheme, np.array([1, 1]))
239    >>> dydt(0.0, np.array([1., 1.]))
240    Array([0., 0.], ...)
241
242    If available, JAX is used for JIT compilation. This will make `dydt`
243    complain if given lists instead of numpy arrays. So stick to the safer,
244    faster side as above.
245
246    The actually used reaction rate constants can be inspected with the `k`
247    attribute of `dydt`:
248
249    >>> dydt.k
250    Array([1., 1.], ...)
251
252    If JAX is available, the Jacobian function will be available as
253    `dydt.jac`:
254
255    >>> dydt.jac(0.0, np.array([1., 1.]))
256    Array([[-1.,  1.],
257           [ 1., -1.]], ...)
258
259    """
260    scheme = rx.core._check_scheme(scheme)
261    A = jnp.asarray(scheme.A)
262    M = jnp.where(A > 0, 0, -A).T
263    k_adj = _adjust_k(scheme, k, ef=ef)
264
265    def _dydt(_t, y):
266        r = k_adj * jnp.prod(jnp.power(y, M), axis=1)
267        return jnp.dot(A, r)
268
269    if _found_jax:
270        # Using JAX for JIT compilation is much faster.
271        _dydt = jit(_dydt)
272
273        # NOTE(schneiderfelipe): the following function is defined
274        # such that _jac(t, y)[i, j] == d f_i / d y_j,
275        # with shape of (n_compounds, n_compounds).
276        def _jac(t, y):
277            logger.warning(f"\x1b[A@t = \x1b[94m{t:10.3f} \x1b[ms\x1b[K")
278            return jacfwd(lambda _y: _dydt(t, _y))(y)
279
280        _dydt.jac = _jac
281
282    _dydt.k = k_adj
283    return _dydt

Generate a rate function that models a reaction scheme.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed model input file. See parse_model. k : array-like Reaction rate constant(s). Units match the concentration units given to the returned function dydt. ef : float, optional Equilibrium factor. This is a parameter that can be used to scale the reaction rates associated to half-equilibrium reactions such that they are faster than the other reactions.

Returns

dydt : callable Reaction rate function. The actual reaction rate constants employed are stored in the attribute k of the returned function. If JAX is available, the attribute jac will hold the Jacobian function of dydt.

Notes

The returned function is suited to be used by ODE solvers such as scipy.integrate.solve_ivp or the older scipy.integrate.ode (see examples below). This is actually what the function get_y from the current module does.

Examples

>>> import numpy as np
>>> import overreact as rx
>>> scheme = rx.parse_reactions("A <=> B")
>>> dydt = get_dydt(scheme, np.array([1, 1]))
>>> dydt(0.0, np.array([1., 1.]))
Array([0., 0.], ...)

If available, JAX is used for JIT compilation. This will make dydt complain if given lists instead of numpy arrays. So stick to the safer, faster side as above.

The actually used reaction rate constants can be inspected with the k attribute of dydt:

>>> dydt.k
Array([1., 1.], ...)

If JAX is available, the Jacobian function will be available as dydt.jac:

>>> dydt.jac(0.0, np.array([1., 1.]))
Array([[-1.,  1.],
       [ 1., -1.]], ...)
def get_enthalpies(compounds: dict, qrrho: bool = True, temperature: float = 298.15):
 97def get_enthalpies(
 98    compounds: dict,
 99    qrrho: bool = True,
100    temperature: float = 298.15,
101):
102    """Obtain enthalpies for compounds at a given temperature.
103
104    Parameters
105    ----------
106    compounds : dict-like
107        A descriptor of the compounds.
108        Mostly likely, this comes from a parsed input file.
109        See `overreact.io.parse_model`.
110    qrrho : bool, optional
111        Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of
112        M. Head-Gordon and others (see
113        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r))
114        on top of the classical RRHO.
115    temperature : array-like, optional
116        Absolute temperature in Kelvin.
117
118    Returns
119    -------
120    array-like
121
122    Examples
123    --------
124    >>> import overreact as rx
125    >>> from overreact import _constants as constants
126    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
127    >>> enthalpies = get_enthalpies(model.compounds)
128    >>> (enthalpies - enthalpies.min()) / constants.kcal
129    array([0. , 2.20053981])
130
131    The enthalpies at absolute zero can easily be obtained (this is used,
132    e.g., in the calculation of the Eckart tunneling coefficient, see
133    `overreact.tunnel.eckart`). We can use this to calculate, for instance,
134    the thermal contributions to the enthalpy:
135
136    >>> zero_enthalpies = get_enthalpies(model.compounds, temperature=0)
137    >>> (enthalpies - zero_enthalpies) / constants.kcal
138    array([2.78, 2.50])
139    """
140    compounds = rx.io._check_compounds(compounds)
141    enthalpies = []
142    for name in compounds:
143        logger.info(f"calculate enthalpy: {name}")
144
145        # TODO(schneiderfelipe): inertia might benefit from caching
146        moments, _, _ = coords.inertia(
147            compounds[name].atommasses,
148            compounds[name].atomcoords,
149        )
150
151        enthalpy = rx.thermo.calc_enthalpy(
152            energy=compounds[name].energy,
153            degeneracy=compounds[name].mult,
154            moments=moments,
155            vibfreqs=compounds[name].vibfreqs,
156            qrrho=qrrho,
157            temperature=temperature,
158        )
159        enthalpies.append(enthalpy)
160    return np.array(enthalpies)

Obtain enthalpies for compounds at a given temperature.

Parameters

compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. qrrho : bool, optional Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of M. Head-Gordon and others (see J. Phys. Chem. C 2015, 119, 4, 1840-1850) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin.

Returns

array-like

Examples

>>> import overreact as rx
>>> from overreact import _constants as constants
>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> enthalpies = get_enthalpies(model.compounds)
>>> (enthalpies - enthalpies.min()) / constants.kcal
array([0. , 2.20053981])

The enthalpies at absolute zero can easily be obtained (this is used, e.g., in the calculation of the Eckart tunneling coefficient, see overreact.tunnel.eckart). We can use this to calculate, for instance, the thermal contributions to the enthalpy:

>>> zero_enthalpies = get_enthalpies(model.compounds, temperature=0)
>>> (enthalpies - zero_enthalpies) / constants.kcal
array([2.78, 2.50])
def get_entropies( compounds: dict, environment: str | None = None, method: str = 'standard', qrrho: bool = True, temperature: float = 298.15, pressure: float = 101325.0):
163def get_entropies(
164    compounds: dict,
165    environment: str | None = None,
166    method: str = "standard",
167    qrrho: bool = True,
168    temperature: float = 298.15,
169    pressure: float = constants.atm,
170):
171    """Obtain entropies for compounds at a given temperature and pressure.
172
173    Parameters
174    ----------
175    compounds : dict-like
176        A descriptor of the compounds.
177        Mostly likely, this comes from a parsed input file.
178        See `overreact.io.parse_model`.
179    environment : str or None, optional
180        Choose between "gas" and a solvent. This is chosen for you by default,
181        based on the names of each compound (e.g. `A(g)` or `A` is gas,
182        `A(w)` or `A(...)` is solvated). In case this is given, all compounds
183        will have the same behavior.
184    method : str, optional
185        This is a placeholder for future functionality.
186        There are plans to implement more sophisticated methods for calculating
187        entropies such as in
188        [*Phys. Chem. Chem. Phys.*, **2019**, 21, 18920-18929](https://doi.org/10.1039/C9CP03226F)
189        and
190        [*J. Chem. Theory Comput.* **2019**, 15, 5, 3204-3214](https://doi.org/10.1021/acs.jctc.9b00214).
191        Head over to the
192        [discussions](https://github.com/geem-lab/overreact/discussions) if
193        you're interested and would like to contribute.
194        Leave this as "standard" for now.
195    qrrho : bool, optional
196        Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of
197        S. Grimme (see
198        [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497))
199        on top of the classical RRHO.
200    temperature : array-like, optional
201        Absolute temperature in Kelvin.
202    pressure : array-like, optional
203        Reference gas pressure.
204
205    Returns
206    -------
207    array-like
208
209    Examples
210    --------
211    >>> import overreact as rx
212    >>> from overreact import _constants as constants
213    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
214    >>> entropies = get_entropies(model.compounds)
215    >>> (entropies - entropies.min()) / constants.calorie
216    array([1.4, 0. ])
217
218    You can consider all compounds as solvated if you want:
219
220    >>> sol_entropies = get_entropies(model.compounds, environment="solvent")
221    >>> (sol_entropies - entropies) / constants.calorie
222    array([-6.35360874, -6.35360874])
223    """
224    compounds = rx.io._check_compounds(compounds)
225    entropies = []
226    for name in compounds:
227        logger.info(f"calculate entropy: {name}")
228
229        if "point_group" in compounds[name]:
230            point_group = compounds[name].point_group
231        else:
232            point_group = coords.find_point_group(
233                compounds[name].atommasses,
234                compounds[name].atomcoords,
235            )
236        symmetry_number = coords.symmetry_number(point_group)
237
238        # TODO(schneiderfelipe): inertia might benefit from caching
239        moments, _, _ = coords.inertia(
240            compounds[name].atommasses,
241            compounds[name].atomcoords,
242        )
243
244        if environment is None:
245            environment = rx.core._get_environment(name)
246        entropy = rx.thermo.calc_entropy(
247            atommasses=compounds[name].atommasses,
248            atomcoords=compounds[name].atomcoords,
249            energy=compounds[name].energy,
250            degeneracy=compounds[name].mult,
251            moments=moments,
252            symmetry_number=symmetry_number,
253            vibfreqs=compounds[name].vibfreqs,
254            environment=environment,
255            method=method,
256            qrrho=qrrho,
257            temperature=temperature,
258            pressure=pressure,
259        )
260
261        if compounds[name].symmetry is not None:
262            # The negative sign here seems correct. See equations (9) and (10)
263            # of doi:10.1002/qua.25686.
264            entropy -= rx.change_reference_state(
265                compounds[name].symmetry,
266                1,
267                temperature=temperature,
268                pressure=pressure,
269            )
270
271        entropies.append(entropy)
272    return np.array(entropies)

Obtain entropies for compounds at a given temperature and pressure.

Parameters

compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. environment : str or None, optional Choose between "gas" and a solvent. This is chosen for you by default, based on the names of each compound (e.g. A(g) or A is gas, A(w) or A(...) is solvated). In case this is given, all compounds will have the same behavior. method : str, optional This is a placeholder for future functionality. There are plans to implement more sophisticated methods for calculating entropies such as in Phys. Chem. Chem. Phys., 2019, 21, 18920-18929 and J. Chem. Theory Comput. 2019, 15, 5, 3204-3214. Head over to the discussions if you're interested and would like to contribute. Leave this as "standard" for now. qrrho : bool, optional Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of S. Grimme (see Theory. Chem. Eur. J., 2012, 18: 9955-9964) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure.

Returns

array-like

Examples

>>> import overreact as rx
>>> from overreact import _constants as constants
>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> entropies = get_entropies(model.compounds)
>>> (entropies - entropies.min()) / constants.calorie
array([1.4, 0. ])

You can consider all compounds as solvated if you want:

>>> sol_entropies = get_entropies(model.compounds, environment="solvent")
>>> (sol_entropies - entropies) / constants.calorie
array([-6.35360874, -6.35360874])
def get_fixed_scheme(scheme, k, fixed_y0):
365def get_fixed_scheme(scheme, k, fixed_y0):
366    """Generate an alternative scheme with some concentrations fixed.
367
368    This function returns data that allow the microkinetic simulation of a
369    reaction network under constraints, namely when some compounds have fixed
370    concentrations. This works by 1. removing all references to the fixed
371    compounds and by 2. properly multiplying the reaction rate constants by
372    the respective concentrations.
373
374    Parameters
375    ----------
376    scheme : Scheme
377        A descriptor of the reaction scheme.
378        Mostly likely, this comes from a parsed model input file.
379        See `overreact.io.parse_model`.
380    k : array-like
381        Reaction rate constant(s). Units match the concentration units given to
382        the returned function ``dydt``.
383    fixed_y0 : dict-like
384        Fixed initial state. Units match the concentration units given to
385        the returned function ``dydt``.
386
387    Returns
388    -------
389    scheme : Scheme
390        Associated reaction scheme with all references to fixed compounds
391        removed.
392    k : array-like
393        Associated (effective) reaction rate constants that model the fixed
394        concentrations.
395
396    Notes
397    -----
398    Keep in mind that when a compound get its concentration fixed, the
399    reaction scheme no longer conserves matter. You can think of it as
400    reacting close to an infinite source of the compound, but it accumulates
401    in the milleu at the given concentration.
402
403    Examples
404    --------
405    >>> import numpy as np
406    >>> import overreact as rx
407
408    Equilibria under a specific pH can be easily modeled:
409
410    >>> pH = 7
411    >>> scheme = rx.parse_reactions("AH <=> A- + H+")
412    >>> k = np.array([1, 1])
413    >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
414    >>> scheme
415    Scheme(compounds=('AH', 'A-'),
416           reactions=('AH -> A-',
417                      'A- -> AH'),
418           is_half_equilibrium=(True, True),
419           A=((-1.0, 1.0),
420              (1.0, -1.0)),
421           B=((-1.0, 0.0),
422              (1.0, 0.0)))
423    >>> k
424    array([1.e+00, 1.e-07])
425
426    It is also possible to model the fixed activity of a solvent, for
427    instance:
428
429    >>> scheme = rx.parse_reactions("A + 2H2O -> B")
430    >>> k = np.array([1.0])
431    >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H2O": 55.6})
432    >>> scheme
433    Scheme(compounds=('A', 'B'),
434           reactions=('A -> B',),
435           is_half_equilibrium=(False,),
436           A=((-1.0,),
437              (1.0,)),
438           B=((-1.0,),
439              (1.0,)))
440    >>> k
441    array([3091.36])
442
443    Multiple reactions work fine, see both examples below:
444
445    >>> pH = 12
446    >>> scheme = rx.parse_reactions("B <- AH <=> A- + H+")
447    >>> k = np.array([10.0, 1, 1])
448    >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
449    >>> scheme
450    Scheme(compounds=('AH', 'B', 'A-'),
451           reactions=('AH -> B',
452                      'AH -> A-',
453                      'A- -> AH'),
454           is_half_equilibrium=(False, True, True),
455           A=((-1.0, -1.0, 1.0),
456              (1.0, 0.0, 0.0),
457              (0.0, 1.0, -1.0)),
458           B=((-1.0, -1.0, 0.0),
459              (1.0, 0.0, 0.0),
460              (0.0, 1.0, 0.0)))
461    >>> k
462    array([1.e+01, 1.e+00, 1.e-12])
463
464    >>> pH = 2
465    >>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"])
466    >>> k = np.array([1, 1, 2, 2])
467    >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
468    >>> scheme
469    Scheme(compounds=('AH', 'A-', 'B-', 'BH'),
470           reactions=('AH -> A-',
471                      'A- -> AH',
472                      'B- -> BH',
473                      'BH -> B-'),
474           is_half_equilibrium=(True, True, True, True),
475           A=((-1.0, 1.0, 0.0, 0.0),
476              (1.0, -1.0, 0.0, 0.0),
477              (0.0, 0.0, -1.0, 1.0),
478              (0.0, 0.0, 1.0, -1.0)),
479           B=((-1.0, 0.0, 0.0, 0.0),
480              (1.0, 0.0, 0.0, 0.0),
481              (0.0, 0.0, -1.0, 0.0),
482              (0.0, 0.0, 1.0, 0.0)))
483    >>> k
484    array([1. , 0.01, 0.02, 2. ])
485
486    Multiple fixed compounds also work fine:
487
488    >>> pH = 6
489    >>> scheme = rx.parse_reactions("A + H2O -> B <=> B- + H+")
490    >>> k = np.array([1.0, 100.0, 2.0])
491    >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH, "H2O": 55.6})
492    >>> scheme
493    Scheme(compounds=('A', 'B', 'B-'),
494           reactions=('A -> B',
495                      'B -> B-',
496                      'B- -> B'),
497           is_half_equilibrium=(False, True, True),
498           A=((-1.0, 0.0, 0.0),
499              (1.0, -1.0, 1.0),
500              (0.0, 1.0, -1.0)),
501           B=((-1.0, 0.0, 0.0),
502              (1.0, -1.0, 0.0),
503              (0.0, 1.0, 0.0)))
504    >>> k
505    array([5.56e+01, 1.00e+02, 2.00e-06])
506
507    This function is a no-op if `fixed_y0` is empty, which is very important
508    for overall code consistency:
509
510    >>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"])
511    >>> k = np.array([1, 1, 2, 2])
512    >>> new_scheme, new_k = rx.get_fixed_scheme(scheme, k, {})
513    >>> new_scheme == scheme
514    True
515    >>> np.allclose(new_k, k)
516    True
517
518    """
519    new_k = np.asarray(k, dtype=np.float64).copy()
520    new_reactions = []
521    for i, (reaction, is_half_equilibrium) in enumerate(
522        zip(scheme.reactions, scheme.is_half_equilibrium),
523    ):
524        for reactants, products, _ in rx.core._parse_reactions(
525            reaction,
526        ):
527            new_reactants = tuple(
528                (coeff, compound)
529                for (coeff, compound) in reactants
530                if compound not in fixed_y0
531            )
532            new_products = tuple(
533                (coeff, compound)
534                for (coeff, compound) in products
535                if compound not in fixed_y0
536            )
537
538            for fixed_compound in fixed_y0:
539                for coeff, compound in reactants:
540                    if fixed_compound == compound:
541                        new_k[i] *= fixed_y0[fixed_compound] ** coeff
542
543            new_reactions.append((new_reactants, new_products, is_half_equilibrium))
544
545    new_reactions = tuple(r for r in rx.core._unparse_reactions(new_reactions))
546    new_is_half_equilibrium = scheme.is_half_equilibrium
547
548    new_A = []
549    new_B = []
550    new_compounds = []
551    for compound, row_A, row_B in zip(
552        scheme.compounds,
553        scheme.A,
554        scheme.B,
555    ):
556        if compound not in fixed_y0:
557            new_compounds.append(compound)
558            new_A.append(row_A)
559            new_B.append(row_B)
560
561    new_compounds = tuple(new_compounds)
562    new_A = tuple(new_A)
563    new_B = tuple(new_B)
564
565    return (
566        rx.core.Scheme(
567            compounds=new_compounds,
568            reactions=new_reactions,
569            is_half_equilibrium=new_is_half_equilibrium,
570            A=new_A,
571            B=new_B,
572        ),
573        new_k,
574    )

Generate an alternative scheme with some concentrations fixed.

This function returns data that allow the microkinetic simulation of a reaction network under constraints, namely when some compounds have fixed concentrations. This works by 1. removing all references to the fixed compounds and by 2. properly multiplying the reaction rate constants by the respective concentrations.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed model input file. See parse_model. k : array-like Reaction rate constant(s). Units match the concentration units given to the returned function dydt. fixed_y0 : dict-like Fixed initial state. Units match the concentration units given to the returned function dydt.

Returns

scheme : Scheme Associated reaction scheme with all references to fixed compounds removed. k : array-like Associated (effective) reaction rate constants that model the fixed concentrations.

Notes

Keep in mind that when a compound get its concentration fixed, the reaction scheme no longer conserves matter. You can think of it as reacting close to an infinite source of the compound, but it accumulates in the milleu at the given concentration.

Examples

>>> import numpy as np
>>> import overreact as rx

Equilibria under a specific pH can be easily modeled:

>>> pH = 7
>>> scheme = rx.parse_reactions("AH <=> A- + H+")
>>> k = np.array([1, 1])
>>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
>>> scheme
Scheme(compounds=('AH', 'A-'),
       reactions=('AH -> A-',
                  'A- -> AH'),
       is_half_equilibrium=(True, True),
       A=((-1.0, 1.0),
          (1.0, -1.0)),
       B=((-1.0, 0.0),
          (1.0, 0.0)))
>>> k
array([1.e+00, 1.e-07])

It is also possible to model the fixed activity of a solvent, for instance:

>>> scheme = rx.parse_reactions("A + 2H2O -> B")
>>> k = np.array([1.0])
>>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H2O": 55.6})
>>> scheme
Scheme(compounds=('A', 'B'),
       reactions=('A -> B',),
       is_half_equilibrium=(False,),
       A=((-1.0,),
          (1.0,)),
       B=((-1.0,),
          (1.0,)))
>>> k
array([3091.36])

Multiple reactions work fine, see both examples below:

>>> pH = 12
>>> scheme = rx.parse_reactions("B <- AH <=> A- + H+")
>>> k = np.array([10.0, 1, 1])
>>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
>>> scheme
Scheme(compounds=('AH', 'B', 'A-'),
       reactions=('AH -> B',
                  'AH -> A-',
                  'A- -> AH'),
       is_half_equilibrium=(False, True, True),
       A=((-1.0, -1.0, 1.0),
          (1.0, 0.0, 0.0),
          (0.0, 1.0, -1.0)),
       B=((-1.0, -1.0, 0.0),
          (1.0, 0.0, 0.0),
          (0.0, 1.0, 0.0)))
>>> k
array([1.e+01, 1.e+00, 1.e-12])
>>> pH = 2
>>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"])
>>> k = np.array([1, 1, 2, 2])
>>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH})
>>> scheme
Scheme(compounds=('AH', 'A-', 'B-', 'BH'),
       reactions=('AH -> A-',
                  'A- -> AH',
                  'B- -> BH',
                  'BH -> B-'),
       is_half_equilibrium=(True, True, True, True),
       A=((-1.0, 1.0, 0.0, 0.0),
          (1.0, -1.0, 0.0, 0.0),
          (0.0, 0.0, -1.0, 1.0),
          (0.0, 0.0, 1.0, -1.0)),
       B=((-1.0, 0.0, 0.0, 0.0),
          (1.0, 0.0, 0.0, 0.0),
          (0.0, 0.0, -1.0, 0.0),
          (0.0, 0.0, 1.0, 0.0)))
>>> k
array([1. , 0.01, 0.02, 2. ])

Multiple fixed compounds also work fine:

>>> pH = 6
>>> scheme = rx.parse_reactions("A + H2O -> B <=> B- + H+")
>>> k = np.array([1.0, 100.0, 2.0])
>>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH, "H2O": 55.6})
>>> scheme
Scheme(compounds=('A', 'B', 'B-'),
       reactions=('A -> B',
                  'B -> B-',
                  'B- -> B'),
       is_half_equilibrium=(False, True, True),
       A=((-1.0, 0.0, 0.0),
          (1.0, -1.0, 1.0),
          (0.0, 1.0, -1.0)),
       B=((-1.0, 0.0, 0.0),
          (1.0, -1.0, 0.0),
          (0.0, 1.0, 0.0)))
>>> k
array([5.56e+01, 1.00e+02, 2.00e-06])

This function is a no-op if fixed_y0 is empty, which is very important for overall code consistency:

>>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"])
>>> k = np.array([1, 1, 2, 2])
>>> new_scheme, new_k = rx.get_fixed_scheme(scheme, k, {})
>>> new_scheme == scheme
True
>>> np.allclose(new_k, k)
True
def get_freeenergies( compounds: dict, bias: float = 0.0, environment: str | None = None, method: str = 'standard', qrrho: bool | tuple[bool, bool] = True, temperature: float = 298.15, pressure: float = 101325.0):
321def get_freeenergies(
322    compounds: dict,
323    bias: float = 0.0,
324    environment: str | None = None,
325    method: str = "standard",
326    qrrho: bool | tuple[bool, bool] = True,
327    temperature: float = 298.15,
328    pressure: float = constants.atm,
329):
330    """Obtain free energies for compounds at a given temperature and pressure.
331
332    Parameters
333    ----------
334    compounds : dict-like
335        A descriptor of the compounds.
336        Mostly likely, this comes from a parsed input file.
337        See `overreact.io.parse_model`.
338    bias : array-like, optional
339        Energy to be added to free energies.
340    environment : str or None, optional
341        Choose between "gas" and a solvent. This is chosen for you by default,
342        based on the names of each compound. If given, all compounds will
343        have the same behavior.
344    method : str, optional
345        This is a placeholder for future functionality.
346        There are plans to implement more sophisticated methods for calculating
347        entropies such as in
348        [*Phys. Chem. Chem. Phys.*, **2019**, 21, 18920-18929](https://doi.org/10.1039/C9CP03226F)
349        and
350        [*J. Chem. Theory Comput.* **2019**, 15, 5, 3204-3214](https://doi.org/10.1021/acs.jctc.9b00214).
351        Head over to the
352        [discussions](https://github.com/geem-lab/overreact/discussions) if
353        you're interested and would like to contribute.
354        Leave this as "standard" for now.
355    qrrho : bool or tuple-like, optional
356        Apply both the quasi-rigid rotor harmonic oscillator (QRRHO)
357        approximations of M. Head-Gordon and others (enthalpy correction, see
358        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r))
359        and S. Grimme (entropy correction, see
360        [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497))
361        on top of the classical RRHO.
362    temperature : array-like, optional
363        Absolute temperature in Kelvin.
364    pressure : array-like, optional
365        Reference gas pressure.
366
367    Returns
368    -------
369    array-like
370
371    Examples
372    --------
373    >>> import overreact as rx
374    >>> from overreact import _constants as constants
375    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
376    >>> freeenergies = get_freeenergies(model.compounds, qrrho=(False, True))
377    >>> (freeenergies - freeenergies.min()) / constants.kcal
378    array([0. , 2.62281461])
379    >>> freeenergies = get_freeenergies(model.compounds)
380    >>> (freeenergies - freeenergies.min()) / constants.kcal
381    array([0. , 2.62862818])
382
383    You can consider all compounds as solvated if you want:
384
385    >>> sol_freeenergies = get_freeenergies(model.compounds, environment="solvent")
386    >>> (sol_freeenergies - freeenergies) / constants.kcal
387    array([1.89432845, 1.89432845])
388
389    You can set a simple energy bias, either as a constant or compound-wise:
390
391    >>> get_freeenergies(model.compounds, bias=1.0) - freeenergies
392    array([1., 1.])
393    >>> get_freeenergies(model.compounds, bias=-1.0) - freeenergies
394    array([-1., -1.])
395    >>> get_freeenergies(model.compounds, bias=[1.0, -1.0]) - freeenergies
396    array([ 1., -1.])
397    """
398    qrrho_enthalpy, qrrho_entropy = _check_qrrho(qrrho)
399    enthalpies = get_enthalpies(
400        compounds,
401        qrrho=qrrho_enthalpy,
402        temperature=temperature,
403    )
404    entropies = get_entropies(
405        compounds,
406        environment=environment,
407        method=method,
408        qrrho=qrrho_entropy,
409        temperature=temperature,
410        pressure=pressure,
411    )
412    # TODO(schneiderfelipe): log the contribution of bias
413    return enthalpies - temperature * entropies + np.asarray(bias)

Obtain free energies for compounds at a given temperature and pressure.

Parameters

compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. bias : array-like, optional Energy to be added to free energies. environment : str or None, optional Choose between "gas" and a solvent. This is chosen for you by default, based on the names of each compound. If given, all compounds will have the same behavior. method : str, optional This is a placeholder for future functionality. There are plans to implement more sophisticated methods for calculating entropies such as in Phys. Chem. Chem. Phys., 2019, 21, 18920-18929 and J. Chem. Theory Comput. 2019, 15, 5, 3204-3214. Head over to the discussions if you're interested and would like to contribute. Leave this as "standard" for now. qrrho : bool or tuple-like, optional Apply both the quasi-rigid rotor harmonic oscillator (QRRHO) approximations of M. Head-Gordon and others (enthalpy correction, see J. Phys. Chem. C 2015, 119, 4, 1840-1850) and S. Grimme (entropy correction, see Theory. Chem. Eur. J., 2012, 18: 9955-9964) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure.

Returns

array-like

Examples

>>> import overreact as rx
>>> from overreact import _constants as constants
>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> freeenergies = get_freeenergies(model.compounds, qrrho=(False, True))
>>> (freeenergies - freeenergies.min()) / constants.kcal
array([0. , 2.62281461])
>>> freeenergies = get_freeenergies(model.compounds)
>>> (freeenergies - freeenergies.min()) / constants.kcal
array([0. , 2.62862818])

You can consider all compounds as solvated if you want:

>>> sol_freeenergies = get_freeenergies(model.compounds, environment="solvent")
>>> (sol_freeenergies - freeenergies) / constants.kcal
array([1.89432845, 1.89432845])

You can set a simple energy bias, either as a constant or compound-wise:

>>> get_freeenergies(model.compounds, bias=1.0) - freeenergies
array([1., 1.])
>>> get_freeenergies(model.compounds, bias=-1.0) - freeenergies
array([-1., -1.])
>>> get_freeenergies(model.compounds, bias=[1.0, -1.0]) - freeenergies
array([ 1., -1.])
def get_internal_energies(compounds: dict, qrrho: bool = True, temperature: float = 298.15):
39def get_internal_energies(
40    compounds: dict,
41    qrrho: bool = True,
42    temperature: float = 298.15,
43):
44    """Obtain internal energies for compounds at a given temperature.
45
46    Parameters
47    ----------
48    compounds : dict-like
49        A descriptor of the compounds.
50        Mostly likely, this comes from a parsed input file.
51        See `overreact.io.parse_model`.
52    qrrho : bool, optional
53        Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of
54        M. Head-Gordon and others (see
55        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r))
56        on top of the classical RRHO.
57    temperature : array-like, optional
58        Absolute temperature in Kelvin.
59
60    Returns
61    -------
62    array-like
63
64    Examples
65    --------
66    >>> import overreact as rx
67    >>> from overreact import _constants as constants
68    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
69    >>> internal_energies = get_internal_energies(model.compounds)
70    >>> (internal_energies - internal_energies.min()) / constants.kcal
71    array([0. , 2.20053981])
72
73    """
74    compounds = rx.io._check_compounds(compounds)
75    internal_energies = []
76    for name in compounds:
77        logger.info(f"calculate internal energy: {name}")
78
79        # TODO(schneiderfelipe): inertia might benefit from caching
80        moments, _, _ = coords.inertia(
81            compounds[name].atommasses,
82            compounds[name].atomcoords,
83        )
84
85        internal_energy = rx.thermo.calc_internal_energy(
86            energy=compounds[name].energy,
87            degeneracy=compounds[name].mult,
88            moments=moments,
89            vibfreqs=compounds[name].vibfreqs,
90            qrrho=qrrho,
91            temperature=temperature,
92        )
93        internal_energies.append(internal_energy)
94    return np.array(internal_energies)

Obtain internal energies for compounds at a given temperature.

Parameters

compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. qrrho : bool, optional Apply the quasi-rigid rotor harmonic oscillator (QRRHO) approximation of M. Head-Gordon and others (see J. Phys. Chem. C 2015, 119, 4, 1840-1850) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin.

Returns

array-like

Examples

>>> import overreact as rx
>>> from overreact import _constants as constants
>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> internal_energies = get_internal_energies(model.compounds)
>>> (internal_energies - internal_energies.min()) / constants.kcal
array([0. , 2.20053981])
def get_k( scheme: Scheme, compounds: dict | None = None, bias: float = 0.0, tunneling: str = 'eckart', qrrho: bool | tuple[bool, bool] = True, scale: str = 'l mol-1 s-1', temperature: float = 298.15, pressure: float = 101325.0, delta_freeenergies: float | None = None, molecularity: float | None = None, volume: float | None = None) -> float:
416def get_k(
417    scheme: Scheme,
418    compounds: dict | None = None,
419    bias: float = 0.0,
420    tunneling: str = "eckart",
421    qrrho: bool | tuple[bool, bool] = True,
422    scale: str = "l mol-1 s-1",
423    temperature: float = 298.15,
424    pressure: float = constants.atm,
425    delta_freeenergies: float | None = None,
426    molecularity: float | None = None,
427    volume: float | None = None,
428) -> float:
429    r"""Obtain reaction rate constants for a given reaction scheme.
430
431    Parameters
432    ----------
433    scheme : Scheme
434        A descriptor of the reaction scheme.
435        Mostly likely, this comes from a parsed input file.
436        See `overreact.io.parse_model`.
437    compounds : dict-like, optional
438        A descriptor of the compounds.
439        Mostly likely, this comes from a parsed input file.
440        See `overreact.io.parse_model`.
441    bias : array-like, optional
442        Energy to be added to free energies.
443    tunneling : str or None, optional
444        Choose between "eckart", "wigner" or None (or "none").
445    qrrho : bool or tuple-like, optional
446        Apply both the quasi-rigid rotor harmonic oscillator (QRRHO)
447        approximations of M. Head-Gordonand others (enthalpy correction, see
448        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r))
449        and S. Grimme (entropy correction, see
450        [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497))
451        on top of the classical RRHO.
452    scale : str, optional
453        Reaction rate units. Possible values are "cm3 mol-1 s-1",
454        "l mol-1 s-1", "m3 mol-1 s-1", "cm3 particle-1 s-1", "mmHg-1 s-1" and
455        "atm-1 s-1".
456    temperature : array-like, optional
457        Absolute temperature in Kelvin.
458    pressure : array-like, optional
459        Reference gas pressure.
460    delta_freeenergies : array-like, optional
461        Use this instead of obtaining delta free energies from the compounds.
462    molecularity : array-like, optional
463        Reaction order, i.e., number of molecules that come together to react.
464        If set, this is used to calculate `delta_moles` for
465        `overreact.thermo.equilibrium_constant`, which effectively calculates a solution
466        equilibrium constant between reactants and the transition state for
467        gas phase data. You should set this to `None` if your free energies
468        were already adjusted for solution Gibbs free energies.
469    volume : float, optional
470        Molar volume.
471
472    Returns
473    -------
474    array-like
475
476    Notes
477    -----
478    Some symbols are accepted as alternatives in "scale": "M-1", "ml" and
479    "torr-1" are understood as "l mol-1", "cm3" and "mmHg-1", respectively.
480
481    Examples
482    --------
483    Below is an example of an estimate for the rate of methyl rotation in
484    ethane (a trivial attempt to reproduce
485    [*Science*, **2006**, 313, 5795, 1951-1955](https://doi.org/10.1126/science.1132178)).
486    **How many turns it does per second?**
487
488    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
489    >>> get_k(model.scheme, model.compounds)
490    array([8.16e+10])
491    >>> get_k(model.scheme, model.compounds, qrrho=(False, True))
492    array([8.24968117e+10])
493    >>> get_k(model.scheme, model.compounds, qrrho=False)
494    array([8.26909266e+10])
495    >>> get_k(model.scheme, model.compounds, tunneling="wigner")
496    array([7.99e+10])
497    >>> get_k(model.scheme, model.compounds, tunneling=None)
498    array([7.35e+10])
499
500    The calculated value is off by less than 2% from the experimental value
501    (:math:`\frac{1}{12 \times 10^{-12}} \text{s}^{-1} = 8.33 \times 10^{10} \text{s}^{-1}`).
502    We use Eckart tunneling by default, but see the effect of changing it
503    above.
504
505    The units of the returned reaction rate constants can be selected for
506    non-unimolecular processes. The following is an attempt to reproduce
507    [*J Atmos Chem*, **1996** 23, 37-49](https://doi.org/10.1007/BF00058703) for
508    the reaction of proton-withdrawal by a chloride radical from the methane
509    molecule
510    :math:`\ce{CH4 + \cdot Cl -> [H3C\cdots H\cdots Cl]^\ddagger -> H3C\cdot + HCl}`:
511
512    >>> model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.jk")
513    >>> get_k(model.scheme, model.compounds, temperature=300,
514    ...       scale="cm3 particle-1 s-1")
515    array([9.60e-14])
516
517    (By the way, according to the Jet Propulsion Laboratory,
518    [Publication No. 19-5](https://jpldataeval.jpl.nasa.gov/),
519    the experimental reaction rate constant for this reaction is
520    :math:`1.0 \times 10^{-13} \text{cm}^3 \text{particle}^{-1} \text{s}^{-1}`.)
521
522    The returned units are "M-1 s-1" by default:
523
524    >>> get_k(model.scheme, model.compounds) \
525    ... == get_k(model.scheme, model.compounds, scale="l mol-1 s-1")
526    array([ True])
527
528    You can also turn the tunneling correction off by using the string "none":
529
530    >>> get_k(model.scheme, model.compounds, tunneling="none") \
531    ... == get_k(model.scheme, model.compounds, tunneling=None)
532    array([ True])
533
534    You can set a simple energy bias, either as a constant or compound-wise:
535
536    >>> get_k(model.scheme, model.compounds, bias=1.0 * constants.kcal,
537    ...       temperature=300.0, scale="cm3 particle-1 s-1")
538    array([5.14e-13])
539    >>> get_k(model.scheme, model.compounds,
540    ...       bias=np.array([0.0, 0.0, -1.4, 0.0, 0.0]) * constants.kcal,
541    ...       temperature=300.0, scale="cm3 particle-1 s-1")
542    array([1.1e-12])
543    """
544    qrrho_enthalpy, qrrho_entropy = _check_qrrho(qrrho)
545    scheme = rx.core._check_scheme(scheme)
546    if compounds is not None:
547        compounds = rx.io._check_compounds(compounds)
548    if delta_freeenergies is None:
549        assert compounds is not None, "compounds could not be inferred"
550        freeenergies = get_freeenergies(
551            compounds,
552            bias=bias,
553            qrrho=(qrrho_enthalpy, qrrho_entropy),
554            # NOTE(schneiderfelipe): ensure we get rate constants in M-1 s-1.
555            # TODO(schneiderfelipe): this strategy will have to change
556            # somewhat when we improve solvation entropy models.
557            environment="solvent",
558            temperature=temperature,
559            pressure=pressure,
560        )
561
562        # TODO(schneiderfelipe): log the contribution of reaction symmetry
563        delta_freeenergies = rx.get_delta(
564            scheme.B,
565            freeenergies,
566        ) - temperature * rx.get_reaction_entropies(
567            scheme.B,
568            temperature=temperature,
569            pressure=pressure,
570        )
571
572    if molecularity is None:
573        molecularity = rx.thermo.get_molecularity(scheme.A)
574
575    # NOTE(schneiderfelipe): passing molecularity here to rates.eyring messes up
576    # rate constant units (by a factor of M-1 s-1 to atm-1 s-1), so we leave it as is.
577    k = rates.eyring(
578        delta_freeenergies,
579        temperature=temperature,
580        pressure=pressure,
581        volume=volume,
582    )
583
584    # make reaction rate constants for equilibria as close as possible to one
585    i = 0
586    while i < len(scheme.is_half_equilibrium):
587        if scheme.is_half_equilibrium[i]:
588            pair = k[i : i + 2]
589            _K = pair[0] / pair[1]
590
591            denom = pair.min()
592            if denom == 0.0:
593                logger.warning(
594                    "found half-equilibrium reaction with zero rate constant: skipping equilibrium normalization",
595                )
596                denom = 1.0
597
598            k[i : i + 2] = pair / denom
599            assert np.isclose(_K, k[i] / k[i + 1]), (
600                f"reaction rate constants {k[i]} and {k[i + 1]} for "
601                "equilibria could not be made to match the expected "
602                f"equilibrium constant value {_K}"
603            )
604
605            # loop over pairs of equilibria
606            i += 1
607        i += 1
608
609    logger.info(
610        "(classical) reaction rate constants: "
611        f"{', '.join([f'{v:7.3g}' for v in k])} atm⁻ⁿ⁺¹·s⁻¹",
612    )
613    if tunneling not in {"none", None}:
614        if compounds is not None:
615            kappa = get_kappa(
616                scheme,
617                compounds,
618                method=tunneling,
619                qrrho=qrrho_enthalpy,
620                temperature=temperature,
621            )
622            k *= kappa
623        else:
624            # TODO(schneiderfelipe): when get_kappa accept deltas, this will
625            # be probably unnecessary.
626            logger.warning(
627                "assuming unitary tunneling coefficients due to incomplete compound data",
628            )
629        logger.info(
630            "(tunneling) reaction rate constants: "
631            f"{', '.join([f'{v:7.3g}' for v in k])} atm⁻ⁿ⁺¹·s⁻¹",
632        )
633
634    # TODO(schneiderfelipe): ensure diffusional limit for reactions in
635    # solvation using Collins-Kimball theory. This includes half-equilibria.
636    return rates.convert_rate_constant(
637        k,
638        new_scale=scale,
639        # NOTE(schneiderfelipe): all the code above should always generate
640        # rate constants in M-1 s-1, so that we convert them here.
641        old_scale="l mol-1 s-1",
642        molecularity=molecularity,
643        temperature=temperature,
644        pressure=pressure,
645    )

Obtain reaction rate constants for a given reaction scheme.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed input file. See parse_model. compounds : dict-like, optional A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. bias : array-like, optional Energy to be added to free energies. tunneling : str or None, optional Choose between "eckart", "wigner" or None (or "none"). qrrho : bool or tuple-like, optional Apply both the quasi-rigid rotor harmonic oscillator (QRRHO) approximations of M. Head-Gordonand others (enthalpy correction, see J. Phys. Chem. C 2015, 119, 4, 1840-1850) and S. Grimme (entropy correction, see Theory. Chem. Eur. J., 2012, 18: 9955-9964) on top of the classical RRHO. scale : str, optional Reaction rate units. Possible values are "cm3 mol-1 s-1", "l mol-1 s-1", "m3 mol-1 s-1", "cm3 particle-1 s-1", "mmHg-1 s-1" and "atm-1 s-1". temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure. delta_freeenergies : array-like, optional Use this instead of obtaining delta free energies from the compounds. molecularity : array-like, optional Reaction order, i.e., number of molecules that come together to react. If set, this is used to calculate delta_moles for overreact.thermo.equilibrium_constant, which effectively calculates a solution equilibrium constant between reactants and the transition state for gas phase data. You should set this to None if your free energies were already adjusted for solution Gibbs free energies. volume : float, optional Molar volume.

Returns

array-like

Notes

Some symbols are accepted as alternatives in "scale": "M-1", "ml" and "torr-1" are understood as "l mol-1", "cm3" and "mmHg-1", respectively.

Examples

Below is an example of an estimate for the rate of methyl rotation in ethane (a trivial attempt to reproduce Science, 2006, 313, 5795, 1951-1955). How many turns it does per second?

>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> get_k(model.scheme, model.compounds)
array([8.16e+10])
>>> get_k(model.scheme, model.compounds, qrrho=(False, True))
array([8.24968117e+10])
>>> get_k(model.scheme, model.compounds, qrrho=False)
array([8.26909266e+10])
>>> get_k(model.scheme, model.compounds, tunneling="wigner")
array([7.99e+10])
>>> get_k(model.scheme, model.compounds, tunneling=None)
array([7.35e+10])

The calculated value is off by less than 2% from the experimental value (\( \frac{1}{12 \times 10^{-12}} \text{s}^{-1} = 8.33 \times 10^{10} \text{s}^{-1} \)). We use Eckart tunneling by default, but see the effect of changing it above.

The units of the returned reaction rate constants can be selected for non-unimolecular processes. The following is an attempt to reproduce J Atmos Chem, 1996 23, 37-49 for the reaction of proton-withdrawal by a chloride radical from the methane molecule \( \ce{CH4 + \cdot Cl -> [H3C\cdots H\cdots Cl]^\ddagger -> H3C\cdot + HCl} \):

>>> model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.jk")
>>> get_k(model.scheme, model.compounds, temperature=300,
...       scale="cm3 particle-1 s-1")
array([9.60e-14])

(By the way, according to the Jet Propulsion Laboratory, Publication No. 19-5, the experimental reaction rate constant for this reaction is \( 1.0 \times 10^{-13} \text{cm}^3 \text{particle}^{-1} \text{s}^{-1} \).)

The returned units are "M-1 s-1" by default:

>>> get_k(model.scheme, model.compounds) \
... == get_k(model.scheme, model.compounds, scale="l mol-1 s-1")
array([ True])

You can also turn the tunneling correction off by using the string "none":

>>> get_k(model.scheme, model.compounds, tunneling="none") \
... == get_k(model.scheme, model.compounds, tunneling=None)
array([ True])

You can set a simple energy bias, either as a constant or compound-wise:

>>> get_k(model.scheme, model.compounds, bias=1.0 * constants.kcal,
...       temperature=300.0, scale="cm3 particle-1 s-1")
array([5.14e-13])
>>> get_k(model.scheme, model.compounds,
...       bias=np.array([0.0, 0.0, -1.4, 0.0, 0.0]) * constants.kcal,
...       temperature=300.0, scale="cm3 particle-1 s-1")
array([1.1e-12])
def get_kappa( scheme: Scheme, compounds: dict, method: str = 'eckart', qrrho: bool = True, temperature: float = 298.15):
649def get_kappa(
650    scheme: Scheme,
651    compounds: dict,
652    method: str = "eckart",
653    qrrho: bool = True,
654    temperature: float = 298.15,
655):
656    r"""Obtain tunneling transmission coefficients at a given temperature.
657
658    One tunneling transmission coefficient is calculated for each reaction. If
659    a reaction lacks a transition state (i.e., a half-equilibrium reaction),
660    its transmission coefficient is set to unity.
661
662    Parameters
663    ----------
664    scheme : Scheme
665        A descriptor of the reaction scheme.
666        Mostly likely, this comes from a parsed input file.
667        See `overreact.io.parse_model`.
668    compounds : dict-like
669        A descriptor of the compounds.
670        Mostly likely, this comes from a parsed input file.
671        See `overreact.io.parse_model`.
672    method : str or None, optional
673        Choose between "eckart", "wigner" or None (or "none").
674    qrrho : bool, optional
675        Apply both the quasi-rigid rotor harmonic oscillator (QRRHO)
676        approximations of M. Head-Gordon and others (enthalpy correction, see
677        [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r))
678        and S. Grimme (entropy correction, see
679        [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497))
680        on top of the classical RRHO.
681    temperature : array-like, optional
682        Absolute temperature in Kelvin.
683
684    Returns
685    -------
686    array-like
687
688    Raises
689    ------
690    ValueError
691        If `method` is not supported.
692
693    Examples
694    --------
695    Below is an example of an estimate of how much quantum tunneling
696    contributes to the rate of methyl rotation in ethane (see
697    [*Science*, **2006**, 313, 5795, 1951-1955](https://doi.org/10.1126/science.1132178)
698    for some interesting experimental data on this reaction).
699
700    >>> model = rx.parse_model("data/ethane/B97-3c/model.k")
701    >>> kappa = get_kappa(model.scheme, model.compounds)
702    >>> kappa
703    array([1.110])
704    >>> get_kappa(model.scheme, model.compounds, method="none")
705    array([1.0])
706    >>> get_kappa(model.scheme, model.compounds, method="none") \
707    ... == get_kappa(model.scheme, model.compounds, method=None)
708    array([ True])
709
710    You can calculate each piece of the reaction rate constant by hand,
711    if you want. Just make sure that you don't calculate the tunneling
712    coefficient twice:
713
714    >>> kappa * get_k(model.scheme, model.compounds, tunneling=None)
715    array([8.e+10])
716    """
717    scheme = rx.core._check_scheme(scheme)
718    compounds = rx.io._check_compounds(compounds)
719
720    if method == "eckart":
721        # NOTE(schneiderfelipe): We need electronic energies + ZPE here, so we
722        # get smaller transmission coefficients.
723        energies = get_enthalpies(compounds, qrrho=qrrho, temperature=0.0)
724        delta_forward = rx.get_delta(scheme.B, energies)  # B - A
725        delta_backward = delta_forward - rx.get_delta(
726            scheme.A,
727            energies,
728        )  # B - C == B - A - (C - A)
729
730    kappas = []
731    for i, ts in enumerate(
732        rx.get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium),
733    ):
734        if ts is None:
735            kappas.append(1.0)
736        else:
737            transition_state = scheme.compounds[ts]
738            vibfreq = compounds[transition_state].vibfreqs[0]
739
740            if method == "eckart":
741                with warnings.catch_warnings():
742                    warnings.filterwarnings("error")
743                    try:
744                        kappa = tunnel.eckart(
745                            vibfreq,
746                            delta_forward[i],
747                            delta_backward[i],
748                            temperature=temperature,
749                        )
750                    except RuntimeWarning as e:
751                        logger.warning(
752                            f"using Wigner tunneling correction: {e}",
753                        )
754                        kappa = tunnel.wigner(vibfreq, temperature=temperature)
755            elif method == "wigner":
756                kappa = tunnel.wigner(vibfreq, temperature=temperature)
757            elif method in {"none", None}:
758                kappa = 1.0
759            else:
760                msg = f"unavailable method: '{method}'"
761                raise ValueError(msg)
762
763            kappas.append(kappa)
764
765    # TODO(schneiderfelipe): is this correct? shouldn't we correct shapes
766    # somewhere else?
767    vec_kappas = np.asarray(kappas).flatten()
768    logger.info(
769        "(quantum) tunneling coefficients: "
770        f"{', '.join([f'{kappa:7.3g}' for kappa in vec_kappas])}",
771    )
772    return vec_kappas

Obtain tunneling transmission coefficients at a given temperature.

One tunneling transmission coefficient is calculated for each reaction. If a reaction lacks a transition state (i.e., a half-equilibrium reaction), its transmission coefficient is set to unity.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed input file. See parse_model. compounds : dict-like A descriptor of the compounds. Mostly likely, this comes from a parsed input file. See parse_model. method : str or None, optional Choose between "eckart", "wigner" or None (or "none"). qrrho : bool, optional Apply both the quasi-rigid rotor harmonic oscillator (QRRHO) approximations of M. Head-Gordon and others (enthalpy correction, see J. Phys. Chem. C 2015, 119, 4, 1840-1850) and S. Grimme (entropy correction, see Theory. Chem. Eur. J., 2012, 18: 9955-9964) on top of the classical RRHO. temperature : array-like, optional Absolute temperature in Kelvin.

Returns

array-like

Raises

ValueError If method is not supported.

Examples

Below is an example of an estimate of how much quantum tunneling contributes to the rate of methyl rotation in ethane (see Science, 2006, 313, 5795, 1951-1955 for some interesting experimental data on this reaction).

>>> model = rx.parse_model("data/ethane/B97-3c/model.k")
>>> kappa = get_kappa(model.scheme, model.compounds)
>>> kappa
array([1.110])
>>> get_kappa(model.scheme, model.compounds, method="none")
array([1.0])
>>> get_kappa(model.scheme, model.compounds, method="none") \
... == get_kappa(model.scheme, model.compounds, method=None)
array([ True])

You can calculate each piece of the reaction rate constant by hand, if you want. Just make sure that you don't calculate the tunneling coefficient twice:

>>> kappa * get_k(model.scheme, model.compounds, tunneling=None)
array([8.e+10])
def get_reaction_entropies(transform, temperature=298.15, pressure=101325.0):
849def get_reaction_entropies(transform, temperature=298.15, pressure=constants.atm):
850    r"""Calculate entropy contributions from the overall reaction structure.
851
852    This function currently implements the reaction translational entropy, a
853    result of the indistinguishability of reactants or products, i.e., a
854    difference in entropy of :math:`R ln 2!` for the reactions
855
856    .. math::
857        \ce{A + B -> C}
858
859    and
860
861    .. math::
862        \ce{2A -> C}
863
864    Parameters
865    ----------
866    transform : array-like
867    temperature : array-like, optional
868        Absolute temperature in Kelvin.
869    pressure : array-like, optional
870        Reference gas pressure.
871
872    Returns
873    -------
874    delta_entropy : array-like
875
876    Examples
877    --------
878    >>> import overreact as rx
879
880    >>> scheme = rx.parse_reactions("A + B <=> C")
881    >>> get_reaction_entropies(scheme.A)
882    array([0.0, 0.0])
883    >>> scheme = rx.parse_reactions("2A <=> C")
884    >>> get_reaction_entropies(scheme.A)
885    array([-5.763, 5.763])
886    >>> get_reaction_entropies(scheme.A, temperature=400.0)
887    array([-5.763, 5.763])
888
889    >>> scheme = rx.parse_reactions("A <=> B + C")
890    >>> get_reaction_entropies(scheme.A)
891    array([0.0, 0.0])
892    >>> scheme = rx.parse_reactions("A <=> 2B")
893    >>> get_reaction_entropies(scheme.A)
894    array([ 5.763, -5.763])
895
896    >>> scheme = rx.parse_reactions("A + B -> E# -> C")
897    >>> get_reaction_entropies(scheme.A)
898    array([0.0])
899    >>> get_reaction_entropies(scheme.B)
900    array([0.0])
901    >>> scheme = rx.parse_reactions("2A -> E# -> C")
902    >>> get_reaction_entropies(scheme.A)
903    array([-5.763])
904    >>> get_reaction_entropies(scheme.B)
905    array([-5.763])
906    """
907    sym = factorial(np.abs(np.asarray(transform)))
908    return np.sum(
909        np.sign(transform)
910        * change_reference_state(sym, 1, temperature=temperature, pressure=pressure),
911        axis=0,
912    )

Calculate entropy contributions from the overall reaction structure.

This function currently implements the reaction translational entropy, a result of the indistinguishability of reactants or products, i.e., a difference in entropy of \( R ln 2! \) for the reactions

$$\ce{A + B -> C}$$

and

$$\ce{2A -> C}$$

Parameters

transform : array-like temperature : array-like, optional Absolute temperature in Kelvin. pressure : array-like, optional Reference gas pressure.

Returns

delta_entropy : array-like

Examples

>>> import overreact as rx
>>> scheme = rx.parse_reactions("A + B <=> C")
>>> get_reaction_entropies(scheme.A)
array([0.0, 0.0])
>>> scheme = rx.parse_reactions("2A <=> C")
>>> get_reaction_entropies(scheme.A)
array([-5.763, 5.763])
>>> get_reaction_entropies(scheme.A, temperature=400.0)
array([-5.763, 5.763])
>>> scheme = rx.parse_reactions("A <=> B + C")
>>> get_reaction_entropies(scheme.A)
array([0.0, 0.0])
>>> scheme = rx.parse_reactions("A <=> 2B")
>>> get_reaction_entropies(scheme.A)
array([ 5.763, -5.763])
>>> scheme = rx.parse_reactions("A + B -> E# -> C")
>>> get_reaction_entropies(scheme.A)
array([0.0])
>>> get_reaction_entropies(scheme.B)
array([0.0])
>>> scheme = rx.parse_reactions("2A -> E# -> C")
>>> get_reaction_entropies(scheme.A)
array([-5.763])
>>> get_reaction_entropies(scheme.B)
array([-5.763])
def get_transition_states(A, B, is_half_equilibrium):
 85def get_transition_states(A, B, is_half_equilibrium):
 86    """Return the indices of transition states for each reaction.
 87
 88    Parameters
 89    ----------
 90    A, B : array-like
 91    is_half_equilibrium : sequence
 92
 93    Returns
 94    -------
 95    sequence
 96
 97    Examples
 98    --------
 99    >>> scheme = parse_reactions("A -> B")
100    >>> print(scheme)
101    Scheme(compounds=('A', 'B'),
102           reactions=('A -> B',),
103           is_half_equilibrium=(False,),
104           A=((-1.,), (1.,)),
105           B=((-1.,), (1.,)))
106    >>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
107    (None,)
108
109    >>> scheme = parse_reactions("S -> E‡ -> S")
110    >>> print(scheme)
111    Scheme(compounds=('S', 'E‡'),
112           reactions=('S -> S',),
113           is_half_equilibrium=(False,),
114           A=((0.,), (0.,)),
115           B=((-1.,), (1.,)))
116    >>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
117    (1,)
118
119    >>> scheme = parse_reactions("E + S <=> ES -> ES‡ -> E + P")
120    >>> print(scheme)
121    Scheme(compounds=('E', 'S', 'ES', 'ES‡', 'P'),
122           reactions=('E + S -> ES', 'ES -> E + S', 'ES -> E + P'),
123           is_half_equilibrium=(True,  True, False),
124           A=((-1.,  1.,  1.),
125              (-1.,  1.,  0.),
126              (1., -1., -1.),
127              (0.,  0.,  0.),
128              (0.,  0.,  1.)),
129           B=((-1.,  0.,  0.),
130              (-1.,  0.,  0.),
131              (1.,  0., -1.),
132              (0.,  0.,  1.),
133              (0.,  0.,  0.)))
134    >>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
135    (None, None, 3)
136
137    """
138    tau = np.asarray(B) - np.asarray(A) > 0  # transition state matrix
139    return tuple(
140        x if not is_half_equilibrium[i] and tau[:, i].any() else None
141        for i, x in enumerate(np.argmax(tau, axis=0))
142    )

Return the indices of transition states for each reaction.

Parameters

A, B : array-like is_half_equilibrium : sequence

Returns

sequence

Examples

>>> scheme = parse_reactions("A -> B")
>>> print(scheme)
Scheme(compounds=('A', 'B'),
       reactions=('A -> B',),
       is_half_equilibrium=(False,),
       A=((-1.,), (1.,)),
       B=((-1.,), (1.,)))
>>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
(None,)
>>> scheme = parse_reactions("S -> E‡ -> S")
>>> print(scheme)
Scheme(compounds=('S', 'E‡'),
       reactions=('S -> S',),
       is_half_equilibrium=(False,),
       A=((0.,), (0.,)),
       B=((-1.,), (1.,)))
>>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
(1,)
>>> scheme = parse_reactions("E + S <=> ES -> ES‡ -> E + P")
>>> print(scheme)
Scheme(compounds=('E', 'S', 'ES', 'ES‡', 'P'),
       reactions=('E + S -> ES', 'ES -> E + S', 'ES -> E + P'),
       is_half_equilibrium=(True,  True, False),
       A=((-1.,  1.,  1.),
          (-1.,  1.,  0.),
          (1., -1., -1.),
          (0.,  0.,  0.),
          (0.,  0.,  1.)),
       B=((-1.,  0.,  0.),
          (-1.,  0.,  0.),
          (1.,  0., -1.),
          (0.,  0.,  1.),
          (0.,  0.,  0.)))
>>> get_transition_states(scheme.A, scheme.B, scheme.is_half_equilibrium)
(None, None, 3)
def get_y( dydt, y0, t_span=None, method='RK23', max_step=inf, first_step=2.220446049250313e-16, rtol=0.001, atol=1e-06, max_time=3600):
 58def get_y(
 59    dydt,
 60    y0,
 61    t_span=None,
 62    method="RK23",
 63    max_step=np.inf,
 64    first_step=np.finfo(np.float64).eps,
 65    rtol=1e-3,
 66    atol=1e-6,
 67    max_time=1 * 60 * 60,
 68):
 69    """Simulate a reaction scheme from its rate function.
 70
 71    This function provides two functions that calculate the concentrations and
 72    the rates of formation at any point in time for any compound. It does that
 73    by solving an initial value problem (IVP) through scipy's ``solve_ivp``
 74    under the hood.
 75
 76    Parameters
 77    ----------
 78    dydt : callable
 79        Right-hand side of the system.
 80    y0 : array-like
 81        Initial state.
 82    t_span : array-like, optional
 83        Interval of integration (t0, tf). The solver starts with t=t0 and
 84        integrates until it reaches t=tf. If not given, a conservative value
 85        is chosen based on the system at hand (the method of choice works for
 86        any zeroth-, first- or second-order reactions).
 87    method : str, optional
 88        Integration method to use. See `scipy.integrate.solve_ivp` for details.
 89        Kinetics problems are very often stiff and, as such, "RK23" and "RK45" may be
 90        unsuited. "LSODA", "BDF", and "Radau" are worth a try if things go bad.
 91    max_step : float, optional
 92        Maximum step to be performed by the integrator.
 93        Defaults to half the total time span.
 94    first_step : float, optional
 95        First step size.
 96        Defaults to half the maximum step, or `np.finfo(np.float64).eps`,
 97        whichever is smallest.
 98    rtol, atol : array-like, optional
 99        See `scipy.integrate.solve_ivp` for details.
100    max_time : float, optional
101        If `t_span` is not given, an interval will be estimated, but it can't
102        be larger than this parameter.
103
104    Returns
105    -------
106    y, r : callable
107        Concentrations and reaction rates as functions of time. The y object
108        is an OdeSolution and stores attributes t_min and t_max.
109
110
111    Examples
112    --------
113    >>> import numpy as np
114    >>> import overreact as rx
115
116    A toy simulation can be performed in just two lines:
117
118    >>> scheme = rx.parse_reactions("A <=> B")
119    >>> y, r = get_y(get_dydt(scheme, np.array([1, 1])), y0=[1, 0])
120
121    The `y` object stores information about the simulation time, which can be
122    used to produce a suitable vector of timepoints for, e.g., plotting:
123
124    >>> y.t_min, y.t_max
125    (0.0, 3.0)
126    >>> t = np.linspace(y.t_min, y.t_max)
127    >>> t
128    array([0. , 0.06122449, ..., 2.93877551, 3. ])
129
130    Both `y` and `r` can be used to check concentrations and rates in any
131    point in time. In particular, both are vectorized:
132
133    >>> y(t)
134    array([[1. , ...],
135           [0. , ...]])
136    >>> r(t)
137    array([[-1. , ...],
138           [ 1. , ...]])
139    """
140    # TODO(schneiderfelipe): raise a meaningful error when y0 has the wrong shape.
141    y0 = np.asarray(y0)
142
143    if t_span is None:
144        # We defined alpha such that 1.0 - alpha is an (under)estimate of the extend
145        # to which the reaction is simulated. And then we apply the Pareto principle.
146        alpha = 0.2
147        n_halflives = np.ceil(-np.log(alpha) / np.log(2))
148
149        halflife_estimate = 1.0
150        if hasattr(dydt, "k"):
151            halflife_estimate = np.max(
152                [
153                    np.max(y0) / 2.0,  # zeroth-order halflife
154                    np.log(2.0),  # first-order halflife
155                    1.0 / np.min(y0[np.nonzero(y0)]),  # second-order halflife
156                ],
157            ) / np.min(dydt.k)
158            logger.info(f"largest halflife guess = {halflife_estimate} s")
159
160        t_span = [0.0, min(n_halflives * halflife_estimate, max_time)]
161        logger.info(f"simulation time span   = {t_span} s")
162
163    max_step = np.min([max_step, (t_span[1] - t_span[0]) / 2.0])
164    logger.warning(f"max step = {max_step} s")
165
166    first_step = np.min([first_step, max_step / 2.0])
167    logger.warning(f"first step = {first_step} s")
168
169    jac = None
170    if hasattr(dydt, "jac"):
171        jac = dydt.jac  # noqa: F841
172
173    logger.warning(f"@t = \x1b[94m{0:10.3f} \x1b[ms\x1b[K")
174    res = solve_ivp(
175        dydt,
176        t_span,
177        y0,
178        method=method,
179        dense_output=True,
180        max_step=max_step,
181        first_step=first_step,
182        rtol=rtol,
183        atol=atol,
184        # jac=jac,  # noqa: ERA001
185    )
186    logger.warning(res)
187    y = res.sol
188
189    def r(t):
190        # TODO(schneiderfelipe): this is probably not the best way to
191        # vectorize a function!
192        try:
193            return np.array([dydt(_t, _y) for _t, _y in zip(t, y(t).T)]).T
194        except TypeError:
195            return dydt(t, y(t))
196
197    return y, r

Simulate a reaction scheme from its rate function.

This function provides two functions that calculate the concentrations and the rates of formation at any point in time for any compound. It does that by solving an initial value problem (IVP) through scipy's solve_ivp under the hood.

Parameters

dydt : callable Right-hand side of the system. y0 : array-like Initial state. t_span : array-like, optional Interval of integration (t0, tf). The solver starts with t=t0 and integrates until it reaches t=tf. If not given, a conservative value is chosen based on the system at hand (the method of choice works for any zeroth-, first- or second-order reactions). method : str, optional Integration method to use. See scipy.integrate.solve_ivp for details. Kinetics problems are very often stiff and, as such, "RK23" and "RK45" may be unsuited. "LSODA", "BDF", and "Radau" are worth a try if things go bad. max_step : float, optional Maximum step to be performed by the integrator. Defaults to half the total time span. first_step : float, optional First step size. Defaults to half the maximum step, or np.finfo(np.float64).eps, whichever is smallest. rtol, atol : array-like, optional See scipy.integrate.solve_ivp for details. max_time : float, optional If t_span is not given, an interval will be estimated, but it can't be larger than this parameter.

Returns

y, r : callable Concentrations and reaction rates as functions of time. The y object is an OdeSolution and stores attributes t_min and t_max.

Examples

>>> import numpy as np
>>> import overreact as rx

A toy simulation can be performed in just two lines:

>>> scheme = rx.parse_reactions("A <=> B")
>>> y, r = get_y(get_dydt(scheme, np.array([1, 1])), y0=[1, 0])

The y object stores information about the simulation time, which can be used to produce a suitable vector of timepoints for, e.g., plotting:

>>> y.t_min, y.t_max
(0.0, 3.0)
>>> t = np.linspace(y.t_min, y.t_max)
>>> t
array([0. , 0.06122449, ..., 2.93877551, 3. ])

Both y and r can be used to check concentrations and rates in any point in time. In particular, both are vectorized:

>>> y(t)
array([[1. , ...],
       [0. , ...]])
>>> r(t)
array([[-1. , ...],
       [ 1. , ...]])
def is_transition_state(name):
351def is_transition_state(name):
352    """Check whether a name specifies a transition state.
353
354    Parameters
355    ----------
356    name : str
357
358    Returns
359    -------
360    bool
361
362    Examples
363    --------
364    >>> is_transition_state("A#")
365    True
366    >>> is_transition_state("A‡")
367    True
368    >>> is_transition_state("pyrrole#")
369    True
370    >>> is_transition_state("pyrrole‡")
371    True
372    >>> is_transition_state("A")
373    False
374    >>> is_transition_state("A~")
375    False
376    >>> is_transition_state("pyrrole")
377    False
378    >>> is_transition_state("pyrrole~")
379    False
380
381    This function also works for names that specify environment:
382
383    >>> is_transition_state("A#(w)")
384    True
385    >>> is_transition_state("A‡(w)")
386    True
387    >>> is_transition_state("TS#(w)")
388    True
389    >>> is_transition_state("TS‡(w)")
390    True
391    >>> is_transition_state("A(w)")
392    False
393    >>> is_transition_state("A~(w)")
394    False
395    >>> is_transition_state("TS(w)")
396    False
397    >>> is_transition_state("TS~(w)")
398    False
399    """
400    return any(marker in name for marker in {"‡", "#"})

Check whether a name specifies a transition state.

Parameters

name : str

Returns

bool

Examples

>>> is_transition_state("A#")
True
>>> is_transition_state("A‡")
True
>>> is_transition_state("pyrrole#")
True
>>> is_transition_state("pyrrole‡")
True
>>> is_transition_state("A")
False
>>> is_transition_state("A~")
False
>>> is_transition_state("pyrrole")
False
>>> is_transition_state("pyrrole~")
False

This function also works for names that specify environment:

>>> is_transition_state("A#(w)")
True
>>> is_transition_state("A‡(w)")
True
>>> is_transition_state("TS#(w)")
True
>>> is_transition_state("TS‡(w)")
True
>>> is_transition_state("A(w)")
False
>>> is_transition_state("A~(w)")
False
>>> is_transition_state("TS(w)")
False
>>> is_transition_state("TS~(w)")
False
def parse_compounds(text, path=('',), select=None):
443def parse_compounds(text, path=("",), select=None):
444    """Parse a set of compounds.
445
446    Parameters
447    ----------
448    text : str, sequence of str or dict-like
449        Compound descriptions or sequence of lines of it.
450    path : sequence of str, optional
451        Paths for searching logfiles.
452    select : sequence of str, optional
453        If defined, only those compounds will be returned.
454
455    Returns
456    -------
457    compounds : immutable dict-like
458
459    Raises
460    ------
461    FileNotFoundError
462        If a logfile is not found.
463
464    Examples
465    --------
466    >>> import overreact as rx
467
468    >>> compounds = rx.parse_compounds("S: data/ethane/B97-3c/staggered.out")
469    >>> compounds
470    {'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
471           'energy': -209483812.77142256,
472           'mult': 1,
473           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
474           ...}}
475
476    >>> compounds = rx.parse_compounds({"S": "data/ethane/B97-3c/staggered.out"})
477    >>> compounds
478    {'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
479           'energy': -209483812.77142256,
480           'mult': 1,
481           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
482           ...}}
483
484    >>> compounds = rx.parse_compounds(["S: data/ethane/B97-3c/staggered.out",
485    ...                              "E‡: data/ethane/B97-3c/eclipsed.out"])
486    >>> compounds
487    {'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
488           'energy': -209483812.77142256,
489           'mult': 1,
490           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
491           ...},
492    'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
493           'energy': -209472585.3539883,
494           'mult': 1,
495           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
496           ...}}
497
498    >>> compounds = rx.parse_compounds('''S: data/ethane/B97-3c/staggered.out
499    ...                                E‡: data/ethane/B97-3c/eclipsed.out''')
500    >>> compounds
501    {'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
502           'energy': -209483812.77142256,
503           'mult': 1,
504           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
505           ...},
506    'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
507           'energy': -209472585.3539883,
508           'mult': 1,
509           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
510           ...}}
511
512    >>> compounds = rx.parse_compounds({"S": "data/ethane/B97-3c/staggered.out",
513    ...                              "E‡": "data/ethane/B97-3c/eclipsed.out"})
514    >>> compounds
515    {'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
516           'energy': -209483812.77142256,
517           'mult': 1,
518           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
519           ...},
520    'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
521           'energy': -209472585.3539883,
522           'mult': 1,
523           'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
524           ...}}
525    """
526    if isinstance(text, dict):
527        return _check_compounds(text)
528    try:
529        lines = text.split("\n")
530    except AttributeError:
531        lines = text
532    name = None
533    compounds = defaultdict(dict)
534    for line in lines:
535        if ":" in line:
536            name, line = (x.strip() for x in line.split(":", 1))
537        if not line:
538            continue
539
540        if name is not None:
541            if "=" in line:
542                key, value = (x.strip() for x in line.split("=", 1))
543            else:
544                key, value = "logfile", line
545
546            if key == "logfile":
547                success = False
548                value = value.strip('"')
549                for p in path:
550                    try:
551                        # TODO(schneiderfelipe): move on to use pathlib.
552                        logger.info(
553                            f"trying to read {os.path.join(p, value)}",
554                        )
555                        compounds[name].update(
556                            read_logfile(os.path.join(p, value)),
557                        )
558                    except FileNotFoundError:
559                        continue
560                    success = True
561                    break
562                if not success:
563                    msg = f"could not find logfile '{value}' in path: {path}"
564                    raise FileNotFoundError(msg)
565            else:
566                # one-line JSON-encoded object
567                compounds[name][key] = json.loads(value)
568    if select is not None:
569        # TODO(schneiderfelipe): this workaround still allow unused compounds
570        # to be parsed! This should change in the future.
571        compounds = {name: compounds[name] for name in select}
572
573    # Apply `extra_energy_term`s
574    for name in compounds:
575        if "extra_energy_term" in compounds[name]:
576            # TODO(schneiderfelipe): this assumes that 1. there's a single `extra_energy_term` and 2. `energy` is present
577            compounds[name]["energy"] += compounds[name]["extra_energy_term"]
578
579    return DotDict(compounds)

Parse a set of compounds.

Parameters

text : str, sequence of str or dict-like Compound descriptions or sequence of lines of it. path : sequence of str, optional Paths for searching logfiles. select : sequence of str, optional If defined, only those compounds will be returned.

Returns

compounds : immutable dict-like

Raises

FileNotFoundError If a logfile is not found.

Examples

>>> import overreact as rx
>>> compounds = rx.parse_compounds("S: data/ethane/B97-3c/staggered.out")
>>> compounds
{'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
       'energy': -209483812.77142256,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...}}
>>> compounds = rx.parse_compounds({"S": "data/ethane/B97-3c/staggered.out"})
>>> compounds
{'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
       'energy': -209483812.77142256,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...}}
>>> compounds = rx.parse_compounds(["S: data/ethane/B97-3c/staggered.out",
...                              "E‡: data/ethane/B97-3c/eclipsed.out"])
>>> compounds
{'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
       'energy': -209483812.77142256,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...},
'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
       'energy': -209472585.3539883,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...}}
>>> compounds = rx.parse_compounds('''S: data/ethane/B97-3c/staggered.out
...                                E‡: data/ethane/B97-3c/eclipsed.out''')
>>> compounds
{'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
       'energy': -209483812.77142256,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...},
'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
       'energy': -209472585.3539883,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...}}
>>> compounds = rx.parse_compounds({"S": "data/ethane/B97-3c/staggered.out",
...                              "E‡": "data/ethane/B97-3c/eclipsed.out"})
>>> compounds
{'S': {'logfile': 'data/ethane/B97-3c/staggered.out',
       'energy': -209483812.77142256,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...},
'E‡': {'logfile': 'data/ethane/B97-3c/eclipsed.out',
       'energy': -209472585.3539883,
       'mult': 1,
       'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
       ...}}
def parse_model(path: str, force_compile: bool = False):
 30def parse_model(path: str, force_compile: bool = False):
 31    """Parse either a source or model input file, whichever is available.
 32
 33    A **source input file** (also known as a `.k` file) contains all the information needed
 34    to *create a model input file*.
 35    A **model input file** (also known as a `.jk` file) is a JSON encoded file with all the
 36    information needed to study microkinetic simulations from first principles.
 37
 38    You probably won't need to use model input files directly, they are
 39    automatically created based on source input files.
 40    [**Take a look at our guide on how to write an source input file**](https://geem-lab.github.io/overreact-guide/input.html).
 41
 42    This function attempts to parse a model input file if available. If not, a source
 43    input file is parsed and a model input file is generated from it. Extensions are
 44    guessed if none given (i.e., if only the base name given).
 45
 46    Parameters
 47    ----------
 48    path : str
 49        Path to the model or source input file.
 50        If the final extension is not `.jk` or `.k`, it is guessed.
 51    force_compile : bool
 52        If True, a `.k` file will take precedence over any `.jk` file for reading. A
 53        `.jk` file is thus either generated or overwritten. This is sometimes
 54        needed to force an update with new data.
 55
 56    Returns
 57    -------
 58    model : immutable dict-like
 59
 60    Raises
 61    ------
 62    FileNotFoundError
 63        If the model or source input file is not found.
 64
 65    Examples
 66    --------
 67    Some examples of how overreact "sees" your data below 😄:
 68
 69    >>> model = parse_model("data/ethane/B97-3c/model.jk")
 70    >>> model.scheme
 71    Scheme(compounds=('S', 'E‡'),
 72           reactions=('S -> S',),
 73           is_half_equilibrium=(False,),
 74           A=((0.0,), (0.0,)),
 75           B=((-1.0,), (1.0,)))
 76    >>> model.compounds["S"]
 77    {'logfile': 'data/ethane/B97-3c/staggered.out',
 78     'energy': -209483812.77142256,
 79     'mult': 1,
 80     'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
 81     'atommasses': (12.011, 12.011, 1.008, 1.008, 1.008, 1.008, 1.008, 1.008),
 82     'atomcoords': ((-7.633588, 2.520693, -4.8e-05),
 83                    ...,
 84                    (-5.832852, 3.674431, 0.363239)),
 85     'vibfreqs': (307.57, 825.42, ..., 3071.11, 3071.45),
 86     'vibdisps': (((-1.7e-05, 3.4e-05, 5.4e-05),
 87                   ...,
 88                   (-0.011061, -0.030431, -0.027036)))}
 89    >>> model_from_source = parse_model("data/ethane/B97-3c/model.k",
 90    ...                                 force_compile=True)
 91    >>> model_from_source == model
 92    True
 93    >>> model_from_source = parse_model("data/ethane/B97-3c/model")
 94    >>> model_from_source == model
 95    True
 96    """
 97    if not path.endswith((".k", ".jk")):
 98        path = f"{path}.jk"
 99        logger.warning(f"assuming `.jk` file in {path}")
100    name, _ = os.path.splitext(path)
101
102    path_jk = f"{name}.jk"
103    if not force_compile and os.path.isfile(path_jk):
104        logger.info(f"parsing `.jk` file in {path_jk}")
105        return _parse_model(path_jk)
106
107    path_k = f"{name}.k"
108    logger.info(f"parsing `.k` file in {path_k}")
109    if not os.path.isfile(path_k):
110        # TODO(schneiderfelipe): add a nice error message here and everywhere?
111        msg = f"no `.k` file found in {path_k}"
112        raise FileNotFoundError(msg)
113
114    model = _parse_source(path_k)
115    with open(path_jk, "w") as f:
116        logger.info(f"writing `.jk` file in {path_jk}")
117        f.write(_unparse_model(model))
118
119    return model

Parse either a source or model input file, whichever is available.

A source input file (also known as a .k file) contains all the information needed to create a model input file. A model input file (also known as a .jk file) is a JSON encoded file with all the information needed to study microkinetic simulations from first principles.

You probably won't need to use model input files directly, they are automatically created based on source input files. Take a look at our guide on how to write an source input file.

This function attempts to parse a model input file if available. If not, a source input file is parsed and a model input file is generated from it. Extensions are guessed if none given (i.e., if only the base name given).

Parameters

path : str Path to the model or source input file. If the final extension is not .jk or .k, it is guessed. force_compile : bool If True, a .k file will take precedence over any .jk file for reading. A .jk file is thus either generated or overwritten. This is sometimes needed to force an update with new data.

Returns

model : immutable dict-like

Raises

FileNotFoundError If the model or source input file is not found.

Examples

Some examples of how overreact "sees" your data below 😄:

>>> model = parse_model("data/ethane/B97-3c/model.jk")
>>> model.scheme
Scheme(compounds=('S', 'E‡'),
       reactions=('S -> S',),
       is_half_equilibrium=(False,),
       A=((0.0,), (0.0,)),
       B=((-1.0,), (1.0,)))
>>> model.compounds["S"]
{'logfile': 'data/ethane/B97-3c/staggered.out',
 'energy': -209483812.77142256,
 'mult': 1,
 'atomnos': (6, 6, 1, 1, 1, 1, 1, 1),
 'atommasses': (12.011, 12.011, 1.008, 1.008, 1.008, 1.008, 1.008, 1.008),
 'atomcoords': ((-7.633588, 2.520693, -4.8e-05),
                ...,
                (-5.832852, 3.674431, 0.363239)),
 'vibfreqs': (307.57, 825.42, ..., 3071.11, 3071.45),
 'vibdisps': (((-1.7e-05, 3.4e-05, 5.4e-05),
               ...,
               (-0.011061, -0.030431, -0.027036)))}
>>> model_from_source = parse_model("data/ethane/B97-3c/model.k",
...                                 force_compile=True)
>>> model_from_source == model
True
>>> model_from_source = parse_model("data/ethane/B97-3c/model")
>>> model_from_source == model
True
def parse_reactions(text: str | list[str]) -> Scheme:
403def parse_reactions(text: str | list[str]) -> Scheme:
404    """
405    Parse a kinetic model as a chemical reaction scheme.
406
407    This is an essential part of the parsing process.
408    See `overreact.io.parse_model` other details.
409
410    Parameters
411    ----------
412    text : str or sequence of str
413        Model description or sequence of lines of it.
414
415    Returns
416    -------
417    scheme : Scheme
418        A descriptor of the reaction scheme.
419
420    Notes
421    -----
422    The model description should comply with the mini-language for systems of
423    reactions. A semi-formal definition of the grammar in
424    [Backus-Naur form](https://en.wikipedia.org/wiki/Backus%E2%80%93Naur_form)
425    is given below:
426
427             equation ::= equation_side arrow equation_side
428        equation_side ::= coefficient compound ['+' coefficient compound]*
429          coefficient ::= [integers] (defaults to 1)
430             compound ::= mix of printable characters
431                arrow ::= '->' | '<=>' | '<-'
432
433    Blank lines and comments (starting with `//`) are ignored. Repeated
434    reactions are ignored. Furthermore, reactions can be chained one after
435    another and, if a single compound (with either a `‡` or a `#` at the end)
436    appears alone on one side of a reaction, it's considered a transition
437    state. Transition states have zero lifetime during the simulation.
438
439    Examples
440    --------
441    What follows is a rather long tour over the parsing process and its
442    output in general. You can skip it if you are not interested in the
443    details.
444
445    >>> scheme = parse_reactions("A -> B  // a direct reaction")
446
447    The reaction above is a direct one (observe that comments are ignored). The
448    returned object has the following attributes:
449
450    >>> scheme.compounds
451    ('A', 'B')
452    >>> scheme.reactions
453    ('A -> B',)
454    >>> scheme.is_half_equilibrium
455    (False,)
456    >>> scheme.A
457    ((-1.,), (1.,))
458    >>> scheme.B
459    ((-1.,), (1.,))
460
461    The same reaction can be specified in reverse order:
462
463    >>> parse_reactions("B <- A  // reverse reaction of the above")
464    Scheme(compounds=('A', 'B'),
465           reactions=('A -> B',),
466           is_half_equilibrium=(False,),
467           A=((-1.,), (1.,)),
468           B=((-1.,), (1.,)))
469
470    Equilibria produce twice as many direct reactions, while the $B$ matrix
471    defines an energy relationship for only one of each pair:
472
473    >>> parse_reactions("A <=> B  // an equilibrium")
474    Scheme(compounds=('A', 'B'),
475           reactions=('A -> B', 'B -> A'),
476           is_half_equilibrium=(True, True),
477           A=((-1.,  1.),
478              (1., -1.)),
479           B=((-1.,  0.),
480              (1.,  0.)))
481
482    Adding twice the same reaction results in a single reaction being added.
483    This of course also works with equilibria (extra whitespaces are ignored):
484
485    >>> parse_reactions('''
486    ...     A <=> B  -> A
487    ...     A  -> B <=> A
488    ...     A  -> B <-  A
489    ...     B <-  A  -> B
490    ... ''')
491    Scheme(compounds=('A', 'B'),
492           reactions=('A -> B', 'B -> A'),
493           is_half_equilibrium=(True, True),
494           A=((-1.,  1.),
495              (1., -1.)),
496           B=((-1.,  0.),
497              (1.,  0.)))
498
499    Transition states are specified with a special symbol at the end (either
500    `‡` or `#`). They are shown among compounds, but the matrix $A$ ensures
501    they'll never have a non-zero rate of formation/consumption. On the other
502    hand, they are needed in the $B$ matrix:
503
504    >>> parse_reactions("A -> A‡ -> B")
505    Scheme(compounds=('A', 'A‡', 'B'),
506           reactions=('A -> B',),
507           is_half_equilibrium=(False,),
508           A=((-1.,), (0.,), (1.,)),
509           B=((-1.,), (1.,), (0.,)))
510
511    This gives the same result as above:
512
513    >>> parse_reactions("A -> A‡ -> B <- A‡ <- A")
514    Scheme(compounds=('A', 'A‡', 'B'),
515           reactions=('A -> B',),
516           is_half_equilibrium=(False,),
517           A=((-1.,), (0.,), (1.,)),
518           B=((-1.,), (1.,), (0.,)))
519
520    It is possible to define a reaction whose product is the same as the
521    reactant. This is found in isomerization processes (e.g., ammonia
522    inversion or the methyl rotation in ethane):
523
524    >>> parse_reactions("S -> E‡ -> S")
525    Scheme(compounds=('S', 'E‡'),
526           reactions=('S -> S',),
527           is_half_equilibrium=(False,),
528           A=((0.,), (0.,)),
529           B=((-1.,), (1.,)))
530
531    As such, a column full of zeros in the $A$ matrix corresponds to a reaction
532    with zero net change. As can be seen, overreact allows for very general
533    models. An interesting feature is that a single transition state can link
534    many different compounds (whether it is useful is a matter of debate):
535
536    >>> parse_reactions('''
537    ...     B  -> B‡  -> C  // chained reactions and transition states
538    ...     B‡ -> D         // this is a bifurcation
539    ...     B  -> B'‡ -> E  // this is a classical competitive reaction
540    ...     A  -> B‡
541    ... ''')
542    Scheme(compounds=('B', 'B‡', 'C', 'D', "B'‡", 'E', 'A'),
543           reactions=('B -> C', 'B -> D', 'B -> E', 'A -> C', 'A -> D'),
544           is_half_equilibrium=(False, False, False, False, False),
545           A=((-1., -1., -1.,  0.,  0.),
546              (0.,  0.,  0.,  0.,  0.),
547              (1.,  0.,  0.,  1.,  0.),
548              (0.,  1.,  0.,  0.,  1.),
549              (0.,  0.,  0.,  0.,  0.),
550              (0.,  0.,  1.,  0.,  0.),
551              (0.,  0.,  0., -1., -1.)),
552           B=((-1., -1., -1.,  0.,  0.),
553              (1.,  1.,  0.,  1.,  1.),
554              (0.,  0.,  0.,  0.,  0.),
555              (0.,  0.,  0.,  0.,  0.),
556              (0.,  0.,  1.,  0.,  0.),
557              (0.,  0.,  0.,  0.,  0.),
558              (0.,  0.,  0., -1., -1.)))
559
560    The following is a borderline case but both reactions should be considered
561    different since they define different processes:
562
563    >>> parse_reactions('''
564    ...     A -> A‡ -> B
565    ...     A -> B
566    ... ''')
567    Scheme(compounds=('A', 'A‡', 'B'),
568           reactions=('A -> B', 'A -> B'),
569           is_half_equilibrium=(False, False),
570           A=((-1., -1.),
571              (0.,  0.),
572              (1.,  1.)),
573           B=((-1., -1.),
574              (1.,  0.),
575              (0.,  1.)))
576
577    The following is correct behavior. In fact, the reactions are badly
578    defined: if more than one transition state are chained, the following
579    happens, which is correct since it's the most physically plausible model
580    that can be extracted. It can be seen as a feature that the product B is
581    ignored and not the reactant A, since the user would easily see the mistake
582    in graphs of concentration over time (the alternative would be no
583    reaction happening at all, which is rather cryptic to debug).
584
585    >>> parse_reactions("A -> A‡ -> A'‡ -> B")
586    Scheme(compounds=('A', 'A‡', "A'‡", 'B'),
587           reactions=("A -> A'‡",),
588           is_half_equilibrium=(False,),
589           A=((-1.,), (0.,), (1.,), (0.,)),
590           B=((-1.,), (1.,), (0.,), (0.,)))
591
592    In any case, it's not clear how a reaction barrier be defined in such a
593    case. If you have a use case, don't hesitate to
594    [open an issue](https://github.com/geem-lab/overreact/issues/), we'll be
595    happy to hear from you.
596    """
597    compounds: dict[str, int] = {}
598    reactions: dict[
599        tuple[str, str, bool, str],
600        tuple[tuple[tuple[int, str], ...], bool],
601    ] = {}
602    A = []  # coefficients between reactants and products
603    B = []  # coefficients between reactants and transition states
604
605    def _add_reaction(reactants, products, is_half_equilibrium, transition):
606        """Local helper function with side-effects."""
607        # TODO(schneiderfelipe): what if reaction is defined, then redefined
608        # as equilibrium, or vice-versa?
609        if (
610            transition is not None
611            and (reactants, products, is_half_equilibrium, transition) in reactions
612        ):
613            return
614
615        # found new reaction
616        reactions[(reactants, products, is_half_equilibrium, transition)] = None
617
618        A_vector = np.zeros(len(compounds))
619        for coefficient, reactant in reactants:
620            A_vector[compounds[reactant]] = -coefficient
621        B_vector = A_vector
622
623        if transition is not None:
624            B_vector = A_vector.copy()
625
626            # it's assumed that
627            #   1. there's a singe transition compound, and
628            #   2. its coefficient equals one
629            B_vector[compounds[transition[-1][-1]]] = 1
630
631        for coefficient, product in products:
632            A_vector[compounds[product]] += coefficient
633
634        if (
635            is_half_equilibrium
636            and (products, reactants, is_half_equilibrium, transition) in reactions
637        ):
638            B_vector = np.zeros(len(compounds))
639
640        A.append(A_vector)
641        B.append(B_vector)
642
643    after_transitions: dict[tuple[tuple[int, str], ...], list[tuple[int, str]]] = {}
644    before_transitions: dict[tuple[tuple[int, str], ...], list[tuple[int, str]]] = {}
645
646    for reactants, products, is_half_equilibrium in _parse_reactions(text):
647        if (reactants, products, False, None) in reactions or (
648            reactants,
649            products,
650            True,
651            None,
652        ) in reactions:
653            continue
654
655        for _, compound in itertools.chain(reactants, products):
656            if compound not in compounds:
657                # found new compound
658                compounds[compound] = len(compounds)
659
660        # TODO(schneiderfelipe): what if a transition state is used in an
661        # equilibrium?
662
663        # it's assumed that if a transition shows up,
664        #   1. it's the only compound in its side of the reaction, and
665        #   2. its coefficient equals one
666        if is_transition_state(reactants[-1][-1]):
667            for before_reactants in before_transitions.get(reactants, []):
668                _add_reaction(
669                    before_reactants,
670                    products,
671                    is_half_equilibrium,
672                    reactants,
673                )
674
675            if reactants in after_transitions:
676                after_transitions[reactants].append(products)
677            else:
678                after_transitions[reactants] = [products]
679            continue
680        elif is_transition_state(products[-1][-1]):
681            for after_products in after_transitions.get(products, []):
682                _add_reaction(reactants, after_products, is_half_equilibrium, products)
683
684            if products in before_transitions:
685                before_transitions[products].append(reactants)
686            else:
687                before_transitions[products] = [reactants]
688            continue
689
690        _add_reaction(reactants, products, is_half_equilibrium, None)
691
692    return Scheme(
693        compounds=tuple(compounds),
694        reactions=tuple(_unparse_reactions(reactions)),
695        is_half_equilibrium=rx._misc.totuple(
696            [reaction[2] for reaction in reactions],
697        ),
698        A=rx._misc.totuple(
699            np.block(
700                [[vector, np.zeros(len(compounds) - len(vector))] for vector in A],
701            ).T,
702        ),
703        B=rx._misc.totuple(
704            np.block(
705                [[vector, np.zeros(len(compounds) - len(vector))] for vector in B],
706            ).T,
707        ),
708    )

Parse a kinetic model as a chemical reaction scheme.

This is an essential part of the parsing process. See parse_model other details.

Parameters

text : str or sequence of str Model description or sequence of lines of it.

Returns

scheme : Scheme A descriptor of the reaction scheme.

Notes

The model description should comply with the mini-language for systems of reactions. A semi-formal definition of the grammar in Backus-Naur form is given below:

     equation ::= equation_side arrow equation_side
equation_side ::= coefficient compound ['+' coefficient compound]*
  coefficient ::= [integers] (defaults to 1)
     compound ::= mix of printable characters
        arrow ::= '->' | '<=>' | '<-'

Blank lines and comments (starting with //) are ignored. Repeated reactions are ignored. Furthermore, reactions can be chained one after another and, if a single compound (with either a or a # at the end) appears alone on one side of a reaction, it's considered a transition state. Transition states have zero lifetime during the simulation.

Examples

What follows is a rather long tour over the parsing process and its output in general. You can skip it if you are not interested in the details.

>>> scheme = parse_reactions("A -> B  // a direct reaction")

The reaction above is a direct one (observe that comments are ignored). The returned object has the following attributes:

>>> scheme.compounds
('A', 'B')
>>> scheme.reactions
('A -> B',)
>>> scheme.is_half_equilibrium
(False,)
>>> scheme.A
((-1.,), (1.,))
>>> scheme.B
((-1.,), (1.,))

The same reaction can be specified in reverse order:

>>> parse_reactions("B <- A  // reverse reaction of the above")
Scheme(compounds=('A', 'B'),
       reactions=('A -> B',),
       is_half_equilibrium=(False,),
       A=((-1.,), (1.,)),
       B=((-1.,), (1.,)))

Equilibria produce twice as many direct reactions, while the $B$ matrix defines an energy relationship for only one of each pair:

>>> parse_reactions("A <=> B  // an equilibrium")
Scheme(compounds=('A', 'B'),
       reactions=('A -> B', 'B -> A'),
       is_half_equilibrium=(True, True),
       A=((-1.,  1.),
          (1., -1.)),
       B=((-1.,  0.),
          (1.,  0.)))

Adding twice the same reaction results in a single reaction being added. This of course also works with equilibria (extra whitespaces are ignored):

>>> parse_reactions('''
...     A <=> B  -> A
...     A  -> B <=> A
...     A  -> B <-  A
...     B <-  A  -> B
... ''')
Scheme(compounds=('A', 'B'),
       reactions=('A -> B', 'B -> A'),
       is_half_equilibrium=(True, True),
       A=((-1.,  1.),
          (1., -1.)),
       B=((-1.,  0.),
          (1.,  0.)))

Transition states are specified with a special symbol at the end (either or #). They are shown among compounds, but the matrix $A$ ensures they'll never have a non-zero rate of formation/consumption. On the other hand, they are needed in the $B$ matrix:

>>> parse_reactions("A -> A‡ -> B")
Scheme(compounds=('A', 'A‡', 'B'),
       reactions=('A -> B',),
       is_half_equilibrium=(False,),
       A=((-1.,), (0.,), (1.,)),
       B=((-1.,), (1.,), (0.,)))

This gives the same result as above:

>>> parse_reactions("A -> A‡ -> B <- A‡ <- A")
Scheme(compounds=('A', 'A‡', 'B'),
       reactions=('A -> B',),
       is_half_equilibrium=(False,),
       A=((-1.,), (0.,), (1.,)),
       B=((-1.,), (1.,), (0.,)))

It is possible to define a reaction whose product is the same as the reactant. This is found in isomerization processes (e.g., ammonia inversion or the methyl rotation in ethane):

>>> parse_reactions("S -> E‡ -> S")
Scheme(compounds=('S', 'E‡'),
       reactions=('S -> S',),
       is_half_equilibrium=(False,),
       A=((0.,), (0.,)),
       B=((-1.,), (1.,)))

As such, a column full of zeros in the $A$ matrix corresponds to a reaction with zero net change. As can be seen, overreact allows for very general models. An interesting feature is that a single transition state can link many different compounds (whether it is useful is a matter of debate):

>>> parse_reactions('''
...     B  -> B‡  -> C  // chained reactions and transition states
...     B‡ -> D         // this is a bifurcation
...     B  -> B'‡ -> E  // this is a classical competitive reaction
...     A  -> B‡
... ''')
Scheme(compounds=('B', 'B‡', 'C', 'D', "B'‡", 'E', 'A'),
       reactions=('B -> C', 'B -> D', 'B -> E', 'A -> C', 'A -> D'),
       is_half_equilibrium=(False, False, False, False, False),
       A=((-1., -1., -1.,  0.,  0.),
          (0.,  0.,  0.,  0.,  0.),
          (1.,  0.,  0.,  1.,  0.),
          (0.,  1.,  0.,  0.,  1.),
          (0.,  0.,  0.,  0.,  0.),
          (0.,  0.,  1.,  0.,  0.),
          (0.,  0.,  0., -1., -1.)),
       B=((-1., -1., -1.,  0.,  0.),
          (1.,  1.,  0.,  1.,  1.),
          (0.,  0.,  0.,  0.,  0.),
          (0.,  0.,  0.,  0.,  0.),
          (0.,  0.,  1.,  0.,  0.),
          (0.,  0.,  0.,  0.,  0.),
          (0.,  0.,  0., -1., -1.)))

The following is a borderline case but both reactions should be considered different since they define different processes:

>>> parse_reactions('''
...     A -> A‡ -> B
...     A -> B
... ''')
Scheme(compounds=('A', 'A‡', 'B'),
       reactions=('A -> B', 'A -> B'),
       is_half_equilibrium=(False, False),
       A=((-1., -1.),
          (0.,  0.),
          (1.,  1.)),
       B=((-1., -1.),
          (1.,  0.),
          (0.,  1.)))

The following is correct behavior. In fact, the reactions are badly defined: if more than one transition state are chained, the following happens, which is correct since it's the most physically plausible model that can be extracted. It can be seen as a feature that the product B is ignored and not the reactant A, since the user would easily see the mistake in graphs of concentration over time (the alternative would be no reaction happening at all, which is rather cryptic to debug).

>>> parse_reactions("A -> A‡ -> A'‡ -> B")
Scheme(compounds=('A', 'A‡', "A'‡", 'B'),
       reactions=("A -> A'‡",),
       is_half_equilibrium=(False,),
       A=((-1.,), (0.,), (1.,), (0.,)),
       B=((-1.,), (1.,), (0.,), (0.,)))

In any case, it's not clear how a reaction barrier be defined in such a case. If you have a use case, don't hesitate to open an issue, we'll be happy to hear from you.

def unparse_reactions(scheme: Scheme) -> str:
147def unparse_reactions(scheme: Scheme) -> str:
148    """Unparse a kinetic model.
149
150    Parameters
151    ----------
152    scheme : Scheme
153        A descriptor of the reaction scheme.
154        Mostly likely, this comes from a parsed input file.
155        See `overreact.io.parse_model`.
156
157    Returns
158    -------
159    text : str
160
161    Notes
162    -----
163    This function assumes complimentary half equilibria are located one after
164    the other in ``scheme.reactions``, which is to be expected from
165    `parse_reactions`.
166
167    Examples
168    --------
169    >>> unparse_reactions(Scheme(compounds=('A', 'B'), reactions=('A -> B',),
170    ...                   is_half_equilibrium=(False,),
171    ...                   A=((-1.,),
172    ...                      ( 1.,)),
173    ...                   B=((-1.,),
174    ...                      ( 1.,))))
175    'A -> B'
176    >>> unparse_reactions(Scheme(compounds=('A', 'B'),
177    ...                   reactions=('A -> B', 'B -> A'),
178    ...                   is_half_equilibrium=(True, True),
179    ...                   A=((-1.,  1.),
180    ...                      ( 1., -1.)),
181    ...                   B=((-1.,  0.),
182    ...                      ( 1.,  0.))))
183    'A <=> B'
184    >>> unparse_reactions(Scheme(compounds=('A', 'A‡', 'B'),
185    ...                   reactions=('A -> B',),
186    ...                   is_half_equilibrium=(False,),
187    ...                   A=((-1.,),
188    ...                      ( 0.,),
189    ...                      ( 1.,)),
190    ...                   B=((-1.,),
191    ...                      ( 1.,),
192    ...                      ( 0.,))))
193    'A -> A‡ -> B'
194    >>> print(unparse_reactions(Scheme(compounds=('B', 'B‡', 'C', 'D', "B'‡",
195    ...                                           'E', 'A'),
196    ...                         reactions=('B -> C', 'B -> D', 'B -> E',
197    ...                                    'A -> C', 'A -> D'),
198    ...                         is_half_equilibrium=(False, False, False,
199    ...                                              False, False),
200    ...                         A=((-1., -1., -1.,  0.,  0.),
201    ...                            ( 0.,  0.,  0.,  0.,  0.),
202    ...                            ( 1.,  0.,  0.,  1.,  0.),
203    ...                            ( 0.,  1.,  0.,  0.,  1.),
204    ...                            ( 0.,  0.,  0.,  0.,  0.),
205    ...                            ( 0.,  0.,  1.,  0.,  0.),
206    ...                            ( 0.,  0.,  0., -1., -1.)),
207    ...                         B=((-1., -1., -1.,  0.,  0.),
208    ...                            ( 1.,  1.,  0.,  1.,  1.),
209    ...                            ( 0.,  0.,  0.,  0.,  0.),
210    ...                            ( 0.,  0.,  0.,  0.,  0.),
211    ...                            ( 0.,  0.,  1.,  0.,  0.),
212    ...                            ( 0.,  0.,  0.,  0.,  0.),
213    ...                            ( 0.,  0.,  0., -1., -1.)))))
214    B -> B‡ -> C
215    B -> B‡ -> D
216    B -> B'‡ -> E
217    A -> B‡ -> C
218    A -> B‡ -> D
219    >>> print(unparse_reactions(Scheme(compounds=('A', 'A‡', 'B'),
220    ...                         reactions=('A -> B', 'A -> B'),
221    ...                         is_half_equilibrium=(False, False),
222    ...                         A=((-1., -1.),
223    ...                            ( 0.,  0.),
224    ...                            ( 1.,  1.)),
225    ...                         B=((-1., -1.),
226    ...                            ( 1.,  0.),
227    ...                            ( 0.,  1.)))))
228    A -> A‡ -> B
229    A -> B
230    >>> unparse_reactions(Scheme(compounds=('A', 'A‡', "A'‡", 'B'),
231    ...                   reactions=("A -> A'‡",),
232    ...                   is_half_equilibrium=(False,),
233    ...                   A=((-1.,),
234    ...                      ( 0.,),
235    ...                      ( 1.,),
236    ...                      ( 0.,)),
237    ...                   B=((-1.,),
238    ...                      ( 1.,),
239    ...                      ( 0.,),
240    ...                      ( 0.,))))
241    "A -> A‡ -> A'‡"
242    >>> unparse_reactions(Scheme(compounds=('S', 'E‡'),
243    ...                   reactions=('S -> S',),
244    ...                   is_half_equilibrium=(False,),
245    ...                   A=((0.0,),
246    ...                      (0.0,)),
247    ...                   B=((-1.0,),
248    ...                      (1.0,))))
249    'S -> E‡ -> S'
250    """
251    scheme = _check_scheme(scheme)
252    transition_states = get_transition_states(
253        scheme.A,
254        scheme.B,
255        scheme.is_half_equilibrium,
256    )
257    lines = []
258    i = 0
259    while i < len(scheme.reactions):
260        if transition_states[i] is not None:
261            lines.append(
262                scheme.reactions[i].replace(
263                    "->",
264                    f"-> {scheme.compounds[transition_states[i]]} ->",
265                ),
266            )
267        elif scheme.is_half_equilibrium[i]:
268            lines.append(scheme.reactions[i].replace("->", "<=>"))
269            i += 1  # avoid backward reaction, which comes next
270        else:
271            lines.append(scheme.reactions[i])
272        i += 1
273    return "\n".join(lines)

Unparse a kinetic model.

Parameters

scheme : Scheme A descriptor of the reaction scheme. Mostly likely, this comes from a parsed input file. See parse_model.

Returns

text : str

Notes

This function assumes complimentary half equilibria are located one after the other in scheme.reactions, which is to be expected from parse_reactions.

Examples

>>> unparse_reactions(Scheme(compounds=('A', 'B'), reactions=('A -> B',),
...                   is_half_equilibrium=(False,),
...                   A=((-1.,),
...                      ( 1.,)),
...                   B=((-1.,),
...                      ( 1.,))))
'A -> B'
>>> unparse_reactions(Scheme(compounds=('A', 'B'),
...                   reactions=('A -> B', 'B -> A'),
...                   is_half_equilibrium=(True, True),
...                   A=((-1.,  1.),
...                      ( 1., -1.)),
...                   B=((-1.,  0.),
...                      ( 1.,  0.))))
'A <=> B'
>>> unparse_reactions(Scheme(compounds=('A', 'A‡', 'B'),
...                   reactions=('A -> B',),
...                   is_half_equilibrium=(False,),
...                   A=((-1.,),
...                      ( 0.,),
...                      ( 1.,)),
...                   B=((-1.,),
...                      ( 1.,),
...                      ( 0.,))))
'A -> A‡ -> B'
>>> print(unparse_reactions(Scheme(compounds=('B', 'B‡', 'C', 'D', "B'‡",
...                                           'E', 'A'),
...                         reactions=('B -> C', 'B -> D', 'B -> E',
...                                    'A -> C', 'A -> D'),
...                         is_half_equilibrium=(False, False, False,
...                                              False, False),
...                         A=((-1., -1., -1.,  0.,  0.),
...                            ( 0.,  0.,  0.,  0.,  0.),
...                            ( 1.,  0.,  0.,  1.,  0.),
...                            ( 0.,  1.,  0.,  0.,  1.),
...                            ( 0.,  0.,  0.,  0.,  0.),
...                            ( 0.,  0.,  1.,  0.,  0.),
...                            ( 0.,  0.,  0., -1., -1.)),
...                         B=((-1., -1., -1.,  0.,  0.),
...                            ( 1.,  1.,  0.,  1.,  1.),
...                            ( 0.,  0.,  0.,  0.,  0.),
...                            ( 0.,  0.,  0.,  0.,  0.),
...                            ( 0.,  0.,  1.,  0.,  0.),
...                            ( 0.,  0.,  0.,  0.,  0.),
...                            ( 0.,  0.,  0., -1., -1.)))))
B -> B‡ -> C
B -> B‡ -> D
B -> B'‡ -> E
A -> B‡ -> C
A -> B‡ -> D
>>> print(unparse_reactions(Scheme(compounds=('A', 'A‡', 'B'),
...                         reactions=('A -> B', 'A -> B'),
...                         is_half_equilibrium=(False, False),
...                         A=((-1., -1.),
...                            ( 0.,  0.),
...                            ( 1.,  1.)),
...                         B=((-1., -1.),
...                            ( 1.,  0.),
...                            ( 0.,  1.)))))
A -> A‡ -> B
A -> B
>>> unparse_reactions(Scheme(compounds=('A', 'A‡', "A'‡", 'B'),
...                   reactions=("A -> A'‡",),
...                   is_half_equilibrium=(False,),
...                   A=((-1.,),
...                      ( 0.,),
...                      ( 1.,),
...                      ( 0.,)),
...                   B=((-1.,),
...                      ( 1.,),
...                      ( 0.,),
...                      ( 0.,))))
"A -> A‡ -> A'‡"
>>> unparse_reactions(Scheme(compounds=('S', 'E‡'),
...                   reactions=('S -> S',),
...                   is_half_equilibrium=(False,),
...                   A=((0.0,),
...                      (0.0,)),
...                   B=((-1.0,),
...                      (1.0,))))
'S -> E‡ -> S'