Skip to content

๐Ÿ“š Documentation

Welcome to the PyReactLab-Core documentation! Here you'll find all the information you need to get started with PyReactLab-Core, including installation instructions, usage guides, and API references.

โš—๏ธ Reaction Analysis made easy

pyreactlab_core.core.chem_react

PhaseRule = Literal['gas', 'liquid', 'aqueous', 'solid'] module-attribute

ReactionMode = Literal['<=>', '=>', '='] module-attribute

logger = logging.getLogger(__name__) module-attribute

ChemReact(reaction_mode_symbol: ReactionMode)

Chemical Reaction Utilities

The ChemReact class provides utilities for analyzing and processing chemical reactions in various phases and conditions. These reactions can be represented in different ways depending on the dominant factors influencing them:

  • Use = โ†’ when thermodynamics dominates
  • Use <=> โ†’ when kinetics + thermodynamics matter
  • Use => โ†’ when kinetics only matter

Initialize the ChemReactUtils class.

Parameters

reaction_mode_symbol : ReactionMode, optional The symbol used to separate reactants and products in a reaction equation.

Notes
  • Use "<=>" when kinetics + thermodynamics matter
  • Use "=" when thermodynamics dominates
  • Use "=>" when kinetics only matter
Source code in pyreactlab_core/core/chem_react.py
def __init__(
    self,
    reaction_mode_symbol: ReactionMode
):
    """
    Initialize the ChemReactUtils class.

    Parameters
    ----------
    reaction_mode_symbol : ReactionMode, optional
        The symbol used to separate reactants and products in a reaction equation.

    Notes
    -----
    - Use "<=>" when kinetics + thermodynamics matter
    - Use "=" when thermodynamics dominates
    - Use "=>" when kinetics only matter
    """
    self.reaction_mode_symbol = reaction_mode_symbol

P_Ref = PRESSURE_REF_Pa / 100000.0 class-attribute instance-attribute

R = R_CONST_J__molK class-attribute instance-attribute

T_Ref = TEMPERATURE_REF_K class-attribute instance-attribute

available_phases = PhaseRule.__args__ class-attribute instance-attribute

reaction_mode_symbol = reaction_mode_symbol instance-attribute

system_inputs: Dict[str, Any] property

Get the system inputs.

analyze_overall_reactions(reactions: List[Dict[str, str]]) -> Dict[str, List[str]]

Analyze a list of chemical reactions and classify species as consumed, produced, or intermediate.

Parameters

reactions : list A list of dictionaries, each containing a reaction string and its name.

Returns

dict A dictionary containing three lists: 'consumed', 'produced', and 'intermediate'. - 'consumed': List of species consumed in the reactions. - 'produced': List of species produced in the reactions. - 'intermediate': List of species that are both consumed and produced in the reactions.

Source code in pyreactlab_core/core/chem_react.py
def analyze_overall_reactions(
        self,
        reactions: List[Dict[str, str]]
) -> Dict[str, List[str]]:
    """
    Analyze a list of chemical reactions and classify species as consumed, produced, or intermediate.

    Parameters
    ----------
    reactions : list
        A list of dictionaries, each containing a reaction string and its name.

    Returns
    -------
    dict
        A dictionary containing three lists: 'consumed', 'produced', and 'intermediate'.
        - 'consumed': List of species consumed in the reactions.
        - 'produced': List of species produced in the reactions.
        - 'intermediate': List of species that are both consumed and produced in the reactions.
    """
    try:
        # Initialize sets for all reactants and products
        all_reactants = set()
        all_products = set()

        # Iterate over reactions
        for reaction in reactions:
            # NOTE: reaction string
            # ! Split the reaction into left and right sides
            sides = reaction['reaction'].split(
                self.reaction_mode_symbol.strip()
            )

            # NOTE: Define a regex pattern to match reactants/products
            # pattern = r'(\d*)?(\w+)\((\w)\)'
            pattern = r'(?:(\d*\.?\d+)\s*)?([A-Z][a-zA-Z0-9]*)\s*(?:\((\w)\))?'

            # SECTION: Extract reactants
            reactants = re.findall(pattern, sides[0])
            reactants = [r[1] for r in reactants]

            # SECTION: Extract products
            products = re.findall(pattern, sides[1])
            products = [p[1] for p in products]

            # NOTE: Update sets
            all_reactants.update(reactants)
            all_products.update(products)

        # Classify species
        consumed = list(all_reactants - all_products)
        produced = list(all_products - all_reactants)
        intermediate = list(all_reactants & all_products)

        # res
        res = {
            'consumed': consumed,
            'produced': produced,
            'intermediate': intermediate
        }

        return res
    except Exception as e:
        raise Exception(f"Error analyzing overall reactions: {e}")

analyze_overall_reactions_v2(reactions: Dict[str, Any]) -> Dict[str, List[str]]

