diff --git a/docs/adding_components.md b/docs/adding_components.md index bfcb6f6a..6cf8b53d 100644 --- a/docs/adding_components.md +++ b/docs/adding_components.md @@ -97,7 +97,7 @@ While only generator power is included in `locally_generated_power`, all categor ## Step 2: Register the Component -Add the component to `COMPONENT_REGISTRY` in `hercules/hybrid_plant.py` (see [Hybrid Plant Components](hybrid_plant.md)). +Add the component to `COMPONENT_REGISTRY` in `hercules/component_registry.py` (see [Hybrid Plant Components](hybrid_plant.md)). The key string (e.g., `"MyComponent"`) is the `component_type` value users will specify in their YAML input files. @@ -142,9 +142,8 @@ pytest tests/my_component_test.py -v - [ ] 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` +- [ ] Import and add to `COMPONENT_REGISTRY` in `hercules/component_registry.py` - [ ] Create tests in `tests/my_component_test.py` - [ ] Create `docs/my_component.md` - [ ] Add to `docs/_toc.yml` - [ ] Update reference tables in `hybrid_plant.md` and `component_types.md` - diff --git a/docs/examples/09_multiunit_thermal_plant.md b/docs/examples/09_multiunit_thermal_plant.md new file mode 100644 index 00000000..7614ebbc --- /dev/null +++ b/docs/examples/09_multiunit_thermal_plant.md @@ -0,0 +1,27 @@ +# Example 09: Multi-unit Thermal Plant + +## Description + +Demonstrates a multi-unit thermal plant with three Open Cycle Gas Turbine (OCGT) units. Each unit has its own state machine and ramp behavior, but they share a common controller that issues power setpoints for all units simultaneously. The example illustrates how the plant responds to changes in setpoints while respecting constraints such as minimum up/down times, ramp rates, and minimum stable load of the individual units. The first two individual units are identical, but their commands and responses are tracked separately in the outputs. The third unit is also an Open Cycle Gas Turbine, but it has half the ramp rate of the first two units. It is given the same power set points as the second unit, demonstrating the effect of the ramp rate parameter. This example demonstrates how to both use the same unit definition for two units (OCGT1 & OCGT2), and how to define a unit with its own definition (OCGT3). + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` + +## Outputs + +To plot the outputs, run: + +```bash +python plot_outputs.py +``` + +The plot shows (for the all units separately): +- Power output over time (demonstrating ramp constraints and minimum stable load in response to setpoint changes for the individual units), as well as total plant power output +- Operating state transitions +- Fuel consumption tracking +- Heat rate variation with load diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index 609ffedc..090323d9 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -28,7 +28,7 @@ The YAML key for each section is a user-chosen `component_name` and is not requi ## 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: +All available component types are defined in `COMPONENT_REGISTRY` in `hercules/component_registry.py`. This dictionary maps `component_type` strings to their Python classes: ```python COMPONENT_REGISTRY = { diff --git a/docs/thermal_plant.md b/docs/thermal_plant.md new file mode 100644 index 00000000..e961064b --- /dev/null +++ b/docs/thermal_plant.md @@ -0,0 +1,93 @@ +# Thermal Plant + +The `ThermalPlant` class models generic single or multiunit thermal power plants. It expects to be assigned one or more thermal units, for example [`OpenCycleGasTurbine`s](open_cycle_gas_turbine.md). The individual units are established in the YAML configuration file, and may be repeats of the same type of units or heterogeneous units. + +In order to use the thermal plant model, set `component_type: ThermalPlant` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `my_thermal_plant`); 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`. + +## Parameters + +The `ThermalPlant` class does not have any default parameters. However, key attributes that must be provided in the YAML configuration file are `units`, which is a list that is used to instantiate the individual thermal units that make up the plant, and `unit_names`, which is a list of unique names for each unit. The number of entries in `units` and `unit_names` must match. + +See the [YAML Configuration](#yaml-configuration) section below for examples of how to specify these parameters in the input file. + +## YAML configuration + +The YAML configuration for the thermal plant includes lists `units` and `unit_names`, that define the configuration for each unit. The `component_type` of each unit must be a valid thermal component type, e.g. `OpenCycleGasTurbine`. See [Component Types](component_types.md) for the full list of available component types. + +The units listed under the `units` field are used to index the subdictionaries for each unit, which specify the parameters and initial conditions for each unit. For example, if `units: ["open_cycle_gas_turbine", "open_cycle_gas_turbine"]`, then the YAML file must include a subdictionary with the key `open_cycle_gas_turbine:` that specify the parameters and initial conditions that will be used for both of the two gas turbines. Different subdictionaries can be defined for each, or a subset, of units by adding a subdictionary defining the desired parameters and initial conditions, and adding it to the appropriate place in the `units` list. This is illustrated in the below example, where the first two units use the `large_ocgt` subdictionary and the last unit uses the `small_ocgt` subdictionary. The `unit_names` field is a list of unique names for each unit, which are used to identify the units in the HDF5 output file and in the `h_dict` passed to controllers. For example, if `unit_names: ["OCGT1", "OCGT2"]`, then the two gas turbines will be identified as `OCGT1` and `OCGT2` in the output file and in the `h_dict`. + +```yaml +my_thermal_plant: + component_type: ThermalPlant + units: ["large_ocgt", "large_ocgt", "small_ocgt"] + unit_names: ["OCGT1", "OCGT2", "OCGT3"] + + large_ocgt: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.1 # 10%/min ramp rate + run_up_rate_fraction: 0.05 # 5%/min run up rate + hot_startup_time: 420.0 # 7 minutes + warm_startup_time: 480.0 # 8 minutes + cold_startup_time: 480.0 # 8 minutes + min_up_time: 1800 # 30 minutes + min_down_time: 3600 # 1 hour + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) + fuel_density: 0.768 # kg/m³ for natural gas + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: + - 0.39 + - 0.37 + - 0.325 + - 0.245 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 0 + + small_ocgt: + component_type: OpenCycleGasTurbine + rated_capacity: 50000 # kW (50 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.15 # 15%/min ramp rate + run_up_rate_fraction: 0.1 # 10%/min run up rate + hot_startup_time: 300.0 # 5 minutes + warm_startup_time: 360.0 # 6 minutes + cold_startup_time: 420.0 # 7 minutes + min_up_time: 1200 # 20 minutes + min_down_time: 2400 # 40 minutes + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6] + fuel_density: 0.768 # kg/m + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: + - 0.38 + - 0.36 + - 0.32 + - 0.22 + log_channels: + - power + initial_conditions: + power: 0 +``` + +## Logging configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. Logging is configured separately for each unit, so the `log_channels` field is specified within each unit's subdictionary. For example, if `unit_names: ["OCGT1", "OCGT1"]`, then the log will have columns `my_thermal_plant.OCGT1.power`, `my_thermal_plant.OCGT1.fuel_volume_rate`, etc. for the first unit, and `my_thermal_plant.OCGT2.power`, `my_thermal_plant.OCGT2.fuel_volume_rate`, etc. for the second unit, assuming those channels are included in the `log_channels` list for each unit. The total power for the thermal plant is always logged to `my_thermal_plant.power`, which is the sum of the power outputs of each unit. diff --git a/examples/09_multiunit_thermal_plant/hercules_input.yaml b/examples/09_multiunit_thermal_plant/hercules_input.yaml new file mode 100644 index 00000000..64e88681 --- /dev/null +++ b/examples/09_multiunit_thermal_plant/hercules_input.yaml @@ -0,0 +1,97 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_07 + +### +# Describe this simulation setup +description: Open Cycle Gas Turbine (OCGT) Example + +dt: 60.0 # 1 minute time step +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC +endtime_utc: "2020-01-01T06:00:00Z" # 6 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 100000 # kW (100 MW) + +thermal_power_plant: + component_type: ThermalPlant + units: ["open_cycle_gas_turbine1", "open_cycle_gas_turbine1", "open_cycle_gas_turbine3"] + unit_names: ["OCGT1", "OCGT2", "OCGT3"] + + open_cycle_gas_turbine1: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.2 # 20% minimum operating point + ramp_rate_fraction: 0.1 # 10%/min ramp rate + run_up_rate_fraction: 0.05 # 5%/min run up rate + hot_startup_time: 420.0 # 7 minutes + warm_startup_time: 480.0 # 8 minutes + cold_startup_time: 480.0 # 8 minutes + min_up_time: 3600 # 1 hour + min_down_time: 3600 # 1 hour + # Natural gas properties from [6] Staffell, "The Energy and Fuel Data Sheet", 2011 + # HHV: 39.05 MJ/m³, Density: 0.768 kg/m³ + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6] + fuel_density: 0.768 # kg/m³ for natural gas [6] + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5] + - 0.39 + - 0.37 + - 0.325 + - 0.245 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 100000 # Start ON at rated capacity (100 MW) + + open_cycle_gas_turbine3: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.2 # 20% minimum operating point + ramp_rate_fraction: 0.05 # 10%/min ramp rate + run_up_rate_fraction: 0.05 # 5%/min run up rate + hot_startup_time: 420.0 # 7 minutes + warm_startup_time: 480.0 # 8 minutes + cold_startup_time: 480.0 # 8 minutes + min_up_time: 3600 # 1 hour + min_down_time: 3600 # 1 hour + # Natural gas properties from [6] Staffell, "The Energy and Fuel Data Sheet", 2011 + # HHV: 39.05 MJ/m³, Density: 0.768 kg/m³ + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6] + fuel_density: 0.768 # kg/m³ for natural gas [6] + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5] + - 0.39 + - 0.37 + - 0.325 + - 0.245 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 100000 # Start ON at rated capacity (100 MW) + +controller: diff --git a/examples/09_multiunit_thermal_plant/hercules_runscript.py b/examples/09_multiunit_thermal_plant/hercules_runscript.py new file mode 100644 index 00000000..6684b3ef --- /dev/null +++ b/examples/09_multiunit_thermal_plant/hercules_runscript.py @@ -0,0 +1,84 @@ +"""Example 09: Multiunit Thermal Plant + +This example demonstrates a thermal power plant constructed from two 50 MW OCGT units. +The power setpoints are split unequally between the two units to demonstrate the ability of the +model to specify setpoints of individual units. +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + + +# Declare the open loop control setpoint sequence used for demonstration. +class OpenLoopController: + """Controller implementing the unit power setpoints in open loop.""" + + def __init__(self, h_dict): + # Access total rated capacity from h_dict, as well as capacities of individual units + self.rated_capacity = h_dict["thermal_power_plant"]["rated_capacity"] + self.unit_1_capacity = h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] + self.unit_2_capacity = h_dict["thermal_power_plant"]["OCGT2"]["rated_capacity"] + self.unit_3_capacity = h_dict["thermal_power_plant"]["OCGT3"]["rated_capacity"] + + def step(self, h_dict): + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 10 * 60: # 10 minutes in seconds + # Before 10 minutes: run all three units at full capacity + self.power_setpoint_1 = self.unit_1_capacity + self.power_setpoint_2 = self.unit_2_capacity + self.power_setpoint_3 = self.unit_3_capacity + elif current_time < 20 * 60: # 20 minutes in seconds + # Between 10 and 20 minutes: shut down unit 1, leave units 2 & 3 + self.power_setpoint_1 = 0.0 + elif current_time < 40 * 60: # 40 minutes in seconds + # Shut down units 2 & 3 + self.power_setpoint_2 = 0.0 + self.power_setpoint_3 = 0.0 + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 40 and 120 minutes: signal to run at full capacity + self.power_setpoint_1 = self.unit_1_capacity + self.power_setpoint_2 = self.unit_2_capacity + self.power_setpoint_3 = self.unit_3_capacity + elif current_time < 180 * 60: # 180 minutes in seconds + # Between 120 and 180 minutes: reduce power of unit 1 to 50% of rated capacity + self.power_setpoint_1 = 0.5 * self.unit_1_capacity + elif current_time < 210 * 60: # 210 minutes in seconds + # Between 180 and 210 minutes: reduce power of unit 1 to 10% of rated capacity + self.power_setpoint_1 = 0.1 * self.unit_1_capacity + elif current_time < 240 * 60: # 240 minutes in seconds + # Between 210 and 240 minutes: move both units to 50% of rated capacity + self.power_setpoint_1 = 0.5 * self.unit_1_capacity + self.power_setpoint_2 = 0.5 * self.unit_2_capacity + self.power_setpoint_3 = 0.5 * self.unit_3_capacity + else: + # After 240 minutes: shut down + self.power_setpoint_1 = 0.0 + self.power_setpoint_2 = 0.0 + self.power_setpoint_3 = 0.0 + + # Update the h_dict with the power setpoints for each unit and return + h_dict["thermal_power_plant"]["power_setpoints"] = [ + self.power_setpoint_1, + self.power_setpoint_2, + self.power_setpoint_3, + ] + + return h_dict + + +# Runscript +if __name__ == "__main__": + # Initialize the Hercules model + hmodel = HerculesModel("hercules_input.yaml") + + # Instantiate the controller and assign to the Hercules model + hmodel.assign_controller(OpenLoopController(hmodel.h_dict)) + + # Run the simulation + hmodel.run() + + hmodel.logger.info("Process completed successfully") diff --git a/examples/09_multiunit_thermal_plant/plot_outputs.py b/examples/09_multiunit_thermal_plant/plot_outputs.py new file mode 100644 index 00000000..b9de2a71 --- /dev/null +++ b/examples/09_multiunit_thermal_plant/plot_outputs.py @@ -0,0 +1,113 @@ +# Plot the outputs of the simulation + +import matplotlib.pyplot as plt +from hercules import HerculesOutput + +# Read the Hercules output file using HerculesOutput +ho = HerculesOutput("outputs/hercules_output.h5") + +# Print metadata information +ho.print_metadata() + +# Create a shortcut to the dataframe +df = ho.df + +# Get the h_dict from metadata +h_dict = ho.h_dict + +# Convert time to minutes for easier reading +time_minutes = df["time"] / 60 + +fig, axarr = plt.subplots(4, 1, sharex=True, figsize=(10, 10)) + +# Plot the power output and setpoint +ax = axarr[0] +ax.plot(time_minutes, df["thermal_power_plant.power"] / 1000, label="Power Output", color="k") +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT1.power_setpoint"] / 1000, + label="Power setpoint (OCGT1)", + color="r", + linestyle="--", +) +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT2.power_setpoint"] / 1000, + label="Power setpoint (OCGT2)", + color="b", + linestyle="--", +) +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT3.power_setpoint"] / 1000, + label="Power setpoint (OCGT3)", + color="g", + linestyle="--", +) +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT1.power"] / 1000, + label="Power output (OCGT1)", + color="r", +) +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT2.power"] / 1000, + label="Power output (OCGT2)", + color="b", +) +ax.plot( + time_minutes, + df["thermal_power_plant.OCGT3.power"] / 1000, + label="Power output (OCGT3)", + color="g", +) +ax.axhline( + h_dict["thermal_power_plant"]["rated_capacity"] / 1000, + color="black", + linestyle=":", + label="Plant rated capacity", +) +ax.axhline( + h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Unit rated capacity", +) +ax.set_ylabel("Power [MW]") +ax.legend() +ax.grid(True) +ax.set_xlim(0, time_minutes.iloc[-1]) + +# Plot the state of each unit +ax = axarr[1] +ax.plot(time_minutes, df["thermal_power_plant.OCGT1.state"], label="OCGT1", color="r") +ax.plot(time_minutes, df["thermal_power_plant.OCGT2.state"], label="OCGT2", color="b") +ax.plot(time_minutes, df["thermal_power_plant.OCGT3.state"], label="OCGT3", color="g") +ax.set_ylabel("State") +ax.set_yticks([0, 1, 2, 3, 4, 5]) +ax.set_yticklabels(["Off", "Hot Starting", "Warm Starting", "Cold Starting", "On", "Stopping"]) +ax.grid(True) +ax.legend() + +# Plot the efficiency of each unit +ax = axarr[2] +ax.plot(time_minutes, df["thermal_power_plant.OCGT1.efficiency"] * 100, label="OCGT1", color="r") +ax.plot(time_minutes, df["thermal_power_plant.OCGT2.efficiency"] * 100, label="OCGT2", color="b") +ax.plot(time_minutes, df["thermal_power_plant.OCGT3.efficiency"] * 100, label="OCGT3", color="g") +ax.set_ylabel("Thermal efficiency [%]") +ax.grid(True) +ax.legend() + +# Fuel consumption +ax = axarr[3] +ax.plot(time_minutes, df["thermal_power_plant.OCGT1.fuel_volume_rate"], label="OCGT1", color="r") +ax.plot(time_minutes, df["thermal_power_plant.OCGT2.fuel_volume_rate"], label="OCGT2", color="b") +ax.plot(time_minutes, df["thermal_power_plant.OCGT3.fuel_volume_rate"], label="OCGT3", color="g") +ax.set_ylabel("Fuel [m³/s]") +ax.grid(True) +ax.legend() +ax.set_xlabel("Time [mins]") + +plt.tight_layout() +plt.show() diff --git a/hercules/component_registry.py b/hercules/component_registry.py new file mode 100644 index 00000000..b6d2243f --- /dev/null +++ b/hercules/component_registry.py @@ -0,0 +1,28 @@ +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.hard_coal_steam_turbine import HardCoalSteamTurbine +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine +from hercules.plant_components.power_playback import PowerPlayback +from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts +from hercules.plant_components.thermal_plant import ThermalPlant +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, + "ThermalPlant": ThermalPlant, + "HardCoalSteamTurbine": HardCoalSteamTurbine, + "PowerPlayback": PowerPlayback, +} + +# Derived from registry keys for validation in utilities.py +VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) diff --git a/hercules/hercules_model.py b/hercules/hercules_model.py index c1ad5cca..dfe78b18 100644 --- a/hercules/hercules_model.py +++ b/hercules/hercules_model.py @@ -377,6 +377,18 @@ def numpy_serializer(obj): else: raise ValueError(f"Output {c} not found in {component_name}") + if "units" in self.h_dict[component_name]: + for unit in component_obj.units: + unit_name = unit.component_name + for c in unit.log_channels: + dataset_name = f"{component_name}.{unit_name}.{c}" + self.hdf5_datasets[dataset_name] = components_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + # Create external signals datasets if "external_signals" in self.h_dict and self.h_dict["external_signals"]: external_signals_group = data_group.create_group("external_signals") @@ -712,6 +724,16 @@ def _log_data_to_hdf5(self): if dataset_name in self.data_buffers: self.data_buffers[dataset_name][self.buffer_row] = output_value + if "units" in self.h_dict[component_name]: + for unit in component_obj.units: + unit_name = unit.component_name + for c in unit.log_channels: + dataset_name = f"{component_name}.{unit_name}.{c}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = self.h_dict[ + component_name + ][unit_name][c] + # Buffer external signals (only those specified in log_channels) if "external_signals" in self.h_dict and self.h_dict["external_signals"]: for signal_name, signal_value in self.h_dict["external_signals"].items(): diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 77864aa7..1ea4ed87 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -1,31 +1,35 @@ import numpy as np -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.hard_coal_steam_turbine import HardCoalSteamTurbine -from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine -from hercules.plant_components.power_playback import PowerPlayback -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, - "HardCoalSteamTurbine": HardCoalSteamTurbine, - "PowerPlayback": PowerPlayback, -} - -# Derived from registry keys for validation in utilities.py -VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) +from hercules.component_registry 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.hard_coal_steam_turbine import HardCoalSteamTurbine +# from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine +# from hercules.plant_components.power_playback import PowerPlayback +# from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts +# from hercules.plant_components.thermal_plant import ThermalPlant +# 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, +# "ThermalPlant": ThermalPlant, +# "HardCoalSteamTurbine": HardCoalSteamTurbine, +# "PowerPlayback": PowerPlayback, +# } + +# # Derived from registry keys for validation in utilities.py +# VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys()) class HybridPlant: diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index dae43879..600af4d4 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -137,3 +137,7 @@ def close_logging(self): for handler in self.logger.handlers[:]: handler.close() self.logger.removeHandler(handler) + + def step(self, h_dict): + """Raise error if step is called on the abstract base class.""" + raise NotImplementedError("Components must implement the step() method") diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 724cfffb..03cf3c04 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -283,6 +283,12 @@ def __init__(self, h_dict, component_name): self.fuel_volume_rate = 0.0 # m³/s self.fuel_mass_rate = 0.0 # kg/s + # Initialize number of starts + self.n_total_starts = 0 + self.n_hot_starts = 0 + self.n_warm_starts = 0 + self.n_cold_starts = 0 + def get_initial_conditions_and_meta_data(self, h_dict): """Add initial conditions and meta data to the h_dict. @@ -297,6 +303,10 @@ def get_initial_conditions_and_meta_data(self, h_dict): h_dict[self.component_name]["efficiency"] = self.efficiency h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate + h_dict[self.component_name]["n_total_starts"] = self.n_total_starts + h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts + h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts + h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts return h_dict def step(self, h_dict): @@ -345,6 +355,12 @@ def step(self, h_dict): h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate + # Update start counts in h_dict for tracking purposes + h_dict[self.component_name]["n_total_starts"] = self.n_total_starts + h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts + h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts + h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts + return h_dict def _control(self, power_setpoint): @@ -406,13 +422,17 @@ def _control(self, power_setpoint): can_start = self.time_in_state >= self.min_down_time if power_setpoint > 0 and can_start: + self.n_total_starts += 1 # Check if hot, warm, or cold starting is implied if self.time_in_state < self.HOT_START_TIME: self.state = self.STATES.HOT_STARTING + self.n_hot_starts += 1 elif self.time_in_state < self.WARM_START_TIME: self.state = self.STATES.WARM_STARTING + self.n_warm_starts += 1 else: self.state = self.STATES.COLD_STARTING + self.n_cold_starts += 1 self.time_in_state = 0.0 return 0.0 # Power is always 0 when off diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py new file mode 100644 index 00000000..926f0baa --- /dev/null +++ b/hercules/plant_components/thermal_plant.py @@ -0,0 +1,109 @@ +""" +Multiunit thermal power plant. +""" + +import copy + +from hercules.plant_components.component_base import ComponentBase +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class ThermalPlant(ComponentBase): + """Thermal power plant comprising multiple units. + + The thermal plant component is designed to represent a collection of thermal generation units + (e.g. gas turbines, steam turbines, RICEs) that are grouped together into a single Hercules + component. This allows users to model a thermal plant with multiple units with finer + granularity than a single aggregate component. Control setpoints can be specified for each unit. + + """ + + component_category = "generator" + + def __init__(self, h_dict, component_name): + # Instantiate individual units from the h_dict. + + self.unit_names = h_dict[component_name]["unit_names"] + generic_units = h_dict[component_name]["units"] + + # Check that unit_names are valid + if len(self.unit_names) != len(generic_units): + raise ValueError( + f"Length of unit_names ({len(self.unit_names)}) must match length of units " + f"({len(generic_units)})." + ) + if len(set(self.unit_names)) != len(self.unit_names): + raise ValueError(f"unit_names must be unique. Found duplicates in {self.unit_names}.") + + for unit, unit_name in zip(generic_units, self.unit_names): + if unit_name not in h_dict[component_name]: + h_dict[component_name][unit_name] = copy.deepcopy(h_dict[component_name][unit]) + + # Remove the template from the component dict since it's now copied into each unit dict + for unit in generic_units: + if unit in h_dict[component_name]: + del h_dict[component_name][unit] + + # Load component registry here to define units in thermal plant + # NOTE: this breaks a circular dependency issue + from hercules.component_registry import COMPONENT_REGISTRY + + self.units = [] + for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): + h_dict_thermal = h_dict[component_name] + h_dict_thermal["dt"] = h_dict["dt"] + h_dict_thermal["starttime"] = h_dict["starttime"] + h_dict_thermal["endtime"] = h_dict["endtime"] + h_dict_thermal["verbose"] = h_dict["verbose"] + unit_type = h_dict["thermal_power_plant"][unit_name]["component_type"] + unit_class = COMPONENT_REGISTRY[unit_type] + if unit_class is None: + raise ValueError(f"Unit type {unit_type} not found in component registry.") + elif not issubclass(unit_class, ThermalComponentBase): + raise ValueError( + f"Unit type {unit_type} must be a subclass of ThermalComponentBase." + ) + else: + self.units.append(unit_class(h_dict_thermal, unit_name)) + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) + + def step(self, h_dict): + """ + Step the thermal plant by stepping each individual unit and summing their power outputs. + """ + thermal_plant_power = 0.0 + + for unit, unit_name, power_setpoint in zip( + self.units, self.unit_names, h_dict[self.component_name]["power_setpoints"] + ): + h_dict_thermal = h_dict[self.component_name] + h_dict_thermal[unit_name]["power_setpoint"] = power_setpoint + h_dict_thermal = unit.step(h_dict_thermal) + thermal_plant_power += h_dict_thermal[unit_name]["power"] + + h_dict[self.component_name]["power"] = thermal_plant_power + + return h_dict + + def get_initial_conditions_and_meta_data(self, h_dict): + """Get initial conditions and metadata for the thermal plant. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + """ + # NOTE: h_dict is modified in place, so h_dict will be updated with the initial + # conditions and metadata for each unit. + for unit in self.units: + h_dict_thermal = h_dict[self.component_name] + unit.get_initial_conditions_and_meta_data(h_dict_thermal) + + h_dict[self.component_name]["power"] = sum( + h_dict_thermal[unit.component_name]["power"] for unit in self.units + ) + h_dict[self.component_name]["rated_capacity"] = sum( + h_dict_thermal[unit.component_name]["rated_capacity"] for unit in self.units + ) + + return h_dict diff --git a/hercules/utilities.py b/hercules/utilities.py index d8a9eb82..401e15bd 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -212,7 +212,7 @@ def load_hercules_input(filename): # Define valid keys required_keys = ["dt", "starttime_utc", "endtime_utc", "plant"] # Lazy import to avoid circular dependency - from hercules.hybrid_plant import VALID_COMPONENT_TYPES + from hercules.component_registry import VALID_COMPONENT_TYPES valid_component_types = list(VALID_COMPONENT_TYPES) other_keys = [ diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 6a9591f8..0160f488 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -429,3 +429,22 @@ "plant": plant, "hard_coal_steam_turbine": hard_coal_steam_turbine, } + +h_dict_thermal_plant = { + "dt": 1.0, + "starttime": 0.0, + "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), + "verbose": False, + "step": 0, + "time": 0.0, + "plant": plant, + "thermal_power_plant": { + "component_type": "ThermalPlant", + "unit_names": ["OCGT1", "HARD_COAL1"], + "units": ["open_cycle_gas_turbine", "hard_coal_steam_turbine"], + "open_cycle_gas_turbine": open_cycle_gas_turbine, + "hard_coal_steam_turbine": hard_coal_steam_turbine, + }, +} diff --git a/tests/thermal_plant_test.py b/tests/thermal_plant_test.py new file mode 100644 index 00000000..b84b9e02 --- /dev/null +++ b/tests/thermal_plant_test.py @@ -0,0 +1,109 @@ +import copy + +import pytest +from hercules.plant_components.thermal_plant import ThermalPlant + +from .test_inputs.h_dict import ( + h_dict_thermal_plant, + simple_battery, +) + + +def test_init_from_dict(): + # Set up a system with one OCGT and one hard coal steam turbine. + h_dict = copy.deepcopy(h_dict_thermal_plant) + ThermalPlant(h_dict, "thermal_power_plant") + + +def test_invalid_unit_type(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + # Unspecified unit + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "invalid_unit"] + with pytest.raises(KeyError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Non thermal-type unit + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "simple_battery"] + h_dict["thermal_power_plant"]["simple_battery"] = copy.deepcopy(simple_battery) + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Incorrect component type + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "hard_coal_steam_turbine"] + h_dict["thermal_power_plant"]["hard_coal_steam_turbine"]["component_type"] = "InvalidComponent" + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + +def test_unit_copies(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + h_dict["thermal_power_plant"]["units"] = [ + "open_cycle_gas_turbine", + "hard_coal_steam_turbine", + "hard_coal_steam_turbine", + ] + + # units and unit_names are unequal length + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Update unit_names with non-unique values + h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST1"] + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Unique values + h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST2"] + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Check that there are three units of the correct types + assert len(tp.units) == 3 + assert tp.units[0].component_type == "OpenCycleGasTurbine" + assert tp.units[1].component_type == "HardCoalSteamTurbine" + assert tp.units[2].component_type == "HardCoalSteamTurbine" + + +def test_h_dict_structure(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Check that the unit dicts were copied correctly (and generic names removed) + assert "open_cycle_gas_turbine" not in h_dict["thermal_power_plant"] + assert "hard_coal_steam_turbine" not in h_dict["thermal_power_plant"] + assert "OCGT1" in h_dict["thermal_power_plant"] + assert "HARD_COAL1" in h_dict["thermal_power_plant"] + assert h_dict["thermal_power_plant"]["OCGT1"]["component_type"] == "OpenCycleGasTurbine" + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["component_type"] == "HardCoalSteamTurbine" + + # Check that the initial conditions of units are copied correctly + h_dict = tp.get_initial_conditions_and_meta_data(h_dict) + assert h_dict["thermal_power_plant"]["OCGT1"]["power"] == 1000 # From initial conditions + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["power"] == 1000 # From initial conditions + assert h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] == 1000 + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["rated_capacity"] == 500000 + + # Check that thermal plant conditions are recorded correctly + assert h_dict["thermal_power_plant"]["power"] == 1000 + 1000 + assert h_dict["thermal_power_plant"]["rated_capacity"] == 500000 + 1000 + + +def test_step(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Provide power setpoints to the two units + h_dict["thermal_power_plant"]["power_setpoints"] = [800, 400000] + + # Step the plant and check that power is updated correctly + h_dict = tp.step(h_dict) + power_ocgt = h_dict["thermal_power_plant"]["OCGT1"]["power"] + power_hard_coal = h_dict["thermal_power_plant"]["HARD_COAL1"]["power"] + + assert power_ocgt < 1000 # Reacts to power setpoint + assert power_hard_coal < 500000 # Reacts to power setpoint + + # Total power computed correctly + assert h_dict["thermal_power_plant"]["power"] == power_ocgt + power_hard_coal