Source code for nskinetics.reactions.reaction

# -*- coding: utf-8 -*-
# NSKinetics: simulation of Non-Steady state enzyme Kinetics and inhibitory phenomena
# Copyright (C) 2025-, Sarang S. Bhagwat <sarangbhagwat.developer@gmail.com>
# 
# This module is under the MIT open-source license. See 
# https://github.com/sarangbhagwat/nskinetics/blob/main/LICENSE
# for license details.

import numpy as np
from numba import njit
from warnings import warn
from .utils import read_equation_str

__all__ = ('Reaction', 'Rxn', 'IrreversibleReaction', 'IrrevRxn',
           'ReversibleReaction', 'RevRxn',
           'ChemicalEquation')

#%% Abstract chemical equation class

class ChemicalEquation():
    """
    Represents a chemical equation with defined stoichiometry.
    
    This class stores and formats the stoichiometry of a reaction,
    and is used to represent the equation symbolically and in string form.
    It can optionally be paired with an external object that also holds stoichiometric information.
    
    Parameters
    ----------
    ID : str
        Unique identifier for the chemical equation.
    species_system : SpeciesSystem
        System containing all chemical species.
    stoichiometry : array-like, optional
        Stoichiometric coefficients of all species (negative for reactants, positive for products).
    paired_obj : object, optional
        An object containing its own 'stoichiometry' attribute that this equation can reference 
        instead of passing 'stoichiometry' as an initialization argument.
    
    """
    def __init__(self, ID, 
                 species_system, 
                 stoichiometry=None, 
                 paired_obj=None, # can pass an object that has a parameter 'stoichiometry'
                 ):
        self.ID = ID
        self.species_system = species_system
        self._stoichiometry = stoichiometry if stoichiometry is not None\
            else paired_obj.stoichiometry
        self.paired_obj = paired_obj
        
    def __str__(self):
        lhs = ''
        rhs = ''
        for chem, stoich in zip(self.species_system.all_sps, self.stoichiometry):
            
            if stoich<0: 
                if not lhs=='': lhs+= ' + '
                if not np.abs(stoich)==1.: lhs+= str(-stoich) + ' '
                lhs+= chem.ID
            elif stoich>0: 
                if not rhs=='': rhs+= ' + '
                if not np.abs(stoich)==1.: rhs+= str(stoich) + ' '
                rhs+= chem.ID
                
        return f'{self.ID}: ChemicalEquation(' + lhs + ' -> ' + rhs + ')'
    
    def __repr__(self):
        return self.__str__()
    
    @property
    def stoichiometry(self):
        """
        The stoichiometric coefficients for this chemical equation.
        
        Returns
        -------
        np.ndarray
            Array of stoichiometric values (negative for reactants, positive for products).
        
        """
        return self._stoichiometry
    @stoichiometry.setter
    def stoichiometry(self, new_stoichiometry):
        self._stoichiometry = new_stoichiometry
        if self.paired_obj is not None:
            if not self.paired_obj.stoichiometry==new_stoichiometry:
                warn(f'Replaced {self.ID} stoichiometry with {new_stoichiometry}, but this does not match the paired_obj stoichiometry: {self.paired_obj.stoichiometry}.',
                     RuntimeWarning)
    
    @classmethod
    def from_string(cls, ID, equation_str, species_system, paired_obj=None):
        """
        Create a ChemicalEquation object from a string representation of a reaction.
        
        Parameters
        ----------
        ID : str
            Identifier for the chemical equation.
        equation_str : str
            String representing the chemical equation, e.g., 
            "A + B -> C",
            "A + B <-> C",
            "A + B -> C; kf=20",
            "A + B <-> C; kf=20, kb=40.5",
            "A + B <-> C; kf=20, kb = 10.5",
        species_system : SpeciesSystem
            System containing all species involved in the reaction.
        paired_obj : object, optional
            Object that may contain a stoichiometry attribute.

        Returns
        -------
        ChemicalEquation
            Instantiated ChemicalEquation object.
        float
            Forward rate constant (kf) parsed from the string, if present.
        float
            Backward rate constant (kb) parsed from the string, if present.
        str
            Rate expression (rate) parsed from the string, if present.
            
        """
        
        stoichiometry, kf, kb = read_equation_str(equation_str, 
                                                   species_system)
        return cls(ID=ID, 
                 species_system=species_system,
                 stoichiometry=stoichiometry,
                 paired_obj=paired_obj),\
            kf, kb
        
