Module pychnosz.core.balance

Reaction balancing utilities.

This module provides functions to balance chemical reactions using basis species, without calculating thermodynamic properties. This is more efficient than subcrt() when only the reaction stoichiometry is needed.

Functions

def balance_reaction(species: str | List[str] | int | List[int],
coeff: int | float | List[int | float],
state: str | List[str] | None = None,
basis: pandas.core.frame.DataFrame | None = None,
messages: bool = False) ‑> Tuple[List, List] | None
Expand source code
def balance_reaction(species: Union[str, List[str], int, List[int]],
                    coeff: Union[int, float, List[Union[int, float]]],
                    state: Optional[Union[str, List[str]]] = None,
                    basis: Optional[pd.DataFrame] = None,
                    messages: bool = False) -> Optional[Tuple[List, List]]:
    """
    Balance a chemical reaction using basis species.

    This function checks if a reaction is balanced and, if not, attempts to
    balance it by adding basis species. Unlike subcrt(), this function only
    performs the balancing calculation without computing thermodynamic properties,
    making it much more efficient for reaction generation.

    Parameters
    ----------
    species : str, int, list of str, or list of int
        Species names or indices in the reaction
    coeff : int, float, or list
        Stoichiometric coefficients for the species
    state : str, list of str, or None
        Physical states for species (optional)
    basis : pd.DataFrame, optional
        Basis species definition to use. If None, uses global basis from thermo()
    messages : bool
        Whether to print informational messages

    Returns
    -------
    tuple or None
        If reaction is balanced or can be balanced:
            (balanced_species, balanced_coeffs) where both are lists
        If reaction cannot be balanced:
            None

    Examples
    --------
    >>> import chnosz
    >>> pychnosz.reset()
    >>> pychnosz.basis(['H2O', 'H+', 'Fe+2'])
    >>> # Balance reaction for Fe+3
    >>> species, coeffs = balance_reaction('Fe+3', [-1])
    >>> print(f"Species: {species}")
    >>> print(f"Coefficients: {coeffs}")
    """

    # Convert inputs to lists
    if not isinstance(species, list):
        species = [species]
    if not isinstance(coeff, list):
        coeff = [coeff]
    if state is not None and not isinstance(state, list):
        state = [state]

    # Validate lengths
    if len(species) != len(coeff):
        raise ValueError("Length of species and coeff must match")

    # Get basis definition
    thermo_sys = thermo()
    if basis is None:
        if hasattr(thermo_sys, 'basis') and thermo_sys.basis is not None:
            basis = thermo_sys.basis
        else:
            raise RuntimeError("Basis species not defined. Call pychnosz.basis() first.")

    # Look up species indices
    ispecies = []
    for i, sp in enumerate(species):
        if isinstance(sp, (int, np.integer)):
            ispecies.append(int(sp))
        else:
            sp_state = state[i] if state and i < len(state) else None
            sp_idx = info(sp, sp_state, messages=messages)
            if sp_idx is None or (isinstance(sp_idx, float) and np.isnan(sp_idx)):
                raise ValueError(f"Species not found: {sp}")
            ispecies.append(sp_idx)

    # Calculate mass balance
    try:
        mass_balance = makeup(ispecies, coeff, sum_formulas=True)

        # Check if balanced
        tolerance = 1e-6
        unbalanced_elements = {elem: val for elem, val in mass_balance.items()
                             if abs(val) > tolerance}

        if not unbalanced_elements:
            # Already balanced
            if messages:
                print("Reaction is already balanced")
            return (species, coeff)

        # Reaction is unbalanced - try to balance using basis species
        missing_composition = {elem: -val for elem, val in unbalanced_elements.items()}

        if messages:
            print("Reaction is not balanced; missing composition:")
            elem_names = list(missing_composition.keys())
            elem_values = list(missing_composition.values())
            print(" ".join(elem_names))
            print(" ".join([f"{val:.4f}" for val in elem_values]))

        # Get basis element columns
        basis_elements = [col for col in basis.columns
                        if col not in ['ispecies', 'logact', 'state']]

        # Check if all missing elements are in basis
        missing_elements = set(missing_composition.keys())
        if not missing_elements.issubset(set(basis_elements)):
            if messages:
                print(f"Cannot balance: elements {missing_elements - set(basis_elements)} not in basis")
            return None

        # Calculate coefficients for missing composition from basis species
        missing_matrix = np.zeros((1, len(basis_elements)))
        for i, elem in enumerate(basis_elements):
            missing_matrix[0, i] = missing_composition.get(elem, 0)

        # Get basis matrix
        basis_matrix = basis[basis_elements].values.T  # Transpose: (elements × basis_species)

        try:
            # Try to find simple integer solutions first
            basis_coeffs = _find_simple_integer_solution(
                basis_matrix.T,
                missing_matrix.flatten(),
                basis['ispecies'].tolist(),
                missing_composition
            )

            if basis_coeffs is None:
                # Fall back to linear algebra solution
                basis_coeffs = np.linalg.solve(basis_matrix, missing_matrix.T).flatten()

                # Apply zapsmall equivalent (digits=7)
                basis_coeffs = np.around(basis_coeffs, decimals=7)

                # Clean up very small numbers
                basis_coeffs[np.abs(basis_coeffs) < 1e-7] = 0

            # Get non-zero coefficients and corresponding basis species
            nonzero_indices = np.abs(basis_coeffs) > 1e-6
            if not np.any(nonzero_indices):
                if messages:
                    print("No basis species needed to balance (coefficients are zero)")
                return (species, coeff)

            # Get basis species info
            basis_indices = basis['ispecies'].values[nonzero_indices]
            basis_coeffs_nz = basis_coeffs[nonzero_indices]

            # Create new species list and coefficients
            new_species = list(species) + [int(idx) for idx in basis_indices]
            new_coeff = list(coeff) + list(basis_coeffs_nz)

            if messages:
                print("Balanced reaction by adding basis species:")
                for sp_idx, cf in zip(basis_indices, basis_coeffs_nz):
                    sp_name = thermo_sys.obigt.loc[int(sp_idx)]['name']
                    print(f"  {cf:.4f} {sp_name}")

            # CRITICAL: Consolidate duplicate species by summing coefficients
            # This prevents infinite recursion and matches subcrt's behavior
            consolidated_species = []
            consolidated_coeffs = []

            # Convert all species to indices for consolidation
            species_indices = []
            for sp in new_species:
                if isinstance(sp, (int, np.integer)):
                    species_indices.append(int(sp))
                else:
                    sp_idx = info(sp, None, messages=False)
                    if sp_idx is None or (isinstance(sp_idx, float) and np.isnan(sp_idx)):
                        # Keep as string if not found
                        species_indices.append(sp)
                    else:
                        species_indices.append(sp_idx)

            # Group by species index and sum coefficients
            species_coeff_map = {}
            for sp_idx, coeff in zip(species_indices, new_coeff):
                if sp_idx in species_coeff_map:
                    species_coeff_map[sp_idx] += coeff
                else:
                    species_coeff_map[sp_idx] = coeff

            # Remove species with zero coefficient (cancelled out)
            for sp_idx, coeff in species_coeff_map.items():
                if abs(coeff) > tolerance:
                    consolidated_species.append(sp_idx)
                    consolidated_coeffs.append(coeff)

            # Now check if consolidated reaction is balanced
            # If not, recursively balance again
            try:
                final_mass_balance = makeup(consolidated_species, consolidated_coeffs, sum_formulas=True)
                final_unbalanced = {elem: val for elem, val in final_mass_balance.items()
                                   if abs(val) > tolerance}

                if final_unbalanced:
                    # Still unbalanced after consolidation - recursively balance
                    if messages:
                        print(f"After consolidation, reaction still unbalanced: {final_unbalanced}")
                        print(f"Attempting recursive balance...")
                    return balance_reaction(consolidated_species, consolidated_coeffs, state=None,
                                           basis=basis, messages=messages)
                else:
                    # Balanced! Return consolidated result
                    if messages:
                        print(f"Reaction balanced after consolidation")
                    return (consolidated_species, consolidated_coeffs)

            except Exception as e:
                # If check fails, return consolidated result anyway
                if messages:
                    print(f"Could not verify final balance: {e}")
                return (consolidated_species, consolidated_coeffs)

        except np.linalg.LinAlgError:
            if messages:
                print("Cannot balance: singular basis matrix")
            return None

    except Exception as e:
        if messages:
            print(f"Error checking reaction balance: {e}")
            import traceback
            traceback.print_exc()
        return None

