diff --git a/data/input/metadata_index.json b/data/input/metadata_index.json index b36d172..76a81fe 100644 --- a/data/input/metadata_index.json +++ b/data/input/metadata_index.json @@ -63,6 +63,12 @@ ] }, "emissions": { + "elec_emission_source" : [ + "Marginal (Cambium, Long-run)", + "Marginal (Cambium, Short-run)", + "Average (Cambium)", + "Constant (User-provided)" + ], "year": [ 2025, 2030, diff --git a/layout/charts.py b/layout/charts.py index 2e9704b..523ce35 100644 --- a/layout/charts.py +++ b/layout/charts.py @@ -54,7 +54,7 @@ def emissions_bar_chart(): dmc.MultiSelect( id="emission-em-scen-dropdown", data=_emission_scen_seed(), # will be overwritten dynamically - value=["em_scenario_a", "em_scenario_b", "em_scenario_c"], + value=["em_scenario_a", "em_scenario_b", "em_scenario_c", "em_scenario_d"], placeholder="Emission Scenarios", searchable=True, clearable=True, diff --git a/layout/input.py b/layout/input.py index e1e3517..3af1f1d 100644 --- a/layout/input.py +++ b/layout/input.py @@ -1163,7 +1163,7 @@ def build_emissions_table(emission_data, active_ids=None, view_mode="simple", un view_mode: One of "simple", "advanced", or "differences" unit_mode: "SI" or "IP" for unit conversion """ - from utils.units import get_unit_converter, get_unit_label + from utils.units import get_converter, get_display_unit emission_df = pd.DataFrame(emission_data) if isinstance(emission_data, list) else emission_data @@ -1181,16 +1181,22 @@ def build_emissions_table(emission_data, active_ids=None, view_mode="simple", un # Sort for stable column order emission_df = emission_df.sort_values("em_scen_id").reset_index(drop=True) - # Get unit label for NG emission rate (dynamic based on unit_mode) - ng_emission_rate_unit = get_unit_label("emissions_rate", unit_mode) + # Get unit label for emission rates (dynamic based on unit_mode) + ng_emission_rate_unit = get_display_unit("gas_emission_factor", unit_mode) + elec_emission_rate_unit = get_display_unit("emissions_rate", unit_mode) # Rows to display (property name, label) # Note: em_scen_id is excluded as it's shown in the header row_config = [ + ("em_scen_name", "Scenario"), + ("elec_emission_source", "Grid emissions source"), + ( + "elec_avg_emission_rate_gCO2e_per_kWh", + f"Constant grid emissions rate ({elec_emission_rate_unit})", + ), ("grid_scenario", "Grid Scenario"), ("gea_grid_region", "GEA Grid Region"), ("emission_type", "Emission Type"), - ("shortrun_weighting", "Short-run weighting"), ("annual_refrig_leakage_percent", "Refrigerant leakage (frac)"), ( "ng_emission_rate_gCO2e_per_kWh", @@ -1319,8 +1325,9 @@ def build_emissions_table(emission_data, active_ids=None, view_mode="simple", un # ---------- Property rows ---------- diff_row_style = TABLE_STYLE.diff_row_style - # Get converter for NG emission rate values - ng_emission_rate_converter = get_unit_converter("emissions_rate", unit_mode) + # Get converter for emission rate values + ng_emission_rate_converter = get_converter("gas_emission_factor", unit_mode) + elec_emission_rate_converter = get_converter("emissions_rate", unit_mode) for field, label in available_rows: is_diff_row = field in diff_fields @@ -1335,13 +1342,19 @@ def build_emissions_table(emission_data, active_ids=None, view_mode="simple", un for idx, scen_id in enumerate(scen_ids): raw_value = emission_df.iloc[idx].get(field, "") - # Apply unit conversion for NG emission rate + # Apply unit conversion for emission rates if field == "ng_emission_rate_gCO2e_per_kWh" and raw_value is not None: try: converted = ng_emission_rate_converter(float(raw_value)) display_value = f"{converted:.2f}" except (ValueError, TypeError): display_value = format_table_value(raw_value, field_name=field) + elif field == "elec_avg_emission_rate_gCO2e_per_kWh" and raw_value is not None: + try: + converted = elec_emission_rate_converter(float(raw_value)) + display_value = f"{converted:.2f}" + except (ValueError, TypeError): + display_value = format_table_value(raw_value, field_name=field) else: display_value = format_table_value(raw_value, field_name=field) @@ -1910,6 +1923,36 @@ def _options(values): ], grow=True, ), + dmc.SimpleGrid( + cols=2, + spacing="md", + children=[ + dmc.Select( + id="edit-em-elec-source", + label="Grid emissions source", + placeholder="Select grid emissions source", + data=_options(emissions_index["elec_emission_source"]), + searchable=True, + clearable=False, + ), + dmc.Stack( + [ + dmc.Text( + id="edit-em-elec-emission-rate-label", + children="Constant grid emissions rate (g/kWh)", + size="sm", + fw=500, + ), + dmc.NumberInput( + id="edit-em-elec-avg-emission-rate", + min=0, + step=1, + ), + ], + gap=4, + ), + ], + ), dmc.SimpleGrid( cols=2, spacing="md", @@ -1936,12 +1979,6 @@ def _options(values): cols=2, spacing="md", children=[ - dmc.TextInput( - id="edit-em-time-zone", - label="Time zone", - placeholder="e.g. America/Los_Angeles", - disabled=True, - ), dmc.Select( id="edit-em-emission-type", label="Emission type", @@ -1950,19 +1987,6 @@ def _options(values): searchable=False, clearable=False, ), - ], - ), - dmc.SimpleGrid( - cols=2, - spacing="md", - children=[ - dmc.NumberInput( - id="edit-em-shortrun-weighting", - label="Short-run weighting", - min=0, - max=1, - step=0.1, - ), dmc.Select( id="edit-em-year", label="Year", diff --git a/pages/emissions_page.py b/pages/emissions_page.py index 33c94b3..d9e9e67 100644 --- a/pages/emissions_page.py +++ b/pages/emissions_page.py @@ -100,6 +100,10 @@ def layout(): "label": "Combustion vs pre-combustion", "value": "emission_types", }, + { + "label": "Different electricity emission factors", + "value": "emission_sources", + }, ], value=None, placeholder="Select a scenario group", @@ -235,6 +239,7 @@ def handle_emission_group_selection(group_id, metadata_data, selected_ids, store - year: Varies years (2025, 2035, 2045), others reset to defaults - refrigerant_leakage: Varies leakage (0.01, 0.05, 0.1), others reset to defaults - emission_types: Varies emission type ("Includes pre-combustion" vs "Combustion only"), others reset to defaults + - emission_sources: Varies emission source (average and marginal) and associated values, others reset to defaults """ # When dropdown is cleared, clear the stored group to allow re-selecting if not group_id: @@ -257,8 +262,12 @@ def handle_emission_group_selection(group_id, metadata_data, selected_ids, store default_ids = ["em_scenario_a", "em_scenario_b"] if group_id != "emission_types": # only two scenarios for comparing emission types default_ids.append("em_scenario_c") + if group_id == "emission_sources": # fourth scenario for comparing emission sources + default_ids.append("em_scenario_d") # Get defaults + default_elec_emission_source = EmissionScenarioDefaults.ELEC_EMISSION_SOURCE.value + default_elec_avg_emission_rate = EmissionScenarioDefaults.ELEC_AVG_EMISSION_RATE_G_KWH.value default_year = EmissionScenarioDefaults.YEAR.value default_leakage = EmissionScenarioDefaults.REFRIGERANT_LEAKAGE.value default_emission_type = EmissionScenarioDefaults.EMISSION_TYPE.value @@ -272,24 +281,37 @@ def handle_emission_group_selection(group_id, metadata_data, selected_ids, store EmissionScenarioDefaults.NG_EMISSION_RATE_G_KWH_COMBUSTION.value, default_ng_emission_rate, ] + elec_emission_source_values = [ + "Average (Cambium)", + "Constant (User-provided)", + "Marginal (Cambium, Long-run)", + "Marginal (Cambium, Short-run)", + ] + elec_avg_emission_rate_values = [None, 0, None, None] + emission_sources_names = [ + "Cambium Average Emissions", + "Constant (Zero) Emissions", + "Long-run Marginal Emissions", + "Short-run Marginal Emissions", + ] # Create base scenario template from first existing scenario or defaults base_scenario = ( existing_scenarios[0].copy() if existing_scenarios else { + "elec_emission_source": default_elec_emission_source, + "elec_avg_emission_rate_gCO2e_per_kWh": default_elec_avg_emission_rate, "grid_scenario": "MidCase", "gea_grid_region": None, - "time_zone": "America/Los_Angeles", "emission_type": default_emission_type, - "shortrun_weighting": 0, "annual_refrig_leakage_percent": default_leakage, "ng_emission_rate_gCO2e_per_kWh": default_ng_emission_rate, "year": default_year, } ) - # Reset to default 2/3 scenarios (a, b, optionally c) with group-specific values + # Reset to default 2/3/4 scenarios (a, b, optionally c & d) with group-specific values updated_scenarios = [] for idx, scen_id in enumerate(default_ids): scen = {**base_scenario, "em_scen_id": scen_id, "em_scen_name": ""} @@ -297,27 +319,53 @@ def handle_emission_group_selection(group_id, metadata_data, selected_ids, store if group_id == "year": # Vary year, reset others to defaults scen["year"] = year_values[idx % len(year_values)] + scen["em_scen_name"] = str(scen["year"]) + scen["annual_refrig_leakage_percent"] = default_leakage scen["emission_type"] = default_emission_type scen["ng_emission_rate_gCO2e_per_kWh"] = default_ng_emission_rate - scen["em_scen_name"] = str(scen["year"]) + scen["elec_emission_source"] = default_elec_emission_source + scen["elec_avg_emission_rate_gCO2e_per_kWh"] = default_elec_avg_emission_rate elif group_id == "refrigerant_leakage": # Vary leakage, reset others to defaults scen["annual_refrig_leakage_percent"] = leakage_values[idx % len(leakage_values)] + pct_leakage = scen["annual_refrig_leakage_percent"] * 100 + scen["em_scen_name"] = f"{pct_leakage:.0f}% leakage" + scen["year"] = default_year scen["emission_type"] = default_emission_type scen["ng_emission_rate_gCO2e_per_kWh"] = default_ng_emission_rate - pct_leakage = scen["annual_refrig_leakage_percent"] * 100 - scen["em_scen_name"] = f"{pct_leakage:.0f}% leakage" + scen["elec_emission_source"] = default_elec_emission_source + scen["elec_avg_emission_rate_gCO2e_per_kWh"] = default_elec_avg_emission_rate elif group_id == "emission_types": # Set emission type, reset others to defaults scen["emission_type"] = emission_types[idx % len(emission_types)] scen["ng_emission_rate_gCO2e_per_kWh"] = ng_emission_rate_values[ idx % len(ng_emission_rate_values) ] + scen["em_scen_name"] = scen["emission_type"] + + scen["year"] = default_year + scen["annual_refrig_leakage_percent"] = default_leakage + scen["elec_emission_source"] = default_elec_emission_source + scen["elec_avg_emission_rate_gCO2e_per_kWh"] = default_elec_avg_emission_rate + elif group_id == "emission_sources": + # Vary source, avg emissions rate, and short-run weighting, reset others to defaults + scen["elec_emission_source"] = elec_emission_source_values[ + idx % len(elec_emission_source_values) + ] + scen["elec_avg_emission_rate_gCO2e_per_kWh"] = elec_avg_emission_rate_values[ + idx % len(elec_avg_emission_rate_values) + ] + scen["em_scen_name"] = emission_sources_names[idx % len(emission_sources_names)] + if scen["elec_emission_source"] == "Constant (User-provided)": + scen["grid_scenario"] = None + scen["gea_grid_region"] = None + scen["year"] = default_year scen["annual_refrig_leakage_percent"] = default_leakage - scen["em_scen_name"] = scen["emission_type"] + scen["emission_type"] = default_emission_type + scen["ng_emission_rate_gCO2e_per_kWh"] = default_ng_emission_rate updated_scenarios.append(scen) @@ -555,11 +603,11 @@ def remove_emission_scenario(remove_clicks, metadata_data, selected_em_ids): Output("emissions-edit-modal", "opened"), Output("edit-em-scenario-id-input", "value"), Output("edit-em-scenario-name-input", "value"), + Output("edit-em-elec-source", "value"), + Output("edit-em-elec-avg-emission-rate", "value"), Output("edit-em-grid-scenario", "value"), Output("edit-em-gea-grid-region", "value"), - Output("edit-em-time-zone", "value"), Output("edit-em-emission-type", "value"), - Output("edit-em-shortrun-weighting", "value"), Output("edit-em-year", "value"), Output("edit-em-refrig-leakage", "value"), Output("edit-em-ng-emission-rate", "value"), @@ -579,13 +627,13 @@ def open_edit_emission_modal(edit_clicks, metadata_data, unit_mode): "", "", "", + None, "", "", "", None, None, None, - None, "No emission data.", ) @@ -606,13 +654,13 @@ def open_edit_emission_modal(edit_clicks, metadata_data, unit_mode): "", "", "", + None, "", "", "", None, None, None, - None, "Failed to parse button id.", ) @@ -625,44 +673,53 @@ def open_edit_emission_modal(edit_clicks, metadata_data, unit_mode): "", "", "", + None, "", "", "", None, None, None, - None, f"Scenario {em_scen_id!r} not found.", ) - # Convert NG emissions rate for display based on unit mode + # Convert emissions rates for display based on unit mode + from utils.units import get_converter + ng_emission_rate_base = scen.get("ng_emission_rate_gCO2e_per_kWh") if ng_emission_rate_base is not None and unit_mode == "IP": - from utils.units import get_unit_converter - - ng_converter = get_unit_converter("emissions_rate", "IP") + ng_converter = get_converter("gas_emission_factor", "IP") ng_emission_rate_display = ng_converter(float(ng_emission_rate_base)) else: ng_emission_rate_display = ng_emission_rate_base + elec_avg_emission_rate_base = scen.get("elec_avg_emission_rate_gCO2e_per_kWh") + if elec_avg_emission_rate_base is not None and unit_mode == "IP": + elec_converter = get_converter("emissions_rate", "IP") + elec_avg_emission_rate_display = elec_converter(float(elec_avg_emission_rate_base)) + else: + elec_avg_emission_rate_display = elec_avg_emission_rate_base + # Round refrigerant leakage to 2 decimal places for display refrig_leakage = scen.get("annual_refrig_leakage_percent") if refrig_leakage is not None: refrig_leakage = round(float(refrig_leakage), 2) - # Round NG emission rate display to 2 decimal places + # Round emission rate display to 2 decimal places if ng_emission_rate_display is not None: ng_emission_rate_display = round(float(ng_emission_rate_display), 2) + if elec_avg_emission_rate_display is not None: + elec_avg_emission_rate_display = round(float(elec_avg_emission_rate_display), 2) return ( True, scen.get("em_scen_id"), scen.get("em_scen_name", ""), + scen.get("elec_emission_source", ""), + elec_avg_emission_rate_display, scen.get("grid_scenario", ""), scen.get("gea_grid_region", ""), - scen.get("time_zone", ""), scen.get("emission_type", ""), - scen.get("shortrun_weighting"), str(scen.get("year")) if scen.get("year") is not None else "", refrig_leakage, ng_emission_rate_display, @@ -677,11 +734,11 @@ def open_edit_emission_modal(edit_clicks, metadata_data, unit_mode): Input("edit-em-scenario-save-btn", "n_clicks"), State("edit-em-scenario-id-input", "value"), State("edit-em-scenario-name-input", "value"), + State("edit-em-elec-source", "value"), + State("edit-em-elec-avg-emission-rate", "value"), State("edit-em-grid-scenario", "value"), State("edit-em-gea-grid-region", "value"), - State("edit-em-time-zone", "value"), State("edit-em-emission-type", "value"), - State("edit-em-shortrun-weighting", "value"), State("edit-em-year", "value"), State("edit-em-refrig-leakage", "value"), State("edit-em-ng-emission-rate", "value"), @@ -693,11 +750,11 @@ def save_edit_emission( n_clicks, scen_id, scen_name, + elec_emission_source, + elec_avg_emission_rate, grid_scenario, gea_grid_region, - time_zone, emission_type, - shortrun_weighting, year, refrig_leakage, ng_emission_rate, @@ -714,11 +771,6 @@ def save_edit_emission( return True, no_update, "Scenario ID is missing." # basic type cleaning - try: - shortrun_weighting = float(shortrun_weighting) if shortrun_weighting is not None else 0.0 - except (TypeError, ValueError): - shortrun_weighting = 0.0 - try: year = int(year) if year is not None and year != "" else 2025 except (TypeError, ValueError): @@ -734,6 +786,14 @@ def save_edit_emission( except (TypeError, ValueError): ng_emission_rate = 0.0 + try: + elec_avg_emission_rate = ( + float(elec_avg_emission_rate) if elec_avg_emission_rate is not None else None + ) + except (TypeError, ValueError): + elec_avg_emission_rate = None + # default avg emission rate is None to generate an error if calculation is run without a valid input + # Convert NG emissions rate back to base units (g/kWh) if in IP mode unit_mode = unit_mode or "SI" if unit_mode == "IP" and ng_emission_rate > 0: @@ -743,6 +803,13 @@ def save_edit_emission( # g/kWh = (lb/kBTU) / (g_to_lb / Wh_to_BTU) ng_emission_rate = ng_emission_rate / (g_to_lb / Wh_to_BTU) + if unit_mode == "IP" and elec_avg_emission_rate is not None: + from utils.units import g_to_lb + + # IP unit is lb/kWh, convert back to g/kWh + # g/kWh = (lb/kWh) / (g_to_lb) + elec_avg_emission_rate = elec_avg_emission_rate / (g_to_lb) + scenarios = metadata_data.get("emission_settings", []) updated = False new_scenarios = [] @@ -751,11 +818,11 @@ def save_edit_emission( if scen.get("em_scen_id") == scen_id: new_scen = scen.copy() new_scen["em_scen_name"] = scen_name or scen.get("em_scen_name", "") + new_scen["elec_emission_source"] = elec_emission_source + new_scen["elec_avg_emission_rate_gCO2e_per_kWh"] = elec_avg_emission_rate new_scen["grid_scenario"] = grid_scenario new_scen["gea_grid_region"] = gea_grid_region - new_scen["time_zone"] = time_zone new_scen["emission_type"] = emission_type - new_scen["shortrun_weighting"] = shortrun_weighting new_scen["year"] = year new_scen["annual_refrig_leakage_percent"] = refrig_leakage new_scen["ng_emission_rate_gCO2e_per_kWh"] = ng_emission_rate @@ -786,15 +853,18 @@ def cancel_edit_emission_modal(n_clicks): @callback( Output("edit-em-ng-emission-rate-label", "children"), + Output("edit-em-elec-emission-rate-label", "children"), Input("unit-toggle", "value"), ) -def update_ng_emissions_rate_label(unit_mode): - """Update NG emissions rate label based on unit mode.""" - from utils.units import get_unit_label +def update_emissions_rate_label(unit_mode): + """Update NG/elec emissions rate label based on unit mode.""" + from utils.units import get_display_unit unit_mode = unit_mode or "SI" - ng_unit = get_unit_label("emissions_rate", unit_mode) - return f"Gas emissions rate ({ng_unit})" + ng_unit = get_display_unit("gas_emission_factor", unit_mode) + elec_unit = get_display_unit("emissions_rate", unit_mode) + + return f"Gas emissions rate ({ng_unit})", f"Constant grid emissions rate ({elec_unit})" @callback( @@ -936,3 +1006,26 @@ def update_ng_rate_on_emission_type_change(emission_type, unit_mode): ) return ng_emission_rate + + +@callback( + Output("edit-em-elec-avg-emission-rate", "disabled"), + Output("edit-em-grid-scenario", "value", allow_duplicate=True), + Output("edit-em-gea-grid-region", "value", allow_duplicate=True), + Input("edit-em-elec-source", "value"), + State("edit-em-grid-scenario", "value"), + State("edit-em-gea-grid-region", "value"), + prevent_initial_call=True, +) +def update_emission_inputs_on_source_change(emission_source, grid_scenario, grid_region): + """Update emissions inputs when emission source changes to fixed user-provided value.""" + + # disable average emissions rate input + disable_avg = emission_source != "Constant (User-provided)" + + # blank out cambium-related inputs + if not disable_avg: + grid_scenario = None + grid_region = None + + return disable_avg, grid_scenario, grid_region diff --git a/src/config.py b/src/config.py index 4c3338b..f79c319 100644 --- a/src/config.py +++ b/src/config.py @@ -44,12 +44,22 @@ class EquipmentTableRows(Enum): class EmissionTableRows(Enum): """Row configurations for emission table view modes.""" - SIMPLE = ("grid_scenario", "gea_grid_region", "emission_type", "year") + SIMPLE = ( + "em_scen_name", + "elec_emission_source", + "elec_avg_emission_rate_gCO2e_per_kWh", + "grid_scenario", + "gea_grid_region", + "emission_type", + "year", + ) ADVANCED = ( + "em_scen_name", + "elec_emission_source", + "elec_avg_emission_rate_gCO2e_per_kWh", "grid_scenario", "gea_grid_region", "emission_type", - "shortrun_weighting", "annual_refrig_leakage_percent", "ng_emission_rate_gCO2e_per_kWh", "year", @@ -59,6 +69,8 @@ class EmissionTableRows(Enum): class EmissionScenarioDefaults(Enum): """Default values for emission scenario parameters.""" + ELEC_EMISSION_SOURCE: str = "Marginal (Cambium, Long-run)" + ELEC_AVG_EMISSION_RATE_G_KWH: float = None YEAR: int = 2025 REFRIGERANT_LEAKAGE: float = 0.02 EMISSION_TYPE: str = "Includes pre-combustion" @@ -158,6 +170,9 @@ class Columns(StrEnum): SRMER_CO2E_C = "srmer_co2e_c" SRMER_CO2E_P = "srmer_co2e_p" SRMER_CO2E = "srmer_co2e" + AER_LOAD_CO2E_C = "aer_load_co2e_c" + AER_LOAD_CO2E_P = "aer_load_co2e_p" + AER_LOAD_CO2E = "aer_load_co2e" SHORTRUN_WEIGHTING = "shortrun_weighting" ELEC_EMISSIONS_RATE_G_PER_KWH = "elec_emissions_rate_gCO2e_per_kWh" diff --git a/src/emissions.py b/src/emissions.py index 2c39029..dd286e8 100644 --- a/src/emissions.py +++ b/src/emissions.py @@ -10,11 +10,11 @@ class EmissionScenario(DotAccessMixin, BaseModel): em_scen_id: str em_scen_name: str - grid_scenario: str + elec_emission_source: str + elec_avg_emission_rate_gCO2e_per_kWh: float | None = None + grid_scenario: str | None = None gea_grid_region: str | None = None - time_zone: str emission_type: str - shortrun_weighting: float annual_refrig_leakage_percent: float ng_emission_rate_gCO2e_per_kWh: float year: int @@ -24,7 +24,7 @@ class StandardEmissions: """ Unified interface for emissions data. Canonical schema: - emission_scenario | gea_grid_region | time_zone | year | timestamp | lrmer_co2e_c | lrmer_co2e_p | srmer_co2e_c | srmer_co2e_p + emission_scenario | gea_grid_region | year | timestamp | lrmer_co2e_c | lrmer_co2e_p | srmer_co2e_c | srmer_co2e_p | aer_load_co2e_c | aer_load_co2e_p """ def __init__(self, df: pd.DataFrame): @@ -36,12 +36,13 @@ def _validate(df: pd.DataFrame) -> pd.DataFrame: "emission_scenario", "gea_grid_region", "year", - "time_zone", "timestamp", "lrmer_co2e_c", "lrmer_co2e_p", "srmer_co2e_c", "srmer_co2e_p", + "aer_load_co2e_c", + "aer_load_co2e_p", ] missing = [c for c in required if c not in df.columns] if missing: @@ -54,7 +55,14 @@ def _validate(df: pd.DataFrame) -> pd.DataFrame: df = df.sort_values("timestamp").set_index("timestamp") # enforce numeric - for col in ["lrmer_co2e_c", "lrmer_co2e_p", "srmer_co2e_c", "srmer_co2e_p"]: + for col in [ + "lrmer_co2e_c", + "lrmer_co2e_p", + "srmer_co2e_c", + "srmer_co2e_p", + "aer_load_co2e_c", + "aer_load_co2e_p", + ]: df[col] = pd.to_numeric(df[col], errors="coerce") if df[col].isnull().any(): raise ValueError(f"Invalid numeric values in column {col}") @@ -107,7 +115,6 @@ def get_emissions_data( "em_scen_id": scenario.em_scen_id, "emission_scenario": scenario.grid_scenario, "gea_grid_region": scenario.gea_grid_region, - "time_zone": scenario.time_zone, "emission_type": scenario.emission_type, "year": df["year"], "timestamp": df["timestamp"], @@ -117,6 +124,9 @@ def get_emissions_data( "srmer_co2e_c": df["srmer_co2e_c"], "srmer_co2e_p": df["srmer_co2e_p"], "srmer_co2e": df["srmer_co2e"], + "aer_load_co2e_c": df["aer_load_co2e_c"], + "aer_load_co2e_p": df["aer_load_co2e_p"], + "aer_load_co2e": df["aer_load_co2e"], } ) diff --git a/src/energy.py b/src/energy.py index e41c0d0..cd85c28 100644 --- a/src/energy.py +++ b/src/energy.py @@ -1149,12 +1149,7 @@ def site_to_source( f"Processing emission scenario: {em_scen_id}, year={metadata[em_scen_id].year}" ) - emissions_data = get_emissions_data(metadata[em_scen_id]) - logger.debug(f"Loaded {len(emissions_data.df)} emission data rows") - em_scen = metadata[em_scen_id] - - shortrun_weighting = float(em_scen.shortrun_weighting) annual_refrig_leakage_percent = float(em_scen.annual_refrig_leakage_percent) gas_emissions_rate = float(em_scen.ng_emission_rate_gCO2e_per_kWh) @@ -1166,62 +1161,117 @@ def site_to_source( base[Col.HOUR.value] = base.index.hour base[Col.DOY.value] = base.index.dayofyear - # collapse emissions to month-hour averages - emissions_data.df[Col.MONTH.value] = emissions_data.df.index.month - emissions_data.df[Col.HOUR.value] = emissions_data.df.index.hour - emissions_data.df[Col.SHORTRUN_WEIGHTING.value] = shortrun_weighting - group_cols = [Col.MONTH.value, Col.HOUR.value] - - # all rates are in gCO2e/kWh - if em_scen.emission_type == "Combustion only": - emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = ( - emissions_data.df[Col.LRMER_CO2E_C.value] * (1 - shortrun_weighting) - ) + (emissions_data.df[Col.SRMER_CO2E_C.value] * shortrun_weighting) - elif em_scen.emission_type == "Includes pre-combustion": - emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = ( - ( - emissions_data.df[Col.LRMER_CO2E_C.value] - + emissions_data.df[Col.LRMER_CO2E_P.value] + if em_scen.elec_emission_source in [ + "Marginal (Cambium, Long-run)", + "Marginal (Cambium, Short-run)", + "Average (Cambium)", + ]: + emissions_data = get_emissions_data(metadata[em_scen_id]) + logger.debug( + f"Cambium grid emissions: Loaded {len(emissions_data.df)} emission data rows" + ) + + # collapse emissions to month-hour averages + emissions_data.df[Col.MONTH.value] = emissions_data.df.index.month + emissions_data.df[Col.HOUR.value] = emissions_data.df.index.hour + group_cols = [Col.MONTH.value, Col.HOUR.value] + + if em_scen.elec_emission_source == "Average (Cambium)": + # all rates are in gCO2e/kWh + if em_scen.emission_type == "Combustion only": + emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = emissions_data.df[ + Col.AER_LOAD_CO2E_C.value + ] + elif em_scen.emission_type == "Includes pre-combustion": + emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = ( + emissions_data.df[Col.AER_LOAD_CO2E_C.value] + + emissions_data.df[Col.AER_LOAD_CO2E_P.value] + ) + else: + raise ValueError(f"Invalid emissions_type: {em_scen.emission_type}") + + df_em = ( + emissions_data.df.groupby(group_cols)[ + [ + Col.ELEC_EMISSIONS_RATE_G_PER_KWH, + Col.AER_LOAD_CO2E_C.value, + Col.AER_LOAD_CO2E_P.value, + Col.AER_LOAD_CO2E.value, + ] + ] + .mean() + .reset_index() ) - * (1 - shortrun_weighting) - ) + ( - ( - emissions_data.df[Col.SRMER_CO2E_C.value] - + emissions_data.df[Col.SRMER_CO2E_P.value] + else: + if em_scen.elec_emission_source == "Marginal (Cambium, Long-run)": + shortrun_weighting = 0 + elif em_scen.elec_emission_source == "Marginal (Cambium, Short-run)": + shortrun_weighting = 1 + emissions_data.df[Col.SHORTRUN_WEIGHTING.value] = shortrun_weighting + + # all rates are in gCO2e/kWh + if em_scen.emission_type == "Combustion only": + emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = ( + emissions_data.df[Col.LRMER_CO2E_C.value] * (1 - shortrun_weighting) + ) + (emissions_data.df[Col.SRMER_CO2E_C.value] * shortrun_weighting) + elif em_scen.emission_type == "Includes pre-combustion": + emissions_data.df[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = ( + ( + emissions_data.df[Col.LRMER_CO2E_C.value] + + emissions_data.df[Col.LRMER_CO2E_P.value] + ) + * (1 - shortrun_weighting) + ) + ( + ( + emissions_data.df[Col.SRMER_CO2E_C.value] + + emissions_data.df[Col.SRMER_CO2E_P.value] + ) + * shortrun_weighting + ) + else: + raise ValueError(f"Invalid emissions_type: {em_scen.emission_type}") + + df_em = ( + emissions_data.df.groupby(group_cols)[ + [ + Col.ELEC_EMISSIONS_RATE_G_PER_KWH, + Col.LRMER_CO2E_C.value, + Col.LRMER_CO2E_P.value, + Col.LRMER_CO2E.value, + Col.SRMER_CO2E_C.value, + Col.SRMER_CO2E_P.value, + Col.SRMER_CO2E.value, + Col.SHORTRUN_WEIGHTING.value, + ] + ] + .mean() + .reset_index() ) - * shortrun_weighting - ) - else: - raise ValueError(f"Invalid emissions_type: {em_scen.emission_type}") - - df_em = ( - emissions_data.df.groupby(group_cols)[ - [ - Col.ELEC_EMISSIONS_RATE_G_PER_KWH, - Col.LRMER_CO2E_C.value, - Col.LRMER_CO2E_P.value, - Col.LRMER_CO2E.value, - Col.SRMER_CO2E_C.value, - Col.SRMER_CO2E_P.value, - Col.SRMER_CO2E.value, - Col.SHORTRUN_WEIGHTING.value, - ] - ] - .mean() - .reset_index() - ) - # expand loads with this year's emissions - merged = base.merge(df_em, on=[Col.MONTH.value, Col.HOUR.value], how="left") + # expand loads with this year's emissions + merged = base.merge(df_em, on=[Col.MONTH.value, Col.HOUR.value], how="left") - nan_count = merged[Col.ELEC_EMISSIONS_RATE_G_PER_KWH].isna().sum() - if nan_count > 0: - logger.warning(f"Merge produced {nan_count} rows with missing emission rates") + nan_count = merged[Col.ELEC_EMISSIONS_RATE_G_PER_KWH].isna().sum() + if nan_count > 0: + logger.warning(f"Merge produced {nan_count} rows with missing emission rates") - # Note: Keep original load data year (already extracted above) for timestamp - # reconstruction. This avoids Feb 29 errors when leap year load data is - # used with non-leap emission scenario years. Emissions are still correct - # because they're matched by month+hour pattern. + # Note: Keep original load data year (already extracted above) for timestamp + # reconstruction. This avoids Feb 29 errors when leap year load data is + # used with non-leap emission scenario years. Emissions are still correct + # because they're matched by month+hour pattern. + + elif em_scen.elec_emission_source == "Constant (User-provided)": + elec_avg_emission_rate = em_scen.elec_avg_emission_rate_gCO2e_per_kWh + if elec_avg_emission_rate is not None and elec_avg_emission_rate >= 0: + logger.debug(f"Constant grid emissions rate: {elec_avg_emission_rate} gCO2e/kWh") + + merged = base.copy() + merged[Col.ELEC_EMISSIONS_RATE_G_PER_KWH] = float(elec_avg_emission_rate) + else: + raise ValueError("Provide a valid input value for constant grid emissions rate.") + + else: + raise ValueError(f"Invalid elec_emissions_source: {em_scen.elec_emission_source}") ## fuel switching logic emissions_intensity_HP = ( diff --git a/src/metadata.py b/src/metadata.py index 76740e9..f4ca6e4 100644 --- a/src/metadata.py +++ b/src/metadata.py @@ -97,11 +97,11 @@ def create(cls, **overrides: Any) -> "Metadata": EmissionScenario( em_scen_id="em_scenario_a", em_scen_name="2025", + elec_emission_source="Marginal (Cambium, Long-run)", + elec_avg_emission_rate_gCO2e_per_kWh=None, grid_scenario="MidCase", gea_grid_region=None, - time_zone="America/Los_Angeles", emission_type="Includes pre-combustion", - shortrun_weighting=0, annual_refrig_leakage_percent=0.05, ng_emission_rate_gCO2e_per_kWh=239.2, year=2025, @@ -109,11 +109,11 @@ def create(cls, **overrides: Any) -> "Metadata": EmissionScenario( em_scen_id="em_scenario_b", em_scen_name="2035", + elec_emission_source="Marginal (Cambium, Long-run)", + elec_avg_emission_rate_gCO2e_per_kWh=None, grid_scenario="MidCase", gea_grid_region=None, - time_zone="America/Los_Angeles", emission_type="Includes pre-combustion", - shortrun_weighting=0, annual_refrig_leakage_percent=0.05, ng_emission_rate_gCO2e_per_kWh=239.2, year=2035, @@ -121,11 +121,11 @@ def create(cls, **overrides: Any) -> "Metadata": EmissionScenario( em_scen_id="em_scenario_c", em_scen_name="2045", + elec_emission_source="Marginal (Cambium, Long-run)", + elec_avg_emission_rate_gCO2e_per_kWh=None, grid_scenario="MidCase", gea_grid_region=None, - time_zone="America/Los_Angeles", emission_type="Includes pre-combustion", - shortrun_weighting=0, annual_refrig_leakage_percent=0.05, ng_emission_rate_gCO2e_per_kWh=239.2, year=2045, diff --git a/tests/conftest.py b/tests/conftest.py index 80ec79b..953b9c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,11 +54,11 @@ def emission_scenario(): """Create a sample emission scenario for testing.""" return EmissionScenario( em_scen_id="test_em_1", + elec_emission_source="Marginal (Cambium, Long-run)", + elec_avg_emission_rate_gCO2e_per_kWh=None, grid_scenario="MidCase", gea_grid_region="CAMX", - time_zone="America/Los_Angeles", emission_type="Includes pre-combustion", - shortrun_weighting=0.0, annual_refrig_leakage_percent=0.05, ng_emission_rate_gCO2e_per_kWh=239.2, year=2025, diff --git a/utils/units.py b/utils/units.py index 707945f..7ad45be 100644 --- a/utils/units.py +++ b/utils/units.py @@ -185,7 +185,7 @@ def sqft_to_sqm(sqft): "emissions_rate": { "base": "g CO₂e/kWh", "SI": {"unit": "g CO₂e/kWh", "func": lambda x: x}, - "IP": {"unit": "lb CO₂e/kBTU", "func": lambda x: x * g_to_lb / Wh_to_BTU}, + "IP": {"unit": "lb CO₂e/kWh", "func": lambda x: x * g_to_lb}, }, # --- Gas emission factor --- "gas_emission_factor": { @@ -298,6 +298,8 @@ def sqft_to_sqm(sqft): "lrmer_co2e_p": ("emissions_rate", "LRMER Pre-combustion"), "srmer_co2e_c": ("emissions_rate", "SRMER Combustion"), "srmer_co2e_p": ("emissions_rate", "SRMER Pre-combustion"), + "aer_load_co2e_c": ("emissions_rate", "AER Load Combustion"), + "aer_load_co2e_p": ("emissions_rate", "AER Load Pre-combustion"), "ng_emission_rate_gCO2e_per_kWh": ("gas_emission_factor", "Gas Emissions Rate"), # === Refrigerant Mass (kg) === "hr_wwhp_refrigerant_weight_kg": ("mass", "HR-WWHP Refrig Weight"), @@ -327,6 +329,7 @@ def sqft_to_sqm(sqft): # === Emission Scenario Parameters === "lrmer_co2e": (None, "LRMER CO₂e"), "srmer_co2e": (None, "SRMER CO₂e"), + "aer_load_co2e": (None, "AER Load CO₂e"), "shortrun_weighting": (None, "Short-run Weighting"), "year": (None, "Year"), # === Scenario Identifiers ===