Analyze a list of chemical reactions and classify species as consumed, produced, or intermediate (version 2).

Parameters

reactions : list A list of dictionaries, each containing a reaction string and its name.

Returns

dict A dictionary containing three lists: 'consumed', 'produced', and 'intermediate'. - 'consumed': List of species consumed in the reactions. - 'produced': List of species produced in the reactions. - 'intermediate': List of species that are both consumed and produced in the reactions.

Source code in pyreactlab_core/core/chem_react.py
def analyze_overall_reactions_v2(
    self,
    reactions: Dict[str, Any]
) -> Dict[str, List[str]]:
    """
    Analyze a list of chemical reactions and classify species as consumed, produced, or intermediate (version 2).

    Parameters
    ----------
    reactions : list
        A list of dictionaries, each containing a reaction string and its name.

    Returns
    -------
    dict
        A dictionary containing three lists: 'consumed', 'produced', and 'intermediate'.
        - 'consumed': List of species consumed in the reactions.
        - 'produced': List of species produced in the reactions.
        - 'intermediate': List of species that are both consumed and produced in the reactions.
    """
    try:
        # Initialize sets for all reactants and products
        all_reactants = set()
        all_products = set()

        # Iterate over reactions
        for reaction_name, reaction_value in reactions.items():
            # SECTION: Extract reactants
            reactants = reaction_value['reactants_names']

            # SECTION: Extract products
            products = reaction_value['products_names']

            # NOTE: Update sets
            all_reactants.update(reactants)
            all_products.update(products)

        # Classify species
        consumed = list(all_reactants - all_products)
        produced = list(all_products - all_reactants)
        intermediate = list(all_reactants & all_products)

        # res
        res = {
            'consumed': consumed,
            'produced': produced,
            'intermediate': intermediate
        }

        return res
    except Exception as e:
        raise Exception(f"Error analyzing overall reactions: {e}")

analyze_reaction(reaction_pack: Dict[str, str], phase_rule: Optional[str] = None) -> Dict[str, Any]

Analyze a chemical reaction and extract relevant information.

Parameters

reaction_pack : dict A dictionary containing the reaction and its name. phase_rule : str, optional The phase of the reaction, which can be 'gas', 'liquid', 'aqueous', or 'solid'.

Returns

dict A dictionary containing the analyzed reaction data, including reactants, products, reaction coefficient, and carbon count.

