diff --git a/GenX/tool_logic/diurnal_generation.py b/GenX/tool_logic/diurnal_generation.py index b6547a2b..3f188d4f 100644 --- a/GenX/tool_logic/diurnal_generation.py +++ b/GenX/tool_logic/diurnal_generation.py @@ -17,6 +17,7 @@ from matplotlib.patches import Patch from GenX.tool_logic.palette import RESOURCE_COLORS +from GenX.tool_logic.resources import classify_resource logger = logging.getLogger(__name__) @@ -32,29 +33,10 @@ def classify(name): - """Map a GenX resource name to a technology group.""" - n = name.lower() - if n.endswith("_dr") or "_dr_" in n: - return "DR" - if "nuclear" in n: - return "Nuclear" - if "coal" in n: - return "Coal" - if ("combined_cycle" in n or n.endswith("_cc_new") - or "combustion_turbine" in n or n.endswith("_ct_new")): - return "Natural Gas" - if "hydroelectric" in n: - return "Hydro" - if "wind" in n: - return "Wind" - if "pv" in n or "solar" in n or "distributed_generation" in n: - return "Solar" - if "batter" in n: - return "Battery" - return "Other" - - -# What is this function doing ?? + """Backward-compatible alias for the shared GenX resource classifier.""" + return classify_resource(name) + + def zone_set(spec): # Converts a user's zone request into a set for the MCP tool to use # i.e. what comes in as "[1, 3, 5]" gets transformed to {1, 3, 5} @@ -117,7 +99,30 @@ def diurnal_by_tech(case_dir, period, zones="all", verbose=False): for g, cols in groups.items(): x = data[cols].sum(axis=1).to_numpy() out[g] = np.bincount(hod, weights * x, minlength=24) / wsum - df = pd.DataFrame(out, index=range(24)) + + # Stable snake_case keys are shared with the capacity classifier; translate + # only at the presentation boundary so calculations remain consistent. + label_by_key = { + "natural_gas": "Natural Gas", + "hydro": "Hydro", + "nuclear": "Nuclear", + "coal": "Coal", + "wind": "Wind", + "solar": "Solar", + "battery": "Battery", + "biomass": "Other", + "other": "Other", + "dr": "DR", + } + display_out = {} + for key, values in out.items(): + label = label_by_key.get(key, "Other") + if label in display_out: + display_out[label] = display_out[label] + values + else: + display_out[label] = values + + df = pd.DataFrame(display_out, index=range(24)) return df.reindex(columns=[g for g in STACK_ORDER if g in df.columns], fill_value=0.0) diff --git a/GenX/tool_logic/plot_capacity.py b/GenX/tool_logic/plot_capacity.py index 88d1fcf2..daa28f81 100644 --- a/GenX/tool_logic/plot_capacity.py +++ b/GenX/tool_logic/plot_capacity.py @@ -7,23 +7,15 @@ import matplotlib.pyplot as plt from GenX.tool_logic.palette import RESOURCE_COLORS +from GenX.tool_logic.resources import classify_resource, RESOURCE_LABELS -# Resource name mapping for proper capitalization -resource_labels = { - "coal": "Coal", - "natural_gas": "Natural Gas", - "solar": "Solar", - "wind": "Wind", - "battery": "Battery", - "hydropower": "Hydro", - "hydro": "Hydro", - "nuclear": "Nuclear", - "biomass": "Biomass", -} +# Resource-label aliases retained for callers that import this module directly. +resource_labels = dict(RESOURCE_LABELS) -# Colors for capacity bar graph, keyed by resource_group (shared palette) +# Colors for capacity bar graph, keyed by the shared technology group. resource_colors = { - group: RESOURCE_COLORS[label] for group, label in resource_labels.items() + group: RESOURCE_COLORS[label] + for group, label in resource_labels.items() } # Plot titles for each capacity column @@ -35,57 +27,7 @@ "NetCap": "Net Capacity Change", } -# Classify resources for plotting - -def classify_resource(resource): - r = str(resource).lower() - - # Ignore rows that should not be visualized as resource types - if r == "total": - return "ignore" - - # Ignoring distributed generation for now - if "distributed_generation" in r or "distributed generation" in r: - return "ignore" - - # Storage - if "batt" in r: - return "battery" - - # Hydro - if "hydroelectric" in r or "hydro" in r: - return "hydro" - - # Solar - if "utilitypv" in r: - return "solar" - if "solar" in r or "photovoltaic" in r: - return "solar" - - # Wind - if "wind" in r: - return "wind" - - # Biomass - if "biomass" in r: - return "biomass" - - # Nuclear - if "nuclear" in r: - return "nuclear" - - # Coal - if "coal" in r: - return "coal" - - # Natural gas bucket including petroleum - if "natural_gas" in r or "naturalgas" in r: - return "natural_gas" - if "petroleum" in r or "oil" in r: - return "natural_gas" - - # Catch all for debugging - return "unclassified" +# Resource classification is shared with the diurnal-generation plotter. # Filter to a user-specified list of zone numbers def filter_by_zones(df: pd.DataFrame, zones: list[int]) -> pd.DataFrame: diff --git a/GenX/tool_logic/resources.py b/GenX/tool_logic/resources.py new file mode 100644 index 00000000..d38e7887 --- /dev/null +++ b/GenX/tool_logic/resources.py @@ -0,0 +1,85 @@ +"""One resource-name taxonomy shared by all GenX result visualizations. + +GenX result labels are not always canonical technology names: names can contain +technology tokens such as combined_cycle, combustion_turbine, _DR, hydroelectric, +distributed_generation or fuel terminology. Keep the mapping here so capacity +plots and time-of-day generation plots cannot silently report different totals. +""" + +from __future__ import annotations + +import re + +# Keys are stable snake_case identifiers used by data-processing functions. +# Labels are shared with palette.py and user-facing chart legends. +RESOURCE_LABELS: dict[str, str] = { + "coal": "Coal", + "natural_gas": "Natural Gas", + "solar": "Solar", + "wind": "Wind", + "battery": "Battery", + "hydro": "Hydro", + "nuclear": "Nuclear", + "biomass": "Biomass", + "other": "Other", + "dr": "DR", +} + + +def classify_resource(resource: object) -> str: + """Map a GenX resource name to a stable shared technology key. + + Order matters: subtotal rows are ignored, demand-response resources are + distinguished before fuel names, and specific technologies precede broad + fallback matches. + """ + name = str(resource or "").strip().lower() + normalized = re.sub(r"[\s\-]+", "_", name) + + if normalized in {"", "total", "annualsum", "zone"}: + return "ignore" + + if normalized.endswith("_dr") or "_dr_" in normalized or normalized == "dr": + return "dr" + + if "batt" in normalized or "storage" in normalized: + return "battery" + + if "hydroelectric" in normalized or "hydropower" in normalized or "hydro" in normalized: + return "hydro" + + if "nuclear" in normalized: + return "nuclear" + + if "coal" in normalized: + return "coal" + + if "natural_gas" in normalized or "naturalgas" in normalized or "gas" in normalized: + return "natural_gas" + + if "petroleum" in normalized or re.search(r"(^|_)oil($|_)", normalized): + return "natural_gas" + + if "biomass" in normalized or "biogas" in normalized: + return "biomass" + + if "wind" in normalized: + return "wind" + + if ( + "solar" in normalized + or "photovoltaic" in normalized + or "utilitypv" in normalized + or re.search(r"(^|_)pv($|_)", normalized) + ): + return "solar" + + if ( + "combined_cycle" in normalized + or "combustion_turbine" in normalized + or normalized.endswith("_cc_new") + or normalized.endswith("_ct_new") + ): + return "natural_gas" + + return "other" diff --git a/tests/test_genx_diurnal_weights.py b/tests/test_genx_diurnal_weights.py index 3884572e..c5ffbb72 100644 --- a/tests/test_genx_diurnal_weights.py +++ b/tests/test_genx_diurnal_weights.py @@ -60,3 +60,18 @@ def test_diurnal_computes_profile_when_every_hour_has_weight(tmp_path): assert result.shape == (24, 2) assert result["Coal"].tolist() == [100 + hour for hour in range(24)] assert result["Solar"].tolist() == [50 + hour for hour in range(24)] + + + +def test_diurnal_groups_use_shared_classification_and_display_labels(tmp_path): + from GenX.tool_logic.diurnal_generation import diurnal_by_tech + + case = _write_case( + tmp_path, + [1.0] * 24, + ) + result = diurnal_by_tech(str(case), period=1) + + assert "Coal" in result.columns + assert "Solar" in result.columns + assert result.shape == (24, 2) diff --git a/tests/test_genx_server.py b/tests/test_genx_server.py index 6f1937c7..f378b955 100644 --- a/tests/test_genx_server.py +++ b/tests/test_genx_server.py @@ -302,3 +302,65 @@ def test_paths_outside_the_allowed_roots_are_refused(genx_server, tmp_path, monk result = genx_server.summarize_capacity(str(outside / "capacity.csv")) assert result["success"] is False assert "csv_path" in result["message"] + + + +def test_genx_capacity_and_diurnal_classifiers_share_one_taxonomy(): + """Every recognized class must have the same label in both plot pathways.""" + from GenX.tool_logic.plot_capacity import classify_resource, resource_labels + from GenX.tool_logic.diurnal_generation import classify + + samples = { + "MA_natural_gas_combined_cycle": "natural_gas", + "CT_combustion_turbine": "natural_gas", + "MA_oil_steam": "natural_gas", + "MA_coal": "coal", + "MA_solar_pv": "solar", + "MA_distributed_generation_solar": "solar", + "MA_distributed_generation_wind": "wind", + "MA_wind_onshore": "wind", + "MA_battery_4hr": "battery", + "MA_hydroelectric": "hydro", + "MA_nuclear": "nuclear", + "MA_biomass": "biomass", + } + for name, expected_key in samples.items(): + assert classify_resource(name) == expected_key + assert classify(name) == expected_key + assert expected_key in resource_labels + + +@pytest.mark.parametrize( + "name,expected", + [ + ("Total", "ignore"), + ("MA_coal", "coal"), + ("MA_natural_gas_combined_cycle", "natural_gas"), + ("MA_combustion_turbine_ct_new", "natural_gas"), + ("MA_cc_new", "natural_gas"), + ("MA_ct_new", "natural_gas"), + ("MA_petroleum_oil", "natural_gas"), + ("MA_distributed_generation_solar", "solar"), + ("MA_solar_photovoltaic", "solar"), + ("MA_utilitypv", "solar"), + ("MA_wind", "wind"), + ("MA_batt_4hr", "battery"), + ("MA_hydroelectric", "hydro"), + ("MA_nuclear", "nuclear"), + ("MA_biomass", "biomass"), + ("MA_unknown_technology", "other"), + ], +) +def test_shared_genx_resource_classifier(name, expected): + from GenX.tool_logic.resources import classify_resource + + assert classify_resource(name) == expected + + +def test_capacity_classifier_handles_distributed_generation_by_technology(): + """Use explicit technology tokens; never assume all distributed generation is solar.""" + from GenX.tool_logic.resources import classify_resource + + assert classify_resource("MA_distributed_generation_solar") == "solar" + assert classify_resource("MA_distributed_generation_wind") == "wind" + assert classify_resource("MA_distributed_generation") == "other"