#%% Abstract reaction class
class AbstractReaction():
    """
    Abstract base class to define the stoichiometry of a chemical reaction.
    
    This class does not define any kinetic behavior (i.e., no rate constants),
    but sets up the stoichiometry from either a ChemicalEquation, lists/dicts of
    reactants and products, or a raw stoichiometry array.
    
    One and only one of the following must be provided:
        - reactants and products (as dict or list)
        - chem_equation
        - stoichiometry
        
    Parameters
    ----------
    ID : str
        Identifier for the reaction.
    species_system : SpeciesSystem
        System containing the chemical species.
    reactants : list or dict, optional
        Reactant species. If dict, keys are species IDs and values are stoichiometric coefficients.
    products : list or dict, optional
        Product species. Same format as `reactants`.
    chem_equation : ChemicalEquation, optional
        Equation object defining reaction stoichiometry.
    stoichiometry : array-like, optional
        Array of stoichiometric coefficients.
        
    """
    def __init__(self, ID, 
                 species_system,
                 reactants=None, products=None,
                 chem_equation=None,
                 stoichiometry=None,
                 ):
        self.ID = ID
        self.reactants = reactants
        self.products = products
        self.chem_equation = chem_equation
        self.species_system = species_system
        
        if stoichiometry is None:
            if chem_equation is None:
                stoichiometry = []
                if isinstance(reactants, dict) and isinstance(products, dict):
                    reactant_keys = reactants.keys()
                    product_keys = products.keys()
                    for i in species_system.all_sps:
                        if i.ID in reactant_keys: 
                            stoichiometry.append(reactants[i])
                        elif i.ID in product_keys: 
                            stoichiometry.append(products[i])
                        else:
                            stoichiometry.append(0.)
                elif isinstance(reactants, list) and isinstance(products, list):
                    for i in species_system.all_sps:
                        if i.ID in reactants: 
                            stoichiometry.append(-1)
                        elif i.ID in products: 
                            stoichiometry.append(1)
                        else:
                            stoichiometry.append(0.)
                else:
                    raise TypeError('\nGiven reactants and products must both be either lists or dicts.\n')
                
                self.stoichiometry = np.array(stoichiometry)
            
            else:
                self.stoichiometry = chem_equation.stoichiometry
        else:
            self.stoichiometry = stoichiometry
        
        if self.chem_equation is None:
            self.chem_equation = ChemicalEquation(ID=ID+'_eqn', 
                                                  species_system=species_system,
                                                  stoichiometry=self.stoichiometry)
        
#%% Reaction class

@njit(cache=True)
def dconcs_dt_v0_3(
    kf, kb, species_concs_vector, rxn_stoichs, rl_exps,
    reactant_indices, product_indices
    ):
    n_species = species_concs_vector.size
    
    # Early exit if any reactant is exhausted
    for idx in reactant_indices:
        if species_concs_vector[idx] <= 1e-20:
            return np.zeros(n_species, dtype=species_concs_vector.dtype)
        
    # Compute forward rate
    forward = kf
    for idx in reactant_indices:
        conc = species_concs_vector[idx]
        exp  = rl_exps[idx]
        forward *= conc ** exp
        
    # Compute backward rate
    backward = kb
    for idx in product_indices:
        conc = species_concs_vector[idx]
        exp  = rl_exps[idx]
        backward *= conc ** exp
        
    change = forward - backward
    
    # Evaluate limiting reactant (to avoid negative concentrations)
    min_ratio = 1e20
    limiting_found = False
    
    for i in range(n_species):
        stoich = rxn_stoichs[i]
        projected = species_concs_vector[i] + change * stoich
        if projected < 0.0:
            if stoich != 0.0:
                ratio = species_concs_vector[i] / abs(stoich)
                if ratio < min_ratio:
                    min_ratio = ratio
                    limiting_found = True
                    
    if limiting_found:
        change = min_ratio
        
    # Compute final concentration rate of change
    result = np.empty(n_species, dtype=species_concs_vector.dtype)
    for i in range(n_species):
        result[i] = change * rxn_stoichs[i]
        
    return result

