Module pychnosz.utils.formula

Chemical formula parsing and manipulation utilities.

This module provides Python equivalents of the R functions in makeup.R and util.formula.R: - makeup(): Parse chemical formulas and return elemental composition - Formula validation and parsing - Molecular weight and entropy calculations - Stoichiometric matrix operations

Author: CHNOSZ Python port

Functions

def ZC(formula: str | int | List[str | int]) ‑> float | List[float]
Expand source code
def ZC(formula: Union[str, int, List[Union[str, int]]]) -> Union[float, List[float]]:
    """
    Calculate average oxidation state of carbon in chemical formulas.
    
    Parameters
    ----------
    formula : str, int, or list
        Chemical formula(s) or species index(es)
        
    Returns
    -------
    float or list of float
        Average oxidation state(s) of carbon
    """
    # Get elemental compositions
    compositions = makeup(formula, count_zero=False)
    if not isinstance(compositions, list):
        compositions = [compositions]
    
    results = []
    
    # Nominal charges of elements
    known_elements = ['H', 'N', 'O', 'S', 'Z']
    charges = [-1, 3, 2, 2, 1]
    
    for comp in compositions:
        if comp is None or 'C' not in comp:
            results.append(np.nan)
            continue
        
        # Calculate total charge from known elements
        total_charge = 0
        unknown_elements = []
        
        for element, count in comp.items():
            if element == 'C':
                continue
            elif element in known_elements:
                idx = known_elements.index(element)
                total_charge += count * charges[idx]
            else:
                unknown_elements.append(element)
        
        if unknown_elements:
            warnings.warn(f"element(s) {' '.join(unknown_elements)} not in "
                         f"{' '.join(known_elements)} so not included in ZC calculation")
        
        # Calculate carbon oxidation state
        n_carbon = comp['C']
        zc = total_charge / n_carbon
        results.append(zc)
    
    if len(results) == 1:
        return results[0]
    else:
        return results

Calculate average oxidation state of carbon in chemical formulas.

Parameters

formula : str, int, or list
Chemical formula(s) or species index(es)

Returns

float or list of float
Average oxidation state(s) of carbon
def as_chemical_formula(makeup_dict: Dict[str, float] | pandas.core.frame.DataFrame,
drop_zero: bool = True) ‑> str | List[str]
Expand source code
def as_chemical_formula(makeup_dict: Union[Dict[str, float], pd.DataFrame], 
                       drop_zero: bool = True) -> Union[str, List[str]]:
    """
    Convert elemental makeup to chemical formula string(s).
    
    Parameters
    ----------
    makeup_dict : dict or DataFrame
        Elemental composition(s)
    drop_zero : bool
        Whether to exclude zero coefficients
        
    Returns
    -------
    str or list of str
        Chemical formula string(s)
    """
    if isinstance(makeup_dict, pd.DataFrame):
        # Handle matrix of compositions
        results = []
        for i in range(len(makeup_dict)):
            row_dict = makeup_dict.iloc[i].to_dict()
            formula = _dict_to_formula(row_dict, drop_zero)
            results.append(formula)
        return results
    else:
        # Handle single composition
        return _dict_to_formula(makeup_dict, drop_zero)

Convert elemental makeup to chemical formula string(s).

Parameters

makeup_dict : dict or DataFrame
Elemental composition(s)
drop_zero : bool
Whether to exclude zero coefficients

Returns

str or list of str
Chemical formula string(s)
def calculate_ghs(formula: str,
G: float = nan,
H: float = nan,
S: float = nan,
T: float = 298.15,
E_units: str = 'J') ‑> Dict[str, float]
Expand source code
def calculate_ghs(formula: str, G: float = np.nan, H: float = np.nan, 
                 S: float = np.nan, T: float = 298.15, 
                 E_units: str = "J") -> Dict[str, float]:
    """
    Calculate missing G, H, or S from the other two values.
    
    Parameters
    ----------
    formula : str
        Chemical formula
    G : float
        Gibbs energy of formation
    H : float
        Enthalpy of formation  
    S : float
        Standard entropy
    T : float
        Temperature in K
    E_units : str
        Energy units ("J" or "cal")
        
    Returns
    -------
    dict
        Dictionary with G, H, S values
    """
    # Calculate elemental entropy
    Se = entropy(formula)
    if E_units == "cal":
        Se = Se / 4.184  # Convert J to cal
    
    # Calculate missing value
    if pd.isna(G):
        G = H - T * (S - Se)
    elif pd.isna(H):
        H = G + T * (S - Se)
    elif pd.isna(S):
        S = (H - G) / T + Se
    
    return {"G": G, "H": H, "S": S}