Source code in pyreactlab_core/core/chem_react.py
def analyze_reaction(
        self,
        reaction_pack: Dict[str, str],
        phase_rule: Optional[str] = None
) -> Dict[str, Any]:
    """
    Analyze a chemical reaction and extract relevant information.

    Parameters
    ----------
    reaction_pack : dict
        A dictionary containing the reaction and its name.
    phase_rule : str, optional
        The phase of the reaction, which can be 'gas', 'liquid', 'aqueous', or 'solid'.

    Returns
    -------
    dict
        A dictionary containing the analyzed reaction data, including reactants,
        products, reaction coefficient, and carbon count.
    """
    try:
        # NOTE: check reaction_pack
        if not isinstance(reaction_pack, dict):
            raise ValueError("reaction_pack must be a dictionary.")

        if 'reaction' not in reaction_pack or 'name' not in reaction_pack:
            raise ValueError(
                "reaction_pack must contain 'reaction' and 'name' keys.")

        # NOTE: check phase
        # set phase
        phase_set = self.phase_rule_analysis(phase_rule)

        # SECTION: extract data from reaction
        reaction = reaction_pack['reaction']
        name = reaction_pack['name']

        # ! Split the reaction into left and right sides
        sides = reaction.split(self.reaction_mode_symbol.strip())

        # Define a regex pattern to match reactants/products
        # pattern = r'(\d*)?(\w+)\((\w)\)'
        # pattern = r'(\d*\.?\d+)?(\w+)\((\w)\)'
        # pattern = r'(?:(\d*\.?\d+)\s*)?([A-Z][a-zA-Z0-9]*)\s*(?:\((\w)\))?'
        # NOTE: multi-purpose pattern
        pattern = r'(?:(\d*\.?\d+)\s*)?(e(?:\{-?1?\}|[+-])?|\[[^\]\s]+\](?:\d+)?(?:\{[^{}\s]+\})?|(?:(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*)(?:[ยท*](?:\d+)?(?:(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*))*(?:\{[^{}\s]+\})?)\s*(?:\((g|l|s|aq)\))?'

        # SECTION: SECTION: Extract reactants and products
        # Extract reactants
        reactants = re.findall(pattern, sides[0])
        reactants = [
            {
                'coefficient': float(r[0]) if r[0] else float(1),
                'molecule': r[1],
                'state': r[2] if r[2] else phase_set
            } for r in reactants
        ]

        # NOTE: reactants full name
        reactants_names = []
        # loop over reactants
        for i, item in enumerate(reactants):
            # ! check phase_set and phase_rule
            if phase_rule is None:
                # check item state
                if item['state'] == 'empty':
                    raise ValueError(
                        f"Phase rule is empty but reactant '{item['molecule']}' has state '{item['state']}'.")
            else:
                # check item state
                if item['state'] != phase_set:
                    raise ValueError(
                        f"Phase rule is '{phase_set}' but reactant '{item['molecule']}' has state '{item['state']}'.")

            # generate full name
            full_name = item['molecule'] + "-" + item['state']
            # append to list
            reactants_names.append(full_name)
            # update source
            reactants[i]['molecule_state'] = full_name

        # Extract products
        products = re.findall(pattern, sides[1])
        products = [
            {
                'coefficient': float(p[0]) if p[0] else float(1),
                'molecule': p[1],
                'state': p[2] if p[2] else phase_set
            } for p in products
        ]

        # NOTE: products full name
        products_names = []
        # loop over products
        for i, item in enumerate(products):
            # ! check phase_set and phase_rule
            if phase_rule is None:
                # check item state
                if item['state'] == 'empty':
                    raise ValueError(
                        f"Phase rule is empty but product '{item['molecule']}' has state '{item['state']}'.")
            else:
                # check item state
                if item['state'] != phase_set:
                    raise ValueError(
                        f"Phase rule is '{phase_set}' but product '{item['molecule']}' has state '{item['state']}'.")

            # generate full name
            full_name = item['molecule'] + "-" + item['state']
            # append to list
            products_names.append(full_name)
            # update source
            products[i]['molecule_state'] = full_name

        # SECTION: all components
        all_components = reactants_names + products_names
        # >> remove duplicates
        all_components: List[str] = list(set(all_components))

        # SECTION: reaction coefficient and stoichiometry
        reaction_coefficients = 0
        reaction_stoichiometry = {}
        reaction_stoichiometry_matrix = []

        # iterate over reactants and products to calculate reaction coefficients
        # NOTE: reactants
        for item in reactants:
            reaction_coefficients += item['coefficient']
            reaction_stoichiometry[
                item['molecule_state']
            ] = -1 * item['coefficient']
            # append to stoichiometric matrix
            reaction_stoichiometry_matrix.append(
                -1 * item['coefficient']
            )

        # NOTE: products
        for item in products:
            reaction_coefficients -= item['coefficient']
            reaction_stoichiometry[
                item['molecule_state']
            ] = item['coefficient']
            # append to stoichiometric matrix
            reaction_stoichiometry_matrix.append(
                item['coefficient']
            )

        # SECTION: Carbon count for each component
        carbon_count = {}
        for r in reactants:
            carbon_count[r['molecule_state']] = self.count_carbon(
                r['molecule'],
                r['coefficient']
            )
        for p in products:
            carbon_count[p['molecule_state']] = self.count_carbon(
                p['molecule'],
                p['coefficient']
            )

        # SECTION: reaction state
        reaction_state = {}
        for r in reactants:
            # set
            reaction_state[r['molecule_state']] = r['state']
        for p in products:
            # set
            reaction_state[p['molecule_state']] = p['state']

        # NOTE: reaction phase
        # reaction
        reaction_phase = self.determine_reaction_phase(
            reaction_state
        )

        # NOTE: unique states
        state_count = self.count_reaction_states(
            reaction_state
        )

        # SECTION: Symbolic reaction without states
        symbolic_reaction = ""
        symbolic_unbalanced_reaction = ""

        # reactants
        for i, r in enumerate(reactants):
            if i == 0:
                if r['coefficient'] == 1:
                    symbolic_reaction += f"{r['molecule']}"
                else:
                    symbolic_reaction += f"{r['coefficient']}{r['molecule']}"
                # unbalanced
                symbolic_unbalanced_reaction += f"{r['molecule']}"
            else:
                if r['coefficient'] == 1:
                    symbolic_reaction += f" + {r['molecule']}"
                else:
                    symbolic_reaction += f" + {r['coefficient']}{r['molecule']}"
                # unbalanced
                symbolic_unbalanced_reaction += f" + {r['molecule']}"
        # reaction mode symbol
        symbolic_reaction += f" {self.reaction_mode_symbol} "
        symbolic_unbalanced_reaction += f" {self.reaction_mode_symbol} "

        # products
        for i, p in enumerate(products):
            if i == 0:
                if p['coefficient'] == 1:
                    symbolic_reaction += f"{p['molecule']}"
                else:
                    symbolic_reaction += f"{p['coefficient']}{p['molecule']}"
                # unbalanced
                symbolic_unbalanced_reaction += f"{p['molecule']}"
            else:
                if p['coefficient'] == 1:
                    symbolic_reaction += f" + {p['molecule']}"
                else:
                    symbolic_reaction += f" + {p['coefficient']}{p['molecule']}"
                # unbalanced
                symbolic_unbalanced_reaction += f" + {p['molecule']}"

        # SECTION: set id for each component
        # NOTE: component ids
        component_ids = {}
        for i, r in enumerate(reactants):
            component_ids[r['molecule_state']] = i+1
        offset = len(reactants)
        for i, p in enumerate(products):
            component_ids[p['molecule_state']] = offset + i + 1

        # res
        res = {
            'name': name,
            'reaction': reaction,
            "component_ids": component_ids,
            "all_components": all_components,
            "symbolic_reaction": symbolic_reaction,
            "symbolic_unbalanced_reaction": symbolic_unbalanced_reaction,
            'reactants': reactants,
            'reactants_names': reactants_names,
            'products': products,
            'products_names': products_names,
            'reaction_coefficients': reaction_coefficients,
            'reaction_stoichiometry': reaction_stoichiometry,
            'reaction_stoichiometry_matrix': reaction_stoichiometry_matrix,
            'carbon_count': carbon_count,
            'reaction_state': reaction_state,
            'reaction_phase': reaction_phase,
            'state_count': state_count,
        }

        return res
    except Exception as e:
        raise Exception(f"Error analyzing reaction: {e}")