@njit(cache=True)
def dconcs_dt_custom_rate_f(
    change,
    species_concs_vector, rxn_stoichs, rl_exps,
    reactant_indices, product_indices
    ):
    
    n_species = species_concs_vector.size
    
    # Early exit if any reactant is exhausted
    for idx in reactant_indices:
        if species_concs_vector[idx] <= 1e-20:
            return np.zeros(n_species, dtype=species_concs_vector.dtype)
        
    # # Compute forward rate
    # forward = kf
    # for idx in reactant_indices:
    #     conc = species_concs_vector[idx]
    #     exp  = rl_exps[idx]
    #     forward *= conc ** exp
        
    # # Compute backward rate
    # backward = kb
    # for idx in product_indices:
    #     conc = species_concs_vector[idx]
    #     exp  = rl_exps[idx]
    #     backward *= conc ** exp
        
    # change = forward - backward
    
    # Evaluate limiting reactant (to avoid negative concentrations)
    min_ratio = 1e20
    limiting_found = False
    
    for i in range(n_species):
        stoich = rxn_stoichs[i]
        projected = species_concs_vector[i] + change * stoich
        if projected < 0.0:
            if stoich != 0.0:
                ratio = species_concs_vector[i] / abs(stoich)
                if ratio < min_ratio:
                    min_ratio = ratio
                    limiting_found = True
                    
    if limiting_found:
        change = min_ratio
        
    # Compute final concentration rate of change
    result = np.empty(n_species, dtype=species_concs_vector.dtype)
    for i in range(n_species):
        result[i] = change * rxn_stoichs[i]
        
    return result