Calculate missing G, H, or S from the other two values.

Parameters

formula : str
Chemical formula
G : float
Gibbs energy of formation
H : float
Enthalpy of formation
S : float
Standard entropy
T : float
Temperature in K
E_units : str
Energy units ("J" or "cal")

Returns

dict
Dictionary with G, H, S values
def entropy(formula: str | int | List[str | int]) ‑> float | List[float]
Expand source code
def entropy(formula: Union[str, int, List[Union[str, int]]]) -> Union[float, List[float]]:
    """
    Calculate standard molal entropy of elements in chemical formulas.
    
    Parameters
    ----------
    formula : str, int, or list
        Chemical formula(s) or species index(es)
        
    Returns
    -------
    float or list of float
        Standard entropy(ies) in J/(mol*K)
    """
    thermo_obj = thermo()
    if thermo_obj.element is None:
        raise RuntimeError("Element data not available")
    
    # Convert to stoichiometric matrix
    compositions = makeup(formula, count_zero=False)
    if not isinstance(compositions, list):
        compositions = [compositions]
    
    entropies = []
    
    for comp in compositions:
        if comp is None:
            entropies.append(np.nan)
            continue
        
        total_entropy = 0.0
        has_na = False
        
        for element, count in comp.items():
            
            # Look up element entropy
            element_data = thermo_obj.element[thermo_obj.element['element'] == element]
            if len(element_data) == 0:
                warnings.warn(f"Element {element} not available in thermo().element")
                has_na = True
                continue
            
            element_s = element_data.iloc[0]['s']
            element_n = element_data.iloc[0]['n']
            
            if pd.isna(element_s) or pd.isna(element_n):
                has_na = True
                continue
            
            # Entropy per atom
            entropy_per_atom = element_s / element_n
            total_entropy += count * entropy_per_atom
        
        if has_na and total_entropy == 0:
            entropies.append(np.nan)
        else:
            # Convert to Joules (assuming input is in cal)
            entropies.append(total_entropy * 4.184)  # cal to J conversion
    
    if len(entropies) == 1:
        return entropies[0]
    else:
        return entropies

Calculate standard molal entropy of elements in chemical formulas.

Parameters

formula : str, int, or list
Chemical formula(s) or species index(es)

Returns

float or list of float
Standard entropy(ies) in J/(mol*K)
def get_formula(formula: str | int | List[str | int]) ‑> str | List[str]
Expand source code
def get_formula(formula: Union[str, int, List[Union[str, int]]]) -> Union[str, List[str]]:
    """
    Get chemical formulas for species indices or return formula strings.
    
    Parameters
    ----------
    formula : str, int, or list
        Chemical formula(s) or species index(es)
        
    Returns
    -------
    str or list of str
        Chemical formula(s)
    """
    # Handle single values
    if not isinstance(formula, list):
        formula = [formula]
        single_result = True
    else:
        single_result = False
    
    results = []
    thermo_obj = thermo()
    
    for f in formula:
        if isinstance(f, str):
            # Already a formula
            results.append(f)
        elif isinstance(f, int):
            # Species index - look up formula
            if thermo_obj.obigt is not None:
                # Use .loc for label-based indexing (species indices are 1-based labels)
                if f in thermo_obj.obigt.index:
                    formula_str = thermo_obj.obigt.loc[f, 'formula']
                    results.append(formula_str)
                else:
                    raise FormulaError(f"Species index {f} not found in OBIGT database")
            else:
                raise FormulaError("Thermodynamic database not initialized")
        else:
            # Try to convert to string
            results.append(str(f))
    
    if single_result:
        return results[0]
    else:
        return results