count_carbon(molecule: str, coefficient: float) -> float

Count the number of carbon atoms in a molecule.

Parameters

molecule : str The chemical formula of the molecule. coefficient : float The coefficient of the molecule in the reaction.

Returns

float The number of carbon atoms in the molecule multiplied by the coefficient.

Source code in pyreactlab_core/core/chem_react.py
def count_carbon(self, molecule: str, coefficient: float) -> float:
    """
    Count the number of carbon atoms in a molecule.

    Parameters
    ----------
    molecule : str
        The chemical formula of the molecule.
    coefficient : float
        The coefficient of the molecule in the reaction.

    Returns
    -------
    float
        The number of carbon atoms in the molecule multiplied by the coefficient.
    """
    try:
        # NOTE: check molecule
        if not isinstance(molecule, str):
            raise ValueError("Molecule must be a string.")

        # NOTE: check coefficient
        if not isinstance(coefficient, (int, float)):
            raise ValueError("Coefficient must be an integer or float.")

        # NOTE: Check if the molecule contains carbon atoms
        if re.search(r'C(?![a-z])', molecule):
            carbon_count = len(re.findall(
                r'C(?![a-z])', molecule)) * coefficient
            return carbon_count
        else:
            return 0.0
    except Exception as e:
        raise Exception(
            f"Error counting carbon in molecule '{molecule}': {e}")

count_reaction_states(reaction_dict: Dict[str, str]) -> Dict[str, int]

Counts the number of unique states in a reaction as g, l, aq, or s.

Parameters

reaction_dict: dict A dictionary where keys are component names and values are their states.

Returns

dict A dictionary with the counts of each state (g, l, aq, s).

Source code in pyreactlab_core/core/chem_react.py
def count_reaction_states(self, reaction_dict: Dict[str, str]) -> Dict[str, int]:
    '''
    Counts the number of unique states in a reaction as g, l, aq, or s.

    Parameters
    ----------
    reaction_dict: dict
        A dictionary where keys are component names and values are their states.

    Returns
    -------
    dict
        A dictionary with the counts of each state (g, l, aq, s).
    '''
    try:
        # Collect the states from the values in the dictionary
        available_states = reaction_dict.values()

        # how many g, l, aq, or s
        state_count = {
            'g': 0,
            'l': 0,
            'aq': 0,
            's': 0
        }

        # Count the occurrences of each state
        for state in available_states:
            if state in state_count:
                state_count[state] += 1

        return state_count

    except Exception as e:
        raise Exception(f"Error determining reaction phase: {e}")

define_component_id(reaction_res)

Define component ID

Parameters

reaction_res: dict reaction_res

Returns

component_list: list component list component_dict: dict component dict comp_list: list component list comp_coeff: list component coefficient

