pychnosz.data.add_obigt API documentation
Description: Implementation of add_OBIGT() function for Python CHNOSZ …

# Module `pychnosz.data.add_obigt`

Implementation of add_OBIGT() function for Python CHNOSZ.

This function loads external OBIGT database files and replaces or adds entries to the currently loaded thermodynamic database, mimicking the behavior of R CHNOSZ add.OBIGT() function.

## Functions

`def add_OBIGT(file: str | pandas.core.frame.DataFrame,
force: bool = True,
messages: bool = True) ‑> List[int]`
  Expand source code

      def add_OBIGT(file: Union[str, pd.DataFrame], force: bool = True, messages: bool = True) -> List[int]:
          """
          Add or replace entries in the thermodynamic database from external files or DataFrames.

          This function replicates the behavior of R CHNOSZ add.OBIGT() by loading
          CSV files from inst/extdata/OBIGT/ or accepting pandas DataFrames directly,
          and replacing entries with matching names.

          Parameters
          ----------
          file : str or pd.DataFrame
              Either:
              - Name of the database file to load (e.g., "SUPCRT92")
                The function will look for file.csv in inst/extdata/OBIGT/
              - Full path to a CSV file
              - A pandas DataFrame containing OBIGT data
          force : bool, default True
              If True, proceed even if some species are not found
          messages : bool, default True
              If True, print informational messages about additions/replacements
              If False, suppress all output (equivalent to R's suppressMessages())

          Returns
          -------
          list of int
              List of species indices (1-based) that were added or replaced

          Examples
          --------
          >>> import pychnosz
          >>> import pandas as pd
          >>>
          >>> # Example 1: Load from file name
          >>> pychnosz.reset()
          >>> indices = pychnosz.add_OBIGT("SUPCRT92")
          >>>
          >>> # Example 2: Load from DataFrame
          >>> thermo_df = pd.read_csv("thermodata.csv")
          >>> indices = pychnosz.add_OBIGT(thermo_df)
          >>>
          >>> # Example 3: Suppress messages
          >>> indices = pychnosz.add_OBIGT(thermo_df, messages=False)

          Notes
          -----
          This function modifies the thermo() object in place, replacing entries
          with matching names and adding new entries for species not in the database.
          The behavior exactly matches R CHNOSZ add.OBIGT().
          """

          # Get the thermo system
          thermo_sys = thermo()

          # Ensure the thermodynamic system is initialized
          if not thermo_sys.is_initialized() or thermo_sys.obigt is None:
              thermo_sys.reset()

          # Handle DataFrame input
          if isinstance(file, pd.DataFrame):
              new_data = file.copy()
              file_path = "<DataFrame>"
              file_basename = None
          else:
              # Handle string file path
              # If file is not an existing path, look for it in OBIGT directories
              if not os.path.exists(file):
                  if not file.endswith('.csv'):
                      file_to_find = file + '.csv'
                  else:
                      file_to_find = file

                  # Look for the file in the OBIGT data directory
                  # Use package-relative path
                  base_paths = [
                      os.path.join(os.path.dirname(__file__), 'extdata', 'OBIGT'),
                  ]

                  file_path = None
                  for base_path in base_paths:
                      potential_path = os.path.join(base_path, file_to_find)
                      if os.path.exists(potential_path):
                          file_path = potential_path
                          break

                  if file_path is None:
                      raise FileNotFoundError(f"Could not find OBIGT file: {file}")
              else:
                  # Use the file path as provided
                  file_path = file

              # Extract the basename for source_file column
              file_basename = os.path.basename(file_path)

              # Read the CSV file
              try:
                  new_data = pd.read_csv(file_path)
              except Exception as e:
                  raise ValueError(f"Error reading {file_path}: {e}")

          if new_data.empty:
              raise ValueError(f"No data found in {file_path if isinstance(file, str) else 'DataFrame'}")

          # Validate columns before processing
          # Get the current OBIGT database to determine required columns
          to1 = thermo_sys.obigt

          # Define core required columns that all species must have
          # These are the fundamental columns needed for thermodynamic calculations
          # Model-specific columns (logK*, T*, P*, etc.) are optional
          core_required_columns = [
              'name', 'abbrv', 'formula', 'state', 'ref1', 'ref2', 'date', 'E_units',
              'G', 'H', 'S', 'Cp', 'V',
              'a1.a', 'a2.b', 'a3.c', 'a4.d', 'c1.e', 'c2.f', 'omega.lambda', 'z.T'
          ]

          # The 'model' column is optional and will be auto-generated if missing
          # Filter to only include columns that exist in current OBIGT (for compatibility)
          required_columns = [col for col in core_required_columns if col in to1.columns]

          # Check for missing required columns
          missing_columns = [col for col in required_columns if col not in new_data.columns]

          if missing_columns:
              raise ValueError(
                  f"Missing required columns in input data: {', '.join(missing_columns)}. "
                  f"Please ensure the CSV file contains all necessary OBIGT database columns."
              )

          # Special handling for 'model' column
          if 'model' not in new_data.columns:
              # Create model column with proper values
              new_data = new_data.copy()  # Make a copy to avoid SettingWithCopyWarning

              # Assign model based on state:
              # - aqueous species (state == 'aq') get 'HKF'
              # - non-aqueous species get 'CGL'
              new_data['model'] = new_data['state'].apply(lambda x: 'HKF' if x == 'aq' else 'CGL')

              # Issue a warning to inform the user
              warnings.warn(
                  "The 'model' column was not found in the input data. "
                  "Auto-generating 'model' column: 'HKF' for aqueous species (state='aq'), "
                  "'CGL' for all other species.",
                  UserWarning
              )

          # Get energy units from the file (all unique values)
          # Match R's behavior: unique values joined with " and "
          if 'E_units' in new_data.columns:
              unique_units = new_data['E_units'].dropna().unique().tolist()
              # Filter out non-energy unit values like "CGL" (which is a model, not energy unit)
              # Valid energy units are typically "cal" and "J"
              energy_unit_names = [str(u) for u in unique_units if str(u) in ['cal', 'J']]
              # Join in the order they appear in the file (matching R's paste(unique(...), collapse = " and "))
              energy_units_str = ' and '.join(energy_unit_names) if energy_unit_names else 'cal'
          else:
              energy_units_str = 'cal'

          # Create identifier strings for matching (name + state)
          id1 = to1['name'].astype(str) + ' ' + to1['state'].astype(str)
          id2 = new_data['name'].astype(str) + ' ' + new_data['state'].astype(str)

          # Track the indices we've modified/added
          inew = []

          # Check which entries in new_data exist in current database
          # does_exist is a boolean array indicating which id2 entries are in id1
          does_exist = id2.isin(id1.values)

          # Get the indices in to1 where matches exist (matching R's match(id2, id1))
          # This gives us the positions in to1 for each id2 element
          ispecies_exist = []
          for i, id_val in enumerate(id2):
              if does_exist.iloc[i]:
                  # Find the index in to1 where this matches
                  match_idx = id1[id1 == id_val].index[0]
                  ispecies_exist.append(match_idx)
              else:
                  ispecies_exist.append(None)

          nexist = sum(does_exist)

          # Check if new_data has columns that to1 doesn't have, and add them
          # Use object dtype for new columns to match pandas default behavior and avoid FutureWarning
          for col in new_data.columns:
              if col not in to1.columns:
                  # Determine dtype from new_data
                  dtype = new_data[col].dtype
                  # Use object dtype for string columns to avoid dtype incompatibility
                  if dtype == object or pd.api.types.is_string_dtype(dtype):
                      to1[col] = pd.Series(dtype=object)
                  else:
                      to1[col] = np.nan

          if force:
              # Replace existing entries
              if nexist > 0:
                  # Update rows in to1 for species that exist
                  for i, idx in enumerate(ispecies_exist):
                      if idx is not None:
                          # Replace the row in to1 with data from new_data
                          for col in new_data.columns:
                              # col should now be in to1 since we added missing columns above
                              to1.loc[idx, col] = new_data.iloc[i][col]
                          # Set source_file for replaced entries
                          if file_basename is not None:
                              to1.loc[idx, 'source_file'] = file_basename

                  # Add these indices to inew
                  inew.extend([idx for idx in ispecies_exist if idx is not None])

                  # Remove existing entries from new_data (to2 <- to2[!does.exist, ])
                  to2 = new_data[~does_exist].copy()
              else:
                  to2 = new_data.copy()
          else:
              # Ignore any new entries that already exist
              to2 = new_data[~does_exist].copy()
              nexist = 0

          # Add new entries
          if len(to2) > 0:
              # Store the starting index for new additions
              len_id1 = len(id1)

              # Ensure new entries have all required columns
              # Make a proper copy to avoid SettingWithCopyWarning
              to2 = to2.copy()
              for col in to1.columns:
                  if col not in to2.columns:
                      to2[col] = np.nan

              # Set source_file for new entries
              if file_basename is not None:
                  to2['source_file'] = file_basename

              # Reorder columns to match current OBIGT
              to2 = to2.reindex(columns=to1.columns)

              # Add to the database
              # Use concat with explicit future behavior to avoid FutureWarning
              to1 = pd.concat([to1, to2], ignore_index=True, sort=False)

              # Add new indices: (length(id1)+1):nrow(to1)
              new_indices = list(range(len_id1 + 1, len(to1) + 1))
              inew.extend(new_indices)

          # Reset rownames to 1:nrow (matching R's rownames(thermo$OBIGT) <- 1:nrow(thermo$OBIGT))
          to1.index = range(1, len(to1) + 1)

          # Update the thermo system with modified database
          thermo_sys.obigt = to1

          # Update formula_ox if the column exists in the database
          if 'formula_ox' in to1.columns:
              # Create a DataFrame with name and formula_ox columns
              # Keep the same index as the obigt DataFrame (1-based)
              formula_ox_df = pd.DataFrame({
                  'name': to1['name'],
                  'formula_ox': to1['formula_ox']
              })
              # Preserve the 1-based index
              formula_ox_df.index = to1.index
              thermo_sys.formula_ox = formula_ox_df
          else:
              # If formula_ox column doesn't exist, set to None
              thermo_sys.formula_ox = None

          # Print summary (matching R CHNOSZ output)
          if messages:
              print(f"add_OBIGT: read {len(new_data)} rows; made {nexist} replacements, {len(to2) if len(to2) > 0 else 0} additions [energy units: {energy_units_str}]")

          return inew

  Add or replace entries in the thermodynamic database from external files or DataFrames.

  This function replicates the behavior of R CHNOSZ add.OBIGT() by loading CSV files from inst/extdata/OBIGT/ or accepting pandas DataFrames directly, and replacing entries with matching names.

  ## Parameters

  `file` :`str` or `pd.DataFrame`
    Either: - Name of the database file to load (e.g., "SUPCRT92") The function will look for file.csv in inst/extdata/OBIGT/ - Full path to a CSV file - A pandas DataFrame containing OBIGT data
  `force` :`bool`, default `True`
    If True, proceed even if some species are not found
  `messages` :`bool`, default `True`
    If True, print informational messages about additions/replacements If False, suppress all output (equivalent to R's suppressMessages())

  ## Returns

  `list` of `int`
    List of species indices (1-based) that were added or replaced

  ## Examples

      >>> import pychnosz
      >>> import pandas as pd
      >>>
      >>> # Example 1: Load from file name
      >>> pychnosz.reset()
      >>> indices = pychnosz.add_OBIGT("SUPCRT92")
      >>>
      >>> # Example 2: Load from DataFrame
      >>> thermo_df = pd.read_csv("thermodata.csv")
      >>> indices = pychnosz.add_OBIGT(thermo_df)
      >>>
      >>> # Example 3: Suppress messages
      >>> indices = pychnosz.add_OBIGT(thermo_df, messages=False)

  ## Notes

  This function modifies the thermo() object in place, replacing entries with matching names and adding new entries for species not in the database. The behavior exactly matches R CHNOSZ add.OBIGT().

`def list_OBIGT_files() ‑> List[str]`
  Expand source code

      def list_OBIGT_files() -> List[str]:
          """
          List available OBIGT database files.

          Returns
          -------
          list of str
              List of available .csv files in the OBIGT directory
          """

          # Use package-relative path
          base_paths = [
              os.path.join(os.path.dirname(__file__), 'extdata', 'OBIGT'),
          ]

          files = []
          for base_path in base_paths:
              if os.path.exists(base_path):
                  csv_files = [f[:-4] for f in os.listdir(base_path) if f.endswith('.csv')]
                  files.extend(csv_files)
                  break

          return sorted(list(set(files)))  # Remove duplicates and sort

  List available OBIGT database files.

  ## Returns

  `list` of `str`
    List of available .csv files in the OBIGT directory
`def reset_OBIGT() ‑> None`
  Expand source code

      def reset_OBIGT() -> None:
          """
          Reset OBIGT database to default state.

          This function reloads the default thermodynamic database,
          removing any modifications made by add_OBIGT().
          """
          from ..utils.reset import reset
          reset()
          print("OBIGT database reset to default state")

  Reset OBIGT database to default state.

  This function reloads the default thermodynamic database, removing any modifications made by add_OBIGT().