Balance a chemical reaction using basis species.

This function checks if a reaction is balanced and, if not, attempts to balance it by adding basis species. Unlike subcrt(), this function only performs the balancing calculation without computing thermodynamic properties, making it much more efficient for reaction generation.

Parameters

species : str, int, list of str, or list of int
Species names or indices in the reaction
coeff : int, float, or list
Stoichiometric coefficients for the species
state : str, list of str, or None
Physical states for species (optional)
basis : pd.DataFrame, optional
Basis species definition to use. If None, uses global basis from thermo()
messages : bool
Whether to print informational messages

Returns

tuple or None
If reaction is balanced or can be balanced: (balanced_species, balanced_coeffs) where both are lists If reaction cannot be balanced: None

Examples

>>> import chnosz
>>> pychnosz.reset()
>>> pychnosz.basis(['H2O', 'H+', 'Fe+2'])
>>> # Balance reaction for Fe+3
>>> species, coeffs = balance_reaction('Fe+3', [-1])
>>> print(f"Species: {species}")
>>> print(f"Coefficients: {coeffs}")
def format_reaction(species: List[str | int], coeffs: List[float]) ‑> str
Expand source code
def format_reaction(species: List[Union[str, int]], coeffs: List[float]) -> str:
    """
    Format a reaction as a string for EQ3/6 input.

    Parameters
    ----------
    species : list
        Species names or indices
    coeffs : list
        Stoichiometric coefficients

    Returns
    -------
    str
        Formatted reaction string like "-1.0000 Fe+3 1.0000 Fe+2 0.2500 O2(g)"
    """
    thermo_sys = thermo()
    parts = []

    for sp, coeff in zip(species, coeffs):
        # Get species name if we have an index
        if isinstance(sp, (int, np.integer)):
            sp_name = thermo_sys.obigt.loc[int(sp)]['name']
        else:
            sp_name = sp

        # Replace 'water' with 'H2O' for EQ3 compatibility
        if sp_name == 'water':
            sp_name = 'H2O'

        parts.append(f"{coeff:.4f}")
        parts.append(sp_name)

    return " ".join(parts)

Format a reaction as a string for EQ3/6 input.

Parameters

species : list
Species names or indices
coeffs : list
Stoichiometric coefficients

Returns

str
Formatted reaction string like "-1.0000 Fe+3 1.0000 Fe+2 0.2500 O2(g)"