Source code in pyreactlab_core/core/chem_react.py
def define_component_id(self, reaction_res):
    '''
    Define component ID

    Parameters
    ----------
    reaction_res: dict
        reaction_res

    Returns
    -------
    component_list: list
        component list
    component_dict: dict
        component dict
    comp_list: list
        component list
    comp_coeff: list
        component coefficient
    '''
    try:
        # NOTE: component list
        component_list = []

        # SECTION: Iterate over reactions and extract reactants and products
        for item in reaction_res:
            for reactant in reaction_res[item]['reactants']:
                component_list.append(reactant['molecule'])
            for product in reaction_res[item]['products']:
                component_list.append(product['molecule'])

        # remove duplicate
        component_list = list(set(component_list))

        # component id: key, value
        component_dict = {}
        for i, item in enumerate(component_list):
            component_dict[item] = i

        # SECTION: Initialize the component list
        comp_list = [
            {i: 0.0 for i in component_dict.keys()} for _ in range(len(reaction_res))
        ]

        # NOTE: Iterate over reactions and components
        for j, reaction in enumerate(reaction_res):
            for item in component_dict.keys():
                # Check reactants
                for reactant in reaction_res[reaction]['reactants']:
                    if reactant['molecule'] == item:
                        comp_list[j][item] = -1 * \
                            float(reactant['coefficient'])

                # Check products
                for product in reaction_res[reaction]['products']:
                    if product['molecule'] == item:
                        comp_list[j][item] = float(product['coefficient'])

        # Convert comp_list to comp_matrix
        comp_coeff = [
            [comp_list[j][item] for item in component_dict.keys()] for j in range(len(reaction_res))
        ]

        # res
        return component_list, component_dict, comp_list, comp_coeff
    except Exception as e:
        raise Exception(f"Error defining component ID: {e}")

define_component_id_v2(reaction_res) staticmethod

Define component ID (version 2)

Parameters

reaction_res: dict reaction_res

Returns

component_list: list component list component_dict: dict component dict comp_list: list component list comp_coeff: list component coefficient component_state_list: list component state list

Source code in pyreactlab_core/core/chem_react.py
@staticmethod
def define_component_id_v2(reaction_res):
    '''
    Define component ID (version 2)

    Parameters
    ----------
    reaction_res: dict
        reaction_res

    Returns
    -------
    component_list: list
        component list
    component_dict: dict
        component dict
    comp_list: list
        component list
    comp_coeff: list
        component coefficient
    component_state_list: list
        component state list
    '''
    try:
        # NOTE: component list
        component_list = []
        component_state_list = []

        # SECTION: Iterate over reactions and extract reactants and products
        for item in reaction_res:
            # reactants
            for reactant in reaction_res[item]['reactants']:
                component_list.append(reactant['molecule_state'])
                # add molecule and molecule state
                component_state_list.append(
                    (
                        reactant['molecule'],
                        reactant['state'],
                        reactant['molecule_state']
                    )
                )
            # products
            for product in reaction_res[item]['products']:
                component_list.append(product['molecule_state'])
                # add molecule and molecule state
                component_state_list.append(
                    (
                        product['molecule'],
                        product['state'],
                        product['molecule_state']
                    )
                )

        # remove duplicate
        component_list = list(set(component_list))
        # remove duplicates in component_state_list
        component_state_list = list(set(component_state_list))

        # component id: key, value
        component_dict = {}

        # loop over component list
        for i, item in enumerate(component_list):
            component_dict[item] = i

        # SECTION: Initialize the component list
        comp_list = [
            {i: 0.0 for i in component_dict.keys()} for _ in range(len(reaction_res))
        ]

        # NOTE: Iterate over reactions and components
        for j, reaction in enumerate(reaction_res):
            for item in component_dict.keys():
                # Check reactants
                for reactant in reaction_res[reaction]['reactants']:
                    if reactant['molecule_state'] == item:
                        comp_list[j][item] = -1 * \
                            float(reactant['coefficient'])

                # Check products
                for product in reaction_res[reaction]['products']:
                    if product['molecule_state'] == item:
                        comp_list[j][item] = float(product['coefficient'])

        # Convert comp_list to comp_matrix
        comp_coeff = [
            [comp_list[j][item] for item in component_dict.keys()] for j in range(len(reaction_res))
        ]

        # res
        return (
            component_list,
            component_dict,
            comp_list,
            comp_coeff,
            component_state_list
        )
    except Exception as e:
        raise Exception(f"Error defining component ID: {e}")

determine_reaction_phase(reaction_dict: Dict[str, str]) -> str

Determine the phase of a reaction based on the states of its components.

Parameters

reaction_dict: dict A dictionary where keys are component names and values are their states.

Returns

str The phase of the reaction, which can be 'gas', 'liquid', 'aqueous', 'solid', or a combination of these.

Source code in pyreactlab_core/core/chem_react.py
def determine_reaction_phase(self, reaction_dict: Dict[str, str]) -> str:
    '''
    Determine the phase of a reaction based on the states of its components.

    Parameters
    ----------
    reaction_dict: dict
        A dictionary where keys are component names and values are their states.

    Returns
    -------
    str
        The phase of the reaction, which can be 'gas', 'liquid', 'aqueous', 'solid', or a combination of these.
    '''
    try:
        # Collect the states from the values in the dictionary
        available_states = set(reaction_dict.values())

        # Convert the states to full names
        state_names = self.state_name_set(available_states)

        # Determine phase based on the number of unique states
        if len(state_names) == 1:
            return f'{state_names[0]}'
        else:
            return f'{"-".join(state_names)}'
    except Exception as e:
        raise Exception(f"Error determining reaction phase: {e}")