Get chemical formulas for species indices or return formula strings.

Parameters

formula : str, int, or list
Chemical formula(s) or species index(es)

Returns

str or list of str
Chemical formula(s)
def i2A(formula: str | List[str] | Dict[str, float]) ‑> numpy.ndarray
Expand source code
def i2A(formula: Union[str, List[str], Dict[str, float]]) -> np.ndarray:
    """
    Convert formula(s) to stoichiometric matrix.
    
    Parameters
    ----------
    formula : str, list, or dict
        Chemical formula(s) or composition
        
    Returns
    -------
    np.ndarray
        Stoichiometric matrix with elements as columns
    """
    if isinstance(formula, np.ndarray):
        return formula
    elif isinstance(formula, dict) and all(isinstance(k, str) for k in formula.keys()):
        # Single composition dictionary
        return np.array([[formula.get(k, 0) for k in sorted(formula.keys())]])
    
    # Get compositions with zero padding
    compositions = makeup(formula, count_zero=True)
    if not isinstance(compositions, list):
        compositions = [compositions]
    
    # Get all elements
    all_elements = set()
    for comp in compositions:
        if comp is not None:
            all_elements.update(comp.keys())
    
    all_elements = sorted(list(all_elements))
    
    # Build matrix
    matrix = np.zeros((len(compositions), len(all_elements)))
    for i, comp in enumerate(compositions):
        if comp is not None:
            for j, element in enumerate(all_elements):
                matrix[i, j] = comp.get(element, 0)
    
    return matrix

Convert formula(s) to stoichiometric matrix.

Parameters

formula : str, list, or dict
Chemical formula(s) or composition

Returns

np.ndarray
Stoichiometric matrix with elements as columns
def makeup(formula: str | int | List[str | int],
multiplier: float | List[float] = 1.0,
sum_formulas: bool = False,
count_zero: bool = False) ‑> Dict[str, float] | List[Dict[str, float]]
Expand source code
def makeup(formula: Union[str, int, List[Union[str, int]]], 
           multiplier: Union[float, List[float]] = 1.0,
           sum_formulas: bool = False,
           count_zero: bool = False) -> Union[Dict[str, float], List[Dict[str, float]]]:
    """
    Return elemental makeup (counts) of chemical formula(s).
    
    Handles formulas with parenthetical subformulas, suffixed formulas,
    charges, and fractional coefficients.
    
    Parameters
    ----------
    formula : str, int, or list
        Chemical formula(s) or species index(es)
    multiplier : float or list of float
        Multiplier(s) to apply to formula coefficients
    sum_formulas : bool
        If True, return sum of all formulas
    count_zero : bool
        If True, include zero counts for all elements appearing in any formula
        
    Returns
    -------
    dict or list of dict
        Elemental composition(s) as {element: count} dictionaries
        
    Examples
    --------
    >>> makeup("H2O")
    {'H': 2, 'O': 1}
    
    >>> makeup("Ca(OH)2")
    {'Ca': 1, 'O': 2, 'H': 2}
    
    >>> makeup(["H2O", "CO2"])
    [{'H': 2, 'O': 1}, {'C': 1, 'O': 2}]
    """
    # Handle matrix input
    if isinstance(formula, np.ndarray) and formula.ndim == 2:
        return [makeup(formula[i, :]) for i in range(formula.shape[0])]
    
    # Handle named numeric objects (return unchanged)
    if isinstance(formula, dict) and all(isinstance(k, str) for k in formula.keys()):
        return formula
    
    # Handle list of named objects
    if isinstance(formula, list) and len(formula) > 0:
        if isinstance(formula[0], dict) and all(isinstance(k, str) for k in formula[0].keys()):
            return formula
    
    # Prepare multiplier
    if not isinstance(multiplier, list):
        multiplier = [multiplier]
    
    # Handle multiple formulas
    if isinstance(formula, list):
        if len(multiplier) != 1 and len(multiplier) != len(formula):
            raise ValueError("multiplier does not have length = 1 or length = number of formulas")
        
        if len(multiplier) == 1:
            multiplier = multiplier * len(formula)
        
        # Get formulas for any species indices
        formula = get_formula(formula)
        
        results = []
        for i, f in enumerate(formula):
            result = makeup(f, multiplier[i])
            results.append(result)
        
        # Handle sum_formulas option
        if sum_formulas:
            all_elements = set()
            for result in results:
                if result is not None:
                    all_elements.update(result.keys())
            
            summed = {}
            for element in all_elements:
                summed[element] = sum(result.get(element, 0) for result in results if result is not None)
            return summed
        
        # Handle count_zero option
        elif count_zero:
            # Get all elements appearing in any formula
            all_elements = set()
            for result in results:
                if result is not None:
                    all_elements.update(result.keys())
            
            # Add zero counts for missing elements
            complete_results = []
            for result in results:
                if result is None:
                    complete_result = {element: np.nan for element in all_elements}
                else:
                    complete_result = {element: result.get(element, 0) for element in all_elements}
                complete_results.append(complete_result)
            
            return complete_results
        
        return results
    
    # Handle single formula
    if isinstance(formula, int):
        # Get formula from species index
        thermo_obj = thermo()
        if thermo_obj.obigt is not None:
            # Use .loc for label-based indexing (species indices are 1-based labels)
            if formula in thermo_obj.obigt.index:
                formula = thermo_obj.obigt.loc[formula, 'formula']
            else:
                raise FormulaError(f"Species index {formula} not found in OBIGT database")
        else:
            raise FormulaError("Thermodynamic database not initialized")
    
    if formula is None or pd.isna(formula):
        return None
    
    # Parse single formula
    try:
        result = _parse_formula(str(formula))
        
        # Apply multiplier
        if multiplier[0] != 1.0:
            result = {element: count * multiplier[0] for element, count in result.items()}
        
        # Validate elements
        _validate_elements(result)
        
        return result
    
    except Exception as e:
        raise FormulaError(f"Error parsing formula '{formula}': {e}")

