From 78567fca74674029baa0dbb3d287be127ca3bf12 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:39:57 +0530 Subject: [PATCH 01/10] feat(genx): add shared resource classification taxonomy --- GenX/tool_logic/resources.py | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 GenX/tool_logic/resources.py diff --git a/GenX/tool_logic/resources.py b/GenX/tool_logic/resources.py new file mode 100644 index 00000000..43f39575 --- /dev/null +++ b/GenX/tool_logic/resources.py @@ -0,0 +1,82 @@ +"""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", + "ignore": "Ignore", +} + + +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 "distributed_generation" in normalized + or re.search(r"(^|_)pv($|_)", normalized) + ): + return "solar" + + if "combined_cycle" in normalized or "combustion_turbine" in normalized: + return "natural_gas" + + return "other" From c0f03f563362c0a881b50fb0be7a1860f24faa20 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:00 +0530 Subject: [PATCH 02/10] refactor(genx): use shared classifier for capacity plots --- GenX/tool_logic/plot_capacity.py | 72 ++++---------------------------- 1 file changed, 7 insertions(+), 65 deletions(-) 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: From e4949d8afda34e8ad30f575096c04a6edaeb5212 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:03 +0530 Subject: [PATCH 03/10] refactor(genx): align diurnal resource groups with capacity plot --- GenX/tool_logic/diurnal_generation.py | 28 +++++---------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/GenX/tool_logic/diurnal_generation.py b/GenX/tool_logic/diurnal_generation.py index b6547a2b..72e933e5 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} From 035132a1d197ca1cf9d13d1575921f83eb4005cc Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:07 +0530 Subject: [PATCH 04/10] test(genx): cover cross-plot classification invariants --- tests/test_genx_server.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_genx_server.py b/tests/test_genx_server.py index 6f1937c7..972c1992 100644 --- a/tests/test_genx_server.py +++ b/tests/test_genx_server.py @@ -302,3 +302,62 @@ 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_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_does_not_drop_distributed_generation(): + """Distributed-generation resources remain visible in technology totals.""" + from GenX.tool_logic.resources import classify_resource + + assert classify_resource("MA_distributed_generation_solar") == "solar" + assert classify_resource("MA_distributed_generation_wind") == "wind" From 965c9e62a2121059b376a76be6182622bd72d7c5 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:41:06 +0530 Subject: [PATCH 05/10] fix(genx): handle ambiguous distributed generation conservatively --- GenX/tool_logic/resources.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/GenX/tool_logic/resources.py b/GenX/tool_logic/resources.py index 43f39575..03b892e1 100644 --- a/GenX/tool_logic/resources.py +++ b/GenX/tool_logic/resources.py @@ -23,7 +23,6 @@ "biomass": "Biomass", "other": "Other", "dr": "DR", - "ignore": "Ignore", } @@ -71,7 +70,6 @@ def classify_resource(resource: object) -> str: "solar" in normalized or "photovoltaic" in normalized or "utilitypv" in normalized - or "distributed_generation" in normalized or re.search(r"(^|_)pv($|_)", normalized) ): return "solar" From 34b5c88fbf598de9f9c27b27f1576531e270c875 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:41:17 +0530 Subject: [PATCH 06/10] test(genx): clarify ambiguous distributed generation classification --- tests/test_genx_server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_genx_server.py b/tests/test_genx_server.py index 972c1992..a79f5644 100644 --- a/tests/test_genx_server.py +++ b/tests/test_genx_server.py @@ -355,9 +355,10 @@ def test_shared_genx_resource_classifier(name, expected): assert classify_resource(name) == expected -def test_capacity_classifier_does_not_drop_distributed_generation(): - """Distributed-generation resources remain visible in technology totals.""" +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" From 6e2d3d3163b049c4734d303283d8c8db52e91da4 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:43:58 +0530 Subject: [PATCH 07/10] fix(genx): translate shared keys to display labels --- GenX/tool_logic/diurnal_generation.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/GenX/tool_logic/diurnal_generation.py b/GenX/tool_logic/diurnal_generation.py index 72e933e5..3f188d4f 100644 --- a/GenX/tool_logic/diurnal_generation.py +++ b/GenX/tool_logic/diurnal_generation.py @@ -99,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) From 52fcdd94bdcabd49a744f495f31322b250ec603a Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:44:07 +0530 Subject: [PATCH 08/10] test(genx): assert diurnal labels stay stable after shared classification --- tests/test_genx_diurnal_weights.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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) From 68cc3c35abb560eff552e237350d3a398ba5b686 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:49:25 +0530 Subject: [PATCH 09/10] fix(genx): preserve legacy cc and ct resource suffixes --- GenX/tool_logic/resources.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/GenX/tool_logic/resources.py b/GenX/tool_logic/resources.py index 03b892e1..d38e7887 100644 --- a/GenX/tool_logic/resources.py +++ b/GenX/tool_logic/resources.py @@ -74,7 +74,12 @@ def classify_resource(resource: object) -> str: ): return "solar" - if "combined_cycle" in normalized or "combustion_turbine" in normalized: + 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" From 0b694a0d060d1169609cdcfb45b1219a0a5eafa5 Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:49:28 +0530 Subject: [PATCH 10/10] test(genx): cover legacy cc and ct resource suffixes --- tests/test_genx_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_genx_server.py b/tests/test_genx_server.py index a79f5644..f378b955 100644 --- a/tests/test_genx_server.py +++ b/tests/test_genx_server.py @@ -337,6 +337,8 @@ def test_genx_capacity_and_diurnal_classifiers_share_one_taxonomy(): ("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"),