phase_rule_analysis(phase_rule: Optional[str] = None) -> str

Analyze the phase rule of a reaction.

Parameters

phase_rule : str, optional The phase rule of the reaction.

Returns

phase_symbol : str The phase symbol of the reaction, which can be 'g', 'l', or 'empty'.

Source code in pyreactlab_core/core/chem_react.py
def phase_rule_analysis(self, phase_rule: Optional[str] = None) -> str:
    """
    Analyze the phase rule of a reaction.

    Parameters
    ----------
    phase_rule : str, optional
        The phase rule of the reaction.

    Returns
    -------
    phase_symbol : str
        The phase symbol of the reaction, which can be 'g', 'l', or 'empty'.
    """
    try:
        # SECTION: check phase rule
        # Check if phase_rule is None
        if phase_rule is None or phase_rule == 'None':
            # set default phase
            return 'empty'

        # SECTION: check phase rule
        # Check if the phase rule is valid
        if phase_rule not in self.available_phases:
            raise ValueError(
                f"Phase rule must be {', '.join(self.available_phases)}.")

        # check phase
        if phase_rule == 'gas':
            phase_symbol = 'g'
        elif phase_rule == 'liquid':
            phase_symbol = 'l'
        elif phase_rule == 'aqueous':
            phase_symbol = 'aq'
        elif phase_rule == 'solid':
            phase_symbol = 's'
        else:
            phase_symbol = 'empty'

        return phase_symbol
    except Exception as e:
        raise Exception(f"Error analyzing phase rule: {e}")

reaction_phase_analysis(reaction_res: Dict[str, Any])

Analyze the reaction phase and separate reactants and products by their phases.

Parameters

reaction_res: dict A dictionary containing the reaction results, including reactants and products.

Returns
Source code in pyreactlab_core/core/chem_react.py
def reaction_phase_analysis(
    self,
    reaction_res: Dict[str, Any],
):
    '''
    Analyze the reaction phase and separate reactants and products by their phases.

    Parameters
    ----------
    reaction_res: dict
        A dictionary containing the reaction results, including reactants and products.

    Returns
    -------

    '''
    try:
        # NOTE: initialize phase dict
        phase_dict = {
            'g': [],
            'l': [],
            'aq': [],
            's': []
        }

        # SECTION: Iterate over reactions and classify reactants and products by phase
        for reaction_name, reaction_data in reaction_res.items():
            # reactants
            for reactant in reaction_data['reactants']:
                phase = reactant['state']
                if phase in phase_dict:
                    # ! molecule state
                    phase_dict[phase].append(reactant['molecule_state'])
                else:
                    raise ValueError(
                        f"Unknown phase '{phase}' for reactant '{reactant['molecule']}'.")

            # products
            for product in reaction_data['products']:
                phase = product['state']
                if phase in phase_dict:
                    # ! molecule state
                    phase_dict[phase].append(product['molecule_state'])
                else:
                    raise ValueError(
                        f"Unknown phase '{phase}' for product '{product['molecule']}'.")

        # NOTE: remove duplicates in each phase
        for phase in phase_dict:
            phase_dict[phase] = list(set(phase_dict[phase]))

        # res
        return phase_dict
    except Exception as e:
        raise Exception(f"Error analyzing reaction phase: {e}")

state_name_set(state_set: set) -> List[str]

Convert state set to full names

Parameters

state_set: set Set of states

Returns

state_names: list List of full state names

Source code in pyreactlab_core/core/chem_react.py
def state_name_set(self, state_set: set) -> List[str]:
    '''
    Convert state set to full names

    Parameters
    ----------
    state_set: set
        Set of states

    Returns
    -------
    state_names: list
        List of full state names
    '''
    try:
        state_dict = {
            'g': 'gas',
            'l': 'liquid',
            'aq': 'aqueous',
            's': 'solid'
        }

        # Map the states from the set to their full names
        return [state_dict[state] for state in state_set]
    except Exception as e:
        raise Exception(f"Error converting state set to full names: {e}")

โš–๏ธ Reaction Stoichiometry made easy

pyreactlab_core.docs.chem_balance

ARROW_RE = re.compile('\\s*(<=>|=>|->|=)\\s*') module-attribute

logger = logging.getLogger(__name__) module-attribute

Species(raw: str, formula: str, charge: int, atoms: Dict[str, int]) dataclass

atoms: Dict[str, int] instance-attribute

charge: int instance-attribute

formula: str instance-attribute

raw: str instance-attribute

is_electron() -> bool

Source code in pyreactlab_core/docs/chem_balance.py
def is_electron(self) -> bool:
    return self.raw == "e-" and self.charge == -1 and self.atoms == {}

balance(equation: str | Reaction, method: str = 'algebraic', medium: str = 'auto') -> Optional[str]

Balance a chemical equation using the specified method.

Parameters

equation : str The unbalanced chemical equation as a string. method : str, optional The balancing method to use. Options are: - "algebraic": Uses algebraic matrix method. - "half": Uses half-reaction method (redox/ionic friendly). - "oxidation": Uses oxidation-number method. Default is "algebraic". medium : str, optional The medium for half-reaction method. Options are: - "auto": Automatically detect medium. - "acid": Acidic medium. - "base": Basic medium. Default is "auto".

Returns

str The balanced chemical equation as a string.

Source code in pyreactlab_core/docs/chem_balance.py
def balance(
        equation: str | Reaction,
        method: str = "algebraic",
        medium: str = "auto"
) -> Optional[str]:
    """
    Balance a chemical equation using the specified method.

    Parameters
    ----------
    equation : str
        The unbalanced chemical equation as a string.
    method : str, optional
        The balancing method to use. Options are:
        - "algebraic": Uses algebraic matrix method.
        - "half": Uses half-reaction method (redox/ionic friendly).
        - "oxidation": Uses oxidation-number method.
        Default is "algebraic".
    medium : str, optional
        The medium for half-reaction method. Options are:
        - "auto": Automatically detect medium.
        - "acid": Acidic medium.
        - "base": Basic medium.
        Default is "auto".

    Returns
    -------
    str
        The balanced chemical equation as a string.
    """
    try:
        # SECTION: Input handling
        # equation: str | Reaction
        if not isinstance(equation, (str, Reaction)):
            logger.error("equation must be a string or Reaction object.")
            return None

        # normalize method
        m = method.lower().strip()
        # >> check
        if m not in ("algebraic", "half", "oxidation"):
            logger.error(
                "Invalid method specified, method must be: algebraic | half | oxidation")
            return None

        # medium check
        medium = medium.lower().strip()
        # >> check
        if medium not in ("auto", "acid", "base"):
            logger.error(
                "Invalid medium specified, medium must be: auto | acid | base")
            return None

        # SECTION: Reaction object extraction
        if isinstance(equation, Reaction):
            equation = equation.symbolic_unbalanced_reaction

        # NOTE: empty check
        if not equation or not equation.strip():
            raise ValueError("Equation string is empty.")

        # SECTION: Equation string extraction
        if m in ("algebraic", "matrix"):
            return balance_algebraic(equation, include_charge="auto")
        if m in ("half", "half-reaction", "ion", "ion-electron"):
            return balance_half_reaction(equation, medium=medium)
        if m in ("oxidation", "oxidation-number", "ox"):
            return balance_oxidation_number(equation)

        logger.error(
            "Invalid method specified,method must be: algebraic | half | oxidation")
        return None
    except Exception as e:
        logging.error(f"Error in balancing equation '{equation}': {e}")
        return None

balance_algebraic(equation: str, include_charge: str = 'auto') -> str

