aqequil.dissrxn_generator API documentation
Description: Module for generating dissociation reactions for species that lack them …

# Module `aqequil.dissrxn_generator`

Module for generating dissociation reactions for species that lack them.

This module uses CHNOSZ to automatically generate balanced dissociation reactions using strict and auxiliary basis species.

## Functions

`def coefficients_to_integers(coeffs, tolerance=1e-06)`
  Expand source code

      def coefficients_to_integers(coeffs, tolerance=1e-6):
          """
          Convert fractional coefficients to integers by finding LCM of denominators.

          Parameters
          ----------
          coeffs : list
              List of coefficient values (can be floats)
          tolerance : float
              Tolerance for considering a coefficient as zero

          Returns
          -------
          list
              List of integer coefficients
          """
          # Convert to fractions and find LCM of denominators
          fractions = []
          for c in coeffs:
              if abs(c) < tolerance:
                  fractions.append(Fraction(0))
              else:
                  frac = Fraction(c).limit_denominator(10000)
                  fractions.append(frac)

          # Get LCM of all denominators
          denominators = [f.denominator for f in fractions]
          lcm_val = get_lcm_list(denominators) if denominators else 1

          # Multiply all coefficients by LCM
          integer_coeffs = [float(f.numerator * lcm_val // f.denominator) for f in fractions]

          return integer_coeffs

  Convert fractional coefficients to integers by finding LCM of denominators.

  ## Parameters

  `coeffs` :`list`
    List of coefficient values (can be floats)
  `tolerance` :`float`
    Tolerance for considering a coefficient as zero

  ## Returns

  `list`
    List of integer coefficients
`def find_species_needing_dissrxns(thermo_df, verbose=1)`
  Expand source code

      def find_species_needing_dissrxns(thermo_df, verbose=1):
          """
          Find all species that need dissociation reactions (missing or unbalanced).

          Parameters
          ----------
          thermo_df : pd.DataFrame
              Thermodynamic database dataframe
          verbose : int
              Verbosity level

          Returns
          -------
          list
              List of species names that need dissociation reactions
          """
          from .dissrxn_balancer import check_dissrxn_balanced

          needs_dissrxn = []

          for idx, row in thermo_df.iterrows():
              # Skip basis species
              if row.get('tag') == 'basis':
                  continue

              sp_name = row['name']
              dissrxn = row.get('dissrxn', '')

              # Check if dissociation reaction is missing or unbalanced
              # Handle both string and NaN/None cases
              if pd.isna(dissrxn) or dissrxn == '' or (isinstance(dissrxn, str) and dissrxn.strip() == ''):
                  needs_dissrxn.append(sp_name)
              elif not check_dissrxn_balanced(str(dissrxn), sp_name, verbose=verbose >= 2):
                  needs_dissrxn.append(sp_name)

          return needs_dissrxn

  Find all species that need dissociation reactions (missing or unbalanced).

  ## Parameters

  `thermo_df` :`pd.DataFrame`
    Thermodynamic database dataframe
  `verbose` :`int`
    Verbosity level

  ## Returns

  `list`
    List of species names that need dissociation reactions
`def gcd(a, b)`
  Expand source code

      def gcd(a, b):
          """Greatest common divisor."""
          while b:
              a, b = b, a % b
          return abs(a)

  Greatest common divisor.

`def generate_dissrxn(sp_name,
thermo_df,
basis_pref,
aux_pref=None,
HOZ_balancers=None,
verbose=1,
redox_elem_states=None)`
  Expand source code

      def generate_dissrxn(sp_name, thermo_df, basis_pref, aux_pref=None,
                           HOZ_balancers=None, verbose=1, redox_elem_states=None):
          """
          Generate a dissociation reaction for a species.

          This function replicates the logic from the R function spec_diss() in
          redox_and_dissrxns.r (lines 536-775), which uses oxidation state information
          to choose the most appropriate basis species.

          Parameters
          ----------
          sp_name : str
              Name of species to generate dissociation reaction for
          thermo_df : pd.DataFrame
              Thermodynamic database dataframe with formula, formula_ox, tag columns
          basis_pref : dict
              Dictionary mapping elements (including pseudoelements) to preferred basis species names
          aux_pref : dict, optional
              Dictionary mapping elements to lists of preferred auxiliary basis species
          HOZ_balancers : list, optional
              List of species to use for balancing H, O, and charge
          verbose : int
              Verbosity level
          redox_elem_states : dict, optional
              Dictionary mapping elements to their pseudoelement names for each oxidation state
              Format: {element: {ox_state_string: pseudoelement_name}}

          Returns
          -------
          str
              Dissociation reaction string, or None if generation fails
          """
          if HOZ_balancers is None:
              HOZ_balancers = ["H+", "O2(g)", "H2O"]

          if aux_pref is None:
              aux_pref = {}

          if redox_elem_states is None:
              redox_elem_states = {}

          try:
              # Get formula for this species
              sp_row = thermo_df[thermo_df['name'] == sp_name]
              if sp_row.empty:
                  return None

              # Use formula_modded if available (for redox-suppressed elements), else use formula
              sp_formula = sp_row.iloc[0].get('formula_modded', sp_row.iloc[0]['formula'])
              # If formula_modded is NaN or empty, fall back to formula
              if pd.isna(sp_formula) or sp_formula == '' or sp_formula == 'nan':
                  sp_formula = sp_row.iloc[0]['formula']

              # Get formula_ox_modded for oxidation state information
              sp_formula_ox_modded = sp_row.iloc[0].get('formula_ox_modded', 'nan')
              if pd.isna(sp_formula_ox_modded) or sp_formula_ox_modded == 'nan' or sp_formula_ox_modded == '':
                  sp_formula_ox_modded = sp_row.iloc[0].get('formula_ox', 'nan')

              # Get elemental composition
              comp = pychnosz.makeup(sp_formula)

              # Get elements that are not H, O, Z (including pseudoelements)
              basis_elem = [e for e in comp.keys() if e not in ['H', 'O', 'Z']]

              # Special case: if this is a gas species and there's an aqueous species with the same formula_modded
              # that is a basis species, create a simple phase-change reaction
              # e.g., CH4(g) -> CH4(aq), CO(g) -> CO(aq), S2(g) -> S2(aq)
              if sp_row.iloc[0].get('state') == 'gas':
                  # Look for an aqueous basis species with matching formula_modded
                  matching_aq_basis = thermo_df[
                      (thermo_df['tag'] == 'basis') &
                      (thermo_df['state'] == 'aq') &
                      (thermo_df['formula_modded'] == sp_formula)
                  ]
                  if not matching_aq_basis.empty:
                      aq_species_name = matching_aq_basis.iloc[0]['name']
                      # Simple phase change reaction
                      dissrxn_str = f"-1.0000 {sp_name} 1.0000 {aq_species_name}"
                      if verbose >= 2:
                          print(f"  Phase change reaction: {dissrxn_str}")
                      return dissrxn_str

              # Determine which basis species to use for each element
              # This implements the oxidation-state-aware selection logic from R's spec_diss()
              chosen_basis_species = []
              chosen_basis_coeffs = []

              # If we don't have oxidation state information, use simple method
              if pd.isna(sp_formula_ox_modded) or sp_formula_ox_modded == 'nan' or sp_formula_ox_modded == '':
                  # Simple method: just use basis_pref
                  for elem in basis_elem:
                      if elem in basis_pref:
                          chosen_basis_species.append(basis_pref[elem])
                          chosen_basis_coeffs.append(comp[elem])
              else:
                  # Oxidation-state-aware method (R code lines 583-706)
                  # For each element, match oxidation states to basis/aux species

                  for elem in basis_elem:
                      # Get count of this element in the species
                      elem_count = comp[elem]

                      # Check if this is a pseudoelement (redox-suppressed element)
                      # Pseudoelements have names like "Fejiiip", "Sjin", "Cjivn"
                      is_pseudoelement = 'j' in elem and any(c in elem for c in ['i', 'v', 'x', 'p', 'n', 'z'])

                      if is_pseudoelement:
                          # This is a pseudoelement from redox suppression
                          # Use the preferred basis species directly
                          if elem in basis_pref:
                              chosen_basis_species.append(basis_pref[elem])
                              chosen_basis_coeffs.append(elem_count)
                          continue

                      # Element is NOT redox-suppressed
                      # Use oxidation-state matching to find best basis/aux species
                      # This replicates R code lines 605-706

                      # Check if we have aux_pref for this element
                      if elem not in aux_pref or not aux_pref[elem]:
                          # No aux preference - just use simplest basis
                          if elem in basis_pref:
                              chosen_basis_species.append(basis_pref[elem])
                              chosen_basis_coeffs.append(elem_count)
                          continue

                      # Get oxidation states of this element in the species
                      elem_ox_states = get_element_oxidation_states(sp_formula_ox_modded, elem)

                      if not elem_ox_states:
                          # Couldn't parse oxidation states, fall back to simple method
                          if elem in basis_pref:
                              chosen_basis_species.append(basis_pref[elem])
                              chosen_basis_coeffs.append(elem_count)
                          continue

                      # Get available basis and aux species for this element
                      # Build a list of (species_name, average_ox_state, formula_ox)
                      basis_aux_candidates = []

                      # Get the strict basis species for this element
                      if elem in basis_pref:
                          basis_sp_name = basis_pref[elem]
                          basis_row = thermo_df[thermo_df['name'] == basis_sp_name]
                          if not basis_row.empty:
                              basis_formula_ox = basis_row.iloc[0].get('formula_ox_modded',
                                                                         basis_row.iloc[0].get('formula_ox', ''))
                              avg_ox = get_average_oxidation_state(basis_formula_ox, elem)
                              basis_aux_candidates.append((basis_sp_name, avg_ox, basis_formula_ox))

                      # Get aux species for this element
                      for aux_sp_name in aux_pref[elem]:
                          # Don't allow a species to dissociate into itself
                          if aux_sp_name == sp_name:
                              continue
                          aux_row = thermo_df[thermo_df['name'] == aux_sp_name]
                          if not aux_row.empty:
                              aux_formula_ox = aux_row.iloc[0].get('formula_ox_modded',
                                                                     aux_row.iloc[0].get('formula_ox', ''))
                              avg_ox = get_average_oxidation_state(aux_formula_ox, elem)
                              basis_aux_candidates.append((aux_sp_name, avg_ox, aux_formula_ox))

                      if not basis_aux_candidates:
                          # No candidates found, fall back
                          if elem in basis_pref:
                              chosen_basis_species.append(basis_pref[elem])
                              chosen_basis_coeffs.append(elem_count)
                          continue

                      # For each oxidation state instance in the species, find the closest matching basis/aux
                      # This implements R code lines 651-705
                      if len(elem_ox_states) == 1:
                          # Simple case: element has one oxidation state
                          ox_state, count = elem_ox_states[0]

                          # Find closest matching basis/aux species
                          closest_sp = min(basis_aux_candidates, key=lambda x: abs(x[1] - ox_state))
                          chosen_sp_name = closest_sp[0]

                          # Get how many atoms of this element are in the chosen basis species
                          chosen_row = thermo_df[thermo_df['name'] == chosen_sp_name]
                          chosen_formula = chosen_row.iloc[0].get('formula_modded', chosen_row.iloc[0]['formula'])
                          if pd.isna(chosen_formula) or chosen_formula == '' or chosen_formula == 'nan':
                              chosen_formula = chosen_row.iloc[0]['formula']
                          chosen_makeup = pychnosz.makeup(chosen_formula)
                          n_elem_in_chosen = chosen_makeup.get(elem, 1)

                          # Coefficient = (count of elem in species) / (count of elem in basis)
                          coeff = count / n_elem_in_chosen

                          chosen_basis_species.append(chosen_sp_name)
                          chosen_basis_coeffs.append(coeff)
                      else:
                          # Complex case: element has multiple oxidation states
                          # e.g., linnaeite has Co+2 and Co+3
                          # Match each oxidation state to the closest basis/aux species
                          chosen_dict = {}  # species_name -> coefficient

                          for ox_state, count in elem_ox_states:
                              # Find closest matching basis/aux species for this oxidation state
                              closest_sp = min(basis_aux_candidates, key=lambda x: abs(x[1] - ox_state))
                              chosen_sp_name = closest_sp[0]

                              # Get how many atoms of this element are in the chosen basis species
                              chosen_row = thermo_df[thermo_df['name'] == chosen_sp_name]
                              chosen_formula = chosen_row.iloc[0].get('formula_modded', chosen_row.iloc[0]['formula'])
                              if pd.isna(chosen_formula) or chosen_formula == '' or chosen_formula == 'nan':
                                  chosen_formula = chosen_row.iloc[0]['formula']
                              chosen_makeup = pychnosz.makeup(chosen_formula)
                              n_elem_in_chosen = chosen_makeup.get(elem, 1)

                              # Coefficient for this instance
                              coeff = count / n_elem_in_chosen

                              # Accumulate coefficients for the same species
                              if chosen_sp_name in chosen_dict:
                                  chosen_dict[chosen_sp_name] += coeff
                              else:
                                  chosen_dict[chosen_sp_name] = coeff

                          # Add all chosen species and coefficients
                          for sp_name_inner, coeff in chosen_dict.items():
                              chosen_basis_species.append(sp_name_inner)
                              chosen_basis_coeffs.append(coeff)

              # New approach: use formula_ox_modded to directly identify which basis/aux species to use
              # This replicates the workflow that successfully works in testing

              # Build basis_to_elem mapping (basis species -> element/pseudoelement)
              basis_to_elem = {}
              for idx, row in thermo_df[thermo_df['tag'] == 'basis'].iterrows():
                  try:
                      formula_modded = row.get('formula_modded', row['formula'])
                      if pd.isna(formula_modded) or formula_modded == '':
                          continue
                      mkp = pychnosz.makeup(str(formula_modded))
                      elems = [e for e in mkp.keys() if e not in ['H', 'O', 'Z']]
                      if len(elems) == 1:
                          basis_to_elem[row['name']] = elems[0]
                  except:
                      continue

              # Reverse mapping: element/pseudoelement -> basis species
              elem_to_basis = {v: k for k, v in basis_to_elem.items()}

              # Parse formula_ox_modded to get element+oxidation combinations
              # Use shared function that handles pseudoelements
              element_ox_modded = parse_formula_ox_string(sp_formula_ox_modded, include_pseudoelements=True)

              # For each element+oxidation in formula_ox_modded, find the appropriate basis/aux species
              basis_to_add = []
              for el_with_charge in element_ox_modded.keys():
                  # Extract just the element/pseudoelement part (remove charge and digits)
                  el = el_with_charge.rstrip('0123456789+-')

                  # Extract oxidation state from el_with_charge (e.g., "Cu+1" -> 1, "S-2" -> -2)
                  ox_state_str = el_with_charge[len(el):]  # e.g., "+1", "-2"
                  try:
                      ox_state_val = int(ox_state_str) if ox_state_str else 0
                  except:
                      ox_state_val = 0

                  # Try to find basis/aux species with matching oxidation state
                  # Format: Convert "Cu+1" to "Cu+" or "Cu+2" to "Cu+2" (standard ionic notation)
                  if ox_state_val != 0:
                      # Generate possible ionic notation forms
                      # For +1, try "Cu+" and "Cu+1"
                      # For +2, try "Cu+2" and "Cu++"
                      # For -2, try "S-2" and "S--"
                      possible_names = []
                      if ox_state_val > 0:
                          # Positive charge
                          possible_names.append(f"{el}+{abs(ox_state_val)}" if abs(ox_state_val) > 1 else f"{el}+")
                          possible_names.append(f"{el}{'+'*abs(ox_state_val)}")
                      else:
                          # Negative charge
                          possible_names.append(f"{el}-{abs(ox_state_val)}" if abs(ox_state_val) > 1 else f"{el}-")
                          possible_names.append(f"{el}{'-'*abs(ox_state_val)}")

                      # Check if any of these forms exist as basis/aux species
                      found = False
                      for possible_name in possible_names:
                          if possible_name in list(thermo_df['name']):
                              el_db_entry = thermo_df[thermo_df['name'] == possible_name].iloc[0]
                              if el_db_entry['tag'] in ['basis', 'aux', 'refstate']:
                                  basis_to_add.append(possible_name)
                                  if verbose >= 2:
                                      print(f"  Found {possible_name} for {el_with_charge} (tag={el_db_entry['tag']})")
                                  found = True
                                  break

                      if found:
                          continue

                  # Check if el is a pseudoelement and map to basis
                  if el in elem_to_basis:
                      basis_sp = elem_to_basis[el]
                      basis_to_add.append(basis_sp)
                      if verbose >= 2:
                          print(f"  Mapped {el} -> {basis_sp}")
                      continue

              # Remove duplicates while preserving order
              seen = set()
              basis_to_add = [x for x in basis_to_add if not (x in seen or seen.add(x))]

              if verbose >= 2:
                  print(f"  Basis species to add: {basis_to_add}")

              # Try with just H+ first (works for linnaeite)
              # If that fails, try with partial HOZ [H+, H2O] (needed for ilvaite)
              # If that fails, try with full HOZ_balancers (needed for laurite)
              hoz_for_basis = [hb.replace('(g)', '') if hb == 'O2(g)' else hb for hb in HOZ_balancers]

              try:
                  # Try with just H+
                  basis_list = basis_to_add + ['H+']
                  seen = set()
                  basis_list = [x for x in basis_list if not (x in seen or seen.add(x))]

                  if verbose >= 2:
                      print(f"  Trying basis with H+: {basis_list}")

                  b = pychnosz.basis(basis_list, messages=False, global_state=False)

                  # Use balance_reaction instead of subcrt for efficiency
                  # This only calculates the stoichiometry without thermodynamic properties
                  balance_result = pychnosz.balance_reaction([sp_name], [-1], basis=b, messages=False)

                  if balance_result is None:
                      raise Exception("balance_reaction returned None")

                  balanced_species, coeffs = balance_result

              except Exception as e:
                  # Try with partial HOZ (H+ and H2O only)
                  if verbose >= 2:
                      print(f"  H+ only failed ({e}), trying partial HOZ [H+, H2O]")

                  try:
                      basis_list = basis_to_add + ['H+', 'H2O']
                      seen = set()
                      basis_list = [x for x in basis_list if not (x in seen or seen.add(x))]

                      if verbose >= 2:
                          print(f"  Trying basis with H+ and H2O: {basis_list}")

                      b = pychnosz.basis(basis_list, messages=False, global_state=False)

                      # Use balance_reaction instead of subcrt for efficiency
                      balance_result = pychnosz.balance_reaction([sp_name], [-1], basis=b, messages=False)

                      if balance_result is None:
                          raise Exception("balance_reaction returned None")

                      balanced_species, coeffs = balance_result

                  except Exception as e2:
                      # Try with full HOZ_balancers
                      if verbose >= 2:
                          print(f"  Partial HOZ failed ({e2}), trying full HOZ")

                      basis_list = basis_to_add + hoz_for_basis
                      seen = set()
                      basis_list = [x for x in basis_list if not (x in seen or seen.add(x))]

                      if verbose >= 2:
                          print(f"  Trying basis with full HOZ: {basis_list}")

                      try:
                          b = pychnosz.basis(basis_list, messages=False, global_state=False)

                          # Use balance_reaction instead of subcrt for efficiency
                          balance_result = pychnosz.balance_reaction([sp_name], [-1], basis=b, messages=False)

                          if balance_result is None:
                              return None

                          balanced_species, coeffs = balance_result

                      except Exception as e3:
                          if verbose >= 2:
                              print(f"  Error: {e3}")
                          return None

              # Convert species to names if they are indices
              # CRITICAL: indices from balance_reaction() are 1-based CHNOSZ OBIGT indices
              # These must be looked up using .loc[] not .iloc[]
              species_names = []
              thermo_sys = pychnosz.thermo()
              for sp in balanced_species:
                  if isinstance(sp, (int, np.int64, np.int32)):
                      # Species index from CHNOSZ - use .loc[] to look up (1-based label)
                      if sp in thermo_sys.obigt.index:
                          species_names.append(thermo_sys.obigt.loc[sp]['name'])
                      else:
                          # Fallback: species might not be in CHNOSZ but in thermo_df
                          sp_name_lookup = thermo_df[thermo_df['name'].notna()].query(f'name == "{sp}"')
                          if len(sp_name_lookup) > 0:
                              species_names.append(sp_name_lookup.iloc[0]['name'])
                          else:
                              species_names.append(str(sp))
                  else:
                      species_names.append(sp)

              # Filter out polymorphs of the dissociating species
              filtered_coeffs = []
              filtered_names = []
              found_primary = False
              for coeff, name in zip(coeffs, species_names):
                  if name == sp_name:
                      if not found_primary and coeff < 0:
                          filtered_coeffs.append(coeff)
                          filtered_names.append(name)
                          found_primary = True
                  else:
                      filtered_coeffs.append(coeff)
                      filtered_names.append(name)

              coeffs = filtered_coeffs
              species_names = filtered_names

              # Replace 'water' with 'H2O' for EQ3 compatibility
              species_names = ['H2O' if n == 'water' else n for n in species_names]

              # Restore (g) suffix for gas species that were in HOZ_balancers
              # e.g., convert 'O2' back to 'O2(g)' if 'O2(g)' was in HOZ_balancers
              for i, name in enumerate(species_names):
                  for hb in HOZ_balancers:
                      if '(g)' in hb:
                          # Extract base name without (g) suffix
                          base_name = hb.replace('(g)', '')
                          if name == base_name:
                              species_names[i] = hb
                              break

              # Convert coefficients to integers if needed (R code lines 728-761)
              if any(abs(c % 1) > 1e-6 for c in coeffs):
                  coeffs = coefficients_to_integers(coeffs)

              # Reduce coefficients to their simplest form by dividing by their GCD
              coeffs = reduce_coefficients(coeffs)

              # Format as string
              parts = []
              for coeff, name in zip(coeffs, species_names):
                  parts.append(f"{coeff:.4f}")
                  parts.append(name)

              dissrxn_str = " ".join(parts)

              if verbose >= 2:
                  print(f"Generated dissociation reaction for {sp_name}:")
                  print(f"  {dissrxn_str}")

              return dissrxn_str

          except Exception as e:
              if verbose >= 1:
                  print(f"Error generating dissociation reaction for {sp_name}: {e}")
              import traceback
              if verbose >= 2:
                  traceback.print_exc()
              return None

  Generate a dissociation reaction for a species.

  This function replicates the logic from the R function spec_diss() in redox_and_dissrxns.r (lines 536-775), which uses oxidation state information to choose the most appropriate basis species.

  ## Parameters

  `sp_name` :`str`
    Name of species to generate dissociation reaction for
  `thermo_df` :`pd.DataFrame`
    Thermodynamic database dataframe with formula, formula_ox, tag columns
  `basis_pref` :`dict`
    Dictionary mapping elements (including pseudoelements) to preferred basis species names
  `aux_pref` :`dict`, optional
    Dictionary mapping elements to lists of preferred auxiliary basis species
  `HOZ_balancers` :`list`, optional
    List of species to use for balancing H, O, and charge
  `verbose` :`int`
    Verbosity level
  `redox_elem_states` :`dict`, optional
    Dictionary mapping elements to their pseudoelement names for each oxidation state Format: {element: {ox_state_string: pseudoelement_name}}

  ## Returns

  `str`
    Dissociation reaction string, or None if generation fails
`def get_lcm_list(numbers)`
  Expand source code

      def get_lcm_list(numbers):
          """Get the least common multiple of a list of numbers."""
          if len(numbers) == 0:
              return 1
          result = numbers[0]
          for num in numbers[1:]:
              result = lcm(result, num)
          return result

  Get the least common multiple of a list of numbers.

`def get_simplest_basis(basis_candidates, elem)`
  Expand source code

      def get_simplest_basis(basis_candidates, elem):
          """
          Get the simplest basis species for an element (fewest total atoms).

          Parameters
          ----------
          basis_candidates : list
              List of candidate basis species names
          elem : str
              Element to represent

          Returns
          -------
          str
              Name of simplest basis species, or None if none found
          """
          min_complexity = float('inf')
          simplest = None

          for candidate in basis_candidates:
              try:
                  mkp = chnosz.makeup(candidate)
                  # Calculate complexity as sum of absolute values
                  complexity = sum(abs(v) for v in mkp.values())

                  # Prefer species with 1 atom of the element
                  if elem in mkp and mkp[elem] == 1:
                      complexity -= 0.5  # Bonus for having 1 atom

                  if complexity < min_complexity:
                      min_complexity = complexity
                      simplest = candidate

              except:
                  continue

          return simplest

  Get the simplest basis species for an element (fewest total atoms).

  ## Parameters

  `basis_candidates` :`list`
    List of candidate basis species names
  `elem` :`str`
    Element to represent

  ## Returns

  `str`
    Name of simplest basis species, or None if none found
`def lcm(a, b)`
  Expand source code

      def lcm(a, b):
          """Least common multiple."""
          if a == 0 or b == 0:
              return 0
          return abs(a * b) // gcd(a, b)

  Least common multiple.

`def match_basis_comp(sp_elements, elem)`
  Expand source code

      def match_basis_comp(sp_elements, elem):
          """
          Check if a species contains only elem, H, O, and charge.

          Parameters
          ----------
          sp_elements : list
              List of elements in the species
          elem : str
              Element to check for

          Returns
          -------
          bool
              True if species matches basis composition requirements
          """
          if elem in sp_elements:
              valid_elements = set([elem, 'H', 'O', 'Z'])
              return all(e in valid_elements for e in sp_elements)
          return False

  Check if a species contains only elem, H, O, and charge.

  ## Parameters

  `sp_elements` :`list`
    List of elements in the species
  `elem` :`str`
    Element to check for

  ## Returns

  `bool`
    True if species matches basis composition requirements
`def reduce_coefficients(coeffs, tolerance=1e-06)`
  Expand source code

      def reduce_coefficients(coeffs, tolerance=1e-6):
          """
          Reduce integer coefficients to their simplest form by dividing by their GCD.

          Parameters
          ----------
          coeffs : list
              List of integer coefficient values
          tolerance : float
              Tolerance for considering a coefficient as zero

          Returns
          -------
          list
              List of reduced integer coefficients
          """
          # Filter out zero coefficients for GCD calculation
          non_zero_coeffs = [abs(int(round(c))) for c in coeffs if abs(c) > tolerance]

          if not non_zero_coeffs:
              return coeffs

          # Calculate GCD of all non-zero coefficients
          result_gcd = non_zero_coeffs[0]
          for coeff in non_zero_coeffs[1:]:
              result_gcd = gcd(result_gcd, coeff)

          # If GCD is 1, coefficients are already reduced
          if result_gcd <= 1:
              return coeffs

          # Divide all coefficients by GCD
          reduced_coeffs = [c / result_gcd for c in coeffs]

          return reduced_coeffs

  Reduce integer coefficients to their simplest form by dividing by their GCD.

  ## Parameters

  `coeffs` :`list`
    List of integer coefficient values
  `tolerance` :`float`
    Tolerance for considering a coefficient as zero

  ## Returns

  `list`
    List of reduced integer coefficients