From b8c6e0c92dc89d4c24e061498982aca6a2e3f663 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:48:17 -0700 Subject: [PATCH 01/38] Update component base --- hercules/plant_components/component_base.py | 28 +++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 963fa3a4..f98a1880 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -1,6 +1,8 @@ # Base class for plant components in Hercules. +from typing import ClassVar + from hercules.utilities import setup_logging @@ -9,19 +11,41 @@ class ComponentBase: Provides common functionality for all Hercules plant components including logging setup, time step management, and shared configuration parameters. + + Subclasses must define the class attribute ``component_category`` (a short string + identifying the broad category, e.g. ``"battery"``, ``"wind_farm"``). The per-instance + ``component_name`` (the unique YAML key chosen by the user) is passed into ``__init__`` + and may differ from the category when multiple instances of the same type are present. + ``component_type`` is always set automatically to the concrete class name. """ + # Subclasses must override this with the appropriate category string. + component_category: ClassVar[str] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, "component_category"): + raise TypeError( + f"{cls.__name__} must define a class attribute 'component_category'" + ) + def __init__(self, h_dict, component_name): """Initialize the base component with a dictionary of parameters. Args: h_dict (dict): Dictionary containing simulation parameters. - component_name (str): Name of the component. + component_name (str): Unique name for this component instance (the YAML top-level + key). For single-instance plants this is typically the category name (e.g. + ``"battery"``); for multi-instance plants it may be any user-chosen string + (e.g. ``"battery_unit_1"``). """ - # Store the component name + # Store the component name (unique instance identifier from the YAML key) self.component_name = component_name + # Derive component_type from the concrete class name — no hardcoding needed + self.component_type = type(self).__name__ + # Set up logging # Check if log_file_name is defined in the h_dict[component_name] if "log_file_name" in h_dict[component_name]: From b1ac19ed945f89bc1c56a23f3f73809e3c0f8c90 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:48:45 -0700 Subject: [PATCH 02/38] update test --- tests/utilities_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utilities_test.py b/tests/utilities_test.py index 91e72465..b8b2c987 100644 --- a/tests/utilities_test.py +++ b/tests/utilities_test.py @@ -217,7 +217,7 @@ def test_load_hercules_input_invalid_component_type(): temp_file = f.name try: - with pytest.raises(ValueError, match="wind_farm has an invalid component_type"): + with pytest.raises(ValueError, match="wind_farm.*unrecognised component_type"): load_hercules_input(temp_file) finally: os.unlink(temp_file) From 66989bcc60bb7b3cc1fad03a994022046fa442e0 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:49:37 -0700 Subject: [PATCH 03/38] update tests --- tests/hybrid_plant_test.py | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/hybrid_plant_test.py b/tests/hybrid_plant_test.py index 8e1754b7..c395a172 100644 --- a/tests/hybrid_plant_test.py +++ b/tests/hybrid_plant_test.py @@ -115,3 +115,55 @@ def test_add_plant_metadata_to_h_dict(): assert "component_names" in result assert "generator_names" in result assert "n_components" in result + + +def test_component_category_attributes(): + """Test that component objects expose the correct component_category class attribute.""" + hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind_solar_battery)) + + assert hp.component_objects["wind_farm"].component_category == "wind_farm" + assert hp.component_objects["solar_farm"].component_category == "solar_farm" + assert hp.component_objects["battery"].component_category == "battery" + + +def test_component_type_auto_set(): + """Test that component_type is automatically derived from the class name.""" + hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind_solar_battery)) + + assert hp.component_objects["wind_farm"].component_type == "WindFarm" + assert hp.component_objects["solar_farm"].component_type == "SolarPySAMPVWatts" + assert hp.component_objects["battery"].component_type == "BatterySimple" + + +def test_multi_instance_batteries(): + """Test that two BatterySimple instances with unique names can coexist in one plant.""" + battery_cfg = { + "component_type": "BatterySimple", + "energy_capacity": 100.0, + "charge_rate": 50.0, + "discharge_rate": 50.0, + "max_SOC": 0.9, + "min_SOC": 0.1, + "log_channels": ["power"], + "initial_conditions": {"SOC": 0.5}, + } + multi_battery_h_dict = copy.deepcopy(h_dict_battery) + multi_battery_h_dict["battery_unit_2"] = copy.deepcopy(battery_cfg) + + hp = hybrid_plant.HybridPlant(multi_battery_h_dict) + + assert hp.n_components == 2 + assert "battery" in hp.component_names + assert "battery_unit_2" in hp.component_names + # Each instance carries its unique component_name + assert hp.component_objects["battery"].component_name == "battery" + assert hp.component_objects["battery_unit_2"].component_name == "battery_unit_2" + # Both have battery category → neither is a generator + assert len(hp.generator_names) == 0 + + +def test_custom_component_name_passed_through(): + """Test that the YAML key becomes the component_name on the instantiated object.""" + hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind)) + + assert hp.component_objects["wind_farm"].component_name == "wind_farm" From 71090d764b23c848e2ab4f56f2c84dc15cc4574a Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:50:16 -0700 Subject: [PATCH 04/38] update name/type --- hercules/plant_components/wind_farm.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py index 7e957c0e..dad33518 100644 --- a/hercules/plant_components/wind_farm.py +++ b/hercules/plant_components/wind_farm.py @@ -39,20 +39,21 @@ class WindFarm(ComponentBase): All three strategies support detailed turbine dynamics (filter_model or dof1_model). """ - def __init__(self, h_dict): + component_category = "wind_farm" + + def __init__(self, h_dict, component_name="wind_farm"): """Initialize the WindFarm class. Args: h_dict (dict): Dictionary containing simulation parameters. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"wind_farm"`` for backward compatibility. Raises: ValueError: If wake_method is invalid or required parameters are missing. """ - # Store the name of this component - self.component_name = "wind_farm" - - # Get the wake_method from h_dict - wake_method = h_dict[self.component_name].get("wake_method", "dynamic") + # Get the wake_method from h_dict (use parameter before super sets self.component_name) + wake_method = h_dict[component_name].get("wake_method", "dynamic") # Validate wake_method if wake_method not in ["dynamic", "precomputed", "no_added_wakes"]: @@ -63,12 +64,8 @@ def __init__(self, h_dict): self.wake_method = wake_method - # Store the type of this component (for backward compatibility) - component_type = h_dict[self.component_name].get("component_type", "WindFarm") - self.component_type = component_type - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) self.logger.info(f"Initializing WindFarm with wake_method='{self.wake_method}'") From 3c126a6cbcf1a4395eef98a85fa36ddb2de9781e Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:54:23 -0700 Subject: [PATCH 05/38] name type pattern --- .../plant_components/battery_lithium_ion.py | 16 +++--- hercules/plant_components/battery_simple.py | 16 +++--- .../plant_components/electrolyzer_plant.py | 16 +++--- .../open_cycle_gas_turbine.py | 55 +++++++++---------- hercules/plant_components/solar_pysam_base.py | 27 ++++----- .../plant_components/solar_pysam_pvwatts.py | 11 ++-- .../thermal_component_base.py | 11 ++-- .../plant_components/wind_farm_scada_power.py | 15 +++-- 8 files changed, 79 insertions(+), 88 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index dd1b2fb2..d3b2e5c3 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -87,7 +87,9 @@ class BatteryLithiumIon(ComponentBase): Nov. 2021, doi: 10.1016/j.est.2021.103252. """ - def __init__(self, h_dict): + component_category = "battery" + + def __init__(self, h_dict, component_name="battery"): """Initialize the BatteryLithiumIon class. This model represents a detailed lithium-ion battery with diffusion transients @@ -102,16 +104,12 @@ def __init__(self, h_dict): - min_SOC: Minimum state of charge (0-1) - initial_conditions: Dictionary with initial SOC - allow_grid_power_consumption: Optional, defaults to False + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"battery"`` for backward compatibility. """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatteryLithiumIon" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) self.V_cell_nom = 3.3 # [V] self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 8e77bf22..9df769c2 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -81,7 +81,9 @@ class BatterySimple(ComponentBase): All power units are in kW and energy units are in kWh. """ - def __init__(self, h_dict): + component_category = "battery" + + def __init__(self, h_dict, component_name="battery"): """Initialize the BatterySimple class. This model represents a simple battery with energy storage and power constraints. @@ -99,15 +101,11 @@ def __init__(self, h_dict): - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - self_discharge_time_constant: Optional self-discharge time constant - track_usage: Optional boolean to enable usage tracking + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"battery"`` for backward compatibility. """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatterySimple" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # size = h_dict[self.component_name]["size"] self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py index 3599be24..9ab4373f 100644 --- a/hercules/plant_components/electrolyzer_plant.py +++ b/hercules/plant_components/electrolyzer_plant.py @@ -14,7 +14,9 @@ class ElectrolyzerPlant(ComponentBase): The Electrolyzer plant uses the electrolyzer model from https://github.com/NREL/electrolyzer """ - def __init__(self, h_dict): + component_category = "electrolyzer" + + def __init__(self, h_dict, component_name="electrolyzer"): """Initialize the ElectrolyzerPlant class. Args: @@ -87,16 +89,12 @@ def __init__(self, h_dict): - finances: Financial parameters including: - discount_rate: Discount rate for financial calculations [%]. - install_factor: Installation factor for capital expenditure [0,1]. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"electrolyzer"`` for backward compatibility. """ - # Store the name of this component - self.component_name = "electrolyzer" - - # Store the type of this component - self.component_type = "ElectrolyzerPlant" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) electrolyzer_dict = {} # Check if general key exists in electrolyzer section, otherwise use top-level general diff --git a/hercules/plant_components/open_cycle_gas_turbine.py b/hercules/plant_components/open_cycle_gas_turbine.py index 6b5580b8..a5d84780 100644 --- a/hercules/plant_components/open_cycle_gas_turbine.py +++ b/hercules/plant_components/open_cycle_gas_turbine.py @@ -46,10 +46,7 @@ class OpenCycleGasTurbine(ThermalComponentBase): All efficiency values are HHV (Higher Heating Value) net plant efficiencies. """ - component_name = "open_cycle_gas_turbine" - component_type = "OpenCycleGasTurbine" - - def __init__(self, h_dict): + def __init__(self, h_dict, component_name="open_cycle_gas_turbine"): """Initialize the OpenCycleGasTurbine class. Args: @@ -86,46 +83,48 @@ def __init__(self, h_dict): readings from the SC1A curve in Exhibit ES-4 of [5]: power_fraction = [1.0, 0.75, 0.50, 0.25], efficiency = [0.39, 0.37, 0.325, 0.245]. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"open_cycle_gas_turbine"`` for backward compatibility. """ # Apply fixed default parameters based on [1], [2] and [3] # back into the h_dict if they are not provided - if "min_stable_load_fraction" not in h_dict[self.component_name]: - h_dict[self.component_name]["min_stable_load_fraction"] = 0.40 - if "ramp_rate_fraction" not in h_dict[self.component_name]: - h_dict[self.component_name]["ramp_rate_fraction"] = 0.1 - if "hot_startup_time" not in h_dict[self.component_name]: - h_dict[self.component_name]["hot_startup_time"] = 420.0 - if "warm_startup_time" not in h_dict[self.component_name]: - h_dict[self.component_name]["warm_startup_time"] = 480.0 - if "cold_startup_time" not in h_dict[self.component_name]: - h_dict[self.component_name]["cold_startup_time"] = 480.0 - if "min_up_time" not in h_dict[self.component_name]: - h_dict[self.component_name]["min_up_time"] = 1800.0 - if "min_down_time" not in h_dict[self.component_name]: - h_dict[self.component_name]["min_down_time"] = 3600.0 + if "min_stable_load_fraction" not in h_dict[component_name]: + h_dict[component_name]["min_stable_load_fraction"] = 0.40 + if "ramp_rate_fraction" not in h_dict[component_name]: + h_dict[component_name]["ramp_rate_fraction"] = 0.1 + if "hot_startup_time" not in h_dict[component_name]: + h_dict[component_name]["hot_startup_time"] = 420.0 + if "warm_startup_time" not in h_dict[component_name]: + h_dict[component_name]["warm_startup_time"] = 480.0 + if "cold_startup_time" not in h_dict[component_name]: + h_dict[component_name]["cold_startup_time"] = 480.0 + if "min_up_time" not in h_dict[component_name]: + h_dict[component_name]["min_up_time"] = 1800.0 + if "min_down_time" not in h_dict[component_name]: + h_dict[component_name]["min_down_time"] = 3600.0 # If the run_up_rate_fraction is not provided, it defaults to the ramp_rate_fraction - if "run_up_rate_fraction" not in h_dict[self.component_name]: - h_dict[self.component_name]["run_up_rate_fraction"] = h_dict[self.component_name][ + if "run_up_rate_fraction" not in h_dict[component_name]: + h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][ "ramp_rate_fraction" ] # Default HHV for natural gas (39.05 MJ/m³) from [6] - if "hhv" not in h_dict[self.component_name]: - h_dict[self.component_name]["hhv"] = 39050000 # J/m³ (39.05 MJ/m³) + if "hhv" not in h_dict[component_name]: + h_dict[component_name]["hhv"] = 39050000 # J/m³ (39.05 MJ/m³) # Default fuel density for natural gas (0.768 kg/m³) from [6] - if "fuel_density" not in h_dict[self.component_name]: - h_dict[self.component_name]["fuel_density"] = 0.768 # kg/m³ + if "fuel_density" not in h_dict[component_name]: + h_dict[component_name]["fuel_density"] = 0.768 # kg/m³ # Default HHV net plant efficiency table based on approximate readings from # the SC1A curve in Exhibit ES-4 of [5] - if "efficiency_table" not in h_dict[self.component_name]: - h_dict[self.component_name]["efficiency_table"] = { + if "efficiency_table" not in h_dict[component_name]: + h_dict[component_name]["efficiency_table"] = { "power_fraction": [1.0, 0.75, 0.50, 0.25], "efficiency": [0.39, 0.37, 0.325, 0.245], } - # Call the base class init - super().__init__(h_dict) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 88b62524..95699e37 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -18,17 +18,18 @@ class SolarPySAMBase(ComponentBase): Note PVSam is no longer supported in Hercules. """ - def __init__(self, h_dict): + component_category = "solar_farm" + + def __init__(self, h_dict, component_name="solar_farm"): """Initialize the base solar PySAM simulator. Args: h_dict (dict): Dictionary containing simulation parameters. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"solar_farm"`` for backward compatibility. """ - # Store the name of this component - self.component_name = "solar_farm" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # Load and process solar data self._load_solar_data(h_dict) @@ -168,16 +169,16 @@ def get_initial_conditions_and_meta_data(self, h_dict): dict: Dictionary containing simulation parameters with initial conditions and meta data. """ # This is a bit of a hack but need this to exist - h_dict["solar_farm"]["capacity"] = self.system_capacity - h_dict["solar_farm"]["power"] = self.power - h_dict["solar_farm"]["dc_power"] = self.dc_power - h_dict["solar_farm"]["dni"] = self.dni - h_dict["solar_farm"]["poa"] = self.poa - h_dict["solar_farm"]["aoi"] = self.aoi + h_dict[self.component_name]["capacity"] = self.system_capacity + h_dict[self.component_name]["power"] = self.power + h_dict[self.component_name]["dc_power"] = self.dc_power + h_dict[self.component_name]["dni"] = self.dni + h_dict[self.component_name]["poa"] = self.poa + h_dict[self.component_name]["aoi"] = self.aoi # Log the start time UTC if available if hasattr(self, "starttime_utc"): - h_dict["solar_farm"]["starttime_utc"] = self.starttime_utc + h_dict[self.component_name]["starttime_utc"] = self.starttime_utc return h_dict diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 1ad60444..6f8d7452 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -9,17 +9,16 @@ class SolarPySAMPVWatts(SolarPySAMBase): """Solar simulator using PySAM's simplified PV model (Pvwattsv8).""" - def __init__(self, h_dict): + def __init__(self, h_dict, component_name="solar_farm"): """Initialize the PVWatts solar simulator. Args: h_dict (dict): Dictionary containing simulation parameters. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"solar_farm"`` for backward compatibility. """ - # Store the type of this component - self.component_type = "SolarPySAMPVWatts" - - # Call the base class init - super().__init__(h_dict) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # Set up PV system model parameters self._setup_model_parameters(h_dict) diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 3553b03c..1d589f65 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -62,9 +62,7 @@ class ThermalComponentBase(ComponentBase): """ - # Default component name and type; subclasses override these as class attributes - component_name = "thermal_component" - component_type = "ThermalComponentBase" + component_category = "thermal" class STATES(IntEnum): """Enumeration of thermal component operating states.""" @@ -82,7 +80,7 @@ class STATES(IntEnum): HOT_START_TIME = 8 * 60 * 60 # 8 hours (less than 8 hours triggers a hot start) WARM_START_TIME = 48 * 60 * 60 # 48 hours (less than 48 hours triggers a warm start) - def __init__(self, h_dict): + def __init__(self, h_dict, component_name="thermal_component"): """Initialize the ThermalComponentBase class. Args: @@ -108,10 +106,11 @@ def __init__(self, h_dict): - efficiency_table: Dictionary with power_fraction and efficiency arrays (both as fractions 0-1). Efficiency values must be HHV net plant efficiencies. + component_name (str): Unique name for this instance (the YAML top-level key). """ - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # Extract parameters from the h_dict component_dict = h_dict[self.component_name] diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py index 4651d3a6..69c2414f 100644 --- a/hercules/plant_components/wind_farm_scada_power.py +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -12,19 +12,18 @@ class WindFarmSCADAPower(ComponentBase): """Wind farm model that uses SCADA power data to simulate wind farm performance.""" - def __init__(self, h_dict): + component_category = "wind_farm" + + def __init__(self, h_dict, component_name="wind_farm"): """Initialize the WindFarm class. Args: h_dict (dict): Dictionary containing simulation parameters. + component_name (str): Unique name for this instance (the YAML top-level key). + Defaults to ``"wind_farm"`` for backward compatibility. """ - # Store the name of this component - self.component_name = "wind_farm" - - self.component_type = "WindFarmSCADAPower" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) self.logger.info("Initializing WindFarmSCADAPower") From 2967e0af37e5cbdef65975f41fbb0889aab0afa2 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:55:55 -0700 Subject: [PATCH 06/38] redo around name/type/cat --- hercules/hybrid_plant.py | 87 ++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 439f85c4..c21e0ba9 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -7,7 +7,21 @@ from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts from hercules.plant_components.wind_farm import WindFarm from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower -from hercules.utilities import get_available_component_names, get_available_generator_names + +# Registry mapping component_type strings (class names) to their classes. +# Add new component types here to make them discoverable by HybridPlant. +_COMPONENT_REGISTRY = { + "WindFarm": WindFarm, + "WindFarmSCADAPower": WindFarmSCADAPower, + "SolarPySAMPVWatts": SolarPySAMPVWatts, + "BatterySimple": BatterySimple, + "BatteryLithiumIon": BatteryLithiumIon, + "ElectrolyzerPlant": ElectrolyzerPlant, + "OpenCycleGasTurbine": OpenCycleGasTurbine, +} + +# component_category values that represent generators (vs. storage/conversion) +_GENERATOR_CATEGORIES = {"wind_farm", "solar_farm", "thermal"} class HybridPlant: @@ -29,20 +43,14 @@ def __init__(self, h_dict): Raises: Exception: If no plant components are found in the input dictionary. """ - # get a list of possible component names - all_component_names = get_available_component_names() - - # get a list of possible generator names - all_generator_names = get_available_generator_names() - - # Make a list of component names that are in the h_dict + # Discover components: any top-level h_dict entry whose value is a dict + # containing a "component_type" key is treated as a plant component. + # This allows user-chosen instance names (e.g. "battery_unit_1") while + # remaining backward compatible with conventional names (e.g. "battery"). self.component_names = [ - component_name for component_name in all_component_names if component_name in h_dict - ] - - # Make a list of generator names that are in the h_dict - self.generator_names = [ - generator_name for generator_name in all_generator_names if generator_name in h_dict + key + for key, val in h_dict.items() + if isinstance(val, dict) and "component_type" in val ] # Add in the number of components @@ -59,6 +67,13 @@ def __init__(self, h_dict): component_name, h_dict ) + # Determine generator names from component_category after instantiation + self.generator_names = [ + name + for name, obj in self.component_objects.items() + if obj.component_category in _GENERATOR_CATEGORIES + ] + def add_plant_metadata_to_h_dict(self, h_dict): """Add plant component metadata to the h_dict. @@ -98,31 +113,13 @@ def get_plant_component(self, component_name, h_dict): """ component_type = h_dict[component_name]["component_type"] - # Handle wind farm component types with unified WindFarm class - if component_type == "WindFarm": - return WindFarm(h_dict) - - if component_type == "WindFarmSCADAPower": - return WindFarmSCADAPower(h_dict) - - if component_type == "SolarPySAMPVWatts": - return SolarPySAMPVWatts(h_dict) - - if component_type == "BatteryLithiumIon": - return BatteryLithiumIon(h_dict) - - if component_type == "BatterySimple": - return BatterySimple(h_dict) - - if component_type == "ElectrolyzerPlant": - return ElectrolyzerPlant(h_dict) - - if component_type == "OpenCycleGasTurbine": - return OpenCycleGasTurbine(h_dict) - - raise Exception( - f"Unknown component_type '{component_type}' for component '{component_name}'" - ) + cls = _COMPONENT_REGISTRY.get(component_type) + if cls is None: + raise ValueError( + f"Unknown component_type '{component_type}' for component '{component_name}'. " + f"Available types: {sorted(_COMPONENT_REGISTRY)}" + ) + return cls(h_dict, component_name) def step(self, h_dict): """Execute one simulation step for all plant components. @@ -135,16 +132,18 @@ def step(self, h_dict): """ # Collect the component objects for component_name in self.component_names: - # If component_name is battery, invert the sign of the power_setpoint - if component_name == "battery": + is_battery = ( + self.component_objects[component_name].component_category == "battery" + ) + + # Battery sign convention: negate setpoint before step, restore after + if is_battery: h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"] # Update h_dict by calling the step method of each component object h_dict = self.component_objects[component_name].step(h_dict) - # If component_name is battery, invert the sign of the power_setpoint back - # And invert the sign of the power output - if component_name == "battery": + if is_battery: h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"] h_dict[component_name]["power"] = -h_dict[component_name]["power"] From ccc843815a216716ae487b821ac962c14c90f41b Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 13:56:17 -0700 Subject: [PATCH 07/38] remove unsused functions --- hercules/utilities.py | 101 ++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 67 deletions(-) diff --git a/hercules/utilities.py b/hercules/utilities.py index e6a340f0..a963ead9 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -16,53 +16,17 @@ hercules_complex_type = np.csingle -def get_available_component_names(): - """Return available component names. - - Returns: - list: Available plant component names. - """ - return [ - "wind_farm", - "solar_farm", - "battery", - "electrolyzer", - "open_cycle_gas_turbine", - ] - - -def get_available_generator_names(): - """Return available generator component names. - - Returns power generators (wind_farm, solar_farm, open_cycle_gas_turbine), excluding - storage and conversion components. - - Returns: - list: Available generator component names. - """ - return [ - "wind_farm", - "solar_farm", - "open_cycle_gas_turbine", - ] - - -def get_available_component_types(): - """Return available component types by component. - - Returns: - dict: Component names mapped to available simulation types. - """ - return { - "wind_farm": [ - "WindFarm", - "WindFarmSCADAPower", - ], - "solar_farm": ["SolarPySAMPVWatts"], - "battery": ["BatterySimple", "BatteryLithiumIon"], - "electrolyzer": ["ElectrolyzerPlant"], - "open_cycle_gas_turbine": ["OpenCycleGasTurbine"], - } +# All component_type strings (class names) that can appear in a YAML component_type field. +# Keep this in sync with hybrid_plant._COMPONENT_REGISTRY. +_VALID_COMPONENT_TYPES = [ + "WindFarm", + "WindFarmSCADAPower", + "SolarPySAMPVWatts", + "BatterySimple", + "BatteryLithiumIon", + "ElectrolyzerPlant", + "OpenCycleGasTurbine", +] class Loader(yaml.SafeLoader): @@ -260,8 +224,7 @@ def load_hercules_input(filename): # Define valid keys required_keys = ["dt", "starttime_utc", "endtime_utc", "plant"] - component_names = get_available_component_names() - component_types = get_available_component_types() + valid_component_types = _VALID_COMPONENT_TYPES other_keys = [ "name", "description", @@ -275,6 +238,11 @@ def load_hercules_input(filename): "output_buffer_size", ] + # Discover component entries: any top-level dict entry containing "component_type" + component_names = [ + key for key, val in h_dict.items() if isinstance(val, dict) and "component_type" in val + ] + # Validate required keys for key in required_keys: if key not in h_dict: @@ -310,20 +278,23 @@ def load_hercules_input(filename): if not isinstance(h_dict["plant"]["interconnect_limit"], (float, int)): raise ValueError(f"Interconnect limit must be a float in input file {filename}") - # Validate all keys are valid + # Validate all keys are valid: required, known other keys, or a discovered component entry for key in h_dict: if key not in required_keys + component_names + other_keys: - raise ValueError(f'Key "{key}" not a valid key in input file {filename}') + raise ValueError( + f'Key "{key}" is not a recognised key in input file {filename}. ' + "If this is a plant component, ensure it contains a 'component_type' field." + ) # Disallow pre-defined start/end; derive from UTC + dt policy if ("starttime" in h_dict) or ("endtime" in h_dict): raise ValueError("starttime/endtime must not be provided; they are derived from *_utc") - # Validate component structures + # Validate component structures (component_names entries already have component_type, + # but we still check each is a dict for safety) for key in component_names: - if key in h_dict: - if not isinstance(h_dict[key], dict): - raise ValueError(f"{key} must be a dictionary in input file {filename}") + if not isinstance(h_dict[key], dict): + raise ValueError(f"{key} must be a dictionary in input file {filename}") # Set verbose default and validate if "verbose" not in h_dict: @@ -338,21 +309,17 @@ def load_hercules_input(filename): # Validate no components have verbose key for key in component_names: - if key in h_dict and "verbose" in h_dict[key]: + if "verbose" in h_dict[key]: raise ValueError(f"{key} cannot include a verbose key in input file {filename}") - # Validate component types + # Validate component types (component_type presence already guaranteed by discovery) for key in component_names: - if key in h_dict: - if "component_type" not in h_dict[key]: - raise ValueError( - f"{key} must include a component_type key in input file {filename}" - ) - if h_dict[key]["component_type"] not in component_types[key]: - raise ValueError( - f"{key} has an invalid component_type {h_dict[key]['component_type']} " - f"in input file {filename}" - ) + if h_dict[key]["component_type"] not in valid_component_types: + raise ValueError( + f'"{key}" has an unrecognised component_type ' + f'"{h_dict[key]["component_type"]}" in input file {filename}. ' + f"Available types: {sorted(valid_component_types)}" + ) # Handle external_data structure normalization From 398e4f4413fcf83910a32f83d491c2a8c30930e3 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:05:00 -0700 Subject: [PATCH 08/38] linting --- hercules/hybrid_plant.py | 8 ++------ hercules/plant_components/component_base.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index c21e0ba9..d4a1a419 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -48,9 +48,7 @@ def __init__(self, h_dict): # This allows user-chosen instance names (e.g. "battery_unit_1") while # remaining backward compatible with conventional names (e.g. "battery"). self.component_names = [ - key - for key, val in h_dict.items() - if isinstance(val, dict) and "component_type" in val + key for key, val in h_dict.items() if isinstance(val, dict) and "component_type" in val ] # Add in the number of components @@ -132,9 +130,7 @@ def step(self, h_dict): """ # Collect the component objects for component_name in self.component_names: - is_battery = ( - self.component_objects[component_name].component_category == "battery" - ) + is_battery = self.component_objects[component_name].component_category == "battery" # Battery sign convention: negate setpoint before step, restore after if is_battery: diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index f98a1880..5c288bb0 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -25,9 +25,7 @@ class ComponentBase: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if not hasattr(cls, "component_category"): - raise TypeError( - f"{cls.__name__} must define a class attribute 'component_category'" - ) + raise TypeError(f"{cls.__name__} must define a class attribute 'component_category'") def __init__(self, h_dict, component_name): """Initialize the base component with a dictionary of parameters. From 8a4d406802649ceaa0c3e78c7adcde8ee1472e3e Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:08:45 -0700 Subject: [PATCH 09/38] remove defaults --- hercules/plant_components/battery_lithium_ion.py | 2 +- hercules/plant_components/battery_simple.py | 2 +- hercules/plant_components/electrolyzer_plant.py | 2 +- hercules/plant_components/open_cycle_gas_turbine.py | 2 +- hercules/plant_components/solar_pysam_base.py | 2 +- hercules/plant_components/solar_pysam_pvwatts.py | 2 +- hercules/plant_components/thermal_component_base.py | 2 +- hercules/plant_components/wind_farm.py | 2 +- hercules/plant_components/wind_farm_scada_power.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index d3b2e5c3..5a0f6b7d 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -89,7 +89,7 @@ class BatteryLithiumIon(ComponentBase): component_category = "battery" - def __init__(self, h_dict, component_name="battery"): + def __init__(self, h_dict, component_name): """Initialize the BatteryLithiumIon class. This model represents a detailed lithium-ion battery with diffusion transients diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 9df769c2..aa136838 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -83,7 +83,7 @@ class BatterySimple(ComponentBase): component_category = "battery" - def __init__(self, h_dict, component_name="battery"): + def __init__(self, h_dict, component_name): """Initialize the BatterySimple class. This model represents a simple battery with energy storage and power constraints. diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py index 9ab4373f..ada7e5bc 100644 --- a/hercules/plant_components/electrolyzer_plant.py +++ b/hercules/plant_components/electrolyzer_plant.py @@ -16,7 +16,7 @@ class ElectrolyzerPlant(ComponentBase): component_category = "electrolyzer" - def __init__(self, h_dict, component_name="electrolyzer"): + def __init__(self, h_dict, component_name): """Initialize the ElectrolyzerPlant class. Args: diff --git a/hercules/plant_components/open_cycle_gas_turbine.py b/hercules/plant_components/open_cycle_gas_turbine.py index a5d84780..15d4d347 100644 --- a/hercules/plant_components/open_cycle_gas_turbine.py +++ b/hercules/plant_components/open_cycle_gas_turbine.py @@ -46,7 +46,7 @@ class OpenCycleGasTurbine(ThermalComponentBase): All efficiency values are HHV (Higher Heating Value) net plant efficiencies. """ - def __init__(self, h_dict, component_name="open_cycle_gas_turbine"): + def __init__(self, h_dict, component_name): """Initialize the OpenCycleGasTurbine class. Args: diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 95699e37..96b9e833 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -20,7 +20,7 @@ class SolarPySAMBase(ComponentBase): component_category = "solar_farm" - def __init__(self, h_dict, component_name="solar_farm"): + def __init__(self, h_dict, component_name): """Initialize the base solar PySAM simulator. Args: diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 6f8d7452..517aae19 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -9,7 +9,7 @@ class SolarPySAMPVWatts(SolarPySAMBase): """Solar simulator using PySAM's simplified PV model (Pvwattsv8).""" - def __init__(self, h_dict, component_name="solar_farm"): + def __init__(self, h_dict, component_name): """Initialize the PVWatts solar simulator. Args: diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 1d589f65..75cff58c 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -80,7 +80,7 @@ class STATES(IntEnum): HOT_START_TIME = 8 * 60 * 60 # 8 hours (less than 8 hours triggers a hot start) WARM_START_TIME = 48 * 60 * 60 # 48 hours (less than 48 hours triggers a warm start) - def __init__(self, h_dict, component_name="thermal_component"): + def __init__(self, h_dict, component_name): """Initialize the ThermalComponentBase class. Args: diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py index dad33518..10e50c29 100644 --- a/hercules/plant_components/wind_farm.py +++ b/hercules/plant_components/wind_farm.py @@ -41,7 +41,7 @@ class WindFarm(ComponentBase): component_category = "wind_farm" - def __init__(self, h_dict, component_name="wind_farm"): + def __init__(self, h_dict, component_name): """Initialize the WindFarm class. Args: diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py index 69c2414f..b249fc11 100644 --- a/hercules/plant_components/wind_farm_scada_power.py +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -14,7 +14,7 @@ class WindFarmSCADAPower(ComponentBase): component_category = "wind_farm" - def __init__(self, h_dict, component_name="wind_farm"): + def __init__(self, h_dict, component_name): """Initialize the WindFarm class. Args: From 49628b8a78a1c55832720057966a863f83f5baf8 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:18:06 -0700 Subject: [PATCH 10/38] Add names to tests --- tests/battery_simple_test.py | 26 +++++----- tests/electrolyzer_plant_test.py | 12 ++--- tests/open_cycle_gas_turbine_test.py | 20 ++++---- .../battery_regression_test.py | 6 +-- .../electrolyzer_plant_regression_test.py | 2 +- .../solar_pysam_pvwatts_regression_test.py | 2 +- tests/solar_pysam_pvwatts_test.py | 8 ++-- tests/thermal_component_base_test.py | 48 +++++++++---------- tests/wind_farm_direct_test.py | 16 +++---- tests/wind_farm_dynamic_floris_test.py | 24 +++++----- tests/wind_farm_precom_floris_test.py | 24 +++++----- tests/wind_farm_scada_power_test.py | 34 ++++++------- 12 files changed, 111 insertions(+), 111 deletions(-) diff --git a/tests/battery_simple_test.py b/tests/battery_simple_test.py index b4318d70..6612c47d 100644 --- a/tests/battery_simple_test.py +++ b/tests/battery_simple_test.py @@ -11,12 +11,12 @@ def create_simple_battery(): test_h_dict = copy.deepcopy(h_dict_simple_battery) - return BatterySimple(test_h_dict) + return BatterySimple(test_h_dict, "battery") def create_LIB(): test_h_dict = copy.deepcopy(h_dict_lib_battery) - return BatteryLithiumIon(test_h_dict) + return BatteryLithiumIon(test_h_dict, "battery") @pytest.fixture @@ -40,7 +40,7 @@ def step_inputs(P_avail, P_signal): def test_SB_init(): test_h_dict = copy.deepcopy(h_dict_simple_battery) - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") assert SB.dt == test_h_dict["dt"] assert SB.SOC == test_h_dict["battery"]["initial_conditions"]["SOC"] @@ -67,7 +67,7 @@ def test_SB_init(): test_h_dict2["battery"]["usage_calc_interval"] = 100 test_h_dict2["battery"]["usage_lifetime"] = 0.1 test_h_dict2["battery"]["usage_cycles"] = 10 - SB = BatterySimple(test_h_dict2) + SB = BatterySimple(test_h_dict2, "battery") assert SB.eta_charge == np.sqrt(0.9) assert SB.eta_discharge == np.sqrt(0.9) assert SB.tau_self_discharge == 100 @@ -121,7 +121,7 @@ def test_SB_step(SB: BatterySimple): def test_LI_init(): """Test init""" test_h_dict = copy.deepcopy(h_dict_lib_battery) - LI = BatteryLithiumIon(test_h_dict) + LI = BatteryLithiumIon(test_h_dict, "battery") assert LI.dt == test_h_dict["dt"] assert LI.SOC == test_h_dict["battery"]["initial_conditions"]["SOC"] assert LI.SOC_min == test_h_dict["battery"]["min_SOC"] @@ -134,7 +134,7 @@ def test_LI_init(): def test_LI_post_init(): test_h_dict = copy.deepcopy(h_dict_lib_battery) - LI = BatteryLithiumIon(test_h_dict) + LI = BatteryLithiumIon(test_h_dict, "battery") assert LI.SOH == 1 assert LI.T == 25 assert LI.x == 0 @@ -267,7 +267,7 @@ def test_allow_grid_power_consumption(SB: BatterySimple): # Test with allow_grid_power_consumption = True test_h_dict = copy.deepcopy(h_dict_simple_battery) test_h_dict["battery"]["allow_grid_power_consumption"] = True - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") # Ask exceeds rated power out = SB.step(step_inputs(P_avail=3e3, P_signal=2.5e3)) @@ -275,7 +275,7 @@ def test_allow_grid_power_consumption(SB: BatterySimple): assert out["battery"]["reject"] == 0.5e3 test_h_dict["battery"]["allow_grid_power_consumption"] = False - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") out = SB.step(step_inputs(P_avail=3e3, P_signal=2.5e3)) assert out["battery"]["power"] == 2e3 @@ -287,13 +287,13 @@ def test_allow_grid_power_consumption(SB: BatterySimple): # Ask is under rated power test_h_dict["battery"]["allow_grid_power_consumption"] = True - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") out = SB.step(step_inputs(P_avail=0.25e3, P_signal=1e3)) assert out["battery"]["power"] == 1e3 # Ignores P_avail, as expected assert out["battery"]["reject"] == 0 test_h_dict["battery"]["allow_grid_power_consumption"] = False - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") out = SB.step(step_inputs(P_avail=0.25e3, P_signal=1e3)) assert out["battery"]["power"] == 0.25e3 # Uses P_avail assert out["battery"]["reject"] == 0.75e3 # "Rejects" the rest of the signal ask @@ -310,7 +310,7 @@ def test_SB_roundtrip_efficiency(): test_h_dict["battery"]["roundtrip_efficiency"] = 0.9 test_h_dict["battery"]["allow_grid_power_consumption"] = True test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") # Verify efficiency parameters are set correctly assert_almost_equal(SB.eta_charge, np.sqrt(0.9), 6) @@ -371,7 +371,7 @@ def test_SB_roundtrip_efficiency_perfect(): test_h_dict["battery"]["roundtrip_efficiency"] = 1.0 test_h_dict["battery"]["allow_grid_power_consumption"] = True test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") # Verify perfect efficiency assert SB.eta_charge == 1.0 @@ -406,7 +406,7 @@ def test_SB_roundtrip_efficiency_various_values(): test_h_dict["battery"]["roundtrip_efficiency"] = rte test_h_dict["battery"]["allow_grid_power_consumption"] = True test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC - SB = BatterySimple(test_h_dict) + SB = BatterySimple(test_h_dict, "battery") # Small charge-discharge cycle to avoid SOC limits initial_energy = SB.current_batt_state diff --git a/tests/electrolyzer_plant_test.py b/tests/electrolyzer_plant_test.py index 634e4f2e..aa1b524a 100644 --- a/tests/electrolyzer_plant_test.py +++ b/tests/electrolyzer_plant_test.py @@ -8,7 +8,7 @@ def test_allow_grid_power_consumption(): # Test with allow_grid_power_consumption = False test_h_dict = copy.deepcopy(h_dict_electrolyzer) - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs = { "plant": { @@ -25,7 +25,7 @@ def test_allow_grid_power_consumption(): # Match locally available power test_h_dict = copy.deepcopy(h_dict_electrolyzer) - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs["electrolyzer"]["electrolyzer_signal"] = 3000 for _ in range(100): # Run 100 steps out = electrolyzer.step(step_inputs) @@ -35,7 +35,7 @@ def test_allow_grid_power_consumption(): # Ask exceeds locally available power test_h_dict = copy.deepcopy(h_dict_electrolyzer) - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs["electrolyzer"]["electrolyzer_signal"] = 4000 for _ in range(100): # Run 100 steps out = electrolyzer.step(step_inputs) @@ -45,7 +45,7 @@ def test_allow_grid_power_consumption(): # Now, allow grid charging and repeat tests test_h_dict = copy.deepcopy(h_dict_electrolyzer) test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs["electrolyzer"]["electrolyzer_signal"] = 2000 for _ in range(100): # Run 100 steps @@ -54,7 +54,7 @@ def test_allow_grid_power_consumption(): test_h_dict = copy.deepcopy(h_dict_electrolyzer) test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs["electrolyzer"]["electrolyzer_signal"] = 3000 for _ in range(100): # Run 100 steps out = electrolyzer.step(step_inputs) @@ -63,7 +63,7 @@ def test_allow_grid_power_consumption(): test_h_dict = copy.deepcopy(h_dict_electrolyzer) test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") step_inputs["electrolyzer"]["electrolyzer_signal"] = 4000 for _ in range(100): # Run 100 steps out = electrolyzer.step(step_inputs) diff --git a/tests/open_cycle_gas_turbine_test.py b/tests/open_cycle_gas_turbine_test.py index 1c2e8a76..cbaa25cc 100644 --- a/tests/open_cycle_gas_turbine_test.py +++ b/tests/open_cycle_gas_turbine_test.py @@ -11,7 +11,7 @@ def test_init_from_dict(): """Test that OpenCycleGasTurbine can be initialized from a dictionary.""" - ocgt = OpenCycleGasTurbine(copy.deepcopy(h_dict_open_cycle_gas_turbine)) + ocgt = OpenCycleGasTurbine(copy.deepcopy(h_dict_open_cycle_gas_turbine), "open_cycle_gas_turbine") assert ocgt is not None @@ -20,7 +20,7 @@ def test_default_inputs(): h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) # Test that the ramp_rate_fraction is 0.5 (from test fixture) - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.ramp_rate_fraction == 0.5 # Test that the run_up_rate_fraction is 0.2 (from test fixture) @@ -30,7 +30,7 @@ def test_default_inputs(): # it defaults to the ramp_rate_fraction h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.run_up_rate_fraction == ocgt.ramp_rate_fraction # Now test that the default value of the ramp_rate_fraction is @@ -39,7 +39,7 @@ def test_default_inputs(): h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["ramp_rate_fraction"] del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.ramp_rate_fraction == 0.1 assert ocgt.run_up_rate_fraction == 0.1 @@ -53,7 +53,7 @@ def test_default_inputs(): del h_dict["open_cycle_gas_turbine"]["warm_startup_time"] del h_dict["open_cycle_gas_turbine"]["hot_startup_time"] del h_dict["open_cycle_gas_turbine"]["min_stable_load_fraction"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.min_stable_load_fraction == 0.40 assert ocgt.hot_startup_time == 7 * 60.0 assert ocgt.warm_startup_time == 8 * 60.0 @@ -61,12 +61,12 @@ def test_default_inputs(): h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["min_up_time"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.min_up_time == 30 * 60.0 h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["min_down_time"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") assert ocgt.min_down_time == 60 * 60.0 @@ -74,7 +74,7 @@ def test_default_hhv(): """Test that OpenCycleGasTurbine provides default HHV for natural gas from [6].""" h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["hhv"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") # Default HHV for natural gas is 39.05 MJ/m³ = 39,050,000 J/m³ from [6] assert ocgt.hhv == 39050000 @@ -84,7 +84,7 @@ def test_default_fuel_density(): h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) if "fuel_density" in h_dict["open_cycle_gas_turbine"]: del h_dict["open_cycle_gas_turbine"]["fuel_density"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") # Default fuel density for natural gas is 0.768 kg/m³ from [6] assert ocgt.fuel_density == 0.768 @@ -97,7 +97,7 @@ def test_default_efficiency_table(): """ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) del h_dict["open_cycle_gas_turbine"]["efficiency_table"] - ocgt = OpenCycleGasTurbine(h_dict) + ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine") # Default HHV net plant efficiency from SC1A curve in Exhibit ES-4 of [5] np.testing.assert_array_equal( ocgt.efficiency_power_fraction, diff --git a/tests/regression_tests/battery_regression_test.py b/tests/regression_tests/battery_regression_test.py index 46005039..6c2a467a 100644 --- a/tests/regression_tests/battery_regression_test.py +++ b/tests/regression_tests/battery_regression_test.py @@ -130,7 +130,7 @@ def test_SimpleBattery_regression_(): - battery = BatterySimple(test_h_dict) + battery = BatterySimple(test_h_dict, "battery") times_test = np.arange(0, 5.5, test_h_dict["dt"]) powers_test = np.zeros_like(times_test) @@ -162,7 +162,7 @@ def test_SimpleBattery_regression_(): def test_LIB_regression_(): - battery = BatteryLithiumIon(test_h_dict) + battery = BatteryLithiumIon(test_h_dict, "battery") times_test = np.arange(0, 5.5, test_h_dict["dt"]) powers_test = np.zeros_like(times_test) @@ -210,7 +210,7 @@ def test_SimpleBattery_usage_calc_regression(): battery_dict["battery"]["usage_cycles"] = 5 battery_dict["battery"]["initial_conditions"] = {"SOC": 0.23} - SB = BatterySimple(battery_dict) + SB = BatterySimple(battery_dict, "battery") power_avail = 10e3 * np.ones(21) power_signal = [ diff --git a/tests/regression_tests/electrolyzer_plant_regression_test.py b/tests/regression_tests/electrolyzer_plant_regression_test.py index 2b6ee2e0..49d37f7e 100644 --- a/tests/regression_tests/electrolyzer_plant_regression_test.py +++ b/tests/regression_tests/electrolyzer_plant_regression_test.py @@ -172,7 +172,7 @@ def test_ElectrolyzerPlant_regression_(): - electrolyzer = ElectrolyzerPlant(test_h_dict) + electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer") times_test = np.arange(0, 12.0, test_h_dict["dt"]) H2_output_test = np.zeros_like(times_test) diff --git a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py index f7edcf4b..19c2c966 100644 --- a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py +++ b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py @@ -109,7 +109,7 @@ def get_solar_params(): def test_SolarPySAM_regression_control(): solar_dict = get_solar_params() - SPS = SolarPySAMPVWatts(solar_dict) + SPS = SolarPySAMPVWatts(solar_dict, "solar_farm") power_setpoint = 13800.0 # Slightly below most of the base outputs. diff --git a/tests/solar_pysam_pvwatts_test.py b/tests/solar_pysam_pvwatts_test.py index e5fc0ccf..cf62a78c 100644 --- a/tests/solar_pysam_pvwatts_test.py +++ b/tests/solar_pysam_pvwatts_test.py @@ -14,7 +14,7 @@ def test_init(): # testing the `init` function: reading the inputs from input dictionary test_h_dict = copy.deepcopy(h_dict_solar_pvwatts) - SPS = SolarPySAMPVWatts(test_h_dict) + SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm") assert SPS.dt == test_h_dict["dt"] # Test that system_capacity is stored correctly @@ -31,7 +31,7 @@ def test_return_outputs(): # Note: Current SolarPySAMPVWatts doesn't have return_outputs method, # so we test the attributes directly test_h_dict = copy.deepcopy(h_dict_solar_pvwatts) - SPS = SolarPySAMPVWatts(test_h_dict) + SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm") assert SPS.power == 25 assert SPS.dni == 1000 @@ -53,7 +53,7 @@ def test_return_outputs(): def test_step(): # testing the `step` function: calculating power based on inputs at first timestep test_h_dict = copy.deepcopy(h_dict_solar_pvwatts) - SPS = SolarPySAMPVWatts(test_h_dict) + SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm") step_inputs = {"step": 0, "solar_farm": {"power_setpoint": 1e9}} @@ -70,7 +70,7 @@ def test_step(): def test_control(): test_h_dict = copy.deepcopy(h_dict_solar_pvwatts) - SPS = SolarPySAMPVWatts(test_h_dict) + SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm") # Test curtailment - set power setpoint above uncurtailed power, # should get uncurtailed power diff --git a/tests/thermal_component_base_test.py b/tests/thermal_component_base_test.py index 7b1cf89e..ac9b0865 100644 --- a/tests/thermal_component_base_test.py +++ b/tests/thermal_component_base_test.py @@ -10,7 +10,7 @@ def test_init_from_dict(): """Test that ThermalComponentBase can be initialized from a dictionary.""" - tpb = ThermalComponentBase(copy.deepcopy(h_dict_thermal_component)) + tpb = ThermalComponentBase(copy.deepcopy(h_dict_thermal_component), "thermal_component") assert tpb is not None @@ -21,44 +21,44 @@ def test_invalid_inputs(): h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["rated_capacity"] = "1000" with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test min_stable_load_fraction must be between 0 and 1 h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["min_stable_load_fraction"] = 1.1 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["min_stable_load_fraction"] = -0.1 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test ramp_rate_fraction must be a number greater than 0 h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["ramp_rate_fraction"] = 0 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test run_up_rate_fraction must be a number greater than 0 h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["run_up_rate_fraction"] = 0 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test min_up_time must be a number greater than or equal to 0 h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["min_up_time"] = 0 - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["min_up_time"] = -1 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test min_down_time must be a number greater than or equal to 0 h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["min_down_time"] = 0 - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["min_down_time"] = -1 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test hot_startup_time must be a number greater than the ramp_time # determined by the run_up_rate_fraction @@ -69,9 +69,9 @@ def test_invalid_inputs(): # The above implies a ramp_time of 60s h_dict["thermal_component"]["hot_startup_time"] = 59 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["hot_startup_time"] = 60 - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") # Test cold_startup_time must be a number greater than or equal to the # hot_startup_time (which in this setup equals the ramp_time determined @@ -87,9 +87,9 @@ def test_invalid_inputs(): # The above implies a ramp_time of 60s h_dict["thermal_component"]["cold_startup_time"] = 59 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["cold_startup_time"] = 60 - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") def test_compute_ramp_and_readying_times(): @@ -101,7 +101,7 @@ def test_compute_ramp_and_readying_times(): # The above implies a ramp_time of 60s h_dict["thermal_component"]["hot_startup_time"] = 60 h_dict["thermal_component"]["cold_startup_time"] = 120 - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") assert tcb.ramp_time == 60 assert tcb.hot_readying_time == 0 assert tcb.cold_readying_time == 60 @@ -113,7 +113,7 @@ def test_initial_conditions(): # Test that power > 0 implies ON state h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["initial_conditions"]["power"] = 1000 - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") assert tcb.power_output == 1000 assert tcb.state == ThermalComponentBase.STATES.ON # When ON, time_in_state should equal min_up_time (ready to stop) @@ -122,7 +122,7 @@ def test_initial_conditions(): # Test that power == 0 implies OFF state h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["initial_conditions"]["power"] = 0 - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") assert tcb.power_output == 0 assert tcb.state == ThermalComponentBase.STATES.OFF # When OFF, time_in_state should equal min_down_time (ready to start) @@ -132,10 +132,10 @@ def test_initial_conditions(): h_dict = copy.deepcopy(h_dict_thermal_component) h_dict["thermal_component"]["initial_conditions"]["power"] = -1 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") h_dict["thermal_component"]["initial_conditions"]["power"] = 1100 with pytest.raises(ValueError): - ThermalComponentBase(h_dict) + ThermalComponentBase(h_dict, "thermal_component") def test_power_setpoint_in_normal_operation(): @@ -151,7 +151,7 @@ def test_power_setpoint_in_normal_operation(): # Set the initial conditions to be 500 kW (implies ON state) h_dict["thermal_component"]["initial_conditions"]["power"] = 500 - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") # Set the power setpoint to the initial condition h_dict["thermal_component"]["power_setpoint"] = 500.0 @@ -219,7 +219,7 @@ def test_transition_on_to_off(): # Set the min_stable_load_fraction to be 0.2 (200 kW) h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") # Initial state: ON with time_in_state = min_up_time (5s, ready to stop) assert tcb.state == tcb.STATES.ON @@ -302,7 +302,7 @@ def test_transition_off_to_on(): # This run up time and min_stable_load_fraction imply a ramp_time of 4 seconds # so the hot readying time should be 3 seconds - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") # Initial state: OFF with time_in_state = min_down_time (3s, ready to start) assert tcb.state == tcb.STATES.OFF @@ -399,7 +399,7 @@ def test_efficiency_clamping(): "power_fraction": [0.25, 0.50, 0.75, 0.9], "efficiency": [0.30, 0.35, 0.38, 0.40], } - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") # Test above highest power fraction (should clamp to 0.40) # rated_capacity = 1000 kW, so 1000 kW = 100% load (above table max of 0.9) @@ -427,7 +427,7 @@ def test_efficiency_interpolation(): "power_fraction": [0.25, 0.50, 0.75, 1.0], "efficiency": [0.30, 0.35, 0.38, 0.40], } - tcb = ThermalComponentBase(h_dict) + tcb = ThermalComponentBase(h_dict, "thermal_component") # Test at table points (rated_capacity = 1000 kW) assert tcb.calculate_efficiency(1000) == pytest.approx(0.40) # 100% load diff --git a/tests/wind_farm_direct_test.py b/tests/wind_farm_direct_test.py index 75b97bab..1c315326 100644 --- a/tests/wind_farm_direct_test.py +++ b/tests/wind_farm_direct_test.py @@ -16,7 +16,7 @@ def test_wind_farm_direct_initialization(): """Test that WindFarm initializes correctly with wake_method='no_added_wakes'.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") assert wind_sim.component_name == "wind_farm" assert wind_sim.component_type == "WindFarm" @@ -32,7 +32,7 @@ def test_wind_farm_direct_initialization(): def test_wind_farm_direct_no_wakes(): """Test that no wake deficits are applied in direct mode.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Verify initial wake deficits are zero assert np.all(wind_sim.floris_wake_deficits == 0.0) @@ -43,7 +43,7 @@ def test_wind_farm_direct_no_wakes(): def test_wind_farm_direct_step(): """Test that the step method works correctly in direct mode.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -71,7 +71,7 @@ def test_wind_farm_direct_step(): def test_wind_farm_direct_no_wake_deficits_over_time(): """Test that wake deficits remain zero throughout simulation.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Run multiple steps for step in range(5): @@ -92,7 +92,7 @@ def test_wind_farm_direct_no_wake_deficits_over_time(): def test_wind_farm_direct_turbine_dynamics(): """Test that turbine dynamics still work in direct mode.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Run a step with very low power setpoint step_h_dict = {"step": 1} @@ -108,7 +108,7 @@ def test_wind_farm_direct_turbine_dynamics(): def test_wind_farm_direct_power_setpoint_zero(): """Test that turbine powers go to zero when setpoint is zero.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Run multiple steps with zero setpoint to ensure filter settles for step in range(10): @@ -124,7 +124,7 @@ def test_wind_farm_direct_power_setpoint_zero(): def test_wind_farm_direct_initial_conditions(): """Test that initial conditions are correctly set in h_dict.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") initial_h_dict = copy.deepcopy(h_dict_wind_direct) result_h_dict = wind_sim.get_initial_conditions_and_meta_data(initial_h_dict) @@ -144,7 +144,7 @@ def test_wind_farm_direct_initial_conditions(): def test_wind_farm_direct_output_consistency(): """Test that outputs are consistent with no wake modeling.""" - wind_sim = WindFarm(h_dict_wind_direct) + wind_sim = WindFarm(h_dict_wind_direct, "wind_farm") # Run a step step_h_dict = {"step": 2} diff --git a/tests/wind_farm_dynamic_floris_test.py b/tests/wind_farm_dynamic_floris_test.py index d48f323f..87251fa7 100644 --- a/tests/wind_farm_dynamic_floris_test.py +++ b/tests/wind_farm_dynamic_floris_test.py @@ -15,7 +15,7 @@ def test_wind_farm_initialization(): """Test that WindFarm initializes correctly with valid inputs (dynamic mode).""" - wind_sim = WindFarm(h_dict_wind) + wind_sim = WindFarm(h_dict_wind, "wind_farm") assert wind_sim.component_name == "wind_farm" assert wind_sim.component_type == "WindFarm" @@ -41,7 +41,7 @@ def test_wind_farm_ws_mean(): # Test that, since individual speed are specified, ws_mean is ignored # Note that h_dict_wind specifies an end time of 10. - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") assert ( wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10] ).all() @@ -56,7 +56,7 @@ def test_wind_farm_ws_mean(): df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"]) df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") assert (wind_sim.ws_mat_mean == 10.0).all() assert (wind_sim.ws_mat[:, :] == 10.0).all() @@ -72,7 +72,7 @@ def test_wind_farm_missing_floris_update_time(): with pytest.raises( ValueError, match="floris_update_time_s must be specified for wake_method='dynamic'" ): - WindFarm(test_h_dict) + WindFarm(test_h_dict, "wind_farm") def test_wind_farm_invalid_update_time(): @@ -81,7 +81,7 @@ def test_wind_farm_invalid_update_time(): test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5 # Less than 1 second with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"): - WindFarm(test_h_dict) + WindFarm(test_h_dict, "wind_farm") def test_wind_farm_step(): @@ -90,7 +90,7 @@ def test_wind_farm_step(): # Set a shorter update time for testing test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -110,7 +110,7 @@ def test_wind_farm_step(): def test_wind_farm_time_utc_conversion(): """Test that time_utc column is properly converted to datetime.""" - wind_sim = WindFarm(h_dict_wind) + wind_sim = WindFarm(h_dict_wind, "wind_farm") # Check that time_utc was converted to datetime type # The wind_sim should have successfully processed the CSV with time_utc column @@ -129,7 +129,7 @@ def test_wind_farm_power_setpoint_too_high(): test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Set very high power setpoint values that should not limit power output step_h_dict = {"step": 1} @@ -152,7 +152,7 @@ def test_wind_farm_power_setpoint_applies(): test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Set very low power setpoint values that should definitely limit power output step_h_dict = {"step": 1} @@ -174,7 +174,7 @@ def test_wind_farm_power_setpoint_applies(): def test_wind_farm_get_initial_conditions_and_meta_data(): """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" - wind_sim = WindFarm(h_dict_wind) + wind_sim = WindFarm(h_dict_wind, "wind_farm") # Create a copy of the input h_dict to avoid modifying the original test_h_dict = copy.deepcopy(h_dict_wind) @@ -253,7 +253,7 @@ def test_wind_farm_regular_floris_updates(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Run 5 steps with constant power setpoints floris_calc_counts = [] @@ -315,7 +315,7 @@ def test_wind_farm_power_setpoints_buffer(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Run steps with varying power setpoints for step in range(5): diff --git a/tests/wind_farm_precom_floris_test.py b/tests/wind_farm_precom_floris_test.py index ca091025..b96a994d 100644 --- a/tests/wind_farm_precom_floris_test.py +++ b/tests/wind_farm_precom_floris_test.py @@ -21,7 +21,7 @@ def test_wind_farm_precom_floris_initialization(): """Test that WindFarm initializes correctly with valid inputs.""" - wind_sim = WindFarm(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm") assert wind_sim.component_name == "wind_farm" assert wind_sim.component_type == "WindFarm" @@ -51,7 +51,7 @@ def test_wind_farm_precom_floris_ws_mean(): # Test that, since individual speed are specified, ws_mean is ignored # Note that h_dict_wind_precom_floris specifies an end time of 10. - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") assert ( wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10] ).all() @@ -66,7 +66,7 @@ def test_wind_farm_precom_floris_ws_mean(): df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"]) df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") assert (wind_sim.ws_mat_mean == 10.0).all() assert (wind_sim.ws_mat[:, :] == 10.0).all() @@ -82,7 +82,7 @@ def test_wind_farm_precom_floris_requires_floris_update_time(): with pytest.raises( ValueError, match="floris_update_time_s must be specified for wake_method='precomputed'" ): - WindFarm(test_h_dict) + WindFarm(test_h_dict, "wind_farm") def test_wind_farm_precom_floris_invalid_update_time(): @@ -91,12 +91,12 @@ def test_wind_farm_precom_floris_invalid_update_time(): test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5 with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"): - WindFarm(test_h_dict) + WindFarm(test_h_dict, "wind_farm") def test_wind_farm_precom_floris_step(): """Test that the step method updates outputs correctly.""" - wind_sim = WindFarm(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm") # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -116,7 +116,7 @@ def test_wind_farm_precom_floris_step(): def test_wind_farm_precom_floris_power_setpoint_applies(): """Test that turbine powers equal power setpoint when setpoint is very low.""" - wind_sim = WindFarm(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm") # Set very low power setpoint values that should definitely limit power output step_h_dict = {"step": 1} @@ -138,7 +138,7 @@ def test_wind_farm_precom_floris_power_setpoint_applies(): def test_wind_farm_precom_floris_get_initial_conditions_and_meta_data(): """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" - wind_sim = WindFarm(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm") # Create a copy of the input h_dict to avoid modifying the original test_h_dict_copy = copy.deepcopy(h_dict_wind_precom_floris) @@ -178,7 +178,7 @@ def test_wind_farm_precom_floris_get_initial_conditions_and_meta_data(): def test_wind_farm_precom_floris_precomputed_wake_deficits(): """Test that wake deficits are precomputed and stored correctly.""" - wind_sim = WindFarm(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm") # Verify that precomputed wake wind speeds exist assert hasattr(wind_sim, "wind_speeds_withwakes_all") @@ -232,7 +232,7 @@ def test_wind_farm_precom_floris_velocities_update_correctly(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Store initial wind speeds initial_background = wind_sim.wind_speeds_background.copy() @@ -304,7 +304,7 @@ def test_wind_farm_precom_floris_time_utc_reconstruction(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Verify that starttime_utc is set correctly assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" @@ -441,7 +441,7 @@ def test_wind_farm_precom_floris_time_utc_different_starttime(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarm(test_h_dict) + wind_sim = WindFarm(test_h_dict, "wind_farm") # Verify that starttime_utc is set correctly assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" diff --git a/tests/wind_farm_scada_power_test.py b/tests/wind_farm_scada_power_test.py index 5d7a449b..a20b62e8 100644 --- a/tests/wind_farm_scada_power_test.py +++ b/tests/wind_farm_scada_power_test.py @@ -26,7 +26,7 @@ def test_wind_farm_scada_power_initialization(): """Test that WindFarmSCADAPower initializes correctly with valid inputs.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") assert wind_sim.component_name == "wind_farm" assert wind_sim.component_type == "WindFarmSCADAPower" @@ -40,7 +40,7 @@ def test_wind_farm_scada_power_initialization(): def test_wind_farm_scada_power_infers_n_turbines(): """Test that number of turbines is correctly inferred from power columns.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") assert wind_sim.n_turbines == 3 assert len(wind_sim.power_columns) == 3 @@ -49,7 +49,7 @@ def test_wind_farm_scada_power_infers_n_turbines(): def test_wind_farm_scada_power_infers_rated_power(): """Test that rated power is correctly inferred from 99th percentile.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Check that rated power is positive and reasonable assert wind_sim.rated_turbine_power == 5000.0 @@ -58,7 +58,7 @@ def test_wind_farm_scada_power_infers_rated_power(): def test_wind_farm_scada_power_no_wakes(): """Test that no wake deficits are applied in SCADA power mode.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Verify initial wake deficits are zero assert np.all(wind_sim.floris_wake_deficits == 0.0) @@ -69,7 +69,7 @@ def test_wind_farm_scada_power_no_wakes(): def test_wind_farm_scada_power_step(): """Test that the step method works correctly.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -101,7 +101,7 @@ def test_wind_farm_scada_power_step(): def test_wind_farm_scada_power_power_setpoint_applies(): """Test that turbine powers are limited by power setpoint when setpoint is low.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Set very low power setpoint values that should definitely limit power output # Run multiple steps to let filter settle (within available data range 0-9) @@ -124,7 +124,7 @@ def test_wind_farm_scada_power_power_setpoint_applies(): def test_wind_farm_scada_power_power_setpoint_zero(): """Test that turbine powers go to zero when setpoint is zero.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Run multiple steps with zero setpoint to ensure filter settles (within available data range) for step in range(wind_sim.n_steps): @@ -140,7 +140,7 @@ def test_wind_farm_scada_power_power_setpoint_zero(): def test_wind_farm_scada_power_get_initial_conditions_and_meta_data(): """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Create a copy of the input h_dict to avoid modifying the original test_h_dict_copy = copy.deepcopy(h_dict_wind_scada) @@ -215,7 +215,7 @@ def test_wind_farm_scada_power_time_utc_handling(): test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = WindFarmSCADAPower(test_h_dict) + wind_sim = WindFarmSCADAPower(test_h_dict, "wind_farm") # Verify that starttime_utc is set correctly assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" @@ -270,7 +270,7 @@ def test_wind_farm_scada_power_time_utc_validation_start_too_early(): test_h_dict["dt"] = 1.0 with pytest.raises(ValueError, match="Start time UTC .* is before the earliest time"): - WindFarmSCADAPower(test_h_dict) + WindFarmSCADAPower(test_h_dict, "wind_farm") finally: if os.path.exists(temp_scada_file): @@ -311,7 +311,7 @@ def test_wind_farm_scada_power_time_utc_validation_end_too_late(): test_h_dict["dt"] = 1.0 with pytest.raises(ValueError, match="End time UTC .* is after the latest time"): - WindFarmSCADAPower(test_h_dict) + WindFarmSCADAPower(test_h_dict, "wind_farm") finally: if os.path.exists(temp_scada_file): @@ -350,7 +350,7 @@ def test_wind_farm_scada_power_ws_mean_handling(): test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z" test_h_dict["dt"] = 1.0 - wind_sim = WindFarmSCADAPower(test_h_dict) + wind_sim = WindFarmSCADAPower(test_h_dict, "wind_farm") # Verify that ws_mat is properly tiled from ws_mean assert wind_sim.ws_mat.shape == (4, 3) @@ -365,7 +365,7 @@ def test_wind_farm_scada_power_ws_mean_handling(): def test_wind_farm_scada_power_output_consistency(): """Test that outputs are consistent with no wake modeling.""" - wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") # Run a step step_h_dict = {"step": 2} @@ -389,7 +389,7 @@ def test_wind_farm_scada_power_output_consistency(): def test_wind_farm_scada_power_multiple_file_formats(): """Test that SCADA data can be loaded from different file formats.""" # Test CSV (already tested above, but included for completeness) - wind_sim_csv = WindFarmSCADAPower(h_dict_wind_scada) + wind_sim_csv = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm") assert wind_sim_csv.n_turbines == 3 # Test pickle format @@ -405,7 +405,7 @@ def test_wind_farm_scada_power_multiple_file_formats(): test_h_dict = copy.deepcopy(h_dict_wind_scada) test_h_dict["wind_farm"]["scada_filename"] = temp_pickle_file - wind_sim_pkl = WindFarmSCADAPower(test_h_dict) + wind_sim_pkl = WindFarmSCADAPower(test_h_dict, "wind_farm") assert wind_sim_pkl.n_turbines == 3 finally: @@ -421,7 +421,7 @@ def test_wind_farm_scada_power_multiple_file_formats(): test_h_dict = copy.deepcopy(h_dict_wind_scada) test_h_dict["wind_farm"]["scada_filename"] = temp_feather_file - wind_sim_ftr = WindFarmSCADAPower(test_h_dict) + wind_sim_ftr = WindFarmSCADAPower(test_h_dict, "wind_farm") assert wind_sim_ftr.n_turbines == 3 finally: @@ -443,7 +443,7 @@ def test_wind_farm_scada_power_invalid_file_format(): test_h_dict["wind_farm"]["scada_filename"] = temp_file with pytest.raises(ValueError, match="SCADA file must be a .csv or .p, .f or .ftr file"): - WindFarmSCADAPower(test_h_dict) + WindFarmSCADAPower(test_h_dict, "wind_farm") finally: if os.path.exists(temp_file): From 3e90380b072006996c703ff0d09b6f825ca9d9c5 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:19:42 -0700 Subject: [PATCH 11/38] fix docstring --- .../plant_components/battery_lithium_ion.py | 923 +++++++++-------- hercules/plant_components/battery_simple.py | 933 +++++++++--------- .../plant_components/electrolyzer_plant.py | 1 - .../open_cycle_gas_turbine.py | 1 - hercules/plant_components/solar_pysam_base.py | 1 - .../plant_components/solar_pysam_pvwatts.py | 1 - hercules/plant_components/wind_farm.py | 1 - .../plant_components/wind_farm_scada_power.py | 1 - 8 files changed, 927 insertions(+), 935 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 5a0f6b7d..23ec6788 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -1,462 +1,461 @@ -""" -Battery models -Author: Zack tully - zachary.tully@nlr.gov -March 2024 - -References: -[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion -batteries, incorporating the effects of state of health, state of charge, and -temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, -Nov. 2021, doi: 10.1016/j.est.2021.103252. -""" - -import numpy as np -from hercules.plant_components.component_base import ComponentBase -from hercules.utilities import hercules_float_type - - -def kJ2kWh(kJ): - """Convert a value in kJ to kWh. - - Args: - kJ (float): Energy value in kilojoules. - - Returns: - float: Energy value in kilowatt-hours. - """ - return kJ / 3600 - - -def kWh2kJ(kWh): - """Convert a value in kWh to kJ. - - Args: - kWh (float): Energy value in kilowatt-hours. - - Returns: - float: Energy value in kilojoules. - """ - return kWh * 3600 - - -def years_to_usage_rate(years, dt): - """Convert a number of years to a usage rate. - - Args: - years (float): Life of the storage system in years. - dt (float): Time step of the simulation in seconds. - - Returns: - float: Usage rate per time step. - """ - days = years * 365 - hours = days * 24 - seconds = hours * 3600 - usage_lifetime = seconds / dt - - return 1 / usage_lifetime - - -def cycles_to_usage_rate(cycles): - """Convert cycle number to degradation rate. - - Args: - cycles (int): Number of cycles until the unit needs to be replaced. - - Returns: - float: Degradation rate per cycle. - """ - return 1 / cycles - - -class BatteryLithiumIon(ComponentBase): - """Detailed lithium-ion battery model with equivalent circuit modeling. - - This model represents a detailed lithium-ion battery with diffusion transients - and losses modeled as an equivalent circuit model. Calculations in this class - are primarily from [1]. - - Battery specifications: - - Cathode Material: LiFePO4 (all 5 cells) - - Anode Material: Graphite (all 5 cells) - - References: - [1] M.-K. Tran et al., "A comprehensive equivalent circuit model for lithium-ion - batteries, incorporating the effects of state of health, state of charge, and - temperature on model parameters," Journal of Energy Storage, vol. 43, p. 103252, - Nov. 2021, doi: 10.1016/j.est.2021.103252. - """ - - component_category = "battery" - - def __init__(self, h_dict, component_name): - """Initialize the BatteryLithiumIon class. - - This model represents a detailed lithium-ion battery with diffusion transients - and losses modeled as an equivalent circuit model. - - Args: - h_dict (dict): Dictionary containing simulation parameters including: - - energy_capacity: Battery energy capacity in kWh - - charge_rate: Maximum charge rate in kW - - discharge_rate: Maximum discharge rate in kW - - max_SOC: Maximum state of charge (0-1) - - min_SOC: Minimum state of charge (0-1) - - initial_conditions: Dictionary with initial SOC - - allow_grid_power_consumption: Optional, defaults to False - component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"battery"`` for backward compatibility. - """ - - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) - - self.V_cell_nom = 3.3 # [V] - self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 - - self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] - self.max_charge_power = h_dict[self.component_name]["charge_rate"] # [kW] - self.max_discharge_power = h_dict[self.component_name]["discharge_rate"] # [kW] - - initial_conditions = h_dict[self.component_name]["initial_conditions"] - self.SOC = initial_conditions["SOC"] # [fraction] - self.SOC_max = h_dict[self.component_name]["max_SOC"] - self.SOC_min = h_dict[self.component_name]["min_SOC"] - - # Flag for allowing grid to charge the battery - if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): - self.allow_grid_power_consumption = h_dict[self.component_name][ - "allow_grid_power_consumption" - ] - else: - self.allow_grid_power_consumption = False - - self.T = 25 # [C] temperature - self.SOH = 1 # State of Health - - self.post_init() - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - - # Add what we want later - h_dict[self.component_name]["power"] = 0 - - return h_dict - - def post_init(self): - """Calculate derived battery parameters after initialization. - - This method calculates cell configuration, capacity, voltage, current limits, - and initializes the equivalent circuit model parameters. - """ - - # Calculate the total cells and series/parallel configuration - self.n_cells = self.energy_capacity * 1e3 / (self.V_cell_nom * self.C_cell) - # TODO: need a systematic way to decide parallel and series cells - # TODO: choose a default voltage to choose the series and parallel configuration. - # TODO: allow user to specify a specific configuration - self.n_p = np.sqrt(self.n_cells) # number of cells in parallel - self.n_s = np.sqrt(self.n_cells) # number of cells in series - - # Calculate the capacity in Ah and the max charge/discharge rate in A - # C-rate = 1 means the cell discharges fully in one hour - self.C = self.C_cell * self.n_p # [Ah] capacity - - C_rate_charge = self.max_charge_power / self.energy_capacity - C_rate_discharge = self.max_discharge_power / self.energy_capacity - self.max_C_rate = np.max([C_rate_charge, C_rate_discharge]) # [A] [capacity/hr] - - # Nominal battery voltage and current - self.V_bat_nom = self.V_cell_nom * self.n_s # [V] - self.I_bat_max = self.C_cell * self.max_C_rate * self.n_p # [A] - - # Max charge/discharge in kW - self.P_max = self.C * self.max_C_rate * self.V_bat_nom * 1e-3 # [kW] - self.P_min = -self.P_max # [kW] - - # Max and min charge level in Ah - self.charge = self.SOC * self.C # [Ah] - self.charge_max = self.SOC_max * self.C - self.charge_min = self.SOC_min * self.C - - # initial state of RC branch state space - self.x = 0 - self.V_RC = 0 - self.error_sum = 0 - - # 10th order polynomial fit of the OCV curve from [1] Fig.4 - self.OCV_polynomial = np.array( - [ - 3.59292657e03, - -1.67001912e04, - 3.29199313e04, - -3.58557498e04, - 2.35571965e04, - -9.56351032e03, - 2.36147233e03, - -3.35943038e02, - 2.49233107e01, - 2.47115515e00, - ], - dtype=hercules_float_type, - ) - self.poly_order = len(self.OCV_polynomial) - - # Equivalent circuit component coefficientys from [1] Table 2 - # value = c1 + c2 * SOH + c3 * T + c4 * SOC - self.ECM_coefficients = np.array( - [ - [10424.73, -48.2181, -114.74, -1.40433], # R0 [micro ohms] - [13615.54, -68.0889, -87.527, -37.1084], # R1 [micro ohms] - [-11116.7, 180.4576, 237.4219, 40.14711], # C1 [F] - ], - dtype=hercules_float_type, - ) - - # initial state of battery outputs for hercules - self.power_kw = 0 - self.P_reject = 0 - self.P_charge = 0 - - def OCV(self): - """Calculate cell open circuit voltage (OCV) as a function of SOC. - - Uses a 10th order polynomial fit of the OCV curve from [1] Fig.4. - - Returns: - float: Cell open circuit voltage in volts. - """ - - ocv = 0 - for i, c in enumerate(self.OCV_polynomial): - ocv += c * self.SOC ** (self.poly_order - i - 1) - - return ocv - - def build_SS(self): - """Build RC branch state space matrices for equivalent circuit model. - - Constructs state space matrices for the current SOH (state of health), - T (temperature), and SOC (state of charge) using coefficients from [1] Table 2. - - Returns: - tuple: A, B, C, D state space matrices for the RC branch. - """ - - R_0, R_1, C_1 = self.ECM_coefficients @ np.array( - [1, self.SOH * 100, self.T, self.SOC * 100], dtype=hercules_float_type - ) - R_0 *= 1e-6 - R_1 *= 1e-6 - - A = -1 / (R_1 * C_1) - B = 1 - C = 1 / C_1 - D = R_0 - - return A, B, C, D - - def step_cell(self, u): - """Update the equivalent circuit model state for one time step. - - Args: - u (float): Cell current in amperes. - """ - # TODO: What if dt is very slow? skip this integration and return steady state value - # update the state of the cell model - A, B, C, D = self.build_SS() - - xd = A * self.x + B * u - y = C * self.x + D * u - - self.x = self.integrate(self.x, xd) - self.V_RC = y - - def integrate(self, x, xd): - """Integrate state derivatives using Euler method. - - Args: - x (float): Current state value. - xd (float): State derivative. - - Returns: - float: Updated state value. - """ - # TODO: Use better integration method like closed form step response solution - return x + xd * self.dt # Euler integration - - def V_cell(self): - """Calculate total cell voltage. - - Returns: - float: Cell voltage in volts (OCV + RC voltage drop). - """ - return self.OCV() + self.V_RC - - def calc_power(self, I_bat): - """Calculate battery power from current. - - Args: - I_bat (float): Battery current in amperes. - - Returns: - float: Battery power in watts. - """ - # Total battery voltage (cells in series) times current - return self.V_cell() * self.n_s * I_bat # [W] - - def step(self, h_dict): - """Advance the battery simulation by one time step. - - Updates the battery state including SOC, equivalent circuit dynamics, and power output - based on the requested power setpoint and available power. - - Args: - h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] - - plant.locally_generated_power: Available power for charging [kW] - - Returns: - dict: Updated h_dict with battery outputs: - - power: Actual charging/discharging power [kW] - - reject: Rejected power due to constraints [kW] - - soc: State of charge [0-1] - """ - - P_signal = h_dict[self.component_name]["power_setpoint"] # [kW] requested power - if self.allow_grid_power_consumption: - P_avail = np.inf - else: - P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power - - # Calculate charging/discharging current [A] from power - I_charge, I_reject = self.control(P_signal, P_avail) - i_charge = I_charge / self.n_p # [A] Cell current - - # Update charge - self.charge += I_charge * self.dt / 3600 # [Ah] - self.SOC = self.charge / (self.C) - - # Update RC branch dynamics - self.step_cell(i_charge) - - # Calculate actual power - self.power_kw = self.calc_power(I_charge) * 1e-3 - self.P_reject = P_signal - self.power_kw - - # Update power signal error integral - if (P_signal < self.max_charge_power) & (P_signal > self.max_discharge_power): - self.error_sum += self.P_reject * self.dt - - # Update the outputs - h_dict[self.component_name]["power"] = self.power_kw - h_dict[self.component_name]["reject"] = self.P_reject - h_dict[self.component_name]["soc"] = self.SOC - - # Return the updated dictionary - return h_dict - - def control(self, P_signal, P_avail): - """Calculate charging/discharging current from requested power. - - Uses an iterative approach to account for errors between nominal and actual - battery voltage. Includes integral control to correct for persistent voltage errors. - - Args: - P_signal (float): Requested charging/discharging power in kW. - P_avail (float): Power available for charging/discharging in kW. - - Returns: - tuple: (I_charge, I_reject) where: - - I_charge: Charging/discharging current in amperes that the battery can provide - - I_reject: Current equivalent of power that cannot be provided in amperes - """ - - # Current according to nominal voltage - I_signal = P_signal * 1e3 / self.V_bat_nom - - # Iteratively adjust setpoint to account for inherent error in V_nom - error = P_signal - self.calc_power(I_signal) * 1e-3 - count = 0 # safety count - tol = self.V_bat_nom * self.I_bat_max * 1e-9 - while np.abs(error) > tol: - count += 1 - error = P_signal - self.calc_power(I_signal) * 1e-3 - I_signal += error * 1e3 / self.V_bat_nom - - if count > 100: - # assert False, "Too many interations, breaking the while loop." - break - - # Error integral acts like an offset correcting for persistent errors between nominal and - # actual battery voltage. - I_signal += self.error_sum * 1e3 / self.V_bat_nom * 0.01 - # Is this calc just as accurate as iterative? - I_avail = P_avail * 1e3 / (self.V_cell() * self.n_s) - - # Check charging, discharging, and amperage constraints. - I_charge, I_reject = self.constraints(I_signal, I_avail) - - return I_charge, I_reject - - def constraints(self, I_signal, I_avail): - """Apply battery operational constraints to the requested current. - - Checks whether the requested charging/discharging action will violate battery - charge limits, power limits, or available power. Returns the constrained current - and any rejected current. - - Args: - I_signal (float): Requested charging/discharging current in amperes. - I_avail (float): Current available for charging/discharging in amperes. - - Returns: - tuple: (I_charge, I_reject) where: - - I_charge: Constrained charging/discharging current in amperes - - I_reject: Rejected current due to constraints in amperes - """ - - # Charge (energy) constraint, upper. Charging current that would fill the battery up - # completely in one time step - c_hi1 = (self.charge_max - self.charge) / (self.dt / 3600) - # Charge rate (power) constraint, upper. - c_hi2 = self.I_bat_max - # Available power - c_hi3 = I_avail - - # Take the most restrictive upper constraint - c_hi = np.min([c_hi1, c_hi2, c_hi3]) - - # Charge (energy) constraint, lower. - c_lo1 = (self.charge_min - self.charge) / (self.dt / 3600) - # Discharge rate (power) constraint, lower. - c_lo2 = -self.I_bat_max - - # Take the most restrictive lower constraint - c_lo = np.max([c_lo1, c_lo2]) - - if (I_signal >= c_lo) & (I_signal <= c_hi): - # It is possible to fulfill the requested signal - I_charge = I_signal - I_reject = 0 - elif I_signal < c_lo: - # The battery is constrained to charge/discharge higher than the requested signal - I_charge = c_lo - I_reject = I_signal - I_charge - elif I_signal > c_hi: - # The battery is constrained to charge/discharge lower than the requested signal - I_charge = c_hi - I_reject = I_signal - I_charge - - return I_charge, I_reject +""" +Battery models +Author: Zack tully - zachary.tully@nlr.gov +March 2024 + +References: +[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion +batteries, incorporating the effects of state of health, state of charge, and +temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, +Nov. 2021, doi: 10.1016/j.est.2021.103252. +""" + +import numpy as np +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import hercules_float_type + + +def kJ2kWh(kJ): + """Convert a value in kJ to kWh. + + Args: + kJ (float): Energy value in kilojoules. + + Returns: + float: Energy value in kilowatt-hours. + """ + return kJ / 3600 + + +def kWh2kJ(kWh): + """Convert a value in kWh to kJ. + + Args: + kWh (float): Energy value in kilowatt-hours. + + Returns: + float: Energy value in kilojoules. + """ + return kWh * 3600 + + +def years_to_usage_rate(years, dt): + """Convert a number of years to a usage rate. + + Args: + years (float): Life of the storage system in years. + dt (float): Time step of the simulation in seconds. + + Returns: + float: Usage rate per time step. + """ + days = years * 365 + hours = days * 24 + seconds = hours * 3600 + usage_lifetime = seconds / dt + + return 1 / usage_lifetime + + +def cycles_to_usage_rate(cycles): + """Convert cycle number to degradation rate. + + Args: + cycles (int): Number of cycles until the unit needs to be replaced. + + Returns: + float: Degradation rate per cycle. + """ + return 1 / cycles + + +class BatteryLithiumIon(ComponentBase): + """Detailed lithium-ion battery model with equivalent circuit modeling. + + This model represents a detailed lithium-ion battery with diffusion transients + and losses modeled as an equivalent circuit model. Calculations in this class + are primarily from [1]. + + Battery specifications: + - Cathode Material: LiFePO4 (all 5 cells) + - Anode Material: Graphite (all 5 cells) + + References: + [1] M.-K. Tran et al., "A comprehensive equivalent circuit model for lithium-ion + batteries, incorporating the effects of state of health, state of charge, and + temperature on model parameters," Journal of Energy Storage, vol. 43, p. 103252, + Nov. 2021, doi: 10.1016/j.est.2021.103252. + """ + + component_category = "battery" + + def __init__(self, h_dict, component_name): + """Initialize the BatteryLithiumIon class. + + This model represents a detailed lithium-ion battery with diffusion transients + and losses modeled as an equivalent circuit model. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - energy_capacity: Battery energy capacity in kWh + - charge_rate: Maximum charge rate in kW + - discharge_rate: Maximum discharge rate in kW + - max_SOC: Maximum state of charge (0-1) + - min_SOC: Minimum state of charge (0-1) + - initial_conditions: Dictionary with initial SOC + - allow_grid_power_consumption: Optional, defaults to False + component_name (str): Unique name for this instance (the YAML top-level key). + """ + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) + + self.V_cell_nom = 3.3 # [V] + self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 + + self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] + self.max_charge_power = h_dict[self.component_name]["charge_rate"] # [kW] + self.max_discharge_power = h_dict[self.component_name]["discharge_rate"] # [kW] + + initial_conditions = h_dict[self.component_name]["initial_conditions"] + self.SOC = initial_conditions["SOC"] # [fraction] + self.SOC_max = h_dict[self.component_name]["max_SOC"] + self.SOC_min = h_dict[self.component_name]["min_SOC"] + + # Flag for allowing grid to charge the battery + if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): + self.allow_grid_power_consumption = h_dict[self.component_name][ + "allow_grid_power_consumption" + ] + else: + self.allow_grid_power_consumption = False + + self.T = 25 # [C] temperature + self.SOH = 1 # State of Health + + self.post_init() + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + + # Add what we want later + h_dict[self.component_name]["power"] = 0 + + return h_dict + + def post_init(self): + """Calculate derived battery parameters after initialization. + + This method calculates cell configuration, capacity, voltage, current limits, + and initializes the equivalent circuit model parameters. + """ + + # Calculate the total cells and series/parallel configuration + self.n_cells = self.energy_capacity * 1e3 / (self.V_cell_nom * self.C_cell) + # TODO: need a systematic way to decide parallel and series cells + # TODO: choose a default voltage to choose the series and parallel configuration. + # TODO: allow user to specify a specific configuration + self.n_p = np.sqrt(self.n_cells) # number of cells in parallel + self.n_s = np.sqrt(self.n_cells) # number of cells in series + + # Calculate the capacity in Ah and the max charge/discharge rate in A + # C-rate = 1 means the cell discharges fully in one hour + self.C = self.C_cell * self.n_p # [Ah] capacity + + C_rate_charge = self.max_charge_power / self.energy_capacity + C_rate_discharge = self.max_discharge_power / self.energy_capacity + self.max_C_rate = np.max([C_rate_charge, C_rate_discharge]) # [A] [capacity/hr] + + # Nominal battery voltage and current + self.V_bat_nom = self.V_cell_nom * self.n_s # [V] + self.I_bat_max = self.C_cell * self.max_C_rate * self.n_p # [A] + + # Max charge/discharge in kW + self.P_max = self.C * self.max_C_rate * self.V_bat_nom * 1e-3 # [kW] + self.P_min = -self.P_max # [kW] + + # Max and min charge level in Ah + self.charge = self.SOC * self.C # [Ah] + self.charge_max = self.SOC_max * self.C + self.charge_min = self.SOC_min * self.C + + # initial state of RC branch state space + self.x = 0 + self.V_RC = 0 + self.error_sum = 0 + + # 10th order polynomial fit of the OCV curve from [1] Fig.4 + self.OCV_polynomial = np.array( + [ + 3.59292657e03, + -1.67001912e04, + 3.29199313e04, + -3.58557498e04, + 2.35571965e04, + -9.56351032e03, + 2.36147233e03, + -3.35943038e02, + 2.49233107e01, + 2.47115515e00, + ], + dtype=hercules_float_type, + ) + self.poly_order = len(self.OCV_polynomial) + + # Equivalent circuit component coefficientys from [1] Table 2 + # value = c1 + c2 * SOH + c3 * T + c4 * SOC + self.ECM_coefficients = np.array( + [ + [10424.73, -48.2181, -114.74, -1.40433], # R0 [micro ohms] + [13615.54, -68.0889, -87.527, -37.1084], # R1 [micro ohms] + [-11116.7, 180.4576, 237.4219, 40.14711], # C1 [F] + ], + dtype=hercules_float_type, + ) + + # initial state of battery outputs for hercules + self.power_kw = 0 + self.P_reject = 0 + self.P_charge = 0 + + def OCV(self): + """Calculate cell open circuit voltage (OCV) as a function of SOC. + + Uses a 10th order polynomial fit of the OCV curve from [1] Fig.4. + + Returns: + float: Cell open circuit voltage in volts. + """ + + ocv = 0 + for i, c in enumerate(self.OCV_polynomial): + ocv += c * self.SOC ** (self.poly_order - i - 1) + + return ocv + + def build_SS(self): + """Build RC branch state space matrices for equivalent circuit model. + + Constructs state space matrices for the current SOH (state of health), + T (temperature), and SOC (state of charge) using coefficients from [1] Table 2. + + Returns: + tuple: A, B, C, D state space matrices for the RC branch. + """ + + R_0, R_1, C_1 = self.ECM_coefficients @ np.array( + [1, self.SOH * 100, self.T, self.SOC * 100], dtype=hercules_float_type + ) + R_0 *= 1e-6 + R_1 *= 1e-6 + + A = -1 / (R_1 * C_1) + B = 1 + C = 1 / C_1 + D = R_0 + + return A, B, C, D + + def step_cell(self, u): + """Update the equivalent circuit model state for one time step. + + Args: + u (float): Cell current in amperes. + """ + # TODO: What if dt is very slow? skip this integration and return steady state value + # update the state of the cell model + A, B, C, D = self.build_SS() + + xd = A * self.x + B * u + y = C * self.x + D * u + + self.x = self.integrate(self.x, xd) + self.V_RC = y + + def integrate(self, x, xd): + """Integrate state derivatives using Euler method. + + Args: + x (float): Current state value. + xd (float): State derivative. + + Returns: + float: Updated state value. + """ + # TODO: Use better integration method like closed form step response solution + return x + xd * self.dt # Euler integration + + def V_cell(self): + """Calculate total cell voltage. + + Returns: + float: Cell voltage in volts (OCV + RC voltage drop). + """ + return self.OCV() + self.V_RC + + def calc_power(self, I_bat): + """Calculate battery power from current. + + Args: + I_bat (float): Battery current in amperes. + + Returns: + float: Battery power in watts. + """ + # Total battery voltage (cells in series) times current + return self.V_cell() * self.n_s * I_bat # [W] + + def step(self, h_dict): + """Advance the battery simulation by one time step. + + Updates the battery state including SOC, equivalent circuit dynamics, and power output + based on the requested power setpoint and available power. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - battery.power_setpoint: Requested charging/discharging power [kW] + - plant.locally_generated_power: Available power for charging [kW] + + Returns: + dict: Updated h_dict with battery outputs: + - power: Actual charging/discharging power [kW] + - reject: Rejected power due to constraints [kW] + - soc: State of charge [0-1] + """ + + P_signal = h_dict[self.component_name]["power_setpoint"] # [kW] requested power + if self.allow_grid_power_consumption: + P_avail = np.inf + else: + P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power + + # Calculate charging/discharging current [A] from power + I_charge, I_reject = self.control(P_signal, P_avail) + i_charge = I_charge / self.n_p # [A] Cell current + + # Update charge + self.charge += I_charge * self.dt / 3600 # [Ah] + self.SOC = self.charge / (self.C) + + # Update RC branch dynamics + self.step_cell(i_charge) + + # Calculate actual power + self.power_kw = self.calc_power(I_charge) * 1e-3 + self.P_reject = P_signal - self.power_kw + + # Update power signal error integral + if (P_signal < self.max_charge_power) & (P_signal > self.max_discharge_power): + self.error_sum += self.P_reject * self.dt + + # Update the outputs + h_dict[self.component_name]["power"] = self.power_kw + h_dict[self.component_name]["reject"] = self.P_reject + h_dict[self.component_name]["soc"] = self.SOC + + # Return the updated dictionary + return h_dict + + def control(self, P_signal, P_avail): + """Calculate charging/discharging current from requested power. + + Uses an iterative approach to account for errors between nominal and actual + battery voltage. Includes integral control to correct for persistent voltage errors. + + Args: + P_signal (float): Requested charging/discharging power in kW. + P_avail (float): Power available for charging/discharging in kW. + + Returns: + tuple: (I_charge, I_reject) where: + - I_charge: Charging/discharging current in amperes that the battery can provide + - I_reject: Current equivalent of power that cannot be provided in amperes + """ + + # Current according to nominal voltage + I_signal = P_signal * 1e3 / self.V_bat_nom + + # Iteratively adjust setpoint to account for inherent error in V_nom + error = P_signal - self.calc_power(I_signal) * 1e-3 + count = 0 # safety count + tol = self.V_bat_nom * self.I_bat_max * 1e-9 + while np.abs(error) > tol: + count += 1 + error = P_signal - self.calc_power(I_signal) * 1e-3 + I_signal += error * 1e3 / self.V_bat_nom + + if count > 100: + # assert False, "Too many interations, breaking the while loop." + break + + # Error integral acts like an offset correcting for persistent errors between nominal and + # actual battery voltage. + I_signal += self.error_sum * 1e3 / self.V_bat_nom * 0.01 + # Is this calc just as accurate as iterative? + I_avail = P_avail * 1e3 / (self.V_cell() * self.n_s) + + # Check charging, discharging, and amperage constraints. + I_charge, I_reject = self.constraints(I_signal, I_avail) + + return I_charge, I_reject + + def constraints(self, I_signal, I_avail): + """Apply battery operational constraints to the requested current. + + Checks whether the requested charging/discharging action will violate battery + charge limits, power limits, or available power. Returns the constrained current + and any rejected current. + + Args: + I_signal (float): Requested charging/discharging current in amperes. + I_avail (float): Current available for charging/discharging in amperes. + + Returns: + tuple: (I_charge, I_reject) where: + - I_charge: Constrained charging/discharging current in amperes + - I_reject: Rejected current due to constraints in amperes + """ + + # Charge (energy) constraint, upper. Charging current that would fill the battery up + # completely in one time step + c_hi1 = (self.charge_max - self.charge) / (self.dt / 3600) + # Charge rate (power) constraint, upper. + c_hi2 = self.I_bat_max + # Available power + c_hi3 = I_avail + + # Take the most restrictive upper constraint + c_hi = np.min([c_hi1, c_hi2, c_hi3]) + + # Charge (energy) constraint, lower. + c_lo1 = (self.charge_min - self.charge) / (self.dt / 3600) + # Discharge rate (power) constraint, lower. + c_lo2 = -self.I_bat_max + + # Take the most restrictive lower constraint + c_lo = np.max([c_lo1, c_lo2]) + + if (I_signal >= c_lo) & (I_signal <= c_hi): + # It is possible to fulfill the requested signal + I_charge = I_signal + I_reject = 0 + elif I_signal < c_lo: + # The battery is constrained to charge/discharge higher than the requested signal + I_charge = c_lo + I_reject = I_signal - I_charge + elif I_signal > c_hi: + # The battery is constrained to charge/discharge lower than the requested signal + I_charge = c_hi + I_reject = I_signal - I_charge + + return I_charge, I_reject diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index aa136838..f19c57bf 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -1,467 +1,466 @@ -""" -Battery models -Author: Zack tully - zachary.tully@nrel.gov -March 2024 - -References: -[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion -batteries, incorporating the effects of state of health, state of charge, and -temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, -Nov. 2021, doi: 10.1016/j.est.2021.103252. -""" - -import numpy as np -import rainflow -from hercules.plant_components.component_base import ComponentBase -from hercules.utilities import hercules_float_type - - -def kJ2kWh(kJ): - """Convert a value in kJ to kWh. - - Args: - kJ (float): Energy value in kilojoules. - - Returns: - float: Energy value in kilowatt-hours. - """ - return kJ / 3600 - - -def kWh2kJ(kWh): - """Convert a value in kWh to kJ. - - Args: - kWh (float): Energy value in kilowatt-hours. - - Returns: - float: Energy value in kilojoules. - """ - return kWh * 3600 - - -def years_to_usage_rate(years, dt): - """Convert a number of years to a usage rate. - - Args: - years (float): Life of the storage system in years. - dt (float): Time step of the simulation in seconds. - - Returns: - float: Usage rate per time step. - """ - days = years * 365 - hours = days * 24 - seconds = hours * 3600 - usage_lifetime = seconds / dt - - return 1 / usage_lifetime - - -def cycles_to_usage_rate(cycles): - """Convert cycle number to degradation rate. - - Args: - cycles (int): Number of cycles until the unit needs to be replaced. - - Returns: - float: Degradation rate per cycle. - """ - return 1 / cycles - - -class BatterySimple(ComponentBase): - """Simple battery energy storage model. - - This model represents a basic battery with energy storage and power constraints. - It tracks state of charge, applies efficiency losses, and optionally tracks - usage-based degradation using rainflow cycle counting. - - Note: - All power units are in kW and energy units are in kWh. - """ - - component_category = "battery" - - def __init__(self, h_dict, component_name): - """Initialize the BatterySimple class. - - This model represents a simple battery with energy storage and power constraints. - It tracks state of charge and applies efficiency losses. - - Args: - h_dict (dict): Dictionary containing simulation parameters including: - - energy_capacity: Battery energy capacity in kWh - - charge_rate: Maximum charge rate in kW - - discharge_rate: Maximum discharge rate in kW - - max_SOC: Maximum state of charge (0-1) - - min_SOC: Minimum state of charge (0-1) - - initial_conditions: Dictionary with initial SOC - - allow_grid_power_consumption: Optional, defaults to False - - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - - self_discharge_time_constant: Optional self-discharge time constant - - track_usage: Optional boolean to enable usage tracking - component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"battery"`` for backward compatibility. - """ - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) - - # size = h_dict[self.component_name]["size"] - self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] - initial_conditions = h_dict[self.component_name]["initial_conditions"] - self.SOC = initial_conditions["SOC"] # [fraction] - - self.SOC_max = h_dict[self.component_name]["max_SOC"] - self.SOC_min = h_dict[self.component_name]["min_SOC"] - - # Charge (Energy) limits [kJ] - self.E_min = kWh2kJ(self.SOC_min * self.energy_capacity) - self.E_max = kWh2kJ(self.SOC_max * self.energy_capacity) - - charge_rate = h_dict[self.component_name]["charge_rate"] # [kW] - discharge_rate = h_dict[self.component_name]["discharge_rate"] # [kW] - - # Charge/discharge (Power) limits [kW] - self.P_min = -discharge_rate - self.P_max = charge_rate - - # Ramp up/down limits [kW/s] - self.R_min = -np.inf - self.R_max = np.inf - - # Flag for allowing grid to charge the battery - if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): - self.allow_grid_power_consumption = h_dict[self.component_name][ - "allow_grid_power_consumption" - ] - else: - self.allow_grid_power_consumption = False - - # Efficiency and self-discharge parameters - if "roundtrip_efficiency" in h_dict[self.component_name].keys(): - self.eta_charge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) - self.eta_discharge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) - else: - self.eta_charge = 1 - self.eta_discharge = 1 - - if "self_discharge_time_constant" in h_dict[self.component_name].keys(): - self.tau_self_discharge = h_dict[self.component_name]["self_discharge_time_constant"] - else: - self.tau_self_discharge = np.inf - - if "track_usage" in h_dict[self.component_name].keys(): - if h_dict[self.component_name]["track_usage"]: - self.track_usage = True - # Set usage tracking parameters - if "usage_calc_interval" in h_dict[self.component_name].keys(): - self.usage_calc_interval = ( - h_dict[self.component_name]["usage_calc_interval"] / self.dt - ) - else: - self.usage_calc_interval = 100 / self.dt # timesteps - - if "usage_lifetime" in h_dict[self.component_name].keys(): - usage_lifetime = h_dict[self.component_name]["usage_lifetime"] - self.usage_time_rate = years_to_usage_rate(usage_lifetime, self.dt) - else: - self.usage_time_rate = 0 - if "usage_cycles" in h_dict[self.component_name].keys(): - usage_cycles = h_dict[self.component_name]["usage_cycles"] - self.usage_cycles_rate = cycles_to_usage_rate(usage_cycles) - else: - self.usage_cycles_rate = 0 - - # TODO: add the ability to impact efficiency of the battery operation - - else: - self.track_usage = False - self.usage_calc_interval = np.inf - else: - self.track_usage = False - self.usage_calc_interval = np.inf - - # Degradation and state storage - self.P_charge_storage = [] - self.E_store = [] - self.total_cycle_usage = 0 - self.cycle_usage_perc = 0 - self.total_time_usage = 0 - self.time_usage_perc = 0 - self.step_counter = 0 - # TODO there should be a better way to dynamically store these than to append a list - - self.build_SS() - self.x = np.array( - [[initial_conditions["SOC"] * self.energy_capacity * 3600]], dtype=hercules_float_type - ) - self.y = None - - # self.total_battery_capacity = 3600 * self.energy_capacity / self.dt - self.current_batt_state = self.SOC * self.energy_capacity - self.E = kWh2kJ(self.current_batt_state) - - self.power_kw = 0 - self.P_reject = 0 - self.P_charge = 0 - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - - # Add what we want later - h_dict[self.component_name]["power"] = 0 - h_dict[self.component_name]["soc"] = self.SOC - - return h_dict - - def step(self, h_dict): - """Advance the battery simulation by one time step. - - Updates the battery state including SOC, energy storage, and power output - based on the requested power setpoint and available power. Optionally - calculates usage-based degradation. - - Args: - h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] - - plant.locally_generated_power: Available power for charging [kW] - - Returns: - dict: Updated h_dict with battery outputs: - - power: Actual charging/discharging power [kW] - - reject: Rejected power due to constraints [kW] - - soc: State of charge [0-1] - - usage_in_time: Time-based usage percentage - - usage_in_cycles: Cycle-based usage percentage - - total_cycles: Total equivalent cycles completed - """ - self.step_counter += 1 - - # Power available for the battery to use for charging (should be >=0) - power_setpoint = h_dict[self.component_name]["power_setpoint"] - # Power signal desired by the controller - if self.allow_grid_power_consumption: - P_avail = np.inf - else: - P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power - - P_charge, P_reject = self.control(P_avail, power_setpoint) - - # Update energy state - # self.E += self.P_charge * self.dt - self.step_SS(P_charge) - self.E = self.x[0, 0] # TODO find a better way to make self.x 1-D - - self.current_batt_state = kJ2kWh(self.E) - - self.power_kw = P_charge - self.SOC = self.current_batt_state / self.energy_capacity - - self.P_charge_storage.append(P_charge) - self.E_store.append(self.E) - - if self.step_counter >= self.usage_calc_interval: - # reset step_counter - self.step_counter = 0 - self.calc_usage() - - # Update the outputs - h_dict[self.component_name]["power"] = self.power_kw - h_dict[self.component_name]["reject"] = P_reject - h_dict[self.component_name]["soc"] = self.SOC - h_dict[self.component_name]["usage_in_time"] = self.time_usage_perc - h_dict[self.component_name]["usage_in_cycles"] = self.cycle_usage_perc - h_dict[self.component_name]["total_cycles"] = self.total_cycle_usage - - # Return the updated dictionary - return h_dict - - def control(self, P_avail, power_setpoint): - """Apply battery operational constraints to requested power. - - Low-level controller that enforces energy, power, and ramp rate constraints. - Determines the actual charging/discharging power and any rejected power. - - Args: - P_avail (float): Available power for charging in kW. - power_setpoint (float): Desired charging/discharging power in kW. - - Returns: - tuple: (P_charge, P_reject) where: - - P_charge: Actual charging/discharging power in kW (positive for charging) - - P_reject: Rejected power due to constraints in kW (positive when - power cannot be absorbed, negative when required power unavailable) - """ - - # TODO remove ramp rate constraints because they are never used? - - # Upper constraints [kW] - # c_hi1 = (self.E_max - self.E) / self.dt # energy - c_hi1 = self.SS_input_function_inverse((self.E_max - self.x[0, 0]) / self.dt) - c_hi2 = self.P_max # power - c_hi3 = self.R_max * self.dt + self.P_charge # ramp rate - c_hi4 = P_avail - - # Lower constraints [kW] - # c_lo1 = (self.E_min - self.E) / self.dt # energy - c_lo1 = self.SS_input_function_inverse((self.E_min - self.x[0, 0]) / self.dt) - c_lo2 = self.P_min # power - c_lo3 = self.R_min * self.dt + self.P_charge # ramp rate - - # High constraint is the most restrictive of the high constraints - c_hi = np.min([c_hi1, c_hi2, c_hi3, c_hi4]) - c_hi = np.max([c_hi, 0]) - - # Low constraint is the most restrictive of the low constraints - c_lo = np.max([c_lo1, c_lo2, c_lo3]) - c_lo = np.min([c_lo, 0]) - - # TODO: force low constraint to be no higher than lowest high constraint - if (power_setpoint >= c_lo) & (power_setpoint <= c_hi): - P_charge = power_setpoint - P_reject = 0 - elif power_setpoint < c_lo: - P_charge = c_lo - P_reject = power_setpoint - P_charge - elif power_setpoint > c_hi: - P_charge = c_hi - P_reject = power_setpoint - P_charge - - self.P_charge = P_charge - self.P_reject = P_reject - - return P_charge, P_reject - - def build_SS(self): - """Build state-space model matrices for battery dynamics. - - Constructs the state-space representation that includes self-discharge - and efficiency losses. - """ - self.A = np.array([[-1 / self.tau_self_discharge]], dtype=hercules_float_type) - # B matrix is handled by the SS_input_function - self.C = np.array([[1, 0]], dtype=hercules_float_type).T - self.D = np.array([[0, 1]], dtype=hercules_float_type).T - - def SS_input_function(self, P_charge): - """Apply efficiency losses to charging/discharging power. - - Converts the commanded power to actual power stored/released from - the battery considering efficiency losses. - - Args: - P_charge (float): Commanded charging/discharging power in kW. - - Returns: - float: Actual power stored/released considering efficiency in kW. - """ - # P_in is the amount of power that actually gets stored in the state E - # P_charge is the amount of power given to the charging physics - - if P_charge >= 0: - P_in = self.eta_charge * P_charge - else: - P_in = P_charge / self.eta_discharge - return P_in - - def SS_input_function_inverse(self, P_in): - """Calculate required commanded power for desired stored power. - - Inverse of SS_input_function to determine the commanded power needed - to achieve a desired power storage/release rate. - - Args: - P_in (float): Desired power to be stored/released in kW. - - Returns: - float: Required commanded power considering efficiency in kW. - """ - if P_in >= 0: - P_charge = P_in / self.eta_charge - else: - P_charge = P_in * self.eta_discharge - return P_charge - - def step_SS(self, u): - """Advance the state-space model by one time step. - - Updates the battery energy state considering self-discharge and - efficiency losses. - - Args: - u (float): Input power command in kW. - """ - # Advance the state-space loop - xd = self.A * self.x + self.SS_input_function(u) - y = self.C * self.x + self.D * u - - self.x = self.integrate(self.x, xd) - self.y = y - - def integrate(self, x, xd): - """Integrate state derivatives using Euler method. - - Args: - x (np.ndarray): Current state vector. - xd (np.ndarray): State derivative vector. - - Returns: - np.ndarray: Updated state vector. - """ - # TODO: Use better integration method like closed form step response solution - return x + xd * self.dt # Euler integration - - def calc_usage(self): - """Calculate battery usage based on cycle counting and time. - - Uses the rainflow algorithm to count cycles in the energy storage operation - following the three-point technique (ASTM Standard E 1049-85). Also tracks - time-based usage for degradation modeling. - """ - # Count rainflow cycles - # This step uses the rainflow algorithm to count how many cycles exist in the - # storage operation using the three-point technique (ASTM Standard E 1049-85) - # The algorithm returns the size (amplitude) of the cycle, and the number of cycles at - # that amplitude at that point in the signal - ranges_counts = rainflow.count_cycles(self.E_store) - ranges = np.array([rc[0] for rc in ranges_counts], dtype=hercules_float_type) - counts = np.array([rc[1] for rc in ranges_counts], dtype=hercules_float_type) - self.total_cycle_usage = (ranges * counts).sum() / self.E_max - self.cycle_usage_perc = self.total_cycle_usage * self.usage_cycles_rate * 100 - - # Calculate time usage - self.total_time_usage += self.usage_calc_interval * self.dt - self.time_usage_perc = self.total_time_usage * self.usage_time_rate * 100 - - # self.apply_degradation(this_period_degradation) - - def apply_degradation(self, degradation): - """Apply degradation effects to battery performance. - - This method would apply the calculated degradation to battery efficiency - and capacity, but is not yet implemented. - - Args: - degradation (float): Degradation factor to apply. - - Raises: - NotImplementedError: Method is not yet implemented. - """ - # total_degradation_effect = self.total_degradation*self.degradation_rate - # print('degradation penalty', total_degradation_effect, np.sqrt(total_degradation_effect)) - # self.eta_charge = self.eta_charge - np.sqrt(total_degradation_effect) - # self.eta_discharge = self.eta_discharge - np.sqrt(total_degradation_effect) - raise NotImplementedError( - "Degradation impacts on real-time efficiency have not yet been implemented." - ) +""" +Battery models +Author: Zack tully - zachary.tully@nrel.gov +March 2024 + +References: +[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion +batteries, incorporating the effects of state of health, state of charge, and +temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, +Nov. 2021, doi: 10.1016/j.est.2021.103252. +""" + +import numpy as np +import rainflow +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import hercules_float_type + + +def kJ2kWh(kJ): + """Convert a value in kJ to kWh. + + Args: + kJ (float): Energy value in kilojoules. + + Returns: + float: Energy value in kilowatt-hours. + """ + return kJ / 3600 + + +def kWh2kJ(kWh): + """Convert a value in kWh to kJ. + + Args: + kWh (float): Energy value in kilowatt-hours. + + Returns: + float: Energy value in kilojoules. + """ + return kWh * 3600 + + +def years_to_usage_rate(years, dt): + """Convert a number of years to a usage rate. + + Args: + years (float): Life of the storage system in years. + dt (float): Time step of the simulation in seconds. + + Returns: + float: Usage rate per time step. + """ + days = years * 365 + hours = days * 24 + seconds = hours * 3600 + usage_lifetime = seconds / dt + + return 1 / usage_lifetime + + +def cycles_to_usage_rate(cycles): + """Convert cycle number to degradation rate. + + Args: + cycles (int): Number of cycles until the unit needs to be replaced. + + Returns: + float: Degradation rate per cycle. + """ + return 1 / cycles + + +class BatterySimple(ComponentBase): + """Simple battery energy storage model. + + This model represents a basic battery with energy storage and power constraints. + It tracks state of charge, applies efficiency losses, and optionally tracks + usage-based degradation using rainflow cycle counting. + + Note: + All power units are in kW and energy units are in kWh. + """ + + component_category = "battery" + + def __init__(self, h_dict, component_name): + """Initialize the BatterySimple class. + + This model represents a simple battery with energy storage and power constraints. + It tracks state of charge and applies efficiency losses. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - energy_capacity: Battery energy capacity in kWh + - charge_rate: Maximum charge rate in kW + - discharge_rate: Maximum discharge rate in kW + - max_SOC: Maximum state of charge (0-1) + - min_SOC: Minimum state of charge (0-1) + - initial_conditions: Dictionary with initial SOC + - allow_grid_power_consumption: Optional, defaults to False + - roundtrip_efficiency: Optional roundtrip efficiency (0-1) + - self_discharge_time_constant: Optional self-discharge time constant + - track_usage: Optional boolean to enable usage tracking + component_name (str): Unique name for this instance (the YAML top-level key). + """ + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) + + # size = h_dict[self.component_name]["size"] + self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] + initial_conditions = h_dict[self.component_name]["initial_conditions"] + self.SOC = initial_conditions["SOC"] # [fraction] + + self.SOC_max = h_dict[self.component_name]["max_SOC"] + self.SOC_min = h_dict[self.component_name]["min_SOC"] + + # Charge (Energy) limits [kJ] + self.E_min = kWh2kJ(self.SOC_min * self.energy_capacity) + self.E_max = kWh2kJ(self.SOC_max * self.energy_capacity) + + charge_rate = h_dict[self.component_name]["charge_rate"] # [kW] + discharge_rate = h_dict[self.component_name]["discharge_rate"] # [kW] + + # Charge/discharge (Power) limits [kW] + self.P_min = -discharge_rate + self.P_max = charge_rate + + # Ramp up/down limits [kW/s] + self.R_min = -np.inf + self.R_max = np.inf + + # Flag for allowing grid to charge the battery + if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): + self.allow_grid_power_consumption = h_dict[self.component_name][ + "allow_grid_power_consumption" + ] + else: + self.allow_grid_power_consumption = False + + # Efficiency and self-discharge parameters + if "roundtrip_efficiency" in h_dict[self.component_name].keys(): + self.eta_charge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) + self.eta_discharge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) + else: + self.eta_charge = 1 + self.eta_discharge = 1 + + if "self_discharge_time_constant" in h_dict[self.component_name].keys(): + self.tau_self_discharge = h_dict[self.component_name]["self_discharge_time_constant"] + else: + self.tau_self_discharge = np.inf + + if "track_usage" in h_dict[self.component_name].keys(): + if h_dict[self.component_name]["track_usage"]: + self.track_usage = True + # Set usage tracking parameters + if "usage_calc_interval" in h_dict[self.component_name].keys(): + self.usage_calc_interval = ( + h_dict[self.component_name]["usage_calc_interval"] / self.dt + ) + else: + self.usage_calc_interval = 100 / self.dt # timesteps + + if "usage_lifetime" in h_dict[self.component_name].keys(): + usage_lifetime = h_dict[self.component_name]["usage_lifetime"] + self.usage_time_rate = years_to_usage_rate(usage_lifetime, self.dt) + else: + self.usage_time_rate = 0 + if "usage_cycles" in h_dict[self.component_name].keys(): + usage_cycles = h_dict[self.component_name]["usage_cycles"] + self.usage_cycles_rate = cycles_to_usage_rate(usage_cycles) + else: + self.usage_cycles_rate = 0 + + # TODO: add the ability to impact efficiency of the battery operation + + else: + self.track_usage = False + self.usage_calc_interval = np.inf + else: + self.track_usage = False + self.usage_calc_interval = np.inf + + # Degradation and state storage + self.P_charge_storage = [] + self.E_store = [] + self.total_cycle_usage = 0 + self.cycle_usage_perc = 0 + self.total_time_usage = 0 + self.time_usage_perc = 0 + self.step_counter = 0 + # TODO there should be a better way to dynamically store these than to append a list + + self.build_SS() + self.x = np.array( + [[initial_conditions["SOC"] * self.energy_capacity * 3600]], dtype=hercules_float_type + ) + self.y = None + + # self.total_battery_capacity = 3600 * self.energy_capacity / self.dt + self.current_batt_state = self.SOC * self.energy_capacity + self.E = kWh2kJ(self.current_batt_state) + + self.power_kw = 0 + self.P_reject = 0 + self.P_charge = 0 + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + + # Add what we want later + h_dict[self.component_name]["power"] = 0 + h_dict[self.component_name]["soc"] = self.SOC + + return h_dict + + def step(self, h_dict): + """Advance the battery simulation by one time step. + + Updates the battery state including SOC, energy storage, and power output + based on the requested power setpoint and available power. Optionally + calculates usage-based degradation. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - battery.power_setpoint: Requested charging/discharging power [kW] + - plant.locally_generated_power: Available power for charging [kW] + + Returns: + dict: Updated h_dict with battery outputs: + - power: Actual charging/discharging power [kW] + - reject: Rejected power due to constraints [kW] + - soc: State of charge [0-1] + - usage_in_time: Time-based usage percentage + - usage_in_cycles: Cycle-based usage percentage + - total_cycles: Total equivalent cycles completed + """ + self.step_counter += 1 + + # Power available for the battery to use for charging (should be >=0) + power_setpoint = h_dict[self.component_name]["power_setpoint"] + # Power signal desired by the controller + if self.allow_grid_power_consumption: + P_avail = np.inf + else: + P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power + + P_charge, P_reject = self.control(P_avail, power_setpoint) + + # Update energy state + # self.E += self.P_charge * self.dt + self.step_SS(P_charge) + self.E = self.x[0, 0] # TODO find a better way to make self.x 1-D + + self.current_batt_state = kJ2kWh(self.E) + + self.power_kw = P_charge + self.SOC = self.current_batt_state / self.energy_capacity + + self.P_charge_storage.append(P_charge) + self.E_store.append(self.E) + + if self.step_counter >= self.usage_calc_interval: + # reset step_counter + self.step_counter = 0 + self.calc_usage() + + # Update the outputs + h_dict[self.component_name]["power"] = self.power_kw + h_dict[self.component_name]["reject"] = P_reject + h_dict[self.component_name]["soc"] = self.SOC + h_dict[self.component_name]["usage_in_time"] = self.time_usage_perc + h_dict[self.component_name]["usage_in_cycles"] = self.cycle_usage_perc + h_dict[self.component_name]["total_cycles"] = self.total_cycle_usage + + # Return the updated dictionary + return h_dict + + def control(self, P_avail, power_setpoint): + """Apply battery operational constraints to requested power. + + Low-level controller that enforces energy, power, and ramp rate constraints. + Determines the actual charging/discharging power and any rejected power. + + Args: + P_avail (float): Available power for charging in kW. + power_setpoint (float): Desired charging/discharging power in kW. + + Returns: + tuple: (P_charge, P_reject) where: + - P_charge: Actual charging/discharging power in kW (positive for charging) + - P_reject: Rejected power due to constraints in kW (positive when + power cannot be absorbed, negative when required power unavailable) + """ + + # TODO remove ramp rate constraints because they are never used? + + # Upper constraints [kW] + # c_hi1 = (self.E_max - self.E) / self.dt # energy + c_hi1 = self.SS_input_function_inverse((self.E_max - self.x[0, 0]) / self.dt) + c_hi2 = self.P_max # power + c_hi3 = self.R_max * self.dt + self.P_charge # ramp rate + c_hi4 = P_avail + + # Lower constraints [kW] + # c_lo1 = (self.E_min - self.E) / self.dt # energy + c_lo1 = self.SS_input_function_inverse((self.E_min - self.x[0, 0]) / self.dt) + c_lo2 = self.P_min # power + c_lo3 = self.R_min * self.dt + self.P_charge # ramp rate + + # High constraint is the most restrictive of the high constraints + c_hi = np.min([c_hi1, c_hi2, c_hi3, c_hi4]) + c_hi = np.max([c_hi, 0]) + + # Low constraint is the most restrictive of the low constraints + c_lo = np.max([c_lo1, c_lo2, c_lo3]) + c_lo = np.min([c_lo, 0]) + + # TODO: force low constraint to be no higher than lowest high constraint + if (power_setpoint >= c_lo) & (power_setpoint <= c_hi): + P_charge = power_setpoint + P_reject = 0 + elif power_setpoint < c_lo: + P_charge = c_lo + P_reject = power_setpoint - P_charge + elif power_setpoint > c_hi: + P_charge = c_hi + P_reject = power_setpoint - P_charge + + self.P_charge = P_charge + self.P_reject = P_reject + + return P_charge, P_reject + + def build_SS(self): + """Build state-space model matrices for battery dynamics. + + Constructs the state-space representation that includes self-discharge + and efficiency losses. + """ + self.A = np.array([[-1 / self.tau_self_discharge]], dtype=hercules_float_type) + # B matrix is handled by the SS_input_function + self.C = np.array([[1, 0]], dtype=hercules_float_type).T + self.D = np.array([[0, 1]], dtype=hercules_float_type).T + + def SS_input_function(self, P_charge): + """Apply efficiency losses to charging/discharging power. + + Converts the commanded power to actual power stored/released from + the battery considering efficiency losses. + + Args: + P_charge (float): Commanded charging/discharging power in kW. + + Returns: + float: Actual power stored/released considering efficiency in kW. + """ + # P_in is the amount of power that actually gets stored in the state E + # P_charge is the amount of power given to the charging physics + + if P_charge >= 0: + P_in = self.eta_charge * P_charge + else: + P_in = P_charge / self.eta_discharge + return P_in + + def SS_input_function_inverse(self, P_in): + """Calculate required commanded power for desired stored power. + + Inverse of SS_input_function to determine the commanded power needed + to achieve a desired power storage/release rate. + + Args: + P_in (float): Desired power to be stored/released in kW. + + Returns: + float: Required commanded power considering efficiency in kW. + """ + if P_in >= 0: + P_charge = P_in / self.eta_charge + else: + P_charge = P_in * self.eta_discharge + return P_charge + + def step_SS(self, u): + """Advance the state-space model by one time step. + + Updates the battery energy state considering self-discharge and + efficiency losses. + + Args: + u (float): Input power command in kW. + """ + # Advance the state-space loop + xd = self.A * self.x + self.SS_input_function(u) + y = self.C * self.x + self.D * u + + self.x = self.integrate(self.x, xd) + self.y = y + + def integrate(self, x, xd): + """Integrate state derivatives using Euler method. + + Args: + x (np.ndarray): Current state vector. + xd (np.ndarray): State derivative vector. + + Returns: + np.ndarray: Updated state vector. + """ + # TODO: Use better integration method like closed form step response solution + return x + xd * self.dt # Euler integration + + def calc_usage(self): + """Calculate battery usage based on cycle counting and time. + + Uses the rainflow algorithm to count cycles in the energy storage operation + following the three-point technique (ASTM Standard E 1049-85). Also tracks + time-based usage for degradation modeling. + """ + # Count rainflow cycles + # This step uses the rainflow algorithm to count how many cycles exist in the + # storage operation using the three-point technique (ASTM Standard E 1049-85) + # The algorithm returns the size (amplitude) of the cycle, and the number of cycles at + # that amplitude at that point in the signal + ranges_counts = rainflow.count_cycles(self.E_store) + ranges = np.array([rc[0] for rc in ranges_counts], dtype=hercules_float_type) + counts = np.array([rc[1] for rc in ranges_counts], dtype=hercules_float_type) + self.total_cycle_usage = (ranges * counts).sum() / self.E_max + self.cycle_usage_perc = self.total_cycle_usage * self.usage_cycles_rate * 100 + + # Calculate time usage + self.total_time_usage += self.usage_calc_interval * self.dt + self.time_usage_perc = self.total_time_usage * self.usage_time_rate * 100 + + # self.apply_degradation(this_period_degradation) + + def apply_degradation(self, degradation): + """Apply degradation effects to battery performance. + + This method would apply the calculated degradation to battery efficiency + and capacity, but is not yet implemented. + + Args: + degradation (float): Degradation factor to apply. + + Raises: + NotImplementedError: Method is not yet implemented. + """ + # total_degradation_effect = self.total_degradation*self.degradation_rate + # print('degradation penalty', total_degradation_effect, np.sqrt(total_degradation_effect)) + # self.eta_charge = self.eta_charge - np.sqrt(total_degradation_effect) + # self.eta_discharge = self.eta_discharge - np.sqrt(total_degradation_effect) + raise NotImplementedError( + "Degradation impacts on real-time efficiency have not yet been implemented." + ) diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py index ada7e5bc..42e4f645 100644 --- a/hercules/plant_components/electrolyzer_plant.py +++ b/hercules/plant_components/electrolyzer_plant.py @@ -90,7 +90,6 @@ def __init__(self, h_dict, component_name): - discount_rate: Discount rate for financial calculations [%]. - install_factor: Installation factor for capital expenditure [0,1]. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"electrolyzer"`` for backward compatibility. """ # Call the base class init (sets self.component_name and self.component_type) diff --git a/hercules/plant_components/open_cycle_gas_turbine.py b/hercules/plant_components/open_cycle_gas_turbine.py index 15d4d347..014f9368 100644 --- a/hercules/plant_components/open_cycle_gas_turbine.py +++ b/hercules/plant_components/open_cycle_gas_turbine.py @@ -84,7 +84,6 @@ def __init__(self, h_dict, component_name): power_fraction = [1.0, 0.75, 0.50, 0.25], efficiency = [0.39, 0.37, 0.325, 0.245]. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"open_cycle_gas_turbine"`` for backward compatibility. """ # Apply fixed default parameters based on [1], [2] and [3] diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 96b9e833..9ac8a53f 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -26,7 +26,6 @@ def __init__(self, h_dict, component_name): Args: h_dict (dict): Dictionary containing simulation parameters. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"solar_farm"`` for backward compatibility. """ # Call the base class init (sets self.component_name and self.component_type) super().__init__(h_dict, component_name) diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 517aae19..59db3157 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -15,7 +15,6 @@ def __init__(self, h_dict, component_name): Args: h_dict (dict): Dictionary containing simulation parameters. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"solar_farm"`` for backward compatibility. """ # Call the base class init (sets self.component_name and self.component_type) super().__init__(h_dict, component_name) diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py index 10e50c29..ee1ddfa4 100644 --- a/hercules/plant_components/wind_farm.py +++ b/hercules/plant_components/wind_farm.py @@ -47,7 +47,6 @@ def __init__(self, h_dict, component_name): Args: h_dict (dict): Dictionary containing simulation parameters. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"wind_farm"`` for backward compatibility. Raises: ValueError: If wake_method is invalid or required parameters are missing. diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py index b249fc11..6efec212 100644 --- a/hercules/plant_components/wind_farm_scada_power.py +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -20,7 +20,6 @@ def __init__(self, h_dict, component_name): Args: h_dict (dict): Dictionary containing simulation parameters. component_name (str): Unique name for this instance (the YAML top-level key). - Defaults to ``"wind_farm"`` for backward compatibility. """ # Call the base class init (sets self.component_name and self.component_type) super().__init__(h_dict, component_name) From 4385d858e4ae4b52972b96fbf5216e6c1ab673a7 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:20:02 -0700 Subject: [PATCH 12/38] linting --- tests/open_cycle_gas_turbine_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/open_cycle_gas_turbine_test.py b/tests/open_cycle_gas_turbine_test.py index cbaa25cc..c3dff7a9 100644 --- a/tests/open_cycle_gas_turbine_test.py +++ b/tests/open_cycle_gas_turbine_test.py @@ -11,7 +11,9 @@ def test_init_from_dict(): """Test that OpenCycleGasTurbine can be initialized from a dictionary.""" - ocgt = OpenCycleGasTurbine(copy.deepcopy(h_dict_open_cycle_gas_turbine), "open_cycle_gas_turbine") + ocgt = OpenCycleGasTurbine( + copy.deepcopy(h_dict_open_cycle_gas_turbine), "open_cycle_gas_turbine" + ) assert ocgt is not None From 08c2896f63adc7d2f4ade06a3e88c0a8b1116768 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Thu, 26 Feb 2026 15:43:53 -0700 Subject: [PATCH 13/38] Update docs --- docs/_toc.yml | 1 + docs/battery.md | 2 +- docs/component_types.md | 106 +++++++++++++++++++++++++++++++++ docs/electrolyzer.md | 2 +- docs/h_dict.md | 23 +++++-- docs/hercules_input.md | 8 +-- docs/hybrid_plant.md | 27 ++++++--- docs/open_cycle_gas_turbine.md | 2 + docs/solar_pv.md | 2 +- 9 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 docs/component_types.md diff --git a/docs/_toc.yml b/docs/_toc.yml index 1977a67f..42349558 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -18,6 +18,7 @@ parts: - file: timing - file: h_dict - file: hybrid_plant + - file: component_types - file: hercules_model - file: output_files - caption: Plant Components diff --git a/docs/battery.md b/docs/battery.md index 761790e2..6590cf78 100644 --- a/docs/battery.md +++ b/docs/battery.md @@ -12,7 +12,7 @@ for consistency with other components. This inversion applies to power_setpoint ### Parameters -Battery parameters are defined in the hercules input yaml file used to initialize `HerculesModel`. +Battery parameters are defined in the hercules input yaml file used to initialize `HerculesModel`. The YAML section key is a user-chosen `component_name` (e.g. `battery`, `battery_unit_1`); the examples below use `battery` as a conventional choice. See [Component Names, Types, and Categories](component_types.md) for details. #### Required Parameters - `component_type`: `"BatterySimple"` or `"BatteryLithiumIon"` diff --git a/docs/component_types.md b/docs/component_types.md new file mode 100644 index 00000000..ec2078a8 --- /dev/null +++ b/docs/component_types.md @@ -0,0 +1,106 @@ +# Component Names, Types, and Categories + +Three related but distinct concepts govern how plant components are identified in Hercules: `component_name`, `component_type`, and `component_category`. Understanding the distinction is important for writing YAML input files and for programmatically working with `h_dict`. + +## The Three Concepts + +### `component_name` + +The **component name** is the top-level YAML key for the component section. It is user-chosen and becomes the key used to access the component's state in `h_dict` throughout the simulation. + +- **Source**: YAML input file (the key you choose) +- **Can be**: Any valid YAML string — `"battery"`, `"battery_unit_1"`, `"my_wind_farm"`, etc. +- **Available as**: `self.component_name` on the component object; `h_dict[component_name]` at runtime + +The name does not need to match the category. Using the category name (e.g. `battery:`) is a common convention for single-instance plants and is used throughout most examples in these docs. + +### `component_type` + +The **component type** is the string value of the `component_type` field inside the component's YAML section. It determines which Python class gets instantiated. + +- **Source**: `component_type:` field in the component's YAML block +- **Must be**: Exactly one of the registered class name strings (see [reference table](#complete-component-type-reference) below) +- **Available as**: `self.component_type` on the component object (set automatically from the class name — never needs to be hardcoded in component code) + +### `component_category` + +The **component category** is a class-level attribute defined in each component class. It is not read from YAML — it is part of the class definition itself. + +- **Source**: `component_category = "..."` class variable in the Python class +- **Used by**: `HybridPlant` to classify components as generators vs. storage/conversion, and to apply the battery sign convention +- **Available as**: `self.component_category` on the component object (and `ComponentBase.component_category` as a class attribute) + +Every `ComponentBase` subclass **must** define `component_category`; a `TypeError` is raised at class-definition time if it is missing. + +### Summary + +| Concept | Set by | Example value | Used for | +|---|---|---|---| +| `component_name` | User (YAML key) | `"battery_unit_1"` | Accessing `h_dict[name]`; unique instance ID | +| `component_type` | User (`component_type:` field) | `"BatterySimple"` | Registry lookup to select the Python class | +| `component_category` | Developer (class variable) | `"battery"` | Generator classification; sign convention | + +--- + +## Complete Component Type Reference + +| `component_type` | `component_category` | Generator? | Documentation | +|---|---|---|---| +| `WindFarm` | `wind_farm` | Yes | [Wind](wind.md) | +| `WindFarmSCADAPower` | `wind_farm` | Yes | [Wind](wind.md) | +| `SolarPySAMPVWatts` | `solar_farm` | Yes | [Solar PV](solar_pv.md) | +| `BatterySimple` | `battery` | No | [Battery](battery.md) | +| `BatteryLithiumIon` | `battery` | No | [Battery](battery.md) | +| `ElectrolyzerPlant` | `electrolyzer` | No | [Electrolyzer](electrolyzer.md) | +| `OpenCycleGasTurbine` | `thermal` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | + +Components in the `wind_farm`, `solar_farm`, and `thermal` categories are classified as generators and contribute to `h_dict["plant"]["locally_generated_power"]`. + +--- + +## Multi-Instance Plants + +Because `component_name` is user-chosen, you can include multiple instances of the same `component_type` in one plant. Give each instance a unique YAML key: + +```yaml +battery_unit_1: + component_type: BatterySimple + energy_capacity: 100.0 # kWh + charge_rate: 50.0 # kW + discharge_rate: 50.0 # kW + max_SOC: 0.9 + min_SOC: 0.1 + initial_conditions: + SOC: 0.5 + +battery_unit_2: + component_type: BatterySimple + energy_capacity: 200.0 # kWh + charge_rate: 100.0 # kW + discharge_rate: 100.0 # kW + max_SOC: 0.95 + min_SOC: 0.05 + initial_conditions: + SOC: 0.8 +``` + +In a controller, access each instance by its name: + +```python +class MyController: + def step(self, h_dict): + power_1 = h_dict["battery_unit_1"]["power"] + power_2 = h_dict["battery_unit_2"]["power"] + + h_dict["battery_unit_1"]["power_setpoint"] = 25.0 + h_dict["battery_unit_2"]["power_setpoint"] = -50.0 + return h_dict +``` + +`h_dict["component_names"]` contains the list of all discovered component names, e.g. `["battery_unit_1", "battery_unit_2"]`. + +--- + +## Conventions + +For single-instance plants, it is conventional to use the `component_category` as the YAML key — e.g. `battery:`, `wind_farm:`, `solar_farm:`. This matches most examples in these docs and makes the input file easy to read. It is not required; the key is always user-chosen. diff --git a/docs/electrolyzer.md b/docs/electrolyzer.md index 991089f9..cbf43b8e 100644 --- a/docs/electrolyzer.md +++ b/docs/electrolyzer.md @@ -2,7 +2,7 @@ The hydrogen electrolyzer modules use the [electrolyzer](https://github.com/NREL/electrolyzer) package developed by the National Laboratory of the Rockies to predict hydrogen output of hydrogen electrolyzer plants. This repo contains models for PEM and Alkaline electrolyzer cell types. -To create a hydrogen electrolyzer plant, set `component_type` = `ElectrolyzerPlant` in the input dictionary (.yaml file). +To create a hydrogen electrolyzer plant, set `component_type: ElectrolyzerPlant` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `electrolyzer`); see [Component Names, Types, and Categories](component_types.md) for details. ## Inputs diff --git a/docs/h_dict.md b/docs/h_dict.md index 58e8e778..58f24a95 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -30,7 +30,9 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `controller` | dict | Controller configuration | - | | **Hybrid Plant Components** | -### Wind Farm (`wind_farm`) +Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is auto-discovered as a plant component. The key is a user-chosen `component_name` (e.g. `wind_farm`, `battery_unit_1`) — it does not need to match the category name. See [Component Names, Types, and Categories](component_types.md) for details. + +### Wind Farm | `component_type` | str | Must be "WindFarm" or "WindFarmSCADAPower" | | `floris_input_file` | str | FLORIS input file path | | `wind_input_filename` | str | Wind data input file | @@ -39,7 +41,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `log_channels` | list | List of channels to log (e.g., ["power", "wind_speed_mean_background", "turbine_powers"]) | | `floris_update_time_s` | float | How often to update FLORIS wake calculations in seconds | -### Solar Farm (`solar_farm`) +### Solar Farm | `component_type` | str | "SolarPySAMPVWatts" | | **For SolarPySAMPVWatts:** | | `pysam_model` | str | "pvwatts" | @@ -52,7 +54,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `log_channels` | list | List of channels to log (e.g., ["power", "dni", "poa", "aoi"]) | | `initial_conditions` | dict | Initial power, DNI, POA | -### Battery (`battery`) +### Battery | Key | Type | Description | Default | |-----|------|-------------|---------| | `component_type` | str | "BatterySimple" or "BatteryLithiumIon" | Required | @@ -71,7 +73,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `usage_lifetime` | float | Battery lifetime in years (BatterySimple only) | - | | `usage_cycles` | int | Number of cycles until replacement (BatterySimple only) | - | -### Electrolyzer (`electrolyzer`) +### Electrolyzer | Key | Type | Description | |-----|------|-------------| | `initialize` | bool | Initialize electrolyzer | @@ -83,6 +85,19 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `cell_params` | dict | Cell parameters | | `degradation` | dict | Degradation parameters | +### Open Cycle Gas Turbine + +Set `component_type: OpenCycleGasTurbine`. See {doc}`open_cycle_gas_turbine` for the full parameter reference. + +| Key | Type | Description | Default | +|-----|------|-------------|---------| +| `component_type` | str | `"OpenCycleGasTurbine"` | Required | +| `rated_capacity` | float | Rated power output in kW | Required | +| `initial_conditions` | dict | Initial state (`power`, `state`) | Required | +| `min_stable_load_fraction` | float | Minimum stable load as fraction of rated capacity | 0.40 | +| `ramp_rate_fraction` | float | Ramp rate as fraction of rated capacity per minute | 0.10 | +| `log_channels` | list | List of channels to log | `["power"]` | + ### External Data (`external_data`) | Key | Type | Description | Default | |-----|------|-------------|---------| diff --git a/docs/hercules_input.md b/docs/hercules_input.md index 0ad7416c..0af8c995 100644 --- a/docs/hercules_input.md +++ b/docs/hercules_input.md @@ -12,7 +12,7 @@ The input file structure mirrors the `h_dict` structure documented in the [h_dic - **Top level parameters**: `dt`, `starttime_utc`, `endtime_utc` (see [timing](timing.md) for details) - **Plant configuration**: `interconnect_limit` -- **Hybrid plant configurations**: `wind_farm`, `solar_farm`, `battery`, `electrolyzer` +- **Plant component sections**: any number of user-named sections, each containing a `component_type` key that identifies the component class to use (see [Component Names, Types, and Categories](component_types.md)) - **External data**: `external_data` for external time series data (e.g., LMP prices, weather forecasts) - **Optional settings**: `verbose`, `name`, `description`, `output_file` @@ -45,7 +45,7 @@ verbose: False plant: interconnect_limit: 30000 # kW -wind_farm: +wind_farm: # User-chosen component_name; component_type determines the class component_type: WindFarm wake_method: dynamic floris_input_file: inputs/floris_input.yaml @@ -59,7 +59,7 @@ wind_farm: - wind_direction_mean floris_update_time_s: 30.0 -solar_farm: +solar_farm: # User-chosen component_name component_type: SolarPySAMPVWatts solar_input_filename: inputs/solar_input.csv lat: 39.7442 @@ -77,7 +77,7 @@ solar_farm: dni: 1000 poa: 1000 -battery: +battery: # User-chosen component_name component_type: BatterySimple energy_capacity: 100.0 # MWh charge_rate: 50.0 # MW diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index b764dc6d..def6193f 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -4,15 +4,24 @@ The `HybridPlant` class manages all plant components in Hercules. It handles ini ## Overview -HybridPlant automatically detects and initializes components based on the [h_dict structure](h_dict.md). Each component is configured through its respective section in the h_dict (e.g., `wind_farm`, `solar_farm`, `battery`, `electrolyzer`). +`HybridPlant` auto-discovers components from the [h_dict](h_dict.md) at initialization time. Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is treated as a plant component. The YAML key becomes the component's `component_name` (a user-chosen instance identifier), and the `component_type` value determines which Python class is instantiated. + +See [Component Names, Types, and Categories](component_types.md) for a full explanation of how `component_name`, `component_type`, and `component_category` relate to each other. ## Available Components -| Component | Component Type | Description | -|-----------|----------------|-------------| -| `wind_farm` | `WindFarm` | FLORIS-based wind farm simulation | -| `wind_farm` | `WindFarmSCADAPower` | Pass through wind farm SCADA | -| `solar_farm` | `SolarPySAMPVWatts` | PySAM-based simplified solar simulation | -| `battery` | `BatterySimple` | Basic battery storage model | -| `battery` | `BatteryLithiumIon` | Detailed lithium-ion battery model | -| `electrolyzer` | `ElectrolyzerPlant` | Hydrogen production system | +| `component_type` | `component_category` | Generator? | Documentation | +|---|---|---|---| +| `WindFarm` | `wind_farm` | Yes | [Wind](wind.md) | +| `WindFarmSCADAPower` | `wind_farm` | Yes | [Wind](wind.md) | +| `SolarPySAMPVWatts` | `solar_farm` | Yes | [Solar PV](solar_pv.md) | +| `BatterySimple` | `battery` | No | [Battery](battery.md) | +| `BatteryLithiumIon` | `battery` | No | [Battery](battery.md) | +| `ElectrolyzerPlant` | `electrolyzer` | No | [Electrolyzer](electrolyzer.md) | +| `OpenCycleGasTurbine` | `thermal` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | + +The YAML key for each section is a user-chosen `component_name` and is not required to match the category name. For example, a `BatterySimple` component could be named `battery`, `battery_unit_1`, or anything else. + +## Generator Classification + +`HybridPlant` classifies components into generators and non-generators based on `component_category`. Components in the `wind_farm`, `solar_farm`, and `thermal` categories are generators; their power outputs are summed into `h_dict["plant"]["locally_generated_power"]` each time step. Batteries and electrolyzers are excluded from this sum. diff --git a/docs/open_cycle_gas_turbine.md b/docs/open_cycle_gas_turbine.md index b2a4aa27..59ae68b3 100644 --- a/docs/open_cycle_gas_turbine.md +++ b/docs/open_cycle_gas_turbine.md @@ -2,6 +2,8 @@ The `OpenCycleGasTurbine` class models an open-cycle gas turbine (OCGT), also known as a peaker plant or simple-cycle gas turbine. Since this class is focused on peaker plant behavior, this class was developed based on aeroderivative engines. It is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class. +Set `component_type: OpenCycleGasTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `open_cycle_gas_turbine`); see [Component Names, Types, and Categories](component_types.md) for details. + For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`. ## OCGT-Specific Parameters diff --git a/docs/solar_pv.md b/docs/solar_pv.md index d9a00311..ae84d729 100644 --- a/docs/solar_pv.md +++ b/docs/solar_pv.md @@ -4,7 +4,7 @@ The solar PV modules use the [PySAM](https://nrel-pysam.readthedocs.io/en/main/o Presently only one solar simulator is available -1. **`SolarPySAMPVWatts`** - Uses the [PVWatts model](https://sam.nrel.gov/photovoltaic.html) in [`Pvwattsv8`](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html), which calculates estimated PV electrical output with configurable efficiency and loss parameters. This model is less detailed but more time-efficient, making it suitable for longer duration simulations (approximately 1 year). Set `component_type` = `SolarPySAMPVWatts` in the input dictionary (.yaml file). +1. **`SolarPySAMPVWatts`** - Uses the [PVWatts model](https://sam.nrel.gov/photovoltaic.html) in [`Pvwattsv8`](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html), which calculates estimated PV electrical output with configurable efficiency and loss parameters. This model is less detailed but more time-efficient, making it suitable for longer duration simulations (approximately 1 year). Set `component_type: SolarPySAMPVWatts` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `solar_farm`); see [Component Names, Types, and Categories](component_types.md) for details. From f52adb130904733a79a0870bce531a1feaac2637 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 08:51:53 -0700 Subject: [PATCH 14/38] Add component types module --- hercules/component_types.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 hercules/component_types.py diff --git a/hercules/component_types.py b/hercules/component_types.py new file mode 100644 index 00000000..37daea75 --- /dev/null +++ b/hercules/component_types.py @@ -0,0 +1,19 @@ +"""Canonical definitions of Hercules component types. + +This module defines the single source of truth for all valid +``component_type`` string values that can appear in Hercules input +files and in the HybridPlant component registry. +""" + +# Canonical list of all supported component_type strings. +# When adding a new component, update this sequence and the +# _COMPONENT_REGISTRY in hybrid_plant to keep them aligned. +VALID_COMPONENT_TYPES = ( + "WindFarm", + "WindFarmSCADAPower", + "SolarPySAMPVWatts", + "BatterySimple", + "BatteryLithiumIon", + "ElectrolyzerPlant", + "OpenCycleGasTurbine", +) From 781e863cf528dff583ccdf819e21f9a9ba6a0ab6 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 08:52:17 -0700 Subject: [PATCH 15/38] import component types rather then redefining --- hercules/utilities.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/hercules/utilities.py b/hercules/utilities.py index a963ead9..0a8fa1b7 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -11,22 +11,15 @@ import yaml from scipy.interpolate import interp1d +from hercules.component_types import VALID_COMPONENT_TYPES + # Hercules float type for consistent precision hercules_float_type = np.float32 hercules_complex_type = np.csingle # All component_type strings (class names) that can appear in a YAML component_type field. -# Keep this in sync with hybrid_plant._COMPONENT_REGISTRY. -_VALID_COMPONENT_TYPES = [ - "WindFarm", - "WindFarmSCADAPower", - "SolarPySAMPVWatts", - "BatterySimple", - "BatteryLithiumIon", - "ElectrolyzerPlant", - "OpenCycleGasTurbine", -] +_VALID_COMPONENT_TYPES = list(VALID_COMPONENT_TYPES) class Loader(yaml.SafeLoader): From c45ee6084e89b2c23b90836d5e38153d4fdb2ee5 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 08:52:41 -0700 Subject: [PATCH 16/38] check component registry against componet types --- hercules/hybrid_plant.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index d4a1a419..9d3b1643 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -1,5 +1,6 @@ import numpy as np +from hercules.component_types import VALID_COMPONENT_TYPES from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon from hercules.plant_components.battery_simple import BatterySimple from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant @@ -20,6 +21,14 @@ "OpenCycleGasTurbine": OpenCycleGasTurbine, } +# Import-time safety check to prevent drift between this registry and VALID_COMPONENT_TYPES. +if set(_COMPONENT_REGISTRY) != set(VALID_COMPONENT_TYPES): + raise RuntimeError( + "HybridPlant component registry keys are out of sync with VALID_COMPONENT_TYPES. " + f"Registry keys: {sorted(_COMPONENT_REGISTRY)}; " + f"VALID_COMPONENT_TYPES: {sorted(VALID_COMPONENT_TYPES)}" + ) + # component_category values that represent generators (vs. storage/conversion) _GENERATOR_CATEGORIES = {"wind_farm", "solar_farm", "thermal"} From fd320ba1f41eeff0e755722d351da1b46d9996f8 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 09:08:35 -0700 Subject: [PATCH 17/38] remove hard-coding --- hercules/plant_components/wind_farm.py | 16 ++++++++-------- .../plant_components/wind_farm_scada_power.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py index ee1ddfa4..469cfaa7 100644 --- a/hercules/plant_components/wind_farm.py +++ b/hercules/plant_components/wind_farm.py @@ -570,17 +570,17 @@ def get_initial_conditions_and_meta_data(self, h_dict): Returns: dict: Dictionary containing simulation parameters with initial conditions and meta data. """ - h_dict["wind_farm"]["n_turbines"] = self.n_turbines - h_dict["wind_farm"]["capacity"] = self.capacity - h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power - h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0] - h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0] - h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers - h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) + h_dict[self.component_name]["n_turbines"] = self.n_turbines + h_dict[self.component_name]["capacity"] = self.capacity + h_dict[self.component_name]["rated_turbine_power"] = self.rated_turbine_power + h_dict[self.component_name]["wind_direction_mean"] = self.wd_mat_mean[0] + h_dict[self.component_name]["wind_speed_mean_background"] = self.ws_mat_mean[0] + h_dict[self.component_name]["turbine_powers"] = self.turbine_powers + h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) # Log the start time UTC if available if hasattr(self, "starttime_utc"): - h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc + h_dict[self.component_name]["starttime_utc"] = self.starttime_utc return h_dict diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py index 6efec212..a93281ee 100644 --- a/hercules/plant_components/wind_farm_scada_power.py +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -202,17 +202,17 @@ def get_initial_conditions_and_meta_data(self, h_dict): Returns: dict: Dictionary containing simulation parameters with initial conditions and meta data. """ - h_dict["wind_farm"]["n_turbines"] = self.n_turbines - h_dict["wind_farm"]["capacity"] = self.capacity - h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power - h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0] - h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0] - h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers - h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) + h_dict[self.component_name]["n_turbines"] = self.n_turbines + h_dict[self.component_name]["capacity"] = self.capacity + h_dict[self.component_name]["rated_turbine_power"] = self.rated_turbine_power + h_dict[self.component_name]["wind_direction_mean"] = self.wd_mat_mean[0] + h_dict[self.component_name]["wind_speed_mean_background"] = self.ws_mat_mean[0] + h_dict[self.component_name]["turbine_powers"] = self.turbine_powers + h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) # Log the start time UTC if available if hasattr(self, "starttime_utc"): - h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc + h_dict[self.component_name]["starttime_utc"] = self.starttime_utc return h_dict From 6f42dce57cbafca7ac0cc242df940610019ecab7 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 14:56:52 -0700 Subject: [PATCH 18/38] update category test --- tests/hybrid_plant_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hybrid_plant_test.py b/tests/hybrid_plant_test.py index c395a172..bfa09f27 100644 --- a/tests/hybrid_plant_test.py +++ b/tests/hybrid_plant_test.py @@ -121,9 +121,9 @@ def test_component_category_attributes(): """Test that component objects expose the correct component_category class attribute.""" hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind_solar_battery)) - assert hp.component_objects["wind_farm"].component_category == "wind_farm" - assert hp.component_objects["solar_farm"].component_category == "solar_farm" - assert hp.component_objects["battery"].component_category == "battery" + assert hp.component_objects["wind_farm"].component_category == "generator" + assert hp.component_objects["solar_farm"].component_category == "generator" + assert hp.component_objects["battery"].component_category == "storage" def test_component_type_auto_set(): From 0470faa359258a13bf8d96ab8787a012cc0951a6 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 14:57:08 -0700 Subject: [PATCH 19/38] update component categories --- hercules/plant_components/battery_lithium_ion.py | 2 +- hercules/plant_components/battery_simple.py | 2 +- hercules/plant_components/component_base.py | 6 +++--- hercules/plant_components/electrolyzer_plant.py | 2 +- hercules/plant_components/solar_pysam_base.py | 2 +- hercules/plant_components/thermal_component_base.py | 2 +- hercules/plant_components/wind_farm.py | 2 +- hercules/plant_components/wind_farm_scada_power.py | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 23ec6788..011d0080 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -87,7 +87,7 @@ class BatteryLithiumIon(ComponentBase): Nov. 2021, doi: 10.1016/j.est.2021.103252. """ - component_category = "battery" + component_category = "storage" def __init__(self, h_dict, component_name): """Initialize the BatteryLithiumIon class. diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index f19c57bf..9fcf8861 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -81,7 +81,7 @@ class BatterySimple(ComponentBase): All power units are in kW and energy units are in kWh. """ - component_category = "battery" + component_category = "storage" def __init__(self, h_dict, component_name): """Initialize the BatterySimple class. diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 5c288bb0..14d95899 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -12,14 +12,14 @@ class ComponentBase: Provides common functionality for all Hercules plant components including logging setup, time step management, and shared configuration parameters. - Subclasses must define the class attribute ``component_category`` (a short string - identifying the broad category, e.g. ``"battery"``, ``"wind_farm"``). The per-instance + Subclasses must define the class attribute ``component_category`` with one of three + values: ``"generator"``, ``"load"``, or ``"storage"``. The per-instance ``component_name`` (the unique YAML key chosen by the user) is passed into ``__init__`` and may differ from the category when multiple instances of the same type are present. ``component_type`` is always set automatically to the concrete class name. """ - # Subclasses must override this with the appropriate category string. + # Subclasses must override this with one of: "generator", "load", "storage" component_category: ClassVar[str] def __init_subclass__(cls, **kwargs): diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py index 42e4f645..cd95e58f 100644 --- a/hercules/plant_components/electrolyzer_plant.py +++ b/hercules/plant_components/electrolyzer_plant.py @@ -14,7 +14,7 @@ class ElectrolyzerPlant(ComponentBase): The Electrolyzer plant uses the electrolyzer model from https://github.com/NREL/electrolyzer """ - component_category = "electrolyzer" + component_category = "load" def __init__(self, h_dict, component_name): """Initialize the ElectrolyzerPlant class. diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 9ac8a53f..bf980e71 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -18,7 +18,7 @@ class SolarPySAMBase(ComponentBase): Note PVSam is no longer supported in Hercules. """ - component_category = "solar_farm" + component_category = "generator" def __init__(self, h_dict, component_name): """Initialize the base solar PySAM simulator. diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 75cff58c..724cfffb 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -62,7 +62,7 @@ class ThermalComponentBase(ComponentBase): """ - component_category = "thermal" + component_category = "generator" class STATES(IntEnum): """Enumeration of thermal component operating states.""" diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py index 469cfaa7..2d44c298 100644 --- a/hercules/plant_components/wind_farm.py +++ b/hercules/plant_components/wind_farm.py @@ -39,7 +39,7 @@ class WindFarm(ComponentBase): All three strategies support detailed turbine dynamics (filter_model or dof1_model). """ - component_category = "wind_farm" + component_category = "generator" def __init__(self, h_dict, component_name): """Initialize the WindFarm class. diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py index 627237a7..64e5c20c 100644 --- a/hercules/plant_components/wind_farm_scada_power.py +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -18,7 +18,7 @@ class WindFarmSCADAPower(ComponentBase): there is no option to control. """ - component_category = "wind_farm" + component_category = "generator" def __init__(self, h_dict, component_name): """Initialize the WindFarm class. From fc279370ecda16858997c42abbffd7ec066a7565 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 14:57:47 -0700 Subject: [PATCH 20/38] update docs --- docs/component_types.md | 23 ++++++++++++----------- docs/hybrid_plant.md | 16 ++++++++-------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/component_types.md b/docs/component_types.md index ec2078a8..6093a4e8 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -27,7 +27,8 @@ The **component type** is the string value of the `component_type` field inside The **component category** is a class-level attribute defined in each component class. It is not read from YAML — it is part of the class definition itself. - **Source**: `component_category = "..."` class variable in the Python class -- **Used by**: `HybridPlant` to classify components as generators vs. storage/conversion, and to apply the battery sign convention +- **Valid values**: `"generator"`, `"load"`, or `"storage"` +- **Used by**: `HybridPlant` to classify components as generators vs. load/storage, and to apply the storage sign convention - **Available as**: `self.component_category` on the component object (and `ComponentBase.component_category` as a class attribute) Every `ComponentBase` subclass **must** define `component_category`; a `TypeError` is raised at class-definition time if it is missing. @@ -38,7 +39,7 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro |---|---|---|---| | `component_name` | User (YAML key) | `"battery_unit_1"` | Accessing `h_dict[name]`; unique instance ID | | `component_type` | User (`component_type:` field) | `"BatterySimple"` | Registry lookup to select the Python class | -| `component_category` | Developer (class variable) | `"battery"` | Generator classification; sign convention | +| `component_category` | Developer (class variable) | `"storage"` | Generator classification; sign convention | --- @@ -46,15 +47,15 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro | `component_type` | `component_category` | Generator? | Documentation | |---|---|---|---| -| `WindFarm` | `wind_farm` | Yes | [Wind](wind.md) | -| `WindFarmSCADAPower` | `wind_farm` | Yes | [Wind](wind.md) | -| `SolarPySAMPVWatts` | `solar_farm` | Yes | [Solar PV](solar_pv.md) | -| `BatterySimple` | `battery` | No | [Battery](battery.md) | -| `BatteryLithiumIon` | `battery` | No | [Battery](battery.md) | -| `ElectrolyzerPlant` | `electrolyzer` | No | [Electrolyzer](electrolyzer.md) | -| `OpenCycleGasTurbine` | `thermal` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | - -Components in the `wind_farm`, `solar_farm`, and `thermal` categories are classified as generators and contribute to `h_dict["plant"]["locally_generated_power"]`. +| `WindFarm` | `generator` | Yes | [Wind](wind.md) | +| `WindFarmSCADAPower` | `generator` | Yes | [Wind](wind.md) | +| `SolarPySAMPVWatts` | `generator` | Yes | [Solar PV](solar_pv.md) | +| `BatterySimple` | `storage` | No | [Battery](battery.md) | +| `BatteryLithiumIon` | `storage` | No | [Battery](battery.md) | +| `ElectrolyzerPlant` | `load` | No | [Electrolyzer](electrolyzer.md) | +| `OpenCycleGasTurbine` | `generator` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | + +Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. --- diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index def6193f..f6573d6a 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -12,16 +12,16 @@ See [Component Names, Types, and Categories](component_types.md) for a full expl | `component_type` | `component_category` | Generator? | Documentation | |---|---|---|---| -| `WindFarm` | `wind_farm` | Yes | [Wind](wind.md) | -| `WindFarmSCADAPower` | `wind_farm` | Yes | [Wind](wind.md) | -| `SolarPySAMPVWatts` | `solar_farm` | Yes | [Solar PV](solar_pv.md) | -| `BatterySimple` | `battery` | No | [Battery](battery.md) | -| `BatteryLithiumIon` | `battery` | No | [Battery](battery.md) | -| `ElectrolyzerPlant` | `electrolyzer` | No | [Electrolyzer](electrolyzer.md) | -| `OpenCycleGasTurbine` | `thermal` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | +| `WindFarm` | `generator` | Yes | [Wind](wind.md) | +| `WindFarmSCADAPower` | `generator` | Yes | [Wind](wind.md) | +| `SolarPySAMPVWatts` | `generator` | Yes | [Solar PV](solar_pv.md) | +| `BatterySimple` | `storage` | No | [Battery](battery.md) | +| `BatteryLithiumIon` | `storage` | No | [Battery](battery.md) | +| `ElectrolyzerPlant` | `load` | No | [Electrolyzer](electrolyzer.md) | +| `OpenCycleGasTurbine` | `generator` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | The YAML key for each section is a user-chosen `component_name` and is not required to match the category name. For example, a `BatterySimple` component could be named `battery`, `battery_unit_1`, or anything else. ## Generator Classification -`HybridPlant` classifies components into generators and non-generators based on `component_category`. Components in the `wind_farm`, `solar_farm`, and `thermal` categories are generators; their power outputs are summed into `h_dict["plant"]["locally_generated_power"]` each time step. Batteries and electrolyzers are excluded from this sum. +`HybridPlant` classifies components into generators and non-generators based on `component_category`. Components with `component_category == "generator"` have their power outputs summed into `h_dict["plant"]["locally_generated_power"]` each time step. Storage and load components are excluded from this sum. From 17d24acdbc0e186c1c75e3fe4d8f79502f7313c9 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 14:57:56 -0700 Subject: [PATCH 21/38] update hybrid plant --- hercules/hybrid_plant.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 9d3b1643..d7970ad9 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -29,9 +29,6 @@ f"VALID_COMPONENT_TYPES: {sorted(VALID_COMPONENT_TYPES)}" ) -# component_category values that represent generators (vs. storage/conversion) -_GENERATOR_CATEGORIES = {"wind_farm", "solar_farm", "thermal"} - class HybridPlant: """Manages hybrid plant components for Hercules. @@ -78,7 +75,7 @@ def __init__(self, h_dict): self.generator_names = [ name for name, obj in self.component_objects.items() - if obj.component_category in _GENERATOR_CATEGORIES + if obj.component_category == "generator" ] def add_plant_metadata_to_h_dict(self, h_dict): @@ -139,16 +136,16 @@ def step(self, h_dict): """ # Collect the component objects for component_name in self.component_names: - is_battery = self.component_objects[component_name].component_category == "battery" + is_storage = self.component_objects[component_name].component_category == "storage" - # Battery sign convention: negate setpoint before step, restore after - if is_battery: + # Storage sign convention: negate setpoint before step, restore after + if is_storage: h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"] # Update h_dict by calling the step method of each component object h_dict = self.component_objects[component_name].step(h_dict) - if is_battery: + if is_storage: h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"] h_dict[component_name]["power"] = -h_dict[component_name]["power"] From 7d31522a2d73bbd6047612ca60126b56c219e90c Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 15:02:43 -0700 Subject: [PATCH 22/38] Consolidate componet registry --- hercules/component_types.py | 34 ++++++++++++++++++++++------------ hercules/hybrid_plant.py | 33 +++------------------------------ 2 files changed, 25 insertions(+), 42 deletions(-) diff --git a/hercules/component_types.py b/hercules/component_types.py index 37daea75..9f47700c 100644 --- a/hercules/component_types.py +++ b/hercules/component_types.py @@ -5,15 +5,25 @@ files and in the HybridPlant component registry. """ -# Canonical list of all supported component_type strings. -# When adding a new component, update this sequence and the -# _COMPONENT_REGISTRY in hybrid_plant to keep them aligned. -VALID_COMPONENT_TYPES = ( - "WindFarm", - "WindFarmSCADAPower", - "SolarPySAMPVWatts", - "BatterySimple", - "BatteryLithiumIon", - "ElectrolyzerPlant", - "OpenCycleGasTurbine", -) +from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon +from hercules.plant_components.battery_simple import BatterySimple +from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine +from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts +from hercules.plant_components.wind_farm import WindFarm +from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower + +# Registry mapping component_type strings to their classes. +# Add new component types here to make them discoverable by HybridPlant. +COMPONENT_REGISTRY = { + "WindFarm": WindFarm, + "WindFarmSCADAPower": WindFarmSCADAPower, + "SolarPySAMPVWatts": SolarPySAMPVWatts, + "BatterySimple": BatterySimple, + "BatteryLithiumIon": BatteryLithiumIon, + "ElectrolyzerPlant": ElectrolyzerPlant, + "OpenCycleGasTurbine": OpenCycleGasTurbine, +} + +# Derived from registry keys for validation purposes +VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index d7970ad9..6038a251 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -1,33 +1,6 @@ import numpy as np -from hercules.component_types import VALID_COMPONENT_TYPES -from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon -from hercules.plant_components.battery_simple import BatterySimple -from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant -from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine -from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts -from hercules.plant_components.wind_farm import WindFarm -from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower - -# Registry mapping component_type strings (class names) to their classes. -# Add new component types here to make them discoverable by HybridPlant. -_COMPONENT_REGISTRY = { - "WindFarm": WindFarm, - "WindFarmSCADAPower": WindFarmSCADAPower, - "SolarPySAMPVWatts": SolarPySAMPVWatts, - "BatterySimple": BatterySimple, - "BatteryLithiumIon": BatteryLithiumIon, - "ElectrolyzerPlant": ElectrolyzerPlant, - "OpenCycleGasTurbine": OpenCycleGasTurbine, -} - -# Import-time safety check to prevent drift between this registry and VALID_COMPONENT_TYPES. -if set(_COMPONENT_REGISTRY) != set(VALID_COMPONENT_TYPES): - raise RuntimeError( - "HybridPlant component registry keys are out of sync with VALID_COMPONENT_TYPES. " - f"Registry keys: {sorted(_COMPONENT_REGISTRY)}; " - f"VALID_COMPONENT_TYPES: {sorted(VALID_COMPONENT_TYPES)}" - ) +from hercules.component_types import COMPONENT_REGISTRY class HybridPlant: @@ -117,11 +90,11 @@ def get_plant_component(self, component_name, h_dict): """ component_type = h_dict[component_name]["component_type"] - cls = _COMPONENT_REGISTRY.get(component_type) + cls = COMPONENT_REGISTRY.get(component_type) if cls is None: raise ValueError( f"Unknown component_type '{component_type}' for component '{component_name}'. " - f"Available types: {sorted(_COMPONENT_REGISTRY)}" + f"Available types: {sorted(COMPONENT_REGISTRY)}" ) return cls(h_dict, component_name) From 9d25aa17c8652b06a908287efc6ae4ff8cb2d055 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 15:05:22 -0700 Subject: [PATCH 23/38] fix circular import --- hercules/component_types.py | 29 ----------------------------- hercules/hybrid_plant.py | 23 ++++++++++++++++++++++- hercules/utilities.py | 11 ++++------- 3 files changed, 26 insertions(+), 37 deletions(-) delete mode 100644 hercules/component_types.py diff --git a/hercules/component_types.py b/hercules/component_types.py deleted file mode 100644 index 9f47700c..00000000 --- a/hercules/component_types.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Canonical definitions of Hercules component types. - -This module defines the single source of truth for all valid -``component_type`` string values that can appear in Hercules input -files and in the HybridPlant component registry. -""" - -from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon -from hercules.plant_components.battery_simple import BatterySimple -from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant -from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine -from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts -from hercules.plant_components.wind_farm import WindFarm -from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower - -# Registry mapping component_type strings to their classes. -# Add new component types here to make them discoverable by HybridPlant. -COMPONENT_REGISTRY = { - "WindFarm": WindFarm, - "WindFarmSCADAPower": WindFarmSCADAPower, - "SolarPySAMPVWatts": SolarPySAMPVWatts, - "BatterySimple": BatterySimple, - "BatteryLithiumIon": BatteryLithiumIon, - "ElectrolyzerPlant": ElectrolyzerPlant, - "OpenCycleGasTurbine": OpenCycleGasTurbine, -} - -# Derived from registry keys for validation purposes -VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 6038a251..a24f59bd 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -1,6 +1,27 @@ import numpy as np -from hercules.component_types import COMPONENT_REGISTRY +from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon +from hercules.plant_components.battery_simple import BatterySimple +from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine +from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts +from hercules.plant_components.wind_farm import WindFarm +from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower + +# Registry mapping component_type strings to their classes. +# Add new component types here to make them discoverable by HybridPlant. +COMPONENT_REGISTRY = { + "WindFarm": WindFarm, + "WindFarmSCADAPower": WindFarmSCADAPower, + "SolarPySAMPVWatts": SolarPySAMPVWatts, + "BatterySimple": BatterySimple, + "BatteryLithiumIon": BatteryLithiumIon, + "ElectrolyzerPlant": ElectrolyzerPlant, + "OpenCycleGasTurbine": OpenCycleGasTurbine, +} + +# Derived from registry keys for validation in utilities.py +VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) class HybridPlant: diff --git a/hercules/utilities.py b/hercules/utilities.py index 0a8fa1b7..940f9cac 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -11,17 +11,11 @@ import yaml from scipy.interpolate import interp1d -from hercules.component_types import VALID_COMPONENT_TYPES - # Hercules float type for consistent precision hercules_float_type = np.float32 hercules_complex_type = np.csingle -# All component_type strings (class names) that can appear in a YAML component_type field. -_VALID_COMPONENT_TYPES = list(VALID_COMPONENT_TYPES) - - class Loader(yaml.SafeLoader): """Custom YAML loader supporting !include tags. @@ -217,7 +211,10 @@ def load_hercules_input(filename): # Define valid keys required_keys = ["dt", "starttime_utc", "endtime_utc", "plant"] - valid_component_types = _VALID_COMPONENT_TYPES + # Lazy import to avoid circular dependency + from hercules.hybrid_plant import VALID_COMPONENT_TYPES + + valid_component_types = list(VALID_COMPONENT_TYPES) other_keys = [ "name", "description", From 0f364ce9ac06682c99a84cb85ef0c5b1b47104bb Mon Sep 17 00:00:00 2001 From: paulf81 Date: Fri, 27 Feb 2026 15:09:08 -0700 Subject: [PATCH 24/38] update docs --- docs/_toc.yml | 1 + docs/adding_components.md | 155 ++++++++++++++++++++++++++++++++++++++ docs/component_types.md | 2 + docs/hybrid_plant.md | 18 +++++ 4 files changed, 176 insertions(+) create mode 100644 docs/adding_components.md diff --git a/docs/_toc.yml b/docs/_toc.yml index 42349558..a1d8d86c 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -19,6 +19,7 @@ parts: - file: h_dict - file: hybrid_plant - file: component_types + - file: adding_components - file: hercules_model - file: output_files - caption: Plant Components diff --git a/docs/adding_components.md b/docs/adding_components.md new file mode 100644 index 00000000..ea0bdc91 --- /dev/null +++ b/docs/adding_components.md @@ -0,0 +1,155 @@ +# Adding a New Component + +This guide explains how to add a new plant component type to Hercules. The process involves three steps: + +1. Create the component class +2. Register the component +3. Document the component + +## Step 1: Create the Component Class + +Create a new Python file in `hercules/plant_components/` for your component. The class must: + +- Inherit from `ComponentBase` +- Define a `component_category` class attribute +- Implement `__init__`, `step`, and `get_initial_conditions_and_meta_data` methods + +### Minimal Example + +```python +# hercules/plant_components/my_component.py + +from hercules.plant_components.component_base import ComponentBase + + +class MyComponent(ComponentBase): + """Brief description of the component.""" + + component_category = "generator" # or "storage" or "load" + + def __init__(self, h_dict, component_name): + """Initialize the component. + + Args: + h_dict: Dictionary containing simulation parameters. + component_name: Unique name for this instance (the YAML key). + """ + # Call base class init first + super().__init__(h_dict, component_name) + + # Read component-specific parameters from h_dict + self.rated_power = h_dict[self.component_name]["rated_power"] + + # Initialize internal state + self.power = 0.0 + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add initial conditions and metadata to h_dict. + + Called once during HybridPlant initialization. + + Args: + h_dict: Dictionary containing simulation parameters. + + Returns: + Updated h_dict with initial conditions. + """ + h_dict[self.component_name]["power"] = self.power + return h_dict + + def step(self, h_dict): + """Advance the simulation by one time step. + + Args: + h_dict: Dictionary containing current simulation state. + + Returns: + Updated h_dict with component outputs. + """ + # Read inputs (e.g., setpoint from controller) + setpoint = h_dict[self.component_name].get("power_setpoint", 0.0) + + # Compute outputs + self.power = min(setpoint, self.rated_power) + + # Write outputs to h_dict + h_dict[self.component_name]["power"] = self.power + + return h_dict +``` + +### Key Requirements + +| Requirement | Description | +|---|---| +| `component_category` | Must be `"generator"`, `"storage"`, or `"load"`. Generators contribute to `locally_generated_power`. | +| `super().__init__()` | Sets `self.component_name`, `self.component_type`, `self.dt`, `self.starttime`, and configures logging. | +| `power` output | All components must write a `power` value to `h_dict[self.component_name]["power"]` in the `step` method. | +| Return `h_dict` | Both `get_initial_conditions_and_meta_data` and `step` must return the modified `h_dict`. | + +### Component Categories + +- **`generator`**: Produces power (wind, solar, gas turbine). Power is summed into `locally_generated_power`. +- **`storage`**: Stores and releases power (batteries). Sign convention is automatically handled by `HybridPlant`. +- **`load`**: Consumes power (electrolyzers). + +## Step 2: Register the Component + +Add the component to `COMPONENT_REGISTRY` in `hercules/hybrid_plant.py`: + +```python +from hercules.plant_components.my_component import MyComponent + +COMPONENT_REGISTRY = { + "WindFarm": WindFarm, + # ... existing entries ... + "MyComponent": MyComponent, # Add your component here +} +``` + +The key string (e.g., `"MyComponent"`) is the `component_type` value users will specify in their YAML input files. + +## Step 3: Document the Component + +1. **Create a docs page**: Add `docs/my_component.md` with usage examples and parameter reference. + +2. **Update the table of contents**: Add the page to `docs/_toc.yml` under "Plant Components": + + ```yaml + - caption: Plant Components + chapters: + - file: wind + - file: solar_pv + # ... + - file: my_component # Add your page + ``` + +3. **Update reference tables**: Add your component to the tables in: + - [hybrid_plant.md](hybrid_plant.md) — Available Components table + - [component_types.md](component_types.md) — Complete Component Type Reference table + +## Testing + +Add unit tests in `tests/my_component_test.py`. Test at minimum: + +- Initialization with valid parameters +- `step` method produces expected outputs +- `get_initial_conditions_and_meta_data` sets initial state + +Run tests with: + +```bash +pytest tests/my_component_test.py -v +``` + +## Summary Checklist + +- [ ] Create `hercules/plant_components/my_component.py` +- [ ] Inherit from `ComponentBase` +- [ ] Define `component_category` class attribute +- [ ] Implement `__init__`, `step`, `get_initial_conditions_and_meta_data` +- [ ] Import and add to `COMPONENT_REGISTRY` in `hercules/hybrid_plant.py` +- [ ] Create `docs/my_component.md` +- [ ] Add to `docs/_toc.yml` +- [ ] Update reference tables in `hybrid_plant.md` and `component_types.md` +- [ ] Create tests in `tests/my_component_test.py` diff --git a/docs/component_types.md b/docs/component_types.md index 6093a4e8..364fc40b 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -57,6 +57,8 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. +For a guide on implementing new component types, see [Adding Components](adding_components.md). + --- ## Multi-Instance Plants diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index f6573d6a..609ffedc 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -25,3 +25,21 @@ The YAML key for each section is a user-chosen `component_name` and is not requi ## Generator Classification `HybridPlant` classifies components into generators and non-generators based on `component_category`. Components with `component_category == "generator"` have their power outputs summed into `h_dict["plant"]["locally_generated_power"]` each time step. Storage and load components are excluded from this sum. + +## Component Registry + +All available component types are defined in `COMPONENT_REGISTRY` at the top of `hercules/hybrid_plant.py`. This dictionary maps `component_type` strings to their Python classes: + +```python +COMPONENT_REGISTRY = { + "WindFarm": WindFarm, + "WindFarmSCADAPower": WindFarmSCADAPower, + "SolarPySAMPVWatts": SolarPySAMPVWatts, + "BatterySimple": BatterySimple, + "BatteryLithiumIon": BatteryLithiumIon, + "ElectrolyzerPlant": ElectrolyzerPlant, + "OpenCycleGasTurbine": OpenCycleGasTurbine, +} +``` + +When adding a new component type, it must be registered here. See [Adding Components](adding_components.md) for a complete guide. From 4a8d4779cb3ff6db3ae688f2b65941a44f642077 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:03:13 -0700 Subject: [PATCH 25/38] spelling --- hercules/utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hercules/utilities.py b/hercules/utilities.py index 940f9cac..6c6583a2 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -306,7 +306,7 @@ def load_hercules_input(filename): for key in component_names: if h_dict[key]["component_type"] not in valid_component_types: raise ValueError( - f'"{key}" has an unrecognised component_type ' + f'"{key}" has an unrecognized component_type ' f'"{h_dict[key]["component_type"]}" in input file {filename}. ' f"Available types: {sorted(valid_component_types)}" ) From 15776cde55fe6033fd28598b812fae9a3b4e4378 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:03:23 -0700 Subject: [PATCH 26/38] fix out-dated docstring --- hercules/plant_components/battery_lithium_ion.py | 3 ++- hercules/plant_components/battery_simple.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 011d0080..eb932cc8 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -323,7 +323,8 @@ def step(self, h_dict): Args: h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] + - .power_setpoint: Requested charging/discharging power [kW], + where is this battery's key (i.e. ``self.component_name``) - plant.locally_generated_power: Available power for charging [kW] Returns: diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 9fcf8861..5e979e35 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -233,7 +233,8 @@ def step(self, h_dict): Args: h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] + - .power_setpoint: Requested charging/discharging power [kW], + where is this battery's key (i.e. ``self.component_name``) - plant.locally_generated_power: Available power for charging [kW] Returns: From f06188e8954743b892fc8d656c19645906dfa218 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:03:37 -0700 Subject: [PATCH 27/38] check categories --- hercules/plant_components/component_base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 14d95899..dae43879 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -22,11 +22,26 @@ class ComponentBase: # Subclasses must override this with one of: "generator", "load", "storage" component_category: ClassVar[str] + # Valid component categories + _ALLOWED_CATEGORIES = {"generator", "load", "storage"} + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if not hasattr(cls, "component_category"): raise TypeError(f"{cls.__name__} must define a class attribute 'component_category'") + value = cls.component_category + if not isinstance(value, str): + raise TypeError( + f"{cls.__name__}.component_category must be a string in " + f"{cls._ALLOWED_CATEGORIES}, got {type(value).__name__!r}: {value!r}" + ) + if value not in cls._ALLOWED_CATEGORIES: + raise TypeError( + f"{cls.__name__}.component_category must be one of " + f"{cls._ALLOWED_CATEGORIES}, got {value!r}" + ) + def __init__(self, h_dict, component_name): """Initialize the base component with a dictionary of parameters. From 4777c86a4f763642edda00d9a2a378a52bc710a6 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:04:24 -0700 Subject: [PATCH 28/38] fix test --- tests/utilities_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utilities_test.py b/tests/utilities_test.py index b8b2c987..bd39efe1 100644 --- a/tests/utilities_test.py +++ b/tests/utilities_test.py @@ -217,7 +217,7 @@ def test_load_hercules_input_invalid_component_type(): temp_file = f.name try: - with pytest.raises(ValueError, match="wind_farm.*unrecognised component_type"): + with pytest.raises(ValueError, match="wind_farm.*unrecognized component_type"): load_hercules_input(temp_file) finally: os.unlink(temp_file) From 531fcc2470d676c3f57e592a57765534a23c48f3 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:17:01 -0700 Subject: [PATCH 29/38] change snippet to link --- docs/adding_components.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/adding_components.md b/docs/adding_components.md index ea0bdc91..5f4e3977 100644 --- a/docs/adding_components.md +++ b/docs/adding_components.md @@ -95,17 +95,8 @@ class MyComponent(ComponentBase): ## Step 2: Register the Component -Add the component to `COMPONENT_REGISTRY` in `hercules/hybrid_plant.py`: +Add the component to `COMPONENT_REGISTRY` in `hercules/hybrid_plant.py` (see [Hybrid Plant Components](hybrid_plant.md)). -```python -from hercules.plant_components.my_component import MyComponent - -COMPONENT_REGISTRY = { - "WindFarm": WindFarm, - # ... existing entries ... - "MyComponent": MyComponent, # Add your component here -} -``` The key string (e.g., `"MyComponent"`) is the `component_type` value users will specify in their YAML input files. From 8a224801eb3c91a0ea45455c10747f92288f0cd1 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 12:13:47 -0700 Subject: [PATCH 30/38] Revert battery models --- .../plant_components/battery_lithium_ion.py | 18 ++++++++++-------- hercules/plant_components/battery_simple.py | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index eb932cc8..4462b93f 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -87,9 +87,7 @@ class BatteryLithiumIon(ComponentBase): Nov. 2021, doi: 10.1016/j.est.2021.103252. """ - component_category = "storage" - - def __init__(self, h_dict, component_name): + def __init__(self, h_dict): """Initialize the BatteryLithiumIon class. This model represents a detailed lithium-ion battery with diffusion transients @@ -104,11 +102,16 @@ def __init__(self, h_dict, component_name): - min_SOC: Minimum state of charge (0-1) - initial_conditions: Dictionary with initial SOC - allow_grid_power_consumption: Optional, defaults to False - component_name (str): Unique name for this instance (the YAML top-level key). """ - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) + # Store the name of this component + self.component_name = "battery" + + # Store the type of this component + self.component_type = "BatteryLithiumIon" + + # Call the base class init + super().__init__(h_dict, self.component_name) self.V_cell_nom = 3.3 # [V] self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 @@ -323,8 +326,7 @@ def step(self, h_dict): Args: h_dict (dict): Dictionary containing simulation state including: - - .power_setpoint: Requested charging/discharging power [kW], - where is this battery's key (i.e. ``self.component_name``) + - battery.power_setpoint: Requested charging/discharging power [kW] - plant.locally_generated_power: Available power for charging [kW] Returns: diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 5e979e35..ccf0da32 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -81,9 +81,7 @@ class BatterySimple(ComponentBase): All power units are in kW and energy units are in kWh. """ - component_category = "storage" - - def __init__(self, h_dict, component_name): + def __init__(self, h_dict): """Initialize the BatterySimple class. This model represents a simple battery with energy storage and power constraints. @@ -101,10 +99,15 @@ def __init__(self, h_dict, component_name): - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - self_discharge_time_constant: Optional self-discharge time constant - track_usage: Optional boolean to enable usage tracking - component_name (str): Unique name for this instance (the YAML top-level key). """ - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) + # Store the name of this component + self.component_name = "battery" + + # Store the type of this component + self.component_type = "BatterySimple" + + # Call the base class init + super().__init__(h_dict, self.component_name) # size = h_dict[self.component_name]["size"] self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] @@ -233,8 +236,7 @@ def step(self, h_dict): Args: h_dict (dict): Dictionary containing simulation state including: - - .power_setpoint: Requested charging/discharging power [kW], - where is this battery's key (i.e. ``self.component_name``) + - battery.power_setpoint: Requested charging/discharging power [kW] - plant.locally_generated_power: Available power for charging [kW] Returns: From 1f4b2d41f93dd97a7bc01a2b6c4a4aaa48fad3d3 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 12:24:14 -0700 Subject: [PATCH 31/38] Make necessary changes to battery classes --- hercules/plant_components/battery_lithium_ion.py | 15 ++++++--------- hercules/plant_components/battery_simple.py | 16 +++++++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 4462b93f..011d0080 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -87,7 +87,9 @@ class BatteryLithiumIon(ComponentBase): Nov. 2021, doi: 10.1016/j.est.2021.103252. """ - def __init__(self, h_dict): + component_category = "storage" + + def __init__(self, h_dict, component_name): """Initialize the BatteryLithiumIon class. This model represents a detailed lithium-ion battery with diffusion transients @@ -102,16 +104,11 @@ def __init__(self, h_dict): - min_SOC: Minimum state of charge (0-1) - initial_conditions: Dictionary with initial SOC - allow_grid_power_consumption: Optional, defaults to False + component_name (str): Unique name for this instance (the YAML top-level key). """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatteryLithiumIon" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) self.V_cell_nom = 3.3 # [V] self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index ccf0da32..e6e02a1e 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -81,7 +81,9 @@ class BatterySimple(ComponentBase): All power units are in kW and energy units are in kWh. """ - def __init__(self, h_dict): + component_category = "storage" + + def __init__(self, h_dict, component_name): """Initialize the BatterySimple class. This model represents a simple battery with energy storage and power constraints. @@ -99,15 +101,11 @@ def __init__(self, h_dict): - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - self_discharge_time_constant: Optional self-discharge time constant - track_usage: Optional boolean to enable usage tracking + component_name (str): Unique name for this instance (the YAML top-level key). """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatterySimple" - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # size = h_dict[self.component_name]["size"] self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] @@ -240,7 +238,7 @@ def step(self, h_dict): - plant.locally_generated_power: Available power for charging [kW] Returns: - dict: Updated h_dict with battery outputs: + dict: Updated h_dict with battery outputs stored under self.component_name: - power: Actual charging/discharging power [kW] - reject: Rejected power due to constraints [kW] - soc: State of charge [0-1] From 4ba47d4583bf889497ddb0a24d7f16b1ee0b1fbf Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 12:32:34 -0700 Subject: [PATCH 32/38] add comment --- hercules/utilities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hercules/utilities.py b/hercules/utilities.py index 6c6583a2..8857aee7 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -309,6 +309,7 @@ def load_hercules_input(filename): f'"{key}" has an unrecognized component_type ' f'"{h_dict[key]["component_type"]}" in input file {filename}. ' f"Available types: {sorted(valid_component_types)}" + "(Did you forget to add a new component_type to the COMPONENT_REGISTRY)" ) # Handle external_data structure normalization From 55b16d28f20cb45b659e6f551506d39fbdcb2ec1 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 12:44:23 -0700 Subject: [PATCH 33/38] Revert (again) --- .../plant_components/battery_lithium_ion.py | 925 ++++++++--------- hercules/plant_components/battery_simple.py | 936 +++++++++--------- 2 files changed, 933 insertions(+), 928 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 011d0080..dd1b2fb2 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -1,461 +1,464 @@ -""" -Battery models -Author: Zack tully - zachary.tully@nlr.gov -March 2024 - -References: -[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion -batteries, incorporating the effects of state of health, state of charge, and -temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, -Nov. 2021, doi: 10.1016/j.est.2021.103252. -""" - -import numpy as np -from hercules.plant_components.component_base import ComponentBase -from hercules.utilities import hercules_float_type - - -def kJ2kWh(kJ): - """Convert a value in kJ to kWh. - - Args: - kJ (float): Energy value in kilojoules. - - Returns: - float: Energy value in kilowatt-hours. - """ - return kJ / 3600 - - -def kWh2kJ(kWh): - """Convert a value in kWh to kJ. - - Args: - kWh (float): Energy value in kilowatt-hours. - - Returns: - float: Energy value in kilojoules. - """ - return kWh * 3600 - - -def years_to_usage_rate(years, dt): - """Convert a number of years to a usage rate. - - Args: - years (float): Life of the storage system in years. - dt (float): Time step of the simulation in seconds. - - Returns: - float: Usage rate per time step. - """ - days = years * 365 - hours = days * 24 - seconds = hours * 3600 - usage_lifetime = seconds / dt - - return 1 / usage_lifetime - - -def cycles_to_usage_rate(cycles): - """Convert cycle number to degradation rate. - - Args: - cycles (int): Number of cycles until the unit needs to be replaced. - - Returns: - float: Degradation rate per cycle. - """ - return 1 / cycles - - -class BatteryLithiumIon(ComponentBase): - """Detailed lithium-ion battery model with equivalent circuit modeling. - - This model represents a detailed lithium-ion battery with diffusion transients - and losses modeled as an equivalent circuit model. Calculations in this class - are primarily from [1]. - - Battery specifications: - - Cathode Material: LiFePO4 (all 5 cells) - - Anode Material: Graphite (all 5 cells) - - References: - [1] M.-K. Tran et al., "A comprehensive equivalent circuit model for lithium-ion - batteries, incorporating the effects of state of health, state of charge, and - temperature on model parameters," Journal of Energy Storage, vol. 43, p. 103252, - Nov. 2021, doi: 10.1016/j.est.2021.103252. - """ - - component_category = "storage" - - def __init__(self, h_dict, component_name): - """Initialize the BatteryLithiumIon class. - - This model represents a detailed lithium-ion battery with diffusion transients - and losses modeled as an equivalent circuit model. - - Args: - h_dict (dict): Dictionary containing simulation parameters including: - - energy_capacity: Battery energy capacity in kWh - - charge_rate: Maximum charge rate in kW - - discharge_rate: Maximum discharge rate in kW - - max_SOC: Maximum state of charge (0-1) - - min_SOC: Minimum state of charge (0-1) - - initial_conditions: Dictionary with initial SOC - - allow_grid_power_consumption: Optional, defaults to False - component_name (str): Unique name for this instance (the YAML top-level key). - """ - - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) - - self.V_cell_nom = 3.3 # [V] - self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 - - self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] - self.max_charge_power = h_dict[self.component_name]["charge_rate"] # [kW] - self.max_discharge_power = h_dict[self.component_name]["discharge_rate"] # [kW] - - initial_conditions = h_dict[self.component_name]["initial_conditions"] - self.SOC = initial_conditions["SOC"] # [fraction] - self.SOC_max = h_dict[self.component_name]["max_SOC"] - self.SOC_min = h_dict[self.component_name]["min_SOC"] - - # Flag for allowing grid to charge the battery - if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): - self.allow_grid_power_consumption = h_dict[self.component_name][ - "allow_grid_power_consumption" - ] - else: - self.allow_grid_power_consumption = False - - self.T = 25 # [C] temperature - self.SOH = 1 # State of Health - - self.post_init() - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - - # Add what we want later - h_dict[self.component_name]["power"] = 0 - - return h_dict - - def post_init(self): - """Calculate derived battery parameters after initialization. - - This method calculates cell configuration, capacity, voltage, current limits, - and initializes the equivalent circuit model parameters. - """ - - # Calculate the total cells and series/parallel configuration - self.n_cells = self.energy_capacity * 1e3 / (self.V_cell_nom * self.C_cell) - # TODO: need a systematic way to decide parallel and series cells - # TODO: choose a default voltage to choose the series and parallel configuration. - # TODO: allow user to specify a specific configuration - self.n_p = np.sqrt(self.n_cells) # number of cells in parallel - self.n_s = np.sqrt(self.n_cells) # number of cells in series - - # Calculate the capacity in Ah and the max charge/discharge rate in A - # C-rate = 1 means the cell discharges fully in one hour - self.C = self.C_cell * self.n_p # [Ah] capacity - - C_rate_charge = self.max_charge_power / self.energy_capacity - C_rate_discharge = self.max_discharge_power / self.energy_capacity - self.max_C_rate = np.max([C_rate_charge, C_rate_discharge]) # [A] [capacity/hr] - - # Nominal battery voltage and current - self.V_bat_nom = self.V_cell_nom * self.n_s # [V] - self.I_bat_max = self.C_cell * self.max_C_rate * self.n_p # [A] - - # Max charge/discharge in kW - self.P_max = self.C * self.max_C_rate * self.V_bat_nom * 1e-3 # [kW] - self.P_min = -self.P_max # [kW] - - # Max and min charge level in Ah - self.charge = self.SOC * self.C # [Ah] - self.charge_max = self.SOC_max * self.C - self.charge_min = self.SOC_min * self.C - - # initial state of RC branch state space - self.x = 0 - self.V_RC = 0 - self.error_sum = 0 - - # 10th order polynomial fit of the OCV curve from [1] Fig.4 - self.OCV_polynomial = np.array( - [ - 3.59292657e03, - -1.67001912e04, - 3.29199313e04, - -3.58557498e04, - 2.35571965e04, - -9.56351032e03, - 2.36147233e03, - -3.35943038e02, - 2.49233107e01, - 2.47115515e00, - ], - dtype=hercules_float_type, - ) - self.poly_order = len(self.OCV_polynomial) - - # Equivalent circuit component coefficientys from [1] Table 2 - # value = c1 + c2 * SOH + c3 * T + c4 * SOC - self.ECM_coefficients = np.array( - [ - [10424.73, -48.2181, -114.74, -1.40433], # R0 [micro ohms] - [13615.54, -68.0889, -87.527, -37.1084], # R1 [micro ohms] - [-11116.7, 180.4576, 237.4219, 40.14711], # C1 [F] - ], - dtype=hercules_float_type, - ) - - # initial state of battery outputs for hercules - self.power_kw = 0 - self.P_reject = 0 - self.P_charge = 0 - - def OCV(self): - """Calculate cell open circuit voltage (OCV) as a function of SOC. - - Uses a 10th order polynomial fit of the OCV curve from [1] Fig.4. - - Returns: - float: Cell open circuit voltage in volts. - """ - - ocv = 0 - for i, c in enumerate(self.OCV_polynomial): - ocv += c * self.SOC ** (self.poly_order - i - 1) - - return ocv - - def build_SS(self): - """Build RC branch state space matrices for equivalent circuit model. - - Constructs state space matrices for the current SOH (state of health), - T (temperature), and SOC (state of charge) using coefficients from [1] Table 2. - - Returns: - tuple: A, B, C, D state space matrices for the RC branch. - """ - - R_0, R_1, C_1 = self.ECM_coefficients @ np.array( - [1, self.SOH * 100, self.T, self.SOC * 100], dtype=hercules_float_type - ) - R_0 *= 1e-6 - R_1 *= 1e-6 - - A = -1 / (R_1 * C_1) - B = 1 - C = 1 / C_1 - D = R_0 - - return A, B, C, D - - def step_cell(self, u): - """Update the equivalent circuit model state for one time step. - - Args: - u (float): Cell current in amperes. - """ - # TODO: What if dt is very slow? skip this integration and return steady state value - # update the state of the cell model - A, B, C, D = self.build_SS() - - xd = A * self.x + B * u - y = C * self.x + D * u - - self.x = self.integrate(self.x, xd) - self.V_RC = y - - def integrate(self, x, xd): - """Integrate state derivatives using Euler method. - - Args: - x (float): Current state value. - xd (float): State derivative. - - Returns: - float: Updated state value. - """ - # TODO: Use better integration method like closed form step response solution - return x + xd * self.dt # Euler integration - - def V_cell(self): - """Calculate total cell voltage. - - Returns: - float: Cell voltage in volts (OCV + RC voltage drop). - """ - return self.OCV() + self.V_RC - - def calc_power(self, I_bat): - """Calculate battery power from current. - - Args: - I_bat (float): Battery current in amperes. - - Returns: - float: Battery power in watts. - """ - # Total battery voltage (cells in series) times current - return self.V_cell() * self.n_s * I_bat # [W] - - def step(self, h_dict): - """Advance the battery simulation by one time step. - - Updates the battery state including SOC, equivalent circuit dynamics, and power output - based on the requested power setpoint and available power. - - Args: - h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] - - plant.locally_generated_power: Available power for charging [kW] - - Returns: - dict: Updated h_dict with battery outputs: - - power: Actual charging/discharging power [kW] - - reject: Rejected power due to constraints [kW] - - soc: State of charge [0-1] - """ - - P_signal = h_dict[self.component_name]["power_setpoint"] # [kW] requested power - if self.allow_grid_power_consumption: - P_avail = np.inf - else: - P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power - - # Calculate charging/discharging current [A] from power - I_charge, I_reject = self.control(P_signal, P_avail) - i_charge = I_charge / self.n_p # [A] Cell current - - # Update charge - self.charge += I_charge * self.dt / 3600 # [Ah] - self.SOC = self.charge / (self.C) - - # Update RC branch dynamics - self.step_cell(i_charge) - - # Calculate actual power - self.power_kw = self.calc_power(I_charge) * 1e-3 - self.P_reject = P_signal - self.power_kw - - # Update power signal error integral - if (P_signal < self.max_charge_power) & (P_signal > self.max_discharge_power): - self.error_sum += self.P_reject * self.dt - - # Update the outputs - h_dict[self.component_name]["power"] = self.power_kw - h_dict[self.component_name]["reject"] = self.P_reject - h_dict[self.component_name]["soc"] = self.SOC - - # Return the updated dictionary - return h_dict - - def control(self, P_signal, P_avail): - """Calculate charging/discharging current from requested power. - - Uses an iterative approach to account for errors between nominal and actual - battery voltage. Includes integral control to correct for persistent voltage errors. - - Args: - P_signal (float): Requested charging/discharging power in kW. - P_avail (float): Power available for charging/discharging in kW. - - Returns: - tuple: (I_charge, I_reject) where: - - I_charge: Charging/discharging current in amperes that the battery can provide - - I_reject: Current equivalent of power that cannot be provided in amperes - """ - - # Current according to nominal voltage - I_signal = P_signal * 1e3 / self.V_bat_nom - - # Iteratively adjust setpoint to account for inherent error in V_nom - error = P_signal - self.calc_power(I_signal) * 1e-3 - count = 0 # safety count - tol = self.V_bat_nom * self.I_bat_max * 1e-9 - while np.abs(error) > tol: - count += 1 - error = P_signal - self.calc_power(I_signal) * 1e-3 - I_signal += error * 1e3 / self.V_bat_nom - - if count > 100: - # assert False, "Too many interations, breaking the while loop." - break - - # Error integral acts like an offset correcting for persistent errors between nominal and - # actual battery voltage. - I_signal += self.error_sum * 1e3 / self.V_bat_nom * 0.01 - # Is this calc just as accurate as iterative? - I_avail = P_avail * 1e3 / (self.V_cell() * self.n_s) - - # Check charging, discharging, and amperage constraints. - I_charge, I_reject = self.constraints(I_signal, I_avail) - - return I_charge, I_reject - - def constraints(self, I_signal, I_avail): - """Apply battery operational constraints to the requested current. - - Checks whether the requested charging/discharging action will violate battery - charge limits, power limits, or available power. Returns the constrained current - and any rejected current. - - Args: - I_signal (float): Requested charging/discharging current in amperes. - I_avail (float): Current available for charging/discharging in amperes. - - Returns: - tuple: (I_charge, I_reject) where: - - I_charge: Constrained charging/discharging current in amperes - - I_reject: Rejected current due to constraints in amperes - """ - - # Charge (energy) constraint, upper. Charging current that would fill the battery up - # completely in one time step - c_hi1 = (self.charge_max - self.charge) / (self.dt / 3600) - # Charge rate (power) constraint, upper. - c_hi2 = self.I_bat_max - # Available power - c_hi3 = I_avail - - # Take the most restrictive upper constraint - c_hi = np.min([c_hi1, c_hi2, c_hi3]) - - # Charge (energy) constraint, lower. - c_lo1 = (self.charge_min - self.charge) / (self.dt / 3600) - # Discharge rate (power) constraint, lower. - c_lo2 = -self.I_bat_max - - # Take the most restrictive lower constraint - c_lo = np.max([c_lo1, c_lo2]) - - if (I_signal >= c_lo) & (I_signal <= c_hi): - # It is possible to fulfill the requested signal - I_charge = I_signal - I_reject = 0 - elif I_signal < c_lo: - # The battery is constrained to charge/discharge higher than the requested signal - I_charge = c_lo - I_reject = I_signal - I_charge - elif I_signal > c_hi: - # The battery is constrained to charge/discharge lower than the requested signal - I_charge = c_hi - I_reject = I_signal - I_charge - - return I_charge, I_reject +""" +Battery models +Author: Zack tully - zachary.tully@nlr.gov +March 2024 + +References: +[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion +batteries, incorporating the effects of state of health, state of charge, and +temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, +Nov. 2021, doi: 10.1016/j.est.2021.103252. +""" + +import numpy as np +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import hercules_float_type + + +def kJ2kWh(kJ): + """Convert a value in kJ to kWh. + + Args: + kJ (float): Energy value in kilojoules. + + Returns: + float: Energy value in kilowatt-hours. + """ + return kJ / 3600 + + +def kWh2kJ(kWh): + """Convert a value in kWh to kJ. + + Args: + kWh (float): Energy value in kilowatt-hours. + + Returns: + float: Energy value in kilojoules. + """ + return kWh * 3600 + + +def years_to_usage_rate(years, dt): + """Convert a number of years to a usage rate. + + Args: + years (float): Life of the storage system in years. + dt (float): Time step of the simulation in seconds. + + Returns: + float: Usage rate per time step. + """ + days = years * 365 + hours = days * 24 + seconds = hours * 3600 + usage_lifetime = seconds / dt + + return 1 / usage_lifetime + + +def cycles_to_usage_rate(cycles): + """Convert cycle number to degradation rate. + + Args: + cycles (int): Number of cycles until the unit needs to be replaced. + + Returns: + float: Degradation rate per cycle. + """ + return 1 / cycles + + +class BatteryLithiumIon(ComponentBase): + """Detailed lithium-ion battery model with equivalent circuit modeling. + + This model represents a detailed lithium-ion battery with diffusion transients + and losses modeled as an equivalent circuit model. Calculations in this class + are primarily from [1]. + + Battery specifications: + - Cathode Material: LiFePO4 (all 5 cells) + - Anode Material: Graphite (all 5 cells) + + References: + [1] M.-K. Tran et al., "A comprehensive equivalent circuit model for lithium-ion + batteries, incorporating the effects of state of health, state of charge, and + temperature on model parameters," Journal of Energy Storage, vol. 43, p. 103252, + Nov. 2021, doi: 10.1016/j.est.2021.103252. + """ + + def __init__(self, h_dict): + """Initialize the BatteryLithiumIon class. + + This model represents a detailed lithium-ion battery with diffusion transients + and losses modeled as an equivalent circuit model. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - energy_capacity: Battery energy capacity in kWh + - charge_rate: Maximum charge rate in kW + - discharge_rate: Maximum discharge rate in kW + - max_SOC: Maximum state of charge (0-1) + - min_SOC: Minimum state of charge (0-1) + - initial_conditions: Dictionary with initial SOC + - allow_grid_power_consumption: Optional, defaults to False + """ + + # Store the name of this component + self.component_name = "battery" + + # Store the type of this component + self.component_type = "BatteryLithiumIon" + + # Call the base class init + super().__init__(h_dict, self.component_name) + + self.V_cell_nom = 3.3 # [V] + self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 + + self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] + self.max_charge_power = h_dict[self.component_name]["charge_rate"] # [kW] + self.max_discharge_power = h_dict[self.component_name]["discharge_rate"] # [kW] + + initial_conditions = h_dict[self.component_name]["initial_conditions"] + self.SOC = initial_conditions["SOC"] # [fraction] + self.SOC_max = h_dict[self.component_name]["max_SOC"] + self.SOC_min = h_dict[self.component_name]["min_SOC"] + + # Flag for allowing grid to charge the battery + if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): + self.allow_grid_power_consumption = h_dict[self.component_name][ + "allow_grid_power_consumption" + ] + else: + self.allow_grid_power_consumption = False + + self.T = 25 # [C] temperature + self.SOH = 1 # State of Health + + self.post_init() + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + + # Add what we want later + h_dict[self.component_name]["power"] = 0 + + return h_dict + + def post_init(self): + """Calculate derived battery parameters after initialization. + + This method calculates cell configuration, capacity, voltage, current limits, + and initializes the equivalent circuit model parameters. + """ + + # Calculate the total cells and series/parallel configuration + self.n_cells = self.energy_capacity * 1e3 / (self.V_cell_nom * self.C_cell) + # TODO: need a systematic way to decide parallel and series cells + # TODO: choose a default voltage to choose the series and parallel configuration. + # TODO: allow user to specify a specific configuration + self.n_p = np.sqrt(self.n_cells) # number of cells in parallel + self.n_s = np.sqrt(self.n_cells) # number of cells in series + + # Calculate the capacity in Ah and the max charge/discharge rate in A + # C-rate = 1 means the cell discharges fully in one hour + self.C = self.C_cell * self.n_p # [Ah] capacity + + C_rate_charge = self.max_charge_power / self.energy_capacity + C_rate_discharge = self.max_discharge_power / self.energy_capacity + self.max_C_rate = np.max([C_rate_charge, C_rate_discharge]) # [A] [capacity/hr] + + # Nominal battery voltage and current + self.V_bat_nom = self.V_cell_nom * self.n_s # [V] + self.I_bat_max = self.C_cell * self.max_C_rate * self.n_p # [A] + + # Max charge/discharge in kW + self.P_max = self.C * self.max_C_rate * self.V_bat_nom * 1e-3 # [kW] + self.P_min = -self.P_max # [kW] + + # Max and min charge level in Ah + self.charge = self.SOC * self.C # [Ah] + self.charge_max = self.SOC_max * self.C + self.charge_min = self.SOC_min * self.C + + # initial state of RC branch state space + self.x = 0 + self.V_RC = 0 + self.error_sum = 0 + + # 10th order polynomial fit of the OCV curve from [1] Fig.4 + self.OCV_polynomial = np.array( + [ + 3.59292657e03, + -1.67001912e04, + 3.29199313e04, + -3.58557498e04, + 2.35571965e04, + -9.56351032e03, + 2.36147233e03, + -3.35943038e02, + 2.49233107e01, + 2.47115515e00, + ], + dtype=hercules_float_type, + ) + self.poly_order = len(self.OCV_polynomial) + + # Equivalent circuit component coefficientys from [1] Table 2 + # value = c1 + c2 * SOH + c3 * T + c4 * SOC + self.ECM_coefficients = np.array( + [ + [10424.73, -48.2181, -114.74, -1.40433], # R0 [micro ohms] + [13615.54, -68.0889, -87.527, -37.1084], # R1 [micro ohms] + [-11116.7, 180.4576, 237.4219, 40.14711], # C1 [F] + ], + dtype=hercules_float_type, + ) + + # initial state of battery outputs for hercules + self.power_kw = 0 + self.P_reject = 0 + self.P_charge = 0 + + def OCV(self): + """Calculate cell open circuit voltage (OCV) as a function of SOC. + + Uses a 10th order polynomial fit of the OCV curve from [1] Fig.4. + + Returns: + float: Cell open circuit voltage in volts. + """ + + ocv = 0 + for i, c in enumerate(self.OCV_polynomial): + ocv += c * self.SOC ** (self.poly_order - i - 1) + + return ocv + + def build_SS(self): + """Build RC branch state space matrices for equivalent circuit model. + + Constructs state space matrices for the current SOH (state of health), + T (temperature), and SOC (state of charge) using coefficients from [1] Table 2. + + Returns: + tuple: A, B, C, D state space matrices for the RC branch. + """ + + R_0, R_1, C_1 = self.ECM_coefficients @ np.array( + [1, self.SOH * 100, self.T, self.SOC * 100], dtype=hercules_float_type + ) + R_0 *= 1e-6 + R_1 *= 1e-6 + + A = -1 / (R_1 * C_1) + B = 1 + C = 1 / C_1 + D = R_0 + + return A, B, C, D + + def step_cell(self, u): + """Update the equivalent circuit model state for one time step. + + Args: + u (float): Cell current in amperes. + """ + # TODO: What if dt is very slow? skip this integration and return steady state value + # update the state of the cell model + A, B, C, D = self.build_SS() + + xd = A * self.x + B * u + y = C * self.x + D * u + + self.x = self.integrate(self.x, xd) + self.V_RC = y + + def integrate(self, x, xd): + """Integrate state derivatives using Euler method. + + Args: + x (float): Current state value. + xd (float): State derivative. + + Returns: + float: Updated state value. + """ + # TODO: Use better integration method like closed form step response solution + return x + xd * self.dt # Euler integration + + def V_cell(self): + """Calculate total cell voltage. + + Returns: + float: Cell voltage in volts (OCV + RC voltage drop). + """ + return self.OCV() + self.V_RC + + def calc_power(self, I_bat): + """Calculate battery power from current. + + Args: + I_bat (float): Battery current in amperes. + + Returns: + float: Battery power in watts. + """ + # Total battery voltage (cells in series) times current + return self.V_cell() * self.n_s * I_bat # [W] + + def step(self, h_dict): + """Advance the battery simulation by one time step. + + Updates the battery state including SOC, equivalent circuit dynamics, and power output + based on the requested power setpoint and available power. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - battery.power_setpoint: Requested charging/discharging power [kW] + - plant.locally_generated_power: Available power for charging [kW] + + Returns: + dict: Updated h_dict with battery outputs: + - power: Actual charging/discharging power [kW] + - reject: Rejected power due to constraints [kW] + - soc: State of charge [0-1] + """ + + P_signal = h_dict[self.component_name]["power_setpoint"] # [kW] requested power + if self.allow_grid_power_consumption: + P_avail = np.inf + else: + P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power + + # Calculate charging/discharging current [A] from power + I_charge, I_reject = self.control(P_signal, P_avail) + i_charge = I_charge / self.n_p # [A] Cell current + + # Update charge + self.charge += I_charge * self.dt / 3600 # [Ah] + self.SOC = self.charge / (self.C) + + # Update RC branch dynamics + self.step_cell(i_charge) + + # Calculate actual power + self.power_kw = self.calc_power(I_charge) * 1e-3 + self.P_reject = P_signal - self.power_kw + + # Update power signal error integral + if (P_signal < self.max_charge_power) & (P_signal > self.max_discharge_power): + self.error_sum += self.P_reject * self.dt + + # Update the outputs + h_dict[self.component_name]["power"] = self.power_kw + h_dict[self.component_name]["reject"] = self.P_reject + h_dict[self.component_name]["soc"] = self.SOC + + # Return the updated dictionary + return h_dict + + def control(self, P_signal, P_avail): + """Calculate charging/discharging current from requested power. + + Uses an iterative approach to account for errors between nominal and actual + battery voltage. Includes integral control to correct for persistent voltage errors. + + Args: + P_signal (float): Requested charging/discharging power in kW. + P_avail (float): Power available for charging/discharging in kW. + + Returns: + tuple: (I_charge, I_reject) where: + - I_charge: Charging/discharging current in amperes that the battery can provide + - I_reject: Current equivalent of power that cannot be provided in amperes + """ + + # Current according to nominal voltage + I_signal = P_signal * 1e3 / self.V_bat_nom + + # Iteratively adjust setpoint to account for inherent error in V_nom + error = P_signal - self.calc_power(I_signal) * 1e-3 + count = 0 # safety count + tol = self.V_bat_nom * self.I_bat_max * 1e-9 + while np.abs(error) > tol: + count += 1 + error = P_signal - self.calc_power(I_signal) * 1e-3 + I_signal += error * 1e3 / self.V_bat_nom + + if count > 100: + # assert False, "Too many interations, breaking the while loop." + break + + # Error integral acts like an offset correcting for persistent errors between nominal and + # actual battery voltage. + I_signal += self.error_sum * 1e3 / self.V_bat_nom * 0.01 + # Is this calc just as accurate as iterative? + I_avail = P_avail * 1e3 / (self.V_cell() * self.n_s) + + # Check charging, discharging, and amperage constraints. + I_charge, I_reject = self.constraints(I_signal, I_avail) + + return I_charge, I_reject + + def constraints(self, I_signal, I_avail): + """Apply battery operational constraints to the requested current. + + Checks whether the requested charging/discharging action will violate battery + charge limits, power limits, or available power. Returns the constrained current + and any rejected current. + + Args: + I_signal (float): Requested charging/discharging current in amperes. + I_avail (float): Current available for charging/discharging in amperes. + + Returns: + tuple: (I_charge, I_reject) where: + - I_charge: Constrained charging/discharging current in amperes + - I_reject: Rejected current due to constraints in amperes + """ + + # Charge (energy) constraint, upper. Charging current that would fill the battery up + # completely in one time step + c_hi1 = (self.charge_max - self.charge) / (self.dt / 3600) + # Charge rate (power) constraint, upper. + c_hi2 = self.I_bat_max + # Available power + c_hi3 = I_avail + + # Take the most restrictive upper constraint + c_hi = np.min([c_hi1, c_hi2, c_hi3]) + + # Charge (energy) constraint, lower. + c_lo1 = (self.charge_min - self.charge) / (self.dt / 3600) + # Discharge rate (power) constraint, lower. + c_lo2 = -self.I_bat_max + + # Take the most restrictive lower constraint + c_lo = np.max([c_lo1, c_lo2]) + + if (I_signal >= c_lo) & (I_signal <= c_hi): + # It is possible to fulfill the requested signal + I_charge = I_signal + I_reject = 0 + elif I_signal < c_lo: + # The battery is constrained to charge/discharge higher than the requested signal + I_charge = c_lo + I_reject = I_signal - I_charge + elif I_signal > c_hi: + # The battery is constrained to charge/discharge lower than the requested signal + I_charge = c_hi + I_reject = I_signal - I_charge + + return I_charge, I_reject diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index e6e02a1e..8e77bf22 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -1,467 +1,469 @@ -""" -Battery models -Author: Zack tully - zachary.tully@nrel.gov -March 2024 - -References: -[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion -batteries, incorporating the effects of state of health, state of charge, and -temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, -Nov. 2021, doi: 10.1016/j.est.2021.103252. -""" - -import numpy as np -import rainflow -from hercules.plant_components.component_base import ComponentBase -from hercules.utilities import hercules_float_type - - -def kJ2kWh(kJ): - """Convert a value in kJ to kWh. - - Args: - kJ (float): Energy value in kilojoules. - - Returns: - float: Energy value in kilowatt-hours. - """ - return kJ / 3600 - - -def kWh2kJ(kWh): - """Convert a value in kWh to kJ. - - Args: - kWh (float): Energy value in kilowatt-hours. - - Returns: - float: Energy value in kilojoules. - """ - return kWh * 3600 - - -def years_to_usage_rate(years, dt): - """Convert a number of years to a usage rate. - - Args: - years (float): Life of the storage system in years. - dt (float): Time step of the simulation in seconds. - - Returns: - float: Usage rate per time step. - """ - days = years * 365 - hours = days * 24 - seconds = hours * 3600 - usage_lifetime = seconds / dt - - return 1 / usage_lifetime - - -def cycles_to_usage_rate(cycles): - """Convert cycle number to degradation rate. - - Args: - cycles (int): Number of cycles until the unit needs to be replaced. - - Returns: - float: Degradation rate per cycle. - """ - return 1 / cycles - - -class BatterySimple(ComponentBase): - """Simple battery energy storage model. - - This model represents a basic battery with energy storage and power constraints. - It tracks state of charge, applies efficiency losses, and optionally tracks - usage-based degradation using rainflow cycle counting. - - Note: - All power units are in kW and energy units are in kWh. - """ - - component_category = "storage" - - def __init__(self, h_dict, component_name): - """Initialize the BatterySimple class. - - This model represents a simple battery with energy storage and power constraints. - It tracks state of charge and applies efficiency losses. - - Args: - h_dict (dict): Dictionary containing simulation parameters including: - - energy_capacity: Battery energy capacity in kWh - - charge_rate: Maximum charge rate in kW - - discharge_rate: Maximum discharge rate in kW - - max_SOC: Maximum state of charge (0-1) - - min_SOC: Minimum state of charge (0-1) - - initial_conditions: Dictionary with initial SOC - - allow_grid_power_consumption: Optional, defaults to False - - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - - self_discharge_time_constant: Optional self-discharge time constant - - track_usage: Optional boolean to enable usage tracking - component_name (str): Unique name for this instance (the YAML top-level key). - """ - - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) - - # size = h_dict[self.component_name]["size"] - self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] - initial_conditions = h_dict[self.component_name]["initial_conditions"] - self.SOC = initial_conditions["SOC"] # [fraction] - - self.SOC_max = h_dict[self.component_name]["max_SOC"] - self.SOC_min = h_dict[self.component_name]["min_SOC"] - - # Charge (Energy) limits [kJ] - self.E_min = kWh2kJ(self.SOC_min * self.energy_capacity) - self.E_max = kWh2kJ(self.SOC_max * self.energy_capacity) - - charge_rate = h_dict[self.component_name]["charge_rate"] # [kW] - discharge_rate = h_dict[self.component_name]["discharge_rate"] # [kW] - - # Charge/discharge (Power) limits [kW] - self.P_min = -discharge_rate - self.P_max = charge_rate - - # Ramp up/down limits [kW/s] - self.R_min = -np.inf - self.R_max = np.inf - - # Flag for allowing grid to charge the battery - if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): - self.allow_grid_power_consumption = h_dict[self.component_name][ - "allow_grid_power_consumption" - ] - else: - self.allow_grid_power_consumption = False - - # Efficiency and self-discharge parameters - if "roundtrip_efficiency" in h_dict[self.component_name].keys(): - self.eta_charge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) - self.eta_discharge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) - else: - self.eta_charge = 1 - self.eta_discharge = 1 - - if "self_discharge_time_constant" in h_dict[self.component_name].keys(): - self.tau_self_discharge = h_dict[self.component_name]["self_discharge_time_constant"] - else: - self.tau_self_discharge = np.inf - - if "track_usage" in h_dict[self.component_name].keys(): - if h_dict[self.component_name]["track_usage"]: - self.track_usage = True - # Set usage tracking parameters - if "usage_calc_interval" in h_dict[self.component_name].keys(): - self.usage_calc_interval = ( - h_dict[self.component_name]["usage_calc_interval"] / self.dt - ) - else: - self.usage_calc_interval = 100 / self.dt # timesteps - - if "usage_lifetime" in h_dict[self.component_name].keys(): - usage_lifetime = h_dict[self.component_name]["usage_lifetime"] - self.usage_time_rate = years_to_usage_rate(usage_lifetime, self.dt) - else: - self.usage_time_rate = 0 - if "usage_cycles" in h_dict[self.component_name].keys(): - usage_cycles = h_dict[self.component_name]["usage_cycles"] - self.usage_cycles_rate = cycles_to_usage_rate(usage_cycles) - else: - self.usage_cycles_rate = 0 - - # TODO: add the ability to impact efficiency of the battery operation - - else: - self.track_usage = False - self.usage_calc_interval = np.inf - else: - self.track_usage = False - self.usage_calc_interval = np.inf - - # Degradation and state storage - self.P_charge_storage = [] - self.E_store = [] - self.total_cycle_usage = 0 - self.cycle_usage_perc = 0 - self.total_time_usage = 0 - self.time_usage_perc = 0 - self.step_counter = 0 - # TODO there should be a better way to dynamically store these than to append a list - - self.build_SS() - self.x = np.array( - [[initial_conditions["SOC"] * self.energy_capacity * 3600]], dtype=hercules_float_type - ) - self.y = None - - # self.total_battery_capacity = 3600 * self.energy_capacity / self.dt - self.current_batt_state = self.SOC * self.energy_capacity - self.E = kWh2kJ(self.current_batt_state) - - self.power_kw = 0 - self.P_reject = 0 - self.P_charge = 0 - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - - # Add what we want later - h_dict[self.component_name]["power"] = 0 - h_dict[self.component_name]["soc"] = self.SOC - - return h_dict - - def step(self, h_dict): - """Advance the battery simulation by one time step. - - Updates the battery state including SOC, energy storage, and power output - based on the requested power setpoint and available power. Optionally - calculates usage-based degradation. - - Args: - h_dict (dict): Dictionary containing simulation state including: - - battery.power_setpoint: Requested charging/discharging power [kW] - - plant.locally_generated_power: Available power for charging [kW] - - Returns: - dict: Updated h_dict with battery outputs stored under self.component_name: - - power: Actual charging/discharging power [kW] - - reject: Rejected power due to constraints [kW] - - soc: State of charge [0-1] - - usage_in_time: Time-based usage percentage - - usage_in_cycles: Cycle-based usage percentage - - total_cycles: Total equivalent cycles completed - """ - self.step_counter += 1 - - # Power available for the battery to use for charging (should be >=0) - power_setpoint = h_dict[self.component_name]["power_setpoint"] - # Power signal desired by the controller - if self.allow_grid_power_consumption: - P_avail = np.inf - else: - P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power - - P_charge, P_reject = self.control(P_avail, power_setpoint) - - # Update energy state - # self.E += self.P_charge * self.dt - self.step_SS(P_charge) - self.E = self.x[0, 0] # TODO find a better way to make self.x 1-D - - self.current_batt_state = kJ2kWh(self.E) - - self.power_kw = P_charge - self.SOC = self.current_batt_state / self.energy_capacity - - self.P_charge_storage.append(P_charge) - self.E_store.append(self.E) - - if self.step_counter >= self.usage_calc_interval: - # reset step_counter - self.step_counter = 0 - self.calc_usage() - - # Update the outputs - h_dict[self.component_name]["power"] = self.power_kw - h_dict[self.component_name]["reject"] = P_reject - h_dict[self.component_name]["soc"] = self.SOC - h_dict[self.component_name]["usage_in_time"] = self.time_usage_perc - h_dict[self.component_name]["usage_in_cycles"] = self.cycle_usage_perc - h_dict[self.component_name]["total_cycles"] = self.total_cycle_usage - - # Return the updated dictionary - return h_dict - - def control(self, P_avail, power_setpoint): - """Apply battery operational constraints to requested power. - - Low-level controller that enforces energy, power, and ramp rate constraints. - Determines the actual charging/discharging power and any rejected power. - - Args: - P_avail (float): Available power for charging in kW. - power_setpoint (float): Desired charging/discharging power in kW. - - Returns: - tuple: (P_charge, P_reject) where: - - P_charge: Actual charging/discharging power in kW (positive for charging) - - P_reject: Rejected power due to constraints in kW (positive when - power cannot be absorbed, negative when required power unavailable) - """ - - # TODO remove ramp rate constraints because they are never used? - - # Upper constraints [kW] - # c_hi1 = (self.E_max - self.E) / self.dt # energy - c_hi1 = self.SS_input_function_inverse((self.E_max - self.x[0, 0]) / self.dt) - c_hi2 = self.P_max # power - c_hi3 = self.R_max * self.dt + self.P_charge # ramp rate - c_hi4 = P_avail - - # Lower constraints [kW] - # c_lo1 = (self.E_min - self.E) / self.dt # energy - c_lo1 = self.SS_input_function_inverse((self.E_min - self.x[0, 0]) / self.dt) - c_lo2 = self.P_min # power - c_lo3 = self.R_min * self.dt + self.P_charge # ramp rate - - # High constraint is the most restrictive of the high constraints - c_hi = np.min([c_hi1, c_hi2, c_hi3, c_hi4]) - c_hi = np.max([c_hi, 0]) - - # Low constraint is the most restrictive of the low constraints - c_lo = np.max([c_lo1, c_lo2, c_lo3]) - c_lo = np.min([c_lo, 0]) - - # TODO: force low constraint to be no higher than lowest high constraint - if (power_setpoint >= c_lo) & (power_setpoint <= c_hi): - P_charge = power_setpoint - P_reject = 0 - elif power_setpoint < c_lo: - P_charge = c_lo - P_reject = power_setpoint - P_charge - elif power_setpoint > c_hi: - P_charge = c_hi - P_reject = power_setpoint - P_charge - - self.P_charge = P_charge - self.P_reject = P_reject - - return P_charge, P_reject - - def build_SS(self): - """Build state-space model matrices for battery dynamics. - - Constructs the state-space representation that includes self-discharge - and efficiency losses. - """ - self.A = np.array([[-1 / self.tau_self_discharge]], dtype=hercules_float_type) - # B matrix is handled by the SS_input_function - self.C = np.array([[1, 0]], dtype=hercules_float_type).T - self.D = np.array([[0, 1]], dtype=hercules_float_type).T - - def SS_input_function(self, P_charge): - """Apply efficiency losses to charging/discharging power. - - Converts the commanded power to actual power stored/released from - the battery considering efficiency losses. - - Args: - P_charge (float): Commanded charging/discharging power in kW. - - Returns: - float: Actual power stored/released considering efficiency in kW. - """ - # P_in is the amount of power that actually gets stored in the state E - # P_charge is the amount of power given to the charging physics - - if P_charge >= 0: - P_in = self.eta_charge * P_charge - else: - P_in = P_charge / self.eta_discharge - return P_in - - def SS_input_function_inverse(self, P_in): - """Calculate required commanded power for desired stored power. - - Inverse of SS_input_function to determine the commanded power needed - to achieve a desired power storage/release rate. - - Args: - P_in (float): Desired power to be stored/released in kW. - - Returns: - float: Required commanded power considering efficiency in kW. - """ - if P_in >= 0: - P_charge = P_in / self.eta_charge - else: - P_charge = P_in * self.eta_discharge - return P_charge - - def step_SS(self, u): - """Advance the state-space model by one time step. - - Updates the battery energy state considering self-discharge and - efficiency losses. - - Args: - u (float): Input power command in kW. - """ - # Advance the state-space loop - xd = self.A * self.x + self.SS_input_function(u) - y = self.C * self.x + self.D * u - - self.x = self.integrate(self.x, xd) - self.y = y - - def integrate(self, x, xd): - """Integrate state derivatives using Euler method. - - Args: - x (np.ndarray): Current state vector. - xd (np.ndarray): State derivative vector. - - Returns: - np.ndarray: Updated state vector. - """ - # TODO: Use better integration method like closed form step response solution - return x + xd * self.dt # Euler integration - - def calc_usage(self): - """Calculate battery usage based on cycle counting and time. - - Uses the rainflow algorithm to count cycles in the energy storage operation - following the three-point technique (ASTM Standard E 1049-85). Also tracks - time-based usage for degradation modeling. - """ - # Count rainflow cycles - # This step uses the rainflow algorithm to count how many cycles exist in the - # storage operation using the three-point technique (ASTM Standard E 1049-85) - # The algorithm returns the size (amplitude) of the cycle, and the number of cycles at - # that amplitude at that point in the signal - ranges_counts = rainflow.count_cycles(self.E_store) - ranges = np.array([rc[0] for rc in ranges_counts], dtype=hercules_float_type) - counts = np.array([rc[1] for rc in ranges_counts], dtype=hercules_float_type) - self.total_cycle_usage = (ranges * counts).sum() / self.E_max - self.cycle_usage_perc = self.total_cycle_usage * self.usage_cycles_rate * 100 - - # Calculate time usage - self.total_time_usage += self.usage_calc_interval * self.dt - self.time_usage_perc = self.total_time_usage * self.usage_time_rate * 100 - - # self.apply_degradation(this_period_degradation) - - def apply_degradation(self, degradation): - """Apply degradation effects to battery performance. - - This method would apply the calculated degradation to battery efficiency - and capacity, but is not yet implemented. - - Args: - degradation (float): Degradation factor to apply. - - Raises: - NotImplementedError: Method is not yet implemented. - """ - # total_degradation_effect = self.total_degradation*self.degradation_rate - # print('degradation penalty', total_degradation_effect, np.sqrt(total_degradation_effect)) - # self.eta_charge = self.eta_charge - np.sqrt(total_degradation_effect) - # self.eta_discharge = self.eta_discharge - np.sqrt(total_degradation_effect) - raise NotImplementedError( - "Degradation impacts on real-time efficiency have not yet been implemented." - ) +""" +Battery models +Author: Zack tully - zachary.tully@nrel.gov +March 2024 + +References: +[1] M.-K. Tran et al., “A comprehensive equivalent circuit model for lithium-ion +batteries, incorporating the effects of state of health, state of charge, and +temperature on model parameters,” Journal of Energy Storage, vol. 43, p. 103252, +Nov. 2021, doi: 10.1016/j.est.2021.103252. +""" + +import numpy as np +import rainflow +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import hercules_float_type + + +def kJ2kWh(kJ): + """Convert a value in kJ to kWh. + + Args: + kJ (float): Energy value in kilojoules. + + Returns: + float: Energy value in kilowatt-hours. + """ + return kJ / 3600 + + +def kWh2kJ(kWh): + """Convert a value in kWh to kJ. + + Args: + kWh (float): Energy value in kilowatt-hours. + + Returns: + float: Energy value in kilojoules. + """ + return kWh * 3600 + + +def years_to_usage_rate(years, dt): + """Convert a number of years to a usage rate. + + Args: + years (float): Life of the storage system in years. + dt (float): Time step of the simulation in seconds. + + Returns: + float: Usage rate per time step. + """ + days = years * 365 + hours = days * 24 + seconds = hours * 3600 + usage_lifetime = seconds / dt + + return 1 / usage_lifetime + + +def cycles_to_usage_rate(cycles): + """Convert cycle number to degradation rate. + + Args: + cycles (int): Number of cycles until the unit needs to be replaced. + + Returns: + float: Degradation rate per cycle. + """ + return 1 / cycles + + +class BatterySimple(ComponentBase): + """Simple battery energy storage model. + + This model represents a basic battery with energy storage and power constraints. + It tracks state of charge, applies efficiency losses, and optionally tracks + usage-based degradation using rainflow cycle counting. + + Note: + All power units are in kW and energy units are in kWh. + """ + + def __init__(self, h_dict): + """Initialize the BatterySimple class. + + This model represents a simple battery with energy storage and power constraints. + It tracks state of charge and applies efficiency losses. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - energy_capacity: Battery energy capacity in kWh + - charge_rate: Maximum charge rate in kW + - discharge_rate: Maximum discharge rate in kW + - max_SOC: Maximum state of charge (0-1) + - min_SOC: Minimum state of charge (0-1) + - initial_conditions: Dictionary with initial SOC + - allow_grid_power_consumption: Optional, defaults to False + - roundtrip_efficiency: Optional roundtrip efficiency (0-1) + - self_discharge_time_constant: Optional self-discharge time constant + - track_usage: Optional boolean to enable usage tracking + """ + # Store the name of this component + self.component_name = "battery" + + # Store the type of this component + self.component_type = "BatterySimple" + + # Call the base class init + super().__init__(h_dict, self.component_name) + + # size = h_dict[self.component_name]["size"] + self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] + initial_conditions = h_dict[self.component_name]["initial_conditions"] + self.SOC = initial_conditions["SOC"] # [fraction] + + self.SOC_max = h_dict[self.component_name]["max_SOC"] + self.SOC_min = h_dict[self.component_name]["min_SOC"] + + # Charge (Energy) limits [kJ] + self.E_min = kWh2kJ(self.SOC_min * self.energy_capacity) + self.E_max = kWh2kJ(self.SOC_max * self.energy_capacity) + + charge_rate = h_dict[self.component_name]["charge_rate"] # [kW] + discharge_rate = h_dict[self.component_name]["discharge_rate"] # [kW] + + # Charge/discharge (Power) limits [kW] + self.P_min = -discharge_rate + self.P_max = charge_rate + + # Ramp up/down limits [kW/s] + self.R_min = -np.inf + self.R_max = np.inf + + # Flag for allowing grid to charge the battery + if "allow_grid_power_consumption" in h_dict[self.component_name].keys(): + self.allow_grid_power_consumption = h_dict[self.component_name][ + "allow_grid_power_consumption" + ] + else: + self.allow_grid_power_consumption = False + + # Efficiency and self-discharge parameters + if "roundtrip_efficiency" in h_dict[self.component_name].keys(): + self.eta_charge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) + self.eta_discharge = np.sqrt(h_dict[self.component_name]["roundtrip_efficiency"]) + else: + self.eta_charge = 1 + self.eta_discharge = 1 + + if "self_discharge_time_constant" in h_dict[self.component_name].keys(): + self.tau_self_discharge = h_dict[self.component_name]["self_discharge_time_constant"] + else: + self.tau_self_discharge = np.inf + + if "track_usage" in h_dict[self.component_name].keys(): + if h_dict[self.component_name]["track_usage"]: + self.track_usage = True + # Set usage tracking parameters + if "usage_calc_interval" in h_dict[self.component_name].keys(): + self.usage_calc_interval = ( + h_dict[self.component_name]["usage_calc_interval"] / self.dt + ) + else: + self.usage_calc_interval = 100 / self.dt # timesteps + + if "usage_lifetime" in h_dict[self.component_name].keys(): + usage_lifetime = h_dict[self.component_name]["usage_lifetime"] + self.usage_time_rate = years_to_usage_rate(usage_lifetime, self.dt) + else: + self.usage_time_rate = 0 + if "usage_cycles" in h_dict[self.component_name].keys(): + usage_cycles = h_dict[self.component_name]["usage_cycles"] + self.usage_cycles_rate = cycles_to_usage_rate(usage_cycles) + else: + self.usage_cycles_rate = 0 + + # TODO: add the ability to impact efficiency of the battery operation + + else: + self.track_usage = False + self.usage_calc_interval = np.inf + else: + self.track_usage = False + self.usage_calc_interval = np.inf + + # Degradation and state storage + self.P_charge_storage = [] + self.E_store = [] + self.total_cycle_usage = 0 + self.cycle_usage_perc = 0 + self.total_time_usage = 0 + self.time_usage_perc = 0 + self.step_counter = 0 + # TODO there should be a better way to dynamically store these than to append a list + + self.build_SS() + self.x = np.array( + [[initial_conditions["SOC"] * self.energy_capacity * 3600]], dtype=hercules_float_type + ) + self.y = None + + # self.total_battery_capacity = 3600 * self.energy_capacity / self.dt + self.current_batt_state = self.SOC * self.energy_capacity + self.E = kWh2kJ(self.current_batt_state) + + self.power_kw = 0 + self.P_reject = 0 + self.P_charge = 0 + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + + # Add what we want later + h_dict[self.component_name]["power"] = 0 + h_dict[self.component_name]["soc"] = self.SOC + + return h_dict + + def step(self, h_dict): + """Advance the battery simulation by one time step. + + Updates the battery state including SOC, energy storage, and power output + based on the requested power setpoint and available power. Optionally + calculates usage-based degradation. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - battery.power_setpoint: Requested charging/discharging power [kW] + - plant.locally_generated_power: Available power for charging [kW] + + Returns: + dict: Updated h_dict with battery outputs: + - power: Actual charging/discharging power [kW] + - reject: Rejected power due to constraints [kW] + - soc: State of charge [0-1] + - usage_in_time: Time-based usage percentage + - usage_in_cycles: Cycle-based usage percentage + - total_cycles: Total equivalent cycles completed + """ + self.step_counter += 1 + + # Power available for the battery to use for charging (should be >=0) + power_setpoint = h_dict[self.component_name]["power_setpoint"] + # Power signal desired by the controller + if self.allow_grid_power_consumption: + P_avail = np.inf + else: + P_avail = h_dict["plant"]["locally_generated_power"] # [kW] available power + + P_charge, P_reject = self.control(P_avail, power_setpoint) + + # Update energy state + # self.E += self.P_charge * self.dt + self.step_SS(P_charge) + self.E = self.x[0, 0] # TODO find a better way to make self.x 1-D + + self.current_batt_state = kJ2kWh(self.E) + + self.power_kw = P_charge + self.SOC = self.current_batt_state / self.energy_capacity + + self.P_charge_storage.append(P_charge) + self.E_store.append(self.E) + + if self.step_counter >= self.usage_calc_interval: + # reset step_counter + self.step_counter = 0 + self.calc_usage() + + # Update the outputs + h_dict[self.component_name]["power"] = self.power_kw + h_dict[self.component_name]["reject"] = P_reject + h_dict[self.component_name]["soc"] = self.SOC + h_dict[self.component_name]["usage_in_time"] = self.time_usage_perc + h_dict[self.component_name]["usage_in_cycles"] = self.cycle_usage_perc + h_dict[self.component_name]["total_cycles"] = self.total_cycle_usage + + # Return the updated dictionary + return h_dict + + def control(self, P_avail, power_setpoint): + """Apply battery operational constraints to requested power. + + Low-level controller that enforces energy, power, and ramp rate constraints. + Determines the actual charging/discharging power and any rejected power. + + Args: + P_avail (float): Available power for charging in kW. + power_setpoint (float): Desired charging/discharging power in kW. + + Returns: + tuple: (P_charge, P_reject) where: + - P_charge: Actual charging/discharging power in kW (positive for charging) + - P_reject: Rejected power due to constraints in kW (positive when + power cannot be absorbed, negative when required power unavailable) + """ + + # TODO remove ramp rate constraints because they are never used? + + # Upper constraints [kW] + # c_hi1 = (self.E_max - self.E) / self.dt # energy + c_hi1 = self.SS_input_function_inverse((self.E_max - self.x[0, 0]) / self.dt) + c_hi2 = self.P_max # power + c_hi3 = self.R_max * self.dt + self.P_charge # ramp rate + c_hi4 = P_avail + + # Lower constraints [kW] + # c_lo1 = (self.E_min - self.E) / self.dt # energy + c_lo1 = self.SS_input_function_inverse((self.E_min - self.x[0, 0]) / self.dt) + c_lo2 = self.P_min # power + c_lo3 = self.R_min * self.dt + self.P_charge # ramp rate + + # High constraint is the most restrictive of the high constraints + c_hi = np.min([c_hi1, c_hi2, c_hi3, c_hi4]) + c_hi = np.max([c_hi, 0]) + + # Low constraint is the most restrictive of the low constraints + c_lo = np.max([c_lo1, c_lo2, c_lo3]) + c_lo = np.min([c_lo, 0]) + + # TODO: force low constraint to be no higher than lowest high constraint + if (power_setpoint >= c_lo) & (power_setpoint <= c_hi): + P_charge = power_setpoint + P_reject = 0 + elif power_setpoint < c_lo: + P_charge = c_lo + P_reject = power_setpoint - P_charge + elif power_setpoint > c_hi: + P_charge = c_hi + P_reject = power_setpoint - P_charge + + self.P_charge = P_charge + self.P_reject = P_reject + + return P_charge, P_reject + + def build_SS(self): + """Build state-space model matrices for battery dynamics. + + Constructs the state-space representation that includes self-discharge + and efficiency losses. + """ + self.A = np.array([[-1 / self.tau_self_discharge]], dtype=hercules_float_type) + # B matrix is handled by the SS_input_function + self.C = np.array([[1, 0]], dtype=hercules_float_type).T + self.D = np.array([[0, 1]], dtype=hercules_float_type).T + + def SS_input_function(self, P_charge): + """Apply efficiency losses to charging/discharging power. + + Converts the commanded power to actual power stored/released from + the battery considering efficiency losses. + + Args: + P_charge (float): Commanded charging/discharging power in kW. + + Returns: + float: Actual power stored/released considering efficiency in kW. + """ + # P_in is the amount of power that actually gets stored in the state E + # P_charge is the amount of power given to the charging physics + + if P_charge >= 0: + P_in = self.eta_charge * P_charge + else: + P_in = P_charge / self.eta_discharge + return P_in + + def SS_input_function_inverse(self, P_in): + """Calculate required commanded power for desired stored power. + + Inverse of SS_input_function to determine the commanded power needed + to achieve a desired power storage/release rate. + + Args: + P_in (float): Desired power to be stored/released in kW. + + Returns: + float: Required commanded power considering efficiency in kW. + """ + if P_in >= 0: + P_charge = P_in / self.eta_charge + else: + P_charge = P_in * self.eta_discharge + return P_charge + + def step_SS(self, u): + """Advance the state-space model by one time step. + + Updates the battery energy state considering self-discharge and + efficiency losses. + + Args: + u (float): Input power command in kW. + """ + # Advance the state-space loop + xd = self.A * self.x + self.SS_input_function(u) + y = self.C * self.x + self.D * u + + self.x = self.integrate(self.x, xd) + self.y = y + + def integrate(self, x, xd): + """Integrate state derivatives using Euler method. + + Args: + x (np.ndarray): Current state vector. + xd (np.ndarray): State derivative vector. + + Returns: + np.ndarray: Updated state vector. + """ + # TODO: Use better integration method like closed form step response solution + return x + xd * self.dt # Euler integration + + def calc_usage(self): + """Calculate battery usage based on cycle counting and time. + + Uses the rainflow algorithm to count cycles in the energy storage operation + following the three-point technique (ASTM Standard E 1049-85). Also tracks + time-based usage for degradation modeling. + """ + # Count rainflow cycles + # This step uses the rainflow algorithm to count how many cycles exist in the + # storage operation using the three-point technique (ASTM Standard E 1049-85) + # The algorithm returns the size (amplitude) of the cycle, and the number of cycles at + # that amplitude at that point in the signal + ranges_counts = rainflow.count_cycles(self.E_store) + ranges = np.array([rc[0] for rc in ranges_counts], dtype=hercules_float_type) + counts = np.array([rc[1] for rc in ranges_counts], dtype=hercules_float_type) + self.total_cycle_usage = (ranges * counts).sum() / self.E_max + self.cycle_usage_perc = self.total_cycle_usage * self.usage_cycles_rate * 100 + + # Calculate time usage + self.total_time_usage += self.usage_calc_interval * self.dt + self.time_usage_perc = self.total_time_usage * self.usage_time_rate * 100 + + # self.apply_degradation(this_period_degradation) + + def apply_degradation(self, degradation): + """Apply degradation effects to battery performance. + + This method would apply the calculated degradation to battery efficiency + and capacity, but is not yet implemented. + + Args: + degradation (float): Degradation factor to apply. + + Raises: + NotImplementedError: Method is not yet implemented. + """ + # total_degradation_effect = self.total_degradation*self.degradation_rate + # print('degradation penalty', total_degradation_effect, np.sqrt(total_degradation_effect)) + # self.eta_charge = self.eta_charge - np.sqrt(total_degradation_effect) + # self.eta_discharge = self.eta_discharge - np.sqrt(total_degradation_effect) + raise NotImplementedError( + "Degradation impacts on real-time efficiency have not yet been implemented." + ) From 5ef124fc8bbb77d6821408ceca4cbc762541a7bf Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 12:46:12 -0700 Subject: [PATCH 34/38] Add back changes to battery_simple --- hercules/plant_components/battery_simple.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 8e77bf22..b00c32b0 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -81,7 +81,9 @@ class BatterySimple(ComponentBase): All power units are in kW and energy units are in kWh. """ - def __init__(self, h_dict): + component_category = "storage" + + def __init__(self, h_dict, component_name): """Initialize the BatterySimple class. This model represents a simple battery with energy storage and power constraints. @@ -99,15 +101,11 @@ def __init__(self, h_dict): - roundtrip_efficiency: Optional roundtrip efficiency (0-1) - self_discharge_time_constant: Optional self-discharge time constant - track_usage: Optional boolean to enable usage tracking + component_name (str): Unique name for this instance (the YAML top-level key). """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatterySimple" - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) # size = h_dict[self.component_name]["size"] self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] @@ -240,7 +238,7 @@ def step(self, h_dict): - plant.locally_generated_power: Available power for charging [kW] Returns: - dict: Updated h_dict with battery outputs: + dict: Updated h_dict with battery outputs stored under self.component_name: - power: Actual charging/discharging power [kW] - reject: Rejected power due to constraints [kW] - soc: State of charge [0-1] From 43e22d2c934c6331eeef738a21f63a868f26d9a4 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 12:49:26 -0700 Subject: [PATCH 35/38] add back changes to LI battery --- hercules/plant_components/battery_lithium_ion.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index dd1b2fb2..c6447722 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -87,7 +87,9 @@ class BatteryLithiumIon(ComponentBase): Nov. 2021, doi: 10.1016/j.est.2021.103252. """ - def __init__(self, h_dict): + component_category = "storage" + + def __init__(self, h_dict, component_name): """Initialize the BatteryLithiumIon class. This model represents a detailed lithium-ion battery with diffusion transients @@ -102,16 +104,11 @@ def __init__(self, h_dict): - min_SOC: Minimum state of charge (0-1) - initial_conditions: Dictionary with initial SOC - allow_grid_power_consumption: Optional, defaults to False + component_name (str): Unique name for this instance (the YAML top-level key). """ - # Store the name of this component - self.component_name = "battery" - - # Store the type of this component - self.component_type = "BatteryLithiumIon" - - # Call the base class init - super().__init__(h_dict, self.component_name) + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) self.V_cell_nom = 3.3 # [V] self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 From 6ec2fc372993e1044490e0107df2146ecde251b8 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 13:45:12 -0700 Subject: [PATCH 36/38] remove gen column --- docs/component_types.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/component_types.md b/docs/component_types.md index 364fc40b..815dd580 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -45,15 +45,15 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro ## Complete Component Type Reference -| `component_type` | `component_category` | Generator? | Documentation | -|---|---|---|---| -| `WindFarm` | `generator` | Yes | [Wind](wind.md) | -| `WindFarmSCADAPower` | `generator` | Yes | [Wind](wind.md) | -| `SolarPySAMPVWatts` | `generator` | Yes | [Solar PV](solar_pv.md) | -| `BatterySimple` | `storage` | No | [Battery](battery.md) | -| `BatteryLithiumIon` | `storage` | No | [Battery](battery.md) | -| `ElectrolyzerPlant` | `load` | No | [Electrolyzer](electrolyzer.md) | -| `OpenCycleGasTurbine` | `generator` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | +| `component_type` | `component_category` | Documentation | +|---|---|---| +| `WindFarm` | `generator` | [Wind](wind.md) | +| `WindFarmSCADAPower` | `generator` | [Wind](wind.md) | +| `SolarPySAMPVWatts` | `generator` | [Solar PV](solar_pv.md) | +| `BatterySimple` | `storage` | [Battery](battery.md) | +| `BatteryLithiumIon` | `storage` | [Battery](battery.md) | +| `ElectrolyzerPlant` | `load` | [Electrolyzer](electrolyzer.md) | +| `OpenCycleGasTurbine` | `generator` | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. From 05f7b011b2b27e1e9d2fda2fa22f13ff747f7894 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 2 Mar 2026 13:46:41 -0700 Subject: [PATCH 37/38] typo fix --- hercules/utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hercules/utilities.py b/hercules/utilities.py index 8857aee7..d8a9eb82 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -309,7 +309,7 @@ def load_hercules_input(filename): f'"{key}" has an unrecognized component_type ' f'"{h_dict[key]["component_type"]}" in input file {filename}. ' f"Available types: {sorted(valid_component_types)}" - "(Did you forget to add a new component_type to the COMPONENT_REGISTRY)" + "(Did you forget to add a new component_type to the COMPONENT_REGISTRY?)" ) # Handle external_data structure normalization From 890b2a975b0eb49a7e12ba16bb7b1a1557d9c99c Mon Sep 17 00:00:00 2001 From: paulf81 Date: Mon, 2 Mar 2026 15:55:12 -0700 Subject: [PATCH 38/38] first pass functionalization --- hercules/resource/resource_utilities.py | 570 ++++++++++++ .../wind_solar_resource_downloader.py | 871 +++++------------- 2 files changed, 820 insertions(+), 621 deletions(-) create mode 100644 hercules/resource/resource_utilities.py diff --git a/hercules/resource/resource_utilities.py b/hercules/resource/resource_utilities.py new file mode 100644 index 00000000..bcdf09ed --- /dev/null +++ b/hercules/resource/resource_utilities.py @@ -0,0 +1,570 @@ +"""Shared utilities for resource data downloading and visualization. + +This module provides common functions used by the NSRDB, WTK, and Open-Meteo +resource downloaders, including time parameter validation, data I/O, +elapsed time formatting, and plotting. +""" + +import math +import os +import time +from typing import List, Optional + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from hercules.utilities import hercules_float_type +from rex import ResourceX +from scipy.interpolate import griddata + + +def validate_time_params( + year: Optional[int], + start_date: Optional[str], + end_date: Optional[str], +) -> dict: + """Validate time parameters and compute derived time information. + + Ensures that either ``year`` or both ``start_date`` and ``end_date`` are + provided (but not both). Returns file_years, time_suffix, + time_description, and resolved start_date/end_date values. + + Args: + year (int, optional): Year of data to download. + start_date (str, optional): Start date in 'YYYY-MM-DD' format. + end_date (str, optional): End date in 'YYYY-MM-DD' format. + + Returns: + dict: Dictionary with keys: + - file_years (list[int]): Years spanned by the time range. + - time_suffix (str): Filename-safe suffix for the time range. + - time_description (str): Human-readable time range description. + - start_date (str): Resolved start date string. + - end_date (str): Resolved end date string. + + Raises: + ValueError: If the parameter combination is invalid or if + start_date > end_date. + """ + if year is not None and (start_date is not None or end_date is not None): + raise ValueError( + "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." + ) + + if year is None and (start_date is None or end_date is None): + raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") + + if year is not None: + return { + "file_years": [year], + "time_suffix": str(year), + "time_description": f"year {year}", + "start_date": f"{year}-01-01", + "end_date": f"{year}-12-31", + } + + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + if start_dt > end_dt: + raise ValueError("start_date must be before end_date") + + return { + "file_years": list(range(start_dt.year, end_dt.year + 1)), + "time_suffix": f"{start_date}_to_{end_date}".replace("-", ""), + "time_description": f"period {start_date} to {end_date}", + "start_date": start_date, + "end_date": end_date, + } + + +def create_bounding_box( + target_lat: float, + target_lon: float, + coord_delta: float, +) -> tuple: + """Create a bounding box from a center point and coordinate delta. + + Args: + target_lat (float): Center latitude coordinate. + target_lon (float): Center longitude coordinate. + coord_delta (float): Half-width of the bounding box in degrees. + + Returns: + tuple: (llcrn_lat, llcrn_lon, urcrn_lat, urcrn_lon) lower-left and + upper-right corners of the bounding box. + """ + return ( + target_lat - coord_delta, + target_lon - coord_delta, + target_lat + coord_delta, + target_lon + coord_delta, + ) + + +def download_nrel_rex_data( + dataset_path: str, + dataset_filename_prefix: str, + source_name: str, + target_lat: float, + target_lon: float, + variables: List[str], + bounding_box: tuple, + file_years: List[int], + start_date: Optional[str], + end_date: Optional[str], + output_dir: str, + filename_prefix: str, + time_suffix: str, + time_description: str, + os_error_hint: str = "This could be caused by an invalid API key or date range.", +) -> dict: + """Download data from an NLR rex-based dataset (NSRDB or WTK). + + Handles the complete download workflow: fetching data via ResourceX for + each year, concatenating across years, converting to the hercules float + type, and saving to feather format. + + Args: + dataset_path (str): Base path of the dataset on the NLR HSDS server. + dataset_filename_prefix (str): Filename prefix for the HDF5 files + in the format ``{dataset_filename_prefix}_{year}.h5``. + source_name (str): Human-readable data source name (e.g., "NSRDB", + "WTK") used in log messages. + target_lat (float): Target latitude coordinate. + target_lon (float): Target longitude coordinate. + variables (list[str]): List of variables to download. + bounding_box (tuple): (llcrn_lat, llcrn_lon, urcrn_lat, urcrn_lon) + corners of the spatial bounding box. + file_years (list[int]): List of years to download. + start_date (str, optional): Start date for filtering. If None, no + date filtering is applied. + end_date (str, optional): End date for filtering. If None, no date + filtering is applied. + output_dir (str): Directory to save output feather files. + filename_prefix (str): Prefix for output filenames. + time_suffix (str): Suffix for output filenames encoding the time + range. + time_description (str): Human-readable time range for log messages. + os_error_hint (str, optional): Additional context for OSError + messages. Defaults to "This could be caused by an invalid API + key or date range." + + Returns: + dict: Dictionary containing DataFrames for each variable and a + "coordinates" key with lat/lon data. + + Raises: + OSError: If there is an error accessing the NLR HSDS server. + """ + llcrn_lat, llcrn_lon, urcrn_lat, urcrn_lon = bounding_box + + print(f"Downloading {source_name} data for {time_description}") + print(f"Target coordinates: ({target_lat}, {target_lon})") + print(f"Bounding box: ({llcrn_lat}, {llcrn_lon}) to ({urcrn_lat}, {urcrn_lon})") + print(f"Variables: {variables}") + print(f"Years to process: {file_years}") + + t0 = time.time() + + data_dict = {} + all_dataframes = {var: [] for var in variables} + + try: + for file_year in file_years: + print(f"\nProcessing year {file_year}...") + fp = f"{dataset_path}/{dataset_filename_prefix}_{file_year}.h5" + + with ResourceX(fp) as res: + for var in variables: + print(f" Downloading {var} for {file_year}...") + df_year = res.get_box_df( + var, + lat_lon_1=[llcrn_lat, llcrn_lon], + lat_lon_2=[urcrn_lat, urcrn_lon], + ) + + if start_date is not None and end_date is not None: + df_year = df_year.loc[start_date:end_date] + + all_dataframes[var].append(df_year) + + if "coordinates" not in data_dict: + gids = df_year.columns.values + coordinates = res.lat_lon[gids] + df_coords = pd.DataFrame(coordinates, index=gids, columns=["lat", "lon"]) + data_dict["coordinates"] = df_coords + + for var in variables: + if all_dataframes[var]: + print(f"Concatenating {var} data across {len(all_dataframes[var])} years...") + data_dict[var] = pd.concat(all_dataframes[var], axis=0).sort_index() + + for col in data_dict[var].columns: + if pd.api.types.is_numeric_dtype(data_dict[var][col]): + data_dict[var][col] = data_dict[var][col].astype(hercules_float_type) + + all_dataframes[var].clear() + + save_variable_to_feather( + data_dict[var], + output_dir, + filename_prefix, + var, + time_suffix, + ) + + save_coords_to_feather( + data_dict["coordinates"], + output_dir, + filename_prefix, + time_suffix, + ) + + except OSError as e: + print(f"Error downloading {source_name} data: {e}") + print(os_error_hint) + raise + except Exception as e: + print(f"Error downloading {source_name} data: {e}") + raise + + print_elapsed_time(t0, source_name) + + return data_dict + + +def save_variable_to_feather( + df: pd.DataFrame, + output_dir: str, + filename_prefix: str, + var_name: str, + time_suffix: str, +) -> str: + """Save a variable DataFrame to feather format. + + Args: + df (pd.DataFrame): DataFrame to save. + output_dir (str): Directory to save the file in. + filename_prefix (str): Prefix for the filename. + var_name (str): Variable name included in the filename. + time_suffix (str): Time range suffix included in the filename. + + Returns: + str: Path to the saved feather file. + """ + output_file = os.path.join(output_dir, f"{filename_prefix}_{var_name}_{time_suffix}.feather") + df.reset_index().to_feather(output_file) + print(f"Saved {var_name} data to {output_file}") + return output_file + + +def save_coords_to_feather( + df_coords: pd.DataFrame, + output_dir: str, + filename_prefix: str, + time_suffix: str, +) -> str: + """Save a coordinates DataFrame to feather format. + + Args: + df_coords (pd.DataFrame): Coordinates DataFrame with 'lat' and + 'lon' columns. + output_dir (str): Directory to save the file in. + filename_prefix (str): Prefix for the filename. + time_suffix (str): Time range suffix included in the filename. + + Returns: + str: Path to the saved feather file. + """ + coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") + df_coords.reset_index().to_feather(coords_file) + print(f"Saved coordinates to {coords_file}") + return coords_file + + +def print_elapsed_time(t0: float, source_name: str) -> None: + """Print elapsed time since t0 in minutes:seconds format. + + Args: + t0 (float): Start time from ``time.time()``. + source_name (str): Name of the data source for the log message. + """ + total_time = (time.time() - t0) / 60 + decimal_part = math.modf(total_time)[0] * 60 + print( + f"{source_name} download completed in " + f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d}" + " minutes" + ) + + +def dispatch_plots( + data_dict: dict, + variables: List[str], + plot_data: bool, + plot_type: str, + title: str, +) -> None: + """Dispatch plotting based on the plot_data flag and plot_type. + + Args: + data_dict (dict): Dictionary containing DataFrames for each variable + and a "coordinates" key. + variables (list[str]): List of variable names to plot. + plot_data (bool): Whether to create plots. + plot_type (str): Type of plot: 'timeseries' or 'map'. + title (str): Title for the plots. + """ + if plot_data and data_dict and "coordinates" in data_dict: + coordinates_array = data_dict["coordinates"][["lat", "lon"]].values + if plot_type == "timeseries": + plot_timeseries(data_dict, variables, coordinates_array, title) + elif plot_type == "map": + plot_spatial_map(data_dict, variables, coordinates_array, title) + + +# --------------------------------------------------------------------------- +# Plotting functions +# --------------------------------------------------------------------------- + + +def plot_timeseries( + data_dict: dict, + variables: List[str], + coordinates: np.ndarray, + title: str, +): + """Create time-series plots for the downloaded data. + + Args: + data_dict (dict): Dictionary containing DataFrames for each variable. + variables (list[str]): List of variables to plot. + coordinates (np.ndarray): Array of coordinates for the data points. + title (str): Title for the plots. + """ + n_vars = len(variables) + if n_vars == 0: + return + + fig, axes = plt.subplots(n_vars, 1, figsize=(12, 4 * n_vars), sharex=True) + if n_vars == 1: + axes = [axes] + + for i, var in enumerate(variables): + if var in data_dict: + df = data_dict[var] + + for col in df.columns: + axes[i].plot(df.index, df[col], alpha=0.7, linewidth=0.8) + + axes[i].set_ylabel(get_variable_label(var)) + axes[i].set_title(f"{var.replace('_', ' ').title()}") + axes[i].grid(True, alpha=0.3) + + axes[-1].set_xlabel("Time") + plt.suptitle(f"{title} - Time Series", fontsize=14, fontweight="bold") + plt.tight_layout() + + +def plot_spatial_map( + data_dict: dict, + variables: List[str], + coordinates: np.ndarray, + title: str, +): + """Create spatial maps showing the mean values across the region. + + Args: + data_dict (dict): Dictionary containing DataFrames for each variable. + variables (list[str]): List of variables to plot. + coordinates (np.ndarray): Array of coordinates for the data points. + title (str): Title for the plots. + """ + n_vars = len(variables) + if n_vars == 0: + return + + n_cols = min(2, n_vars) + n_rows = math.ceil(n_vars / n_cols) + + plt.figure(figsize=(8 * n_cols, 6 * n_rows)) + + for i, var in enumerate(variables): + if var in data_dict: + df = data_dict[var] + + lats = coordinates[:, 0] + lons = coordinates[:, 1] + + mean_values = df.mean(axis=0).values + + ax = plt.subplot(n_rows, n_cols, i + 1, projection=ccrs.PlateCarree()) + + ax.add_feature(cfeature.COASTLINE, alpha=0.5) + ax.add_feature(cfeature.BORDERS, linestyle=":", alpha=0.5) + ax.add_feature( + cfeature.LAND, + edgecolor="black", + facecolor="lightgray", + alpha=0.3, + ) + ax.add_feature(cfeature.OCEAN, facecolor="lightblue", alpha=0.3) + + if len(lats) > 4: + grid_lon = np.linspace(min(lons), max(lons), 50) + grid_lat = np.linspace(min(lats), max(lats), 50) + grid_lon, grid_lat = np.meshgrid(grid_lon, grid_lat) + + try: + grid_values = griddata( + (lons, lats), + mean_values, + (grid_lon, grid_lat), + method="cubic", + ) + contour = ax.contourf( + grid_lon, + grid_lat, + grid_values, + levels=15, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + contour, + ax=ax, + orientation="vertical", + label=get_variable_label(var), + shrink=0.8, + ) + except Exception: + sc = ax.scatter( + lons, + lats, + c=mean_values, + s=100, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + sc, + ax=ax, + orientation="vertical", + label=get_variable_label(var), + shrink=0.8, + ) + else: + sc = ax.scatter( + lons, + lats, + c=mean_values, + s=100, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + sc, + ax=ax, + orientation="vertical", + label=get_variable_label(var), + shrink=0.8, + ) + + ax.scatter( + lons, + lats, + c="black", + s=20, + transform=ccrs.PlateCarree(), + alpha=0.8, + ) + + ax.set_title(f"{var.replace('_', ' ').title()}") + + ax.set_xticks(np.linspace(min(lons), max(lons), 5)) + ax.set_yticks(np.linspace(min(lats), max(lats), 5)) + ax.set_xticklabels( + [f"{lon:.2f}°" for lon in np.linspace(min(lons), max(lons), 5)], + fontsize=8, + ) + ax.set_yticklabels( + [f"{lat:.2f}°" for lat in np.linspace(min(lats), max(lats), 5)], + fontsize=8, + ) + ax.set_xlabel("Longitude") + ax.set_ylabel("Latitude") + + plt.suptitle( + f"{title} - Spatial Distribution (Time-Averaged)", + fontsize=14, + fontweight="bold", + ) + plt.tight_layout() + + +# --------------------------------------------------------------------------- +# Variable metadata helpers +# --------------------------------------------------------------------------- + + +def get_variable_label(variable: str) -> str: + """Get appropriate axis label with units for a variable. + + Args: + variable (str): Variable name. + + Returns: + str: Label with units for the variable. + """ + labels = { + "ghi": "GHI (W/m²)", + "dni": "DNI (W/m²)", + "dhi": "DHI (W/m²)", + "windspeed_100m": "Wind Speed at 100m (m/s)", + "winddirection_100m": "Wind Direction at 100m (°)", + "turbulent_kinetic_energy_100m": "TKE at 100m (m²/s²)", + "temperature_100m": "Temperature at 100m (°C)", + "pressure_100m": "Pressure at 100m (Pa)", + "wind_speed_80m": "Wind Speed at 80m (m/s)", + "windspeed_80m": "Wind Speed at 80m (m/s)", + "wind_direction_80m": "Wind Direction at 80m (°)", + "winddirection_80m": "Wind Direction at 80m (°)", + "temperature_2m": "Temperature at 2m (°C)", + "shortwave_radiation_instant": "Shortwave Radiation (W/m²)", + "diffuse_radiation_instant": "Diffuse Radiation (W/m²)", + "direct_normal_irradiance_instant": "Direct Normal Irradiance (W/m²)", + } + return labels.get(variable, variable.replace("_", " ").title()) + + +def get_variable_colormap(variable: str) -> str: + """Get appropriate matplotlib colormap name for a variable. + + Args: + variable (str): Variable name. + + Returns: + str: Matplotlib colormap name for the variable. + """ + colormaps = { + "ghi": "plasma", + "dni": "plasma", + "dhi": "plasma", + "windspeed_100m": "viridis", + "winddirection_100m": "hsv", + "turbulent_kinetic_energy_100m": "cividis", + "temperature_100m": "RdYlBu_r", + "pressure_100m": "coolwarm", + "wind_speed_80m": "viridis", + "windspeed_80m": "viridis", + "wind_direction_80m": "hsv", + "winddirection_80m": "hsv", + "temperature_2m": "RdYlBu_r", + "shortwave_radiation_instant": "plasma", + "diffuse_radiation_instant": "plasma", + "direct_normal_irradiance_instant": "plasma", + } + return colormaps.get(variable, "viridis") diff --git a/hercules/resource/wind_solar_resource_downloader.py b/hercules/resource/wind_solar_resource_downloader.py index 51870963..610015cc 100644 --- a/hercules/resource/wind_solar_resource_downloader.py +++ b/hercules/resource/wind_solar_resource_downloader.py @@ -1,7 +1,6 @@ -""" -WTK, NSRDB, and Open-Meteo Data Downloader +"""WTK, NSRDB, and Open-Meteo Data Downloader -This script provides functions to download weather data from multiple sources: +This module provides functions to download weather data from multiple sources: - NLR's Wind Toolkit (WTK) for high-resolution wind data - NLR's National Solar Radiation Database (NSRDB) for solar irradiance data - Open-Meteo API for historical weather data with global coverage @@ -14,23 +13,40 @@ Updated: September 2025 (Added Open-Meteo support) """ -import math import os import time import warnings from typing import List, Optional -import cartopy.crs as ccrs -import cartopy.feature as cfeature -import matplotlib.pyplot as plt -import numpy as np import openmeteo_requests import pandas as pd import requests_cache +from hercules.resource.resource_utilities import ( + create_bounding_box, + dispatch_plots, + download_nrel_rex_data, + get_variable_colormap, + get_variable_label, + plot_spatial_map, + plot_timeseries, + print_elapsed_time, + save_coords_to_feather, + save_variable_to_feather, + validate_time_params, +) from hercules.utilities import hercules_float_type from retry_requests import retry -from rex import ResourceX -from scipy.interpolate import griddata + +# Re-export plotting utilities so existing callers can still import them here +__all__ = [ + "download_nsrdb_data", + "download_wtk_data", + "download_openmeteo_data", + "plot_timeseries", + "plot_spatial_map", + "get_variable_label", + "get_variable_colormap", +] def download_nsrdb_data( @@ -90,136 +106,32 @@ def download_nsrdb_data( allows for more flexible time periods than full year. Plots are not automatically shown. If plot_data is True, call matplotlib.pyplot.show() to display the figure. """ - - # Create output directory if it doesn't exist os.makedirs(output_dir, exist_ok=True) - # Validate input parameters - if year is not None and (start_date is not None or end_date is not None): - raise ValueError( - "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." - ) - - if year is None and (start_date is None or end_date is None): - raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") - - # Determine the approach and set up file paths and time info - if year is not None: - # Full year approach - file_years = [year] - time_suffix = str(year) - time_description = f"year {year}" - else: - # Date range approach - - start_dt = pd.to_datetime(start_date) - end_dt = pd.to_datetime(end_date) - - if start_dt > end_dt: - raise ValueError("start_date must be before end_date") - - # Get all years in the date range - file_years = list(range(start_dt.year, end_dt.year + 1)) - time_suffix = f"{start_date}_to_{end_date}".replace("-", "") - time_description = f"period {start_date} to {end_date}" - - # Create the bounding box - llcrn_lat = target_lat - coord_delta - llcrn_lon = target_lon - coord_delta - urcrn_lat = target_lat + coord_delta - urcrn_lon = target_lon + coord_delta - - print(f"Downloading NSRDB data for {time_description}") - print(f"Target coordinates: ({target_lat}, {target_lon})") - print(f"Bounding box: ({llcrn_lat}, {llcrn_lon}) to ({urcrn_lat}, {urcrn_lon})") - print(f"Variables: {variables}") - print(f"Years to process: {file_years}") - - t0 = time.time() - - data_dict = {} - all_dataframes = {var: [] for var in variables} - - try: - # Process each year in the range - for file_year in file_years: - print(f"\nProcessing year {file_year}...") - fp = f"{nsrdb_dataset_path}/{nsrdb_filename_prefix}_{file_year}.h5" - - with ResourceX(fp) as res: - # Download each variable for this year - for var in variables: - print(f" Downloading {var} for {file_year}...") - df_year = res.get_box_df( - var, lat_lon_1=[llcrn_lat, llcrn_lon], lat_lon_2=[urcrn_lat, urcrn_lon] - ) - - # Filter by date range if using date range approach - if start_date is not None and end_date is not None: - # Filter the DataFrame to the specified date range - df_year = df_year.loc[start_date:end_date] - - all_dataframes[var].append(df_year) - - # Get coordinates (only need to do this once) - if "coordinates" not in data_dict: - gids = df_year.columns.values - coordinates = res.lat_lon[gids] - df_coords = pd.DataFrame(coordinates, index=gids, columns=["lat", "lon"]) - data_dict["coordinates"] = df_coords - - # Concatenate all years for each variable - for var in variables: - if all_dataframes[var]: - print(f"Concatenating {var} data across {len(all_dataframes[var])} years...") - data_dict[var] = pd.concat(all_dataframes[var], axis=0).sort_index() - - # Convert numeric columns to float32 for memory efficiency - for col in data_dict[var].columns: - if pd.api.types.is_numeric_dtype(data_dict[var][col]): - data_dict[var][col] = data_dict[var][col].astype(hercules_float_type) - - # Clear intermediate DataFrames to free memory - all_dataframes[var].clear() - - # Save to feather format - output_file = os.path.join( - output_dir, f"{filename_prefix}_{var}_{time_suffix}.feather" - ) - data_dict[var].reset_index().to_feather(output_file) - print(f"Saved {var} data to {output_file}") - - # Save coordinates - coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") - data_dict["coordinates"].reset_index().to_feather(coords_file) - print(f"Saved coordinates to {coords_file}") - - except OSError as e: - print(f"Error downloading NSRDB data: {e}") - print("This could be caused by an invalid API key, NSRDB dataset path, or date range.") - raise - except Exception as e: - print(f"Error downloading NSRDB data: {e}") - raise - - total_time = (time.time() - t0) / 60 - decimal_part = math.modf(total_time)[0] * 60 - print( - "NSRDB download completed in " - f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" + time_params = validate_time_params(year, start_date, end_date) + bounding_box = create_bounding_box(target_lat, target_lon, coord_delta) + + data_dict = download_nrel_rex_data( + dataset_path=nsrdb_dataset_path, + dataset_filename_prefix=nsrdb_filename_prefix, + source_name="NSRDB", + target_lat=target_lat, + target_lon=target_lon, + variables=variables, + bounding_box=bounding_box, + file_years=time_params["file_years"], + start_date=start_date, + end_date=end_date, + output_dir=output_dir, + filename_prefix=filename_prefix, + time_suffix=time_params["time_suffix"], + time_description=time_params["time_description"], + os_error_hint=( + "This could be caused by an invalid API key, NSRDB dataset path, or date range." + ), ) - # Create plots if requested - if plot_data and data_dict and "coordinates" in data_dict: - coordinates_array = data_dict["coordinates"][["lat", "lon"]].values - if plot_type == "timeseries": - plot_timeseries( - data_dict, variables, coordinates_array, f"{filename_prefix} NSRDB Data" - ) - elif plot_type == "map": - plot_spatial_map( - data_dict, variables, coordinates_array, f"{filename_prefix} NSRDB Data" - ) + dispatch_plots(data_dict, variables, plot_data, plot_type, f"{filename_prefix} NSRDB Data") return data_dict @@ -273,134 +185,53 @@ def download_wtk_data( allows for more flexible time periods than full year. Plots are not automatically shown. If plot_data is True, call matplotlib.pyplot.show() to display the figure. """ - - # Create output directory if it doesn't exist os.makedirs(output_dir, exist_ok=True) - # Validate input parameters - if year is not None and (start_date is not None or end_date is not None): - raise ValueError( - "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." - ) - - if year is None and (start_date is None or end_date is None): - raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") - - # Determine the approach and set up file paths and time info - if year is not None: - # Full year approach - file_years = [year] - time_suffix = str(year) - time_description = f"year {year}" - else: - # Date range approach - - start_dt = pd.to_datetime(start_date) - end_dt = pd.to_datetime(end_date) - - if start_dt > end_dt: - raise ValueError("start_date must be before end_date") - - # Get all years in the date range - file_years = list(range(start_dt.year, end_dt.year + 1)) - time_suffix = f"{start_date}_to_{end_date}".replace("-", "") - time_description = f"period {start_date} to {end_date}" - - # Create the bounding box - llcrn_lat = target_lat - coord_delta - llcrn_lon = target_lon - coord_delta - urcrn_lat = target_lat + coord_delta - urcrn_lon = target_lon + coord_delta - - print(f"Downloading WTK data for {time_description}") - print(f"Target coordinates: ({target_lat}, {target_lon})") - print(f"Bounding box: ({llcrn_lat}, {llcrn_lon}) to ({urcrn_lat}, {urcrn_lon})") - print(f"Variables: {variables}") - print(f"Years to process: {file_years}") - - t0 = time.time() + time_params = validate_time_params(year, start_date, end_date) + bounding_box = create_bounding_box(target_lat, target_lon, coord_delta) + + data_dict = download_nrel_rex_data( + dataset_path="/nrel/wtk/wtk-led/conus/v1.0.0/5min", + dataset_filename_prefix="wtk_conus", + source_name="WTK", + target_lat=target_lat, + target_lon=target_lon, + variables=variables, + bounding_box=bounding_box, + file_years=time_params["file_years"], + start_date=start_date, + end_date=end_date, + output_dir=output_dir, + filename_prefix=filename_prefix, + time_suffix=time_params["time_suffix"], + time_description=time_params["time_description"], + os_error_hint="This could be caused by an invalid API key or date range.", + ) - data_dict = {} - all_dataframes = {var: [] for var in variables} + dispatch_plots(data_dict, variables, plot_data, plot_type, f"{filename_prefix} WTK Data") - try: - # Process each year in the range - for file_year in file_years: - print(f"\nProcessing year {file_year}...") - fp = f"/nrel/wtk/wtk-led/conus/v1.0.0/5min/wtk_conus_{file_year}.h5" - - with ResourceX(fp) as res: - # Download each variable for this year - for var in variables: - print(f" Downloading {var} for {file_year}...") - df_year = res.get_box_df( - var, lat_lon_1=[llcrn_lat, llcrn_lon], lat_lon_2=[urcrn_lat, urcrn_lon] - ) - - # Filter by date range if using date range approach - if start_date is not None and end_date is not None: - # Filter the DataFrame to the specified date range - df_year = df_year.loc[start_date:end_date] - - all_dataframes[var].append(df_year) - - # Get coordinates (only need to do this once) - if "coordinates" not in data_dict: - gids = df_year.columns.values - coordinates = res.lat_lon[gids] - df_coords = pd.DataFrame(coordinates, index=gids, columns=["lat", "lon"]) - data_dict["coordinates"] = df_coords - - # Concatenate all years for each variable - for var in variables: - if all_dataframes[var]: - print(f"Concatenating {var} data across {len(all_dataframes[var])} years...") - data_dict[var] = pd.concat(all_dataframes[var], axis=0).sort_index() - - # Convert numeric columns to float32 for memory efficiency - for col in data_dict[var].columns: - if pd.api.types.is_numeric_dtype(data_dict[var][col]): - data_dict[var][col] = data_dict[var][col].astype(hercules_float_type) - - # Clear intermediate DataFrames to free memory - all_dataframes[var].clear() - - # Save to feather format - output_file = os.path.join( - output_dir, f"{filename_prefix}_{var}_{time_suffix}.feather" - ) - data_dict[var].reset_index().to_feather(output_file) - print(f"Saved {var} data to {output_file}") - - # Save coordinates - coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") - data_dict["coordinates"].reset_index().to_feather(coords_file) - print(f"Saved coordinates to {coords_file}") - - except OSError as e: - print(f"Error downloading WTK data: {e}") - print("This could be caused by an invalid API key or date range.") - raise - except Exception as e: - print(f"Error downloading WTK data: {e}") - raise + return data_dict - total_time = (time.time() - t0) / 60 - decimal_part = math.modf(total_time)[0] * 60 - print( - "WTK download completed in " - f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" - ) - # Create plots if requested - if plot_data and data_dict and "coordinates" in data_dict: - coordinates_array = data_dict["coordinates"][["lat", "lon"]].values - if plot_type == "timeseries": - plot_timeseries(data_dict, variables, coordinates_array, f"{filename_prefix} WTK Data") - elif plot_type == "map": - plot_spatial_map(data_dict, variables, coordinates_array, f"{filename_prefix} WTK Data") +# --------------------------------------------------------------------------- +# Open-Meteo variable mapping +# --------------------------------------------------------------------------- - return data_dict +OPENMETEO_VARIABLE_MAPPING = { + "wind_speed_80m": "wind_speed_80m", + "wind_direction_80m": "wind_direction_80m", + "temperature_2m": "temperature_2m", + "shortwave_radiation_instant": "shortwave_radiation_instant", + "diffuse_radiation_instant": "diffuse_radiation_instant", + "direct_normal_irradiance_instant": "direct_normal_irradiance_instant", + "ghi": "shortwave_radiation_instant", + "dni": "direct_normal_irradiance_instant", + "dhi": "diffuse_radiation_instant", + "windspeed_80m": "wind_speed_80m", + "winddirection_80m": "wind_direction_80m", +} +"""Mapping from user-facing variable names (including aliases) to Open-Meteo API parameter +names.""" def download_openmeteo_data( @@ -462,421 +293,219 @@ def download_openmeteo_data( spans from 1940 to present. Plots are not automatically shown. If plot_data is True, call matplotlib.pyplot.show() to display the figure. """ - - # Create output directory if it doesn't exist os.makedirs(output_dir, exist_ok=True) - # Validate input parameters - if year is not None and (start_date is not None or end_date is not None): - raise ValueError( - "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." - ) - - if year is None and (start_date is None or end_date is None): - raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") - - # Determine the approach and set up time info - if year is not None: - start_date = f"{year}-01-01" - end_date = f"{year}-12-31" - time_suffix = str(year) - time_description = f"year {year}" - else: - start_dt = pd.to_datetime(start_date) - end_dt = pd.to_datetime(end_date) - - if start_dt > end_dt: - raise ValueError("start_date must be before end_date") - - time_suffix = f"{start_date}_to_{end_date}".replace("-", "") - time_description = f"period {start_date} to {end_date}" + time_params = validate_time_params(year, start_date, end_date) + time_suffix = time_params["time_suffix"] + time_description = time_params["time_description"] + api_start_date = time_params["start_date"] + api_end_date = time_params["end_date"] print(f"Downloading Open-Meteo data for {time_description}") print(f"Target coordinates: ({target_lat}, {target_lon})") print(f"Variables: {variables}") print("Note: Open-Meteo provides point data (coord_delta ignored)") - # Map variable names to Open-Meteo API parameters - variable_mapping = { - "wind_speed_80m": "wind_speed_80m", - "wind_direction_80m": "wind_direction_80m", - "temperature_2m": "temperature_2m", - "shortwave_radiation_instant": "shortwave_radiation_instant", - "diffuse_radiation_instant": "diffuse_radiation_instant", - "direct_normal_irradiance_instant": "direct_normal_irradiance_instant", - "ghi": "shortwave_radiation_instant", # Alias for solar users - "dni": "direct_normal_irradiance_instant", # Alias for solar users - "dhi": "diffuse_radiation_instant", # Alias for solar users - "windspeed_80m": "wind_speed_80m", # Alias for wind users - "winddirection_80m": "wind_direction_80m", # Alias for wind users - } - - # Validate variables and map them - mapped_variables = [] - for var in variables: - if var in variable_mapping: - mapped_variables.append(variable_mapping[var]) - else: - print(f"Warning: Variable '{var}' not available in Open-Meteo. Skipping.") - - if not mapped_variables: - raise ValueError("No valid variables found for Open-Meteo download.") + mapped_variables = _map_openmeteo_variables(variables) t0 = time.time() try: - # Setup the Open-Meteo API client with cache and retry on error - cache_session = requests_cache.CachedSession(".cache", expire_after=3600) - retry_session = retry(cache_session, retries=5, backoff_factor=0.2) - openmeteo = openmeteo_requests.Client(session=retry_session) - - # Setup API parameters - url = "https://historical-forecast-api.open-meteo.com/v1/forecast" - params = { - "latitude": target_lat, - "longitude": target_lon, - "start_date": start_date, - "end_date": end_date, - "minutely_15": mapped_variables, - "wind_speed_unit": "ms", - } - - # Try to make the API request with SSL verification first, then fallback to no verification - try: - responses = openmeteo.weather_api(url, params=params) - print("API request successful with SSL verification.") - except Exception as e: - print(f"SSL verification failed: {str(e)[:100]}...") - print("Trying with SSL verification disabled...") - - # Suppress SSL warnings since we're intentionally disabling verification - warnings.filterwarnings("ignore", message="Unverified HTTPS request") - - # Create a new session with SSL verification disabled - cache_session_no_ssl = requests_cache.CachedSession(".cache", expire_after=3600) - cache_session_no_ssl.verify = False - retry_session_no_ssl = retry(cache_session_no_ssl, retries=5, backoff_factor=0.2) - openmeteo_no_ssl = openmeteo_requests.Client(session=retry_session_no_ssl) - - responses = openmeteo_no_ssl.weather_api(url, params=params) - print("API request successful with SSL verification disabled.") - - # Create data dictionary in the same format as WTK/NSRDB and initialize dataframes - data_dict = {} - data_dict["coordinates"] = pd.DataFrame() - - # Initialize for each variable - original_var_names = [] - for var in mapped_variables: - # Use original variable name (not mapped name) for consistency - original_var_name = None - for orig, mapped in variable_mapping.items(): - if mapped == var and orig in variables: - original_var_name = orig - break - - var_name = original_var_name if original_var_name else var - data_dict[var_name] = pd.DataFrame() - - original_var_names.append(var_name) - - # Process the responses for each lat/lon - for gid, response in enumerate(responses): - print(f"Coordinates retrieved: {response.Latitude()}°N {response.Longitude()}°E") - print(f"Elevation: {response.Elevation()} m asl") - - # Process minutely_15 data - minutely_15 = response.Minutely15() - - # Create the date range - date_range = pd.date_range( - start=pd.to_datetime(minutely_15.Time(), unit="s", utc=True), - end=pd.to_datetime(minutely_15.TimeEnd(), unit="s", utc=True), - freq=pd.Timedelta(seconds=minutely_15.Interval()), - inclusive="left", - ) - - # Create coordinates DataFrame (single point, but match the format) - # Use a synthetic GID (grid ID) to match WTK/NSRDB format - df_coords = pd.DataFrame( - [[response.Latitude(), response.Longitude()]], index=[gid], columns=["lat", "lon"] - ) - data_dict["coordinates"] = pd.concat([data_dict["coordinates"], df_coords], axis=0) - - # Process each requested variable - for i, var_name in enumerate(original_var_names): - var_data = minutely_15.Variables(i).ValuesAsNumpy() - - # Create DataFrame with same structure as WTK/NSRDB (datetime index, gid columns) - # Convert to float32 for memory efficiency - df_var = pd.DataFrame( - var_data.astype(hercules_float_type), index=date_range, columns=[gid] - ) - df_var.index.name = "time_index" - - data_dict[var_name] = pd.concat([data_dict[var_name], df_var], axis=1) - - # Check for duplicates, remove if any exist, and rename locations indices consecutively - if remove_duplicate_coords & (len(data_dict["coordinates"]) > 1): - duplicate_mask = data_dict["coordinates"].duplicated( - subset=["lat", "lon"], keep="first" - ) - data_dict["coordinates"] = data_dict["coordinates"][~duplicate_mask] + responses = _fetch_openmeteo_responses( + target_lat, target_lon, api_start_date, api_end_date, mapped_variables + ) - for var_name in original_var_names: - data_dict[var_name] = data_dict[var_name][ - [c for c in data_dict["coordinates"].index] - ] - data_dict[var_name].columns = range(len(data_dict["coordinates"])) + data_dict, original_var_names = _process_openmeteo_responses( + responses, mapped_variables, variables + ) - data_dict["coordinates"] = data_dict["coordinates"].reset_index(drop=True) + if remove_duplicate_coords and len(data_dict["coordinates"]) > 1: + _remove_duplicate_coordinates(data_dict, original_var_names) - # Save variables to feather format for var_name in original_var_names: - output_file = os.path.join( - output_dir, f"{filename_prefix}_{var_name}_{time_suffix}.feather" + save_variable_to_feather( + data_dict[var_name], output_dir, filename_prefix, var_name, time_suffix ) - data_dict[var_name].reset_index().to_feather(output_file) - print(f"Saved {var_name} data to {output_file}") - # Save coordinates - coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") - data_dict["coordinates"].reset_index().to_feather(coords_file) - print(f"Saved coordinates to {coords_file}") + save_coords_to_feather(data_dict["coordinates"], output_dir, filename_prefix, time_suffix) except Exception as e: print(f"Error downloading Open-Meteo data: {e}") raise - total_time = (time.time() - t0) / 60 - decimal_part = math.modf(total_time)[0] * 60 - print( - "Open-Meteo download completed in " - f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" - ) + print_elapsed_time(t0, "Open-Meteo") - # Create plots if requested - if plot_data and data_dict and "coordinates" in data_dict: - coordinates_array = data_dict["coordinates"][["lat", "lon"]].values - if plot_type == "timeseries": - plot_timeseries( - data_dict, variables, coordinates_array, f"{filename_prefix} Open-Meteo Data" - ) - elif plot_type == "map": - plot_spatial_map( - data_dict, variables, coordinates_array, f"{filename_prefix} Open-Meteo Data" - ) + dispatch_plots(data_dict, variables, plot_data, plot_type, f"{filename_prefix} Open-Meteo Data") return data_dict -def plot_timeseries(data_dict: dict, variables: List[str], coordinates: np.ndarray, title: str): - """Create time-series plots for the downloaded data. +# --------------------------------------------------------------------------- +# Open-Meteo internal helpers +# --------------------------------------------------------------------------- - Args: - data_dict (dict): Dictionary containing DataFrames for each variable. - variables (List[str]): List of variables to plot. - coordinates (np.ndarray): Array of coordinates for the data points. - title (str): Title for the plots. - """ - n_vars = len(variables) - if n_vars == 0: - return +def _map_openmeteo_variables(variables: List[str]) -> list: + """Map user-facing variable names to Open-Meteo API parameter names. + + Args: + variables (list[str]): List of user-facing variable names. - # Create subplots based on number of variables - fig, axes = plt.subplots(n_vars, 1, figsize=(12, 4 * n_vars), sharex=True) - if n_vars == 1: - axes = [axes] + Returns: + list: List of mapped Open-Meteo API parameter names. - for i, var in enumerate(variables): - if var in data_dict: - df = data_dict[var] + Raises: + ValueError: If no valid variables are found after mapping. + """ + mapped_variables = [] + for var in variables: + if var in OPENMETEO_VARIABLE_MAPPING: + mapped_variables.append(OPENMETEO_VARIABLE_MAPPING[var]) + else: + print(f"Warning: Variable '{var}' not available in Open-Meteo. Skipping.") - # Plot all time series (one for each spatial point) - for col in df.columns: - axes[i].plot(df.index, df[col], alpha=0.7, linewidth=0.8) + if not mapped_variables: + raise ValueError("No valid variables found for Open-Meteo download.") - axes[i].set_ylabel(get_variable_label(var)) - axes[i].set_title(f"{var.replace('_', ' ').title()}") - axes[i].grid(True, alpha=0.3) + return mapped_variables - axes[-1].set_xlabel("Time") - plt.suptitle(f"{title} - Time Series", fontsize=14, fontweight="bold") - plt.tight_layout() +def _fetch_openmeteo_responses( + target_lat: float | List[float], + target_lon: float | List[float], + start_date: str, + end_date: str, + mapped_variables: list, +) -> list: + """Fetch data from the Open-Meteo API with SSL fallback. -def plot_spatial_map(data_dict: dict, variables: List[str], coordinates: np.ndarray, title: str): - """Create spatial maps showing the mean values across the region. + Attempts the request with SSL verification first. If that fails, retries + with SSL verification disabled. Args: - data_dict (dict): Dictionary containing DataFrames for each variable. - variables (List[str]): List of variables to plot. - coordinates (np.ndarray): Array of coordinates for the data points. - title (str): Title for the plots. + target_lat (float | list[float]): Target latitude(s). + target_lon (float | list[float]): Target longitude(s). + start_date (str): Start date in 'YYYY-MM-DD' format. + end_date (str): End date in 'YYYY-MM-DD' format. + mapped_variables (list): List of Open-Meteo API parameter names. + + Returns: + list: List of Open-Meteo API response objects. """ + cache_session = requests_cache.CachedSession(".cache", expire_after=3600) + retry_session = retry(cache_session, retries=5, backoff_factor=0.2) + openmeteo = openmeteo_requests.Client(session=retry_session) + + url = "https://historical-forecast-api.open-meteo.com/v1/forecast" + params = { + "latitude": target_lat, + "longitude": target_lon, + "start_date": start_date, + "end_date": end_date, + "minutely_15": mapped_variables, + "wind_speed_unit": "ms", + } - n_vars = len(variables) - if n_vars == 0: - return - - # Calculate subplot layout - n_cols = min(2, n_vars) - n_rows = math.ceil(n_vars / n_cols) - - plt.figure(figsize=(8 * n_cols, 6 * n_rows)) - - for i, var in enumerate(variables): - if var in data_dict: - df = data_dict[var] - - # Extract coordinates - lats = coordinates[:, 0] - lons = coordinates[:, 1] - - # Calculate mean values across time - mean_values = df.mean(axis=0).values - - # Create subplot with map projection - ax = plt.subplot(n_rows, n_cols, i + 1, projection=ccrs.PlateCarree()) - - # Add geographic features - ax.add_feature(cfeature.COASTLINE, alpha=0.5) - ax.add_feature(cfeature.BORDERS, linestyle=":", alpha=0.5) - ax.add_feature(cfeature.LAND, edgecolor="black", facecolor="lightgray", alpha=0.3) - ax.add_feature(cfeature.OCEAN, facecolor="lightblue", alpha=0.3) - - # Create interpolated grid for smoother visualization - if len(lats) > 4: # Only interpolate if we have enough points - grid_lon = np.linspace(min(lons), max(lons), 50) - grid_lat = np.linspace(min(lats), max(lats), 50) - grid_lon, grid_lat = np.meshgrid(grid_lon, grid_lat) - - try: - grid_values = griddata( - (lons, lats), mean_values, (grid_lon, grid_lat), method="cubic" - ) - contour = ax.contourf( - grid_lon, - grid_lat, - grid_values, - levels=15, - cmap=get_variable_colormap(var), - transform=ccrs.PlateCarree(), - ) - plt.colorbar( - contour, - ax=ax, - orientation="vertical", - label=get_variable_label(var), - shrink=0.8, - ) - except Exception: - # Fall back to scatter plot if interpolation fails - sc = ax.scatter( - lons, - lats, - c=mean_values, - s=100, - cmap=get_variable_colormap(var), - transform=ccrs.PlateCarree(), - ) - plt.colorbar( - sc, ax=ax, orientation="vertical", label=get_variable_label(var), shrink=0.8 - ) - else: - # Use scatter plot for few points - sc = ax.scatter( - lons, - lats, - c=mean_values, - s=100, - cmap=get_variable_colormap(var), - transform=ccrs.PlateCarree(), - ) - plt.colorbar( - sc, ax=ax, orientation="vertical", label=get_variable_label(var), shrink=0.8 - ) - - # Add points on top - ax.scatter(lons, lats, c="black", s=20, transform=ccrs.PlateCarree(), alpha=0.8) - - # Set title - ax.set_title(f"{var.replace('_', ' ').title()}") - - # Set coordinate labels - ax.set_xticks(np.linspace(min(lons), max(lons), 5)) - ax.set_yticks(np.linspace(min(lats), max(lats), 5)) - ax.set_xticklabels( - [f"{lon:.2f}°" for lon in np.linspace(min(lons), max(lons), 5)], fontsize=8 - ) - ax.set_yticklabels( - [f"{lat:.2f}°" for lat in np.linspace(min(lats), max(lats), 5)], fontsize=8 - ) - ax.set_xlabel("Longitude") - ax.set_ylabel("Latitude") + try: + responses = openmeteo.weather_api(url, params=params) + print("API request successful with SSL verification.") + except Exception as e: + print(f"SSL verification failed: {str(e)[:100]}...") + print("Trying with SSL verification disabled...") + + warnings.filterwarnings("ignore", message="Unverified HTTPS request") - plt.suptitle(f"{title} - Spatial Distribution (Time-Averaged)", fontsize=14, fontweight="bold") - plt.tight_layout() + cache_session_no_ssl = requests_cache.CachedSession(".cache", expire_after=3600) + cache_session_no_ssl.verify = False + retry_session_no_ssl = retry(cache_session_no_ssl, retries=5, backoff_factor=0.2) + openmeteo_no_ssl = openmeteo_requests.Client(session=retry_session_no_ssl) + responses = openmeteo_no_ssl.weather_api(url, params=params) + print("API request successful with SSL verification disabled.") -def get_variable_label(variable: str) -> str: - """Get appropriate label and units for a variable. + return responses + + +def _process_openmeteo_responses( + responses: list, + mapped_variables: list, + original_variables: List[str], +) -> tuple: + """Process Open-Meteo API responses into a data dictionary. Args: - variable (str): Variable name. + responses (list): List of Open-Meteo API response objects. + mapped_variables (list): List of mapped Open-Meteo API parameter names. + original_variables (list[str]): Original user-facing variable names. Returns: - str: Label with units for the variable. + tuple: (data_dict, original_var_names) where data_dict contains DataFrames for each + variable and coordinates, and original_var_names is the list of variable names used + as keys in data_dict. """ - labels = { - "ghi": "GHI (W/m²)", - "dni": "DNI (W/m²)", - "dhi": "DHI (W/m²)", - "windspeed_100m": "Wind Speed at 100m (m/s)", - "winddirection_100m": "Wind Direction at 100m (°)", - "turbulent_kinetic_energy_100m": "TKE at 100m (m²/s²)", - "temperature_100m": "Temperature at 100m (°C)", - "pressure_100m": "Pressure at 100m (Pa)", - # Open-Meteo variables - "wind_speed_80m": "Wind Speed at 80m (m/s)", - "windspeed_80m": "Wind Speed at 80m (m/s)", - "wind_direction_80m": "Wind Direction at 80m (m/s)", - "winddirection_80m": "Wind Direction at 80m (m/s)", - "temperature_2m": "Temperature at 2m (°C)", - "shortwave_radiation_instant": "Shortwave Radiation (W/m²)", - "diffuse_radiation_instant": "Diffuse Radiation (W/m²)", - "direct_normal_irradiance_instant": "Direct Normal Irradiance (W/m²)", - } - return labels.get(variable, variable.replace("_", " ").title()) + data_dict = {"coordinates": pd.DataFrame()} + + original_var_names = [] + for var in mapped_variables: + original_var_name = None + for orig, mapped in OPENMETEO_VARIABLE_MAPPING.items(): + if mapped == var and orig in original_variables: + original_var_name = orig + break + + var_name = original_var_name if original_var_name else var + data_dict[var_name] = pd.DataFrame() + original_var_names.append(var_name) + + for gid, response in enumerate(responses): + print(f"Coordinates retrieved: {response.Latitude()}°N {response.Longitude()}°E") + print(f"Elevation: {response.Elevation()} m asl") + + minutely_15 = response.Minutely15() + + date_range = pd.date_range( + start=pd.to_datetime(minutely_15.Time(), unit="s", utc=True), + end=pd.to_datetime(minutely_15.TimeEnd(), unit="s", utc=True), + freq=pd.Timedelta(seconds=minutely_15.Interval()), + inclusive="left", + ) + df_coords = pd.DataFrame( + [[response.Latitude(), response.Longitude()]], index=[gid], columns=["lat", "lon"] + ) + data_dict["coordinates"] = pd.concat([data_dict["coordinates"], df_coords], axis=0) -def get_variable_colormap(variable: str) -> str: - """Get appropriate colormap for a variable. + for i, var_name in enumerate(original_var_names): + var_data = minutely_15.Variables(i).ValuesAsNumpy() - Args: - variable (str): Variable name. + df_var = pd.DataFrame( + var_data.astype(hercules_float_type), index=date_range, columns=[gid] + ) + df_var.index.name = "time_index" - Returns: - str: Matplotlib colormap name for the variable. + data_dict[var_name] = pd.concat([data_dict[var_name], df_var], axis=1) + + return data_dict, original_var_names + + +def _remove_duplicate_coordinates( + data_dict: dict, + original_var_names: list, +) -> None: + """Remove duplicate coordinates from the data dictionary in-place. + + When multiple requested coordinates map to the same weather grid cell, this function keeps + only the first occurrence and re-indexes the columns consecutively. + + Args: + data_dict (dict): Data dictionary to modify. Must contain a "coordinates" key. + original_var_names (list): List of variable names to filter. """ - colormaps = { - "ghi": "plasma", - "dni": "plasma", - "dhi": "plasma", - "windspeed_100m": "viridis", - "winddirection_100m": "hsv", - "turbulent_kinetic_energy_100m": "cividis", - "temperature_100m": "RdYlBu_r", - "pressure_100m": "coolwarm", - # Open-Meteo variables - "wind_speed_80m": "viridis", - "windspeed_80m": "viridis", - "wind_direction_80m": "hsv", - "winddirection_80m": "hsv", - "temperature_2m": "RdYlBu_r", - "shortwave_radiation_instant": "plasma", - "diffuse_radiation_instant": "plasma", - "direct_normal_irradiance_instant": "plasma", - } - return colormaps.get(variable, "viridis") + duplicate_mask = data_dict["coordinates"].duplicated(subset=["lat", "lon"], keep="first") + data_dict["coordinates"] = data_dict["coordinates"][~duplicate_mask] + + for var_name in original_var_names: + data_dict[var_name] = data_dict[var_name][[c for c in data_dict["coordinates"].index]] + data_dict[var_name].columns = range(len(data_dict["coordinates"])) + + data_dict["coordinates"] = data_dict["coordinates"].reset_index(drop=True)