Source code in pyreactlab_core/docs/chem_balance.py
def balance_algebraic(equation: str, include_charge: str = "auto") -> str:
    left_tokens, right_tokens = _split_equation(equation)
    reactants = [parse_species(t) for t in left_tokens]
    products = [parse_species(t) for t in right_tokens]

    if include_charge == "auto":
        use_charge = any(sp.charge != 0 for sp in reactants +
                         products) or any(sp.is_electron() for sp in reactants + products)
    elif include_charge == "yes":
        use_charge = True
    elif include_charge == "no":
        use_charge = False
    else:
        raise ValueError("include_charge must be 'auto', 'yes', or 'no'.")

    A = _build_balance_matrix(reactants, products, include_charge=use_charge)
    basis = _nullspace_rational(A)
    if not basis:
        raise ValueError(
            "No non-trivial solution found. Equation may be impossible to balance.")

    # choose simplest integer basis vector
    candidates = [_fraction_vec_to_small_int(v) for v in basis]
    best = min(candidates, key=lambda ints: sum(abs(x) for x in ints))

    nL = len(reactants)
    lhs = [(best[i], reactants[i]) for i in range(nL) if best[i] != 0]
    rhs = [(best[nL + j], products[j])
           for j in range(len(products)) if best[nL + j] != 0]

    # overall positive
    if any(c < 0 for c, _ in lhs + rhs):
        lhs = [(-c, sp) for c, sp in lhs]
        rhs = [(-c, sp) for c, sp in rhs]

    # gcd normalize
    allc = [c for c, _ in lhs + rhs]
    g = 0
    for c in allc:
        g = _gcd(g, c)
    if g > 1:
        lhs = [(c // g, sp) for c, sp in lhs]
        rhs = [(c // g, sp) for c, sp in rhs]

    return _format_equation(lhs, rhs)

balance_half_reaction(equation: str, medium: str = 'auto') -> str

Redox/ionic-friendly mode: - decides acid/base for medium='auto' without allowing BOTH H+ and OH- - adds only needed helpers on LHS, then solves with charge conservation - moves negative coefficients across, cancels both sides, normalizes - falls back to algebraic for non-ionic reactions

Source code in pyreactlab_core/docs/chem_balance.py
def balance_half_reaction(equation: str, medium: str = "auto") -> str:
    """
    Redox/ionic-friendly mode:
      - decides acid/base for medium='auto' without allowing BOTH H+ and OH-
      - adds only needed helpers on LHS, then solves with charge conservation
      - moves negative coefficients across, cancels both sides, normalizes
      - falls back to algebraic for non-ionic reactions
    """
    left_tokens, right_tokens = _split_equation(equation)
    base_left = [_normalize_token(t) for t in left_tokens]
    base_right = [_normalize_token(t) for t in right_tokens]

    all_base = base_left + base_right
    parsed_base = [parse_species(t) for t in all_base]

    if medium not in ("auto", "acid", "base"):
        raise ValueError("medium must be 'auto', 'acid', or 'base'.")

    if medium == "auto":
        md = _detect_medium(all_base)
        if md == "unknown":
            # If not ionic/redox, do NOT invent H+/OH-/e-
            if not _looks_ionic_or_redox(parsed_base):
                return balance_algebraic(equation, include_charge="auto")
            md = "acid"  # default for ionic redox if no hint
        medium = md

    if medium == "acid":
        helpers = ["H2O", "H{+}", "e-"]
    else:
        helpers = ["H2O", "OH{-}", "e-"]

    present = set(all_base)
    add_left = [h for h in helpers if h not in present]

    reactants = [parse_species(t) for t in (base_left + add_left)]
    products = [parse_species(t) for t in base_right]

    A = _build_balance_matrix(reactants, products, include_charge=True)
    basis = _nullspace_rational(A)
    if not basis:
        return balance_algebraic(equation, include_charge="auto")

    # choose simplest, but also prefer smaller helper usage
    helper_idxs = list(range(len(base_left), len(base_left) + len(add_left)))

    best_ints = None
    best_score = None
    for v in basis:
        ints = _fraction_vec_to_small_int(v)
        score = sum(abs(x) for x in ints) + 3 * \
            sum(abs(ints[i]) for i in helper_idxs)
        if best_score is None or score < best_score:
            best_score = score
            best_ints = ints

    if best_ints is None:
        return balance_algebraic(equation, include_charge="auto")

    coeffs = best_ints
    nL = len(reactants)
    lhs_pairs = [(coeffs[i], reactants[i]) for i in range(nL)]
    rhs_pairs = [(coeffs[nL + j], products[j]) for j in range(len(products))]

    # Move negative coefficients across arrow
    lhs: List[Tuple[int, Species]] = []
    rhs: List[Tuple[int, Species]] = []

    for c, sp in lhs_pairs:
        if c > 0:
            lhs.append((c, sp))
        elif c < 0:
            rhs.append((-c, sp))

    for c, sp in rhs_pairs:
        if c > 0:
            rhs.append((c, sp))
        elif c < 0:
            lhs.append((-c, sp))

    # Cancel same species on both sides
    lhs, rhs = _cancel_both_sides(lhs, rhs)

    # GCD normalize
    allc = [c for c, _ in lhs + rhs]
    g = 0
    for c in allc:
        g = _gcd(g, c)
    if g > 1:
        lhs = [(c // g, sp) for c, sp in lhs]
        rhs = [(c // g, sp) for c, sp in rhs]

    return _format_equation(lhs, rhs)

balance_oxidation_number(equation: str) -> str

Source code in pyreactlab_core/docs/chem_balance.py
def balance_oxidation_number(equation: str) -> str:
    # You can extend this to output steps; final robust answer comes from algebraic solve.
    return balance_algebraic(equation, include_charge="auto")

parse_species(token: str) -> Species

Source code in pyreactlab_core/docs/chem_balance.py
def parse_species(token: str) -> Species:
    tok = _normalize_token(token)

    if _is_electron_token(tok):
        return Species(
            raw="e-",
            formula="e",
            charge=-1,
            atoms={}
        )

    base, charge = _parse_charge(tok)
    base = base.strip()

    # hydrates: CuSO4*5H2O
    parts = [p.strip() for p in base.split("*") if p.strip()]
    atoms_total: Dict[str, int] = {}

    for part in parts:
        m = re.match(r"^(\d+)(.*)$", part)
        mult = 1
        frag = part
        if m:
            mult = int(m.group(1))
            frag = m.group(2).strip()
        frag_atoms = _parse_atoms_single(frag)
        for el, cnt in frag_atoms.items():
            atoms_total[el] = atoms_total.get(el, 0) + cnt * mult

    return Species(raw=tok, formula=base, charge=charge, atoms=atoms_total)