Return elemental makeup (counts) of chemical formula(s).

Handles formulas with parenthetical subformulas, suffixed formulas, charges, and fractional coefficients.

Parameters

formula : str, int, or list
Chemical formula(s) or species index(es)
multiplier : float or list of float
Multiplier(s) to apply to formula coefficients
sum_formulas : bool
If True, return sum of all formulas
count_zero : bool
If True, include zero counts for all elements appearing in any formula

Returns

dict or list of dict
Elemental composition(s) as {element: count} dictionaries

Examples

>>> makeup("H2O")
{'H': 2, 'O': 1}
>>> makeup("Ca(OH)2")
{'Ca': 1, 'O': 2, 'H': 2}
>>> makeup(["H2O", "CO2"])
[{'H': 2, 'O': 1}, {'C': 1, 'O': 2}]
def mass(formula: str | int | List[str | int]) ‑> float | List[float]
Expand source code
def mass(formula: Union[str, int, List[Union[str, int]]]) -> Union[float, List[float]]:
    """
    Calculate molecular mass of chemical formula(s).
    
    Parameters
    ----------
    formula : str, int, or list
        Chemical formula(s) or species index(es)
        
    Returns
    -------
    float or list of float
        Molecular mass(es) in g/mol
    """
    thermo_obj = thermo()
    if thermo_obj.element is None:
        raise RuntimeError("Element data not available")
    
    # Convert to stoichiometric matrix
    compositions = makeup(formula, count_zero=False)
    if not isinstance(compositions, list):
        compositions = [compositions]
    
    masses = []
    
    for comp in compositions:
        if comp is None:
            masses.append(np.nan)
            continue
        
        total_mass = 0.0
        for element, count in comp.items():
            if element == 'Z':
                continue  # Charge has no mass
            
            # Look up element mass
            element_data = thermo_obj.element[thermo_obj.element['element'] == element]
            if len(element_data) == 0:
                raise FormulaError(f"Element {element} not found in element database")
            
            element_mass = element_data.iloc[0]['mass']
            total_mass += count * element_mass
        
        masses.append(total_mass)
    
    if len(masses) == 1:
        return masses[0]
    else:
        return masses

Calculate molecular mass of chemical formula(s).

Parameters

formula : str, int, or list
Chemical formula(s) or species index(es)

Returns

