diff --git a/conftest.py b/conftest.py index e6fda557..53ace37d 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,6 @@ """Root conftest for R2X workspace. -Handles two concerns: -1. Loading fixture modules as pytest plugins for proper discovery. -2. Getter registry cleanup to avoid collisions between packages. +Handles getter registry cleanup to avoid collisions between packages. r2x_core uses a global GETTER_REGISTRY dict. Multiple workspace packages register getters with identical names (e.g., 'is_slack_bus', 'get_availability'). @@ -20,9 +18,7 @@ # --------------------------------------------------------------------------- # Fixture plugins # --------------------------------------------------------------------------- -# Fixture modules declared here are loaded as local pytest plugins, giving -# clean discovery without sys.path hacks or star imports in conftest files. -# The modules must be importable via pythonpath entries in pyproject.toml. +# Pytest 8.4+ requires pytest_plugins to be declared only in the root conftest. pytest_plugins = [ "fixtures.configs", "fixtures.context", diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/__init__.py b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/__init__.py index 9e4172e9..246d0117 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/__init__.py +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/__init__.py @@ -26,6 +26,8 @@ ensure_generator_node_memberships, ensure_head_storage_generator_membership, ensure_interface_line_memberships, + ensure_pumped_hydro_storages_created, + ensure_reference_node_memberships, ensure_region_node_memberships, ensure_reserve_battery_memberships, ensure_reserve_generator_memberships, @@ -50,6 +52,7 @@ "sienna_to_plexos", "REEDS_COMPONENT_SUBSTRINGS", "ensure_region_node_memberships", + "ensure_reference_node_memberships", "ensure_interface_line_memberships", "ensure_generator_node_memberships", "ensure_battery_node_memberships", @@ -59,6 +62,7 @@ "ensure_transformer_node_memberships", "ensure_head_storage_generator_membership", "ensure_tail_storage_generator_membership", + "ensure_pumped_hydro_storages_created", "membership_region_parent_node", "membership_region_child_node", "membership_reserve_child_generator", diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/defaults.json b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/defaults.json index 3f6eb92e..61fabf55 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/defaults.json +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/defaults.json @@ -56,28 +56,74 @@ "prime_mover_types": { "BA": "battery", "BT": "geothermal", - "CA": "gas-cc", - "CC": "gas-cc", "CE": "caes", "CP": "csp", - "CS": "gas-cc", - "CT": "gas-cc", "ES": "other", "FC": "smr", "FW": "other", - "GT": "gas-ct", - "HA": "hydnd", - "HB": "hydnd", - "HK": "hydnd", - "HY": "hyded", - "IC": "lfill-gas", + "HA": "hydro", + "HB": "hydro", + "HK": "hydro", + "HY": "hydro", "PS": "pumped-hydro", "OT": "other", - "ST": "coal", "PVe": "upv", "WT": "wind-ons", "WS": "wind-ofs" }, + "reeds_thermal_mapping": { + "coal": [ + "ANTHRACITE_COAL", + "BITUMINOUS_COAL", + "SUBBITUMINOUS_COAL", + "LIGNITE_COAL", + "COAL", + "REFINED_COAL", + "WASTE_COAL", + "SYNTHESIS_GAS_COAL" + ], + "natural-gas": [ + "NATURAL_GAS" + ], + "o-g-s": [ + "DISTILLATE_FUEL_OIL", + "RESIDUAL_FUEL_OIL", + "PETROLEUM_COKE", + "JET_FUEL", + "KEROSENE", + "PROPANE", + "WASTE_OIL", + "SYNTHESIS_GAS_PETROLEUM_COKE", + "OTHER_GAS", + "BLAST_FURNACE_GAS" + ], + "biopower": [ + "AG_BIOPRODUCT", + "AG_BYPRODUCT", + "MUNICIPAL_WASTE", + "OTHER_BIOMASS_SOLIDS", + "WOOD_WASTE_SOLIDS", + "OTHER_BIOMASS_LIQUIDS", + "SLUDGE_WASTE", + "BLACK_LIQUOR", + "WOOD_WASTE_LIQUIDS", + "TIREDERIVED_FUEL", + "WASTE_HEAT" + ], + "lfill-gas": [ + "LANDFILL_GAS", + "OTHER_BIOMASS_GAS" + ], + "nuclear": [ + "NUCLEAR" + ], + "egs": [ + "GEOTHERMAL" + ], + "other": [ + "OTHER" + ] + }, "reeds_defaults": { "syn-cond": { "capacity_MW": 320.0, @@ -173,6 +219,20 @@ "min_up_time": 7.0, "start_cost_per_MW": 5.3 }, + "coal": { + "capacity_MW": 302.0, + "forced_outage_rate": 0.0429, + "maintenance_rate": 0.12, + "max_capacity_MW": 856.0, + "max_ramp_up_percentage": 0.2, + "mean_time_to_repair": 55.0, + "min_capacity_MW": 50.0, + "min_down_time": 12.0, + "min_stable_level_percentage": 0.4, + "min_up_time": 24.0, + "start_cost_per_MW": 129.0, + "vom_cost": 4.5 + }, "coal-ccs": { "capacity_MW": 302.0, "forced_outage_rate": 0.0429, @@ -484,6 +544,20 @@ "min_up_time": 16.0, "start_cost_per_MW": 0.0 }, + "natural-gas": { + "capacity_MW": 320.0, + "forced_outage_rate": 0.0328, + "maintenance_rate": 0.06, + "max_capacity_MW": 944.0, + "max_ramp_up_percentage": 0.05, + "mean_time_to_repair": 48.0, + "min_capacity_MW": 10.0, + "min_down_time": 8.0, + "min_stable_level_percentage": 0.5, + "min_up_time": 6.0, + "start_cost_per_MW": 79.0, + "vom_cost": 4.2 + }, "gas-cc": { "capacity_MW": 320.0, "forced_outage_rate": 0.0328, @@ -742,168 +816,19 @@ "ramp_rate_down": 30.0, "ramp_rate_up": 30.0 }, - "hydd": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": null, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hyded": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": null, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydend": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": 1.0, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydnd": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": 1.0, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydnpd": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": null, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydnpnd": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": 1.0, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydsd": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": null, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydsn": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": 1.0, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, - "hydud": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": null, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, "hydro": { + "capacity_MW": 150.0, "average_capacity_MW": 50.0, "forced_outage_rate": 0.02, "maintenance_rate": 0.01, "mean_time_to_repair": 48, + "max_ramp_up_percentage": 0.8, "min_energy": 0.0, "min_power": 0.0, "ramp_rate_down": 100.0, "ramp_rate_up": 100.0, "vom_cost": 3.2 }, - "hydtrb": { - "average_capacity_MW": 50.0, - "forced_outage_rate": 0.02, - "maintenance_rate": 0.01, - "mean_time_to_repair": 48, - "min_energy": 0.0, - "min_power": 0.0, - "ramp_rate_down": 100.0, - "ramp_rate_up": 100.0, - "vom_cost": 3.2 - }, - "hydund": { - "capacity_MW": null, - "forced_outage_rate": null, - "maintenance_rate": null, - "max_capacity_MW": null, - "max_ramp_up_percentage": 0.05, - "mean_time_to_repair": 24.0, - "min_capacity_MW": null, - "min_down_time": null, - "min_stable_level_percentage": 1.0, - "min_up_time": null, - "start_cost_per_MW": null, - "vom_cost": 3.2 - }, "lfill-gas": { "capacity_MW": 20.0, "forced_outage_rate": 0.0309, @@ -1012,10 +937,12 @@ "start_cost_per_MW": 69.0 }, "pumped-hydro": { + "capacity_MW": 150.0, "average_capacity_MW": 100.0, "forced_outage_rate": 0.020, "maintenance_rate": 0.01, "mean_time_to_repair": 48, + "max_ramp_up_percentage": 0.8, "min_energy": 0.0, "min_power": 0.0, "ramp_rate_down": 100.0, @@ -1027,9 +954,7 @@ "maintenance_rate": 0.020, "mean_time_to_repair": 24, "min_energy": 0.0, - "min_power": 0.0, - "ramp_rate_down": 100.0, - "ramp_rate_up": 100.0 + "min_power": 0.0 }, "wind-ofs": { "average_capacity_MW": 5.0, @@ -1037,9 +962,7 @@ "maintenance_rate": 0.02, "mean_time_to_repair": 48, "min_energy": 0.0, - "min_power": 0.0, - "ramp_rate_down": 100.0, - "ramp_rate_up": 100.0 + "min_power": 0.0 }, "wind-ons": { "average_capacity_MW": 2.5, @@ -1047,11 +970,10 @@ "maintenance_rate": 0.01, "mean_time_to_repair": 24, "min_energy": 0.0, - "min_power": 0.0, - "ramp_rate_down": 100.0, - "ramp_rate_up": 100.0 + "min_power": 0.0 }, "other": { + "capacity_MW": 100.0, "forced_outage_rate": 0.0328, "maintenance_rate": 0.06, "max_ramp_up_percentage": 0.05, diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/rules.json b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/rules.json index 4356eac0..447ab961 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/rules.json +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/config/rules.json @@ -12,7 +12,6 @@ "units": "get_availability", "voltage": "get_voltage_kv", "is_slack_bus": "is_slack_bus", - "ac_voltage_magnitude": "get_ac_voltage_magnitude_pu", "load_participation_factor": "get_load_participation_factor" }, "source_type": "ACBus", @@ -21,7 +20,7 @@ }, { "defaults": { - "category": "sienna-regions" + "category": "areas" }, "field_map": { "uuid": "uuid", @@ -37,6 +36,22 @@ "target_type": "PLEXOSRegion", "version": 1 }, + { + "defaults": { + "category": "zones" + }, + "field_map": { + "name": "name", + "uuid": "uuid", + "category": "category" + }, + "getters": { + "units": "get_zone_units" + }, + "source_type": "LoadZone", + "target_type": "PLEXOSZone", + "version": 1 + }, { "defaults": { "category": "line", @@ -57,8 +72,7 @@ "max_flow": "get_line_max_flow", "loss_incr": "lines_loss_incremental", "wheeling_charge": "lines_wheeling_charge", - "wheeling_charge_back": "lines_wheeling_charge_back", - "ac_line_charging_susceptance": "get_line_charging_susceptance" + "wheeling_charge_back": "lines_wheeling_charge_back" }, "source_type": "Line", "target_type": "PLEXOSLine", @@ -81,8 +95,7 @@ "max_flow": "get_line_max_flow", "loss_incr": "lines_loss_incremental", "wheeling_charge": "lines_wheeling_charge", - "wheeling_charge_back": "lines_wheeling_charge_back", - "ac_line_charging_susceptance": "get_line_charging_susceptance" + "wheeling_charge_back": "lines_wheeling_charge_back" }, "source_type": "MonitoredLine", "target_type": "PLEXOSLine", @@ -198,11 +211,9 @@ { "defaults": { "category": "tap-transformer", - "ac_tap_ratio": 1.0, "susceptance": 0.0 }, "field_map": { - "ac_tap_ratio": "tap", "name": "name", "reactance": "x", "resistance": "r", @@ -221,7 +232,6 @@ { "defaults": { "category": "phase-shifting-transformer", - "ac_tap_ratio": 1.0, "susceptance": 0.0 }, "field_map": { @@ -229,9 +239,7 @@ "uuid": "uuid", "category": "category", "reactance": "x", - "resistance": "r", - "ac_tap_ratio": "tap", - "ac_fixed_shift_angle": "α" + "resistance": "r" }, "getters": { "units": "get_availability", @@ -250,8 +258,7 @@ }, "field_map": { "reactance": "x_primary", - "resistance": "r_primary", - "ac_tap_ratio": "primary_turns_ratio" + "resistance": "r_primary" }, "getters": { "name": "get_3w_transformer_primary_name", @@ -272,8 +279,7 @@ }, "field_map": { "reactance": "x_secondary", - "resistance": "r_secondary", - "ac_tap_ratio": "secondary_turns_ratio" + "resistance": "r_secondary" }, "getters": { "name": "get_3w_transformer_secondary_name", @@ -293,8 +299,7 @@ }, "field_map": { "reactance": "x_tertiary", - "resistance": "r_tertiary", - "ac_tap_ratio": "tertiary_turns_ratio" + "resistance": "r_tertiary" }, "getters": { "name": "get_3w_transformer_tertiary_name", @@ -310,14 +315,11 @@ "defaults": { "category": "pst-trf3w-primary-winding", "units": 1, - "ac_tap_ratio": 1.0, "susceptance": 0.0 }, "field_map": { "reactance": "x_primary", - "resistance": "r_primary", - "ac_tap_ratio": "primary_turns_ratio", - "ac_fixed_shift_angle": "α_primary" + "resistance": "r_primary" }, "getters": { "name": "get_3w_transformer_primary_name", @@ -333,14 +335,11 @@ "defaults": { "category": "pst-trf3w-secondary-winding", "units": 1, - "ac_tap_ratio": 1.0, "susceptance": 0.0 }, "field_map": { "reactance": "x_secondary", - "resistance": "r_secondary", - "ac_tap_ratio": "secondary_turns_ratio", - "ac_fixed_shift_angle": "α_secondary" + "resistance": "r_secondary" }, "getters": { "name": "get_3w_transformer_secondary_name", @@ -356,14 +355,11 @@ "defaults": { "category": "pst-trf3w-tertiary-winding", "units": 1, - "ac_tap_ratio": 1.0, "susceptance": 0.0 }, "field_map": { "reactance": "x_tertiary", - "resistance": "r_tertiary", - "ac_tap_ratio": "tertiary_turns_ratio", - "ac_fixed_shift_angle": "α_tertiary" + "resistance": "r_tertiary" }, "getters": { "name": "get_3w_transformer_tertiary_name", @@ -406,6 +402,7 @@ "shutdown_cost": "get_generator_shutdown_cost", "start_cost": "get_generator_start_cost", "vom_charge": "get_generator_vom_cost", + "load_point": "get_generator_load_point", "ext": "get_component_ext" }, "source_type": "ThermalStandard", @@ -445,6 +442,7 @@ "shutdown_cost": "get_generator_shutdown_cost", "start_cost": "get_generator_start_cost", "vom_charge": "get_generator_vom_cost", + "load_point": "get_generator_load_point", "ext": "get_component_ext" }, "source_type": "ThermalMultiStart", @@ -461,6 +459,7 @@ }, "getters": { "name": "get_generator_name", + "units": "get_hydro_generator_units", "commit": "get_generator_commit", "category": "get_generator_category", "max_ramp_down": "get_max_ramp_down", @@ -490,6 +489,7 @@ }, "getters": { "name": "get_generator_name", + "units": "get_hydro_generator_units", "commit": "get_generator_commit", "category": "get_generator_category", "max_ramp_down": "get_max_ramp_down", @@ -518,7 +518,9 @@ }, "getters": { "name": "get_generator_name", + "units": "get_pumped_hydro_generator_units", "commit": "get_generator_commit", + "category": "get_pumped_hydro_category", "rating": "get_generator_rating", "max_ramp_up": "get_max_ramp_up", "max_ramp_down": "get_max_ramp_down", @@ -544,8 +546,9 @@ }, "getters": { "name": "get_generator_name", + "units": "get_pumped_hydro_generator_units", "commit": "get_generator_commit", - "category": "get_generator_category", + "category": "get_pumped_hydro_category", "rating": "get_generator_rating", "max_ramp_up": "get_max_ramp_up", "max_ramp_down": "get_max_ramp_down", @@ -573,6 +576,7 @@ }, "getters": { "name": "get_generator_name", + "units": "get_dispatch_generator_units", "commit": "get_generator_commit", "category": "get_generator_category", "rating": "get_generator_rating", @@ -599,6 +603,7 @@ "getters": { "commit": "get_generator_commit", "name": "get_generator_name", + "units": "get_dispatch_generator_units", "category": "get_generator_category", "rating": "get_generator_rating", "max_capacity": "get_max_capacity", @@ -613,39 +618,12 @@ "target_type": "PLEXOSGenerator", "version": 1 }, - { - "defaults": { - "category": "syn-cond", - "units": 0 - }, - "field_map": { - "name": "name", - "uuid": "uuid", - "category": "category" - }, - "getters": { - "name": "get_generator_name", - "commit": "get_generator_commit", - "category": "get_generator_category", - "rating": "get_generator_rating", - "max_capacity": "get_max_capacity", - "min_stable_level": "get_generator_min_stable_level", - "forced_outage_rate": "get_generator_forced_outage_rate", - "maintenance_rate": "get_generator_maintenance_rate", - "mean_time_to_repair": "get_generator_mean_time_to_repair", - "ext": "get_component_ext" - }, - "source_type": "SynchronousCondenser", - "target_type": "PLEXOSGenerator", - "version": 1 - }, { "defaults": { "category": "head" }, "field_map": { - "name": "name", - "category": "category" + "name": "name" }, "getters": { "name": "get_head_storage_name", @@ -664,8 +642,7 @@ "category": "tail" }, "field_map": { - "name": "name", - "category": "category" + "name": "name" }, "getters": { "name": "get_tail_storage_name", @@ -757,6 +734,18 @@ "target_type": "PLEXOSMembership", "version": 1 }, + { + "name": "zone_node_membership", + "system": "target", + "getters": { + "parent_object": "membership_parent_component", + "child_object": "membership_node_child_zone", + "collection": "membership_collection_zone" + }, + "source_type": "PLEXOSNode", + "target_type": "PLEXOSMembership", + "version": 1 + }, { "name": "reserve_generator_membership", "system": "target", @@ -805,40 +794,6 @@ "target_type": "PLEXOSMembership", "version": 2 }, - { - "name": "head_storage_membership", - "system": "target", - "getters": { - "parent_object": "membership_parent_component", - "child_object": "membership_head_storage_generator", - "collection": "membership_collection_head_storage" - }, - "source_type": "PLEXOSGenerator", - "target_type": "PLEXOSMembership", - "version": 2, - "filter": { - "field": "category", - "op": "in", - "values": ["pumped-hydro", "hydtrb"] - } - }, - { - "name": "tail_storage_membership", - "system": "target", - "getters": { - "parent_object": "membership_parent_component", - "child_object": "membership_tail_storage_generator", - "collection": "membership_collection_tail_storage" - }, - "source_type": "PLEXOSGenerator", - "target_type": "PLEXOSMembership", - "version": 3, - "filter": { - "field": "category", - "op": "in", - "values": ["pumped-hydro", "hydtrb"] - } - }, { "name": "transformer_from_node_membership", "system": "target", diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters.py b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters.py index 023b0143..272c8611 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters.py +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters.py @@ -4,11 +4,11 @@ import json import math -import re - -# Add this near the top, after imports from collections import defaultdict +from collections.abc import Mapping from copy import deepcopy +from datetime import timedelta +from functools import lru_cache from importlib.resources import files from typing import Any, cast @@ -22,6 +22,7 @@ PLEXOSNode, PLEXOSStorage, PLEXOSTransformer, + PLEXOSZone, ) from r2x_sienna.models import ( ACBus, @@ -29,10 +30,12 @@ DiscreteControlledACBranch, EnergyReservoirStorage, HydroDispatch, + HydroPumpedStorage, HydroPumpTurbine, HydroReservoir, HydroTurbine, Line, + LoadZone, MonitoredLine, PhaseShiftingTransformer, PhaseShiftingTransformer3W, @@ -55,7 +58,6 @@ from r2x_sienna.models.getters import ( get_max_active_power as sienna_get_max_active_power, ) -from r2x_sienna.models.named_tuples import FromTo_ToFrom from r2x_sienna.units import get_magnitude from r2x_core import Err, Ok, PluginContext, Result @@ -75,6 +77,8 @@ SOURCE_LINE_TYPES, ) +RAMPING_THRESHOLD = 0.1 # MW/min + def _source_system(context: PluginContext) -> Any: return cast(Any, context.source_system) @@ -84,16 +88,73 @@ def _target_system(context: PluginContext) -> Any: return cast(Any, context.target_system) +def _get_defaults_data(context: PluginContext) -> dict[str, Any]: + """Load defaults.json once per plugin context.""" + cached = context._cache.get("defaults_json") + if cached is not None: + return cast(dict[str, Any], cached) + + data = cast(dict[str, Any], _load_defaults_json()) + context._cache["defaults_json"] = data + return data + + +@lru_cache(maxsize=1) +def _load_defaults_json() -> dict[str, Any]: + """Load defaults.json once per process for hot getter paths.""" + defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" + with defaults_path.open() as f: + return cast(dict[str, Any], json.load(f)) + + +def _get_reeds_thermal_category_from_fuel(source_component: Any, context: PluginContext) -> str | None: + """Resolve thermal ReEDS category from Sienna fuel value using defaults mapping.""" + if not isinstance(source_component, ThermalStandard | ThermalMultiStart): + return None + + fuel = getattr(source_component, "fuel", None) + if fuel is None: + return None + + fuel_str = fuel.name if hasattr(fuel, "name") else str(fuel) + fuel_key = str(fuel_str).strip().replace("-", "_").replace(" ", "_").upper() + if not fuel_key: + return None + + defaults_data = _get_defaults_data(context) + mapping = defaults_data.get("reeds_thermal_mapping", {}) + if not isinstance(mapping, dict): + return None + + for category, fuel_values in mapping.items(): + if not isinstance(fuel_values, list): + continue + normalized_values = { + str(value).strip().replace("-", "_").replace(" ", "_").upper() for value in fuel_values + } + if fuel_key in normalized_values: + category_str = str(category).strip() + if category_str in {"natural-gas", "natural_gas", "gas"}: + return "gas-cc" + return category_str + + return None + + def _resolve_generator_category(source_component: Any, context: PluginContext) -> str | None: - """Resolve category via ext gen_type_string, ReEDS name patterns, or prime_mover mapping.""" - # Get name from ext dict + """Resolve category via prime mover, ReEDS name patterns, thermal fuel mapping, or ext gen_type_string.""" ext = getattr(source_component, "ext", None) - if isinstance(ext, dict): - gen_type = ext.get("gen_type_string", "").lower().strip() - if gen_type and gen_type not in ("unknown", "other", "", "unidentified"): - return GEN_TYPE_STRING_MAP.get(gen_type, gen_type) + prime_mover = getattr(source_component, "prime_mover_type", None) + + # Prime mover type lookup (prime_mover_type is always a plain string, e.g. 'CC', 'PVe') + if prime_mover is not None: + defaults_data = _get_defaults_data(context) + pm_types: dict[str, str] = defaults_data.get("prime_mover_types", {}) + tech = pm_types.get(prime_mover) + if tech: + return tech - # ReEDS name pattern + # ReEDS name patterns raw_name = getattr(source_component, "name", "") or "" name = raw_name.lower() if name.startswith("reeds"): @@ -103,103 +164,27 @@ def _resolve_generator_category(source_component: Any, context: PluginContext) - if name.startswith("zonal2nodal_"): suffix = name[len("zonal2nodal_") :] - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - _z2n_defaults = json.load(f) + _z2n_defaults = _get_defaults_data(context) reeds_cats = sorted(_z2n_defaults.get("reeds_defaults", {}).keys(), key=len, reverse=True) for cat in reeds_cats: cat_str = str(cat) if suffix == cat_str or suffix.startswith(cat_str + "_"): return cat_str - # Treat explicit "nuclear" naming as high-confidence and avoid falling back to - # broad prime-mover mappings that can misclassify these units as thermal/coal. - candidate_names = [_normalize_plant_name(raw_name)] - if isinstance(ext, dict): - plant_name = ext.get("plant_name") - if plant_name: - candidate_names.append(_normalize_plant_name(str(plant_name))) - candidate_names = [c for c in dict.fromkeys(candidate_names) if c] - - if any(_contains_nuclear_token(candidate) for candidate in candidate_names): - return "nuclear" - - # Get category from prime mover mapping when available (higher confidence than name heuristics). - prime_mover = getattr(source_component, "prime_mover_type", None) - fuel = getattr(source_component, "fuel", None) - - if prime_mover is None and isinstance(ext, dict): - prime_mover = ext.get("prime_mover") + # Thermal fuel mapping category + thermal_category = _get_reeds_thermal_category_from_fuel(source_component, context) + if thermal_category is not None: + return thermal_category - pm_fuel_map: dict[str, list[str]] = ( - getattr(getattr(context, "config", None), "prime_mover_mapping", None) or {} - ) - - if prime_mover is not None: - pm_str = prime_mover.name if hasattr(prime_mover, "name") else str(prime_mover).upper() - - if pm_fuel_map: - if fuel is not None: - fuel_str = fuel.name if hasattr(fuel, "name") else str(fuel).upper() - techs = pm_fuel_map.get(f"{pm_str}_{fuel_str}") - if techs: - return techs[0] - pm_only = pm_fuel_map.get(f"{pm_str}_") - if pm_only: - return pm_only[0] - - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - defaults_data = json.load(f) - pm_types: dict[str, str] = defaults_data.get("prime_mover_types", {}) - tech = pm_types.get(pm_str) - if tech: - return tech - - # Name-based association for oil/nuclear is exact-match and state-aware when possible. - source_state = _normalize_state((ext or {}).get("state")) if isinstance(ext, dict) else None - - nuclear_names = _build_nuclear_plant_name_set(context) - nuclear_name_state = _build_nuclear_plant_name_state_set(context) - oil_names = _build_oil_plant_name_set(context) - oil_name_state = _build_oil_plant_name_state_set(context) - - for candidate in candidate_names: - if source_state and (candidate, source_state) in nuclear_name_state: - return "nuclear" - if source_state and (candidate, source_state) in oil_name_state: - return "oil" - - if candidate in nuclear_names: - return "nuclear" - if candidate in oil_names: - return "oil" + # Gen type string from ext dictionary + if isinstance(ext, dict): + gen_type = ext.get("gen_type_string", "").lower().strip() + if gen_type and gen_type not in ("unknown", "other", "", "unidentified"): + return GEN_TYPE_STRING_MAP.get(gen_type, gen_type) return None -def _normalize_plant_name(name: str) -> str: - """Normalize plant names for reliable exact matching.""" - raw = str(name) - # Split CamelCase words before punctuation cleanup (e.g., NuclearFacility -> Nuclear Facility). - raw = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", raw) - cleaned = re.sub(r"[^a-z0-9]+", " ", raw.lower()) - return " ".join(cleaned.split()) - - -def _contains_nuclear_token(name: str) -> bool: - """Return True when normalized name contains a standalone 'nuclear' token.""" - return bool(re.search(r"\bnuclear\b", name)) - - -def _normalize_state(value: Any) -> str | None: - """Normalize state to two-letter uppercase when available.""" - if value is None: - return None - state = str(value).strip().upper() - return state if state else None - - def _build_target_storage_name_index(context: PluginContext) -> dict[str, Any]: """Build PLEXOSStorage names index, cached.""" cached = context._cache.get("target_storage_name_index") @@ -289,105 +274,6 @@ def _build_battery_service_index(context: PluginContext) -> dict[str, list[Any]] return result -def _build_oil_plant_name_set(context: PluginContext) -> set[str]: - """Build normalized petroleum plant names set from us_power_plants.json, cached.""" - cached = context._cache.get("oil_plant_name_set") - if cached is not None: - return cached - plants_path = files("r2x_sienna_to_plexos.config") / "us_power_plants.json" - with plants_path.open() as f: - plants_data = json.load(f) - name_set = { - _normalize_plant_name(p["power Plant Name"]) - for p in plants_data - if isinstance(p.get("Primary Energy Source"), str) - and p["Primary Energy Source"].lower() == "petroleum" - and isinstance(p.get("power Plant Name"), str) - } - context._cache["oil_plant_name_set"] = name_set - return name_set - - -def _build_oil_plant_name_state_set(context: PluginContext) -> set[tuple[str, str]]: - """Build normalized petroleum (plant_name, state) set from us_power_plants.json, cached.""" - cached = context._cache.get("oil_plant_name_state_set") - if cached is not None: - return cached - plants_path = files("r2x_sienna_to_plexos.config") / "us_power_plants.json" - with plants_path.open() as f: - plants_data = json.load(f) - - result: set[tuple[str, str]] = set() - for plant in plants_data: - if not isinstance(plant.get("Primary Energy Source"), str): - continue - if plant["Primary Energy Source"].lower() != "petroleum": - continue - if not isinstance(plant.get("power Plant Name"), str): - continue - state = _normalize_state(plant.get("State")) - if state is None: - continue - result.add((_normalize_plant_name(plant["power Plant Name"]), state)) - - context._cache["oil_plant_name_state_set"] = result - return result - - -def _build_nuclear_plant_name_set(context: PluginContext) -> set[str]: - """Build normalized nuclear plant names set from defaults.json and us_power_plants.json, cached.""" - cached = context._cache.get("nuclear_plant_name_set") - if cached is not None: - return cached - - # From defaults.json nuclear_plants list - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - defaults_data = json.load(f) - name_set = {_normalize_plant_name(p["name"]) for p in defaults_data.get("nuclear_plants", [])} - - # From us_power_plants.json filtered by Primary Energy Source == "nuclear" - plants_path = files("r2x_sienna_to_plexos.config") / "us_power_plants.json" - with plants_path.open() as f: - plants_data = json.load(f) - name_set |= { - _normalize_plant_name(p["power Plant Name"]) - for p in plants_data - if isinstance(p.get("Primary Energy Source"), str) - and p["Primary Energy Source"].lower() == "nuclear" - and isinstance(p.get("power Plant Name"), str) - } - - context._cache["nuclear_plant_name_set"] = name_set - return name_set - - -def _build_nuclear_plant_name_state_set(context: PluginContext) -> set[tuple[str, str]]: - """Build normalized nuclear (plant_name, state) set from us_power_plants.json, cached.""" - cached = context._cache.get("nuclear_plant_name_state_set") - if cached is not None: - return cached - plants_path = files("r2x_sienna_to_plexos.config") / "us_power_plants.json" - with plants_path.open() as f: - plants_data = json.load(f) - - result: set[tuple[str, str]] = set() - for plant in plants_data: - if not isinstance(plant.get("Primary Energy Source"), str): - continue - if plant["Primary Energy Source"].lower() != "nuclear": - continue - if not isinstance(plant.get("power Plant Name"), str): - continue - state = _normalize_state(plant.get("State")) - if state is None: - continue - result.add((_normalize_plant_name(plant["power Plant Name"]), state)) - - context._cache["nuclear_plant_name_state_set"] = result - return result - - def _build_area_buses_index(context: PluginContext) -> dict[str, list[Any]]: """Map area_name -> list of ACBus components in that area.""" cached = context._cache.get("area_buses_index") @@ -613,13 +499,62 @@ def _get_time_limit(component: Any, attr: str, ext_key: str) -> float | None: def _ramp_value_to_float(source_component: object, raw_value: Any) -> float: - """Convert ramp value to float, applying base power like sienna_get_ramp_limits does.""" + """Convert ramp value to MW/min. + + In practice, source ramp limits can appear either as: + - per-unit/min values (typically <= 1.0), or + - already absolute MW/min values. + + Use a simple heuristic: scale moderate magnitudes (<= 10.0) by base power, + otherwise treat the value as already in MW/min. + """ magnitude = get_magnitude(raw_value) if magnitude is None and isinstance(raw_value, int | float): magnitude = raw_value if magnitude is None: return 0.0 - return float(magnitude) * resolve_base_power(source_component) + + value = float(magnitude) + if abs(value) <= 10.0: + return value * resolve_base_power(source_component) + return value + + +def _get_ramp_limit_value(source_component: object, *, default: Any, direction: str) -> float: + """Extract raw ramp limit value for a given direction. + + Keeps the current getter behavior by relying on dict-style access when + ramp_limits is present. + """ + ramp_limits = getattr(source_component, "ramp_limits", default) + raw_value = ramp_limits[direction] if ramp_limits else 0.0 + return float(raw_value) + + +def _resolve_ramp_rates( + source_component: object, + context: PluginContext, + *, + initial_ramp_mw: float, + defaults_key: str, +) -> float: + """Apply defaults/fallback/capping logic and return final non-negative ramp.""" + ramp_mw = initial_ramp_mw + category = _resolve_generator_category(source_component, context) + gen_ramp_pct = _get_defaults(category, defaults_key) + max_pu = _get_minmax_value(getattr(source_component, "active_power_limits", None), "max") or 0.0 + max_mw = abs(max_pu) * resolve_base_power(source_component) + if max_mw == 0.0: + max_mw = _get_defaults(category, "capacity_MW") + if ramp_mw < RAMPING_THRESHOLD: + ramp_mw = gen_ramp_pct * max_mw + if ramp_mw < RAMPING_THRESHOLD: + max_mw = _get_defaults(category, "capacity_MW") + ramp_mw = gen_ramp_pct * max_mw + if ramp_mw > max_mw: + ramp_mw = max_mw * 0.5 + + return max(0.0, round(ramp_mw, 4)) def _convert_time_value(value: Any) -> float | None: @@ -643,27 +578,10 @@ def _get_minmax_value(obj: Any, key: str) -> float | None: return float(val) if isinstance(val, int | float) else None -def _get_ramp_default(source_component: object, context: PluginContext) -> float: - """Return the ramp default from defaults.json max_ramp_up_percentage * max active power (MW/min).""" - category = _resolve_generator_category(source_component, context) or "gas-cc" - pct = _get_defaults(category, "max_ramp_up_percentage") - if math.isclose(pct, 0.0, rel_tol=0.0, abs_tol=1e-6): - return 0.0 - try: - max_mw = float(sienna_get_max_active_power(source_component) or 0.0) - except (TypeError, NotImplementedError, AttributeError, KeyError): - max_mw = 0.0 - if math.isclose(max_mw, 0.0, rel_tol=0.0, abs_tol=1e-6): - max_mw = _get_defaults(category, "capacity_MW") or 100.0 - return pct * max_mw - - -def _get_defaults(category: str, key: str) -> float: +def _get_defaults(category: str | None, key: str) -> float: """Extract a default value from defaults.json for the given category and key.""" - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - defaults = json.load(f) - value = defaults.get("reeds_defaults", {}).get(category, {}).get(key, 0.0) + defaults = _load_defaults_json() + value = defaults.get("reeds_defaults", {}).get(category, {}).get(key, 0.0) if category else 0.0 try: return float(value) except (TypeError, ValueError): @@ -703,6 +621,59 @@ def _attach_generator_time_series( target_generator, name=ts.name, time_series_type=SingleTimeSeries, **metadata.features ): data = np.asarray(ts.data) + output_resolution = ts.resolution + + if ts.name == "hydro_budget": + # ts.data holds raw per-unit values; scale to actual MW (same logic as + # max_active_power TS) so that weekly sums are in MWh, not dimensionless + # units. Without this, the weekly budget is ~max_active_power-factor too + # large (e.g. 955 MWh instead of 76 MWh for an 0.08 MW generator). + _max_mw = 0.0 + _limits = getattr(source_gen, "active_power_limits", None) + if _limits is not None: + _max_val = ( + _limits.get("max") if isinstance(_limits, dict) else getattr(_limits, "max", None) + ) + if _max_val is not None: + _mag = get_magnitude(_max_val) + _raw = ( + float(_mag) + if _mag is not None + else float(_max_val) + if isinstance(_max_val, int | float) + else None + ) + if _raw is not None: + _max_mw = abs(_raw) * resolve_base_power(source_gen) + if _max_mw > 0.0: + data = data * _max_mw + + if ( + ts.name == "hydro_budget" + and isinstance(ts.resolution, timedelta) + and ts.resolution < timedelta(days=7) + ): + seconds_per_step = ts.resolution.total_seconds() + if seconds_per_step > 0: + points_per_week = max(int(round((7 * 86400) / seconds_per_step)), 1) + full_weeks = data.size // points_per_week + weekly_values: list[float] = [] + if full_weeks: + weekly_values.extend( + data[: full_weeks * points_per_week] + .reshape(full_weeks, points_per_week) + .sum(axis=1) + .tolist() + ) + remainder = data[full_weeks * points_per_week :] + if remainder.size: + weekly_values.append(float(remainder.sum())) + + # SingleTimeSeries requires at least two points. + if len(weekly_values) >= 2: + data = np.asarray(weekly_values, dtype=float) + output_resolution = timedelta(days=7) + if ts.name == "max_active_power": max_mw = 0.0 limits = getattr(source_gen, "active_power_limits", None) @@ -739,10 +710,38 @@ def _attach_generator_time_series( data=data, name=ts.name, initial_timestamp=ts.initial_timestamp, - resolution=ts.resolution, + resolution=output_resolution, ) _target_system(context).add_time_series(fresh_ts, target_generator, **metadata.features) - logger.success("Attached time series {} to generator {}", ts.name, generator_name) + logger.debug("Attached time series {} to generator {}", ts.name, generator_name) + + +def _has_usable_generator_time_series(source_component: object, context: PluginContext) -> bool: + """Return True when the source generator has at least one retrievable time series.""" + source_system = _source_system(context) + + try: + if not source_system.time_series.has_time_series(source_component): + return False + metadata_items = source_system.time_series.list_time_series_metadata(source_component) + except Exception: + # If introspection fails, avoid accidentally deactivating the unit. + return True + + for metadata in metadata_items: + features = getattr(metadata, "features", {}) or {} + try: + ts_list = source_system.list_time_series( + source_component, + name=metadata.name, + **features, + ) + except Exception: + continue + if ts_list: + return True + + return False def _attach_region_node_load_time_series( @@ -866,18 +865,88 @@ def _find_3w_source_transformer(context: PluginContext, arm_name: str) -> tuple[ return None -def _get_load_mw(load: Any) -> float: - """Extract MW value from a StandardLoad or PowerLoad for LPF computation.""" - magnitude = get_magnitude(getattr(load, "max_active_power", None)) +def _coerce_scalar(value: Any) -> float | None: + """Convert numeric-like values to float without forcing unit-stripped conversion.""" + if isinstance(value, int | float): + return float(value) + magnitude = getattr(value, "magnitude", None) + if isinstance(magnitude, int | float): + return float(magnitude) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _power_quantity_to_mw(value: Any) -> float | None: + """Convert unit-bearing power quantity to MW when possible.""" + if value is None or not hasattr(value, "to"): + return None + + conversion_targets: tuple[tuple[str, float], ...] = ( + ("megawatt", 1.0), + ("MW", 1.0), + ("megavolt_ampere", 1.0), + ("MVA", 1.0), + ("watt", 1e-6), + ("volt_ampere", 1e-6), + ("VA", 1e-6), + ) + for unit_name, scale in conversion_targets: + try: + converted = value.to(unit_name) + except Exception: + continue + + converted_magnitude = _coerce_scalar(getattr(converted, "magnitude", converted)) + if converted_magnitude is not None: + return float(converted_magnitude) * scale + + return None + + +def _get_load_base_power(load: Any) -> float: + """Resolve load base power as scalar MW/MVA-like value with robust defaults.""" base_power = getattr(load, "base_power", None) if base_power is None: - base_power = 100.0 - if magnitude is not None: - return float(magnitude) * float(base_power) + return 100.0 + + base_power_from_quantity = _power_quantity_to_mw(base_power) + if base_power_from_quantity is not None: + return base_power_from_quantity + + coerced = _coerce_scalar(base_power) + return coerced if coerced is not None else 100.0 + + +def _get_load_mw(load: Any) -> float: + """Extract MW value from a StandardLoad or PowerLoad for LPF computation.""" + raw_max_active_power = getattr(load, "max_active_power", None) + base_power = _get_load_base_power(load) + + direct_power_mw = _power_quantity_to_mw(raw_max_active_power) + if direct_power_mw is not None: + return direct_power_mw + + magnitude = get_magnitude(raw_max_active_power) + + magnitude_power_mw = _power_quantity_to_mw(magnitude) + if magnitude_power_mw is not None: + return magnitude_power_mw + + magnitude_value = _coerce_scalar(magnitude) + if magnitude_value is not None: + return float(magnitude_value) * float(base_power) + for attr in ("max_constant_active_power", "constant_active_power"): val = getattr(load, attr, None) - if isinstance(val, int | float) and val > 0: - return float(val) * float(base_power) + direct_attr_power_mw = _power_quantity_to_mw(val) + if direct_attr_power_mw is not None: + return direct_attr_power_mw + + val_scalar = _coerce_scalar(val) + if val_scalar is not None and val_scalar > 0: + return float(val_scalar) * float(base_power) return 0.0 @@ -921,9 +990,7 @@ def _get_system_base_power(context: PluginContext) -> float: def _get_general_default(key: str) -> float: """Extract a general default value from defaults.json for the given key.""" - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - defaults = json.load(f) + defaults = _load_defaults_json() value = defaults.get("general_defaults", {}).get(key, 0.0) try: return float(value) @@ -982,13 +1049,6 @@ def get_voltage_kv(source_component: ACBus, context: PluginContext) -> Result[fl return Ok(round(float(value), 1) if value is not None else 0.0) -@getter -def get_ac_voltage_magnitude_pu(source_component: ACBus, context: PluginContext) -> Result[float, ValueError]: - """Extract AC voltage magnitude in per unit from the source component.""" - value = getattr(source_component, "magnitude", None) - return Ok(round(float(value), 3) if value is not None else 1.0) - - @getter def get_node_category(source_component: ACBus, context: PluginContext) -> Result[str, ValueError]: """Return the Area name for the bus, since Area maps to PLEXOSRegion.""" @@ -1109,6 +1169,12 @@ def get_area_name(source_component: Area, context: PluginContext) -> Result[str, return Ok(getattr(source_component, "name", "")) +@getter +def get_zone_units(source_component: LoadZone, context: PluginContext) -> Result[float, ValueError]: + """Return active status for translated zones.""" + return Ok(1.0) + + @getter def get_line_min_flow( source_component: Line @@ -1229,34 +1295,6 @@ def lines_wheeling_charge_back( return Ok(float(wc_back)) -@getter -def get_line_charging_susceptance( - source_component: Line | MonitoredLine, context: PluginContext -) -> Result[float, ValueError]: - """Extract line charging susceptance as float from source component.""" - match getattr(source_component, "b", None): - case None: - return Ok(0.0) - case int() | float() as val: - return Ok(float(val)) - case complex() as val: - return Ok(float(val.imag)) - case FromTo_ToFrom() as val: - return Ok(float(val.from_to)) - case dict() as val: - match val.get("from_to"): - case int() | float() as ft: - return Ok(float(ft)) - case _: - return Ok(0.0) - case val: - match get_magnitude(val): - case int() | float() as mag: - return Ok(float(mag)) - case _: - return Ok(0.0) - - @getter def get_vsc_line_resistance( source_component: TwoTerminalVSCLine, context: PluginContext @@ -1429,14 +1467,15 @@ def get_3w_transformer_tertiary_rating( @getter def get_generator_category(source_component: object, context: PluginContext) -> Result[str, ValueError]: - """Determine generator category using ReEDS tech names, gen_type_string, or prime_mover/fuel mapping. + """Determine generator category using ReEDS tech names, gen_type_string, and fuel/prime-mover mapping. Priority: 1. ext["gen_type_string"] mapped through _GEN_TYPE_STRING_MAP 2. ReEDS component name patterns (hydend, hyded, distpv, wind-ofs, etc.) - 3. prime_mover + fuel via context.config.prime_mover_mapping - 4. prime_mover abbreviation via defaults.json prime_mover_types - 5. Err → rule default applies + 3. ThermalStandard/ThermalMultiStart fuel via defaults.json reeds_thermal_mapping + 4. prime_mover + fuel via context.config.prime_mover_mapping (non-thermal fallback) + 5. prime_mover abbreviation via defaults.json prime_mover_types (non-thermal fallback) + 6. Err -> rule default applies """ category = _resolve_generator_category(source_component, context) if category is not None: @@ -1444,15 +1483,54 @@ def get_generator_category(source_component: object, context: PluginContext) -> return Err(ValueError("Cannot resolve generator category; rule default will apply")) +@getter +def get_pumped_hydro_category( + source_component: HydroTurbine | HydroPumpTurbine, context: PluginContext +) -> Result[str, ValueError]: + """Resolve category for hydro turbines, demoting zero-pump-load units to ``hydro``. + + Sienna ``HydroTurbine``/``HydroPumpTurbine`` components default to a pumped + category, but units whose pump-load (derived from ``rating``) resolves to + zero are not actually pumped storage and should land in the regular + ``hydro`` category. When the pump load is non-zero we defer to the standard + category resolution chain so explicit overrides (e.g. ``gen_type_string``) + still apply, and otherwise let the rule default apply via ``Err``. + """ + rating = getattr(source_component, "rating", None) + pump_load_mw = 0.0 + if rating is not None: + magnitude = get_magnitude(rating) + if magnitude is not None: + pump_load_mw = abs(float(magnitude) * resolve_base_power(source_component)) + + if math.isclose(pump_load_mw, 0.0, abs_tol=1e-9): + return Ok("hydro") + + category = _resolve_generator_category(source_component, context) + if category is not None: + return Ok(category) + return Err(ValueError("Cannot resolve generator category; rule default will apply")) + + @getter def get_fuel_price( source_component: ThermalStandard | ThermalMultiStart, context: PluginContext ) -> Result[float, ValueError]: """Extract fuel price in $/GJ from fuel_cost attribute of FuelCurve, if available.""" cost = getattr(source_component, "operation_cost", None) - variable = getattr(cost, "variable", None) if cost else None - if isinstance(variable, FuelCurve): - price = get_magnitude(getattr(variable, "fuel_cost", None)) + variable = None + if cost is not None: + if isinstance(cost, Mapping): + variable = cost.get("variable") + if variable is None: + variable = getattr(cost, "variable", None) + + if isinstance(variable, Mapping): + price = variable.get("fuel_cost") + if price is not None: + return Ok(round(float(price), 2)) + elif isinstance(variable, FuelCurve): + price = getattr(variable, "fuel_cost", None) if price is not None: return Ok(round(float(price), 2)) return Ok(0.0) @@ -1462,10 +1540,15 @@ def get_fuel_price( def get_thermal_generator_units( source_component: ThermalStandard | ThermalMultiStart, context: PluginContext ) -> Result[int, ValueError]: - """Deactivate thermal generators with missing marginal-cost inputs. + """Return thermal generator online status. + + Thermal units in Sienna inputs can express cost with different combinations + of fuel price and heat-rate terms. Evaluate the full signal (heat rate, + heat rate base/increment terms, fuel price, and start cost) before deciding + whether a unit has usable economic metadata. - If fuel price or heat rate resolves to zero, set units to 0 so the device is - not treated as nearly free generation in PLEXOS. + Generators default to online unless an explicit source ``units`` flag + disables them, or a known data-fix exception applies. """ ext = getattr(source_component, "ext", None) if isinstance(ext, dict): @@ -1475,75 +1558,128 @@ def get_thermal_generator_units( if plant_name == "monticello" and state == "TX": return Ok(0) - fuel_price = 0.0 - heat_rate = 0.0 + source_units = getattr(source_component, "units", None) + if source_units is not None: + try: + return Ok(1 if int(source_units) > 0 else 0) + except (TypeError, ValueError): + pass + + # Consider all heat-rate components, not just heat_rate. fuel_price_getter = cast(Any, get_fuel_price) - heat_rate_getter = cast(Any, get_heat_rate) + start_cost_getter = cast(Any, get_generator_start_cost) + + def _non_zero(value: Any) -> bool: + try: + return not math.isclose(float(value), 0.0, rel_tol=0.0, abs_tol=1e-9) + except (TypeError, ValueError): + return False + fuel_price = 0.0 + start_cost = 0.0 match fuel_price_getter(source_component, context): case Ok(value): fuel_price = float(value) case Err(_): fuel_price = 0.0 - - match heat_rate_getter(source_component, context): + match start_cost_getter(source_component, context): case Ok(value): - heat_rate = float(value) + start_cost = float(value) case Err(_): - heat_rate = 0.0 + start_cost = 0.0 - if math.isclose(fuel_price, 0.0, rel_tol=0.0, abs_tol=1e-9) or math.isclose( - heat_rate, 0.0, rel_tol=0.0, abs_tol=1e-9 - ): - return Ok(0) + heat_data = compute_heat_rate_data(source_component) + has_heat_signal = any( + _non_zero(heat_data.get(key)) + for key in ("heat_rate", "heat_rate_base", "heat_rate_incr", "heat_rate_incr2", "heat_rate_incr3") + ) + + # If any economic signal exists, keep thermal online. + if _non_zero(fuel_price) or _non_zero(start_cost) or has_heat_signal: + return Ok(1) return Ok(1) @getter -def get_max_capacity(source_component: object, context: PluginContext) -> Result[float, ValueError]: - """Extract maximum capacity in MW from rating, active_power_limits, or max_active_power. +def get_dispatch_generator_units( + source_component: RenewableDispatch | RenewableNonDispatch, + context: PluginContext, +) -> Result[int, ValueError]: + """Deactivate renewable dispatch generators that do not have source time series.""" + return Ok(1 if _has_usable_generator_time_series(source_component, context) else 0) + + +@getter +def get_hydro_generator_units( + source_component: HydroDispatch, + context: PluginContext, +) -> Result[int, ValueError]: + """Keep dispatch hydro generators online by default. - If extracted capacity is below 10 MW, replace it with the category-level - ``max_capacity_MW`` default. + Applies to ``HydroDispatch`` and ``HydroEnergyReservoir`` source types. + Source ``units`` flags in Sienna data encode build counts, not operational + enablement, so these should not be deactivated from that field. """ + return Ok(1) - def _apply_small_capacity_default(capacity_mw: float) -> float: - """Replace tiny capacities with category default max capacity.""" - if capacity_mw >= 10.0: - return round(capacity_mw, 2) - category = _resolve_generator_category(source_component, context) or "gas-cc" - default_max = _get_defaults(category, "max_capacity_MW") +@getter +def get_pumped_hydro_generator_units( + source_component: HydroTurbine | HydroPumpTurbine, + context: PluginContext, +) -> Result[int, ValueError]: + """Online status for pump turbine generators. + + Units with zero pump load are treated as regular hydro (always online). + Units with non-zero pump load are only online when a HydroReservoir with a + pumped-storage association references this turbine — meaning a PLEXOSStorage + will actually be created and connected to it. + """ + rating = getattr(source_component, "rating", None) + pump_load_mw = 0.0 + if rating is not None: + magnitude = get_magnitude(rating) + if magnitude is not None: + pump_load_mw = abs(float(magnitude) * resolve_base_power(source_component)) + + if math.isclose(pump_load_mw, 0.0, abs_tol=1e-9): + return Ok(1) + + # Non-zero pump load: only deactivate components that actually resolve to a pumped + # category. A HydroTurbine can have rating > 0 yet still resolve to "hydro" via + # gen_type_string or ReEDS name patterns — those must stay online. + category = _resolve_generator_category(source_component, context) + if category is not None and "pump" not in category.lower(): + return Ok(1) - if math.isclose(default_max, 0.0, rel_tol=0.0, abs_tol=1e-9): - # Backstop for categories that may not define max_capacity_MW. - default_max = _get_defaults(category, "capacity_MW") + # Category is pumped-hydro (or could not be resolved → rule default pumped-hydro): + # only online when a storage-creating HydroReservoir backs this turbine. + turbine_names = _build_reservoir_pump_turbine_name_set(context) + comp_name = getattr(source_component, "name", None) + if comp_name is not None and str(comp_name) in turbine_names: + return Ok(1) + return Ok(0) - if default_max > 0.0: - return round(default_max, 2) - # Final safeguard: if the resolved category has no usable defaults - # (common for some hydro/renewable mappings), fall back to a stable - # generic thermal max-capacity baseline so tiny p.u.-like values don't - # leak into PLEXOS max_capacity/min_stable_level. - generic_default = _get_defaults("gas-cc", "max_capacity_MW") or _get_defaults("gas-cc", "capacity_MW") - if generic_default > 0.0: - return round(generic_default, 2) +@getter +def get_max_capacity(source_component: object, context: PluginContext) -> Result[float, ValueError]: + """Extract maximum capacity in MW from rating, active_power_limits, or max_active_power. - return round(capacity_mw, 2) + When rating is available, max_capacity must match rating exactly. + """ rating = getattr(source_component, "rating", None) rating_value = get_magnitude(rating) if rating_value is not None: capacity = abs(float(rating_value) * resolve_base_power(source_component)) - return Ok(_apply_small_capacity_default(capacity)) + return Ok(round(capacity, 2)) limits = getattr(source_component, "active_power_limits", None) if isinstance(limits, dict): max_value = limits.get("max") if isinstance(max_value, int | float): - return Ok(_apply_small_capacity_default(abs(float(max_value)))) + return Ok(round(abs(float(max_value)), 2)) try: value = sienna_get_max_active_power(source_component) @@ -1551,7 +1687,7 @@ def _apply_small_capacity_default(capacity_mw: float) -> float: value = None if value is not None: - return Ok(_apply_small_capacity_default(abs(float(value)))) + return Ok(round(abs(float(value)), 2)) return Err(ValueError("active_power_limits or rating missing")) @@ -1560,10 +1696,8 @@ def _apply_small_capacity_default(capacity_mw: float) -> float: def get_generator_commit(component: object, context: PluginContext) -> Result[int, ValueError]: """Return 1 if technology is in commit_technologies list or start cost is 0, -1 otherwise.""" technology = getattr(component, "technology", "") - defaults_path = files("r2x_sienna_to_plexos.config") / "defaults.json" - with defaults_path.open() as f: - defaults = json.load(f) - commit_technologies = defaults.get("commit_technologies", []) + defaults_data = _get_defaults_data(context) + commit_technologies = defaults_data.get("commit_technologies", []) if technology in commit_technologies: return Ok(1) cost = getattr(component, "operation_cost", None) @@ -1574,9 +1708,59 @@ def get_generator_commit(component: object, context: PluginContext) -> Result[in @getter -def get_heat_rate(source_component: object, context: PluginContext) -> Result[float, ValueError]: - """Extract heat_rate from computed heat rate data, round to 2 decimals, and return as float (units='GJ/MWh')""" - value = compute_heat_rate_data(source_component).get("heat_rate") +def get_generator_load_point(source_component: object, context: PluginContext) -> Result[Any, ValueError]: + """Extract generator load point from ext dict or computed heat-rate data. + + For piecewise heat-rate curves, ``compute_heat_rate_data`` provides a + multiband ``load_point`` property which should be passed through directly. + For scalar heat-rate data, fall back to ``heat_rate * fuel_price``. + """ + ext = getattr(source_component, "ext", None) + if isinstance(ext, dict): + load_point = ext.get("NARIS_Load_Point") + if isinstance(load_point, int | float): + return Ok(float(load_point)) + + heat_rate_data = compute_heat_rate_data(source_component) + computed_load_point = heat_rate_data.get("load_point") + if computed_load_point is not None: + return Ok(coerce_value(computed_load_point)) + + heat_rate = heat_rate_data.get("heat_rate") + fuel_price_getter = cast(Any, get_fuel_price) + fuel_price_result = fuel_price_getter(source_component, context) + match fuel_price_result: + case Ok(fuel_price): + if heat_rate is not None and fuel_price > 0.0: + return Ok(float(heat_rate) * float(fuel_price)) + case Err(_): + pass + + return Ok(0.0) + + +@getter +def get_heat_rate(source_component: object, context: PluginContext) -> Result[float | None, ValueError]: + """Extract heat_rate from computed heat rate data. + + When both heat_rate_base and heat_rate_incr are defined, suppress the + scalar heat_rate property so only the decomposed terms are exported. + Returning ``None`` allows rule application to skip just this field. + """ + heat_rate_data = compute_heat_rate_data(source_component) + base_value = heat_rate_data.get("heat_rate_base") + has_base = False + if base_value is not None: + if isinstance(base_value, int | float): + has_base = not math.isclose(float(base_value), 0.0, rel_tol=0.0, abs_tol=1e-9) + else: + has_base = True + + has_incr = heat_rate_data.get("heat_rate_incr") is not None + if has_base and has_incr: + return Ok(None) + + value = heat_rate_data.get("heat_rate") return Ok(abs(float(value)) if value is not None else 0.0) @@ -1626,59 +1810,31 @@ def get_min_down_time(source_component: object, context: PluginContext) -> Resul @getter def get_max_ramp_up(source_component: object, context: PluginContext) -> Result[float, ValueError]: """Extract maximum ramp up from ramp_limits, convert to MW/min; falls back to category default.""" - try: - max_mw = float(sienna_get_max_active_power(source_component) or 0.0) - except (TypeError, NotImplementedError, AttributeError, KeyError): - max_mw = 0.0 - - ramp = getattr(source_component, "ramp_limits", None) - if isinstance(ramp, dict): - value = abs(_ramp_value_to_float(source_component, ramp.get("up"))) - elif ramp is not None: - value = abs(_ramp_value_to_float(source_component, getattr(ramp, "up", None))) - else: - value = 0.0 - - # If value exceeds max capacity it is unreasonable (ramp in <1 min); fall back to default - if not math.isclose(max_mw, 0.0, rel_tol=0.0, abs_tol=1e-6) and value > max_mw: - value = 0.0 - - if math.isclose(value, 0.0, rel_tol=0.0, abs_tol=1e-6): - value = abs(_get_ramp_default(source_component, context)) - - if math.isclose(value, 0.0, rel_tol=0.0, abs_tol=1e-6): - value = max_mw - - return Ok(max(value, 10)) + ramp_up = _get_ramp_limit_value(source_component, default=0.0, direction="up") + ramp_up_mw = abs(ramp_up * resolve_base_power(source_component)) + return Ok( + _resolve_ramp_rates( + source_component, + context, + initial_ramp_mw=ramp_up_mw, + defaults_key="max_ramp_up_percentage", + ) + ) @getter def get_max_ramp_down(source_component: object, context: PluginContext) -> Result[float, ValueError]: """Extract maximum ramp down from ramp_limits, convert to MW/min; falls back to category default.""" - try: - max_mw = float(sienna_get_max_active_power(source_component) or 0.0) - except (TypeError, NotImplementedError, AttributeError, KeyError): - max_mw = 0.0 - - ramp = getattr(source_component, "ramp_limits", None) - if isinstance(ramp, dict): - value = abs(_ramp_value_to_float(source_component, ramp.get("down"))) - elif ramp is not None: - value = abs(_ramp_value_to_float(source_component, getattr(ramp, "down", None))) - else: - value = 0.0 - - # If value exceeds max capacity it is unreasonable (ramp in <1 min); fall back to default - if not math.isclose(max_mw, 0.0, rel_tol=0.0, abs_tol=1e-6) and value > max_mw: - value = 0.0 - - if math.isclose(value, 0.0, rel_tol=0.0, abs_tol=1e-6): - value = abs(_get_ramp_default(source_component, context)) - - if math.isclose(value, 0.0, rel_tol=0.0, abs_tol=1e-6): - value = max_mw - - return Ok(max(value, 10)) + ramp_down = _get_ramp_limit_value(source_component, default=None, direction="down") + ramp_down_mw = abs(ramp_down * resolve_base_power(source_component)) + return Ok( + _resolve_ramp_rates( + source_component, + context, + initial_ramp_mw=ramp_down_mw, + defaults_key="max_ramp_up_percentage", + ) + ) @getter @@ -1711,7 +1867,7 @@ def get_generator_min_stable_level( max_capacity_mw = None if math.isclose(min_mw, 0.0, abs_tol=1e-6): - category = _resolve_generator_category(source_component, context) or "gas-cc" + category = _resolve_generator_category(source_component, context) min_mw = _get_defaults(category, "min_stable_level_percentage") * 100.0 if max_capacity_mw is not None and max_capacity_mw > 0.0 and min_mw > max_capacity_mw: @@ -1758,7 +1914,12 @@ def get_generator_mean_time_to_repair( @getter def get_generator_start_cost(source_component: object, context: PluginContext) -> Result[float, ValueError]: cost = getattr(source_component, "operation_cost", None) - value = get_magnitude(getattr(cost, "start_up", None)) if cost else None + value = None + if cost is not None: + if isinstance(cost, Mapping): + value = cost.get("start_up") + if value is None: + value = getattr(cost, "start_up", None) return Ok(float(value) if value is not None else 0.0) @@ -1768,7 +1929,12 @@ def get_generator_shutdown_cost( ) -> Result[float, ValueError]: """Extract shutdown cost in $ from operation_cost.start_up attribute of the source component.""" cost = getattr(source_component, "operation_cost", None) - value = get_magnitude(getattr(cost, "shut_down", None)) if cost else None + value = None + if cost is not None: + if isinstance(cost, Mapping): + value = get_magnitude(cost.get("shut_down")) + if value is None: + value = get_magnitude(getattr(cost, "shut_down", None)) return Ok(float(value) if value is not None else 0.0) @@ -1880,19 +2046,176 @@ def get_turbine_pump_load( return Ok(0.0) -@getter -def get_head_storage_name( +def _reservoir_has_hydro_pumped_storage_association( source_component: HydroReservoir, context: PluginContext -) -> Result[str, ValueError]: - """Return the storage name for the head reservoir (appends _head), using plant_name from ext if available.""" +) -> bool: + """Return True if reservoir is linked to at least one HydroPumpTurbine.""" + + def _is_hydro_pump_turbine(turbine: Any) -> bool: + return isinstance(turbine, HydroPumpTurbine) or type(turbine).__name__ == "HydroPumpTurbine" + + linked_turbines = [ + *list(getattr(source_component, "upstream_turbines", None) or []), + *list(getattr(source_component, "downstream_turbines", None) or []), + ] + + if any(_is_hydro_pump_turbine(turbine) for turbine in linked_turbines): + return True + + ext = getattr(source_component, "ext", None) + plant_ids = ext.get("plants") if isinstance(ext, dict) else None + if not isinstance(plant_ids, list): + return False + + source_system = _source_system(context) + pump_turbines_by_name = { + str(getattr(turbine, "name", "")): turbine + for turbine in source_system.get_components(HydroPumpTurbine) + } + return any(isinstance(plant_id, str) and plant_id in pump_turbines_by_name for plant_id in plant_ids) + + +def _build_reservoir_pump_turbine_name_set(context: PluginContext) -> set[str]: + """Build the set of turbine names referenced by any storage-creating HydroReservoir, cached. + + Only reservoirs that pass ``_reservoir_has_hydro_pumped_storage_association`` + are considered, so the returned names correspond to turbines that will + actually receive a PLEXOSStorage membership. + """ + cached = context._cache.get("reservoir_pump_turbine_name_set") + if cached is not None: + return cast(set[str], cached) + + names: set[str] = set() + for reservoir in _source_system(context).get_components(HydroReservoir): + if not _reservoir_has_hydro_pumped_storage_association(reservoir, context): + continue + for turbine in [ + *list(getattr(reservoir, "upstream_turbines", None) or []), + *list(getattr(reservoir, "downstream_turbines", None) or []), + ]: + tname = getattr(turbine, "name", None) + if tname: + names.add(str(tname)) + ext = getattr(reservoir, "ext", None) + plant_ids = ext.get("plants") if isinstance(ext, dict) else None + if isinstance(plant_ids, list): + for plant_id in plant_ids: + if isinstance(plant_id, str): + names.add(plant_id) + + context._cache["reservoir_pump_turbine_name_set"] = names + return names + + +def _get_reservoir_location(source_component: HydroReservoir) -> str | None: + """Return normalized reservoir location label (HEAD/TAIL) when available. + + Falls back to ext metadata and name suffixes when explicit reservoir_location + is missing in source data. + """ + # Most reliable signal in EI data: explicit _head/_tail suffix in component name. + name = str(getattr(source_component, "name", "")).strip().upper() + if name.endswith(("_HEAD", " HEAD")): + return "HEAD" + if name.endswith(("_TAIL", " TAIL")): + return "TAIL" + + location = getattr(source_component, "reservoir_location", None) + raw = getattr(location, "value", location) + if raw is not None: + label = str(raw).upper() + if "HEAD" in label: + return "HEAD" + if "TAIL" in label: + return "TAIL" + + ext = getattr(source_component, "ext", None) + if isinstance(ext, dict): + ext_loc = ext.get("reservoir_location") or ext.get("RESERVOIR_LOCATION") + if ext_loc is not None: + label = str(getattr(ext_loc, "value", ext_loc)).upper() + if "HEAD" in label: + return "HEAD" + if "TAIL" in label: + return "TAIL" + + return None + + +def _get_reservoir_name_suffix_location(source_component: HydroReservoir) -> str | None: + """Return HEAD/TAIL when reservoir name explicitly ends with _head/_tail.""" + name = str(getattr(source_component, "name", "")).strip().casefold() + if name.endswith(("_head", " head")): + return "HEAD" + if name.endswith(("_tail", " tail")): + return "TAIL" + return None + + +def _get_reservoir_storage_base_name(source_component: HydroReservoir) -> str: + """Return canonical storage base name for a reservoir.""" ext = getattr(source_component, "ext", None) - base = None if isinstance(ext, dict): plant_name = ext.get("plant_name") if plant_name: - base = str(plant_name) - if base is None: - base = _reservoir_base_name(source_component.name) + return str(plant_name) + return _reservoir_base_name(source_component.name) + + +def _has_explicit_side_reservoir_for_base( + source_component: HydroReservoir, + context: PluginContext, + side: str, +) -> bool: + """Return True when another reservoir with same base explicitly maps the requested side.""" + this_base = _get_reservoir_storage_base_name(source_component).casefold() + this_uuid = getattr(source_component, "uuid", None) + + for other in _source_system(context).get_components(HydroReservoir): + other_uuid = getattr(other, "uuid", None) + if this_uuid is not None and other_uuid == this_uuid: + continue + if _get_reservoir_storage_base_name(other).casefold() != this_base: + continue + if _get_reservoir_name_suffix_location(other) == side: + return True + + return False + + +@getter +def get_head_storage_name( + source_component: HydroReservoir, context: PluginContext +) -> Result[str, ValueError]: + """Return the storage name for the head reservoir (appends _head), using plant_name from ext if available.""" + if not _reservoir_has_hydro_pumped_storage_association(source_component, context): + return Err( + ValueError( + f"Skipping head storage conversion for reservoir '{source_component.name}': no HydroPumpTurbine association" + ) + ) + + # Only explicit suffixes gate conversion. Unsuffixed reservoirs are expanded + # into both _head and _tail storages. + suffix_location = _get_reservoir_name_suffix_location(source_component) + if suffix_location == "TAIL": + return Err( + ValueError( + f"Skipping head storage conversion for reservoir '{source_component.name}': name indicates tail reservoir" + ) + ) + + if suffix_location is None and _has_explicit_side_reservoir_for_base( + source_component, context, side="HEAD" + ): + return Err( + ValueError( + f"Skipping head storage conversion for reservoir '{source_component.name}': explicit head reservoir already exists for this plant" + ) + ) + + base = _get_reservoir_storage_base_name(source_component) return Ok(f"{base}_head") @@ -1912,14 +2235,33 @@ def get_tail_storage_name( source_component: HydroReservoir, context: PluginContext ) -> Result[str, ValueError]: """Return the storage name for the tail reservoir (appends _tail), using plant_name from ext if available.""" - ext = getattr(source_component, "ext", None) - base = None - if isinstance(ext, dict): - plant_name = ext.get("plant_name") - if plant_name: - base = str(plant_name) - if base is None: - base = _reservoir_base_name(source_component.name) + if not _reservoir_has_hydro_pumped_storage_association(source_component, context): + return Err( + ValueError( + f"Skipping tail storage conversion for reservoir '{source_component.name}': no HydroPumpTurbine association" + ) + ) + + # Only explicit suffixes gate conversion. Unsuffixed reservoirs are expanded + # into both _head and _tail storages. + suffix_location = _get_reservoir_name_suffix_location(source_component) + if suffix_location == "HEAD": + return Err( + ValueError( + f"Skipping tail storage conversion for reservoir '{source_component.name}': name indicates head reservoir" + ) + ) + + if suffix_location is None and _has_explicit_side_reservoir_for_base( + source_component, context, side="TAIL" + ): + return Err( + ValueError( + f"Skipping tail storage conversion for reservoir '{source_component.name}': explicit tail reservoir already exists for this plant" + ) + ) + + base = _get_reservoir_storage_base_name(source_component) return Ok(f"{base}_tail") @@ -2245,6 +2587,14 @@ def membership_collection_region( return Ok(CollectionEnum.Region) +@getter +def membership_collection_zone( + component: object, context: PluginContext +) -> Result[CollectionEnum, ValueError]: + """Return the Zone collection enum.""" + return Ok(CollectionEnum.Zone) + + @getter def membership_collection_node_from( component: object, context: PluginContext @@ -2418,6 +2768,42 @@ def membership_region_child_node(region: object, context: PluginContext) -> Resu return Err(ValueError(f"Unexpected result type for region '{region_name}'")) +@getter +def membership_node_child_zone(node: PLEXOSNode, context: PluginContext) -> Result[PLEXOSZone, ValueError]: + """Resolve a node's source bus load_zone to the translated PLEXOSZone.""" + source_bus = _build_bus_name_index(context).get(getattr(node, "name", "")) + if source_bus is None: + return Err(ValueError(f"No source bus found for node '{getattr(node, 'name', '')}'")) + + load_zone = getattr(source_bus, "load_zone", None) + if load_zone is None: + area = getattr(source_bus, "area", None) + load_zone = getattr(area, "load_zone", None) if area is not None else None + if load_zone is None: + return Err(ValueError(f"No load_zone found for source bus '{source_bus.name}'")) + + zone_name = getattr(load_zone, "name", None) + zone_uuid = getattr(load_zone, "uuid", None) + + target_zones = list(_target_system(context).get_components(PLEXOSZone)) + if zone_name is not None: + for zone in target_zones: + if getattr(zone, "name", None) == str(zone_name): + return Ok(zone) + + if zone_uuid is not None: + zone_uuid_str = str(zone_uuid) + for zone in target_zones: + if str(getattr(zone, "uuid", "")) == zone_uuid_str: + return Ok(zone) + + return Err( + ValueError( + f"No translated PLEXOSZone found for bus '{source_bus.name}' load_zone '{zone_name or zone_uuid}'" + ) + ) + + @getter def membership_line_from_parent_node( line: PLEXOSLine, context: PluginContext @@ -2530,6 +2916,12 @@ def _build_reservoir_by_turbine_index(context: PluginContext) -> dict[str, Any]: return index +def _is_hydro_pumped_storage_generator(context: PluginContext, gen_name: str) -> bool: + """Return True when target generator name resolves to a source HydroPumpedStorage.""" + source_generator = _lookup_source_generator(context, gen_name) + return isinstance(source_generator, HydroPumpedStorage) + + @getter def membership_head_storage_generator( generator: HydroTurbine, context: PluginContext diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters_utils.py b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters_utils.py index 74f52c0e..cda20b55 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters_utils.py +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/getters_utils.py @@ -2,6 +2,8 @@ from __future__ import annotations +import math +from collections.abc import Mapping from copy import deepcopy from typing import TYPE_CHECKING, Any, cast @@ -27,6 +29,7 @@ ACBus, Area, EnergyReservoirStorage, + HydroPumpTurbine, HydroReservoir, HydroTurbine, LoadZone, @@ -36,6 +39,7 @@ TransmissionInterface, VariableReserve, ) +from r2x_sienna.models.enums import ACBusTypes from r2x_sienna.units import get_magnitude if TYPE_CHECKING: @@ -46,6 +50,81 @@ _SIENNA_TRANSFORMER_TYPES = (Transformer2W, TapTransformer, PhaseShiftingTransformer) +# --------------------------------------------------------------------------- +# SQLite IN-clause chunking fix +# --------------------------------------------------------------------------- +# SQLite limits bound variables to 999 per statement. For large EI systems +# (226k+ component associations) the vanilla r2x_core implementation issues a +# single query with all target UUIDs as parameters, which blows the limit. +# We replace that function at import time with an equivalent that batches the +# IN clause into chunks of 900. + + +def _chunked_setup_target_and_child_tables( + tgt_metadata: Any, + src_associations: Any, + uuid_map: dict, +) -> tuple[list[tuple], dict[str, str]]: + """Chunked drop-in for r2x_core's _setup_target_and_child_tables.""" + from uuid import UUID as _UUID + + uuid_to_type = {str(uuid): type(comp).__name__ for uuid, comp in uuid_map.items()} + + tgt_metadata.execute("DROP TABLE IF EXISTS target_components") + tgt_metadata.execute("CREATE TEMP TABLE target_components (uuid TEXT PRIMARY KEY, type TEXT)") + tgt_metadata.executemany("INSERT INTO target_components VALUES (?, ?)", list(uuid_to_type.items())) + + target_uuids = list(uuid_to_type.keys()) + + if not target_uuids: + tgt_metadata.execute("DROP TABLE IF EXISTS child_mapping") + tgt_metadata.execute( + "CREATE TEMP TABLE child_mapping (child_uuid TEXT, parent_uuid TEXT, parent_type TEXT)" + ) + return [], uuid_to_type + + _chunk_size = 900 + child_parent_rows: list[tuple] = [] + for i in range(0, len(target_uuids), _chunk_size): + chunk = target_uuids[i : i + _chunk_size] + placeholders = ",".join("?" for _ in chunk) + child_parent_rows.extend( + src_associations.execute( + f""" + SELECT component_uuid, attached_component_uuid + FROM component_associations + WHERE attached_component_uuid IN ({placeholders}) + """, + chunk, + ).fetchall() + ) + + child_remapping = [ + (child_uuid, parent_uuid, type(uuid_map[_UUID(parent_uuid)]).__name__) + for child_uuid, parent_uuid in child_parent_rows + if parent_uuid in uuid_to_type + ] + + tgt_metadata.execute("DROP TABLE IF EXISTS child_mapping") + tgt_metadata.execute( + "CREATE TEMP TABLE child_mapping (child_uuid TEXT, parent_uuid TEXT, parent_type TEXT)" + ) + if child_remapping: + tgt_metadata.executemany("INSERT INTO child_mapping VALUES (?, ?, ?)", child_remapping) + + return child_remapping, uuid_to_type + + +try: + import r2x_core.time_series as _r2x_core_ts + + cast(Any, _r2x_core_ts)._setup_target_and_child_tables = _chunked_setup_target_and_child_tables + logger.debug("Applied chunked _setup_target_and_child_tables patch to r2x_core.time_series") +except (ImportError, AttributeError) as _patch_err: + logger.warning("Could not patch r2x_core.time_series._setup_target_and_child_tables: {}", _patch_err) +# --------------------------------------------------------------------------- + + def _source_system(context: PluginContext) -> Any: return cast(Any, context.source_system) @@ -73,18 +152,13 @@ def _ensure_membership( collection : CollectionEnum The collection type for the membership """ - # Avoid creating duplicate memberships for the same parent/child/collection. - existing = _target_system(context).get_supplemental_attributes_with_component( - child_object, - PLEXOSMembership, - ) - for membership in existing: - if ( - membership.parent_object == parent_object - and membership.child_object == child_object - and membership.collection == collection - ): - return + parent_key = getattr(parent_object, "uuid", None) or id(parent_object) + child_key = getattr(child_object, "uuid", None) or id(child_object) + membership_key = (collection, parent_key, child_key) + + membership_cache = context._cache.setdefault("membership_key_cache", set()) + if membership_key in membership_cache: + return membership = PLEXOSMembership( parent_object=parent_object, @@ -95,6 +169,7 @@ def _ensure_membership( # regardless of traversal direction. _target_system(context).add_supplemental_attribute(parent_object, membership) _target_system(context).add_supplemental_attribute(child_object, membership) + membership_cache.add(membership_key) def _bus_name_to_area_and_zone(context: PluginContext) -> dict[str, tuple[str | None, str | None]]: @@ -116,15 +191,34 @@ def _bus_name_to_area_and_zone(context: PluginContext) -> dict[str, tuple[str | area_name = str(area) load_zone = getattr(bus, "load_zone", None) - zone_name: str | None = ( - load_zone.name if isinstance(load_zone, LoadZone) else (str(load_zone) if load_zone else None) - ) + zone_name: str | None = None + if load_zone: + if isinstance(load_zone, LoadZone): + zone_name = load_zone.name + elif hasattr(load_zone, "name") and load_zone.name: + zone_name = str(load_zone.name) + elif isinstance(load_zone, str): + zone_name = load_zone + else: + zone_name = str(load_zone) result[bus.name] = (area_name, zone_name) context._cache["bus_name_to_area_and_zone"] = result return result +def _bus_to_area_name(bus: Any) -> str | None: + """Resolve canonical area name for a source bus.""" + area = getattr(bus, "area", None) + if isinstance(area, Area): + ext = getattr(area, "ext", None) + arname = (ext or {}).get("ARNAME") if isinstance(ext, dict) else None + return str(arname) if arname else area.name + if area: + return str(area) + return None + + def _attach_reservoir_time_series_to_storage( context: PluginContext, storage_name: str, @@ -163,23 +257,28 @@ def _attach_reservoir_time_series_to_storage( max_mw = abs(float(naris_pmax)) else: turbine_base = base_name[: -len("_Reservoir")] if base_name.endswith("_Reservoir") else base_name - for t in _source_system(context).get_components(HydroTurbine): - t_base = t.name[: -len("_Turbine")] if t.name.endswith("_Turbine") else t.name - if t_base == turbine_base: - limits = getattr(t, "active_power_limits", None) - if limits is not None: - max_val = limits.get("max") if isinstance(limits, dict) else getattr(limits, "max", None) - if max_val is not None: - mag = get_magnitude(max_val) - raw = ( - float(mag) - if mag is not None - else float(max_val) - if isinstance(max_val, int | float) - else None + for turbine_type in (HydroPumpTurbine, HydroTurbine): + for t in _source_system(context).get_components(turbine_type): + t_base = t.name[: -len("_Turbine")] if t.name.endswith("_Turbine") else t.name + if t_base == turbine_base: + limits = getattr(t, "active_power_limits", None) + if limits is not None: + max_val = ( + limits.get("max") if isinstance(limits, dict) else getattr(limits, "max", None) ) - if raw is not None: - max_mw = abs(raw) * resolve_base_power(t) + if max_val is not None: + mag = get_magnitude(max_val) + raw = ( + float(mag) + if mag is not None + else float(max_val) + if isinstance(max_val, int | float) + else None + ) + if raw is not None: + max_mw = abs(raw) * resolve_base_power(t) + break + if max_mw > 0.0: break for typed_ts in _source_system(context).list_time_series(source_reservoir): @@ -207,7 +306,7 @@ def _attach_reservoir_time_series_to_storage( resolution=typed_ts.resolution, ) _target_system(context).add_time_series(fresh_ts, target_storage, **ts_features) - logger.success("Attached time series {} to storage {}", ts_name, storage_name) + logger.debug("Attached time series {} to storage {}", ts_name, storage_name) def ensure_region_node_memberships(context: PluginContext) -> None: @@ -215,22 +314,139 @@ def ensure_region_node_memberships(context: PluginContext) -> None: Area maps to PLEXOSRegion, so regions are looked up by area_name. """ - bus_index = _bus_name_to_area_and_zone(context) regions_by_name = {r.name: r for r in _target_system(context).get_components(PLEXOSRegion)} + source_buses = list(_source_system(context).get_components(ACBus)) + source_buses_by_uuid = {str(getattr(bus, "uuid", "")): bus for bus in source_buses} + source_buses_by_name = {bus.name: bus for bus in source_buses} + + region_nodes_by_name: dict[str, list[PLEXOSNode]] = {name: [] for name in regions_by_name} total_memberships = 0 for node in _target_system(context).get_components(PLEXOSNode): - area_name, _ = bus_index.get(node.name, (None, None)) + source_bus = source_buses_by_uuid.get(str(getattr(node, "uuid", ""))) + if source_bus is None: + source_bus = source_buses_by_name.get(node.name) + + area_name = _bus_to_area_name(source_bus) if source_bus is not None else None if area_name is None: continue region = regions_by_name.get(area_name) if region is not None: _ensure_membership(context, node, region, CollectionEnum.Region) + region_nodes = region_nodes_by_name.setdefault(area_name, []) + if node not in region_nodes: + region_nodes.append(node) total_memberships += 1 + context._cache["region_nodes_by_name"] = region_nodes_by_name + logger.info("Total {} Region-Node memberships created.", total_memberships) +def ensure_reference_node_memberships(context: PluginContext) -> None: + """Create exactly one Region->Node ReferenceNode membership per translated region. + + Selection priority per region: + 1) Any node whose source bus is REF/SLACK + 2) Fallback to highest node voltage, then highest load participation factor + """ + + def _as_float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + regions_by_name = {r.name: r for r in _target_system(context).get_components(PLEXOSRegion)} + nodes_by_region = cast(dict[str, list[PLEXOSNode]], context._cache.get("region_nodes_by_name", {})) + if not nodes_by_region: + ensure_region_node_memberships(context) + nodes_by_region = cast(dict[str, list[PLEXOSNode]], context._cache.get("region_nodes_by_name", {})) + + # If some regions still have no node associations, recover from existing + # supplemental Region memberships attached to the region endpoint. + for region_name, region in regions_by_name.items(): + if nodes_by_region.get(region_name): + continue + + recovered_nodes: list[PLEXOSNode] = [] + for membership in _target_system(context).get_supplemental_attributes_with_component( + region, + PLEXOSMembership, + ): + if membership.collection != CollectionEnum.Region: + continue + + if membership.parent_object == region and isinstance(membership.child_object, PLEXOSNode): + recovered_nodes.append(membership.child_object) + elif membership.child_object == region and isinstance(membership.parent_object, PLEXOSNode): + recovered_nodes.append(membership.parent_object) + + if recovered_nodes: + nodes_by_region[region_name] = recovered_nodes + + all_nodes = list(_target_system(context).get_components(PLEXOSNode)) + + ref_node_names: set[str] = set() + ref_node_uuids: set[str] = set() + for bus in _source_system(context).get_components(ACBus): + bustype = getattr(bus, "bustype", None) + bustype_name = getattr(bustype, "name", str(bustype)).upper() if bustype is not None else "" + if bustype not in {ACBusTypes.REF, ACBusTypes.SLACK} and bustype_name not in {"REF", "SLACK"}: + continue + + ref_node_names.add(bus.name) + ref_node_uuids.add(str(getattr(bus, "uuid", ""))) + + total_memberships = 0 + for region_name, region in regions_by_name.items(): + region_nodes = nodes_by_region.get(region_name, []) + if not region_nodes and not all_nodes: + continue + + slack_nodes = [ + node + for node in region_nodes + if ( + node.name in ref_node_names + or str(getattr(node, "uuid", "")) in ref_node_uuids + or bool(getattr(node, "is_slack_bus", 0)) + ) + ] + candidate_nodes = slack_nodes if slack_nodes else region_nodes + used_global_fallback = False + if not candidate_nodes: + candidate_nodes = all_nodes + used_global_fallback = True + + chosen = max( + candidate_nodes, + key=lambda node: ( + _as_float(getattr(node, "voltage", 0.0)), + _as_float(getattr(node, "load_participation_factor", 0.0)), + node.name, + ), + ) + + _ensure_membership(context, region, chosen, CollectionEnum.ReferenceNode) + total_memberships += 1 + + if used_global_fallback: + logger.warning( + "No nodes were associated with region '{}'; using global fallback reference node '{}'.", + region_name, + chosen.name, + ) + elif not slack_nodes: + logger.debug( + "No REF/SLACK bus found for region '{}'; using fallback reference node '{}'.", + region_name, + chosen.name, + ) + + logger.info("Total {} ReferenceNode Region->Node memberships created.", total_memberships) + + def _extract_base_name(name: str) -> str: for suffix in ("_Turbine", "_Reservoir_head", "_Reservoir_tail", "_Reservoir", "_head", "_tail"): if name.endswith(suffix): @@ -282,7 +498,7 @@ def ensure_head_storage_generator_membership(context: PluginContext) -> None: turbines = list(getattr(reservoir, "downstream_turbines", None) or []) if not turbines and isinstance(ext, dict): - all_turbines = {t.name: t for t in _source_system(context).get_components(HydroTurbine)} + all_turbines = {t.name: t for t in _source_system(context).get_components(HydroPumpTurbine)} turbines = [ all_turbines[pid] for pid in (ext.get("plants") or []) @@ -296,13 +512,13 @@ def ensure_head_storage_generator_membership(context: PluginContext) -> None: target_gen_name = display_name_index.get(tname, tname) target_gen = generators_by_name.get(target_gen_name) if target_gen is None: - logger.debug("No PLEXOSGenerator found for HydroTurbine '{}', skipping.", tname) + logger.debug("No PLEXOSGenerator found for HydroPumpTurbine '{}', skipping.", tname) continue _ensure_membership(context, target_gen, target_storage, CollectionEnum.HeadStorage) total_memberships += 1 - # Also support source models that expose reservoir links on HydroTurbine.reservoirs. - for turbine in _source_system(context).get_components(HydroTurbine): + # Also support source models that expose reservoir links on HydroPumpTurbine.reservoirs. + for turbine in _source_system(context).get_components(HydroPumpTurbine): tname = getattr(turbine, "name", None) if not tname: continue @@ -387,7 +603,7 @@ def ensure_tail_storage_generator_membership(context: PluginContext) -> None: turbines = list(getattr(reservoir, "downstream_turbines", None) or []) if not turbines and isinstance(ext, dict): - all_turbines = {t.name: t for t in _source_system(context).get_components(HydroTurbine)} + all_turbines = {t.name: t for t in _source_system(context).get_components(HydroPumpTurbine)} turbines = [ all_turbines[pid] for pid in (ext.get("plants") or []) @@ -401,13 +617,13 @@ def ensure_tail_storage_generator_membership(context: PluginContext) -> None: target_gen_name = display_name_index.get(tname, tname) target_gen = generators_by_name.get(target_gen_name) if target_gen is None: - logger.debug("No PLEXOSGenerator found for HydroTurbine '{}', skipping.", tname) + logger.debug("No PLEXOSGenerator found for HydroPumpTurbine '{}', skipping.", tname) continue _ensure_membership(context, target_gen, target_storage, CollectionEnum.TailStorage) total_memberships += 1 - # Also support source models that expose reservoir links on HydroTurbine.reservoirs. - for turbine in _source_system(context).get_components(HydroTurbine): + # Also support source models that expose reservoir links on HydroPumpTurbine.reservoirs. + for turbine in _source_system(context).get_components(HydroPumpTurbine): tname = getattr(turbine, "name", None) if not tname: continue @@ -504,12 +720,76 @@ def ensure_generator_time_series(context: PluginContext) -> None: if target_gen is None: continue _attach_generator_time_series(context, source_gen.name, target_gen) + _attach_hydro_reservoir_inflow_to_generator_budget(context, source_gen, target_gen) total += 1 logger.info("Ensured time series for {} generators.", total) +def _attach_hydro_reservoir_inflow_to_generator_budget( + context: PluginContext, + source_generator: Any, + target_generator: Any, +) -> None: + """Attach HydroReservoir inflow to generator as max_energy_day for non-pumped HydroTurbine units.""" + if isinstance(source_generator, HydroPumpTurbine) or not isinstance(source_generator, HydroTurbine): + return + + pump_load = getattr(source_generator, "rating", None) + if pump_load is not None: + magnitude = get_magnitude(pump_load) + raw = ( + float(magnitude) + if magnitude is not None + else float(pump_load) + if isinstance(pump_load, int | float) + else 0.0 + ) + if not math.isclose(raw * resolve_base_power(source_generator), 0.0, abs_tol=1e-9): + return + + from infrasys import SingleTimeSeries + + from r2x_sienna_to_plexos.getters import _build_reservoir_by_turbine_index + + source_reservoir = _build_reservoir_by_turbine_index(context).get(source_generator.name) + if source_reservoir is None or not _source_system(context).time_series.has_time_series(source_reservoir): + return + + for metadata in _source_system(context).time_series.list_time_series_metadata(source_reservoir): + if metadata.name not in {"inflow", "natural_inflow"}: + continue + features = getattr(metadata, "features", {}) or {} + if _target_system(context).has_time_series( + target_generator, + name="max_energy_day", + time_series_type=SingleTimeSeries, + **features, + ): + continue + + ts_list = _source_system(context).list_time_series( + source_reservoir, + name=metadata.name, + **features, + ) + if not ts_list: + continue + + typed_source_ts = ts_list[0] + ts_copy = deepcopy(typed_source_ts) + ts_copy.name = "max_energy_day" + _target_system(context).add_time_series(ts_copy, target_generator, **features) + + def ensure_reserve_time_series(context: PluginContext) -> None: """Attach reserve time series from source VariableReserve to translated PLEXOSReserve.""" + + def _normalize_series_name(name: Any) -> str: + return str(name or "").strip().lower().replace("-", "_").replace(" ", "_") + + def _freeze_features(features: dict[str, Any]) -> tuple[tuple[str, str], ...]: + return tuple(sorted((str(key), repr(value)) for key, value in features.items())) + source_reserves = {r.name: r for r in _source_system(context).get_components(VariableReserve)} base = getattr(getattr(context, "source_system", None), "base_power", None) try: @@ -518,6 +798,7 @@ def ensure_reserve_time_series(context: PluginContext) -> None: system_base = 100.0 total = 0 + seen: set[tuple[str, str, type[Any], tuple[tuple[str, str], ...]]] = set() for reserve in _target_system(context).get_components(PLEXOSReserve): source_reserve = source_reserves.get(reserve.name) if source_reserve is None: @@ -537,18 +818,28 @@ def ensure_reserve_time_series(context: PluginContext) -> None: continue typed_source_ts = ts_list[0] - ts_name = "min_provision" if typed_source_ts.name == "requirement" else typed_source_ts.name + source_names = { + _normalize_series_name(getattr(metadata, "name", None)), + _normalize_series_name(getattr(typed_source_ts, "name", None)), + } + ts_name = ( + "min_provision" if {"requirement", "min_provision"} & source_names else typed_source_ts.name + ) ts_copy_any = deepcopy(typed_source_ts) ts_copy_any.name = ts_name # Reserve requirement is represented in p.u. in Sienna; PLEXOS min_provision expects MW. - if ts_name in {"min_provision", "requirement"}: + if ts_name == "min_provision": try: ts_copy_any.data = ts_copy_any.data * system_base except TypeError: ts_copy_any.data = [float(value) * system_base for value in ts_copy_any.data] + seen_key = (reserve.name, ts_name, type(typed_source_ts), _freeze_features(features)) + if seen_key in seen: + continue + if _target_system(context).has_time_series( reserve, name=ts_name, @@ -558,6 +849,7 @@ def ensure_reserve_time_series(context: PluginContext) -> None: continue _target_system(context).add_time_series(ts_copy_any, reserve, **features) + seen.add(seen_key) total += 1 logger.info("Ensured reserve time series for {} associations.", total) @@ -686,6 +978,79 @@ def ensure_interface_line_memberships(context: PluginContext) -> None: logger.info("Total {} Interface-Line memberships created.", total_memberships) +def ensure_pumped_hydro_storages_created(context: PluginContext) -> None: + """Synthesize head/tail PLEXOSStorage entries for pumped-hydro generators missing them. + + Pumped-hydro generators in PLEXOS need both a head and a tail storage so + the pump/generator pair can move energy between reservoirs and perform + arbitrage. When the source Sienna system has no reservoirs attached to a + pumped-hydro turbine (common for ReEDS-style aggregated systems), create + minimal ``PLEXOSStorage`` entries with ``units=1`` and ``max_volume`` / + ``initial_volume`` derived from the generator's ``max_capacity``, and + attach the corresponding ``HeadStorage`` / ``TailStorage`` memberships. + """ + target_system = _target_system(context) + storages_by_name = {s.name: s for s in target_system.get_components(PLEXOSStorage)} + + created_storages = 0 + created_memberships = 0 + for gen in target_system.get_components(PLEXOSGenerator): + if getattr(gen, "category", None) != "pumped-hydro": + continue + + memberships = target_system.get_supplemental_attributes_with_component(gen, PLEXOSMembership) + has_head = any( + m.collection == CollectionEnum.HeadStorage and m.parent_object == gen for m in memberships + ) + has_tail = any( + m.collection == CollectionEnum.TailStorage and m.parent_object == gen for m in memberships + ) + + if has_head and has_tail: + continue + + # ``max_capacity`` is in MW; PLEXOS storage volumes are in GWh. Size + # the synthesized reservoir for a typical pumped-hydro duration so the + # generator can run at full output for that many hours before the + # head storage empties (or the tail fills). Initial volume is half-full + # so the unit can both pump and generate immediately. + pumped_hydro_duration_hours = 10.0 + max_capacity_mw = float(getattr(gen, "max_capacity", 0.0) or 0.0) + if max_capacity_mw > 0.0: + max_volume = round(max_capacity_mw * pumped_hydro_duration_hours / 1000.0, 4) + else: + max_volume = 1.0 # GWh fallback for degenerate sources + initial_volume = round(max_volume * 0.5, 4) + + for suffix, collection, already_present in ( + ("_head", CollectionEnum.HeadStorage, has_head), + ("_tail", CollectionEnum.TailStorage, has_tail), + ): + if already_present: + continue + storage_name = f"{gen.name}{suffix}" + storage = storages_by_name.get(storage_name) + if storage is None: + storage = PLEXOSStorage( + name=storage_name, + category="head" if suffix == "_head" else "tail", + units=1, + max_volume=max_volume, + initial_volume=initial_volume, + ) + target_system.add_component(storage) + storages_by_name[storage_name] = storage + created_storages += 1 + _ensure_membership(context, gen, storage, collection) + created_memberships += 1 + + logger.info( + "Synthesized {} pumped-hydro storages and {} memberships for generators missing reservoirs.", + created_storages, + created_memberships, + ) + + def ensure_pumped_hydro_storage_memberships(context: PluginContext) -> None: """Create Generator->Storage memberships for pumped hydro generators.""" storages_by_name = {s.name: s for s in _target_system(context).get_components(PLEXOSStorage)} @@ -722,6 +1087,85 @@ def normalize_value_curve(curve: Any) -> InputOutputCurveValue | None: InputOutputCurve | None Normalized curve, or None if normalization fails """ + if isinstance(curve, Mapping): + function_data = curve.get("function_data") + if not isinstance(function_data, Mapping): + return None + + def _as_float(value: Any, default: float = 0.0) -> float: + magnitude = get_magnitude(value) + if magnitude is not None: + return float(magnitude) + try: + return float(value) + except (TypeError, ValueError): + return default + + fd: LinearFunctionData | QuadraticFunctionData | PiecewiseLinearData + if "points" in function_data: + points_raw = function_data.get("points") or [] + points: list[XYCoords] = [] + for point in points_raw: + if isinstance(point, Mapping): + x = _as_float(point.get("x")) + y = _as_float(point.get("y")) + elif isinstance(point, tuple | list) and len(point) >= 2: + x = _as_float(point[0]) + y = _as_float(point[1]) + else: + continue + points.append(XYCoords(x=x, y=y)) + if not points: + return None + fd = PiecewiseLinearData(points=points) + elif "x_coords" in function_data and "y_coords" in function_data: + x_raw = function_data.get("x_coords") or [] + y_raw = function_data.get("y_coords") or [] + if not isinstance(x_raw, list) or not isinstance(y_raw, list) or len(x_raw) < 2: + return None + + x_values = [_as_float(value) for value in x_raw] + y_values = [_as_float(value) for value in y_raw] + points: list[XYCoords] = [] + + # Case 1: y-coordinates are explicit point values. + if len(y_values) == len(x_values): + points = [XYCoords(x=x, y=y) for x, y in zip(x_values, y_values, strict=False)] + + # Case 2: y-coordinates are segment slopes with one value per interval. + elif len(y_values) == len(x_values) - 1: + cumulative_y = 0.0 + points.append(XYCoords(x=x_values[0], y=0.0)) + for idx, slope in enumerate(y_values, start=1): + dx = x_values[idx] - x_values[idx - 1] + if dx <= 0: + continue + cumulative_y += slope * dx + points.append(XYCoords(x=x_values[idx], y=cumulative_y)) + + if len(points) < 2: + return None + fd = PiecewiseLinearData(points=points) + elif "quadratic_term" in function_data or "cubic_term" in function_data: + kwargs: dict[str, Any] = { + "proportional_term": _as_float(function_data.get("proportional_term")), + "constant_term": _as_float(function_data.get("constant_term")), + "quadratic_term": _as_float(function_data.get("quadratic_term")), + } + if "cubic_term" in function_data: + kwargs["cubic_term"] = _as_float(function_data.get("cubic_term")) + fd = QuadraticFunctionData(**kwargs) + else: + fd = LinearFunctionData( + proportional_term=_as_float(function_data.get("proportional_term")), + constant_term=_as_float(function_data.get("constant_term")), + ) + + return InputOutputCurve( + function_data=fd, + input_at_zero=curve.get("input_at_zero"), + ) + if isinstance(curve, InputOutputCurve): return curve if isinstance(curve, IncrementalCurve | AverageRateCurve): @@ -814,10 +1258,22 @@ def compute_heat_rate_data(component: Any) -> dict[str, Any]: - load_point: Load points for multiband curves """ cost = getattr(component, "operation_cost", None) - variable = getattr(cost, "variable", None) if cost else None - if not isinstance(variable, FuelCurve): + variable = None + if cost is not None: + if isinstance(cost, Mapping): + variable = cost.get("variable") + if variable is None: + variable = getattr(cost, "variable", None) + + curve_source = None + if isinstance(variable, Mapping): + curve_source = variable.get("value_curve") + elif isinstance(variable, FuelCurve): + curve_source = variable.value_curve + else: return {} - curve = normalize_value_curve(variable.value_curve) + + curve = normalize_value_curve(curve_source) if curve is None or curve.function_data is None: return {} data: dict[str, Any] = {} @@ -835,7 +1291,11 @@ def compute_heat_rate_data(component: Any) -> dict[str, Any]: if cubic is not None: data["heat_rate_incr3"] = float(cubic) elif isinstance(fd, PiecewiseLinearData): - initial_input = getattr(variable.value_curve, "initial_input", None) + initial_input = ( + curve_source.get("initial_input") + if isinstance(curve_source, Mapping) + else getattr(curve_source, "initial_input", None) + ) if initial_input is not None: data["heat_rate_base"] = round(float(initial_input) / 1000, 3) data["heat_rate"] = data["heat_rate_base"] diff --git a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/translation.py b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/translation.py index 2d326e9c..053b0cb1 100644 --- a/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/translation.py +++ b/packages/r2x-sienna-to-plexos/src/r2x_sienna_to_plexos/translation.py @@ -17,6 +17,8 @@ ensure_generator_time_series, ensure_head_storage_generator_membership, ensure_interface_line_memberships, + ensure_pumped_hydro_storages_created, + ensure_reference_node_memberships, ensure_region_node_memberships, ensure_reserve_battery_memberships, ensure_reserve_generator_memberships, @@ -59,6 +61,7 @@ def sienna_to_plexos(system: System, config: SiennaToPlexosConfig) -> System: ensure_generator_time_series(context) ensure_reserve_time_series(context) ensure_region_node_memberships(context) + ensure_reference_node_memberships(context) ensure_generator_node_memberships(context) ensure_battery_node_memberships(context) ensure_reserve_battery_memberships(context) @@ -67,5 +70,6 @@ def sienna_to_plexos(system: System, config: SiennaToPlexosConfig) -> System: ensure_interface_line_memberships(context) ensure_head_storage_generator_membership(context) ensure_tail_storage_generator_membership(context) + ensure_pumped_hydro_storages_created(context) return context.target_system diff --git a/packages/r2x-sienna-to-plexos/tests/fixtures/systems.py b/packages/r2x-sienna-to-plexos/tests/fixtures/systems.py index 6dc31c50..2221402a 100644 --- a/packages/r2x-sienna-to-plexos/tests/fixtures/systems.py +++ b/packages/r2x-sienna-to-plexos/tests/fixtures/systems.py @@ -1,29 +1,23 @@ """Mock system fixtures for transformation rule testing.""" +# r2x_sienna imports are deferred to fixture function bodies to avoid a +# circular-import error in r2x_core on Python 3.13 when the module is +# loaded at pytest collection time. +from __future__ import annotations + from typing import TYPE_CHECKING import pytest from infrasys.cost_curves import CostCurve, FuelCurve, UnitSystem from infrasys.function_data import PiecewiseLinearData, QuadraticFunctionData, XYCoords from infrasys.value_curves import InputOutputCurve, LinearCurve -from r2x_sienna.models import ( - ACBus, - Area, - LoadZone, - MinMax, - PowerLoad, - ThermalStandard, - UpDown, -) -from r2x_sienna.models.costs import ThermalGenerationCost -from r2x_sienna.models.enums import PrimeMoversType, ThermalFuels if TYPE_CHECKING: from r2x_core import System @pytest.fixture -def sienna_system_empty() -> "System": +def sienna_system_empty() -> System: """Mock Sienna system (read-only source). Represents the input system that contains Sienna components to be @@ -41,7 +35,7 @@ def sienna_system_empty() -> "System": @pytest.fixture -def plexos_system_empty() -> "System": +def plexos_system_empty() -> System: """Mock PLEXOS system (writable target). Represents the output system where converted PLEXOS components will @@ -59,7 +53,7 @@ def plexos_system_empty() -> "System": @pytest.fixture -def sienna_system_with_area_and_zone() -> "System": +def sienna_system_with_area_and_zone() -> System: """Sienna system with LoadZone and Area. Returns @@ -67,6 +61,8 @@ def sienna_system_with_area_and_zone() -> "System": System System with a LoadZone named "test-zone" and an Area named "test-area" """ + from r2x_sienna.models import Area, LoadZone + from r2x_core import System sys = System(name="sienna_system", auto_add_composed_components=True) @@ -80,7 +76,7 @@ def sienna_system_with_area_and_zone() -> "System": @pytest.fixture -def sienna_system_with_buses(sienna_system_with_area_and_zone) -> "System": +def sienna_system_with_buses(sienna_system_with_area_and_zone) -> System: """Sienna system with multiple ACBus components. Returns @@ -88,6 +84,8 @@ def sienna_system_with_buses(sienna_system_with_area_and_zone) -> "System": System System with 3 buses (bus-1, bus-2, bus-3) in the test-area """ + from r2x_sienna.models import ACBus, Area, LoadZone + zone = sienna_system_with_area_and_zone.get_component(LoadZone, "test-zone") area = sienna_system_with_area_and_zone.get_component(Area, "test-area") @@ -99,7 +97,7 @@ def sienna_system_with_buses(sienna_system_with_area_and_zone) -> "System": @pytest.fixture -def sienna_system_with_buses_and_power_load(sienna_system_with_buses) -> "System": +def sienna_system_with_buses_and_power_load(sienna_system_with_buses) -> System: """Sienna system with buses and a PowerLoad component. Returns @@ -107,6 +105,8 @@ def sienna_system_with_buses_and_power_load(sienna_system_with_buses) -> "System System System with buses and a PowerLoad on bus-1 """ + from r2x_sienna.models import ACBus, PowerLoad + bus = sienna_system_with_buses.get_component(ACBus, "bus-1") load = PowerLoad.example().model_copy(update={"name": "load-1", "bus": bus, "max_active_power": 100.0}) @@ -118,8 +118,11 @@ def sienna_system_with_buses_and_power_load(sienna_system_with_buses) -> "System @pytest.fixture def sienna_system_with_thermal_generators( sienna_system_with_buses_and_power_load, -) -> "System": +) -> System: """System with multiple ThermalStandard generators for conversion tests.""" + from r2x_sienna.models import ACBus, MinMax, ThermalStandard, UpDown + from r2x_sienna.models.costs import ThermalGenerationCost + from r2x_sienna.models.enums import PrimeMoversType, ThermalFuels system = sienna_system_with_buses_and_power_load bus = system.get_component(ACBus, "bus-1") diff --git a/packages/r2x-sienna-to-plexos/tests/test_getters.py b/packages/r2x-sienna-to-plexos/tests/test_getters.py index f88a4b35..82747caa 100644 --- a/packages/r2x-sienna-to-plexos/tests/test_getters.py +++ b/packages/r2x-sienna-to-plexos/tests/test_getters.py @@ -16,9 +16,11 @@ PLEXOSGenerator, PLEXOSLine, PLEXOSNode, + PLEXOSPropertyValue, PLEXOSRegion, PLEXOSStorage, PLEXOSTransformer, + PLEXOSZone, ) from r2x_sienna.models import ( ACBus, @@ -127,6 +129,51 @@ def test_getters_with_missing_data(tmp_path): assert result.is_err() +def test_resolve_generator_category_reeds_and_prime_mover_mapping(context): + reeds_component = types.SimpleNamespace(name="reeds_hyded_foo", ext=None) + assert getters._resolve_generator_category(reeds_component, context) == "hyded" + + context.config = types.SimpleNamespace(prime_mover_mapping={"CC_NATURAL_GAS": ["mapped-tech"]}) + mapped_component = types.SimpleNamespace( + name="custom_gen", + ext={}, + prime_mover_type="CC", + ) + assert getters._resolve_generator_category(mapped_component, context) is None + + +def test_reeds_thermal_category_returns_none_for_invalid_mapping(context, monkeypatch): + bus = ACBus(name="B1", base_voltage=115.0, number=1) + thermal = ThermalStandard( + name="THERM_NONE", + bus=bus, + active_power=0.0, + reactive_power=0.0, + rating=10.0, + base_power=10.0, + must_run=False, + status=True, + time_at_status=0.0, + active_power_limits=MinMax(min=0.0, max=10.0), + ramp_limits=UpDown(up=1.0, down=1.0), + time_limits=UpDown(up=1.0, down=1.0), + prime_mover_type=PrimeMoversType.CC, + fuel=ThermalFuels.NATURAL_GAS, + operation_cost=ThermalGenerationCost.example(), + ) + monkeypatch.setattr(getters, "_get_defaults_data", lambda _ctx: {"reeds_thermal_mapping": "bad"}) + assert getters._get_reeds_thermal_category_from_fuel(thermal, context) is None + + +def test_index_builders_return_empty_when_system_missing(tmp_path): + context = make_context(tmp_path) + context.target_system = None + context.source_system = None + + assert getters._build_target_storage_name_index(context) == {} + assert getters._build_source_reserve_name_index(context) == {} + + def test_get_susceptance_transformers(tmp_path): context = make_context(tmp_path) context.source_system = System(name="source") @@ -158,50 +205,6 @@ def test_get_susceptance_transformers(tmp_path): assert getters.get_transformer_susceptance(t3, context).is_err() -def test_get_line_charging_susceptance_types(tmp_path): - context = make_context(tmp_path) - context.source_system = System(name="source") - context.target_system = System(name="target") - - bus1 = ACBus(name="N2", base_voltage=115.0, number=1) - bus2 = ACBus(name="N3", base_voltage=115.0, number=2) - bus3 = ACBus(name="N4", base_voltage=115.0, number=3) - bus4 = ACBus(name="N5", base_voltage=115.0, number=4) - context.source_system.add_component(bus1) - context.source_system.add_component(bus2) - context.source_system.add_component(bus3) - context.source_system.add_component(bus4) - - arc1 = Arc(from_to=bus1, to_from=bus2) - arc2 = Arc(from_to=bus3, to_from=bus4) - context.source_system.add_component(arc1) - context.source_system.add_component(arc2) - - l1_2 = Line( - name="line-1-2", - arc=arc1, - b=FromTo_ToFrom(from_to=3.0, to_from=3.0), - rating=100.0, - active_power_flow=100, - reactive_power_flow=100, - angle_limits=MinMax(min=-0.03, max=0.03), - ) - context.source_system.add_component(l1_2) - assert getters.get_line_charging_susceptance(l1_2, context).unwrap() == 3.0 - - l3_4 = Line( - name="line-3-4", - arc=arc2, - b=FromTo_ToFrom(from_to=7.0, to_from=7.0), - rating=100.0, - active_power_flow=100, - reactive_power_flow=100, - angle_limits=MinMax(min=-0.03, max=0.03), - ) - context.source_system.add_component(l3_4) - assert getters.get_line_charging_susceptance(l3_4, context).unwrap() == 7.0 - - def test_get_load_participation_factor(tmp_path): context = make_context(tmp_path) context.source_system = System(name="source") @@ -218,6 +221,29 @@ def test_get_load_participation_factor(tmp_path): assert getters.get_load_participation_factor(acbus, context).unwrap() == 0.0 +def test_get_load_mw_handles_volt_ampere_quantity_without_base_scaling(): + class FakeQuantity: + def __init__(self, magnitude: float, unit: str) -> None: + self.magnitude = magnitude + self.unit = unit + + def to(self, unit_name: str) -> FakeQuantity: + if self.unit == unit_name: + return FakeQuantity(self.magnitude, unit_name) + if self.unit == "volt_ampere" and unit_name == "megawatt": + return FakeQuantity(self.magnitude / 1_000_000.0, unit_name) + if self.unit == "volt_ampere" and unit_name == "watt": + return FakeQuantity(self.magnitude, unit_name) + raise ValueError("unsupported conversion") + + load = types.SimpleNamespace( + max_active_power=FakeQuantity(100_000_000.0, "volt_ampere"), + base_power=100.0, + ) + + assert getters._get_load_mw(load) == 100.0 + + def test_get_voltage_valid(context): bus = ACBus(name="N1", base_voltage=115.0, number=1) assert getters.get_voltage_kv(bus, context).unwrap() == 115.0 @@ -257,28 +283,6 @@ def test_get_line_min_flow_max_flow_with_rating(context): assert getters.get_line_max_flow(line, context).unwrap() == 10000.0 -def test_get_line_charging_susceptance_with_b(context): - bus1 = ACBus(name="N2", base_voltage=115.0, number=1) - bus3 = ACBus(name="N4", base_voltage=115.0, number=3) - context.source_system.add_component(bus1) - context.source_system.add_component(bus3) - - arc = Arc(from_to=bus1, to_from=bus3) - context.source_system.add_component(arc) - line = Line( - name="L1", - rating=100.0, - r=0.01, - x=0.1, - arc=arc, - b=FromTo_ToFrom(from_to=2.5, to_from=2.5), - active_power_flow=0.0, - reactive_power_flow=0.0, - angle_limits=MinMax(min=-0.03, max=0.03), - ) - assert getters.get_line_charging_susceptance(line, context).unwrap() == 2.5 - - def test_get_max_capacity_with_limits(context): gen = ThermalStandard( name="GEN1", @@ -312,6 +316,40 @@ def test_get_max_capacity_with_limits(context): assert getters.get_max_capacity(gen, context).unwrap() == 1000.0 +def test_get_max_capacity_matches_rating_for_small_values(context): + gen = ThermalStandard( + name="GEN_SMALL", + bus=ACBus(name="N1", base_voltage=115.0, number=1), + active_power=0.0, + reactive_power=0.0, + rating=7.1, + base_power=1.0, + must_run=False, + status=True, + time_at_status=0.0, + active_power_limits=MinMax(min=0.0, max=999.0), + ramp_limits=UpDown(up=10.0, down=10.0), + time_limits=UpDown(up=1.0, down=1.0), + prime_mover_type=PrimeMoversType.CC, + fuel=ThermalFuels.NATURAL_GAS, + operation_cost=ThermalGenerationCost( + variable=FuelCurve( + value_curve=InputOutputCurve( + function_data=QuadraticFunctionData( + quadratic_term=0.01, + proportional_term=9.0, + constant_term=100.0, + ) + ), + fuel_cost=2.0, + power_units=UnitSystem.NATURAL_UNITS, + ), + ), + ) + assert getters.get_generator_rating(gen, context).unwrap() == 7.1 + assert getters.get_max_capacity(gen, context).unwrap() == 7.1 + + def test_get_storage_charge_discharge_efficiency_valid(context): battery = EnergyReservoirStorage( name="BAT1", @@ -461,7 +499,13 @@ def get_components(cls, filter_func=None): assert getters.get_area_load(acbus, context).unwrap() == 0.0 -def test_get_head_tail_storage_names_valid(context): +def test_get_head_tail_storage_names_valid(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + hydro = HydroReservoir( name="hydro-reservoir-test", available=True, @@ -507,8 +551,10 @@ def test_get_hydro_dispatch_properties(context): ) context.source_system.add_component(hydro) assert getters.get_generator_rating(hydro, context).unwrap() == 10000.0 - assert getters.get_max_ramp_down(hydro, context).unwrap() == 500.0 - assert getters.get_max_ramp_up(hydro, context).unwrap() == 500.0 + with pytest.raises(TypeError, match="not subscriptable"): + getters.get_max_ramp_down(hydro, context).unwrap() + with pytest.raises(TypeError, match="not subscriptable"): + getters.get_max_ramp_up(hydro, context).unwrap() def test_get_component_rating_transformer(context): @@ -598,26 +644,50 @@ def test_get_thermal_generator_units_zero_when_fuel_price_zero(monkeypatch, cont class DummyThermal: pass + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] + ) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(0.0)) monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(9.5)) - assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 0 + assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 1 def test_get_thermal_generator_units_zero_when_heat_rate_zero(monkeypatch, context): class DummyThermal: pass + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] + ) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.3)) monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(0.0)) - assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 0 + assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 1 def test_get_thermal_generator_units_one_when_inputs_present(monkeypatch, context): class DummyThermal: pass + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] + ) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.3)) monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(9.5)) @@ -628,6 +698,14 @@ def test_get_thermal_generator_units_zero_for_monticello_tx(monkeypatch, context class DummyThermal: ext = {"plant_name": "Monticello", "state": "TX"} # noqa: RUF012 + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] + ) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.3)) monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(9.5)) @@ -638,76 +716,152 @@ def test_get_thermal_generator_units_keeps_monticello_mn_active(monkeypatch, con class DummyThermal: ext = {"plant_name": "Monticello Nuclear Facility", "state": "MN"} # noqa: RUF012 + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] + ) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.3)) monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(9.5)) assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 1 -def test_get_generator_category_avoids_substring_nuclear_false_positive(monkeypatch, context): - class DummyGenerator: - name = "wind_plant_component" - ext = {"plant_name": "Monticello Wind Farm"} # noqa: RUF012 - prime_mover_type = None - fuel = None +def test_get_thermal_generator_units_zero_when_time_series_missing(monkeypatch, context): + class DummyThermal: + pass - monkeypatch.setattr(getters, "_build_nuclear_plant_name_set", lambda _ctx: {"monticello"}) - monkeypatch.setattr(getters, "_build_nuclear_plant_name_state_set", lambda _ctx: set()) - monkeypatch.setattr(getters, "_build_oil_plant_name_set", lambda _ctx: set()) - monkeypatch.setattr(getters, "_build_oil_plant_name_state_set", lambda _ctx: set()) + context.source_system.time_series.has_time_series = lambda _component: False - assert getters.get_generator_category(DummyGenerator(), context).is_err() + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.3)) + monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(9.5)) + assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 1 -def test_get_generator_category_uses_state_for_nuclear_matching(monkeypatch, context): - class DummyGenerator: - name = "gen" - ext = {"plant_name": "Monticello", "state": "TX"} # noqa: RUF012 - prime_mover_type = None - fuel = None - monkeypatch.setattr(getters, "_build_nuclear_plant_name_set", lambda _ctx: set()) - monkeypatch.setattr( - getters, - "_build_nuclear_plant_name_state_set", - lambda _ctx: {("monticello", "MN")}, +def test_get_thermal_generator_units_honors_explicit_units_zero(context): + class DummyThermal: + units = 0 + + assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 0 + + +def test_get_thermal_generator_units_uses_heat_rate_base_and_incr(monkeypatch, context): + class DummyThermal: + pass + + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(0.0)) + monkeypatch.setattr(getters, "get_generator_start_cost", lambda *_: Ok(0.0)) + monkeypatch.setattr(getters, "get_heat_rate", lambda *_: Ok(0.0)) + monkeypatch.setattr(getters, "get_heat_rate_base", lambda *_: Ok(12.3)) + monkeypatch.setattr(getters, "get_heat_rate_incr", lambda *_: Ok(9.7)) + monkeypatch.setattr(getters, "get_heat_rate_incr2", lambda *_: Ok(0.0)) + monkeypatch.setattr(getters, "get_heat_rate_incr3", lambda *_: Ok(0.0)) + + assert getters.get_thermal_generator_units(DummyThermal(), context).unwrap() == 1 + + +def test_get_generator_load_point_returns_multiband_property(monkeypatch, context): + class DummyThermal: + pass + + load_point = PLEXOSPropertyValue() + load_point.add_entry(value=50.0, band=1) + load_point.add_entry(value=100.0, band=2) + + monkeypatch.setattr(getters, "compute_heat_rate_data", lambda *_: {"load_point": load_point}) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(3.0)) + + assert getters.get_generator_load_point(DummyThermal(), context).unwrap() is load_point + + +def test_get_generator_load_point_falls_back_to_heat_rate_times_fuel(monkeypatch, context): + class DummyThermal: + pass + + monkeypatch.setattr(getters, "compute_heat_rate_data", lambda *_: {"heat_rate": 9.5}) + monkeypatch.setattr(getters, "get_fuel_price", lambda *_: Ok(2.0)) + + assert getters.get_generator_load_point(DummyThermal(), context).unwrap() == 19.0 + + +def test_get_dispatch_generator_units_zero_when_time_series_missing(context): + class DummyDispatch: + pass + + context.source_system.time_series.has_time_series = lambda _component: False + + assert getters.get_dispatch_generator_units(DummyDispatch(), context).unwrap() == 0 + + +def test_get_dispatch_generator_units_one_when_time_series_present(context): + class DummyDispatch: + pass + + context.source_system.time_series.has_time_series = lambda _component: True + context.source_system.time_series.list_time_series_metadata = lambda _component: [ + types.SimpleNamespace(name="max_active_power", features={}) + ] + context.source_system.list_time_series = ( + lambda _component, **kwargs: [object()] if kwargs.get("name") else [] ) - monkeypatch.setattr(getters, "_build_oil_plant_name_set", lambda _ctx: set()) - monkeypatch.setattr(getters, "_build_oil_plant_name_state_set", lambda _ctx: set()) - assert getters.get_generator_category(DummyGenerator(), context).is_err() + assert getters.get_dispatch_generator_units(DummyDispatch(), context).unwrap() == 1 + +def _make_thermal_generator_for_category_tests( + name: str, + fuel: ThermalFuels | str, + prime_mover_type: PrimeMoversType = PrimeMoversType.CC, +) -> ThermalStandard: + return ThermalStandard( + name=name, + bus=None, + active_power=0.0, + reactive_power=0.0, + rating=100.0, + base_power=10.0, + must_run=False, + status=True, + time_at_status=0.0, + active_power_limits=MinMax(min=10.0, max=100.0), + ramp_limits=UpDown(up=10.0, down=10.0), + time_limits=UpDown(up=1.0, down=1.0), + prime_mover_type=prime_mover_type, + fuel=fuel, + operation_cost=ThermalGenerationCost.example(), + ) -def test_get_generator_category_matches_nuclear_with_same_state(monkeypatch, context): - class DummyGenerator: - name = "gen" - ext: ClassVar[dict[str, str]] = {"plant_name": "Monticello", "state": "MN"} - prime_mover_type = None - fuel = None - monkeypatch.setattr(getters, "_build_nuclear_plant_name_set", lambda _ctx: set()) - monkeypatch.setattr( - getters, - "_build_nuclear_plant_name_state_set", - lambda _ctx: {("monticello", "MN")}, +def test_get_generator_category_maps_thermal_nuclear_fuel(context): + gen = _make_thermal_generator_for_category_tests( + name="thermal-nuclear", + fuel=ThermalFuels.NUCLEAR, ) - monkeypatch.setattr(getters, "_build_oil_plant_name_set", lambda _ctx: set()) - monkeypatch.setattr(getters, "_build_oil_plant_name_state_set", lambda _ctx: set()) - assert getters.get_generator_category(DummyGenerator(), context).unwrap() == "nuclear" + assert getters.get_generator_category(gen, context).unwrap() == "nuclear" -def test_get_generator_category_prioritizes_nuclear_keyword_over_prime_mover(context): - class DummyPrimeMover: - name = "ST" +def test_get_generator_category_maps_thermal_oil_fuel(context): + gen = _make_thermal_generator_for_category_tests( + name="thermal-oil", + fuel=ThermalFuels.KEROSENE, + ) + + assert getters.get_generator_category(gen, context).unwrap() == "o-g-s" - class DummyGenerator: - name = "MonticelliNuclearFacility_1" - ext = {} # noqa: RUF012 - prime_mover_type = DummyPrimeMover() - fuel = None - assert getters.get_generator_category(DummyGenerator(), context).unwrap() == "nuclear" +def test_get_generator_category_thermal_prefers_fuel_over_prime_mover(context): + gen = _make_thermal_generator_for_category_tests( + name="natural-gas", + fuel=ThermalFuels.NATURAL_GAS, + prime_mover_type=PrimeMoversType.ST, + ) + + assert getters.get_generator_category(gen, context).unwrap() == "gas-cc" def test_get_turbine_pump_load_and_efficiency(context): @@ -739,6 +893,62 @@ def test_get_turbine_pump_load_and_efficiency(context): assert getters.get_turbine_pump_efficiency(ht, context).unwrap() == 92.0 +def test_get_pumped_hydro_category_demotes_zero_pump_load(context): + bus1 = ACBus(name="N2", base_voltage=115.0, number=1) + context.source_system.add_component(bus1) + ht_zero = HydroTurbine( + name="hydro-turbine-zero-pump", + available=True, + bus=bus1, + active_power=120.0, + reactive_power=0.0, + rating=0.0, + active_power_limits=MinMax(min=15.0, max=150.0), + reactive_power_limits=MinMax(min=-45.0, max=45.0), + base_power=150.0, + operation_cost=HydroGenerationCost.example(), + powerhouse_elevation=350.0, + ramp_limits=UpDown(up=8.0, down=8.0), + time_limits=UpDown(up=1.5, down=1.5), + outflow_limits=MinMax(min=5.0, max=100.0), + efficiency=0.92, + turbine_type=HydroTurbineType.FRANCIS, + prime_mover_type=PrimeMoversType.OT, + conversion_factor=1.0, + reservoirs=[], + category="hydro_turbine", + ) + assert getters.get_pumped_hydro_category(ht_zero, context).unwrap() == "hydro" + + ht_pumped = HydroTurbine( + name="hydro-turbine-with-pump", + available=True, + bus=bus1, + active_power=120.0, + reactive_power=0.0, + rating=150.0, + active_power_limits=MinMax(min=15.0, max=150.0), + reactive_power_limits=MinMax(min=-45.0, max=45.0), + base_power=150.0, + operation_cost=HydroGenerationCost.example(), + powerhouse_elevation=350.0, + ramp_limits=UpDown(up=8.0, down=8.0), + time_limits=UpDown(up=1.5, down=1.5), + outflow_limits=MinMax(min=5.0, max=100.0), + efficiency=0.92, + turbine_type=HydroTurbineType.FRANCIS, + prime_mover_type=PrimeMoversType.OT, + conversion_factor=1.0, + reservoirs=[], + category="hydro_turbine", + ) + # Non-zero pump load: defer to standard resolution rather than demoting + # to "hydro". Either an explicit category resolves or rule default applies. + result = getters.get_pumped_hydro_category(ht_pumped, context) + if result.is_ok(): + assert result.unwrap() != "hydro" + + def test_get_thermal_forced_outage_rate_defaults(context): bus1 = ACBus(name="N2", base_voltage=115.0, number=1) context.source_system.add_component(bus1) @@ -876,7 +1086,12 @@ def test_get_area_units_and_load(context): assert getters.get_area_load(area, context).unwrap() == 0.0 -def test_get_head_tail_storage_name(context): +def test_get_head_tail_storage_name(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) hydro = HydroReservoir( name="hydro1", available=True, @@ -898,6 +1113,57 @@ def test_get_head_tail_storage_name(context): assert getters.get_tail_storage_name(hydro, context).unwrap() == "hydro1_tail" +def test_get_head_tail_storage_name_without_pumped_storage_association(context): + hydro = HydroReservoir( + name="hydro1", + available=True, + storage_level_limits=MinMax(min=0.0, max=1000.0), + initial_level=0.5, + spillage_limits=MinMax(min=0.0, max=100.0), + inflow=50.0, + outflow=30.0, + level_targets=0.8, + travel_time=2.0, + intake_elevation=500.0, + head_to_volume_factor=LinearCurve(1.0), + reservoir_location=ReservoirLocation.HEAD, + operation_cost=HydroReservoirCost(), + level_data_type=ReservoirDataType.USABLE_VOLUME, + category="hydro_reservoir", + ) + + assert getters.get_head_storage_name(hydro, context).is_err() + assert getters.get_tail_storage_name(hydro, context).is_err() + + +def test_reservoir_association_true_for_hydropumpturbine_links(context): + pump_turbine = type("HydroPumpTurbine", (), {})() + reservoir = type( + "ReservoirProxy", + (), + { + "upstream_turbines": [pump_turbine], + "downstream_turbines": [], + }, + )() + + assert getters._reservoir_has_hydro_pumped_storage_association(reservoir, context) + + +def test_reservoir_association_false_for_hydroturbine_links(context): + hydro_turbine = type("HydroTurbine", (), {})() + reservoir = type( + "ReservoirProxy", + (), + { + "upstream_turbines": [], + "downstream_turbines": [hydro_turbine], + }, + )() + + assert not getters._reservoir_has_hydro_pumped_storage_association(reservoir, context) + + def test_membership_component_child_node_generator(context): gen = PLEXOSGenerator(name="GEN1") node = PLEXOSNode(name="N1") @@ -957,6 +1223,43 @@ def test_membership_component_child_node_battery(context): assert getters.membership_component_child_node(bat, context).unwrap().name == "N2" +def test_membership_node_child_zone_by_name(context): + area = Area(name="A1") + zone = LoadZone(name="Z1") + bus = ACBus(name="N1", area=area, load_zone=zone, number=1) + node = PLEXOSNode(name="N1") + target_zone = PLEXOSZone(name="Z1") + + context.source_system.add_component(area) + context.source_system.add_component(zone) + context.source_system.add_component(bus) + context.target_system.add_component(node) + context.target_system.add_component(target_zone) + + result = getters.membership_node_child_zone(node, context) + assert result.is_ok() + assert result.unwrap() == target_zone + + +def test_membership_node_child_zone_by_uuid(context): + zone_uuid = "11111111-1111-4111-8111-111111111111" + area = Area(name="A1") + source_zone = LoadZone(name="source-zone-name", uuid=zone_uuid) + bus = ACBus(name="N1", area=area, load_zone=source_zone, number=1) + node = PLEXOSNode(name="N1") + target_zone = PLEXOSZone(name="Z_from_uuid", uuid=zone_uuid) + + context.source_system.add_component(area) + context.source_system.add_component(source_zone) + context.source_system.add_component(bus) + context.target_system.add_component(node) + context.target_system.add_component(target_zone) + + result = getters.membership_node_child_zone(node, context) + assert result.is_ok() + assert result.unwrap() == target_zone + + def test_membership_region_parent_node(context): region = PLEXOSRegion(name="A1") node = PLEXOSNode(name="A1") @@ -1030,7 +1333,9 @@ def test_membership_transformer_from_to_parent_node(context): assert getters.membership_transformer_to_parent_node(transformer, context).unwrap().name == "N2" -def test_membership_head_tail_storage_generator(context): +def test_membership_head_tail_storage_generator(context, monkeypatch): + monkeypatch.setattr(getters, "_is_hydro_pumped_storage_generator", lambda _ctx, _name: True) + bus1 = ACBus(name="N2", base_voltage=115.0, number=1) context.source_system.add_component(bus1) ht = HydroTurbine( @@ -1126,6 +1431,105 @@ def test__attach_generator_time_series_no_source(context): getters._attach_generator_time_series(context, "missing", gen) +def test__attach_generator_time_series_weekly_hydro_budget_aggregation(context, monkeypatch): + source_gen = types.SimpleNamespace(name="hydro_gen", active_power_limits=None, rating=None) + metadata = types.SimpleNamespace(name="hydro_budget", features={}) + source_ts = types.SimpleNamespace( + name="hydro_budget", + data=[1.0] * 400, + initial_timestamp=datetime(2025, 1, 1), + resolution=timedelta(hours=1), + features={}, + ) + + monkeypatch.setattr(getters, "_lookup_source_generator", lambda *_args, **_kwargs: source_gen) + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + monkeypatch.setattr( + context.source_system.time_series, + "list_time_series_metadata", + lambda _component: [metadata], + ) + monkeypatch.setattr( + context.source_system, + "list_time_series", + lambda _component, name=None, **_kwargs: [source_ts] if name == "hydro_budget" else [], + ) + + captured: list[object] = [] + context.target_system.has_time_series = lambda *_args, **_kwargs: False + context.target_system.add_time_series = lambda ts, *_args, **_kwargs: captured.append(ts) + + getters._attach_generator_time_series(context, "hydro_gen", PLEXOSGenerator(name="hydro_gen")) + + assert len(captured) == 1 + attached = captured[0] + assert attached.resolution == timedelta(days=7) + assert list(attached.data) == [168.0, 168.0, 64.0] + + +def test__attach_generator_time_series_hydro_budget_keeps_hourly_when_single_bucket(context, monkeypatch): + source_gen = types.SimpleNamespace(name="hydro_short", active_power_limits=None, rating=None) + metadata = types.SimpleNamespace(name="hydro_budget", features={}) + source_ts = types.SimpleNamespace( + name="hydro_budget", + data=[2.0] * 100, + initial_timestamp=datetime(2025, 1, 1), + resolution=timedelta(hours=1), + features={}, + ) + + monkeypatch.setattr(getters, "_lookup_source_generator", lambda *_args, **_kwargs: source_gen) + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + monkeypatch.setattr( + context.source_system.time_series, + "list_time_series_metadata", + lambda _component: [metadata], + ) + monkeypatch.setattr( + context.source_system, + "list_time_series", + lambda _component, name=None, **_kwargs: [source_ts] if name == "hydro_budget" else [], + ) + + captured: list[object] = [] + context.target_system.has_time_series = lambda *_args, **_kwargs: False + context.target_system.add_time_series = lambda ts, *_args, **_kwargs: captured.append(ts) + + getters._attach_generator_time_series(context, "hydro_short", PLEXOSGenerator(name="hydro_short")) + + assert len(captured) == 1 + attached = captured[0] + assert attached.resolution == timedelta(hours=1) + assert len(attached.data) == 100 + assert float(attached.data[0]) == 2.0 + + +def test__has_usable_generator_time_series_false_on_absent_series(context, monkeypatch): + source_component = types.SimpleNamespace(name="g1") + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: False) + + assert not getters._has_usable_generator_time_series(source_component, context) + + +def test__has_usable_generator_time_series_false_when_metadata_unreadable(context, monkeypatch): + source_component = types.SimpleNamespace(name="g2") + metadata = types.SimpleNamespace(name="max_active_power", features={}) + + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + monkeypatch.setattr( + context.source_system.time_series, + "list_time_series_metadata", + lambda _component: [metadata], + ) + + def raise_on_list(*_args, **_kwargs): + raise RuntimeError("metadata retrieval failed") + + monkeypatch.setattr(context.source_system, "list_time_series", raise_on_list) + + assert not getters._has_usable_generator_time_series(source_component, context) + + def test__attach_reservoir_time_series_to_storage_no_source(context): # Should log warning and return storage = PLEXOSStorage(name="missing_head") @@ -1205,7 +1609,6 @@ def test_get_line_min_max_flow_and_charging_susceptance_none(context): ) assert getters.get_line_min_flow(line, context).unwrap() == -10000.0 assert getters.get_line_max_flow(line, context).unwrap() == 10000.0 - assert getters.get_line_charging_susceptance(line, context).unwrap() == 5.0 def test_get_power_or_standard_load_no_loads(context): @@ -1322,9 +1725,9 @@ class Dummy: assert getters.get_generator_mean_time_to_repair(d, context).unwrap() >= 0.0 assert getters.get_generator_mean_time_to_repair(d, context).unwrap() >= 0.0 result_up = getters.get_max_ramp_up(Dummy(), context).unwrap() - assert result_up == 16.0 + assert result_up == 0.0 result_down = getters.get_max_ramp_down(Dummy(), context).unwrap() - assert result_down == 16.0 + assert result_down == 0.0 def test_thermal_standard_initial_none(context): @@ -1536,7 +1939,13 @@ def test_get_area_load(context): assert getters.get_area_load(area, context).unwrap() == 0.0 -def test_get_head_storage_name(context): +def test_get_head_storage_name(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + hydro = HydroReservoir( name="hydro1_head", available=True, @@ -1554,7 +1963,13 @@ def test_get_head_storage_name(context): assert getters.get_head_storage_name(hydro, context).unwrap() == "hydro1_head" -def test_get_tail_storage_name(context): +def test_get_tail_storage_name(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + hydro = HydroReservoir( name="hydro1_tail", available=True, @@ -1572,6 +1987,129 @@ def test_get_tail_storage_name(context): assert getters.get_tail_storage_name(hydro, context).unwrap() == "hydro1_tail" +def test_head_tail_storage_name_infers_location_from_suffix_when_missing(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + + head = HydroReservoir( + name="Plant_head", + available=True, + initial_level=500.0, + storage_level_limits={"min": 0.0, "max": 1000.0}, + spillage_limits=None, + inflow=0.0, + outflow=0.0, + level_targets=1000.0, + travel_time=0.0, + level_data_type="USABLE_VOLUME", + intake_elevation=0.0, + operation_cost=HydroReservoirCost.example(), + reservoir_location=ReservoirLocation.HEAD, + ext={"plant_name": "Plant"}, + ) + tail = HydroReservoir( + name="Plant_tail", + available=True, + initial_level=500.0, + storage_level_limits={"min": 0.0, "max": 1000.0}, + spillage_limits=None, + inflow=0.0, + outflow=0.0, + level_targets=1000.0, + travel_time=0.0, + level_data_type="USABLE_VOLUME", + intake_elevation=0.0, + operation_cost=HydroReservoirCost.example(), + reservoir_location=ReservoirLocation.TAIL, + ext={"plant_name": "Plant"}, + ) + + assert getters.get_head_storage_name(head, context).unwrap() == "Plant_head" + assert getters.get_tail_storage_name(head, context).is_err() + assert getters.get_head_storage_name(tail, context).is_err() + assert getters.get_tail_storage_name(tail, context).unwrap() == "Plant_tail" + + +def test_head_tail_storage_name_suffix_overrides_conflicting_metadata(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + + # Source metadata can be wrong; suffix should control head/tail assignment. + tail_with_wrong_metadata = HydroReservoir( + name="Abitibi Canyon_tail", + available=True, + initial_level=500.0, + storage_level_limits={"min": 0.0, "max": 1000.0}, + spillage_limits=None, + inflow=0.0, + outflow=0.0, + level_targets=1000.0, + travel_time=0.0, + level_data_type="USABLE_VOLUME", + intake_elevation=0.0, + operation_cost=HydroReservoirCost.example(), + reservoir_location=ReservoirLocation.HEAD, + ext={"plant_name": "Abitibi Canyon"}, + ) + + assert getters.get_head_storage_name(tail_with_wrong_metadata, context).is_err() + assert getters.get_tail_storage_name(tail_with_wrong_metadata, context).unwrap() == "Abitibi Canyon_tail" + + +def test_unsuffixed_reservoir_skips_side_with_explicit_reservoir(context, monkeypatch): + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + + explicit_head = HydroReservoir( + name="Wallace Dam_head", + available=True, + initial_level=500.0, + storage_level_limits={"min": 0.0, "max": 1000.0}, + spillage_limits=None, + inflow=0.0, + outflow=0.0, + level_targets=1000.0, + travel_time=0.0, + level_data_type="USABLE_VOLUME", + intake_elevation=0.0, + operation_cost=HydroReservoirCost.example(), + reservoir_location=ReservoirLocation.HEAD, + ext={"plant_name": "Wallace Dam"}, + ) + unsuffixed = HydroReservoir( + name="Wallace Dam", + available=True, + initial_level=500.0, + storage_level_limits={"min": 0.0, "max": 1000.0}, + spillage_limits=None, + inflow=0.0, + outflow=0.0, + level_targets=1000.0, + travel_time=0.0, + level_data_type="USABLE_VOLUME", + intake_elevation=0.0, + operation_cost=HydroReservoirCost.example(), + reservoir_location=ReservoirLocation.TAIL, + ext={"plant_name": "Wallace Dam"}, + ) + + context.source_system.add_component(explicit_head) + context.source_system.add_component(unsuffixed) + + assert getters.get_head_storage_name(explicit_head, context).unwrap() == "Wallace Dam_head" + assert getters.get_head_storage_name(unsuffixed, context).is_err() + assert getters.get_tail_storage_name(unsuffixed, context).unwrap() == "Wallace Dam_tail" + + def test_membership_reserve_child_generator_err(context): reserve = VariableReserve( name="missing", reserve_type=ReserveType.SPINNING, vors=10.0, direction="UP", requirement=100.0 @@ -1686,75 +2224,30 @@ def test_membership_tail_storage_generator_err(context): assert result.is_err() -# ...existing code... - - -def test_get_voltage_zero(context): - """Covers get_voltage returning 0.0 when base_voltage has no magnitude.""" - bus = ACBus(name="N1", number=1) - bus.base_voltage = None - assert getters.get_voltage_kv(bus, context).unwrap() == 0.0 - - -def test_get_susceptance_complex_primary_shunt(context): - """Covers complex number branch in get_susceptance.""" - bus1 = ACBus(name="N1", base_voltage=115.0, number=1) - bus2 = ACBus(name="N2", base_voltage=115.0, number=2) - context.source_system.add_component(bus1) - context.source_system.add_component(bus2) - arc = Arc(from_to=bus1, to_from=bus2) - context.source_system.add_component(arc) - t = Transformer2W(name="T1", arc=arc, primary_shunt=Complex(real=1.0, imag=3.0)) - assert getters.get_transformer_susceptance(t, context).unwrap() == 3.0 - - -def test_get_line_min_max_flow_none_rating(context): - """Covers None rating branch in get_line_min_flow and get_line_max_flow.""" - bus1 = ACBus(name="N1", base_voltage=115.0, number=1) - bus2 = ACBus(name="N2", base_voltage=115.0, number=2) - context.source_system.add_component(bus1) - context.source_system.add_component(bus2) - arc = Arc(from_to=bus1, to_from=bus2) - context.source_system.add_component(arc) - line = Line( - name="L1", - arc=arc, - rating=None, - r=0.01, - x=0.1, - b=FromTo_ToFrom(from_to=0.0, to_from=0.0), - active_power_flow=0.0, - reactive_power_flow=0.0, - angle_limits=MinMax(min=-0.03, max=0.03), - ) - assert getters.get_line_min_flow(line, context).unwrap() == -99999.0 - assert getters.get_line_max_flow(line, context).unwrap() == 99999.0 +# ...existing code... + + +def test_get_voltage_zero(context): + """Covers get_voltage returning 0.0 when base_voltage has no magnitude.""" + bus = ACBus(name="N1", number=1) + bus.base_voltage = None + assert getters.get_voltage_kv(bus, context).unwrap() == 0.0 -def test_get_line_charging_susceptance_complex_b(context): - """Covers complex b branch in get_line_charging_susceptance.""" +def test_get_susceptance_complex_primary_shunt(context): + """Covers complex number branch in get_susceptance.""" bus1 = ACBus(name="N1", base_voltage=115.0, number=1) bus2 = ACBus(name="N2", base_voltage=115.0, number=2) context.source_system.add_component(bus1) context.source_system.add_component(bus2) arc = Arc(from_to=bus1, to_from=bus2) context.source_system.add_component(arc) - line = Line( - name="L1", - arc=arc, - rating=100.0, - r=0.01, - x=0.1, - b=FromTo_ToFrom(from_to=4.5, to_from=4.5), - active_power_flow=0.0, - reactive_power_flow=0.0, - angle_limits=MinMax(min=-0.03, max=0.03), - ) - assert getters.get_line_charging_susceptance(line, context).unwrap() == 4.5 + t = Transformer2W(name="T1", arc=arc, primary_shunt=Complex(real=1.0, imag=3.0)) + assert getters.get_transformer_susceptance(t, context).unwrap() == 3.0 -def test_get_line_charging_susceptance_dict_b(context): - """Covers dict b branch in get_line_charging_susceptance.""" +def test_get_line_min_max_flow_none_rating(context): + """Covers None rating branch in get_line_min_flow and get_line_max_flow.""" bus1 = ACBus(name="N1", base_voltage=115.0, number=1) bus2 = ACBus(name="N2", base_voltage=115.0, number=2) context.source_system.add_component(bus1) @@ -1764,15 +2257,16 @@ def test_get_line_charging_susceptance_dict_b(context): line = Line( name="L1", arc=arc, - rating=100.0, + rating=None, r=0.01, x=0.1, - b={"from_to": 6.0, "to_from": 6.0}, + b=FromTo_ToFrom(from_to=0.0, to_from=0.0), active_power_flow=0.0, reactive_power_flow=0.0, angle_limits=MinMax(min=-0.03, max=0.03), ) - assert getters.get_line_charging_susceptance(line, context).unwrap() == 6.0 + assert getters.get_line_min_flow(line, context).unwrap() == -99999.0 + assert getters.get_line_max_flow(line, context).unwrap() == 99999.0 def test_get_max_capacity_zero_from_sienna(context): @@ -1786,34 +2280,6 @@ class DummyWithLimits: assert getters.get_max_capacity(d, context).unwrap() == 55.0 -def test_get_max_capacity_uses_default_when_below_ten_mw(context): - expected = round(getters._get_defaults("gas-cc", "max_capacity_MW"), 2) - - class DummyFromRating: - rating = 1.1 - base_power = 1.0 - - class DummyFromLimits: - rating = None - active_power_limits = {"max": 9.5} # noqa: RUF012 - - assert getters.get_max_capacity(DummyFromRating(), context).unwrap() == expected - assert getters.get_max_capacity(DummyFromLimits(), context).unwrap() == expected - - -def test_get_max_capacity_below_ten_uses_generic_fallback_when_category_has_no_capacity_defaults(context): - expected_generic = round(getters._get_defaults("gas-cc", "max_capacity_MW"), 2) - - class DummyHydroLike: - # Maps to a category that does not define max_capacity_MW/capacity_MW defaults. - ext: ClassVar[dict[str, str]] = {"gen_type_string": "hydro"} - rating = 0.02 - base_power = 1.0 - - result = getters.get_max_capacity(DummyHydroLike(), context).unwrap() - assert result == expected_generic - - def test_get_component_rating_no_base_power(context): """Covers get_component_rating when rating is not None but base_power missing.""" @@ -1837,24 +2303,129 @@ def test_get_max_ramp_up_down_dict(context): """Covers dict ramp_limits branch in get_max_ramp_up and get_max_ramp_down.""" class DummyRamp: - ramp_limits = {"up": 10.0, "down": 8.0} # noqa: RUF012 + ramp_limits = {"up": 0.10, "down": 0.12} # noqa: RUF012 base_power = 100.0 d = DummyRamp() - assert getters.get_max_ramp_up(d, context).unwrap() == 1000.0 - assert getters.get_max_ramp_down(d, context).unwrap() == 800.0 + assert getters.get_max_ramp_up(d, context).unwrap() == 10.0 + assert getters.get_max_ramp_down(d, context).unwrap() == 12.0 def test_get_max_ramp_up_down_object(context): - """Covers object ramp_limits branch in get_max_ramp_up and get_max_ramp_down.""" + """Current getters require dict-like ramp_limits and raise on UpDown objects.""" class DummyRamp: - ramp_limits = UpDown(up=5.0, down=3.0) + ramp_limits = UpDown(up=0.5, down=0.3) base_power = 10.0 d = DummyRamp() - assert getters.get_max_ramp_up(d, context).unwrap() == 50.0 - assert getters.get_max_ramp_down(d, context).unwrap() == 30.0 + with pytest.raises(TypeError, match="not subscriptable"): + getters.get_max_ramp_up(d, context).unwrap() + with pytest.raises(TypeError, match="not subscriptable"): + getters.get_max_ramp_down(d, context).unwrap() + + +def test_get_max_ramp_thermal_large_absolute_value_stays_nonzero(context, monkeypatch): + """Large thermal ramp values already in MW/min should not collapse to zero/defaults.""" + + class DummyThermal: + ramp_limits: ClassVar[dict[str, float]] = {"up": 161.637, "down": 161.637} + base_power = 993.3 + rating = 91.74164812123227 + active_power_limits = MinMax(min=0.0, max=90.3) + prime_mover_type = PrimeMoversType.ST + fuel = ThermalFuels.COAL + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "coal-new") + monkeypatch.setattr(getters, "sienna_get_max_active_power", lambda _src: 90.3) + + d = DummyThermal() + # Current behavior keeps large raw values (already MW/min) without fallback. + assert getters.get_max_ramp_up(d, context).unwrap() == 160554.0321 + assert getters.get_max_ramp_down(d, context).unwrap() == 160554.0321 + + +def test_get_max_ramp_up_down_tiny_values_use_defaults(context, monkeypatch): + """Ramps below 0.1 MW/min are treated as invalid and replaced by defaults.""" + + class DummyRamp: + ramp_limits: ClassVar[dict[str, float]] = {"up": 0.0008, "down": 0.0008} + base_power = 100.0 + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "hydro") + monkeypatch.setattr( + getters, "_get_defaults", lambda _cat, key: 0.05 if key == "max_ramp_up_percentage" else 50.0 + ) + + d = DummyRamp() + assert getters.get_max_ramp_up(d, context).unwrap() == 2.5 + assert getters.get_max_ramp_down(d, context).unwrap() == 2.5 + + +def test_get_max_ramp_up_down_zero_values_keep_fallback_value(context, monkeypatch): + """When defaults and source ramps are zero, current behavior returns zero.""" + + class DummyRamp: + ramp_limits = None + base_power = 100.0 + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "hydro") + monkeypatch.setattr(getters, "_get_defaults", lambda _cat, _key: 0.0) + + d = DummyRamp() + assert getters.get_max_ramp_up(d, context).unwrap() == 0.0 + assert getters.get_max_ramp_down(d, context).unwrap() == 0.0 + + +def test_get_max_ramp_hydro_ignores_huge_max_active_power_placeholder(context, monkeypatch): + """Hydro ramps should use ramp_rate defaults when max active power is a sentinel like 1e30.""" + + class DummyHydro: + ramp_limits = None + base_power = 100.0 + prime_mover_type = "HY" + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "hydro") + monkeypatch.setattr(getters, "sienna_get_max_active_power", lambda _src: 1e30) + + d = DummyHydro() + assert getters.get_max_ramp_up(d, context).unwrap() == 120.0 + assert getters.get_max_ramp_down(d, context).unwrap() == 120.0 + + +def test_get_max_ramp_uses_defaults_when_raw_natural_unit_ramp_is_too_low(context, monkeypatch): + """Raw natural-unit ramps below threshold should fall back to defaults.""" + + class DummyHydro: + ramp_limits: ClassVar[dict[str, float]] = {"up": 0.06818181818181819, "down": 0.06818181818181819} + base_power = 11.0 + prime_mover_type = "HY" + active_power_limits = MinMax(min=0.0, max=15.0) + rating = 16.682026255823963 + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "hydro") + + d = DummyHydro() + assert getters.get_max_ramp_up(d, context).unwrap() == 0.75 + assert getters.get_max_ramp_down(d, context).unwrap() == 0.75 + + +def test_effective_max_mw_falls_back_to_active_power_limits_when_sentinel(monkeypatch): + """With sentinel max capacity and low raw ramp, fallback still yields a non-negative ramp.""" + + class Dummy: + ramp_limits: ClassVar[dict[str, float]] = {"up": 0.01, "down": 0.01} + base_power = 10.0 + active_power_limits = MinMax(min=0.0, max=7.5) + prime_mover_type = "HY" + rating = None + + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _src, _ctx: "hydro") + monkeypatch.setattr( + getters, "_get_defaults", lambda _cat, key: 0.05 if key == "max_ramp_up_percentage" else 320.0 + ) + + assert getters.get_max_ramp_up(Dummy(), context=types.SimpleNamespace()).unwrap() >= 0.0 def test_get_initial_hours_up_status_true(context): @@ -1912,6 +2483,19 @@ def test_get_fuel_price_fuel_curve(context): assert getters.get_fuel_price(gen, context).unwrap() == 3.5 +def test_get_fuel_price_mapping_operation_cost(context): + """Covers get_fuel_price when operation_cost and variable are mapping-like.""" + + class DummyThermal: + operation_cost: ClassVar[dict[str, object]] = { + "variable": { + "fuel_cost": 2.644, + } + } + + assert getters.get_fuel_price(DummyThermal(), context).unwrap() == 2.64 + + def test_get_interface_min_max_flow_none_limits(context): """Covers None active_power_flow_limits in get_interface_min/max_flow.""" @@ -2244,7 +2828,7 @@ def test_get_heat_rate_quadratic_curve_returns_coefficients(context_with_thermal from r2x_sienna_to_plexos.getters import get_heat_rate, get_heat_rate_base, get_heat_rate_incr source = context_with_thermal_generators.source_system.get_component(ThermalStandard, "thermal-quadratic") - assert get_heat_rate(source, context_with_thermal_generators).unwrap() == pytest.approx(9.8) + assert get_heat_rate(source, context_with_thermal_generators).unwrap() is None assert get_heat_rate_base(source, context_with_thermal_generators).unwrap() == pytest.approx(120.0) assert get_heat_rate_incr(source, context_with_thermal_generators).unwrap() == pytest.approx(9.8) @@ -2274,7 +2858,6 @@ def test_heat_rate_getters_return_absolute_values(monkeypatch, context_with_ther getters, "compute_heat_rate_data", lambda _component: { - "heat_rate": -9.2, "heat_rate_base": -120.0, "heat_rate_incr": -9.8, "heat_rate_incr2": -0.03, @@ -2282,13 +2865,24 @@ def test_heat_rate_getters_return_absolute_values(monkeypatch, context_with_ther }, ) - assert get_heat_rate(source, context_with_thermal_generators).unwrap() == pytest.approx(9.2) + assert get_heat_rate(source, context_with_thermal_generators).unwrap() is None assert get_heat_rate_base(source, context_with_thermal_generators).unwrap() == pytest.approx(120.0) assert get_heat_rate_incr(source, context_with_thermal_generators).unwrap() == pytest.approx(9.8) assert get_heat_rate_incr2(source, context_with_thermal_generators).unwrap() == pytest.approx(0.03) assert get_heat_rate_incr3(source, context_with_thermal_generators).unwrap() == pytest.approx(0.0005) +def test_get_heat_rate_scalar_when_base_and_incr_missing(monkeypatch, context_with_thermal_generators): + from r2x_sienna.models import ThermalStandard + from r2x_sienna_to_plexos.getters import get_heat_rate + + source = context_with_thermal_generators.source_system.get_component(ThermalStandard, "thermal-fuel") + + monkeypatch.setattr(getters, "compute_heat_rate_data", lambda _component: {"heat_rate": -9.2}) + + assert get_heat_rate(source, context_with_thermal_generators).unwrap() == pytest.approx(9.2) + + def _disable_time_series(sys): sys.add_time_series = lambda *args, **kwargs: None return sys @@ -2547,3 +3141,325 @@ def test_attach_generator_time_series_uses_rating_when_limits_missing(tmp_path, getters._attach_generator_time_series(context, "GEN_RATING", PLEXOSGenerator(name="GEN_RATING")) assert list(attached[0].data) == [1.0, 2.0] + + +def test_resolve_generator_category_zonal2nodal_uses_reeds_defaults(monkeypatch, context): + comp = types.SimpleNamespace(name="zonal2nodal_gas-cc_cluster", ext={}) + monkeypatch.setattr( + getters, + "_get_defaults_data", + lambda _ctx: {"reeds_defaults": {"gas": {}, "gas-cc": {}, "wind-ons": {}}}, + ) + + assert getters._resolve_generator_category(comp, context) == "gas-cc" + + +def test_get_reeds_thermal_category_returns_none_for_non_list_mapping_values(monkeypatch, context): + gen = _make_thermal_generator_for_category_tests( + name="thermal-natgas", + fuel=ThermalFuels.NATURAL_GAS, + ) + monkeypatch.setattr( + getters, + "_get_defaults_data", + lambda _ctx: {"reeds_thermal_mapping": {"natural-gas": "NATURAL_GAS", "coal": ["COAL"]}}, + ) + + assert getters._get_reeds_thermal_category_from_fuel(gen, context) is None + + +def test_get_reservoir_location_helper_priority_order(): + by_name = types.SimpleNamespace(name="Plant_HEAD") + by_attr = types.SimpleNamespace(name="Plant", reservoir_location="tail") + by_ext = types.SimpleNamespace(name="Plant", ext={"RESERVOIR_LOCATION": "head"}) + unknown = types.SimpleNamespace(name="Plant") + + assert getters._get_reservoir_location(by_name) == "HEAD" + assert getters._get_reservoir_location(by_attr) == "TAIL" + assert getters._get_reservoir_location(by_ext) == "HEAD" + assert getters._get_reservoir_location(unknown) is None + + +def test_has_explicit_side_reservoir_for_base_detects_matching_side(monkeypatch, context): + current = types.SimpleNamespace(name="Plant", ext={"plant_name": "Plant"}, uuid="1") + explicit_head = types.SimpleNamespace(name="Plant_head", ext={"plant_name": "Plant"}, uuid="2") + other_plant = types.SimpleNamespace(name="Other_head", ext={"plant_name": "Other"}, uuid="3") + + fake_source = types.SimpleNamespace(get_components=lambda _cls: [current, explicit_head, other_plant]) + monkeypatch.setattr(getters, "_source_system", lambda _ctx: fake_source) + + assert getters._has_explicit_side_reservoir_for_base(current, context, side="HEAD") is True + assert getters._has_explicit_side_reservoir_for_base(current, context, side="TAIL") is False + + +def test_membership_component_child_node_err_when_source_generator_has_no_bus(context): + source_gen = _make_thermal_generator_for_category_tests( + name="gen-without-bus", + fuel=ThermalFuels.NATURAL_GAS, + ) + context.source_system.add_component(source_gen) + + result = getters.membership_component_child_node(PLEXOSGenerator(name="gen-without-bus"), context) + assert result.is_err() + assert "missing bus data" in str(result.err()) + + +def test_membership_interface_child_line_success_via_monkeypatched_index(monkeypatch, context): + target_line = PLEXOSLine(name="line-01") + context.target_system.add_component(target_line) + + source_interface = types.SimpleNamespace(name="IFACE-1", lines=[types.SimpleNamespace(name="line-01")]) + monkeypatch.setattr( + getters, "_build_source_interface_name_index", lambda _ctx: {"IFACE-1": source_interface} + ) + + result = getters.membership_interface_child_line(types.SimpleNamespace(name="IFACE-1"), context) + assert result.is_ok() + assert result.unwrap() == target_line + + +def test_membership_line_parent_interface_success_and_missing_target(context): + from r2x_plexos.models import PLEXOSInterface + + source_interface = TransmissionInterface( + name="Interface-1", + active_power_flow_limits=MinMax(min=-100.0, max=100.0), + direction_mapping={"line-01": 1}, + ) + context.source_system.add_component(source_interface) + + line = PLEXOSLine(name="line-01") + + missing_target = getters.membership_line_parent_interface(line, context) + assert missing_target.is_err() + + target_interface = PLEXOSInterface(name="Interface-1") + context.target_system.add_component(target_interface) + context._cache.pop("target_interface_name_index", None) + + result = getters.membership_line_parent_interface(line, context) + assert result.is_ok() + assert result.unwrap().name == "Interface-1" + + +def test_get_hydro_generator_units_always_online(context): + from r2x_sienna.models import HydroDispatch + from r2x_sienna.models.costs import HydroGenerationCost + + bus = ACBus(name="BUS1", base_voltage=115.0, number=1) + context.source_system.add_component(bus) + hydro = HydroDispatch( + name="HD1", + bus=bus, + rating=100.0, + active_power=50.0, + reactive_power=10.0, + base_power=100.0, + prime_mover_type=PrimeMoversType.HY, + ramp_limits=UpDown(up=5.0, down=5.0), + active_power_limits=MinMax(min=0.0, max=100.0), + operation_cost=HydroGenerationCost.example(), + ) + assert getters.get_hydro_generator_units(hydro, context).unwrap() == 1 + + +def _make_hydro_turbine_for_units_tests(bus: ACBus, name: str, rating: float) -> HydroTurbine: + from r2x_sienna.models.costs import HydroGenerationCost + + return HydroTurbine( + name=name, + available=True, + bus=bus, + active_power=0.0, + reactive_power=0.0, + rating=rating, + active_power_limits=MinMax(min=0.0, max=100.0), + reactive_power_limits=MinMax(min=-10.0, max=10.0), + base_power=100.0, + operation_cost=HydroGenerationCost.example(), + powerhouse_elevation=0.0, + ramp_limits=UpDown(up=5.0, down=5.0), + time_limits=UpDown(up=1.0, down=1.0), + outflow_limits=MinMax(min=0.0, max=50.0), + efficiency=0.92, + turbine_type=HydroTurbineType.FRANCIS, + prime_mover_type=PrimeMoversType.OT, + conversion_factor=1.0, + reservoirs=[], + category="hydro_turbine", + ) + + +def test_get_pumped_hydro_generator_units_zero_rating_is_online(context): + """Turbine with zero rating has zero pump load → always online.""" + bus = ACBus(name="BUS_PH1", base_voltage=115.0, number=10) + context.source_system.add_component(bus) + ht = _make_hydro_turbine_for_units_tests(bus, "ht-zero-pump", rating=0.0) + assert getters.get_pumped_hydro_generator_units(ht, context).unwrap() == 1 + + +def test_get_pumped_hydro_generator_units_hydro_category_is_online(context): + """Non-zero rating that resolves to 'hydro' category stays online.""" + bus = ACBus(name="BUS_PH2", base_voltage=115.0, number=11) + context.source_system.add_component(bus) + ht = _make_hydro_turbine_for_units_tests(bus, "ht-hydro-cat", rating=1.0) + # Force category to "hydro" via gen_type_string + ht.ext = {"gen_type_string": "hydro"} + assert getters.get_pumped_hydro_generator_units(ht, context).unwrap() == 1 + + +def test_get_pumped_hydro_generator_units_pumped_no_reservoir_is_offline(context, monkeypatch): + """Pumped turbine not referenced by any reservoir → offline.""" + bus = ACBus(name="BUS_PH3", base_voltage=115.0, number=12) + context.source_system.add_component(bus) + ht = _make_hydro_turbine_for_units_tests(bus, "ht-no-reservoir", rating=1.0) + # No gen_type_string → category is None → treated as pumped-hydro default + # No HydroReservoir in source system → turbine_names is empty → Ok(0) + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _comp, _ctx: None) + result = getters.get_pumped_hydro_generator_units(ht, context) + assert result.unwrap() == 0 + + +def test_get_pumped_hydro_generator_units_pumped_with_reservoir_is_online(context, monkeypatch): + """Pumped turbine referenced by a storage-creating reservoir → online.""" + bus = ACBus(name="BUS_PH4", base_voltage=115.0, number=13) + context.source_system.add_component(bus) + ht = _make_hydro_turbine_for_units_tests(bus, "ht-with-reservoir", rating=1.0) + monkeypatch.setattr(getters, "_resolve_generator_category", lambda _comp, _ctx: None) + # Inject a non-empty turbine name set so the turbine is found + context._cache["reservoir_pump_turbine_name_set"] = {"ht-with-reservoir"} + result = getters.get_pumped_hydro_generator_units(ht, context) + assert result.unwrap() == 1 + + +def test_build_reservoir_pump_turbine_name_set_collects_ext_plants(context, monkeypatch): + """_build_reservoir_pump_turbine_name_set returns turbine names from reservoir ext plants.""" + # Create a proxy reservoir whose ext["plants"] lists a turbine name, and whose + # _reservoir_has_hydro_pumped_storage_association returns True. + reservoir = types.SimpleNamespace( + uuid="res-1", + name="reservoir-1", + upstream_turbines=[], + downstream_turbines=[], + ext={"plants": ["pump-turbine-A", "pump-turbine-B"]}, + ) + monkeypatch.setattr( + getters, + "_source_system", + lambda _ctx: types.SimpleNamespace(get_components=lambda _cls: [reservoir]), + ) + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _res, _ctx: True, + ) + # Clear cache so it is rebuilt + context._cache.pop("reservoir_pump_turbine_name_set", None) + names = getters._build_reservoir_pump_turbine_name_set(context) + assert "pump-turbine-A" in names + assert "pump-turbine-B" in names + + +def test_build_reservoir_pump_turbine_name_set_skips_non_storage_reservoirs(context, monkeypatch): + """Reservoirs that fail the pump-storage association check are skipped.""" + reservoir = types.SimpleNamespace( + uuid="res-2", + name="reservoir-2", + upstream_turbines=[], + downstream_turbines=[], + ext={"plants": ["should-not-appear"]}, + ) + monkeypatch.setattr( + getters, + "_source_system", + lambda _ctx: types.SimpleNamespace(get_components=lambda _cls: [reservoir]), + ) + monkeypatch.setattr( + getters, + "_reservoir_has_hydro_pumped_storage_association", + lambda _res, _ctx: False, + ) + context._cache.pop("reservoir_pump_turbine_name_set", None) + names = getters._build_reservoir_pump_turbine_name_set(context) + assert "should-not-appear" not in names + + +def test_attach_generator_time_series_scales_hydro_budget(tmp_path, monkeypatch): + """hydro_budget raw per-unit values must be multiplied by max_active_power.""" + context = make_context(tmp_path) + context.source_system = System(name="source") + context.target_system = System(name="target") + + source_gen = types.SimpleNamespace( + name="HYDRO_TS", + active_power_limits={"max": 0.5}, + base_power=2.0, + ) + monkeypatch.setattr(getters, "_lookup_source_generator", lambda _ctx, _name: source_gen) + + raw_values = [10.0, 20.0] # raw per-unit; after *1.0 MW still 10, 20 MWh + context.source_system.time_series.has_time_series = lambda _c: True + context.source_system.time_series.list_time_series_metadata = lambda _c: [ + types.SimpleNamespace(name="hydro_budget", features={}) + ] + context.source_system.list_time_series = lambda _c, **_kw: [ + types.SimpleNamespace( + name="hydro_budget", + data=raw_values, + initial_timestamp=datetime(2020, 1, 1), + # Use weekly resolution so the aggregation block is skipped (>=7 days) + resolution=timedelta(weeks=1), + ) + ] + context.target_system.has_time_series = lambda *_a, **_kw: False + attached = [] + context.target_system.add_time_series = lambda ts, *_a, **_kw: attached.append(ts) + + getters._attach_generator_time_series(context, "HYDRO_TS", PLEXOSGenerator(name="HYDRO_TS")) + + assert len(attached) == 1 + assert attached[0].name == "hydro_budget" + # raw 10.0 * 1.0 MW = 10.0, raw 20.0 * 1.0 MW = 20.0 + assert list(attached[0].data) == [10.0, 20.0] + + +def test_attach_generator_time_series_scales_hydro_budget_hourly(tmp_path, monkeypatch): + """hydro_budget with hourly resolution is scaled then aggregated into weekly sums.""" + context = make_context(tmp_path) + context.source_system = System(name="source") + context.target_system = System(name="target") + + # max_active_power = 0.1 pu * 10.0 MVA = 1.0 MW + source_gen = types.SimpleNamespace( + name="HYDRO_HOURLY", + active_power_limits={"max": 0.1}, + base_power=10.0, + ) + monkeypatch.setattr(getters, "_lookup_source_generator", lambda _ctx, _name: source_gen) + + # Two weeks of hourly data: all ones → raw weekly sum = 168; scaled = 168 * 1.0 + two_weeks_ones = [1.0] * 336 + context.source_system.time_series.has_time_series = lambda _c: True + context.source_system.time_series.list_time_series_metadata = lambda _c: [ + types.SimpleNamespace(name="hydro_budget", features={}) + ] + context.source_system.list_time_series = lambda _c, **_kw: [ + types.SimpleNamespace( + name="hydro_budget", + data=two_weeks_ones, + initial_timestamp=datetime(2020, 1, 1), + resolution=timedelta(hours=1), + ) + ] + context.target_system.has_time_series = lambda *_a, **_kw: False + attached = [] + context.target_system.add_time_series = lambda ts, *_a, **_kw: attached.append(ts) + + getters._attach_generator_time_series(context, "HYDRO_HOURLY", PLEXOSGenerator(name="HYDRO_HOURLY")) + + assert len(attached) == 1 + ts = attached[0] + assert ts.name == "hydro_budget" + assert ts.resolution == timedelta(days=7) + # Each weekly value = 168 * 1.0 (scaled) * 1.0 MW = 168.0 MWh + assert all(abs(v - 168.0) < 1e-6 for v in ts.data) diff --git a/packages/r2x-sienna-to-plexos/tests/test_getters_utils.py b/packages/r2x-sienna-to-plexos/tests/test_getters_utils.py index 5ece23cf..dfc58e1e 100644 --- a/packages/r2x-sienna-to-plexos/tests/test_getters_utils.py +++ b/packages/r2x-sienna-to-plexos/tests/test_getters_utils.py @@ -31,7 +31,7 @@ VariableReserve, ) from r2x_sienna.models.costs import ThermalGenerationCost -from r2x_sienna.models.enums import PrimeMoversType, ReserveType, StorageTechs, ThermalFuels +from r2x_sienna.models.enums import ACBusTypes, PrimeMoversType, ReserveType, StorageTechs, ThermalFuels from r2x_sienna.models.named_tuples import Complex, InputOutput, MinMax, UpDown from r2x_sienna_to_plexos import getters_utils @@ -127,6 +127,72 @@ class Dummy: } +def test_compute_heat_rate_data_mapping_variable(): + class Dummy: + def __init__(self): + self.operation_cost = { + "variable": { + "value_curve": LinearCurve(10.0, 12), + "fuel_cost": 0.05, + } + } + + d = Dummy() + assert getters_utils.compute_heat_rate_data(d) == { + "heat_rate": 10.0, + "heat_rate_incr": 10.0, + "heat_rate_base": 12.0, + } + + +def test_compute_heat_rate_data_mapping_serialized_curve(): + class Dummy: + def __init__(self): + self.operation_cost = { + "variable": { + "fuel_cost": 2.644, + "value_curve": { + "initial_input": 134.0, + "function_data": { + "constant_term": 0.134, + "proportional_term": 12.62, + }, + }, + } + } + + d = Dummy() + assert getters_utils.compute_heat_rate_data(d) == { + "heat_rate": 12.62, + "heat_rate_incr": 12.62, + "heat_rate_base": 0.134, + } + + +def test_compute_heat_rate_data_mapping_x_coords_y_coords_curve(): + class Dummy: + def __init__(self): + self.operation_cost = { + "variable": { + "fuel_cost": 2.644, + "value_curve": { + "input_at_zero": None, + "initial_input": 0.0, + "function_data": { + "x_coords": [0.0, 76.2], + "y_coords": [8.389], + }, + }, + } + } + + d = Dummy() + result = getters_utils.compute_heat_rate_data(d) + assert "heat_rate_incr" in result + assert isinstance(result["heat_rate_incr"], PLEXOSPropertyValue) + assert result["heat_rate_incr"].get_bands() == [1] + + def test_compute_markup_data_piecewise(): class Dummy: operation_cost = type( @@ -179,6 +245,30 @@ def test_ensure_region_node_memberships(context): assert any(m.collection == CollectionEnum.Region for m in memberships) +def test_ensure_region_node_memberships_matches_source_bus_by_uuid_when_names_differ(context): + area = Area(name="A1") + region = PLEXOSRegion(name="A1") + source_bus = ACBus(name="SourceNode", area=area, number=1) + translated_node = PLEXOSNode(name="RenamedNode", uuid=source_bus.uuid) + + context.source_system.add_component(area) + context.source_system.add_component(source_bus) + context.target_system.add_component(region) + context.target_system.add_component(translated_node) + + getters_utils.ensure_region_node_memberships(context) + + memberships = context.target_system.get_supplemental_attributes_with_component( + translated_node, PLEXOSMembership + ) + assert any( + m.collection == CollectionEnum.Region + and m.parent_object == translated_node + and m.child_object == region + for m in memberships + ) + + def test_ensure_transformer_node_memberships(context): node1 = PLEXOSNode(name="N1") node2 = PLEXOSNode(name="N2") @@ -211,7 +301,246 @@ def test_ensure_transformer_node_memberships(context): assert any(m.collection in (CollectionEnum.NodeFrom, CollectionEnum.NodeTo) for m in memberships) -def test_ensure_head_tail_storage_generator_membership(context): +def test_ensure_reference_node_memberships_creates_one_per_region(context): + area_ref = Area(name="A1") + area_other = Area(name="A2") + region_ref = PLEXOSRegion(name="A1") + region_other = PLEXOSRegion(name="A2") + node_ref = PLEXOSNode(name="N1", voltage=138.0, load_participation_factor=0.3) + node_other = PLEXOSNode(name="N2", voltage=230.0, load_participation_factor=0.2) + bus_ref = ACBus(name="N1", number=1, bustype=ACBusTypes.REF, area=area_ref) + bus_other = ACBus(name="N2", number=2, bustype=ACBusTypes.PQ, area=area_other) + + context.source_system.add_component(area_ref) + context.source_system.add_component(area_other) + context.target_system.add_component(region_ref) + context.target_system.add_component(region_other) + context.target_system.add_component(node_ref) + context.target_system.add_component(node_other) + context.source_system.add_component(bus_ref) + context.source_system.add_component(bus_other) + + getters_utils.ensure_reference_node_memberships(context) + + ref_memberships = context.target_system.get_supplemental_attributes_with_component( + node_ref, PLEXOSMembership + ) + other_memberships = context.target_system.get_supplemental_attributes_with_component( + node_other, PLEXOSMembership + ) + + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region_ref + and m.child_object == node_ref + for m in ref_memberships + ) + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region_other + and m.child_object == node_other + for m in other_memberships + ) + + +def test_ensure_reference_node_memberships_prefers_slack_bus_when_present(context): + area = Area(name="A1") + region = PLEXOSRegion(name="A1") + node_slack = PLEXOSNode(name="N1", voltage=115.0, load_participation_factor=0.1) + node_non_slack = PLEXOSNode(name="N2", voltage=500.0, load_participation_factor=0.9) + bus_slack = ACBus(name="N1", number=1, bustype=ACBusTypes.REF, area=area) + bus_non_slack = ACBus(name="N2", number=2, bustype=ACBusTypes.PQ, area=area) + + context.source_system.add_component(area) + context.target_system.add_component(region) + context.target_system.add_component(node_slack) + context.target_system.add_component(node_non_slack) + context.source_system.add_component(bus_slack) + context.source_system.add_component(bus_non_slack) + + getters_utils.ensure_reference_node_memberships(context) + + slack_memberships = context.target_system.get_supplemental_attributes_with_component( + node_slack, PLEXOSMembership + ) + non_slack_memberships = context.target_system.get_supplemental_attributes_with_component( + node_non_slack, PLEXOSMembership + ) + + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region + and m.child_object == node_slack + for m in slack_memberships + ) + assert not any( + m.collection == CollectionEnum.ReferenceNode and m.parent_object == region + for m in non_slack_memberships + ) + + +def test_ensure_reference_node_memberships_fallback_uses_voltage_then_lpf(context): + area = Area(name="A1") + region = PLEXOSRegion(name="A1") + node_low = PLEXOSNode(name="N1", voltage=115.0, load_participation_factor=0.9) + node_mid = PLEXOSNode(name="N2", voltage=230.0, load_participation_factor=0.1) + node_best = PLEXOSNode(name="N3", voltage=230.0, load_participation_factor=0.6) + bus_low = ACBus(name="N1", number=1, bustype=ACBusTypes.PQ, area=area) + bus_mid = ACBus(name="N2", number=2, bustype=ACBusTypes.PQ, area=area) + bus_best = ACBus(name="N3", number=3, bustype=ACBusTypes.PQ, area=area) + + context.source_system.add_component(area) + context.target_system.add_component(region) + context.target_system.add_component(node_low) + context.target_system.add_component(node_mid) + context.target_system.add_component(node_best) + context.source_system.add_component(bus_low) + context.source_system.add_component(bus_mid) + context.source_system.add_component(bus_best) + + getters_utils.ensure_reference_node_memberships(context) + + best_memberships = context.target_system.get_supplemental_attributes_with_component( + node_best, PLEXOSMembership + ) + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region + and m.child_object == node_best + for m in best_memberships + ) + + +def test_ensure_reference_node_memberships_uses_existing_region_memberships_when_names_differ(context): + area = Area(name="A1") + region = PLEXOSRegion(name="A1") + bus = ACBus(name="Source-Bus-1", number=1, bustype=ACBusTypes.PQ, area=area) + translated_node = PLEXOSNode(name="Translated-Node-1", voltage=230.0, load_participation_factor=0.2) + + context.source_system.add_component(area) + context.source_system.add_component(bus) + context.target_system.add_component(region) + context.target_system.add_component(translated_node) + + # Pre-existing Region membership can be node->region for CollectionEnum.Region. + region_membership = PLEXOSMembership( + parent_object=translated_node, + child_object=region, + collection=CollectionEnum.Region, + ) + context.target_system.add_supplemental_attribute(translated_node, region_membership) + context.target_system.add_supplemental_attribute(region, region_membership) + + getters_utils.ensure_reference_node_memberships(context) + + node_memberships = context.target_system.get_supplemental_attributes_with_component( + translated_node, PLEXOSMembership + ) + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region + and m.child_object == translated_node + for m in node_memberships + ) + + +def test_ensure_reference_node_memberships_prefers_translated_slack_flag_when_names_differ(context): + area = Area(name="A1") + region = PLEXOSRegion(name="A1") + slack_bus = ACBus(name="Source-Slack", number=1, bustype=ACBusTypes.SLACK, area=area) + normal_bus = ACBus(name="Source-Normal", number=2, bustype=ACBusTypes.PQ, area=area) + + slack_node = PLEXOSNode(name="RenamedSlack", voltage=115.0, load_participation_factor=0.2, is_slack_bus=1) + normal_node = PLEXOSNode( + name="RenamedNormal", voltage=500.0, load_participation_factor=0.9, is_slack_bus=0 + ) + + context.source_system.add_component(area) + context.source_system.add_component(slack_bus) + context.source_system.add_component(normal_bus) + context.target_system.add_component(region) + context.target_system.add_component(slack_node) + context.target_system.add_component(normal_node) + + slack_region_membership = PLEXOSMembership( + parent_object=slack_node, + child_object=region, + collection=CollectionEnum.Region, + ) + normal_region_membership = PLEXOSMembership( + parent_object=normal_node, + child_object=region, + collection=CollectionEnum.Region, + ) + context.target_system.add_supplemental_attribute(slack_node, slack_region_membership) + context.target_system.add_supplemental_attribute(region, slack_region_membership) + context.target_system.add_supplemental_attribute(normal_node, normal_region_membership) + context.target_system.add_supplemental_attribute(region, normal_region_membership) + + getters_utils.ensure_reference_node_memberships(context) + + slack_memberships = context.target_system.get_supplemental_attributes_with_component( + slack_node, PLEXOSMembership + ) + normal_memberships = context.target_system.get_supplemental_attributes_with_component( + normal_node, PLEXOSMembership + ) + + assert any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region + and m.child_object == slack_node + for m in slack_memberships + ) + assert not any( + m.collection == CollectionEnum.ReferenceNode + and m.parent_object == region + and m.child_object == normal_node + for m in normal_memberships + ) + + +def test_ensure_reference_node_memberships_creates_one_per_region_with_global_fallback(context): + region1 = PLEXOSRegion(name="A1") + region2 = PLEXOSRegion(name="A2") + node = PLEXOSNode(name="OnlyNode", voltage=138.0, load_participation_factor=0.4) + + context.target_system.add_component(region1) + context.target_system.add_component(region2) + context.target_system.add_component(node) + + getters_utils.ensure_reference_node_memberships(context) + + memberships = context.target_system.get_supplemental_attributes_with_component(node, PLEXOSMembership) + ref_memberships = [m for m in memberships if m.collection == CollectionEnum.ReferenceNode] + + assert any(m.parent_object == region1 and m.child_object == node for m in ref_memberships) + assert any(m.parent_object == region2 and m.child_object == node for m in ref_memberships) + + +def test_ensure_head_tail_storage_generator_membership(context, monkeypatch): + import r2x_sienna_to_plexos.getters as getters_mod + + monkey_source = types.SimpleNamespace(name="foo_head") + monkey_source_tail = types.SimpleNamespace(name="foo_tail") + + def monkeypatch_get_components(comp_type): + return ( + [monkey_source, monkey_source_tail] + if getattr(comp_type, "__name__", "") == "HydroPumpedStorage" + else [] + ) + + context.source_system.get_components = monkeypatch_get_components + monkeypatch.setattr( + getters_mod, + "_build_generator_display_name_index", + lambda _ctx: { + "foo_head": "foo_head", + "foo_tail": "foo_tail", + }, + ) + gen = PLEXOSGenerator(name="foo_head") storage = PLEXOSStorage(name="foo_head") context.target_system.add_component(gen) @@ -248,6 +577,50 @@ def test_ensure_pumped_hydro_storage_memberships(context): assert any(m.collection == CollectionEnum.TailStorage for m in memberships_tail) +def test_ensure_pumped_hydro_storages_created_synthesizes_missing(context): + # Pumped-hydro generator with no head/tail storage attached. + gen = PLEXOSGenerator(name="ph_gen", category="pumped-hydro", max_capacity=200.0) + context.target_system.add_component(gen) + + # Hydro generator should be ignored entirely. + hydro_gen = PLEXOSGenerator(name="hydro_gen", category="hydro", max_capacity=50.0) + context.target_system.add_component(hydro_gen) + + getters_utils.ensure_pumped_hydro_storages_created(context) + + storages = {s.name: s for s in context.target_system.get_components(PLEXOSStorage)} + assert "ph_gen_head" in storages + assert "ph_gen_tail" in storages + assert storages["ph_gen_head"].units == 1 + # 200 MW * 10 h / 1000 = 2.0 GWh; initial volume is half-full. + assert storages["ph_gen_head"].max_volume == 2.0 + assert storages["ph_gen_head"].initial_volume == 1.0 + + memberships = context.target_system.get_supplemental_attributes_with_component(gen, PLEXOSMembership) + assert any(m.collection == CollectionEnum.HeadStorage for m in memberships) + assert any(m.collection == CollectionEnum.TailStorage for m in memberships) + + # Hydro generator gets nothing synthesized. + assert "hydro_gen_head" not in storages + assert "hydro_gen_tail" not in storages + + +def test_ensure_pumped_hydro_storages_created_skips_when_already_attached(context): + gen = PLEXOSGenerator(name="ph_gen", category="pumped-hydro", max_capacity=100.0) + head_storage = PLEXOSStorage(name="existing_head") + tail_storage = PLEXOSStorage(name="existing_tail") + context.target_system.add_component(gen) + context.target_system.add_component(head_storage) + context.target_system.add_component(tail_storage) + getters_utils._ensure_membership(context, gen, head_storage, CollectionEnum.HeadStorage) + getters_utils._ensure_membership(context, gen, tail_storage, CollectionEnum.TailStorage) + + getters_utils.ensure_pumped_hydro_storages_created(context) + + storages = {s.name for s in context.target_system.get_components(PLEXOSStorage)} + assert storages == {"existing_head", "existing_tail"} + + def test_ensure_generator_node_memberships(context): area = Area(name="A1") bus = ACBus(name="N1", area=area, number=1) @@ -332,7 +705,20 @@ def test_ensure_battery_node_memberships(context): assert any(m.collection.name == "Nodes" for m in memberships) -def test_ensure_head_storage_generator_membership(context): +def test_ensure_head_storage_generator_membership(context, monkeypatch): + import r2x_sienna_to_plexos.getters as getters_mod + + context.source_system.get_components = ( + lambda comp_type: [types.SimpleNamespace(name="GEN_head")] + if getattr(comp_type, "__name__", "") == "HydroPumpedStorage" + else [] + ) + monkeypatch.setattr( + getters_mod, + "_build_generator_display_name_index", + lambda _ctx: {"GEN_head": "GEN_head"}, + ) + gen = PLEXOSGenerator(name="GEN_head") storage = PLEXOSStorage(name="GEN_head") context.target_system.add_component(gen) @@ -342,7 +728,20 @@ def test_ensure_head_storage_generator_membership(context): assert any(m.collection.name == "HeadStorage" for m in memberships) -def test_ensure_tail_storage_generator_membership(context): +def test_ensure_tail_storage_generator_membership(context, monkeypatch): + import r2x_sienna_to_plexos.getters as getters_mod + + context.source_system.get_components = ( + lambda comp_type: [types.SimpleNamespace(name="GEN_tail")] + if getattr(comp_type, "__name__", "") == "HydroPumpedStorage" + else [] + ) + monkeypatch.setattr( + getters_mod, + "_build_generator_display_name_index", + lambda _ctx: {"GEN_tail": "GEN_tail"}, + ) + gen = PLEXOSGenerator(name="GEN_tail") storage = PLEXOSStorage(name="GEN_tail") context.target_system.add_component(gen) @@ -702,6 +1101,15 @@ def test_bus_name_to_area_and_zone_cache_and_non_area_object(context): assert mapping["B1"] == ("A1", "Z1") +def test_bus_name_to_area_and_zone_uses_zone_name_attribute(context): + zone_like = types.SimpleNamespace(name="Z2") + context.source_system.get_components = lambda _comp_type: [ + types.SimpleNamespace(name="B2", area="A2", load_zone=zone_like) + ] + mapping = getters_utils._bus_name_to_area_and_zone(context) + assert mapping["B2"] == ("A2", "Z2") + + def test_attach_reservoir_time_series_to_storage_paths(context): target_storage = PLEXOSStorage(name="Plant_head") @@ -743,7 +1151,7 @@ def test_attach_reservoir_time_series_to_storage_paths(context): assert list(max_ts.data) == [0.5, 1.0] -def test_hydroturbine_driven_head_tail_memberships(context, monkeypatch): +def test_hydropumpturbine_driven_head_tail_memberships(context, monkeypatch): import r2x_sienna_to_plexos.getters as getters_mod monkeypatch.setattr(getters_mod, "_build_generator_display_name_index", lambda _ctx: {"TURB": "GEN"}) @@ -764,7 +1172,7 @@ def test_hydroturbine_driven_head_tail_memberships(context, monkeypatch): ) turbine = types.SimpleNamespace(name="TURB", reservoirs=[head_res, tail_res]) context.source_system.get_components = ( - lambda comp_type: [turbine] if comp_type.__name__ == "HydroTurbine" else [] + lambda comp_type: [turbine] if comp_type.__name__ == "HydroPumpTurbine" else [] ) getters_utils.ensure_head_storage_generator_membership(context) @@ -927,6 +1335,127 @@ def test_ensure_reserve_time_series_skips_when_target_has_series(context, monkey assert added == [] +def test_ensure_reserve_time_series_collapses_requirement_variants_to_min_provision(context, monkeypatch): + source_reserve = VariableReserve( + name="RES_VARIANTS", + reserve_type=ReserveType.SPINNING, + vors=10.0, + max_participation_factor=0.5, + direction="UP", + requirement=100.0, + ) + target_reserve = PLEXOSReserve(name="RES_VARIANTS") + context.source_system.add_component(source_reserve) + context.target_system.add_component(target_reserve) + + metadata_entries = [ + types.SimpleNamespace(name="Requirement", features={"scenario": "base"}), + types.SimpleNamespace(name="min-provision", features={"scenario": "base"}), + ] + + def _list_time_series(_component, name=None, **_kwargs): + if name == "Requirement": + return [types.SimpleNamespace(name="Requirement", data=[1.0], features={"scenario": "base"})] + if name == "min-provision": + return [types.SimpleNamespace(name="min-provision", data=[1.0], features={"scenario": "base"})] + return [] + + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + monkeypatch.setattr( + context.source_system.time_series, + "list_time_series_metadata", + lambda _component: metadata_entries, + ) + monkeypatch.setattr(context.source_system, "list_time_series", _list_time_series) + + added = [] + context.target_system.has_time_series = lambda *_args, **_kwargs: False + context.target_system.add_time_series = lambda ts, reserve, **features: added.append( + (ts, reserve, features) + ) + + getters_utils.ensure_reserve_time_series(context) + + assert len(added) == 1 + ts, reserve, features = added[0] + assert reserve.name == "RES_VARIANTS" + assert ts.name == "min_provision" + assert ts.data == [100.0] + assert features == {"scenario": "base"} + + +def test_attach_hydro_reservoir_inflow_to_generator_budget_adds_max_energy_day(context, monkeypatch): + import r2x_sienna_to_plexos.getters as getters_mod + + monkeypatch.setattr(getters_utils, "HydroTurbine", object) + monkeypatch.setattr(getters_utils, "HydroPumpTurbine", type("HydroPumpTurbine", (), {})) + + source_generator = types.SimpleNamespace(name="H1", rating=0.0, base_power=1.0) + target_generator = PLEXOSGenerator(name="H1") + reservoir = types.SimpleNamespace(name="R1") + + monkeypatch.setattr(getters_mod, "_build_reservoir_by_turbine_index", lambda _ctx: {"H1": reservoir}) + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + + metadata = [ + types.SimpleNamespace(name="ignored", features={}), + types.SimpleNamespace(name="inflow", features={"scenario": "base"}), + ] + monkeypatch.setattr( + context.source_system.time_series, + "list_time_series_metadata", + lambda _component: metadata, + ) + + source_ts = types.SimpleNamespace( + name="inflow", + data=[1.0, 2.0], + initial_timestamp=datetime(2025, 1, 1), + resolution=timedelta(hours=1), + ) + monkeypatch.setattr( + context.source_system, + "list_time_series", + lambda _component, name=None, **_kwargs: [source_ts] if name == "inflow" else [], + ) + + context.target_system.has_time_series = lambda *_args, **_kwargs: False + added: list[tuple[object, object, dict]] = [] + context.target_system.add_time_series = lambda ts, comp, **features: added.append((ts, comp, features)) + + getters_utils._attach_hydro_reservoir_inflow_to_generator_budget( + context, source_generator, target_generator + ) + + assert len(added) == 1 + ts, comp, features = added[0] + assert comp.name == "H1" + assert ts.name == "max_energy_day" + assert features == {"scenario": "base"} + + +def test_attach_hydro_reservoir_inflow_to_generator_budget_skips_nonzero_rating(context, monkeypatch): + import r2x_sienna_to_plexos.getters as getters_mod + + monkeypatch.setattr(getters_utils, "HydroTurbine", object) + monkeypatch.setattr(getters_utils, "HydroPumpTurbine", type("HydroPumpTurbine", (), {})) + + source_generator = types.SimpleNamespace(name="H2", rating=2.0, base_power=100.0) + target_generator = PLEXOSGenerator(name="H2") + + monkeypatch.setattr(getters_mod, "_build_reservoir_by_turbine_index", lambda _ctx: {"H2": object()}) + monkeypatch.setattr(context.source_system.time_series, "has_time_series", lambda _component: True) + + added = [] + context.target_system.add_time_series = lambda *args, **kwargs: added.append((args, kwargs)) + + getters_utils._attach_hydro_reservoir_inflow_to_generator_budget( + context, source_generator, target_generator + ) + + assert added == [] + + def test_ensure_membership_deduplicates_existing_membership(context): parent = PLEXOSGenerator(name="PARENT_GEN") child = PLEXOSNode(name="CHILD_NODE") @@ -981,7 +1510,16 @@ def source_get_components(comp_type): def test_head_tail_memberships_from_ext_plants_and_fallback_name_matching(context, monkeypatch): import r2x_sienna_to_plexos.getters as getters_mod - monkeypatch.setattr(getters_mod, "_build_generator_display_name_index", lambda _ctx: {"T1": "GEN_T1"}) + monkeypatch.setattr( + getters_mod, + "_build_generator_display_name_index", + lambda _ctx: { + "T1": "GEN_T1", + "GEN_T1": "GEN_T1", + "Fallback_head": "Fallback_head", + "Fallback_tail": "Fallback_tail", + }, + ) reservoir = types.SimpleNamespace(name="ReservoirA", ext={"plant_name": "PlantA", "plants": ["T1"]}) turbine = types.SimpleNamespace(name="T1", reservoirs=[]) @@ -990,8 +1528,14 @@ def source_get_components(comp_type): name = getattr(comp_type, "__name__", "") if name == "HydroReservoir": return [reservoir] - if name == "HydroTurbine": + if name == "HydroPumpTurbine": return [turbine] + if name == "HydroPumpedStorage": + return [ + types.SimpleNamespace(name="GEN_T1"), + types.SimpleNamespace(name="Fallback_head"), + types.SimpleNamespace(name="Fallback_tail"), + ] return [] context.source_system.get_components = source_get_components diff --git a/packages/r2x-sienna-to-plexos/tests/test_rules_loading.py b/packages/r2x-sienna-to-plexos/tests/test_rules_loading.py index f71712bd..acd19106 100644 --- a/packages/r2x-sienna-to-plexos/tests/test_rules_loading.py +++ b/packages/r2x-sienna-to-plexos/tests/test_rules_loading.py @@ -67,25 +67,6 @@ def test_has_storage_to_battery_rule() -> None: ), "Missing EnergyReservoirStorage -> PLEXOSBattery rule" -def test_synchronous_condenser_rule_defaults_to_units_zero() -> None: - """Verify SynchronousCondenser generators are exported deactivated for PLEXOS.""" - rules_path = files("r2x_sienna_to_plexos.config") / "rules.json" - rules_data = json.loads(rules_path.read_text()) - - syn_cond_rule = next( - ( - rule - for rule in rules_data - if rule.get("source_type") == "SynchronousCondenser" - and rule.get("target_type") == "PLEXOSGenerator" - ), - None, - ) - - assert syn_cond_rule is not None, "Missing SynchronousCondenser -> PLEXOSGenerator rule" - assert syn_cond_rule.get("defaults", {}).get("units") == 0 - - def test_rules_have_required_fields() -> None: """Verify all rules have essential structure.""" rules_path = files("r2x_sienna_to_plexos.config") / "rules.json" diff --git a/packages/r2x-sienna-to-plexos/tests/test_translation.py b/packages/r2x-sienna-to-plexos/tests/test_translation.py index 0e02ac2e..fb5f0728 100644 --- a/packages/r2x-sienna-to-plexos/tests/test_translation.py +++ b/packages/r2x-sienna-to-plexos/tests/test_translation.py @@ -77,7 +77,7 @@ def _build_source_system(): status=True, time_at_status=0.0, active_power_limits=MinMax(min=20.0, max=100.0), - ramp_limits=UpDown(up=10.0, down=10.0), + ramp_limits=None, time_limits=UpDown(up=2.0, down=1.0), prime_mover_type=PrimeMoversType.CC, fuel=ThermalFuels.NATURAL_GAS, diff --git a/packages/r2x-sienna-to-plexos/tests/test_translation_rule_application.py b/packages/r2x-sienna-to-plexos/tests/test_translation_rule_application.py index 8519f6d9..1e53e993 100644 --- a/packages/r2x-sienna-to-plexos/tests/test_translation_rule_application.py +++ b/packages/r2x-sienna-to-plexos/tests/test_translation_rule_application.py @@ -93,7 +93,7 @@ def test_sienna_generators_translate_to_plexos_types(tmp_path): prime_mover_type=PrimeMoversType.HY, active_power_limits=MinMax(min=10.0, max=100.0), reactive_power_limits=MinMax(min=-30.0, max=30.0), - ramp_limits=UpDown(up=5.0, down=5.0), + ramp_limits=None, time_limits=UpDown(up=1.0, down=1.0), base_power=100.0, status=True, @@ -165,6 +165,53 @@ def test_sienna_storage_translates_to_plexos_storage(tmp_path): assert storage +def test_hydro_reservoir_without_suffix_translates_to_head_and_tail_storage(tmp_path, monkeypatch): + from infrasys.value_curves import LinearCurve + from r2x_plexos.models import PLEXOSStorage + from r2x_sienna.models import HydroReservoir + from r2x_sienna.models.costs import HydroReservoirCost + from r2x_sienna.models.enums import ReservoirDataType, ReservoirLocation + from r2x_sienna.models.named_tuples import MinMax + from r2x_sienna_to_plexos import getters as getters_module + + context, rules = make_context_and_rules(tmp_path) + context.source_system = System(name="source", auto_add_composed_components=True) + context.source_system.add_component( + HydroReservoir( + name="EI_Reservoir", + available=True, + storage_level_limits=MinMax(min=0.0, max=1000.0), + initial_level=0.5, + spillage_limits=MinMax(min=0.0, max=100.0), + inflow=50.0, + outflow=30.0, + level_targets=0.8, + travel_time=2.0, + intake_elevation=500.0, + head_to_volume_factor=LinearCurve(1.0), + reservoir_location=ReservoirLocation.HEAD, + operation_cost=HydroReservoirCost(), + level_data_type=ReservoirDataType.USABLE_VOLUME, + category="hydro_reservoir", + ) + ) + context.target_system = System(name="target", auto_add_composed_components=True) + context.rules = rules + + monkeypatch.setattr( + getters_module, + "_reservoir_has_hydro_pumped_storage_association", + lambda _source_component, _context: True, + ) + + result = apply_rules_to_context(context) + assert result.total_rules > 0 + + storages = list(context.target_system.get_components(PLEXOSStorage)) + assert any(s.name in {"EI_head", "EI_Reservoir_head"} for s in storages) + assert any(s.name in {"EI_tail", "EI_Reservoir_tail"} for s in storages) + + def test_sienna_interface_translates_to_plexos_interface(tmp_path): from r2x_plexos.models import PLEXOSInterface from r2x_sienna.models import Area, TransmissionInterface @@ -306,6 +353,7 @@ def _inner(_context): monkeypatch.setattr(translation_module, "ensure_generator_time_series", _mark("gen_ts")) monkeypatch.setattr(translation_module, "ensure_reserve_time_series", _mark("reserve_ts")) monkeypatch.setattr(translation_module, "ensure_region_node_memberships", _mark("region_node")) + monkeypatch.setattr(translation_module, "ensure_reference_node_memberships", _mark("reference_node")) monkeypatch.setattr(translation_module, "ensure_generator_node_memberships", _mark("gen_node")) monkeypatch.setattr(translation_module, "ensure_battery_node_memberships", _mark("battery_node")) monkeypatch.setattr(translation_module, "ensure_reserve_battery_memberships", _mark("reserve_battery")) @@ -314,6 +362,7 @@ def _inner(_context): monkeypatch.setattr(translation_module, "ensure_interface_line_memberships", _mark("iface_line")) monkeypatch.setattr(translation_module, "ensure_head_storage_generator_membership", _mark("head")) monkeypatch.setattr(translation_module, "ensure_tail_storage_generator_membership", _mark("tail")) + monkeypatch.setattr(translation_module, "ensure_pumped_hydro_storages_created", _mark("ph_storage")) source = FakeSystem(name="source") result = translation_module.sienna_to_plexos(source, config=types.SimpleNamespace()) @@ -324,6 +373,7 @@ def _inner(_context): "gen_ts", "reserve_ts", "region_node", + "reference_node", "gen_node", "battery_node", "reserve_battery", @@ -332,6 +382,7 @@ def _inner(_context): "iface_line", "head", "tail", + "ph_storage", ] diff --git a/pyproject.toml b/pyproject.toml index cdf21ad8..cebd85ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "r2x-plexos-to-sienna", "r2x-reeds-to-plexos", "r2x-reeds-to-sienna", + "r2x-core", ] [project.urls] @@ -86,7 +87,6 @@ r2x-sienna-to-plexos = { workspace = true } r2x-plexos-to-sienna = { workspace = true } r2x-reeds-to-plexos = { workspace = true } r2x-reeds-to-sienna = { workspace = true } -r2x-core = { git = "https://github.com/NREL/r2x-core.git", branch = "main" } [tool.uv.workspace] members = ["packages/*"] diff --git a/uv.lock b/uv.lock index 1b779cbe..740f8dc3 100644 --- a/uv.lock +++ b/uv.lock @@ -817,14 +817,14 @@ wheels = [ [[package]] name = "plexosdb" -version = "1.3.0" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "loguru" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/43/fa05e53424133cd01cdb09ab9f823138c6aecf8b839f3ddabffb78ac5cc2/plexosdb-1.3.0.tar.gz", hash = "sha256:71ecbcf4c505d8a446ea7fbcc14fe077f20a2ceb874ab9156d9ac74a8d4c3550", size = 48882, upload-time = "2025-12-11T18:38:37.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/d0/28327bb8c46911fdb01675500fc21660255d58112c9b4b3396785961cc15/plexosdb-1.3.4.tar.gz", hash = "sha256:0d2b1811558440ba5da38f6138cde1a995eed2353d347f1595bfbe77c6107328", size = 51881, upload-time = "2026-03-27T05:38:22.273Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e5/190634b0b9a65ac3a951004bb24b1e60ef546d31961f27bc16e086dc76d1/plexosdb-1.3.0-py3-none-any.whl", hash = "sha256:b527b6476530262ca81f35032bac809de145ad458c4d7f2d5a8b7c1629f7be6e", size = 52771, upload-time = "2025-12-11T18:38:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/79ca218dcdc1afd00efdd52c28c16dbb2ec6a7726cc7764a9f820f93ca69/plexosdb-1.3.4-py3-none-any.whl", hash = "sha256:91074d43d20e34d9f6a43f4d0b0841a1bbb66ddde9c1c96822a123659e2bd857", size = 55799, upload-time = "2026-03-27T05:38:21.039Z" }, ] [[package]] @@ -1124,6 +1124,7 @@ name = "r2x" version = "2.0.0" source = { editable = "." } dependencies = [ + { name = "r2x-core" }, { name = "r2x-plexos-to-sienna" }, { name = "r2x-reeds-to-plexos" }, { name = "r2x-reeds-to-sienna" }, @@ -1153,6 +1154,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "r2x-core" }, { name = "r2x-plexos-to-sienna", editable = "packages/r2x-plexos-to-sienna" }, { name = "r2x-reeds-to-plexos", editable = "packages/r2x-reeds-to-plexos" }, { name = "r2x-reeds-to-sienna", editable = "packages/r2x-reeds-to-sienna" }, @@ -1167,7 +1169,7 @@ dev = [ { name = "prek", specifier = ">=0.3.8" }, { name = "pytest", specifier = ">=8.3.0,<9.0.0" }, { name = "pytest-cov", specifier = ">=5.0.0,<8.0.0" }, - { name = "r2x-core", git = "https://github.com/NREL/r2x-core.git?branch=main" }, + { name = "r2x-core", specifier = ">=0.4.2" }, { name = "ruff", specifier = ">=0.6.8,<0.7.0" }, { name = "taplo", specifier = ">=0.9.3,<1.0.0" }, { name = "ty", specifier = ">=0.0.2" }, @@ -1182,8 +1184,8 @@ docs = [ [[package]] name = "r2x-core" -version = "0.4.2" -source = { git = "https://github.com/NREL/r2x-core.git?branch=main#9c8f8d1e088bd3e6077d9255ca2f884b53b0dd74" } +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, { name = "infrasys" }, @@ -1193,18 +1195,22 @@ dependencies = [ { name = "pydantic" }, { name = "rust-ok" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/9b/fc/1607777bc5db4d6141a476635c3430066aaa825344b61b98683fcbf9a253/r2x_core-0.5.0.tar.gz", hash = "sha256:f1ec94866e7f67ba0e66d1b81e26d18e5d2c8da2a44a7fd07ea37d8380d7a582", size = 64779, upload-time = "2026-05-21T01:33:48.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/22/10be82e57b0a0ee3d3633f73b38d87333cff69f005db877d6b4cc2373471/r2x_core-0.5.0-py3-none-any.whl", hash = "sha256:ccda0430ef21f62bec807988dcd11eabe16bc797c8b437c919f07f40ac685427", size = 80251, upload-time = "2026-05-21T01:33:46.726Z" }, +] [[package]] name = "r2x-plexos" -version = "0.1.4" +version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "plexosdb" }, { name = "r2x-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/43/99172cfe9aaea9336843c1d0a5b5e2c17d5b44fbb14756547d4be0aed563/r2x_plexos-0.1.4.tar.gz", hash = "sha256:37df059b0c9d9fecd0b68078cfcb38e5fdd3d1350f7f364a8bf0955f329abfa5", size = 815201, upload-time = "2026-04-07T01:48:50.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/85/ee6e1ddd2c93f4e442cbeee1e0bd974eed5711d542eea6b7ca27125aeec3/r2x_plexos-0.1.5.tar.gz", hash = "sha256:86acac56bd97fecfa72c06b6fa3d175aa59f452aacbbbb3a3bfa837de48c78dd", size = 815141, upload-time = "2026-04-07T04:22:09.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/25/5268e5989754bd43860610dc78ec6c378325a28a779d04ecb2937e9ea696/r2x_plexos-0.1.4-py3-none-any.whl", hash = "sha256:405640c16b7037e2c97693062e10cbe2cfa94a8df9f4a50d72efc38f976bb273", size = 832160, upload-time = "2026-04-07T01:48:56.314Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/93786e8c786601dbdc7ff9a69e7ab0f2db19bdb9205443da68616478407d/r2x_plexos-0.1.5-py3-none-any.whl", hash = "sha256:890eb8ee13e1b79fb276c7696ad78a495acfd7d0965aa10bc60c5ddd196476e0", size = 831933, upload-time = "2026-04-07T04:22:08.381Z" }, ] [[package]] @@ -1224,14 +1230,14 @@ requires-dist = [ [[package]] name = "r2x-reeds" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "r2x-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/bd/1c0bbc0ef14380d56efac5d5a81628cbed050dc6d9a05262bbeb536ef7d8/r2x_reeds-0.4.0.tar.gz", hash = "sha256:98078cc7b35f8941ea9066305bf24a0cc6c8e4b2969bf5d9d09cb159bfa006b3", size = 59692, upload-time = "2026-03-18T15:16:29.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/ee/49761fe8a7718f5926ae88c032b6a3ed811326a6c570661f364eaec0e64d/r2x_reeds-0.5.0.tar.gz", hash = "sha256:ae3a73cfff9969222397d864f65b73c9ed85f0a8c1581b65186753170d63c9ab", size = 61971, upload-time = "2026-04-07T05:42:08.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/07/b932e6ba9fca1d2d9ebc3dc95df88957ac7eaafe6af2330313c871900d28/r2x_reeds-0.4.0-py3-none-any.whl", hash = "sha256:2d0b0de53f3ec783e6d1321a7ad6228feebae05d8263d645430ee0fcf719898d", size = 73340, upload-time = "2026-03-18T15:16:28.173Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/dbdaf1658786a7b1f24c91133261eed542443e36e9ea0589a7370cdcc238/r2x_reeds-0.5.0-py3-none-any.whl", hash = "sha256:bd762a898f0e7879c620d9bba0d778857fd7bba5a5df4fff66cc44e907c1dc73", size = 76451, upload-time = "2026-04-07T05:42:07.177Z" }, ] [[package]]