[docs] class Reaction(AbstractReaction): """ A complete chemical reaction object that includes kinetic parameters and rate law information. This class extends `AbstractReaction` by adding support for reaction rate constants, rate law exponents, reversible/irreversible behavior, and dynamic concentration updates. Includes logic to compute the rate of change in concentrations. One and only one of the following must be provided to define stoichiometry: - reactants and products (as dict or list) - chem_equation - stoichiometry Parameters ---------- ID : str Identifier for the reaction. species_system : SpeciesSystem System containing the chemical species. kf : float Forward reaction rate constant. kb : float, optional Backward reaction rate constant (defaults to 0 for irreversible reactions). rate_f : function, optional Custom rate function (overrides any value parsed from str chem_equation). Must accept species_concs_vector, rxn_stoichs, rl_exps, reactant_indices, and product_indices as arguments (see Reaction.get_dconcs_dt for example use) and additional keyword arguments for parameters from Reaction.rate_params. rate_params: dict, optional Dictionary of parameters passed as keyword arguments to Reaction.rate_f. reactants : list or dict, optional Reactant species. If dict, keys are species IDs and values are stoichiometric coefficients. products : list or dict, optional Product species. Same format as `reactants`. chem_equation : ChemicalEquation, optional Equation object defining reaction stoichiometry. stoichiometry : array-like, optional Stoichiometric coefficients for all species. exponents : array-like, optional Rate law exponents for each species (same length as stoichiometry). get_exponents_from_stoich : bool, optional If True, use the absolute value of stoichiometry as the rate law exponents. freeze_kf : bool, optional If True, prevents modification of the forward rate constant, kf. freeze_kb : bool, optional If True, prevents modification of the backward rate constant, kb. """
[docs] def __init__(self, ID, species_system, kf, kb=0., rate_f=None, rate_params=None, reactants=None, products=None, chem_equation=None, stoichiometry=None, exponents=None, is_transport=False, # note all reactants must have the same compartment and all products must have the same compartment # also, a transport reaction should only dictate how concentrations change is_multicompartment=False, # all reactants must have the same compartment; products may all have mutually different compartments get_exponents_from_stoich=False, freeze_kf=False, freeze_kb=False, atol=1e-6, ): AbstractReaction.__init__(self, ID, species_system, reactants=reactants, products=products, chem_equation=chem_equation, stoichiometry=stoichiometry,) stoich=self.stoichiometry if kf is None: kf = 0. if kb is None: kb = 0. self.rate_f = rate_f if rate_params is None: rate_params = {} rate_params['kf'] = float(kf) rate_params['kb'] = float(kb) self.rate_params = rate_params self._freeze_params = set() # self._freeze_kf = freeze_kf # self._freeze_kb = freeze_kb self.get_exponents_from_stoich = get_exponents_from_stoich if exponents is None: if get_exponents_from_stoich: self.exponents = np.abs(len(stoich)) else: self.exponents = np.ones(len(stoich)) else: self.exponents = exponents self.reactant_indices = reactant_indices = np.where(stoich<0)[0] self.product_indices = product_indices = np.where(stoich>0)[0] self._load_full_string() all_sps = species_system.all_sps try: self.source = all_sps[reactant_indices[0]].compartment except: breakpoint() self.is_transport = is_transport self.is_multicompartment = is_multicompartment if not (is_transport or is_multicompartment): self.destination = self.source elif is_transport: self.destination = all_sps[product_indices[0]].compartment self.atol = atol
@property def kf(self): """ The forward reaction rate constant, kf. Returns ------- float Forward reaction rate constant, kf. """ return self.rate_params['kf'] @kf.setter def kf(self, new_kf): """ Set the forward reaction rate constant, kf. If 'kf' is `_freeze_params`, the value is not updated and a warning is issued. Parameters ---------- new_kf : float New value for the forward reaction rate constant, kf. """ if not 'kf' in self._freeze_params: self.rate_params['kf'] = new_kf else: warn(f'kf for Reaction {self.ID} was not changed to {new_kf} as _freeze_kf was True.\n', RuntimeWarning) @property def kb(self): """ The backward reaction rate constant, kf. Returns ------- float Forward reaction rate constant, kb. """ return self.rate_params['kb'] @kb.setter def kb(self, new_kb): """ Set the backward reaction rate constant, kb. If 'kB' is `_freeze_params`, the value is not updated and a warning is issued. Parameters ---------- new_kb : float New value for the backward reaction rate constant, kb. """ if not 'kb' in self._freeze_params: self.rate_params['kb'] = new_kb else: warn(f'kb for Reaction {self.ID} was not changed to {new_kb} as _freeze_kb was True.\n', RuntimeWarning)
[docs] def get_dconcs_dt(self): """ Calculate the net rate of change in species concentrations for this reaction, using the current kinetic parameters and species concentrations. If self.species_system.log_transformed is True, returns rate of change of np.log(concentrations) instead. Returns ------- ndarray Change in concentration; or zeros if kf and kb are both zero; or change in log(concentrations) if self.log_transformed is True. """ rate_f = self.rate_f sp_sys = self.species_system species_concs_vector = sp_sys._concentrations stoichiometry = self.stoichiometry exponents = self.exponents reactant_indices = self.reactant_indices product_indices = self.product_indices rate_params = self.rate_params dconcs_dt_vector = np.zeros(shape=species_concs_vector.shape) if np.all(species_concs_vector[reactant_indices]<self.atol): pass elif rate_f is not None: change = rate_f(species_concs_vector=species_concs_vector, rxn_stoichs=stoichiometry, rl_exps=exponents, reactant_indices=reactant_indices, product_indices=product_indices, **rate_params) dconcs_dt_vector = dconcs_dt_custom_rate_f(change=change, species_concs_vector=species_concs_vector, rxn_stoichs=stoichiometry, rl_exps=exponents, reactant_indices=reactant_indices, product_indices=product_indices,) else: kf, kb = self.kf, self.kb if not kf==kb==0: dconcs_dt_vector = dconcs_dt_v0_3(kf=kf, kb=kb, species_concs_vector=species_concs_vector, rxn_stoichs=stoichiometry, rl_exps=exponents, reactant_indices=reactant_indices, product_indices=product_indices) else: dconcs_dt_vector = np.zeros(shape=species_concs_vector.shape) if self.is_transport: # multiply by volume ratio for conservation of mass get_comp_vol = sp_sys.get_compartment_volume vol_ratio = get_comp_vol(self.source)/get_comp_vol(self.destination) for i in product_indices: dconcs_dt_vector[i] *= vol_ratio elif self.is_multicompartment: # multiply each species conc change by volume ratio for conservation of mass get_comp_vol = sp_sys.get_compartment_volume src_vol = get_comp_vol(self.source) all_sps = sp_sys.all_sps for i in product_indices: dconcs_dt_vector[i] *= src_vol/get_comp_vol(all_sps[i].compartment) return dconcs_dt_vector
def _load_full_string(self): """ Internal method to format and cache string representations of the reaction's left-hand side, right-hand side, and kinetic parameters. """ lhs = '' rhs = '' rate_f = self.rate_f arrow = '' if rate_f is not None: arrow = '<->' elif rate_f is None and self.kb==0.: arrow = '->' else: arrow = '<->' for chem, stoich in zip(self.species_system.all_sps, self.stoichiometry): if stoich<0: if not lhs=='': lhs+= ' + ' if not np.abs(stoich)==1.: lhs+= str(-stoich) + ' ' lhs+= chem.ID elif stoich>0: if not rhs=='': rhs+= ' + ' if not np.abs(stoich)==1.: rhs+= str(stoich) + ' ' rhs+= chem.ID # param_info = f'kf={self.kf}' param_info = ', '.join([f'{k}={v}' for k, v in self.rate_params.items()]) if not arrow=='<->': kb = self.kb param_info = param_info.replace(', ' + f'kb={kb}', '') param_info = param_info.replace(f'kb={kb}', '') self._lhs_string = lhs self._arrow_string = arrow self._rhs_string = rhs self._param_info_string = param_info full_string = f'{self.ID}: Reaction(' + lhs + ' ' + arrow + ' ' + rhs + '; ' if rate_f is not None: full_string += 'custome rate_f; ' full_string+= param_info + ')' self._full_string = full_string
[docs] def get_equation_str(self): return self._lhs_string + ' ' + self._arrow_string + ' ' + self._rhs_string
def __str__(self): self._load_full_string() return self._full_string def __repr__(self): return self.__str__()
[docs] @classmethod def from_equation(cls, ID, chem_equation, species_system, kf=None, # overrides any parameter info in the chem_equation string kb=None, # overrides any parameter info in the chem_equation string rate_f=None, # overrides any parameter info in the chem_equation string rate_params=None, # overrides any parameter info in the chem_equation string exponents=None, get_exponents_from_stoich=False, is_transport=False, is_multicompartment=False): """ Create a Reaction object from a ChemicalEquation object or string. Parameters ---------- ID : str Identifier for the Reaction. chem_equation : str or ChemicalEquation Reaction as a string or ChemicalEquation object. String represents the chemical equation and may or may not include kf and kb (if not included, must pass at least kf -- kb defaults to zero -- or both kf and kb as arguments). E.g., "A + B -> C", "A + B <-> C", "A + B -> C, kf=20", "A + B <-> C, kf=20, kb=40.5". species_system : SpeciesSystem System containing the involved species. kf : float, optional Forward rate constant (overrides any value parsed from str chem_equation). kb : float, optional Backward rate constant (overrides any value parsed from str chem_equation). rate_f : function, optional Custom rate function (overrides any value parsed from str chem_equation). Must accept species_concs_vector, rxn_stoichs, rl_exps, reactant_indices, and product_indices as arguments (see Reaction.get_dconcs_dt for example use) and additional keyword arguments for parameters from Reaction.rate_params. rate_params: dict, optional Dictionary of parameters passed as keyword arguments to Reaction.rate_f. Each parameter value can be a float or 1-d array. exponents : ndarray, optional Rate law exponents. get_exponents_from_stoich : bool, optional Whether to use absolute stoichiometry as rate law exponents. Returns ------- Reaction Instantiated Reaction object. """ kf_, kb_ = None, None freeze_kb = False if isinstance(chem_equation, str): freeze_kb = '->' in chem_equation and not '<->' in chem_equation chem_equation, kf_, kb_ = ChemicalEquation.from_string(ID=ID+'_eqn', equation_str=chem_equation, species_system=species_system) if kf is None: kf = kf_ if kb is None: kb = kb_ return cls(ID=ID, species_system=species_system, chem_equation=chem_equation, kf=kf, kb=kb, rate_f=rate_f, rate_params=rate_params, exponents=exponents, freeze_kf=False, freeze_kb=freeze_kb, get_exponents_from_stoich=get_exponents_from_stoich, is_transport=is_transport, is_multicompartment=is_multicompartment)
Rxn = IrreversibleReaction = ReversibleReaction = IrrevRxn = RevRxn = Reaction