float or list of float
Molecular mass(es) in g/mol
def species_basis(species: List[int] | numpy.ndarray,
makeup_matrix: numpy.ndarray | None = None,
basis_df: pandas.core.frame.DataFrame | None = None) ‑> numpy.ndarray
Expand source code
def species_basis(species: Union[List[int], np.ndarray],
                  makeup_matrix: Optional[np.ndarray] = None,
                  basis_df: Optional[pd.DataFrame] = None) -> np.ndarray:
    """
    Calculate coefficients for formation reactions from basis species.

    Parameters
    ----------
    species : list of int or array
        Species indices in thermo().obigt
    makeup_matrix : array, optional
        Pre-calculated makeup matrix
    basis_df : pd.DataFrame, optional
        Basis definition to use (if not using global basis)

    Returns
    -------
    np.ndarray
        Formation reaction coefficients matrix
    """
    from ..core.basis import basis_elements, get_basis

    # Follow R CHNOSZ species.basis algorithm exactly
    from ..core.thermo import thermo

    # Get basis dataframe
    if basis_df is None:
        basis_df = get_basis()
        if basis_df is None:
            raise RuntimeError("Basis species not defined")

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

    # Calculate basis elements matrix from basis_df
    element_cols = [col for col in basis_df.columns
                   if col not in ['ispecies', 'logact', 'state']]
    bmat = basis_df[element_cols].values.T

    # basis_elements() already returns transposed matrix (equivalent to R tbmat)
    tbmat = bmat

    # Get thermo object for species lookup
    thermo_obj = thermo()

    # Initialize result matrix
    n_species = len(species)
    n_basis = len(basis_element_names)
    formation_coeffs = np.zeros((n_species, n_basis))

    # Process each species individually (following R apply logic)
    for i, sp_idx in enumerate(species):
        # Get species makeup (equivalent to R mkp <- as.matrix(sapply(makeup(species), c)))
        formula = thermo_obj.obigt.iloc[sp_idx - 1]['formula']
        sp_makeup = makeup([formula], count_zero=True)[0]

        # Convert makeup to array ordered by elements present in species
        sp_elements = list(sp_makeup.keys())
        sp_values = np.array(list(sp_makeup.values()))

        # Find positions of species elements in basis elements (R ielem <- match)
        # All species elements must be in basis
        missing_elements = []
        for elem in sp_elements:
            if elem not in basis_element_names:
                missing_elements.append(elem)
        if missing_elements:
            raise RuntimeError(f"element(s) not in the basis: {' '.join(missing_elements)}")

        # Find positions of basis elements in species elements (R jelem <- match)
        jelem = []
        for elem in basis_element_names:
            try:
                jelem.append(sp_elements.index(elem))
            except ValueError:
                jelem.append(None)  # NA in R

        # Reorder species matrix to match basis elements (R mkp <- mkp[jelem, , drop = FALSE])
        sp_makeup_ordered = np.zeros(len(basis_element_names))
        for j, pos in enumerate(jelem):
            if pos is not None:
                sp_makeup_ordered[j] = sp_values[pos]
            # else remains 0 (equivalent to R mkp[ina, ] <- 0)

        # Solve linear system: tbmat @ coeffs = sp_makeup_ordered
        # This is equivalent to R solve(tbmat, x)
        try:
            coeffs = np.linalg.solve(tbmat, sp_makeup_ordered)
        except np.linalg.LinAlgError:
            raise RuntimeError(f"Singular basis matrix for species {sp_idx}")

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

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

        formation_coeffs[i, :] = coeffs

    return formation_coeffs

Calculate coefficients for formation reactions from basis species.

Parameters

species : list of int or array
Species indices in thermo().obigt
makeup_matrix : array, optional
Pre-calculated makeup matrix
basis_df : pd.DataFrame, optional
Basis definition to use (if not using global basis)

Returns

np.ndarray
Formation reaction coefficients matrix

Classes

class FormulaError (*args, **kwargs)
Expand source code
class FormulaError(Exception):
    """Exception raised for formula parsing errors."""
    pass

Exception raised for formula parsing errors.

Ancestors

  • builtins.Exception
  • builtins.BaseException