Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 29 additions & 24 deletions GenX/tool_logic/diurnal_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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}
Expand Down Expand Up @@ -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)


Expand Down
72 changes: 7 additions & 65 deletions GenX/tool_logic/plot_capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions GenX/tool_logic/resources.py
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions tests/test_genx_diurnal_weights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
62 changes: 62 additions & 0 deletions tests/test_genx_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading