Source code for pudl.scripts.auto_match_utilities

"""A CLI tool for matching FERC and EIA utilities."""

import sys
from pathlib import Path
from typing import Literal, get_args

import click
import pandas as pd

from pudl import PUDL_PACKAGE_DATA_PATH
from pudl.analysis.record_linkage import name_cleaner
from pudl.helpers import get_parquet_table
from pudl.logging_helpers import get_logger

[docs] logger = get_logger(__name__)
[docs] type GlueEntity = Literal["ferc1", "eia"]
[docs] UTILITY_NAME_CLEANER = name_cleaner.CompanyNameCleaner( cleaning_rules_list=[ "remove_word_the_from_the_end", "remove_word_the_from_the_beginning", "replace_ampersand_by_AND", "replace_hyphen_by_space", "replace_underscore_by_space", "remove_punctuation_except_dot", "replace_dot_with_nothing", "remove_math_symbols", "add_space_before_opening_parentheses", "add_space_after_closing_parentheses", "remove_parentheses", "remove_brackets", "remove_curly_brackets", "enforce_single_space_between_words", ], legal_term_location=2, )
[docs] def clean_utility_name(col): """Apply standard cleaning steps to the utility name column.""" col = col.fillna(pd.NA).str.strip().str.lower() col = UTILITY_NAME_CLEANER.apply_name_cleaning(col).str.strip() col = col.replace("", pd.NA) return col
[docs] def match_utility_names( eia_df: pd.DataFrame, ferc_df: pd.DataFrame, false_matches: pd.DataFrame ): """Match FERC and EIA utilities based on their utility names. We note how many of these records are already matched to one another, and ignore these matches. We also ignore matches contained in src/pudl/package_data/glue/utility_id_pudl_false_matches.csv. These are matches that we have hand-labelled as incorrect. """ # Try an exact match on the cleaned name matches = ferc_df.merge( eia_df, on="cleaned_utility_name", how="inner", validate="m:m", suffixes=["_ferc1", "_eia"], ) # Check to see how many of these are already matched. already_matched = matches[ (matches.utility_id_pudl_ferc1 == matches.utility_id_pudl_eia) ] logger.info(f"{len(already_matched)} matches have already been made.") # Which utilities aren't currently matched but should be under this model? # This includes new utilities (not yet assigned a PUDL ID) or utilities where the FERC and EIA PUDL IDs are not the same. matches = matches[ (matches.utility_id_pudl_ferc1 != matches.utility_id_pudl_eia) | (matches.utility_id_pudl_ferc1 is None) | (matches.utility_id_pudl_eia is None) ] matches = pd.merge( matches, false_matches, on=["utility_id_ferc1", "utility_id_eia"], suffixes=["", "_ignore"], how="outer", indicator=True, ) # Drop the matches we wish to ignore logger.info(f"Ignoring {len(matches.loc[matches._merge == 'both'])} matches.") matches = matches.loc[matches._merge == "left_only"].drop( columns=["_merge"] + [col for col in matches.columns if col.endswith("_ignore")] ) return matches
[docs] def get_existing_glue_df(): """Read in the existing handmade glue spreadsheet.""" return pd.read_csv(PUDL_PACKAGE_DATA_PATH / "glue" / "utility_id_pudl.csv")
[docs] def get_false_matches(): """Read in the existing handmade false matches spreadsheet.""" return pd.read_csv( PUDL_PACKAGE_DATA_PATH / "glue" / "utility_id_pudl_false_matches.csv" )
[docs] def drop_records_with_matches( entity: GlueEntity, matches_new: pd.DataFrame, existing_glue_df: pd.DataFrame, matches_existing: pd.DataFrame, unmatched_existing: pd.DataFrame, ): """Drop records in the original dataframe where matches have been found. This takes the original existing_glue_df dataframe, and drops records which were previously unmatched and have been matched using the automated matching method. We do this in preparation for adding the new matches to the spreadsheet. """ utility_column = f"utility_id_{entity}" utility_pudl_column = f"utility_id_pudl_{entity}" matching_utility_column = ( "utility_id_ferc1" if entity == "eia" else "utility_id_eia" ) # Get all records where: # 1) The utility ID of the entity is unmatched in the override CSV # 2) The PUDL-assigned utility ID isn't already used in any match. entity_matched = matches_new[ matches_new[utility_column].isin(unmatched_existing[utility_column]) & (~matches_new[utility_pudl_column].isin(matches_existing.utility_id_pudl)) ] drop_records = existing_glue_df[ existing_glue_df[utility_column].isin(entity_matched[utility_column]) ] # Check that we aren't dropping any EIA matches assert drop_records[matching_utility_column].isnull().all() # Check that no other plants are mapped to this ID before we drop it pudl_ids = drop_records.utility_id_pudl if not ( other_records := existing_glue_df[ (existing_glue_df.utility_id_pudl.isin(pudl_ids)) & (existing_glue_df[matching_utility_column].notnull()) ] ).empty: f"Other records are mapped to the ID we want to drop: \n{other_records.reset_index(drop=True)}" # Drop these unmatched records so we can replace them with matches. logger.info( f"Dropping {len(drop_records)} unmatched {entity} records to replace with matches." ) return existing_glue_df.drop(drop_records.index, axis=0)
[docs] def add_new_matches_to_dataframe( matches_new: pd.DataFrame, existing_glue_df: pd.DataFrame ): """Add new matches to existing hand-mapped dataframe. We add new matches and assign them new PUDL utility IDs, following one of the following scenarios: 1) If the PUDL utility ID already exists in another match, we keep that utility ID to avoid splitting up existing sets of matches. 2) If the PUDL utility ID doesn't show up anywhere else (i.e., this record was previously unmatched to any other utility), we create a new auto-incremented PUDL ID. 3) If the PUDL utility ID was previously matched to both another EIA and another FERC utility, this poses a challenge that needs to be resolved by hand and we raise a ValueError. Once all PUDL utility IDs are assigned, we flag all records where multiple FERC and multiple EIA utilities are matched together. These are uncommon cases and benefit from manual review to ensure that no unexpected connections have been created. """ final_updates = [] matches_new["utility_id_pudl"] = pd.NA for _i, match in matches_new.iterrows(): if match.utility_id_ferc1 in set(existing_glue_df.utility_id_ferc1): if match.utility_id_eia in set(existing_glue_df.utility_id_eia): raise ValueError( f"Found a complex match that connects to different FERC and EIA utilities. This will require manual debugging: {match}" ) match["utility_id_pudl"] = match.utility_id_pudl_ferc1 elif match.utility_id_eia in set(existing_glue_df.utility_id_eia): if match.utility_id_ferc1 in set(existing_glue_df.utility_id_ferc1): raise ValueError( f"Found a complex match that connects to different FERC and EIA utilities. This will require manual debugging: {match}" ) match["utility_id_pudl"] = match.utility_id_pudl_eia final_updates.append(match) updates_df = pd.DataFrame(final_updates) if updates_df.empty: # If no updates, do nothing logger.info("No new matches found.") return None updates_df = updates_df.drop( columns=["utility_id_pudl_ferc1", "utility_id_pudl_eia"] ) # Whichever records have a blank PUDL ID have no linkage to other existing records # and can be reassigned without impact. # Reassign the blank PUDL IDs by auto-incrementing from the current max record value. # First, assign a group ID for each group of cleaned utility names. # We use the cleaned utility name to do this as this is the field # we used to originally join these records. # TODO: If we introduce different joining methods (e.g., address), revise this method. updates_df = updates_df.assign( group_id=lambda x: x.groupby("cleaned_utility_name").ngroup() ) # Then use this value to fill NAs in the utility ID PUDL. updates_df["utility_id_pudl"] = updates_df.utility_id_pudl.fillna( updates_df.group_id + max(existing_glue_df.utility_id_pudl) ).astype("Int64") updates_df = updates_df.drop(columns=["group_id", "cleaned_utility_name"]) # Add a note to these records to help us quickly identify which were matched automatically.s updates_df["notes"] = ( f"These records were automatically matched based on utility name on {pd.to_datetime('today').strftime('%m/%d/%Y')}" ) logger.info( f"Adding {len(updates_df)} utility matches to the override spreadsheet. Remember to manually review these." ) updated_existing_glue_df = pd.concat([existing_glue_df, updates_df]).sort_values( ["utility_id_pudl", "utility_id_ferc1", "utility_id_eia"] ) # Run some sanity checks # Flag all records where one utility ID corresponds to more than one FERC AND more than one EIA utility. ids_per_utility = updated_existing_glue_df.groupby("utility_id_pudl")[ ["utility_id_ferc1", "utility_id_eia"] ].nunique() multi_utility_ids = ids_per_utility[ (ids_per_utility.utility_id_ferc1 > 1) & (ids_per_utility.utility_id_eia > 1) ].index logger.warning( "The following records have more than one FERC and more than one EIA utility matched to them. Please manually review!" ) logger.warning( updated_existing_glue_df[ updated_existing_glue_df.utility_id_pudl.isin(multi_utility_ids) ] ) return updated_existing_glue_df
[docs] def write_updated_matches(test_run: bool, dataframe: pd.DataFrame): """Write the updated matching spreadsheet to disk.""" if test_run: repo_root = Path.cwd() devtools_dir = Path(repo_root / "devtools" / "ferc1-eia-glue") csv_path = devtools_dir / "utility_id_pudl_test_update.csv" else: csv_path = PUDL_PACKAGE_DATA_PATH / "glue" / "utility_id_pudl.csv" logger.info(f"Writing matches to {csv_path}") dataframe.to_csv(csv_path, index=False)
@click.command( context_settings={"help_option_names": ["-h", "--help"]}, ) @click.option( "--test-run", is_flag=True, default=False, help="If passed, will save the updated spreadsheet to a test file rather than overwriting the existing package data.", )
[docs] def main(test_run: bool) -> int: """Match EIA and FERC utilities based on utility name alone.""" # Read in the data, keeping only utility IDs and name eia_df = get_parquet_table( "out_eia__yearly_utilities", columns=["utility_id_eia", "utility_id_pudl", "utility_name_eia"], ).drop_duplicates() ferc_df = get_parquet_table("core_pudl__assn_ferc1_pudl_utilities") # Clean the data eia_df["cleaned_utility_name"] = clean_utility_name(eia_df["utility_name_eia"]) ferc_df["cleaned_utility_name"] = clean_utility_name(ferc_df["utility_name_ferc1"]) # Get false matches (we ignore these even if they exactly name match) false_matches = get_false_matches() # Exactly match the cleaned name matched_utilities = match_utility_names( eia_df=eia_df, ferc_df=ferc_df, false_matches=false_matches ) # Warn about duplicated utilities logger.info("The following utilities are matched to more than one FERC ID:") logger.info( matched_utilities[ matched_utilities.utility_id_ferc1.duplicated(keep=False) ].sort_values("utility_id_ferc1") ) logger.info("The following utilities are matched to more than one EIA ID:") logger.info( matched_utilities[ matched_utilities.utility_id_eia.duplicated(keep=False) ].sort_values("utility_id_eia") ) # Get the override spreadsheet existing_glue_df = get_existing_glue_df() ## Get matches from the CSV matches_existing = existing_glue_df[ existing_glue_df.utility_id_eia.notnull() & existing_glue_df.utility_id_ferc1.notnull() ] # Unmatched records unmatched = existing_glue_df[ ~( existing_glue_df.utility_id_eia.notnull() & existing_glue_df.utility_id_ferc1.notnull() ) ] for entity in get_args(GlueEntity.__value__): existing_glue_df = drop_records_with_matches( entity=entity, matches_new=matched_utilities, existing_glue_df=existing_glue_df, matches_existing=matches_existing, unmatched_existing=unmatched, ) updated_spreadsheet = add_new_matches_to_dataframe( matches_new=matched_utilities, existing_glue_df=existing_glue_df ) if updated_spreadsheet and not updated_spreadsheet.empty: write_updated_matches(test_run=test_run, dataframe=updated_spreadsheet) return 0
if __name__ == "__main__": sys.exit(main())