From b6bd44cbffbd7b17f964aaa955361cd1f1784130 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Wed, 4 Mar 2026 14:37:59 -0700 Subject: [PATCH 01/38] Update constants, instructions for nrel-pysam 7 --- docs/not_used/install_on_kestrel.md | 2 +- hercules/plant_components/solar_pysam_pvwatts.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/not_used/install_on_kestrel.md b/docs/not_used/install_on_kestrel.md index e83a42c1..2d829670 100644 --- a/docs/not_used/install_on_kestrel.md +++ b/docs/not_used/install_on_kestrel.md @@ -97,7 +97,7 @@ chmod 600 ~/.ssh/* Note: This section is untested. Go back to herc_root ``` -pip install nrel-pysam==6.0.0 +pip install nrel-pysam==7.1.0 ``` If you run hercules and get an error that `pyyaml` is missing, you may also need to install it using diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 1ad60444..0c1198f0 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -68,7 +68,6 @@ def _create_system_model(self): system_model.assign(self.model_params) system_model.AdjustmentFactors.adjust_constant = 0 - system_model.AdjustmentFactors.dc_adjust_constant = 0 # Save the system model self.system_model = system_model @@ -95,7 +94,7 @@ def _precompute_power_array(self): # Assign the full solar resource data self.system_model.SolarResource.assign({"solar_resource_data": solar_resource_data}) - self.system_model.AdjustmentFactors.assign({"constant": 0}) + self.system_model.AdjustmentFactors.assign({"adjust_constant": 0}) # Execute the model once for all time steps self.system_model.execute() From e34ebefdebb8f5f64249cfac6c45d01f79a4e7ee Mon Sep 17 00:00:00 2001 From: dzalkind Date: Wed, 4 Mar 2026 14:46:39 -0700 Subject: [PATCH 02/38] Update toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dfea8449..478b33a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "matplotlib~=3.8", "pandas~=2.0", "floris~=4.3", -"nrel-pysam~=6.0", +"nrel-pysam~=7.0", "jupyter", "netCDF4", "rainflow~=3.2", From bb51e8263f434644a929d3b017829011b7efcf08 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Wed, 4 Mar 2026 15:30:41 -0700 Subject: [PATCH 03/38] First pass run through for multiunit thermal plant --- examples/08_thermal_plant/hercules_input.yaml | 62 +++++++++++++ .../08_thermal_plant/hercules_runscript.py | 92 +++++++++++++++++++ examples/08_thermal_plant/plot_outputs.py | 92 +++++++++++++++++++ hercules/hybrid_plant.py | 2 + hercules/plant_components/component_base.py | 4 + hercules/plant_components/thermal_plant.py | 69 ++++++++++++++ 6 files changed, 321 insertions(+) create mode 100644 examples/08_thermal_plant/hercules_input.yaml create mode 100644 examples/08_thermal_plant/hercules_runscript.py create mode 100644 examples/08_thermal_plant/plot_outputs.py create mode 100644 hercules/plant_components/thermal_plant.py diff --git a/examples/08_thermal_plant/hercules_input.yaml b/examples/08_thermal_plant/hercules_input.yaml new file mode 100644 index 00000000..fad9e586 --- /dev/null +++ b/examples/08_thermal_plant/hercules_input.yaml @@ -0,0 +1,62 @@ +# 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_turbine", "open_cycle_gas_turbine"] + unit_names: ["OCGT1", "OCGT2"] + + open_cycle_gas_turbine: + 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) + +controller: + diff --git a/examples/08_thermal_plant/hercules_runscript.py b/examples/08_thermal_plant/hercules_runscript.py new file mode 100644 index 00000000..68385672 --- /dev/null +++ b/examples/08_thermal_plant/hercules_runscript.py @@ -0,0 +1,92 @@ +"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. + +This example demonstrates a simple open cycle gas turbine (OCGT) that: +- Starts on at rated capacity (100 MW) +- At 10 minutes, receives a shutdown command and begins ramping down +- At ~20 minutes, reaches 0 MW and transitions to off +- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting +- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity +- At 120 minutes, receives a command to reduce power to 50% of rated capacity +- At 180 minutes, receives a command to reduce power to 10% of rated capacity + (note this is below the minimum stable load) +- At 210 minutes, receives a command to increase power to 100% of rated capacity +- At 240 minutes (4 hours), receives a shutdown command +- Simulation runs for 6 hours total with 1 minute time steps +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +class ControllerOCGT: + """Controller implementing the OCGT schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.rated_capacity = 200000 # Hardcode for now. Likely need to get from + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 10 * 60: # 10 minutes in seconds + # Before 10 minutes: run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 40 * 60: # 40 minutes in seconds + # Between 10 and 40 minutes: shut down + power_setpoint = 0.0 + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 40 and 120 minutes: signal to run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 180 * 60: # 180 minutes in seconds + # Between 120 and 180 minutes: reduce power to 50% of rated capacity + power_setpoint = 0.5 * self.rated_capacity + elif current_time < 210 * 60: # 210 minutes in seconds + # Between 180 and 210 minutes: reduce power to 10% of rated capacity + power_setpoint = 0.1 * self.rated_capacity + elif current_time < 240 * 60: # 240 minutes in seconds + # Between 210 and 240 minutes: increase power to 100% of rated capacity + power_setpoint = self.rated_capacity + else: + # After 240 minutes: shut down + power_setpoint = 0.0 + + # Split the setpoint between the units + power_setpoint_unit_1 = 0.6 * power_setpoint + power_setpoint_unit_2 = 0.4 * power_setpoint + + h_dict["thermal_power_plant"]["power_setpoints"] = [ + power_setpoint_unit_1, power_setpoint_unit_2 + ] + h_dict["thermal_power_plant"]["power_setpoint"] = power_setpoint + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/08_thermal_plant/plot_outputs.py b/examples/08_thermal_plant/plot_outputs.py new file mode 100644 index 00000000..88d9188d --- /dev/null +++ b/examples/08_thermal_plant/plot_outputs.py @@ -0,0 +1,92 @@ +# Plot the outputs of the simulation for the OCGT example + +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 +print("Simulation Metadata:") +ho.print_metadata() +print() + +# 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="b") +# ax.plot( +# time_minutes, +# df["thermal_power_plant.power_setpoint"] / 1000, +# label="Power Setpoint", +# color="r", +# linestyle="--", +# ) +# ax.axhline( +# h_dict["thermal_power_plant"]["rated_capacity"] / 1000, +# color="gray", +# linestyle=":", +# label="Rated Capacity", +# ) +# ax.axhline( +# h_dict["thermal_power_plant"]["min_stable_load_fraction"] +# * h_dict["thermal_power_plant"]["rated_capacity"] +# / 1000, +# color="gray", +# linestyle="--", +# label="Minimum Stable Load", +# ) +# ax.set_ylabel("Power [MW]") +# ax.set_title("Open Cycle Gas Turbine Power Output") +# ax.legend() +# ax.grid(True) + +# # Plot the state +# ax = axarr[1] +# ax.plot(time_minutes, df["thermal_power_plant.state"], label="State", color="k") +# 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.set_title( +# "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +# ) +# ax.grid(True) + +# # Plot the efficiency +# ax = axarr[2] +# ax.plot( +# time_minutes, +# df["thermal_power_plant.efficiency"] * 100, +# label="Efficiency", +# color="g", +# ) +# ax.set_ylabel("Efficiency [%]") +# ax.set_title("Thermal Efficiency") +# ax.grid(True) + +# # Plot the fuel consumption +# ax = axarr[3] +# ax.plot( +# time_minutes, +# df["thermal_power_plant.fuel_volume_rate"], +# label="Fuel Volume Rate", +# color="orange", +# ) +# ax.set_ylabel("Fuel [m³/s]") +# ax.set_title("Fuel Volume Rate") +# ax.grid(True) + +# ax.set_xlabel("Time [minutes]") + +plt.tight_layout() +plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index a24f59bd..7fa9bd3c 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -7,6 +7,7 @@ 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.plant_components.thermal_plant import ThermalPlant # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. @@ -18,6 +19,7 @@ "BatteryLithiumIon": BatteryLithiumIon, "ElectrolyzerPlant": ElectrolyzerPlant, "OpenCycleGasTurbine": OpenCycleGasTurbine, + "ThermalPlant": ThermalPlant, } # Derived from registry keys for validation in utilities.py diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index dae43879..4c20866d 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""" + raise NotImplementedError("Subclasses must implement the step method") diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py new file mode 100644 index 00000000..efa136a9 --- /dev/null +++ b/hercules/plant_components/thermal_plant.py @@ -0,0 +1,69 @@ +""" +Multiunit thermal power plant. +""" + +from hercules.plant_components.component_base import ComponentBase +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine + + +class ThermalPlant(ComponentBase): + """ + """ + 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"] + # Copy h_dict pieces as needed + for unit_name in self.unit_names: + if unit_name not in h_dict[component_name]: + h_dict[component_name][unit_name] = h_dict[component_name]["open_cycle_gas_turbine"].copy() + del h_dict[component_name]["open_cycle_gas_turbine"] # Remove the template from the component dict since it's now copied into each unit dict + self.units = [] + for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): + h_dict_thermal = h_dict[component_name].copy() + 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"] + self.units.append(OpenCycleGasTurbine(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): + + 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].copy() + 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. + """ + for unit in self.units: + h_dict_thermal = h_dict[self.component_name].copy() + h_dict_thermal = 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 + ) + + # TODO: we likely want to save off data for the individual units to the + # h_dict as well. Will need to figure out how to do that. + + return h_dict From 0176b6fd8922d9b2e48427a4024bb4954eb48078 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Mar 2026 10:38:24 -0700 Subject: [PATCH 04/38] Add logging for individual units within thermal plant --- examples/08_thermal_plant/plot_outputs.py | 36 +++++++++++++++++----- hercules/hercules_model.py | 22 +++++++++++++ hercules/hybrid_plant.py | 2 +- hercules/plant_components/thermal_plant.py | 15 +++++---- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/examples/08_thermal_plant/plot_outputs.py b/examples/08_thermal_plant/plot_outputs.py index 88d9188d..52be236a 100644 --- a/examples/08_thermal_plant/plot_outputs.py +++ b/examples/08_thermal_plant/plot_outputs.py @@ -24,14 +24,34 @@ # Plot the power output and setpoint ax = axarr[0] -ax.plot(time_minutes, df["thermal_power_plant.power"] / 1000, label="Power Output", color="b") -# ax.plot( -# time_minutes, -# df["thermal_power_plant.power_setpoint"] / 1000, -# label="Power Setpoint", -# color="r", -# linestyle="--", -# ) +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.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.axhline( # h_dict["thermal_power_plant"]["rated_capacity"] / 1000, # color="gray", 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 7fa9bd3c..811187a2 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -5,9 +5,9 @@ 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.thermal_plant import ThermalPlant from hercules.plant_components.wind_farm import WindFarm from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower -from hercules.plant_components.thermal_plant import ThermalPlant # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index efa136a9..c96b3012 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -19,11 +19,14 @@ def __init__(self, h_dict, component_name): # Copy h_dict pieces as needed for unit_name in self.unit_names: if unit_name not in h_dict[component_name]: - h_dict[component_name][unit_name] = h_dict[component_name]["open_cycle_gas_turbine"].copy() - del h_dict[component_name]["open_cycle_gas_turbine"] # Remove the template from the component dict since it's now copied into each unit dict + h_dict[component_name][unit_name] = ( + h_dict[component_name]["open_cycle_gas_turbine"].copy() + ) + # Remove the template from the component dict since it's now copied into each unit dict + del h_dict[component_name]["open_cycle_gas_turbine"] self.units = [] for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): - h_dict_thermal = h_dict[component_name].copy() + 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"] @@ -40,11 +43,11 @@ def step(self, h_dict): 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].copy() + 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 @@ -56,7 +59,7 @@ def get_initial_conditions_and_meta_data(self, h_dict): h_dict (dict): Dictionary containing simulation parameters. """ for unit in self.units: - h_dict_thermal = h_dict[self.component_name].copy() + h_dict_thermal = h_dict[self.component_name] h_dict_thermal = unit.get_initial_conditions_and_meta_data(h_dict_thermal) h_dict[self.component_name]["power"] = sum( From 5a71cc2168b23c67973423dc5e5dcd82be627d0b Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Mar 2026 10:51:18 -0700 Subject: [PATCH 05/38] More formatting --- examples/08_thermal_plant/hercules_runscript.py | 5 +++-- hercules/plant_components/thermal_plant.py | 12 +++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/examples/08_thermal_plant/hercules_runscript.py b/examples/08_thermal_plant/hercules_runscript.py index 68385672..5e25ae7c 100644 --- a/examples/08_thermal_plant/hercules_runscript.py +++ b/examples/08_thermal_plant/hercules_runscript.py @@ -34,7 +34,7 @@ def __init__(self, h_dict): h_dict (dict): The hercules input dictionary. """ - self.rated_capacity = 200000 # Hardcode for now. Likely need to get from + self.rated_capacity = 200000 # Hardcode for now. Likely need to get from def step(self, h_dict): """Execute one control step. @@ -76,7 +76,8 @@ def step(self, h_dict): power_setpoint_unit_2 = 0.4 * power_setpoint h_dict["thermal_power_plant"]["power_setpoints"] = [ - power_setpoint_unit_1, power_setpoint_unit_2 + power_setpoint_unit_1, + power_setpoint_unit_2, ] h_dict["thermal_power_plant"]["power_setpoint"] = power_setpoint diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index c96b3012..e53a24c1 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -7,21 +7,20 @@ class ThermalPlant(ComponentBase): - """ - """ + """ """ + 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"] # Copy h_dict pieces as needed for unit_name in self.unit_names: if unit_name not in h_dict[component_name]: - h_dict[component_name][unit_name] = ( - h_dict[component_name]["open_cycle_gas_turbine"].copy() - ) + h_dict[component_name][unit_name] = h_dict[component_name][ + "open_cycle_gas_turbine" + ].copy() # Remove the template from the component dict since it's now copied into each unit dict del h_dict[component_name]["open_cycle_gas_turbine"] self.units = [] @@ -37,7 +36,6 @@ def __init__(self, h_dict, component_name): super().__init__(h_dict, component_name) def step(self, h_dict): - thermal_plant_power = 0.0 for unit, unit_name, power_setpoint in zip( From 84cf03885c6b34c2dbdcf00f898d758dc5df2a74 Mon Sep 17 00:00:00 2001 From: Frederik Date: Thu, 5 Mar 2026 10:38:56 -0700 Subject: [PATCH 06/38] Add steam turbine module --- examples/09_steam_turbine/hercules_input.yaml | 57 ++++++++ .../09_steam_turbine/hercules_runscript.py | 85 ++++++++++++ examples/09_steam_turbine/plot_outputs.py | 92 +++++++++++++ hercules/hybrid_plant.py | 3 + hercules/plant_components/steam_turbine.py | 130 ++++++++++++++++++ 5 files changed, 367 insertions(+) create mode 100644 examples/09_steam_turbine/hercules_input.yaml create mode 100644 examples/09_steam_turbine/hercules_runscript.py create mode 100644 examples/09_steam_turbine/plot_outputs.py create mode 100644 hercules/plant_components/steam_turbine.py diff --git a/examples/09_steam_turbine/hercules_input.yaml b/examples/09_steam_turbine/hercules_input.yaml new file mode 100644 index 00000000..ee96c8a5 --- /dev/null +++ b/examples/09_steam_turbine/hercules_input.yaml @@ -0,0 +1,57 @@ +# 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-01T18:00:00Z" # 18 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 30000 # kW (30 MW) + +steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.1 # 10% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.03 # 3%/min run up rate + hot_startup_time: 7200.0 # 2 hours + warm_startup_time: 14400.0 # 4 hours + cold_startup_time: 28800.0 # 8 hours + min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html + # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ + hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) + fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) + 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (100 MW) + +controller: + diff --git a/examples/09_steam_turbine/hercules_runscript.py b/examples/09_steam_turbine/hercules_runscript.py new file mode 100644 index 00000000..ee27217a --- /dev/null +++ b/examples/09_steam_turbine/hercules_runscript.py @@ -0,0 +1,85 @@ +"""Example 07: Steam Turbine (ST) simulation. + +This example demonstrates a simple steam turbine (ST) that: +- Starts on at rated capacity (100 MW) +- At 10 minutes, receives a shutdown command and begins ramping down +- At ~20 minutes, reaches 0 MW and transitions to off +- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting +- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity +- At 120 minutes, receives a command to reduce power to 50% of rated capacity +- At 180 minutes, receives a command to reduce power to 10% of rated capacity + (note this is below the minimum stable load) +- At 210 minutes, receives a command to increase power to 100% of rated capacity +- At 240 minutes (4 hours), receives a shutdown command +- Simulation runs for 6 hours total with 1 minute time steps +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +class ControllerST: + """Controller implementing the steam turbine schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.rated_capacity = h_dict["steam_turbine"]["rated_capacity"] + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 30 * 60: # 30 minutes in seconds + # Before 30 minutes: run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 30 and 120 minutes: shut down + power_setpoint = 0.0 + elif current_time < 360 * 60: # 360 minutes in seconds + # Between 120 and 360 minutes: signal to run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 720 * 60: # 720 minutes in seconds + # Between 360 and 720 minutes: reduce power to 50% of rated capacity + power_setpoint = 0.5 * self.rated_capacity + elif current_time < 630 * 60: # 630 minutes in seconds + # Between 360 and 630 minutes: reduce power to 10% of rated capacity + power_setpoint = 0.1 * self.rated_capacity + elif current_time < 720 * 60: # 720 minutes in seconds + # Between 630 and 720 minutes: increase power to 100% of rated capacity + power_setpoint = self.rated_capacity + else: + # After 720 minutes: shut down + power_setpoint = 0.0 + + h_dict["steam_turbine"]["power_setpoint"] = power_setpoint + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerST(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/09_steam_turbine/plot_outputs.py b/examples/09_steam_turbine/plot_outputs.py new file mode 100644 index 00000000..d0802bf4 --- /dev/null +++ b/examples/09_steam_turbine/plot_outputs.py @@ -0,0 +1,92 @@ +# Plot the outputs of the simulation for the OCGT example + +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 +print("Simulation Metadata:") +ho.print_metadata() +print() + +# 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["steam_turbine.power"] / 1000, label="Power Output", color="b") +ax.plot( + time_minutes, + df["steam_turbine.power_setpoint"] / 1000, + label="Power Setpoint", + color="r", + linestyle="--", +) +ax.axhline( + h_dict["steam_turbine"]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Rated Capacity", +) +ax.axhline( + h_dict["steam_turbine"]["min_stable_load_fraction"] + * h_dict["steam_turbine"]["rated_capacity"] + / 1000, + color="gray", + linestyle="--", + label="Minimum Stable Load", +) +ax.set_ylabel("Power [MW]") +ax.set_title("Open Cycle Gas Turbine Power Output") +ax.legend() +ax.grid(True) + +# Plot the state +ax = axarr[1] +ax.plot(time_minutes, df["steam_turbine.state"], label="State", color="k") +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.set_title( + "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +) +ax.grid(True) + +# Plot the efficiency +ax = axarr[2] +ax.plot( + time_minutes, + df["steam_turbine.efficiency"] * 100, + label="Efficiency", + color="g", +) +ax.set_ylabel("Efficiency [%]") +ax.set_title("Thermal Efficiency") +ax.grid(True) + +# Plot the fuel consumption +ax = axarr[3] +ax.plot( + time_minutes, + df["steam_turbine.fuel_volume_rate"], + label="Fuel Volume Rate", + color="orange", +) +ax.set_ylabel("Fuel [m³/s]") +ax.set_title("Fuel Volume Rate") +ax.grid(True) + +ax.set_xlabel("Time [minutes]") + +plt.tight_layout() +plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 811187a2..b2fb7f53 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -8,6 +8,8 @@ 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 +from hercules.plant_components.thermal_plant import ThermalPlant +from hercules.plant_components.steam_turbine import SteamTurbine # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. @@ -20,6 +22,7 @@ "ElectrolyzerPlant": ElectrolyzerPlant, "OpenCycleGasTurbine": OpenCycleGasTurbine, "ThermalPlant": ThermalPlant, + "SteamTurbine": SteamTurbine, } # Derived from registry keys for validation in utilities.py diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py new file mode 100644 index 00000000..15ab7371 --- /dev/null +++ b/hercules/plant_components/steam_turbine.py @@ -0,0 +1,130 @@ +""" +Open Cycle Gas Turbine Class. + +Open cycle gas turbine (OCGT) model is a subclass of the ThermalComponentBase class. +It implements the model as presented in [1], [2], [3], [4], [5] and [6]. + +Like other subclasses of ThermalComponentBase, it inherits the main control functions, +and adds defaults for many variables based on [1], [2], [3], [4], [5] and [6]. + +Note: All efficiency values are HHV (Higher Heating Value) net plant efficiencies. +The default efficiency table is based on the SC1A curve from Exhibit ES-4 of [5]. + +Note: This class is based on aeroderivative open cycle gas turbines, +which are commonly used for flexible power generation. + +References: + +[1] Agora Energiewende (2017): Flexibility in thermal power plants + With a focus on existing coal-fired power plants. +[2] "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on + Production Cost Simulation", NREL/CP-6A40-87554, National Renewable + Energy Laboratory, 2024. +[3] Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly + Modelling in Power Systems with Significant Levels of Renewable Generation." + Applied Energy 113 (January 2014): 152–58. + https://doi.org/10.1016/j.apenergy.2013.07.027. +[4] IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, + International Renewable Energy Agency, Abu Dhabi. +[5] M. Oakes, M. Turner, " Cost and Performance Baseline for Fossil Energy Plants, Volume 5: + Natural Gas Electricity Generating Units for Flexible Operation," National Energy + Technology Laboratory, Pittsburgh, May 5, 2023. +[6] I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. + https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf +""" + +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class SteamTurbine(ThermalComponentBase): + """Steam turbine model. + + This model represents an steam turbine with state + management, ramp rate constraints, minimum stable load, and fuel consumption + tracking. Note it is a subclass of the ThermalComponentBase class. + + All efficiency values are HHV (Higher Heating Value) net plant efficiencies. + """ + + def __init__(self, h_dict, component_name): + """Initialize the SteamTurbine class. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - rated_capacity: Maximum power output in kW + - min_stable_load_fraction: Optional, minimum operating point as fraction (0-1). + Default: 0.40 (40%) [4] + - ramp_rate_fraction: Optional, maximum rate of power increase/decrease + as fraction of rated capacity per minute. Default: 0.1 (10%) + - run_up_rate_fraction: Optional, maximum rate of power increase during startup + as fraction of rated capacity per minute. Default: ramp_rate_fraction + - hot_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 420.0 s (7 minutes) [1, 5] + - warm_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 480.0 s (8 minutes) [1, 5] + - cold_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 480.0 s (8 minutes) [1, 5] + - min_up_time: Optional, minimum time unit must remain on in s. + Default: 1800.0 s (30 minutes) [4] + - min_down_time: Optional, minimum time unit must remain off in s. + Default: 3600.0 s (1 hour) [4] + - initial_conditions: Dictionary with initial power (state is + derived automatically: power > 0 means ON, power == 0 means OFF) + - hhv: Optional, higher heating value of natural gas in J/m³. + Default: 39050000 J/m³ (39.05 MJ/m³) [6] + - fuel_density: Optional, fuel density in kg/m³. + Default: 0.768 kg/m³ [6] + - efficiency_table: Optional, dictionary with power_fraction and + efficiency arrays (both as fractions 0-1). Efficiency values must + be HHV net plant efficiencies. Default values are approximate + 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). + """ + + # 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[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[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[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[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] + # Values are adjusted assuming the steam turbine efficiency equals open cycle gas turbine efficiency minus combined cycle gas turbine efficiency + if "efficiency_table" not in h_dict[component_name]: + h_dict[component_name]["efficiency_table"] = { + "power_fraction": [1.0, 0.75, 0.50, 0.4], + "efficiency": [0.14, 0.15, 0.165, 0.17], + } + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) From c9b89404378d3c3ce1eace6086945f56a8ed797d Mon Sep 17 00:00:00 2001 From: Frederik Date: Thu, 5 Mar 2026 13:25:22 -0700 Subject: [PATCH 07/38] Improvement on loading units into thermal plant --- hercules/plant_components/thermal_plant.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index e53a24c1..ae630e8d 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -1,6 +1,7 @@ """ Multiunit thermal power plant. """ +import copy from hercules.plant_components.component_base import ComponentBase from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine @@ -15,14 +16,16 @@ def __init__(self, h_dict, component_name): # Instantiate individual units from the h_dict. self.unit_names = h_dict[component_name]["unit_names"] - # Copy h_dict pieces as needed - for unit_name in self.unit_names: + generic_units = h_dict[component_name]["units"] + + 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] = h_dict[component_name][ - "open_cycle_gas_turbine" - ].copy() + 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 - del h_dict[component_name]["open_cycle_gas_turbine"] + for unit in generic_units: + del h_dict[component_name][unit] + self.units = [] for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): h_dict_thermal = h_dict[component_name] From 6f9991d9b63fb69804feaa12acafdcfc4c1703b6 Mon Sep 17 00:00:00 2001 From: Frederik Date: Thu, 5 Mar 2026 13:42:44 -0700 Subject: [PATCH 08/38] Bug fix for when there are multiple of the same units in a plant --- hercules/plant_components/thermal_plant.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index ae630e8d..70c5b6ad 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -24,8 +24,9 @@ def __init__(self, h_dict, component_name): # Remove the template from the component dict since it's now copied into each unit dict for unit in generic_units: - del h_dict[component_name][unit] - + if unit in h_dict[component_name]: + del h_dict[component_name][unit] + self.units = [] for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): h_dict_thermal = h_dict[component_name] From 65c39559f8f2bf177e5dc64e08ebd17b948ae333 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 5 Mar 2026 13:56:45 -0700 Subject: [PATCH 09/38] Track number of starts --- .../plant_components/thermal_component_base.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 724cfffb..63512b58 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): @@ -406,13 +416,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 From b458dbca8e49faba7c648524f2f8a593c26bb885 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 5 Mar 2026 14:07:11 -0700 Subject: [PATCH 10/38] Make array of units flexible, hacky --- hercules/plant_components/thermal_plant.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 70c5b6ad..4c601a6f 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -5,7 +5,7 @@ from hercules.plant_components.component_base import ComponentBase from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine - +import hercules.hybrid_plant as hp class ThermalPlant(ComponentBase): """ """ @@ -34,7 +34,9 @@ def __init__(self, h_dict, component_name): h_dict_thermal["starttime"] = h_dict["starttime"] h_dict_thermal["endtime"] = h_dict["endtime"] h_dict_thermal["verbose"] = h_dict["verbose"] - self.units.append(OpenCycleGasTurbine(h_dict_thermal, unit_name)) + unit_type = h_dict['thermal_power_plant']['OCGT1']['component_type'] + unit_class = hp.COMPONENT_REGISTRY[unit_type] # Validate that the unit type is in the registry + 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) From 0f6f94919f3e966e315858d248ba3e9f813a4e59 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 5 Mar 2026 14:07:11 -0700 Subject: [PATCH 11/38] Make array of units flexible, hacky --- hercules/plant_components/thermal_plant.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 70c5b6ad..4c601a6f 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -5,7 +5,7 @@ from hercules.plant_components.component_base import ComponentBase from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine - +import hercules.hybrid_plant as hp class ThermalPlant(ComponentBase): """ """ @@ -34,7 +34,9 @@ def __init__(self, h_dict, component_name): h_dict_thermal["starttime"] = h_dict["starttime"] h_dict_thermal["endtime"] = h_dict["endtime"] h_dict_thermal["verbose"] = h_dict["verbose"] - self.units.append(OpenCycleGasTurbine(h_dict_thermal, unit_name)) + unit_type = h_dict['thermal_power_plant']['OCGT1']['component_type'] + unit_class = hp.COMPONENT_REGISTRY[unit_type] # Validate that the unit type is in the registry + 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) From 9f1d377ea4c8924a6a15029bf7cf89c3d8b12a49 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 5 Mar 2026 14:40:40 -0700 Subject: [PATCH 12/38] Revert "Merge branch 'pysam_7' into feature/mm-thermal" This reverts commit 71f3c907e4b1b80a35e05b2c0c812f961549f670, reversing changes made to bb51e8263f434644a929d3b017829011b7efcf08. --- docs/not_used/install_on_kestrel.md | 2 +- hercules/plant_components/solar_pysam_pvwatts.py | 3 ++- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/not_used/install_on_kestrel.md b/docs/not_used/install_on_kestrel.md index 2d829670..e83a42c1 100644 --- a/docs/not_used/install_on_kestrel.md +++ b/docs/not_used/install_on_kestrel.md @@ -97,7 +97,7 @@ chmod 600 ~/.ssh/* Note: This section is untested. Go back to herc_root ``` -pip install nrel-pysam==7.1.0 +pip install nrel-pysam==6.0.0 ``` If you run hercules and get an error that `pyyaml` is missing, you may also need to install it using diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 16313779..59db3157 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -66,6 +66,7 @@ def _create_system_model(self): system_model.assign(self.model_params) system_model.AdjustmentFactors.adjust_constant = 0 + system_model.AdjustmentFactors.dc_adjust_constant = 0 # Save the system model self.system_model = system_model @@ -92,7 +93,7 @@ def _precompute_power_array(self): # Assign the full solar resource data self.system_model.SolarResource.assign({"solar_resource_data": solar_resource_data}) - self.system_model.AdjustmentFactors.assign({"adjust_constant": 0}) + self.system_model.AdjustmentFactors.assign({"constant": 0}) # Execute the model once for all time steps self.system_model.execute() diff --git a/pyproject.toml b/pyproject.toml index 3b0e787a..614768db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "matplotlib~=3.8", "pandas~=2.0", "floris~=4.3", -"nrel-pysam~=7.0", +"nrel-pysam~=6.0", "jupyter", "netCDF4", "rainflow~=3.2", From 7fe5752c69733e1b6771d50184cb86a3d4bdecd5 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 5 Mar 2026 15:37:10 -0700 Subject: [PATCH 13/38] Track starts in h_dict for outputs --- hercules/plant_components/thermal_component_base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 63512b58..03cf3c04 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -355,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): From ee48564f13c4893e87be0a8710f3b429cfcd5e67 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 10:39:04 -0700 Subject: [PATCH 14/38] Formatting --- hercules/hybrid_plant.py | 3 +-- hercules/plant_components/steam_turbine.py | 3 ++- hercules/plant_components/thermal_plant.py | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index a8e1cdcf..7ee73eb5 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -7,11 +7,10 @@ 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.steam_turbine import SteamTurbine 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 -from hercules.plant_components.thermal_plant import ThermalPlant -from hercules.plant_components.steam_turbine import SteamTurbine # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py index 15ab7371..2a8a2a5e 100644 --- a/hercules/plant_components/steam_turbine.py +++ b/hercules/plant_components/steam_turbine.py @@ -119,7 +119,8 @@ def __init__(self, h_dict, component_name): # Default HHV net plant efficiency table based on approximate readings from # the SC1A curve in Exhibit ES-4 of [5] - # Values are adjusted assuming the steam turbine efficiency equals open cycle gas turbine efficiency minus combined cycle gas turbine efficiency + # Values are adjusted assuming the steam turbine efficiency equals open cycle gas turbine + # efficiency minus combined cycle gas turbine efficiency if "efficiency_table" not in h_dict[component_name]: h_dict[component_name]["efficiency_table"] = { "power_fraction": [1.0, 0.75, 0.50, 0.4], diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 4c601a6f..aae96ad5 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -1,11 +1,12 @@ """ Multiunit thermal power plant. """ + import copy -from hercules.plant_components.component_base import ComponentBase -from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine import hercules.hybrid_plant as hp +from hercules.plant_components.component_base import ComponentBase + class ThermalPlant(ComponentBase): """ """ @@ -34,8 +35,10 @@ def __init__(self, h_dict, component_name): 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']['OCGT1']['component_type'] - unit_class = hp.COMPONENT_REGISTRY[unit_type] # Validate that the unit type is in the registry + unit_type = h_dict["thermal_power_plant"]["OCGT1"]["component_type"] + unit_class = hp.COMPONENT_REGISTRY[ + unit_type + ] # Validate that the unit type is in the registry self.units.append(unit_class(h_dict_thermal, unit_name)) # Call the base class init (sets self.component_name and self.component_type) From 056695d62d48a8dc2cb19dc463a52d5e009859ff Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 11:01:50 -0700 Subject: [PATCH 15/38] Clean up dostrings --- hercules/plant_components/component_base.py | 4 +- hercules/plant_components/steam_turbine.py | 129 ++++---------------- hercules/plant_components/thermal_plant.py | 13 +- 3 files changed, 40 insertions(+), 106 deletions(-) diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 4c20866d..600af4d4 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -139,5 +139,5 @@ def close_logging(self): self.logger.removeHandler(handler) def step(self, h_dict): - """Raise error""" - raise NotImplementedError("Subclasses must implement the step method") + """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/steam_turbine.py b/hercules/plant_components/steam_turbine.py index 2a8a2a5e..83c2ac61 100644 --- a/hercules/plant_components/steam_turbine.py +++ b/hercules/plant_components/steam_turbine.py @@ -1,36 +1,5 @@ """ -Open Cycle Gas Turbine Class. - -Open cycle gas turbine (OCGT) model is a subclass of the ThermalComponentBase class. -It implements the model as presented in [1], [2], [3], [4], [5] and [6]. - -Like other subclasses of ThermalComponentBase, it inherits the main control functions, -and adds defaults for many variables based on [1], [2], [3], [4], [5] and [6]. - -Note: All efficiency values are HHV (Higher Heating Value) net plant efficiencies. -The default efficiency table is based on the SC1A curve from Exhibit ES-4 of [5]. - -Note: This class is based on aeroderivative open cycle gas turbines, -which are commonly used for flexible power generation. - -References: - -[1] Agora Energiewende (2017): Flexibility in thermal power plants - With a focus on existing coal-fired power plants. -[2] "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on - Production Cost Simulation", NREL/CP-6A40-87554, National Renewable - Energy Laboratory, 2024. -[3] Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly - Modelling in Power Systems with Significant Levels of Renewable Generation." - Applied Energy 113 (January 2014): 152–58. - https://doi.org/10.1016/j.apenergy.2013.07.027. -[4] IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, - International Renewable Energy Agency, Abu Dhabi. -[5] M. Oakes, M. Turner, " Cost and Performance Baseline for Fossil Energy Plants, Volume 5: - Natural Gas Electricity Generating Units for Flexible Operation," National Energy - Technology Laboratory, Pittsburgh, May 5, 2023. -[6] I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. - https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf +Steam turbine model, for use as a standalone component or part of a multi-unit thermal plant. """ from hercules.plant_components.thermal_component_base import ThermalComponentBase @@ -39,9 +8,9 @@ class SteamTurbine(ThermalComponentBase): """Steam turbine model. - This model represents an steam turbine with state - management, ramp rate constraints, minimum stable load, and fuel consumption - tracking. Note it is a subclass of the ThermalComponentBase class. + This model represents an steam turbine with state management, ramp rate constraints, minimum + stable load, and fuel consumption tracking. Note it is a subclass of the ThermalComponentBase + class. All efficiency values are HHV (Higher Heating Value) net plant efficiencies. """ @@ -50,58 +19,30 @@ def __init__(self, h_dict, component_name): """Initialize the SteamTurbine class. Args: - h_dict (dict): Dictionary containing simulation parameters including: - - rated_capacity: Maximum power output in kW - - min_stable_load_fraction: Optional, minimum operating point as fraction (0-1). - Default: 0.40 (40%) [4] - - ramp_rate_fraction: Optional, maximum rate of power increase/decrease - as fraction of rated capacity per minute. Default: 0.1 (10%) - - run_up_rate_fraction: Optional, maximum rate of power increase during startup - as fraction of rated capacity per minute. Default: ramp_rate_fraction - - hot_startup_time: Optional, time to reach min_stable_load_fraction from off - in s. Includes both readying time and ramping time. - Default: 420.0 s (7 minutes) [1, 5] - - warm_startup_time: Optional, time to reach min_stable_load_fraction from off - in s. Includes both readying time and ramping time. - Default: 480.0 s (8 minutes) [1, 5] - - cold_startup_time: Optional, time to reach min_stable_load_fraction from off - in s. Includes both readying time and ramping time. - Default: 480.0 s (8 minutes) [1, 5] - - min_up_time: Optional, minimum time unit must remain on in s. - Default: 1800.0 s (30 minutes) [4] - - min_down_time: Optional, minimum time unit must remain off in s. - Default: 3600.0 s (1 hour) [4] - - initial_conditions: Dictionary with initial power (state is - derived automatically: power > 0 means ON, power == 0 means OFF) - - hhv: Optional, higher heating value of natural gas in J/m³. - Default: 39050000 J/m³ (39.05 MJ/m³) [6] - - fuel_density: Optional, fuel density in kg/m³. - Default: 0.768 kg/m³ [6] - - efficiency_table: Optional, dictionary with power_fraction and - efficiency arrays (both as fractions 0-1). Efficiency values must - be HHV net plant efficiencies. Default values are approximate - 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). + h_dict (dict): Dictionary containing simulation parameters. + Defaults are specified below. + component_name (str): Unique name for this component instance. """ - # 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[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 + # Specify default parameter values + default_parameters_steam_turbine = { + "min_stable_load_fraction": 0.40, + "ramp_rate_fraction": 0.1, + "hot_startup_time": 420.0, + "warm_startup_time": 480.0, + "cold_startup_time": 480.0, + "min_up_time": 1800.0, + "min_down_time": 3600.0, + "hhv": 39050000, # J/m³ (39.05 + "fuel_density": 0.768, # kg/m³ + "efficiency_table": { + "power_fraction": [1.0, 0.75, 0.50, 0.4], + "efficiency": [0.14, 0.15, 0.165, 0.17], + }, + } + + # Update the input dictionary with default values for any missing parameters + h_dict[component_name] = default_parameters_steam_turbine | h_dict[component_name] # 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[component_name]: @@ -109,23 +50,5 @@ def __init__(self, h_dict, component_name): "ramp_rate_fraction" ] - # Default HHV for natural gas (39.05 MJ/m³) from [6] - 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[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] - # Values are adjusted assuming the steam turbine efficiency equals open cycle gas turbine - # efficiency minus combined cycle gas turbine efficiency - if "efficiency_table" not in h_dict[component_name]: - h_dict[component_name]["efficiency_table"] = { - "power_fraction": [1.0, 0.75, 0.50, 0.4], - "efficiency": [0.14, 0.15, 0.165, 0.17], - } - # 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/thermal_plant.py b/hercules/plant_components/thermal_plant.py index aae96ad5..1e11549b 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -9,7 +9,14 @@ 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" @@ -36,6 +43,7 @@ def __init__(self, h_dict, component_name): h_dict_thermal["endtime"] = h_dict["endtime"] h_dict_thermal["verbose"] = h_dict["verbose"] unit_type = h_dict["thermal_power_plant"]["OCGT1"]["component_type"] + # TODO: Make this more robust, possibly use get_component_class ? unit_class = hp.COMPONENT_REGISTRY[ unit_type ] # Validate that the unit type is in the registry @@ -45,6 +53,9 @@ def __init__(self, h_dict, component_name): 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( From aaefd88c14d51cf08fb06287c9b4c0d22d6ce0c9 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 14:03:44 -0700 Subject: [PATCH 16/38] Add note that initial conditions are correctly set on h_dict --- hercules/plant_components/thermal_plant.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 1e11549b..61e287bd 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -42,12 +42,12 @@ def __init__(self, h_dict, component_name): 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"]["OCGT1"]["component_type"] - # TODO: Make this more robust, possibly use get_component_class ? - unit_class = hp.COMPONENT_REGISTRY[ - unit_type - ] # Validate that the unit type is in the registry - self.units.append(unit_class(h_dict_thermal, unit_name)) + unit_type = h_dict["thermal_power_plant"][unit_name]["component_type"] + unit_class = hp.COMPONENT_REGISTRY[unit_type] + if unit_class is None: + raise ValueError(f"Unit type {unit_type} not found in component registry.") + 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) @@ -76,15 +76,14 @@ def get_initial_conditions_and_meta_data(self, h_dict): 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] - h_dict_thermal = unit.get_initial_conditions_and_meta_data(h_dict_thermal) + 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 ) - # TODO: we likely want to save off data for the individual units to the - # h_dict as well. Will need to figure out how to do that. - return h_dict From 390cfa94387d6ae8ba4f44203d235262108704b4 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 14:43:29 -0700 Subject: [PATCH 17/38] Docs page for thermal plant --- docs/thermal_plant.md | 94 ++++++++++++++++++++++ hercules/plant_components/thermal_plant.py | 3 + 2 files changed, 97 insertions(+) create mode 100644 docs/thermal_plant.md diff --git a/docs/thermal_plant.md b/docs/thermal_plant.md new file mode 100644 index 00000000..ec23b7d9 --- /dev/null +++ b/docs/thermal_plant.md @@ -0,0 +1,94 @@ +# 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. + +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`. + +## 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 list `units` and `unit_names`, as then as subdictionaries, list 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 two subdictionaries with keys `open_cycle_gas_turbine:` that specify the parameters and initial conditions for each of the two gas turbines. 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/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 61e287bd..939a82cd 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -85,5 +85,8 @@ def get_initial_conditions_and_meta_data(self, h_dict): 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 From d3ea4c16da058a2e5912dbe4a9aa367cafe53aa6 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 14:45:32 -0700 Subject: [PATCH 18/38] Check that units are all thermal --- hercules/plant_components/thermal_plant.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 939a82cd..812587a2 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -6,6 +6,7 @@ import hercules.hybrid_plant as hp from hercules.plant_components.component_base import ComponentBase +from hercules.plant_components.thermal_component_base import ThermalComponentBase class ThermalPlant(ComponentBase): @@ -47,6 +48,10 @@ def __init__(self, h_dict, component_name): if unit_class is None: raise ValueError(f"Unit type {unit_type} not found in component registry.") else: + if not issubclass(unit_class, ThermalComponentBase): + raise ValueError( + f"Unit type {unit_type} must be a subclass of ThermalComponentBase." + ) self.units.append(unit_class(h_dict_thermal, unit_name)) # Call the base class init (sets self.component_name and self.component_type) From 493e661485abd276f4bc4b83f863f72f7686b2de Mon Sep 17 00:00:00 2001 From: misi9170 Date: Fri, 6 Mar 2026 15:25:18 -0700 Subject: [PATCH 19/38] Add tests for ThermalPlant --- hercules/plant_components/thermal_plant.py | 17 +++- tests/test_inputs/h_dict.py | 19 ++++ tests/thermal_plant_test.py | 109 +++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/thermal_plant_test.py diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 812587a2..50eab1c8 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -27,6 +27,15 @@ def __init__(self, h_dict, component_name): 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]) @@ -47,11 +56,11 @@ def __init__(self, h_dict, component_name): unit_class = hp.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: - if not issubclass(unit_class, ThermalComponentBase): - raise ValueError( - f"Unit type {unit_type} must be a subclass of ThermalComponentBase." - ) self.units.append(unit_class(h_dict_thermal, unit_name)) # Call the base class init (sets self.component_name and self.component_type) 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 From 137284be51c9165730cfb6bbb8a160a5a5211d28 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 8 Mar 2026 09:42:16 -0600 Subject: [PATCH 20/38] WIP --- .../hercules_input.yaml | 98 +++++++++ .../hercules_runscript.py | 84 +++++++ .../10_combined_cycle_plant/plot_outputs.py | 136 ++++++++++++ hercules/hybrid_plant.py | 2 + .../plant_components/combined_cycle_plant.py | 206 ++++++++++++++++++ hercules/plant_components/thermal_plant.py | 2 +- 6 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 examples/10_combined_cycle_plant/hercules_input.yaml create mode 100644 examples/10_combined_cycle_plant/hercules_runscript.py create mode 100644 examples/10_combined_cycle_plant/plot_outputs.py create mode 100644 hercules/plant_components/combined_cycle_plant.py diff --git a/examples/10_combined_cycle_plant/hercules_input.yaml b/examples/10_combined_cycle_plant/hercules_input.yaml new file mode 100644 index 00000000..bcba1529 --- /dev/null +++ b/examples/10_combined_cycle_plant/hercules_input.yaml @@ -0,0 +1,98 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_10 + +### +# Describe this simulation setup +description: Combined Cycle Gas Turbine (CCGT) 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) + +combined_cycle_plant: + component_type: CombinedCyclePlant + units: ["open_cycle_gas_turbine", "steam_turbine"] + unit_names: ["OCGT", "ST"] + + open_cycle_gas_turbine: + component_type: OpenCycleGasTurbine + rated_capacity: 70000 # kW (70 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: 70000 # Start ON at rated capacity (70 MW) + + steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 3600.0 # 1 hour + warm_startup_time: 7200.0 # 2 hours + cold_startup_time: 14400.0 # 4 hours + # 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (30 MW) + +controller: + diff --git a/examples/10_combined_cycle_plant/hercules_runscript.py b/examples/10_combined_cycle_plant/hercules_runscript.py new file mode 100644 index 00000000..dc42666b --- /dev/null +++ b/examples/10_combined_cycle_plant/hercules_runscript.py @@ -0,0 +1,84 @@ +"""Example 10: Combined Cycle Gas Turbine (CCGT) simulation. + +This example demonstrates a simple combined cycle gas turbine (CCGT) that: +- Starts on at rated capacity (100 MW) +- At 10 minutes, receives a shutdown command and begins ramping down +- At ~20 minutes, reaches 0 MW and transitions to off +- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting +- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity +- At 120 minutes, receives a command to reduce power to 50% of rated capacity +- At 180 minutes, receives a command to reduce power to 10% of rated capacity + (note this is below the minimum stable load) +- At 210 minutes, receives a command to increase power to 100% of rated capacity +- At 240 minutes (4 hours), receives a shutdown command +- Simulation runs for 6 hours total with 1 minute time steps +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +class ControllerCCGT: + """Controller implementing the CCGT schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.rated_capacity = 100000 # Hardcode for now. Likely need to get from + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 10 * 60: # 10 minutes in seconds + # Before 10 minutes: run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 40 * 60: # 40 minutes in seconds + # Between 10 and 40 minutes: shut down + power_setpoint = 0.0 + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 40 and 120 minutes: signal to run at full capacity + power_setpoint = 0.8*self.rated_capacity + elif current_time < 180 * 60: # 180 minutes in seconds + # Between 120 and 180 minutes: reduce power to 50% of rated capacity + power_setpoint = 0.5 * self.rated_capacity + elif current_time < 210 * 60: # 210 minutes in seconds + # Between 180 and 210 minutes: reduce power to 10% of rated capacity + power_setpoint = 0.1 * self.rated_capacity + elif current_time < 240 * 60: # 240 minutes in seconds + # Between 210 and 240 minutes: increase power to 100% of rated capacity + power_setpoint = self.rated_capacity + else: + # After 240 minutes: shut down + power_setpoint = 0.0 + + h_dict["combined_cycle_plant"]["power_setpoint"] = power_setpoint + + return h_dict + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerCCGT(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py new file mode 100644 index 00000000..9ba759ec --- /dev/null +++ b/examples/10_combined_cycle_plant/plot_outputs.py @@ -0,0 +1,136 @@ +# Plot the outputs of the simulation for the OCGT example + +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 +print("Simulation Metadata:") +ho.print_metadata() +print() + +# 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["combined_cycle_plant.power"] / 1000, label="Power Output", color="k") +ax.plot( + time_minutes, + df["combined_cycle_plant.OCGT.power_setpoint"] / 1000, + label="Power Setpoint (OCGT)", + color="r", + linestyle="--", +) +ax.plot( + time_minutes, + df["combined_cycle_plant.ST.power_setpoint"] / 1000, + label="Power Setpoint (ST)", + color="b", + linestyle="--", +) +ax.plot( + time_minutes, + df["combined_cycle_plant.OCGT.power"] / 1000, + label="Power Output (OCGT)", + color="r", +) +ax.plot( + time_minutes, + df["combined_cycle_plant.ST.power"] / 1000, + label="Power Output (ST)", + color="b", +) + +ax = axarr[1] +ax.plot( + time_minutes, + df["combined_cycle_plant.OCGT.state"], + label="State (OCGT)", + color="r", + linestyle="-", +) +ax.plot( + time_minutes, + df["combined_cycle_plant.ST.state"], + label="State (ST)", + color="b", + linestyle="-", +) + +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.set_title( + "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +) +ax.grid(True) + +# ax.axhline( +# h_dict["thermal_power_plant"]["rated_capacity"] / 1000, +# color="gray", +# linestyle=":", +# label="Rated Capacity", +# ) +# ax.axhline( +# h_dict["thermal_power_plant"]["min_stable_load_fraction"] +# * h_dict["thermal_power_plant"]["rated_capacity"] +# / 1000, +# color="gray", +# linestyle="--", +# label="Minimum Stable Load", +# ) +# ax.set_ylabel("Power [MW]") +# ax.set_title("Open Cycle Gas Turbine Power Output") +# ax.legend() +# ax.grid(True) + +# # Plot the state +# ax = axarr[1] +# ax.plot(time_minutes, df["thermal_power_plant.state"], label="State", color="k") +# 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.set_title( +# "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +# ) +# ax.grid(True) + +# # Plot the efficiency +# ax = axarr[2] +# ax.plot( +# time_minutes, +# df["thermal_power_plant.efficiency"] * 100, +# label="Efficiency", +# color="g", +# ) +# ax.set_ylabel("Efficiency [%]") +# ax.set_title("Thermal Efficiency") +# ax.grid(True) + +# # Plot the fuel consumption +# ax = axarr[3] +# ax.plot( +# time_minutes, +# df["thermal_power_plant.fuel_volume_rate"], +# label="Fuel Volume Rate", +# color="orange", +# ) +# ax.set_ylabel("Fuel [m³/s]") +# ax.set_title("Fuel Volume Rate") +# ax.grid(True) + +# ax.set_xlabel("Time [minutes]") + +plt.tight_layout() +plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index b2fb7f53..2ded01e4 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -10,6 +10,7 @@ from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower from hercules.plant_components.thermal_plant import ThermalPlant from hercules.plant_components.steam_turbine import SteamTurbine +from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. @@ -23,6 +24,7 @@ "OpenCycleGasTurbine": OpenCycleGasTurbine, "ThermalPlant": ThermalPlant, "SteamTurbine": SteamTurbine, + "CombinedCyclePlant": CombinedCyclePlant, } # Derived from registry keys for validation in utilities.py diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py new file mode 100644 index 00000000..eb15b4d8 --- /dev/null +++ b/hercules/plant_components/combined_cycle_plant.py @@ -0,0 +1,206 @@ +""" +Multiunit combined cycle gas power plant. +This plant has both an open cycle gas turbine and steam turbine. The steam turbine is modeled +as a single unit with a power output that is a function of the open cycle gas turbine power output. +""" +import copy + +from hercules.plant_components.component_base import ComponentBase +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine +from hercules.plant_components.steam_turbine import SteamTurbine +import hercules.hybrid_plant as hp + +class CombinedCyclePlant(ComponentBase): + """ """ + + component_category = "generator" + + def __init__(self, h_dict, component_name): + # Instantiate individual units from the h_dict. + + self.component_name = component_name + self.component_type = "combined_cycle_plant" + + self.unit_names = h_dict[component_name]["unit_names"] + generic_units = h_dict[component_name]["units"] + if not "steam_turbine" in generic_units: + raise ValueError("For the combined cycle plant, one of the units must be a steam turbine.") + if not "open_cycle_gas_turbine" in generic_units: + raise ValueError("For the combined cycle plant, one of the units must be an open cycle gas turbine.") + + for unit, unit_name in zip(generic_units, self.unit_names): + if unit not in ["open_cycle_gas_turbine", "steam_turbine"]: + raise ValueError("For the combined cycle plant, units must be either 'open_cycle_gas_turbine' or 'steam_turbine'.") + 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] + + self.units = [] + self.unit_types = [] + for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): + h_dict_cctg = h_dict[component_name] + h_dict_cctg["dt"] = h_dict["dt"] + h_dict_cctg["starttime"] = h_dict["starttime"] + h_dict_cctg["endtime"] = h_dict["endtime"] + h_dict_cctg["verbose"] = h_dict["verbose"] + unit_type = h_dict[component_name][unit_name]['component_type'] + unit_class = hp.COMPONENT_REGISTRY[unit_type] # Validate that the unit type is in the registry + self.units.append(unit_class(h_dict_cctg, unit_name)) + self.unit_types.append(self.units[-1].component_type) + + # Extract initial conditions + self.power_output = 0.0 + for unit_name in self.unit_names: + initial_conditions = h_dict[component_name][unit_name]["initial_conditions"] + self.power_output += initial_conditions["power"] # kW + + h_dict[component_name]["power"] = self.power_output + + self.steam_turbine_index = self.unit_types.index("SteamTurbine") + self.gas_turbine_index = self.unit_types.index("OpenCycleGasTurbine") + + # Check that initial conditions are valid + if (self.units[self.gas_turbine_index].power_output == 0 and + self.units[self.steam_turbine_index].power_output > 0): + raise ValueError( + "Invalid initial conditions: steam turbine cannot be producing power if " + "the open cycle gas turbine is not producing power." + ) + + self.gas_power_ratio = ( + self.units[self.gas_turbine_index].rated_capacity / + (self.units[self.steam_turbine_index].rated_capacity + + self.units[self.gas_turbine_index].rated_capacity) + ) + + # Derive initial state from power: if power > 0 then ON, else OFF + for unit in self.units: + if unit.power_output > 0: + unit.state = unit.STATES.ON + # Set time_in_state so the unit is immediately ready to stop + unit.time_in_state = float(unit.min_up_time) # s + else: + unit.state = unit.STATES.OFF + # Set time_in_state so the unit is immediately ready to start + if "time_in_shutdown" in initial_conditions: + unit.time_in_state = float(initial_conditions["time_in_shutdown"]) # s + else: + unit.time_in_state = float(unit.min_down_time) # s + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) + + def step(self, h_dict): + + power_setpoint = h_dict[self.component_name]["power_setpoint"] + + # Determine power setpoints for the units based on the overall combined cycle plant power setpoint + power_setpoints = [0] * len(self.units) + + # TODO: look at better setpoints that make gas produce more power when steam is still down + power_setpoints[self.gas_turbine_index] = self.gas_power_ratio * power_setpoint + power_setpoints[self.steam_turbine_index] = (1 - self.gas_power_ratio) * power_setpoint + + # Check that the power setpoint is a number + if not isinstance(power_setpoint, (int, float)): + raise ValueError("power_setpoint must be a number") + + # Apply control + self.power_output = sum(self.control(power_setpoints)) + + # Step each unit + for unit, unit_name, power_setpoint in zip( + self.units, self.unit_names, power_setpoints + ): + h_dict_ccgt = h_dict[self.component_name] + h_dict_ccgt[unit_name]["power_setpoint"] = power_setpoint + h_dict_ccgt = unit.step(h_dict_ccgt) + + # Update h_dict with outputs + h_dict[self.component_name]["power"] = self.power_output + # h_dict[self.component_name]["state"] = self.state.value + # 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 + + return h_dict + + def get_initial_conditions_and_meta_data(self, h_dict): + """Get initial conditions and metadata for the ccgt plant. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + """ + for unit in self.units: + h_dict_ccgt = h_dict[self.component_name] + h_dict_ccgt = unit.get_initial_conditions_and_meta_data(h_dict_ccgt) + + h_dict[self.component_name]["power"] = sum( + h_dict_ccgt[unit.component_name]["power"] for unit in self.units + ) + + # TODO: we likely want to save off data for the individual units to the + # h_dict as well. Will need to figure out how to do that. + + return h_dict + + def control(self, power_setpoints): + """""" + + # TODO: we probably want to add an actual controller for the gas turbine + self.units[self.gas_turbine_index].power_output = self.units[self.gas_turbine_index]._control(power_setpoints[self.gas_turbine_index]) + + if (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and + self.units[self.steam_turbine_index].state != self.units[self.steam_turbine_index].STATES.OFF): + self.units[self.steam_turbine_index].power_output = self.units[self.steam_turbine_index]._control(power_setpoints[self.steam_turbine_index]) + else: + self.units[self.steam_turbine_index].power_output = self.control_steam_turbine(power_setpoints) + + return [unit.power_output for unit in self.units] + + + def control_steam_turbine(self, power_setpoints): + """ + What I want to do: + - If the gas turbine is off, or starting up, the steam turbine should be off. + - If the gas turbine goes from startup to on, the steam turbine startup process should begin. + - Can we use self.units[].time_in_state to delay the startup until the gas turbine is turned on? + - Current status: might actually be working already. Check what happens. + """ + + if self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0: + # If the gas turbine is stopping but the steam turbine is still producing power, we need to turn off the steam turbine + self.units[self.steam_turbine_index].state = "STOPPING" + self.units[self.steam_turbine_index]._control(0.0) + self.units[self.steam_turbine_index].starting_now = False + # if self.units[self.gas_turbine_index].state == ( + # self.units[self.gas_turbine_index].STATES.HOT_STARTING or + # self.units[self.gas_turbine_index].STATES.WARM_STARTING or + # self.units[self.gas_turbine_index].STATES.COLD_STARTING): + # # If the gas turbine is not on, the steam turbine should be off + # self.units[self.steam_turbine_index].state = self.units[self.steam_turbine_index].STATES.OFF + # self.units[self.steam_turbine_index]._control(0.0) + elif (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and + self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF): + # If the gas turbine just turned on and the steam turbine is still off, we need to start up the steam turbine + if (not self.units[self.steam_turbine_index].starting_now or + not hasattr(self.units[self.steam_turbine_index], 'starting_now')): + self.units[self.steam_turbine_index].time_in_state = 0.0 # Reset time in state to start the startup process + self.units[self.steam_turbine_index].starting_now = True + power_setpoint = (1 - self.gas_power_ratio) * sum(power_setpoints) + self.units[self.steam_turbine_index]._control(power_setpoint) + else: + self.units[self.steam_turbine_index]._control(power_setpoints[self.steam_turbine_index]) + self.units[self.steam_turbine_index].starting_now = False + + return self.units[self.steam_turbine_index].power_output + + # self.units[self.steam_turbine_index].state = "OFF" + + + + diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py index 4c601a6f..608b5fa5 100644 --- a/hercules/plant_components/thermal_plant.py +++ b/hercules/plant_components/thermal_plant.py @@ -34,7 +34,7 @@ def __init__(self, h_dict, component_name): 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']['OCGT1']['component_type'] + unit_type = h_dict[component_name][unit_name]['component_type'] unit_class = hp.COMPONENT_REGISTRY[unit_type] # Validate that the unit type is in the registry self.units.append(unit_class(h_dict_thermal, unit_name)) From 203c91e00809c969506107ad98aa6865746b51b4 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 8 Mar 2026 10:18:33 -0600 Subject: [PATCH 21/38] Fixing merge conflicts --- hercules/hybrid_plant.py | 12 ++---------- hercules/plant_components/combined_cycle_plant.py | 15 ++++++++------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 1668733b..5c37aa28 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -9,14 +9,9 @@ from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts from hercules.plant_components.steam_turbine import SteamTurbine from hercules.plant_components.thermal_plant import ThermalPlant +from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant from hercules.plant_components.wind_farm import WindFarm from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower -<<<<<<< HEAD -from hercules.plant_components.thermal_plant import ThermalPlant -from hercules.plant_components.steam_turbine import SteamTurbine -from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant -======= ->>>>>>> feature/mm-thermal # Registry mapping component_type strings to their classes. # Add new component types here to make them discoverable by HybridPlant. @@ -30,12 +25,9 @@ "OpenCycleGasTurbine": OpenCycleGasTurbine, "ThermalPlant": ThermalPlant, "SteamTurbine": SteamTurbine, -<<<<<<< HEAD - "CombinedCyclePlant": CombinedCyclePlant, -======= "HardCoalSteamTurbine": HardCoalSteamTurbine, "PowerPlayback": PowerPlayback, ->>>>>>> feature/mm-thermal + "CombinedCyclePlant": CombinedCyclePlant, } # Derived from registry keys for validation in utilities.py diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index b34e9d04..33920d2c 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -42,12 +42,12 @@ def __init__(self, h_dict, component_name): self.units = [] self.unit_types = [] 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"] + h_dict_ccgt = h_dict[component_name] + h_dict_ccgt["dt"] = h_dict["dt"] + h_dict_ccgt["starttime"] = h_dict["starttime"] + h_dict_ccgt["endtime"] = h_dict["endtime"] + h_dict_ccgt["verbose"] = h_dict["verbose"] + unit_type = h_dict["combined_cycle_plant"][unit_name]["component_type"] unit_class = hp.COMPONENT_REGISTRY[unit_type] if unit_class is None: raise ValueError(f"Unit type {unit_type} not found in component registry.") @@ -56,7 +56,8 @@ def __init__(self, h_dict, component_name): f"Unit type {unit_type} must be a subclass of ThermalComponentBase." ) else: - self.units.append(unit_class(h_dict_thermal, unit_name)) + self.units.append(unit_class(h_dict_ccgt, unit_name)) + self.unit_types.append(unit_type) # Extract initial conditions self.power_output = 0.0 From 1c55931af00a4064018bfb3430a70d5e42f43731 Mon Sep 17 00:00:00 2001 From: Frederik Date: Mon, 9 Mar 2026 12:58:32 -0600 Subject: [PATCH 22/38] Multi-unit CCGT now functional, probably in the same state as mm-thermal right now --- .../hercules_input.yaml | 14 ++-- .../hercules_runscript.py | 24 +++--- .../10_combined_cycle_plant/plot_outputs.py | 37 ++------- .../plant_components/combined_cycle_plant.py | 77 +++++++++---------- .../thermal_component_base.py | 6 +- 5 files changed, 66 insertions(+), 92 deletions(-) diff --git a/examples/10_combined_cycle_plant/hercules_input.yaml b/examples/10_combined_cycle_plant/hercules_input.yaml index bcba1529..70819f72 100644 --- a/examples/10_combined_cycle_plant/hercules_input.yaml +++ b/examples/10_combined_cycle_plant/hercules_input.yaml @@ -10,7 +10,7 @@ description: Combined Cycle Gas Turbine (CCGT) 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 +endtime_utc: "2020-01-01T10:00:00Z" # 10 hours later verbose: False log_every_n: 1 @@ -25,14 +25,14 @@ combined_cycle_plant: open_cycle_gas_turbine: component_type: OpenCycleGasTurbine rated_capacity: 70000 # kW (70 MW) - min_stable_load_fraction: 0.2 # 20% minimum operating point + 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: 3600 # 1 hour - min_down_time: 3600 # 1 hour + hot_startup_time: 1800.0 # 30 minutes + warm_startup_time: 2700.0 # 45 minutes + cold_startup_time: 2700.0 # 45 minutes + min_up_time: 14400 # 4 hour + min_down_time: 7200 # 2 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] diff --git a/examples/10_combined_cycle_plant/hercules_runscript.py b/examples/10_combined_cycle_plant/hercules_runscript.py index dc42666b..c520affe 100644 --- a/examples/10_combined_cycle_plant/hercules_runscript.py +++ b/examples/10_combined_cycle_plant/hercules_runscript.py @@ -52,23 +52,23 @@ def step(self, h_dict): if current_time < 10 * 60: # 10 minutes in seconds # Before 10 minutes: run at full capacity power_setpoint = self.rated_capacity - elif current_time < 40 * 60: # 40 minutes in seconds - # Between 10 and 40 minutes: shut down + elif current_time < 60 * 60: # 60 minutes in seconds + # Between 10 and 60 minutes: shut down power_setpoint = 0.0 - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 40 and 120 minutes: signal to run at full capacity - power_setpoint = 0.8*self.rated_capacity - elif current_time < 180 * 60: # 180 minutes in seconds - # Between 120 and 180 minutes: reduce power to 50% of rated capacity + elif current_time < 260 * 60: # 260 minutes in seconds + # Between 60 and 260 minutes: signal to run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 360 * 60: # 360 minutes in seconds + # Between 260 and 360 minutes: reduce power to 50% of rated capacity power_setpoint = 0.5 * self.rated_capacity - elif current_time < 210 * 60: # 210 minutes in seconds - # Between 180 and 210 minutes: reduce power to 10% of rated capacity + elif current_time < 480 * 60: # 480 minutes in seconds + # Between 360 and 480 minutes: reduce power to 10% of rated capacity power_setpoint = 0.1 * self.rated_capacity - elif current_time < 240 * 60: # 240 minutes in seconds - # Between 210 and 240 minutes: increase power to 100% of rated capacity + elif current_time < 540 * 60: # 540 minutes in seconds + # Between 480 and 540 minutes: increase power to 100% of rated capacity power_setpoint = self.rated_capacity else: - # After 240 minutes: shut down + # After 540 minutes: shut down power_setpoint = 0.0 h_dict["combined_cycle_plant"]["power_setpoint"] = power_setpoint diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py index 9ba759ec..dd91cf4b 100644 --- a/examples/10_combined_cycle_plant/plot_outputs.py +++ b/examples/10_combined_cycle_plant/plot_outputs.py @@ -39,6 +39,13 @@ color="b", linestyle="--", ) +ax.plot( + time_minutes, + (df["combined_cycle_plant.ST.power_setpoint"]+df["combined_cycle_plant.OCGT.power_setpoint"]) / 1000, + label="Power Setpoint (CCGT)", + color="k", + linestyle="--", +) ax.plot( time_minutes, df["combined_cycle_plant.OCGT.power"] / 1000, @@ -76,36 +83,6 @@ ) ax.grid(True) -# ax.axhline( -# h_dict["thermal_power_plant"]["rated_capacity"] / 1000, -# color="gray", -# linestyle=":", -# label="Rated Capacity", -# ) -# ax.axhline( -# h_dict["thermal_power_plant"]["min_stable_load_fraction"] -# * h_dict["thermal_power_plant"]["rated_capacity"] -# / 1000, -# color="gray", -# linestyle="--", -# label="Minimum Stable Load", -# ) -# ax.set_ylabel("Power [MW]") -# ax.set_title("Open Cycle Gas Turbine Power Output") -# ax.legend() -# ax.grid(True) - -# # Plot the state -# ax = axarr[1] -# ax.plot(time_minutes, df["thermal_power_plant.state"], label="State", color="k") -# 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.set_title( -# "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -# ) -# ax.grid(True) - # # Plot the efficiency # ax = axarr[2] # ax.plot( diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index 33920d2c..73203505 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -105,26 +105,15 @@ def step(self, h_dict): power_setpoint = h_dict[self.component_name]["power_setpoint"] - # Determine power setpoints for the units based on the overall combined cycle plant power setpoint - power_setpoints = [0] * len(self.units) - - # TODO: look at better setpoints that make gas produce more power when steam is still down - power_setpoints[self.gas_turbine_index] = self.gas_power_ratio * power_setpoint - power_setpoints[self.steam_turbine_index] = (1 - self.gas_power_ratio) * power_setpoint - - # Check that the power setpoint is a number - if not isinstance(power_setpoint, (int, float)): - raise ValueError("power_setpoint must be a number") - # Apply control - self.power_output = sum(self.control(power_setpoints)) + self.power_output = sum(self.control(power_setpoint)) # Step each unit - for unit, unit_name, power_setpoint in zip( - self.units, self.unit_names, power_setpoints + for unit, unit_name in zip( + self.units, self.unit_names ): h_dict_ccgt = h_dict[self.component_name] - h_dict_ccgt[unit_name]["power_setpoint"] = power_setpoint + h_dict_ccgt[unit_name]["power_setpoint"] = unit.power_setpoint h_dict_ccgt = unit.step(h_dict_ccgt) # Update h_dict with outputs @@ -155,22 +144,25 @@ def get_initial_conditions_and_meta_data(self, h_dict): return h_dict - def control(self, power_setpoints): + def control(self, power_setpoint): """""" - # TODO: we probably want to add an actual controller for the gas turbine - self.units[self.gas_turbine_index].power_output = self.units[self.gas_turbine_index]._control(power_setpoints[self.gas_turbine_index]) + # Check that the power setpoint is a number + if not isinstance(power_setpoint, (int, float)): + raise ValueError("power_setpoint must be a number") - if (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and - self.units[self.steam_turbine_index].state != self.units[self.steam_turbine_index].STATES.OFF): - self.units[self.steam_turbine_index].power_output = self.units[self.steam_turbine_index]._control(power_setpoints[self.steam_turbine_index]) - else: - self.units[self.steam_turbine_index].power_output = self.control_steam_turbine(power_setpoints) + # Set gas turbine power setpoint + self.units[self.gas_turbine_index].power_setpoint = self.gas_power_ratio * power_setpoint + self.units[self.steam_turbine_index].power_setpoint = (1 - self.gas_power_ratio) * power_setpoint + + # TODO: we probably want to add an actual controller for the gas turbine + self.units[self.gas_turbine_index].power_output = self.units[self.gas_turbine_index]._control(self.units[self.gas_turbine_index].power_setpoint) + self.units[self.steam_turbine_index].power_output = self.control_steam_turbine(self.units[self.steam_turbine_index].power_setpoint) return [unit.power_output for unit in self.units] - def control_steam_turbine(self, power_setpoints): + def control_steam_turbine(self, power_setpoint): """ What I want to do: - If the gas turbine is off, or starting up, the steam turbine should be off. @@ -178,31 +170,32 @@ def control_steam_turbine(self, power_setpoints): - Can we use self.units[].time_in_state to delay the startup until the gas turbine is turned on? - Current status: might actually be working already. Check what happens. """ - - if self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0: + if self.units[self.gas_turbine_index].state != ( + self.units[self.gas_turbine_index].STATES.ON or + self.units[self.gas_turbine_index].STATES.STOPPING): + # If the gas turbine is off or starting up, the steam turbine should be off + self.units[self.steam_turbine_index].can_start = False + self.units[self.steam_turbine_index]._control(0.0) + # self.units[self.steam_turbine_index].starting_now = False + elif (self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0 + or self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.STOPPING): # If the gas turbine is stopping but the steam turbine is still producing power, we need to turn off the steam turbine - self.units[self.steam_turbine_index].state = "STOPPING" self.units[self.steam_turbine_index]._control(0.0) - self.units[self.steam_turbine_index].starting_now = False - # if self.units[self.gas_turbine_index].state == ( - # self.units[self.gas_turbine_index].STATES.HOT_STARTING or - # self.units[self.gas_turbine_index].STATES.WARM_STARTING or - # self.units[self.gas_turbine_index].STATES.COLD_STARTING): - # # If the gas turbine is not on, the steam turbine should be off - # self.units[self.steam_turbine_index].state = self.units[self.steam_turbine_index].STATES.OFF - # self.units[self.steam_turbine_index]._control(0.0) + # self.units[self.steam_turbine_index].starting_now = False elif (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF): # If the gas turbine just turned on and the steam turbine is still off, we need to start up the steam turbine - if (not self.units[self.steam_turbine_index].starting_now or - not hasattr(self.units[self.steam_turbine_index], 'starting_now')): - self.units[self.steam_turbine_index].time_in_state = 0.0 # Reset time in state to start the startup process - self.units[self.steam_turbine_index].starting_now = True - power_setpoint = (1 - self.gas_power_ratio) * sum(power_setpoints) + # if (not self.units[self.steam_turbine_index].starting_now or + # not hasattr(self.units[self.steam_turbine_index], 'starting_now')): + # self.units[self.steam_turbine_index].time_in_state = 0.0 # Reset time in state to start the startup process + # self.units[self.steam_turbine_index].starting_now = True + self.units[self.steam_turbine_index].can_start = ( + self.units[self.steam_turbine_index].time_in_state >= self.units[self.steam_turbine_index].min_down_time ) self.units[self.steam_turbine_index]._control(power_setpoint) else: - self.units[self.steam_turbine_index]._control(power_setpoints[self.steam_turbine_index]) - self.units[self.steam_turbine_index].starting_now = False + # Normal operation + self.units[self.steam_turbine_index]._control(power_setpoint) + # self.units[self.steam_turbine_index].starting_now = False return self.units[self.steam_turbine_index].power_output diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 03cf3c04..5988fd03 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -419,7 +419,10 @@ def _control(self, power_setpoint): # ==================================================================== if self.state == self.STATES.OFF: # Check if we can start (min_down_time satisfied) - can_start = self.time_in_state >= self.min_down_time + if not hasattr(self, "can_start"): + can_start = self.time_in_state >= self.min_down_time + else: + can_start = self.can_start if power_setpoint > 0 and can_start: self.n_total_starts += 1 @@ -434,6 +437,7 @@ def _control(self, power_setpoint): self.state = self.STATES.COLD_STARTING self.n_cold_starts += 1 self.time_in_state = 0.0 + if hasattr(self, "can_start"): del self.can_start return 0.0 # Power is always 0 when off From 810a7b63fe5afc71d3ae19500aee0e476ef1a176 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:03:49 -0600 Subject: [PATCH 23/38] Remove SteamTurbine from this branch --- hercules/hybrid_plant.py | 2 - hercules/plant_components/steam_turbine.py | 54 ---------------------- 2 files changed, 56 deletions(-) delete mode 100644 hercules/plant_components/steam_turbine.py diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 7ee73eb5..f7e30e8d 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -7,7 +7,6 @@ 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.steam_turbine import SteamTurbine 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 @@ -23,7 +22,6 @@ "ElectrolyzerPlant": ElectrolyzerPlant, "OpenCycleGasTurbine": OpenCycleGasTurbine, "ThermalPlant": ThermalPlant, - "SteamTurbine": SteamTurbine, "HardCoalSteamTurbine": HardCoalSteamTurbine, "PowerPlayback": PowerPlayback, } diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py deleted file mode 100644 index 83c2ac61..00000000 --- a/hercules/plant_components/steam_turbine.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Steam turbine model, for use as a standalone component or part of a multi-unit thermal plant. -""" - -from hercules.plant_components.thermal_component_base import ThermalComponentBase - - -class SteamTurbine(ThermalComponentBase): - """Steam turbine model. - - This model represents an steam turbine with state management, ramp rate constraints, minimum - stable load, and fuel consumption tracking. Note it is a subclass of the ThermalComponentBase - class. - - All efficiency values are HHV (Higher Heating Value) net plant efficiencies. - """ - - def __init__(self, h_dict, component_name): - """Initialize the SteamTurbine class. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - Defaults are specified below. - component_name (str): Unique name for this component instance. - """ - - # Specify default parameter values - default_parameters_steam_turbine = { - "min_stable_load_fraction": 0.40, - "ramp_rate_fraction": 0.1, - "hot_startup_time": 420.0, - "warm_startup_time": 480.0, - "cold_startup_time": 480.0, - "min_up_time": 1800.0, - "min_down_time": 3600.0, - "hhv": 39050000, # J/m³ (39.05 - "fuel_density": 0.768, # kg/m³ - "efficiency_table": { - "power_fraction": [1.0, 0.75, 0.50, 0.4], - "efficiency": [0.14, 0.15, 0.165, 0.17], - }, - } - - # Update the input dictionary with default values for any missing parameters - h_dict[component_name] = default_parameters_steam_turbine | h_dict[component_name] - - # 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[component_name]: - h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][ - "ramp_rate_fraction" - ] - - # Call the base class init (sets self.component_name and self.component_type) - super().__init__(h_dict, component_name) From 4f7b72c3b604da23d73cef09f82192e6c161aa61 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:05:07 -0600 Subject: [PATCH 24/38] Remove steam turbine example --- examples/09_steam_turbine/hercules_input.yaml | 57 ------------ .../09_steam_turbine/hercules_runscript.py | 85 ----------------- examples/09_steam_turbine/plot_outputs.py | 92 ------------------- 3 files changed, 234 deletions(-) delete mode 100644 examples/09_steam_turbine/hercules_input.yaml delete mode 100644 examples/09_steam_turbine/hercules_runscript.py delete mode 100644 examples/09_steam_turbine/plot_outputs.py diff --git a/examples/09_steam_turbine/hercules_input.yaml b/examples/09_steam_turbine/hercules_input.yaml deleted file mode 100644 index ee96c8a5..00000000 --- a/examples/09_steam_turbine/hercules_input.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# 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-01T18:00:00Z" # 18 hours later -verbose: False -log_every_n: 1 - -plant: - interconnect_limit: 30000 # kW (30 MW) - -steam_turbine: - component_type: SteamTurbine - rated_capacity: 30000 # kW (30 MW) - min_stable_load_fraction: 0.1 # 10% minimum operating point - ramp_rate_fraction: 0.05 # 5%/min ramp rate - run_up_rate_fraction: 0.03 # 3%/min run up rate - hot_startup_time: 7200.0 # 2 hours - warm_startup_time: 14400.0 # 4 hours - cold_startup_time: 28800.0 # 8 hours - min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine - min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine - # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html - # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ - hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) - fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) - 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.14 - - 0.15 - - 0.165 - - 0.17 - log_channels: - - power - - fuel_volume_rate - - fuel_mass_rate - - state - - efficiency - - power_setpoint - initial_conditions: - power: 30000 # Start ON at rated capacity (100 MW) - -controller: - diff --git a/examples/09_steam_turbine/hercules_runscript.py b/examples/09_steam_turbine/hercules_runscript.py deleted file mode 100644 index ee27217a..00000000 --- a/examples/09_steam_turbine/hercules_runscript.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Example 07: Steam Turbine (ST) simulation. - -This example demonstrates a simple steam turbine (ST) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerST: - """Controller implementing the steam turbine schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = h_dict["steam_turbine"]["rated_capacity"] - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 30 * 60: # 30 minutes in seconds - # Before 30 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 30 and 120 minutes: shut down - power_setpoint = 0.0 - elif current_time < 360 * 60: # 360 minutes in seconds - # Between 120 and 360 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 720 * 60: # 720 minutes in seconds - # Between 360 and 720 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 630 * 60: # 630 minutes in seconds - # Between 360 and 630 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 720 * 60: # 720 minutes in seconds - # Between 630 and 720 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 720 minutes: shut down - power_setpoint = 0.0 - - h_dict["steam_turbine"]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerST(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/09_steam_turbine/plot_outputs.py b/examples/09_steam_turbine/plot_outputs.py deleted file mode 100644 index d0802bf4..00000000 --- a/examples/09_steam_turbine/plot_outputs.py +++ /dev/null @@ -1,92 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -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 -print("Simulation Metadata:") -ho.print_metadata() -print() - -# 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["steam_turbine.power"] / 1000, label="Power Output", color="b") -ax.plot( - time_minutes, - df["steam_turbine.power_setpoint"] / 1000, - label="Power Setpoint", - color="r", - linestyle="--", -) -ax.axhline( - h_dict["steam_turbine"]["rated_capacity"] / 1000, - color="gray", - linestyle=":", - label="Rated Capacity", -) -ax.axhline( - h_dict["steam_turbine"]["min_stable_load_fraction"] - * h_dict["steam_turbine"]["rated_capacity"] - / 1000, - color="gray", - linestyle="--", - label="Minimum Stable Load", -) -ax.set_ylabel("Power [MW]") -ax.set_title("Open Cycle Gas Turbine Power Output") -ax.legend() -ax.grid(True) - -# Plot the state -ax = axarr[1] -ax.plot(time_minutes, df["steam_turbine.state"], label="State", color="k") -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.set_title( - "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -) -ax.grid(True) - -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_minutes, - df["steam_turbine.efficiency"] * 100, - label="Efficiency", - color="g", -) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") -ax.grid(True) - -# Plot the fuel consumption -ax = axarr[3] -ax.plot( - time_minutes, - df["steam_turbine.fuel_volume_rate"], - label="Fuel Volume Rate", - color="orange", -) -ax.set_ylabel("Fuel [m³/s]") -ax.set_title("Fuel Volume Rate") -ax.grid(True) - -ax.set_xlabel("Time [minutes]") - -plt.tight_layout() -plt.show() From f6661949e04a293a924ca782572ed65968d99de4 Mon Sep 17 00:00:00 2001 From: Frederik Date: Mon, 9 Mar 2026 16:36:01 -0600 Subject: [PATCH 25/38] Added efficiency to logging, enabling two different methods of efficiency calculation --- .../hercules_input.yaml | 35 ++++++++ .../10_combined_cycle_plant/plot_outputs.py | 43 +++++++--- .../plant_components/combined_cycle_plant.py | 79 ++++++++++++++++--- 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/examples/10_combined_cycle_plant/hercules_input.yaml b/examples/10_combined_cycle_plant/hercules_input.yaml index 70819f72..e753f0b6 100644 --- a/examples/10_combined_cycle_plant/hercules_input.yaml +++ b/examples/10_combined_cycle_plant/hercules_input.yaml @@ -94,5 +94,40 @@ combined_cycle_plant: initial_conditions: power: 30000 # Start ON at rated capacity (30 MW) + efficiency_table: + power_fraction: + - 1.0 + - 0.95 + - 0.90 + - 0.85 + - 0.80 + - 0.75 + - 0.7 + - 0.65 + - 0.6 + - 0.55 + - 0.50 + - 0.4 + efficiency: # HHV net plant efficiency, fractions (0-1), from CC1A-F curve in Exhibit ES-4 of [5] + - 0.53 + - 0.515 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.515 + - 0.505 + - 0.5 + - 0.49 + - 0.47 + + log_channels: + # - power + # - fuel_volume_rate + # - fuel_mass_rate + # - state + - efficiency + controller: diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py index dd91cf4b..af8232b7 100644 --- a/examples/10_combined_cycle_plant/plot_outputs.py +++ b/examples/10_combined_cycle_plant/plot_outputs.py @@ -83,17 +83,38 @@ ) ax.grid(True) -# # Plot the efficiency -# ax = axarr[2] -# ax.plot( -# time_minutes, -# df["thermal_power_plant.efficiency"] * 100, -# label="Efficiency", -# color="g", -# ) -# ax.set_ylabel("Efficiency [%]") -# ax.set_title("Thermal Efficiency") -# ax.grid(True) +# Plot the efficiency +ax = axarr[2] +ax.plot( + time_minutes, + df["combined_cycle_plant.efficiency"] * 100, + label="Efficiency", + color="g", +) +ax.set_ylabel("Efficiency [%]") +ax.set_title("Thermal Efficiency") +ax.grid(True) + +# Plot the efficiency +ax = axarr[2] +ax.plot( + time_minutes, + df["combined_cycle_plant.efficiency"] * 100, + label="Efficiency", + color="g", +) + +# Plot the efficiency of the OCGT and ST separately +ax = axarr[2] +ax.plot( + time_minutes, + (df["combined_cycle_plant.OCGT.efficiency"] + df["combined_cycle_plant.ST.efficiency"]) * 100, + label="Efficiency", + color="r", +) +ax.set_ylabel("Efficiency [%]") +ax.set_title("Thermal Efficiency") +ax.grid(True) # # Plot the fuel consumption # ax = axarr[3] diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index 73203505..14f00b64 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -5,9 +5,11 @@ """ import copy +import numpy as np import hercules.hybrid_plant as hp from hercules.plant_components.component_base import ComponentBase from hercules.plant_components.thermal_component_base import ThermalComponentBase +from hercules.utilities import hercules_float_type class CombinedCyclePlant(ComponentBase): @@ -84,6 +86,47 @@ def __init__(self, h_dict, component_name): self.units[self.gas_turbine_index].rated_capacity) ) + efficiency_table = h_dict[component_name]["efficiency_table"] + + # Validate efficiency_table structure + if not isinstance(efficiency_table, dict): + raise ValueError("efficiency_table must be a dictionary") + if "power_fraction" not in efficiency_table: + raise ValueError("efficiency_table must contain 'power_fraction'") + if "efficiency" not in efficiency_table: + raise ValueError("efficiency_table must contain 'efficiency'") + + # Extract and convert to numpy arrays for interpolation + self.efficiency_power_fraction = np.array( + efficiency_table["power_fraction"], dtype=hercules_float_type + ) + self.efficiency_values = np.array(efficiency_table["efficiency"], dtype=hercules_float_type) + + # Validate array lengths match + if len(self.efficiency_power_fraction) != len(self.efficiency_values): + raise ValueError( + "efficiency_table power_fraction and efficiency arrays must have the same length" + ) + + # Validate array lengths are at least 1 + if len(self.efficiency_power_fraction) < 1: + raise ValueError("efficiency_table must have at least one entry") + + # Validate power_fraction values are in [0, 1] + if np.any(self.efficiency_power_fraction < 0) or np.any(self.efficiency_power_fraction > 1): + raise ValueError("efficiency_table power_fraction values must be between 0 and 1") + + # Validate efficiency values are in (0, 1] + if np.any(self.efficiency_values <= 0) or np.any(self.efficiency_values > 1): + raise ValueError("efficiency_table efficiency values must be between 0 and 1") + + # Sort arrays by power_fraction for proper interpolation + sort_idx = np.argsort(self.efficiency_power_fraction) + self.efficiency_power_fraction = self.efficiency_power_fraction[sort_idx] + self.efficiency_values = self.efficiency_values[sort_idx] + + self.rated_capacity = self.units[self.gas_turbine_index].rated_capacity + self.units[self.steam_turbine_index].rated_capacity + # Derive initial state from power: if power > 0 then ON, else OFF for unit in self.units: if unit.power_output > 0: @@ -116,10 +159,12 @@ def step(self, h_dict): h_dict_ccgt[unit_name]["power_setpoint"] = unit.power_setpoint h_dict_ccgt = unit.step(h_dict_ccgt) + self.efficiency = self.calculate_efficiency(self.power_output) + # Update h_dict with outputs h_dict[self.component_name]["power"] = self.power_output # h_dict[self.component_name]["state"] = self.state.value - # h_dict[self.component_name]["efficiency"] = self.efficiency + 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 @@ -160,7 +205,6 @@ def control(self, power_setpoint): self.units[self.steam_turbine_index].power_output = self.control_steam_turbine(self.units[self.steam_turbine_index].power_setpoint) return [unit.power_output for unit in self.units] - def control_steam_turbine(self, power_setpoint): """ @@ -176,31 +220,48 @@ def control_steam_turbine(self, power_setpoint): # If the gas turbine is off or starting up, the steam turbine should be off self.units[self.steam_turbine_index].can_start = False self.units[self.steam_turbine_index]._control(0.0) - # self.units[self.steam_turbine_index].starting_now = False elif (self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0 or self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.STOPPING): # If the gas turbine is stopping but the steam turbine is still producing power, we need to turn off the steam turbine self.units[self.steam_turbine_index]._control(0.0) - # self.units[self.steam_turbine_index].starting_now = False elif (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF): # If the gas turbine just turned on and the steam turbine is still off, we need to start up the steam turbine - # if (not self.units[self.steam_turbine_index].starting_now or - # not hasattr(self.units[self.steam_turbine_index], 'starting_now')): - # self.units[self.steam_turbine_index].time_in_state = 0.0 # Reset time in state to start the startup process - # self.units[self.steam_turbine_index].starting_now = True self.units[self.steam_turbine_index].can_start = ( self.units[self.steam_turbine_index].time_in_state >= self.units[self.steam_turbine_index].min_down_time ) self.units[self.steam_turbine_index]._control(power_setpoint) else: # Normal operation self.units[self.steam_turbine_index]._control(power_setpoint) - # self.units[self.steam_turbine_index].starting_now = False return self.units[self.steam_turbine_index].power_output # self.units[self.steam_turbine_index].state = "OFF" + def calculate_efficiency(self, power_output): + """Calculate HHV net efficiency based on current power output. + + Uses linear interpolation from the efficiency table. Values outside the + table range are clamped to the nearest endpoint. + + Args: + power_output (float): Current power output in kW. + + Returns: + float: HHV net efficiency as a fraction (0-1). + """ + if power_output <= 0: + # Return efficiency at lowest power fraction when off + return self.efficiency_values[0] + + # Calculate power fraction + power_fraction = power_output / self.rated_capacity + + # Interpolate efficiency (numpy.interp clamps to endpoints by default) + efficiency = np.interp( + power_fraction, self.efficiency_power_fraction, self.efficiency_values + ) + return efficiency From b1c7905fa4a10a4a637607d730b7b6be526c360a Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:39:34 -0600 Subject: [PATCH 26/38] Clean up example --- .../08_thermal_plant/hercules_runscript.py | 93 --------------- examples/08_thermal_plant/plot_outputs.py | 112 ------------------ .../hercules_input.yaml | 0 .../hercules_runscript.py | 80 +++++++++++++ .../plot_outputs.py | 96 +++++++++++++++ 5 files changed, 176 insertions(+), 205 deletions(-) delete mode 100644 examples/08_thermal_plant/hercules_runscript.py delete mode 100644 examples/08_thermal_plant/plot_outputs.py rename examples/{08_thermal_plant => 09_multiunit_thermal_plant}/hercules_input.yaml (100%) create mode 100644 examples/09_multiunit_thermal_plant/hercules_runscript.py create mode 100644 examples/09_multiunit_thermal_plant/plot_outputs.py diff --git a/examples/08_thermal_plant/hercules_runscript.py b/examples/08_thermal_plant/hercules_runscript.py deleted file mode 100644 index 5e25ae7c..00000000 --- a/examples/08_thermal_plant/hercules_runscript.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. - -This example demonstrates a simple open cycle gas turbine (OCGT) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerOCGT: - """Controller implementing the OCGT schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = 200000 # Hardcode for now. Likely need to get from - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 10 * 60: # 10 minutes in seconds - # Before 10 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 40 * 60: # 40 minutes in seconds - # Between 10 and 40 minutes: shut down - power_setpoint = 0.0 - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 40 and 120 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 180 * 60: # 180 minutes in seconds - # Between 120 and 180 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 210 * 60: # 210 minutes in seconds - # Between 180 and 210 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 240 * 60: # 240 minutes in seconds - # Between 210 and 240 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 240 minutes: shut down - power_setpoint = 0.0 - - # Split the setpoint between the units - power_setpoint_unit_1 = 0.6 * power_setpoint - power_setpoint_unit_2 = 0.4 * power_setpoint - - h_dict["thermal_power_plant"]["power_setpoints"] = [ - power_setpoint_unit_1, - power_setpoint_unit_2, - ] - h_dict["thermal_power_plant"]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/08_thermal_plant/plot_outputs.py b/examples/08_thermal_plant/plot_outputs.py deleted file mode 100644 index 52be236a..00000000 --- a/examples/08_thermal_plant/plot_outputs.py +++ /dev/null @@ -1,112 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -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 -print("Simulation Metadata:") -ho.print_metadata() -print() - -# 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.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.axhline( -# h_dict["thermal_power_plant"]["rated_capacity"] / 1000, -# color="gray", -# linestyle=":", -# label="Rated Capacity", -# ) -# ax.axhline( -# h_dict["thermal_power_plant"]["min_stable_load_fraction"] -# * h_dict["thermal_power_plant"]["rated_capacity"] -# / 1000, -# color="gray", -# linestyle="--", -# label="Minimum Stable Load", -# ) -# ax.set_ylabel("Power [MW]") -# ax.set_title("Open Cycle Gas Turbine Power Output") -# ax.legend() -# ax.grid(True) - -# # Plot the state -# ax = axarr[1] -# ax.plot(time_minutes, df["thermal_power_plant.state"], label="State", color="k") -# 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.set_title( -# "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -# ) -# ax.grid(True) - -# # Plot the efficiency -# ax = axarr[2] -# ax.plot( -# time_minutes, -# df["thermal_power_plant.efficiency"] * 100, -# label="Efficiency", -# color="g", -# ) -# ax.set_ylabel("Efficiency [%]") -# ax.set_title("Thermal Efficiency") -# ax.grid(True) - -# # Plot the fuel consumption -# ax = axarr[3] -# ax.plot( -# time_minutes, -# df["thermal_power_plant.fuel_volume_rate"], -# label="Fuel Volume Rate", -# color="orange", -# ) -# ax.set_ylabel("Fuel [m³/s]") -# ax.set_title("Fuel Volume Rate") -# ax.grid(True) - -# ax.set_xlabel("Time [minutes]") - -plt.tight_layout() -plt.show() diff --git a/examples/08_thermal_plant/hercules_input.yaml b/examples/09_multiunit_thermal_plant/hercules_input.yaml similarity index 100% rename from examples/08_thermal_plant/hercules_input.yaml rename to examples/09_multiunit_thermal_plant/hercules_input.yaml 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..199496bb --- /dev/null +++ b/examples/09_multiunit_thermal_plant/hercules_runscript.py @@ -0,0 +1,80 @@ +"""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"] + + + 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 both units at full capacity + self.power_setpoint_1 = self.unit_1_capacity + self.power_setpoint_2 = self.unit_2_capacity + elif current_time < 20 * 60: # 20 minutes in seconds + # Between 10 and 20 minutes: shut down unit 1, leave unit 2 + self.power_setpoint_1 = 0.0 + elif current_time < 40 * 60: # 40 minutes in seconds + # Shut down unit 2 + self.power_setpoint_2 = 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 + 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 + else: + # After 240 minutes: shut down + self.power_setpoint_1 = 0.0 + self.power_setpoint_2 = 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, + ] + + 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..8ababc34 --- /dev/null +++ b/examples/09_multiunit_thermal_plant/plot_outputs.py @@ -0,0 +1,96 @@ +# Plot the outputs of the simulation for the OCGT example + +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.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.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.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.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.set_ylabel("Fuel [m³/s]") +ax.grid(True) +ax.legend() + +plt.tight_layout() +plt.show() From a4fe75243b1d0c9732266ae1f94f126f6b54a14f Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:42:37 -0600 Subject: [PATCH 27/38] Add example doc --- docs/examples/09_multiunit_thermal_plant.md | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/examples/09_multiunit_thermal_plant.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..d8da5f47 --- /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 two 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 both 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 individual units are identical, but their commands and responses are tracked separately in the outputs. + +## 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 two 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 From 4c94aef66f243167c4774c035e6d8939a20aaa9b Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:44:50 -0600 Subject: [PATCH 28/38] Minor cleanup --- examples/09_multiunit_thermal_plant/hercules_runscript.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/09_multiunit_thermal_plant/hercules_runscript.py b/examples/09_multiunit_thermal_plant/hercules_runscript.py index 199496bb..e68ab739 100644 --- a/examples/09_multiunit_thermal_plant/hercules_runscript.py +++ b/examples/09_multiunit_thermal_plant/hercules_runscript.py @@ -1,7 +1,7 @@ """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 +The power setpoints are split unequally between the two units to demonstrate the ability of the model to specify setpoints of individual units. """ @@ -11,19 +11,16 @@ 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"] - def step(self, h_dict): current_time = h_dict["time"] From 0203b9d0d2b49d5d62d303ee9ec4bd6a91906770 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Mon, 9 Mar 2026 16:48:31 -0600 Subject: [PATCH 29/38] Readd steam turbine that was removed from mm-thermal branch --- .../11_steam_turbine_temp/hercules_input.yaml | 57 ++++++++++++ .../hercules_runscript.py | 85 +++++++++++++++++ .../11_steam_turbine_temp/plot_outputs.py | 92 +++++++++++++++++++ hercules/hybrid_plant.py | 2 + hercules/plant_components/steam_turbine.py | 54 +++++++++++ 5 files changed, 290 insertions(+) create mode 100644 examples/11_steam_turbine_temp/hercules_input.yaml create mode 100644 examples/11_steam_turbine_temp/hercules_runscript.py create mode 100644 examples/11_steam_turbine_temp/plot_outputs.py create mode 100644 hercules/plant_components/steam_turbine.py diff --git a/examples/11_steam_turbine_temp/hercules_input.yaml b/examples/11_steam_turbine_temp/hercules_input.yaml new file mode 100644 index 00000000..ee96c8a5 --- /dev/null +++ b/examples/11_steam_turbine_temp/hercules_input.yaml @@ -0,0 +1,57 @@ +# 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-01T18:00:00Z" # 18 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 30000 # kW (30 MW) + +steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.1 # 10% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.03 # 3%/min run up rate + hot_startup_time: 7200.0 # 2 hours + warm_startup_time: 14400.0 # 4 hours + cold_startup_time: 28800.0 # 8 hours + min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html + # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ + hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) + fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) + 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (100 MW) + +controller: + diff --git a/examples/11_steam_turbine_temp/hercules_runscript.py b/examples/11_steam_turbine_temp/hercules_runscript.py new file mode 100644 index 00000000..ee27217a --- /dev/null +++ b/examples/11_steam_turbine_temp/hercules_runscript.py @@ -0,0 +1,85 @@ +"""Example 07: Steam Turbine (ST) simulation. + +This example demonstrates a simple steam turbine (ST) that: +- Starts on at rated capacity (100 MW) +- At 10 minutes, receives a shutdown command and begins ramping down +- At ~20 minutes, reaches 0 MW and transitions to off +- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting +- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity +- At 120 minutes, receives a command to reduce power to 50% of rated capacity +- At 180 minutes, receives a command to reduce power to 10% of rated capacity + (note this is below the minimum stable load) +- At 210 minutes, receives a command to increase power to 100% of rated capacity +- At 240 minutes (4 hours), receives a shutdown command +- Simulation runs for 6 hours total with 1 minute time steps +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +class ControllerST: + """Controller implementing the steam turbine schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.rated_capacity = h_dict["steam_turbine"]["rated_capacity"] + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 30 * 60: # 30 minutes in seconds + # Before 30 minutes: run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 30 and 120 minutes: shut down + power_setpoint = 0.0 + elif current_time < 360 * 60: # 360 minutes in seconds + # Between 120 and 360 minutes: signal to run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 720 * 60: # 720 minutes in seconds + # Between 360 and 720 minutes: reduce power to 50% of rated capacity + power_setpoint = 0.5 * self.rated_capacity + elif current_time < 630 * 60: # 630 minutes in seconds + # Between 360 and 630 minutes: reduce power to 10% of rated capacity + power_setpoint = 0.1 * self.rated_capacity + elif current_time < 720 * 60: # 720 minutes in seconds + # Between 630 and 720 minutes: increase power to 100% of rated capacity + power_setpoint = self.rated_capacity + else: + # After 720 minutes: shut down + power_setpoint = 0.0 + + h_dict["steam_turbine"]["power_setpoint"] = power_setpoint + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerST(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/11_steam_turbine_temp/plot_outputs.py b/examples/11_steam_turbine_temp/plot_outputs.py new file mode 100644 index 00000000..d0802bf4 --- /dev/null +++ b/examples/11_steam_turbine_temp/plot_outputs.py @@ -0,0 +1,92 @@ +# Plot the outputs of the simulation for the OCGT example + +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 +print("Simulation Metadata:") +ho.print_metadata() +print() + +# 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["steam_turbine.power"] / 1000, label="Power Output", color="b") +ax.plot( + time_minutes, + df["steam_turbine.power_setpoint"] / 1000, + label="Power Setpoint", + color="r", + linestyle="--", +) +ax.axhline( + h_dict["steam_turbine"]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Rated Capacity", +) +ax.axhline( + h_dict["steam_turbine"]["min_stable_load_fraction"] + * h_dict["steam_turbine"]["rated_capacity"] + / 1000, + color="gray", + linestyle="--", + label="Minimum Stable Load", +) +ax.set_ylabel("Power [MW]") +ax.set_title("Open Cycle Gas Turbine Power Output") +ax.legend() +ax.grid(True) + +# Plot the state +ax = axarr[1] +ax.plot(time_minutes, df["steam_turbine.state"], label="State", color="k") +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.set_title( + "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +) +ax.grid(True) + +# Plot the efficiency +ax = axarr[2] +ax.plot( + time_minutes, + df["steam_turbine.efficiency"] * 100, + label="Efficiency", + color="g", +) +ax.set_ylabel("Efficiency [%]") +ax.set_title("Thermal Efficiency") +ax.grid(True) + +# Plot the fuel consumption +ax = axarr[3] +ax.plot( + time_minutes, + df["steam_turbine.fuel_volume_rate"], + label="Fuel Volume Rate", + color="orange", +) +ax.set_ylabel("Fuel [m³/s]") +ax.set_title("Fuel Volume Rate") +ax.grid(True) + +ax.set_xlabel("Time [minutes]") + +plt.tight_layout() +plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 779e01f0..5d8919fb 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -7,6 +7,7 @@ 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.steam_turbine import SteamTurbine from hercules.plant_components.thermal_plant import ThermalPlant from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant from hercules.plant_components.wind_farm import WindFarm @@ -25,6 +26,7 @@ "ThermalPlant": ThermalPlant, "HardCoalSteamTurbine": HardCoalSteamTurbine, "PowerPlayback": PowerPlayback, + "SteamTurbine": SteamTurbine, "CombinedCyclePlant": CombinedCyclePlant, } diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py new file mode 100644 index 00000000..83c2ac61 --- /dev/null +++ b/hercules/plant_components/steam_turbine.py @@ -0,0 +1,54 @@ +""" +Steam turbine model, for use as a standalone component or part of a multi-unit thermal plant. +""" + +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class SteamTurbine(ThermalComponentBase): + """Steam turbine model. + + This model represents an steam turbine with state management, ramp rate constraints, minimum + stable load, and fuel consumption tracking. Note it is a subclass of the ThermalComponentBase + class. + + All efficiency values are HHV (Higher Heating Value) net plant efficiencies. + """ + + def __init__(self, h_dict, component_name): + """Initialize the SteamTurbine class. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + Defaults are specified below. + component_name (str): Unique name for this component instance. + """ + + # Specify default parameter values + default_parameters_steam_turbine = { + "min_stable_load_fraction": 0.40, + "ramp_rate_fraction": 0.1, + "hot_startup_time": 420.0, + "warm_startup_time": 480.0, + "cold_startup_time": 480.0, + "min_up_time": 1800.0, + "min_down_time": 3600.0, + "hhv": 39050000, # J/m³ (39.05 + "fuel_density": 0.768, # kg/m³ + "efficiency_table": { + "power_fraction": [1.0, 0.75, 0.50, 0.4], + "efficiency": [0.14, 0.15, 0.165, 0.17], + }, + } + + # Update the input dictionary with default values for any missing parameters + h_dict[component_name] = default_parameters_steam_turbine | h_dict[component_name] + + # 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[component_name]: + h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][ + "ramp_rate_fraction" + ] + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) From 3e44f177ff257f84c7ed6d853841d2dfffa7d28f Mon Sep 17 00:00:00 2001 From: Frederik Date: Tue, 10 Mar 2026 10:23:29 -0600 Subject: [PATCH 30/38] More sophisticated efficiency calculations when unit is off or starting --- examples/10_combined_cycle_plant/plot_outputs.py | 3 ++- hercules/plant_components/combined_cycle_plant.py | 11 ++++++----- hercules/plant_components/thermal_component_base.py | 9 ++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py index af8232b7..f3a1ea15 100644 --- a/examples/10_combined_cycle_plant/plot_outputs.py +++ b/examples/10_combined_cycle_plant/plot_outputs.py @@ -2,6 +2,7 @@ import matplotlib.pyplot as plt from hercules import HerculesOutput +import numpy as np # Read the Hercules output file using HerculesOutput ho = HerculesOutput("outputs/hercules_output.h5") @@ -108,7 +109,7 @@ ax = axarr[2] ax.plot( time_minutes, - (df["combined_cycle_plant.OCGT.efficiency"] + df["combined_cycle_plant.ST.efficiency"]) * 100, + np.nansum([df["combined_cycle_plant.OCGT.efficiency"], df["combined_cycle_plant.ST.efficiency"]], axis=0) * 100, label="Efficiency", color="r", ) diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index 14f00b64..a738a301 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -236,8 +236,6 @@ def control_steam_turbine(self, power_setpoint): return self.units[self.steam_turbine_index].power_output - # self.units[self.steam_turbine_index].state = "OFF" - def calculate_efficiency(self, power_output): """Calculate HHV net efficiency based on current power output. @@ -250,9 +248,12 @@ def calculate_efficiency(self, power_output): Returns: float: HHV net efficiency as a fraction (0-1). """ - if power_output <= 0: - # Return efficiency at lowest power fraction when off - return self.efficiency_values[0] + if self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.OFF: + # Efficiency is not defined when off + return np.nan + elif power_output <= 0: + # Efficiency is 0 when not producing power (but not off) + return 0.0 # Calculate power fraction power_fraction = power_output / self.rated_capacity diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 5988fd03..bfb7fba5 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -603,9 +603,12 @@ def calculate_efficiency(self, power_output): Returns: float: HHV net efficiency as a fraction (0-1). """ - if power_output <= 0: - # Return efficiency at lowest power fraction when off - return self.efficiency_values[0] + if self.state == self.STATES.OFF: + # Efficiency is not defined when off + return np.nan + elif power_output <= 0: + # Efficiency is 0 when not producing power (but not off) + return 0.0 # Calculate power fraction power_fraction = power_output / self.rated_capacity From 85da590368406c20170196a88370ee428264e47c Mon Sep 17 00:00:00 2001 From: Frederik Date: Tue, 10 Mar 2026 16:43:47 -0600 Subject: [PATCH 31/38] Improved efficiency calculations --- .../hercules_input.yaml | 13 ++--- .../10_combined_cycle_plant/plot_outputs.py | 39 ++++----------- .../plant_components/combined_cycle_plant.py | 50 +++++++++++++++++-- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/examples/10_combined_cycle_plant/hercules_input.yaml b/examples/10_combined_cycle_plant/hercules_input.yaml index e753f0b6..cefeeb7a 100644 --- a/examples/10_combined_cycle_plant/hercules_input.yaml +++ b/examples/10_combined_cycle_plant/hercules_input.yaml @@ -50,10 +50,7 @@ combined_cycle_plant: - 0.245 log_channels: - power - - fuel_volume_rate - - fuel_mass_rate - state - - efficiency - power_setpoint initial_conditions: power: 70000 # Start ON at rated capacity (70 MW) @@ -86,10 +83,7 @@ combined_cycle_plant: - 0.17 log_channels: - power - - fuel_volume_rate - - fuel_mass_rate - state - - efficiency - power_setpoint initial_conditions: power: 30000 # Start ON at rated capacity (30 MW) @@ -123,10 +117,9 @@ combined_cycle_plant: - 0.47 log_channels: - # - power - # - fuel_volume_rate - # - fuel_mass_rate - # - state + - power + - fuel_volume_rate + - fuel_mass_rate - efficiency controller: diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py index f3a1ea15..eeca23e4 100644 --- a/examples/10_combined_cycle_plant/plot_outputs.py +++ b/examples/10_combined_cycle_plant/plot_outputs.py @@ -59,6 +59,8 @@ label="Power Output (ST)", color="b", ) +ax.set_ylabel("Power [MW]") +ax.grid(True) ax = axarr[1] ax.plot( @@ -96,40 +98,19 @@ ax.set_title("Thermal Efficiency") ax.grid(True) -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_minutes, - df["combined_cycle_plant.efficiency"] * 100, - label="Efficiency", - color="g", -) - -# Plot the efficiency of the OCGT and ST separately -ax = axarr[2] +# Plot the fuel consumption +ax = axarr[3] ax.plot( time_minutes, - np.nansum([df["combined_cycle_plant.OCGT.efficiency"], df["combined_cycle_plant.ST.efficiency"]], axis=0) * 100, - label="Efficiency", - color="r", + df["combined_cycle_plant.fuel_volume_rate"], + label="Fuel Volume Rate", + color="orange", ) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") +ax.set_ylabel("Fuel [m³/s]") +ax.set_title("Fuel Volume Rate") ax.grid(True) -# # Plot the fuel consumption -# ax = axarr[3] -# ax.plot( -# time_minutes, -# df["thermal_power_plant.fuel_volume_rate"], -# label="Fuel Volume Rate", -# color="orange", -# ) -# ax.set_ylabel("Fuel [m³/s]") -# ax.set_title("Fuel Volume Rate") -# ax.grid(True) - -# ax.set_xlabel("Time [minutes]") +ax.set_xlabel("Time [minutes]") plt.tight_layout() plt.show() diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index a738a301..95df62dc 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -161,12 +161,15 @@ def step(self, h_dict): self.efficiency = self.calculate_efficiency(self.power_output) + self.fuel_volume_rate = self.calculate_fuel_volume_rate(self.power_output) + self.fuel_mass_rate = self.fuel_volume_rate * self.units[self.gas_turbine_index].fuel_density + # Update h_dict with outputs h_dict[self.component_name]["power"] = self.power_output # h_dict[self.component_name]["state"] = self.state.value 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]["fuel_volume_rate"] = self.fuel_volume_rate + h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate return h_dict @@ -248,12 +251,29 @@ def calculate_efficiency(self, power_output): Returns: float: HHV net efficiency as a fraction (0-1). """ - if self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.OFF: + if self.units[self.gas_turbine_index].state == ( + self.units[self.gas_turbine_index].STATES.OFF + ): # Efficiency is not defined when off return np.nan + elif self.units[self.gas_turbine_index].state == ( + self.units[self.gas_turbine_index].STATES.STOPPING + ): + # Efficiency is not defined when stopping + return np.nan elif power_output <= 0: - # Efficiency is 0 when not producing power (but not off) + # Efficiency is 0 when gas turbine not producing power (but not off) return 0.0 + elif self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF: + # If the steam turbine is not on, we are just running the gas turbine, so efficiency is based on gas turbine power output + return self.units[self.gas_turbine_index].calculate_efficiency(self.units[self.gas_turbine_index].power_output) + elif self.units[self.steam_turbine_index].state != ( + self.units[self.steam_turbine_index].STATES.ON or + self.units[self.steam_turbine_index].STATES.STOPPING): + # If the steam turbine is starting up, it might be producing power, increasing the overall efficiency + efficiency_gas = self.units[self.gas_turbine_index].calculate_efficiency(self.units[self.gas_turbine_index].power_output) + fuel_used = (self.units[self.gas_turbine_index].power_output * 1000.0) / (efficiency_gas * self.units[self.gas_turbine_index].hhv) + return power_output * 1000.0 / (fuel_used * self.units[self.gas_turbine_index].hhv) # Calculate power fraction power_fraction = power_output / self.rated_capacity @@ -265,4 +285,26 @@ def calculate_efficiency(self, power_output): return efficiency + def calculate_fuel_volume_rate(self, power_output): + """Calculate fuel volume flow rate based on power output and HHV net efficiency. + + Args: + power_output (float): Current power output in kW. + + Returns: + float: Fuel volume flow rate in m³/s. + """ + if power_output <= 0: + return 0.0 + + # Calculate current HHV net efficiency + efficiency = self.calculate_efficiency(power_output) + + # Calculate fuel volume rate using HHV net efficiency + # fuel_volume_rate (m³/s) = power (W) / (efficiency * hhv (J/m³)) + # Convert power from kW to W (multiply by 1000) + fuel_m3_per_s = (power_output * 1000.0) / (efficiency * self.units[self.gas_turbine_index].hhv) + + return fuel_m3_per_s + From 061eda4d2b385acba0860243d8e8210551e72e01 Mon Sep 17 00:00:00 2001 From: Frederik Date: Wed, 11 Mar 2026 17:03:49 -0600 Subject: [PATCH 32/38] Unifying all thermal power plant examples into a single runscript --- .../hercules_runscript.py | 71 ++++++++++ .../input_files/hercules_input_hcst.yaml | 68 ++++++++++ .../input_files/hercules_input_mu-ccgt.yaml | 126 ++++++++++++++++++ .../input_files/hercules_input_mutp.yaml | 62 +++++++++ .../input_files/hercules_input_ocgt.yaml | 78 +++++++++++ .../input_files/hercules_input_st.yaml | 57 ++++++++ .../07_thermal_plant_examples/plot_outputs.py | 93 +++++++++++++ hercules/utilities.py | 29 ++++ 8 files changed, 584 insertions(+) create mode 100644 examples/07_thermal_plant_examples/hercules_runscript.py create mode 100644 examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml create mode 100644 examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml create mode 100644 examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml create mode 100644 examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml create mode 100644 examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml create mode 100644 examples/07_thermal_plant_examples/plot_outputs.py diff --git a/examples/07_thermal_plant_examples/hercules_runscript.py b/examples/07_thermal_plant_examples/hercules_runscript.py new file mode 100644 index 00000000..61666634 --- /dev/null +++ b/examples/07_thermal_plant_examples/hercules_runscript.py @@ -0,0 +1,71 @@ +"""Example 07: Thermal power plant simulation. + +This example demonstrates simple thermal units that follow a reference power setpoint. +The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the +controller follows that schedule. The outputs of the simulation are plotted in the +plot_outputs.py script. +The following thermal power plants are currently available for simulation: +- Hard Coal Steam Turbine (HCST) +- Open Cycle Gas Turbine (OCGT) +- Steam Turbine (ST) +- Combined Cycle Gas Turbine modeled as individual gas and steam turbines with a coupling constraint (MU-CCGT) +- Multi-unit thermal plant with 2 OCGTs (MUTP) +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +# Select which thermal plant you want to simulate by changing the yaml file +# Currenctly available: +# - hercules_input_hcst.yaml: Hard coal steam turbine (HCST) +# - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) +# - hercules_input_st.yaml: Steam Turbine (ST) +# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as +# individual gas and steam turbines with a coupling constraint +# - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs +hmodel = HerculesModel("input_files/hercules_input_hcst.yaml") + +class ControllerOCGT: + """Controller implementing the OCGT schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.component_name = h_dict["component_names"][0] + self.rated_capacity = h_dict[self.component_name]["rated_capacity"] + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on schedule provided in yaml file + time_index = sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 + power_setpoint = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] * self.rated_capacity + + h_dict[self.component_name]["power_setpoint"] = power_setpoint + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml b/examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml new file mode 100644 index 00000000..4bbc6f32 --- /dev/null +++ b/examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml @@ -0,0 +1,68 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_07_1 + +### +# Describe this simulation setup +description: Hard Coal Steam Turbine (HCST) Example + +dt: 60.0 # 1 minute time step +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC +endtime_utc: "2020-01-10T00:00:00Z" # 10 days later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 500000 # kW (500 MW) + # Define the power setpoint schedule for the plant + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 43200 + - 129600 + - 388800 + - 561600 + - 648000 + - 777600 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 + +hard_coal_steam_turbine: + component_type: HardCoalSteamTurbine + rated_capacity: 500000 # kW (500 MW) + min_stable_load_fraction: 0.3 # 30% minimum operating point + ramp_rate_fraction: 0.03 # 3%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 27000.0 # 7.5 hours + warm_startup_time: 27000.0 # 7.5 hours + cold_startup_time: 27000.0 # 7.5 hours + min_up_time: 172800 # 48 hours + min_down_time: 172800 # 48 hour + efficiency_table: + power_fraction: + - 1.0 + - 0.50 + - 0.30 + efficiency: # HHV net plant efficiency, fractions (0-1) + - 0.35 + - 0.32 + - 0.32 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 500000 # Start ON at rated capacity + +controller: diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml b/examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml new file mode 100644 index 00000000..cefeeb7a --- /dev/null +++ b/examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml @@ -0,0 +1,126 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_10 + +### +# Describe this simulation setup +description: Combined Cycle Gas Turbine (CCGT) Example + +dt: 60.0 # 1 minute time step +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC +endtime_utc: "2020-01-01T10:00:00Z" # 10 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 100000 # kW (100 MW) + +combined_cycle_plant: + component_type: CombinedCyclePlant + units: ["open_cycle_gas_turbine", "steam_turbine"] + unit_names: ["OCGT", "ST"] + + open_cycle_gas_turbine: + component_type: OpenCycleGasTurbine + rated_capacity: 70000 # kW (70 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: 1800.0 # 30 minutes + warm_startup_time: 2700.0 # 45 minutes + cold_startup_time: 2700.0 # 45 minutes + min_up_time: 14400 # 4 hour + min_down_time: 7200 # 2 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 + - state + - power_setpoint + initial_conditions: + power: 70000 # Start ON at rated capacity (70 MW) + + steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 3600.0 # 1 hour + warm_startup_time: 7200.0 # 2 hours + cold_startup_time: 14400.0 # 4 hours + # 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - state + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (30 MW) + + efficiency_table: + power_fraction: + - 1.0 + - 0.95 + - 0.90 + - 0.85 + - 0.80 + - 0.75 + - 0.7 + - 0.65 + - 0.6 + - 0.55 + - 0.50 + - 0.4 + efficiency: # HHV net plant efficiency, fractions (0-1), from CC1A-F curve in Exhibit ES-4 of [5] + - 0.53 + - 0.515 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.515 + - 0.505 + - 0.5 + - 0.49 + - 0.47 + + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - efficiency + +controller: + diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml b/examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml new file mode 100644 index 00000000..fad9e586 --- /dev/null +++ b/examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml @@ -0,0 +1,62 @@ +# 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_turbine", "open_cycle_gas_turbine"] + unit_names: ["OCGT1", "OCGT2"] + + open_cycle_gas_turbine: + 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) + +controller: + diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml b/examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml new file mode 100644 index 00000000..29aacff5 --- /dev/null +++ b/examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml @@ -0,0 +1,78 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_07_2 + +### +# 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) + + # Define the power setpoint schedule for the plant + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 600 + - 2400 + - 7200 + - 10800 + - 12600 + - 14400 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 + +open_cycle_gas_turbine: + 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) + +controller: + diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml b/examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml new file mode 100644 index 00000000..ee96c8a5 --- /dev/null +++ b/examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml @@ -0,0 +1,57 @@ +# 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-01T18:00:00Z" # 18 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 30000 # kW (30 MW) + +steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.1 # 10% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.03 # 3%/min run up rate + hot_startup_time: 7200.0 # 2 hours + warm_startup_time: 14400.0 # 4 hours + cold_startup_time: 28800.0 # 8 hours + min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html + # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ + hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) + fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) + 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (100 MW) + +controller: + diff --git a/examples/07_thermal_plant_examples/plot_outputs.py b/examples/07_thermal_plant_examples/plot_outputs.py new file mode 100644 index 00000000..2171145c --- /dev/null +++ b/examples/07_thermal_plant_examples/plot_outputs.py @@ -0,0 +1,93 @@ +# Plot the outputs of the simulation for the OCGT example + +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 +print("Simulation Metadata:") +ho.print_metadata() +print() + +# Create a shortcut to the dataframe +df = ho.df + +# Get the h_dict from metadata +h_dict = ho.h_dict +component_name = h_dict["component_names"][0] + +# 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[f"{component_name}.power"] / 1000, label="Power Output", color="b") +ax.plot( + time_minutes, + df[f"{component_name}.power_setpoint"] / 1000, + label="Power Setpoint", + color="r", + linestyle="--", +) +ax.axhline( + h_dict[component_name]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Rated Capacity", +) +ax.axhline( + h_dict[component_name]["min_stable_load_fraction"] + * h_dict[component_name]["rated_capacity"] + / 1000, + color="gray", + linestyle="--", + label="Minimum Stable Load", +) +ax.set_ylabel("Power [MW]") +ax.set_title("Thermal Power Plant Output") +ax.legend() +ax.grid(True) + +# Plot the state +ax = axarr[1] +ax.plot(time_minutes, df[f"{component_name}.state"], label="State", color="k") +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.set_title( + "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" +) +ax.grid(True) + +# Plot the efficiency +ax = axarr[2] +ax.plot( + time_minutes, + df[f"{component_name}.efficiency"] * 100, + label="Efficiency", + color="g", +) +ax.set_ylabel("Efficiency [%]") +ax.set_title("Thermal Efficiency") +ax.grid(True) + +# Plot the fuel consumption +ax = axarr[3] +ax.plot( + time_minutes, + df[f"{component_name}.fuel_volume_rate"], + label="Fuel Volume Rate", + color="orange", +) +ax.set_ylabel("Fuel [m³/s]") +ax.set_title("Fuel Volume Rate") +ax.grid(True) + +ax.set_xlabel("Time [minutes]") + +plt.tight_layout() +plt.show() diff --git a/hercules/utilities.py b/hercules/utilities.py index d8a9eb82..54ce271c 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -226,6 +226,7 @@ def load_hercules_input(filename): "external_data", "output_use_compression", "output_buffer_size", + "power_setpoint_schedule", ] # Discover component entries: any top-level dict entry containing "component_type" @@ -268,6 +269,34 @@ 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}") + # Pass through optional power setpoint schedule + if "power_setpoint_schedule" in h_dict["plant"]: + schedule = h_dict["plant"]["power_setpoint_schedule"] + if not isinstance(schedule, dict): + raise ValueError(f"power_setpoint_schedule must be a dictionary in input file {filename}") + if "time" not in schedule or "power_setpoint_fraction" not in schedule: + raise ValueError( + f"power_setpoint_schedule must contain 'time' and 'power_setpoint_fraction' keys " + f"in input file {filename}" + ) + if len(schedule["time"]) != len(schedule["power_setpoint_fraction"]): + raise ValueError( + f"'time' and 'power_setpoint_fraction' lists in power_setpoint_schedule must be the same length " + f"in input file {filename}" + ) + # Validate time and power_setpoint types + if not all(isinstance(t, (float, int)) for t in schedule["time"]): + raise ValueError( + f"All entries in power_setpoint_schedule 'time' list must be floats or ints " + f"in input file {filename}" + ) + if not all(isinstance(p, (float, int)) for p in schedule["power_setpoint_fraction"]): + raise ValueError( + f"All entries in power_setpoint_schedule 'power_setpoint_fraction' list must be floats or ints " + f"in input file {filename}" + ) + + # 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: From 0cfdab556874032ac0f43198e415144edab3a029 Mon Sep 17 00:00:00 2001 From: Frederik Date: Fri, 13 Mar 2026 13:47:23 -0600 Subject: [PATCH 33/38] Grouped thermal plant examples together --- .../hercules_input.yaml | 57 -------- .../hercules_runscript.py | 85 ------------ .../07_open_cycle_gas_turbine/plot_outputs.py | 92 ------------- .../hercules_runscript.py | 2 +- .../input_files/hercules_input_hcst.yaml | 0 .../input_files/hercules_input_ocgt.yaml | 0 .../input_files/hercules_input_st.yaml | 22 ++- .../plot_outputs.py | 2 +- .../hercules_input.yaml | 50 ------- .../hercules_runscript.py | 92 ------------- .../plot_outputs.py | 95 ------------- .../hercules_runscript.py | 85 ++++++++++++ .../input_files/hercules_input_mu-ccgt.yaml | 17 +++ .../input_files/hercules_input_mutp.yaml | 19 +++ .../plot_outputs.py | 96 +++++++++++++ .../hercules_input.yaml | 62 --------- .../hercules_runscript.py | 77 ----------- .../plot_outputs.py | 96 ------------- .../hercules_input.yaml | 126 ------------------ .../hercules_runscript.py | 84 ------------ .../10_combined_cycle_plant/plot_outputs.py | 116 ---------------- .../11_steam_turbine_temp/hercules_input.yaml | 57 -------- .../hercules_runscript.py | 85 ------------ .../11_steam_turbine_temp/plot_outputs.py | 92 ------------- hercules/utilities.py | 4 +- 25 files changed, 241 insertions(+), 1272 deletions(-) delete mode 100644 examples/07_open_cycle_gas_turbine/hercules_input.yaml delete mode 100644 examples/07_open_cycle_gas_turbine/hercules_runscript.py delete mode 100644 examples/07_open_cycle_gas_turbine/plot_outputs.py rename examples/{07_thermal_plant_examples => 07_thermal_plants}/hercules_runscript.py (98%) rename examples/{07_thermal_plant_examples => 07_thermal_plants}/input_files/hercules_input_hcst.yaml (100%) rename examples/{07_thermal_plant_examples => 07_thermal_plants}/input_files/hercules_input_ocgt.yaml (100%) rename examples/{07_thermal_plant_examples => 07_thermal_plants}/input_files/hercules_input_st.yaml (81%) rename examples/{07_thermal_plant_examples => 07_thermal_plants}/plot_outputs.py (99%) delete mode 100644 examples/08_hard_coal_steam_turbine/hercules_input.yaml delete mode 100644 examples/08_hard_coal_steam_turbine/hercules_runscript.py delete mode 100644 examples/08_hard_coal_steam_turbine/plot_outputs.py create mode 100644 examples/08_multi_unit_thermal_plants/hercules_runscript.py rename examples/{07_thermal_plant_examples => 08_multi_unit_thermal_plants}/input_files/hercules_input_mu-ccgt.yaml (92%) rename examples/{07_thermal_plant_examples => 08_multi_unit_thermal_plants}/input_files/hercules_input_mutp.yaml (83%) create mode 100644 examples/08_multi_unit_thermal_plants/plot_outputs.py delete mode 100644 examples/09_multiunit_thermal_plant/hercules_input.yaml delete mode 100644 examples/09_multiunit_thermal_plant/hercules_runscript.py delete mode 100644 examples/09_multiunit_thermal_plant/plot_outputs.py delete mode 100644 examples/10_combined_cycle_plant/hercules_input.yaml delete mode 100644 examples/10_combined_cycle_plant/hercules_runscript.py delete mode 100644 examples/10_combined_cycle_plant/plot_outputs.py delete mode 100644 examples/11_steam_turbine_temp/hercules_input.yaml delete mode 100644 examples/11_steam_turbine_temp/hercules_runscript.py delete mode 100644 examples/11_steam_turbine_temp/plot_outputs.py diff --git a/examples/07_open_cycle_gas_turbine/hercules_input.yaml b/examples/07_open_cycle_gas_turbine/hercules_input.yaml deleted file mode 100644 index a52ea403..00000000 --- a/examples/07_open_cycle_gas_turbine/hercules_input.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# 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) - -open_cycle_gas_turbine: - 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) - -controller: - diff --git a/examples/07_open_cycle_gas_turbine/hercules_runscript.py b/examples/07_open_cycle_gas_turbine/hercules_runscript.py deleted file mode 100644 index e0d00879..00000000 --- a/examples/07_open_cycle_gas_turbine/hercules_runscript.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. - -This example demonstrates a simple open cycle gas turbine (OCGT) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerOCGT: - """Controller implementing the OCGT schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"] - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 10 * 60: # 10 minutes in seconds - # Before 10 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 40 * 60: # 40 minutes in seconds - # Between 10 and 40 minutes: shut down - power_setpoint = 0.0 - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 40 and 120 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 180 * 60: # 180 minutes in seconds - # Between 120 and 180 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 210 * 60: # 210 minutes in seconds - # Between 180 and 210 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 240 * 60: # 240 minutes in seconds - # Between 210 and 240 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 240 minutes: shut down - power_setpoint = 0.0 - - h_dict["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/07_open_cycle_gas_turbine/plot_outputs.py b/examples/07_open_cycle_gas_turbine/plot_outputs.py deleted file mode 100644 index 292ec11c..00000000 --- a/examples/07_open_cycle_gas_turbine/plot_outputs.py +++ /dev/null @@ -1,92 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -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 -print("Simulation Metadata:") -ho.print_metadata() -print() - -# 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["open_cycle_gas_turbine.power"] / 1000, label="Power Output", color="b") -ax.plot( - time_minutes, - df["open_cycle_gas_turbine.power_setpoint"] / 1000, - label="Power Setpoint", - color="r", - linestyle="--", -) -ax.axhline( - h_dict["open_cycle_gas_turbine"]["rated_capacity"] / 1000, - color="gray", - linestyle=":", - label="Rated Capacity", -) -ax.axhline( - h_dict["open_cycle_gas_turbine"]["min_stable_load_fraction"] - * h_dict["open_cycle_gas_turbine"]["rated_capacity"] - / 1000, - color="gray", - linestyle="--", - label="Minimum Stable Load", -) -ax.set_ylabel("Power [MW]") -ax.set_title("Open Cycle Gas Turbine Power Output") -ax.legend() -ax.grid(True) - -# Plot the state -ax = axarr[1] -ax.plot(time_minutes, df["open_cycle_gas_turbine.state"], label="State", color="k") -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.set_title( - "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -) -ax.grid(True) - -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_minutes, - df["open_cycle_gas_turbine.efficiency"] * 100, - label="Efficiency", - color="g", -) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") -ax.grid(True) - -# Plot the fuel consumption -ax = axarr[3] -ax.plot( - time_minutes, - df["open_cycle_gas_turbine.fuel_volume_rate"], - label="Fuel Volume Rate", - color="orange", -) -ax.set_ylabel("Fuel [m³/s]") -ax.set_title("Fuel Volume Rate") -ax.grid(True) - -ax.set_xlabel("Time [minutes]") - -plt.tight_layout() -plt.show() diff --git a/examples/07_thermal_plant_examples/hercules_runscript.py b/examples/07_thermal_plants/hercules_runscript.py similarity index 98% rename from examples/07_thermal_plant_examples/hercules_runscript.py rename to examples/07_thermal_plants/hercules_runscript.py index 61666634..692ade07 100644 --- a/examples/07_thermal_plant_examples/hercules_runscript.py +++ b/examples/07_thermal_plants/hercules_runscript.py @@ -68,4 +68,4 @@ def step(self, h_dict): # Run the simulation hmodel.run() -hmodel.logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") \ No newline at end of file diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml b/examples/07_thermal_plants/input_files/hercules_input_hcst.yaml similarity index 100% rename from examples/07_thermal_plant_examples/input_files/hercules_input_hcst.yaml rename to examples/07_thermal_plants/input_files/hercules_input_hcst.yaml diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml b/examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml similarity index 100% rename from examples/07_thermal_plant_examples/input_files/hercules_input_ocgt.yaml rename to examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml b/examples/07_thermal_plants/input_files/hercules_input_st.yaml similarity index 81% rename from examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml rename to examples/07_thermal_plants/input_files/hercules_input_st.yaml index ee96c8a5..a935974c 100644 --- a/examples/07_thermal_plant_examples/input_files/hercules_input_st.yaml +++ b/examples/07_thermal_plants/input_files/hercules_input_st.yaml @@ -2,11 +2,11 @@ # Explicitly specify the parameters for demonstration purposes # Name -name: example_07 +name: example_07_2 ### # Describe this simulation setup -description: Open Cycle Gas Turbine (OCGT) Example +description: Steam Turbine (ST) Example dt: 60.0 # 1 minute time step starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC @@ -16,6 +16,24 @@ log_every_n: 1 plant: interconnect_limit: 30000 # kW (30 MW) + # Define the power setpoint schedule for the plant + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 1800 + - 7200 + - 28800 + - 37800 + - 54000 + - 61200 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 steam_turbine: component_type: SteamTurbine diff --git a/examples/07_thermal_plant_examples/plot_outputs.py b/examples/07_thermal_plants/plot_outputs.py similarity index 99% rename from examples/07_thermal_plant_examples/plot_outputs.py rename to examples/07_thermal_plants/plot_outputs.py index 2171145c..fdcb4a55 100644 --- a/examples/07_thermal_plant_examples/plot_outputs.py +++ b/examples/07_thermal_plants/plot_outputs.py @@ -90,4 +90,4 @@ ax.set_xlabel("Time [minutes]") plt.tight_layout() -plt.show() +plt.show() \ No newline at end of file diff --git a/examples/08_hard_coal_steam_turbine/hercules_input.yaml b/examples/08_hard_coal_steam_turbine/hercules_input.yaml deleted file mode 100644 index 7ed338a8..00000000 --- a/examples/08_hard_coal_steam_turbine/hercules_input.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Input YAML for hercules -# Explicitly specify the parameters for demonstration purposes - -# Name -name: example_08 - -### -# Describe this simulation setup -description: Hard Coal Steam Turbine (HCST) Example - -dt: 60.0 # 1 minute time step -starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC -endtime_utc: "2020-01-10T00:00:00Z" # 10 days later -verbose: False -log_every_n: 1 - -plant: - interconnect_limit: 500000 # kW (500 MW) - -hard_coal_steam_turbine: - component_type: HardCoalSteamTurbine - rated_capacity: 500000 # kW (500 MW) - min_stable_load_fraction: 0.3 # 30% minimum operating point - ramp_rate_fraction: 0.03 # 3%/min ramp rate - run_up_rate_fraction: 0.02 # 2%/min run up rate - hot_startup_time: 27000.0 # 7.5 hours - warm_startup_time: 27000.0 # 7.5 hours - cold_startup_time: 27000.0 # 7.5 hours - min_up_time: 172800 # 48 hours - min_down_time: 172800 # 48 hour - efficiency_table: - power_fraction: - - 1.0 - - 0.50 - - 0.30 - efficiency: # HHV net plant efficiency, fractions (0-1) - - 0.35 - - 0.32 - - 0.32 - log_channels: - - power - - fuel_volume_rate - - fuel_mass_rate - - state - - efficiency - - power_setpoint - initial_conditions: - power: 500000 # Start ON at rated capacity - -controller: diff --git a/examples/08_hard_coal_steam_turbine/hercules_runscript.py b/examples/08_hard_coal_steam_turbine/hercules_runscript.py deleted file mode 100644 index 853ca488..00000000 --- a/examples/08_hard_coal_steam_turbine/hercules_runscript.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Example 08: Hard Coal Steam Turbine (HCST) simulation. - -This example demonstrates a simple hard coal steam turbine (HCST) that: -- Starts on at rated capacity -- Receives a shutdown command and begins ramping down -- Transitions to off -- Receives a turn-on command with a setpoint of 100% of rated capacity -- Minimum down-time requirement is reached and the turbine begins ramping up -- Ramps up to 100% of rated capacity -- Receives a command to reduce power to 50% of rated capacity -- Receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- Receives a command to increase power to 100% of rated capacity -- Receives a shutdown command -- Simulation runs for 10 days total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerHCST: - """Controller implementing the HCST schedule described in the module docstring.""" - - def __init__(self, h_dict, component_name="hard_coal_steam_turbine"): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.component_name = component_name - self.rated_capacity = h_dict[self.component_name]["rated_capacity"] - - simulation_length = h_dict["endtime_utc"] - h_dict["starttime_utc"] - self.total_simulation_time = simulation_length.total_seconds() - - def step(self, h_dict): - """Execute one control step. - This controller is scaled by the total simulation time, pulled from the h_dict - This preserves the relative distance between control actions, but changes the - simulation times that they are applied. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 0.05 * self.total_simulation_time: - # First 5% of simulation time, run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 0.15 * self.total_simulation_time: - # Between 5% and 15% of simulation time: shut down - power_setpoint = 0.0 - elif current_time < 0.45 * self.total_simulation_time: - # Between 15% and 45% of simulation time: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 0.65 * self.total_simulation_time: - # Between 45% and 65% of simulation time: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 0.75 * self.total_simulation_time: - # Between 65% and 75% of simulation time: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 0.9 * self.total_simulation_time: # - # Between 75% and 90% of simulation time: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 90% of simulation time: shut down - power_setpoint = 0.0 - - h_dict[self.component_name]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerHCST(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/08_hard_coal_steam_turbine/plot_outputs.py b/examples/08_hard_coal_steam_turbine/plot_outputs.py deleted file mode 100644 index f1d327bd..00000000 --- a/examples/08_hard_coal_steam_turbine/plot_outputs.py +++ /dev/null @@ -1,95 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -import matplotlib.pyplot as plt -from hercules import HerculesOutput - -# Read the Hercules output file using HerculesOutput -ho = HerculesOutput("outputs/hercules_output.h5") -component_name = "hard_coal_steam_turbine" # Change to "open_cycle_gas_turbine" if needed - -# Print metadata information -print("Simulation Metadata:") -ho.print_metadata() -print() - -# Create a shortcut to the dataframe -df = ho.df - -# Get the h_dict from metadata -h_dict = ho.h_dict - -# Convert time to hours for easier reading -time_hours = df["time"] / 60 / 60 - -print("TONNES OF COAL USED:", df[component_name + ".fuel_volume_rate"].sum() * h_dict["dt"]) - -fig, axarr = plt.subplots(4, 1, sharex=True, figsize=(10, 10)) - -# Plot the power output and setpoint -ax = axarr[0] -ax.plot(time_hours, df[component_name + ".power"] / 1000, label="Power Output", color="b") -ax.plot( - time_hours, - df[component_name + ".power_setpoint"] / 1000, - label="Power Setpoint", - color="r", - linestyle="--", -) -ax.axhline( - h_dict[component_name]["rated_capacity"] / 1000, - color="gray", - linestyle=":", - label="Rated Capacity", -) -ax.axhline( - h_dict[component_name]["min_stable_load_fraction"] - * h_dict[component_name]["rated_capacity"] - / 1000, - color="gray", - linestyle="--", - label="Minimum Stable Load", -) -ax.set_ylabel("Power [MW]") -ax.set_title("Open Cycle Gas Turbine Power Output") -ax.legend() -ax.grid(True) - -# Plot the state -ax = axarr[1] -ax.plot(time_hours, df[component_name + ".state"], label="State", color="k") -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.set_title( - "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -) -ax.grid(True) - -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_hours, - df[component_name + ".efficiency"] * 100, - label="Efficiency", - color="g", -) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") -ax.grid(True) - -# Plot the fuel consumption -ax = axarr[3] -ax.plot( - time_hours, - df[component_name + ".fuel_volume_rate"], - label="Fuel Volume Rate", - color="orange", -) -ax.set_ylabel("Fuel [m³/s]") -ax.set_title("Fuel Volume Rate") -ax.grid(True) - -ax.set_xlabel("Time [hours]") - -plt.tight_layout() -plt.show() diff --git a/examples/08_multi_unit_thermal_plants/hercules_runscript.py b/examples/08_multi_unit_thermal_plants/hercules_runscript.py new file mode 100644 index 00000000..9963e411 --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/hercules_runscript.py @@ -0,0 +1,85 @@ +"""Example 07: Thermal power plant simulation. + +This example demonstrates simple thermal units that follow a reference power setpoint. +The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the +controller follows that schedule. The outputs of the simulation are plotted in the +plot_outputs.py script. +The following thermal power plants are currently available for simulation: +- Hard Coal Steam Turbine (HCST) +- Open Cycle Gas Turbine (OCGT) +- Steam Turbine (ST) +- Combined Cycle Gas Turbine modeled as individual gas and steam turbines with a coupling constraint (MU-CCGT) +- Multi-unit thermal plant with 2 OCGTs (MUTP) +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory +import copy + +prepare_output_directory() + +# Initialize the Hercules model +# Select which thermal plant you want to simulate by changing the yaml file +# Currenctly available: +# - hercules_input_hcst.yaml: Hard coal steam turbine (HCST) +# - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) +# - hercules_input_st.yaml: Steam Turbine (ST) +# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as +# individual gas and steam turbines with a coupling constraint +# - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs +hmodel = HerculesModel("input_files/hercules_input_mutp.yaml") + +class ControllerTPP: + """Controller implementing the thermal power plant schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.component_name = h_dict["component_names"][0] + self.unit_capacities = [h_dict[self.component_name][unit_name]["rated_capacity"] + for unit_name in h_dict[self.component_name]["unit_names"]] + self.rated_capacity = sum(self.unit_capacities) + h_dict[self.component_name]["rated_capacity"] = self.rated_capacity + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on schedule provided in yaml file + time_index = sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 + + # If the power setpoint fraction is provided as a list for each unit, use it directly. Otherwise, assume it's a fraction of the + # total rated capacity and distribute it proportionally to the unit capacities. + if isinstance(h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index], (list, tuple)): + power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] + power_setpoints = [power_setpoint * unit_capacity + for power_setpoint, unit_capacity in + zip(power_setpoint_fraction, self.unit_capacities) + ] + h_dict[self.component_name]["power_setpoints"] = power_setpoints + else: + power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] + h_dict[self.component_name]["power_setpoint"] = power_setpoint_fraction * self.rated_capacity + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerTPP(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml similarity index 92% rename from examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml rename to examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml index cefeeb7a..2dd6100a 100644 --- a/examples/07_thermal_plant_examples/input_files/hercules_input_mu-ccgt.yaml +++ b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml @@ -16,6 +16,23 @@ log_every_n: 1 plant: interconnect_limit: 100000 # kW (100 MW) + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 600 + - 3600 + - 15600 + - 21600 + - 28800 + - 32400 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 combined_cycle_plant: component_type: CombinedCyclePlant diff --git a/examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml similarity index 83% rename from examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml rename to examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml index fad9e586..e7ae3d9b 100644 --- a/examples/07_thermal_plant_examples/input_files/hercules_input_mutp.yaml +++ b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml @@ -16,6 +16,25 @@ log_every_n: 1 plant: interconnect_limit: 100000 # kW (100 MW) + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 600 + - 1200 + - 2400 + - 7200 + - 10800 + - 12600 + - 14400 + power_setpoint_fraction: + - [1.0, 1.0] + - [0.0, 1.0] + - [0.0, 0.0] + - [1.0, 1.0] + - [0.5, 1.0] + - [0.1, 1.0] + - [0.5, 0.5] + - [0.0, 0.0] thermal_power_plant: component_type: ThermalPlant diff --git a/examples/08_multi_unit_thermal_plants/plot_outputs.py b/examples/08_multi_unit_thermal_plants/plot_outputs.py new file mode 100644 index 00000000..4eb1dde7 --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/plot_outputs.py @@ -0,0 +1,96 @@ +# Plot the outputs of the simulation for the OCGT example + +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 + +component_name = h_dict["component_names"][0] +unit_names = h_dict[component_name]["unit_names"] + +# 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[f"{component_name}.power"] / 1000, label="Power Output", color="k") +for k, unit_name in enumerate(unit_names): + ax = axarr[0] + ax.plot( + time_minutes, + df[f"{component_name}.{unit_name}.power_setpoint"] / 1000, + label=f"Power setpoint ({unit_name})", + color="C"+str(k), + linestyle="--", + ) + ax.plot( + time_minutes, + df[f"{component_name}.{unit_name}.power"] / 1000, + label=f"Power output ({unit_name})", + color="C"+str(k), + ) + ax.axhline( + h_dict[component_name][unit_name]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Unit rated capacity", + ) + + # Plot the state of each unit + ax = axarr[1] + ax.plot(time_minutes, df[f"{component_name}.{unit_name}.state"], label=unit_name, color="C"+str(k)) + 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() + +ax = axarr[0] +ax.axhline( + h_dict[component_name]["rated_capacity"] / 1000, + color="black", + linestyle=":", + label="Plant rated capacity", +) +ax.set_ylabel("Power [MW]") +ax.legend() +ax.grid(True) +ax.set_xlim(0, time_minutes.iloc[-1]) + +# Plot the efficiency of each unit +ax = axarr[2] +try: + for k, unit_name in enumerate(unit_names): + ax.plot(time_minutes, df[f"{component_name}.{unit_name}.efficiency"] * 100, label=unit_name, color="C"+str(k)) +except: + ax.plot(time_minutes, df[f"{component_name}.efficiency"] * 100, label="Efficiency", color="g") + +# Fuel consumption +ax = axarr[3] +try: + for k, unit_name in enumerate(unit_names): + ax.plot(time_minutes, df[f"{component_name}.{unit_name}.fuel_volume_rate"], label=unit_name, color="C"+str(k)) +except: + ax.plot(time_minutes, df[f"{component_name}.fuel_volume_rate"], label="Fuel Volume Rate", color="orange") +ax.set_ylabel("Fuel [m³/s]") +ax.grid(True) +ax.legend() + +ax.set_ylabel("Thermal efficiency [%]") +ax.grid(True) +ax.legend() + +plt.tight_layout() +plt.show() diff --git a/examples/09_multiunit_thermal_plant/hercules_input.yaml b/examples/09_multiunit_thermal_plant/hercules_input.yaml deleted file mode 100644 index fad9e586..00000000 --- a/examples/09_multiunit_thermal_plant/hercules_input.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# 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_turbine", "open_cycle_gas_turbine"] - unit_names: ["OCGT1", "OCGT2"] - - open_cycle_gas_turbine: - 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) - -controller: - diff --git a/examples/09_multiunit_thermal_plant/hercules_runscript.py b/examples/09_multiunit_thermal_plant/hercules_runscript.py deleted file mode 100644 index e68ab739..00000000 --- a/examples/09_multiunit_thermal_plant/hercules_runscript.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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"] - - 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 both units at full capacity - self.power_setpoint_1 = self.unit_1_capacity - self.power_setpoint_2 = self.unit_2_capacity - elif current_time < 20 * 60: # 20 minutes in seconds - # Between 10 and 20 minutes: shut down unit 1, leave unit 2 - self.power_setpoint_1 = 0.0 - elif current_time < 40 * 60: # 40 minutes in seconds - # Shut down unit 2 - self.power_setpoint_2 = 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 - 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 - else: - # After 240 minutes: shut down - self.power_setpoint_1 = 0.0 - self.power_setpoint_2 = 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, - ] - - 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 deleted file mode 100644 index 8ababc34..00000000 --- a/examples/09_multiunit_thermal_plant/plot_outputs.py +++ /dev/null @@ -1,96 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -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.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.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.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.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.set_ylabel("Fuel [m³/s]") -ax.grid(True) -ax.legend() - -plt.tight_layout() -plt.show() diff --git a/examples/10_combined_cycle_plant/hercules_input.yaml b/examples/10_combined_cycle_plant/hercules_input.yaml deleted file mode 100644 index cefeeb7a..00000000 --- a/examples/10_combined_cycle_plant/hercules_input.yaml +++ /dev/null @@ -1,126 +0,0 @@ -# Input YAML for hercules -# Explicitly specify the parameters for demonstration purposes - -# Name -name: example_10 - -### -# Describe this simulation setup -description: Combined Cycle Gas Turbine (CCGT) Example - -dt: 60.0 # 1 minute time step -starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC -endtime_utc: "2020-01-01T10:00:00Z" # 10 hours later -verbose: False -log_every_n: 1 - -plant: - interconnect_limit: 100000 # kW (100 MW) - -combined_cycle_plant: - component_type: CombinedCyclePlant - units: ["open_cycle_gas_turbine", "steam_turbine"] - unit_names: ["OCGT", "ST"] - - open_cycle_gas_turbine: - component_type: OpenCycleGasTurbine - rated_capacity: 70000 # kW (70 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: 1800.0 # 30 minutes - warm_startup_time: 2700.0 # 45 minutes - cold_startup_time: 2700.0 # 45 minutes - min_up_time: 14400 # 4 hour - min_down_time: 7200 # 2 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 - - state - - power_setpoint - initial_conditions: - power: 70000 # Start ON at rated capacity (70 MW) - - steam_turbine: - component_type: SteamTurbine - rated_capacity: 30000 # kW (30 MW) - min_stable_load_fraction: 0.4 # 40% minimum operating point - ramp_rate_fraction: 0.05 # 5%/min ramp rate - run_up_rate_fraction: 0.02 # 2%/min run up rate - hot_startup_time: 3600.0 # 1 hour - warm_startup_time: 7200.0 # 2 hours - cold_startup_time: 14400.0 # 4 hours - # 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.14 - - 0.15 - - 0.165 - - 0.17 - log_channels: - - power - - state - - power_setpoint - initial_conditions: - power: 30000 # Start ON at rated capacity (30 MW) - - efficiency_table: - power_fraction: - - 1.0 - - 0.95 - - 0.90 - - 0.85 - - 0.80 - - 0.75 - - 0.7 - - 0.65 - - 0.6 - - 0.55 - - 0.50 - - 0.4 - efficiency: # HHV net plant efficiency, fractions (0-1), from CC1A-F curve in Exhibit ES-4 of [5] - - 0.53 - - 0.515 - - 0.52 - - 0.52 - - 0.52 - - 0.52 - - 0.52 - - 0.515 - - 0.505 - - 0.5 - - 0.49 - - 0.47 - - log_channels: - - power - - fuel_volume_rate - - fuel_mass_rate - - efficiency - -controller: - diff --git a/examples/10_combined_cycle_plant/hercules_runscript.py b/examples/10_combined_cycle_plant/hercules_runscript.py deleted file mode 100644 index c520affe..00000000 --- a/examples/10_combined_cycle_plant/hercules_runscript.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Example 10: Combined Cycle Gas Turbine (CCGT) simulation. - -This example demonstrates a simple combined cycle gas turbine (CCGT) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerCCGT: - """Controller implementing the CCGT schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = 100000 # Hardcode for now. Likely need to get from - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 10 * 60: # 10 minutes in seconds - # Before 10 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 60 * 60: # 60 minutes in seconds - # Between 10 and 60 minutes: shut down - power_setpoint = 0.0 - elif current_time < 260 * 60: # 260 minutes in seconds - # Between 60 and 260 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 360 * 60: # 360 minutes in seconds - # Between 260 and 360 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 480 * 60: # 480 minutes in seconds - # Between 360 and 480 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 540 * 60: # 540 minutes in seconds - # Between 480 and 540 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 540 minutes: shut down - power_setpoint = 0.0 - - h_dict["combined_cycle_plant"]["power_setpoint"] = power_setpoint - - return h_dict - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerCCGT(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/10_combined_cycle_plant/plot_outputs.py b/examples/10_combined_cycle_plant/plot_outputs.py deleted file mode 100644 index eeca23e4..00000000 --- a/examples/10_combined_cycle_plant/plot_outputs.py +++ /dev/null @@ -1,116 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -import matplotlib.pyplot as plt -from hercules import HerculesOutput -import numpy as np - -# Read the Hercules output file using HerculesOutput -ho = HerculesOutput("outputs/hercules_output.h5") - -# Print metadata information -print("Simulation Metadata:") -ho.print_metadata() -print() - -# 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["combined_cycle_plant.power"] / 1000, label="Power Output", color="k") -ax.plot( - time_minutes, - df["combined_cycle_plant.OCGT.power_setpoint"] / 1000, - label="Power Setpoint (OCGT)", - color="r", - linestyle="--", -) -ax.plot( - time_minutes, - df["combined_cycle_plant.ST.power_setpoint"] / 1000, - label="Power Setpoint (ST)", - color="b", - linestyle="--", -) -ax.plot( - time_minutes, - (df["combined_cycle_plant.ST.power_setpoint"]+df["combined_cycle_plant.OCGT.power_setpoint"]) / 1000, - label="Power Setpoint (CCGT)", - color="k", - linestyle="--", -) -ax.plot( - time_minutes, - df["combined_cycle_plant.OCGT.power"] / 1000, - label="Power Output (OCGT)", - color="r", -) -ax.plot( - time_minutes, - df["combined_cycle_plant.ST.power"] / 1000, - label="Power Output (ST)", - color="b", -) -ax.set_ylabel("Power [MW]") -ax.grid(True) - -ax = axarr[1] -ax.plot( - time_minutes, - df["combined_cycle_plant.OCGT.state"], - label="State (OCGT)", - color="r", - linestyle="-", -) -ax.plot( - time_minutes, - df["combined_cycle_plant.ST.state"], - label="State (ST)", - color="b", - linestyle="-", -) - -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.set_title( - "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -) -ax.grid(True) - -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_minutes, - df["combined_cycle_plant.efficiency"] * 100, - label="Efficiency", - color="g", -) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") -ax.grid(True) - -# Plot the fuel consumption -ax = axarr[3] -ax.plot( - time_minutes, - df["combined_cycle_plant.fuel_volume_rate"], - label="Fuel Volume Rate", - color="orange", -) -ax.set_ylabel("Fuel [m³/s]") -ax.set_title("Fuel Volume Rate") -ax.grid(True) - -ax.set_xlabel("Time [minutes]") - -plt.tight_layout() -plt.show() diff --git a/examples/11_steam_turbine_temp/hercules_input.yaml b/examples/11_steam_turbine_temp/hercules_input.yaml deleted file mode 100644 index ee96c8a5..00000000 --- a/examples/11_steam_turbine_temp/hercules_input.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# 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-01T18:00:00Z" # 18 hours later -verbose: False -log_every_n: 1 - -plant: - interconnect_limit: 30000 # kW (30 MW) - -steam_turbine: - component_type: SteamTurbine - rated_capacity: 30000 # kW (30 MW) - min_stable_load_fraction: 0.1 # 10% minimum operating point - ramp_rate_fraction: 0.05 # 5%/min ramp rate - run_up_rate_fraction: 0.03 # 3%/min run up rate - hot_startup_time: 7200.0 # 2 hours - warm_startup_time: 14400.0 # 4 hours - cold_startup_time: 28800.0 # 8 hours - min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine - min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine - # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html - # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ - hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) - fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) - 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.14 - - 0.15 - - 0.165 - - 0.17 - log_channels: - - power - - fuel_volume_rate - - fuel_mass_rate - - state - - efficiency - - power_setpoint - initial_conditions: - power: 30000 # Start ON at rated capacity (100 MW) - -controller: - diff --git a/examples/11_steam_turbine_temp/hercules_runscript.py b/examples/11_steam_turbine_temp/hercules_runscript.py deleted file mode 100644 index ee27217a..00000000 --- a/examples/11_steam_turbine_temp/hercules_runscript.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Example 07: Steam Turbine (ST) simulation. - -This example demonstrates a simple steam turbine (ST) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerST: - """Controller implementing the steam turbine schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = h_dict["steam_turbine"]["rated_capacity"] - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 30 * 60: # 30 minutes in seconds - # Before 30 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 30 and 120 minutes: shut down - power_setpoint = 0.0 - elif current_time < 360 * 60: # 360 minutes in seconds - # Between 120 and 360 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 720 * 60: # 720 minutes in seconds - # Between 360 and 720 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 630 * 60: # 630 minutes in seconds - # Between 360 and 630 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 720 * 60: # 720 minutes in seconds - # Between 630 and 720 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 720 minutes: shut down - power_setpoint = 0.0 - - h_dict["steam_turbine"]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerST(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/11_steam_turbine_temp/plot_outputs.py b/examples/11_steam_turbine_temp/plot_outputs.py deleted file mode 100644 index d0802bf4..00000000 --- a/examples/11_steam_turbine_temp/plot_outputs.py +++ /dev/null @@ -1,92 +0,0 @@ -# Plot the outputs of the simulation for the OCGT example - -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 -print("Simulation Metadata:") -ho.print_metadata() -print() - -# 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["steam_turbine.power"] / 1000, label="Power Output", color="b") -ax.plot( - time_minutes, - df["steam_turbine.power_setpoint"] / 1000, - label="Power Setpoint", - color="r", - linestyle="--", -) -ax.axhline( - h_dict["steam_turbine"]["rated_capacity"] / 1000, - color="gray", - linestyle=":", - label="Rated Capacity", -) -ax.axhline( - h_dict["steam_turbine"]["min_stable_load_fraction"] - * h_dict["steam_turbine"]["rated_capacity"] - / 1000, - color="gray", - linestyle="--", - label="Minimum Stable Load", -) -ax.set_ylabel("Power [MW]") -ax.set_title("Open Cycle Gas Turbine Power Output") -ax.legend() -ax.grid(True) - -# Plot the state -ax = axarr[1] -ax.plot(time_minutes, df["steam_turbine.state"], label="State", color="k") -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.set_title( - "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)" -) -ax.grid(True) - -# Plot the efficiency -ax = axarr[2] -ax.plot( - time_minutes, - df["steam_turbine.efficiency"] * 100, - label="Efficiency", - color="g", -) -ax.set_ylabel("Efficiency [%]") -ax.set_title("Thermal Efficiency") -ax.grid(True) - -# Plot the fuel consumption -ax = axarr[3] -ax.plot( - time_minutes, - df["steam_turbine.fuel_volume_rate"], - label="Fuel Volume Rate", - color="orange", -) -ax.set_ylabel("Fuel [m³/s]") -ax.set_title("Fuel Volume Rate") -ax.grid(True) - -ax.set_xlabel("Time [minutes]") - -plt.tight_layout() -plt.show() diff --git a/hercules/utilities.py b/hercules/utilities.py index 54ce271c..0613cd9d 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -290,9 +290,9 @@ def load_hercules_input(filename): f"All entries in power_setpoint_schedule 'time' list must be floats or ints " f"in input file {filename}" ) - if not all(isinstance(p, (float, int)) for p in schedule["power_setpoint_fraction"]): + if not all(isinstance(p, (float, int, list, tuple)) for p in schedule["power_setpoint_fraction"]): raise ValueError( - f"All entries in power_setpoint_schedule 'power_setpoint_fraction' list must be floats or ints " + f"All entries in power_setpoint_schedule 'power_setpoint_fraction' list must be floats, ints, lists, or tuples " f"in input file {filename}" ) From f16e015a1d1fdf5b3afe6525acbfdcca84e82996 Mon Sep 17 00:00:00 2001 From: Frederik Date: Wed, 18 Mar 2026 11:07:44 -0600 Subject: [PATCH 34/38] Added tests for `steam_turbine` and `combined_cycle_plant` components, updated docs --- docs/_toc.yml | 2 + docs/component_types.md | 2 + docs/steam_turbine.md | 155 ++++++++++++++++++ .../hercules_runscript.py | 6 - .../plant_components/combined_cycle_plant.py | 17 ++ hercules/plant_components/steam_turbine.py | 22 +-- tests/combined_cycle_plant_test.py | 78 +++++++++ tests/steam_turbine_test.py | 107 ++++++++++++ tests/test_inputs/h_dict.py | 52 ++++++ 9 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 docs/steam_turbine.md create mode 100644 tests/combined_cycle_plant_test.py create mode 100644 tests/steam_turbine_test.py diff --git a/docs/_toc.yml b/docs/_toc.yml index e40a8940..5456adba 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -31,6 +31,8 @@ parts: - file: electrolyzer - file: thermal_component_base - file: open_cycle_gas_turbine + - file: steam_turbine + - file: combined_cycle_plant - caption: Inputs chapters: - file: hercules_input diff --git a/docs/component_types.md b/docs/component_types.md index 2ddd1f60..9e14387f 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -55,6 +55,8 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro | `BatteryLithiumIon` | `storage` | [Battery](battery.md) | | `ElectrolyzerPlant` | `load` | [Electrolyzer](electrolyzer.md) | | `OpenCycleGasTurbine` | `generator` | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | +| `SteamTurbine` | `generator` | [Steam Turbine](steam_turbine.md) | +| `CombinedCyclePlant` | `generator` | [Multi-unit Combined Cycle Gas Turbine](combined_cycle_plant.md) | Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. diff --git a/docs/steam_turbine.md b/docs/steam_turbine.md new file mode 100644 index 00000000..d4b7c61f --- /dev/null +++ b/docs/steam_turbine.md @@ -0,0 +1,155 @@ +# Hard Coal Steam Turbine + +The `SteamTurbine` (ST) class models a hard coal power production plant using steam turbines. This class is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class. + +Set `component_type: SteamTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `steam_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`. + +## ST-Specific Parameters + +The ST class provides default values for bituminous coal properties from [4]: + +| Parameter | Units | Default | Description | +|-----------|-------|---------|-------------| +| `hhv` | J/m³ | 29310000000 | Higher heating value of bituminous coal (29.31 MJ/m³) [4] | +| `fuel_density` | kg/m³ | 1000 | Fuel density for mass calculations | + +The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the [2] are used. All efficiency values are **HHV (Higher Heating Value) net plant efficiencies**. See {doc}`thermal_component_base` for details on the efficiency table format. + +## Default Parameter Values + +The `SteamTurbine` class provides default values for base class parameters based on References [1-4]. Only `rated_capacity` and `initial_conditions` are required in the YAML configuration. + +| Parameter | Default Value | Source | +|-----------|---------------|--------| +| `min_stable_load_fraction` | 0.30 (30%) | [2] | +| `ramp_rate_fraction` | 0.03 (3%/min) | [1] | +| `run_up_rate_fraction` | Same as `ramp_rate_fraction` | — | +| `hot_startup_time` | 7.5 hours | [1] | +| `warm_startup_time` | 7.5 hours | [1] | +| `cold_startup_time` | 7.5 hours | [1] | +| `min_up_time` | 48 hours | [2] | +| `min_down_time` | 48 hours | [2] | +| `efficiency_table` | Average plant efficiency | [2,3] | + +### Default Efficiency Table + +The default HHV net plant efficiency table is based on [2,3]: + +| Power Fraction | HHV Net Efficiency | +|---------------|-------------------| +| 1.00 | 0.35 (35%) | +| 0.5o | 0.32 (32%) | +| 0.30 | 0.30 (30%) | + +## HCST Outputs + +The HCST model provides the following outputs (inherited from base class): + +| Output | Units | Description | +|--------|-------|-------------| +| `power` | kW | Actual power output | +| `state` | integer | Operating state number (0-5), corresponding to the `STATES` enum | +| `efficiency` | fraction (0-1) | Current HHV net plant efficiency | +| `fuel_volume_rate` | m³/s | Fuel volume flow rate | +| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` | + +### Efficiency and Fuel Rate + +HHV net plant efficiency varies with load based on the `efficiency_table`. The fuel volume rate is calculated as: + +$$ +\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}} +$$ + +Where: +- `power` is in W (converted from kW internally) +- `efficiency` is the HHV net efficiency interpolated from the efficiency table +- `hhv` is the higher heating value in J/m³ +- Result is fuel volume rate in m³/s + +The fuel mass rate is then computed from the volume rate using the fuel density: + +$$ +\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density} +$$ + +Where: +- `fuel_volume_rate` is in m³/s +- `fuel_density` is in kg/m³ +- Result is fuel mass rate in kg/s + +## YAML Configuration + +### Minimal Configuration + +Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters): + +```yaml +steam_turbine: + component_type: SteamTurbine + rated_capacity: 100000 # kW (100 MW) + initial_conditions: + power: 0 # 0 kW means OFF; power > 0 means ON +``` + +### Full Configuration + +All parameters explicitly specified: + +```yaml +steam_turbine: + component_type: SteamTurbine + rated_capacity: 500000 # kW (500 MW) + min_stable_load_fraction: 0.3 # 30% minimum operating point + ramp_rate_fraction: 0.03 # 3%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 27000.0 # 7.5 hours + warm_startup_time: 27000.0 # 7.5 hours + cold_startup_time: 27000.0 # 7.5 hours + min_up_time: 172800 # 48 hours + min_down_time: 172800 # 48 hour + hhv: 29310000000 # J/m³ for bituminous coal (29.31 MJ/m³) [4] + fuel_density: 1000 # kg/m³ for bituminous coal + efficiency_table: + power_fraction: + - 1.0 + - 0.50 + - 0.30 + efficiency: # HHV net plant efficiency, fractions (0-1) + - 0.35 + - 0.32 + - 0.32 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 0 # 0 kW means OFF; power > 0 means ON +``` + +## Logging Configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. + +**Available Channels:** +- `power`: Actual power output in kW (always logged) +- `state`: Operating state number (0-5), corresponding to the `STATES` enum +- `fuel_volume_rate`: Fuel volume flow rate in m³/s +- `fuel_mass_rate`: Fuel mass flow rate in kg/s +- `efficiency`: Current HHV net plant efficiency (0-1) +- `power_setpoint`: Requested power setpoint in kW + +## References + +1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants." + +2. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi. + +3. T. Schmitt, S. Leptinsky, M. Turner, A. Zoelle, C. White, S. Hughes, S. Homsy, et al. “Cost And Performance Baseline for Fossil Energy Plants Volume 1: Bituminous Coal and Natural Gas Electricity.” Pittsburgh, PA: National Energy Technology Laboratory, October 14, 2022b. https://doi.org/10.2172/1893822. + +4. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf diff --git a/examples/08_multi_unit_thermal_plants/hercules_runscript.py b/examples/08_multi_unit_thermal_plants/hercules_runscript.py index 9963e411..3bf92bef 100644 --- a/examples/08_multi_unit_thermal_plants/hercules_runscript.py +++ b/examples/08_multi_unit_thermal_plants/hercules_runscript.py @@ -5,9 +5,6 @@ controller follows that schedule. The outputs of the simulation are plotted in the plot_outputs.py script. The following thermal power plants are currently available for simulation: -- Hard Coal Steam Turbine (HCST) -- Open Cycle Gas Turbine (OCGT) -- Steam Turbine (ST) - Combined Cycle Gas Turbine modeled as individual gas and steam turbines with a coupling constraint (MU-CCGT) - Multi-unit thermal plant with 2 OCGTs (MUTP) """ @@ -21,9 +18,6 @@ # Initialize the Hercules model # Select which thermal plant you want to simulate by changing the yaml file # Currenctly available: -# - hercules_input_hcst.yaml: Hard coal steam turbine (HCST) -# - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) -# - hercules_input_st.yaml: Steam Turbine (ST) # - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as # individual gas and steam turbines with a coupling constraint # - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index 95df62dc..fa5ee25c 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -30,8 +30,17 @@ def __init__(self, h_dict, component_name): if not "open_cycle_gas_turbine" in generic_units: raise ValueError("For the combined cycle plant, one of the units must be an open cycle gas turbine.") + print(generic_units) + print(len(generic_units)) + if len(generic_units) != 2: + print(generic_units) + raise ValueError("For the combined cycle plant, there must be exactly two units: " + "one steam turbine and one open cycle gas turbine.") + for unit, unit_name in zip(generic_units, self.unit_names): + print(unit) if unit not in ["open_cycle_gas_turbine", "steam_turbine"]: + print(unit) raise ValueError("For the combined cycle plant, units must be either 'open_cycle_gas_turbine' or 'steam_turbine'.") if unit_name not in h_dict[component_name]: h_dict[component_name][unit_name] = copy.deepcopy(h_dict[component_name][unit]) @@ -86,6 +95,13 @@ def __init__(self, h_dict, component_name): self.units[self.gas_turbine_index].rated_capacity) ) + # Default HHV net plant efficiency table based on [2]: + if "efficiency_table" not in h_dict[component_name]: + h_dict[component_name]["efficiency_table"] = { + "power_fraction": [1.0, 0.95, 0.90, 0.85, 0.80, 0.75, 0.7, 0.65, 0.6, 0.55, 0.50, 0.4], + "efficiency": [0.53, 0.515, 0.52, 0.52, 0.52, 0.52, 0.52, 0.515, 0.505, 0.5, 0.47, 0.47], + } + efficiency_table = h_dict[component_name]["efficiency_table"] # Validate efficiency_table structure @@ -126,6 +142,7 @@ def __init__(self, h_dict, component_name): self.efficiency_values = self.efficiency_values[sort_idx] self.rated_capacity = self.units[self.gas_turbine_index].rated_capacity + self.units[self.steam_turbine_index].rated_capacity + h_dict[component_name]["rated_capacity"] = self.rated_capacity # Derive initial state from power: if power > 0 then ON, else OFF for unit in self.units: diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py index 83c2ac61..c674e2ba 100644 --- a/hercules/plant_components/steam_turbine.py +++ b/hercules/plant_components/steam_turbine.py @@ -26,18 +26,18 @@ def __init__(self, h_dict, component_name): # Specify default parameter values default_parameters_steam_turbine = { - "min_stable_load_fraction": 0.40, - "ramp_rate_fraction": 0.1, - "hot_startup_time": 420.0, - "warm_startup_time": 480.0, - "cold_startup_time": 480.0, - "min_up_time": 1800.0, - "min_down_time": 3600.0, - "hhv": 39050000, # J/m³ (39.05 - "fuel_density": 0.768, # kg/m³ + "min_stable_load_fraction": 0.30, + "ramp_rate_fraction": 0.03, + "hot_startup_time": 27000.0, + "warm_startup_time": 27000.0, + "cold_startup_time": 27000.0, + "min_up_time": 172800.0, + "min_down_time": 172800.0, + "hhv": 29310000000, # J/m³ + "fuel_density": 1000, # kg/m³ "efficiency_table": { - "power_fraction": [1.0, 0.75, 0.50, 0.4], - "efficiency": [0.14, 0.15, 0.165, 0.17], + "power_fraction": [1.0, 0.5, 0.3], + "efficiency": [0.35, 0.32, 0.30], }, } diff --git a/tests/combined_cycle_plant_test.py b/tests/combined_cycle_plant_test.py new file mode 100644 index 00000000..e56153da --- /dev/null +++ b/tests/combined_cycle_plant_test.py @@ -0,0 +1,78 @@ +import copy + +import pytest +from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant + +from .test_inputs.h_dict import ( + h_dict_combined_cycle_plant, + simple_battery, +) + + +def test_init_from_dict(): + # Set up a system with one OCGT and one steam turbine. + h_dict = copy.deepcopy(h_dict_combined_cycle_plant) + CombinedCyclePlant(h_dict, "combined_cycle_plant") + + +def test_invalid_unit_type(): + h_dict = copy.deepcopy(h_dict_combined_cycle_plant) + + # Wrong types of units to make up the combined cycle plant + h_dict["combined_cycle_plant"]["units"] = ["open_cycle_gas_turbine", "open_cycle_gas_turbine"] + with pytest.raises(ValueError): + CombinedCyclePlant(h_dict, "combined_cycle_plant") + + # Additional units not part of combined cycle plant + h_dict["combined_cycle_plant"]["units"] = ["open_cycle_gas_turbine", "steam_turbine", "simple_battery"] + h_dict["combined_cycle_plant"]["simple_battery"] = copy.deepcopy(simple_battery) + with pytest.raises(ValueError): + CombinedCyclePlant(h_dict, "combined_cycle_plant") + + # Incorrect component type + h_dict["combined_cycle_plant"]["units"] = ["open_cycle_gas_turbine", "steam_turbine"] + h_dict["combined_cycle_plant"]["steam_turbine"]["component_type"] = "InvalidComponent" + with pytest.raises(KeyError): + CombinedCyclePlant(h_dict, "combined_cycle_plant") + + +def test_h_dict_structure(): + h_dict = copy.deepcopy(h_dict_combined_cycle_plant) + + tp = CombinedCyclePlant(h_dict, "combined_cycle_plant") + + # Check that the unit dicts were copied correctly (and generic names removed) + assert "open_cycle_gas_turbine" not in h_dict["combined_cycle_plant"] + assert "steam_turbine" not in h_dict["combined_cycle_plant"] + assert "OCGT" in h_dict["combined_cycle_plant"] + assert "ST" in h_dict["combined_cycle_plant"] + assert h_dict["combined_cycle_plant"]["OCGT"]["component_type"] == "OpenCycleGasTurbine" + assert h_dict["combined_cycle_plant"]["ST"]["component_type"] == "SteamTurbine" + + # Check that the initial conditions of units are copied correctly + h_dict = tp.get_initial_conditions_and_meta_data(h_dict) + assert h_dict["combined_cycle_plant"]["OCGT"]["power"] == 1000 # From initial conditions + assert h_dict["combined_cycle_plant"]["ST"]["power"] == 1000 # From initial conditions + + # Check that combined cycle plant conditions are recorded correctly + assert h_dict["combined_cycle_plant"]["power"] == 1000 + 1000 + + +def test_step(): + h_dict = copy.deepcopy(h_dict_combined_cycle_plant) + + tp = CombinedCyclePlant(h_dict, "combined_cycle_plant") + + # Provide power setpoints to the two units + h_dict["combined_cycle_plant"]["power_setpoint"] = 1500 + + # Step the plant and check that power is updated correctly + h_dict = tp.step(h_dict) + power_ocgt = h_dict["combined_cycle_plant"]["OCGT"]["power"] + power_steam = h_dict["combined_cycle_plant"]["ST"]["power"] + + assert power_ocgt < 1000 # Reacts to power setpoint + assert power_steam < 1000 # Reacts to power setpoint + + # Total power computed correctly + assert h_dict["combined_cycle_plant"]["power"] == power_ocgt + power_steam diff --git a/tests/steam_turbine_test.py b/tests/steam_turbine_test.py new file mode 100644 index 00000000..c74e408b --- /dev/null +++ b/tests/steam_turbine_test.py @@ -0,0 +1,107 @@ +import copy + +import numpy as np +from hercules.plant_components.steam_turbine import SteamTurbine +from hercules.utilities import hercules_float_type + +from .test_inputs.h_dict import ( + h_dict_steam_turbine, +) + + +def test_init_from_dict(): + """Test that SteamTurbine can be initialized from a dictionary.""" + st = SteamTurbine( + copy.deepcopy(h_dict_steam_turbine), "steam_turbine" + ) + assert st is not None + + +def test_default_inputs(): + """Test that SteamTurbine uses default inputs when not provided.""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + + # Test that the ramp_rate_fraction input is correct from input dict + st = SteamTurbine(h_dict, "steam_turbine") + assert st.ramp_rate_fraction == 0.04 + + # Test that the run_up_rate_fraction input is correct from input dict + assert st.run_up_rate_fraction == 0.02 + + # Test that if the run_up_rate_fraction is not provided, + # it defaults to the ramp_rate_fraction + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["run_up_rate_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.run_up_rate_fraction == st.ramp_rate_fraction + + # Now test that the default value of the ramp_rate_fraction is + # applied to both the ramp_rate_fraction and the run_up_rate_fraction + # if they are both not provided + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["ramp_rate_fraction"] + del h_dict["steam_turbine"]["run_up_rate_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.ramp_rate_fraction == 0.03 + assert st.run_up_rate_fraction == 0.03 + + # Test the remaining default values + # Delete startup times first, since changing min_stable_load_fraction and + # ramp rates affects ramp_time validation against startup times + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["ramp_rate_fraction"] + del h_dict["steam_turbine"]["run_up_rate_fraction"] + del h_dict["steam_turbine"]["cold_startup_time"] + del h_dict["steam_turbine"]["warm_startup_time"] + del h_dict["steam_turbine"]["hot_startup_time"] + del h_dict["steam_turbine"]["min_stable_load_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_stable_load_fraction == 0.30 + assert st.hot_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert st.warm_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert st.cold_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["min_up_time"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_up_time == 48 * 60.0 * 60.0 # 48 hours in seconds + + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["min_down_time"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_down_time == 48 * 60.0 * 60.0 # 48 hours in seconds + + +def test_default_hhv(): + """Test that SteamTurbine provides default HHV for bituminous coal from [4].""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["hhv"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.hhv == 29310000000 + + +def test_default_fuel_density(): + """Test that SteamTurbine provides default fuel density.""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + if "fuel_density" in h_dict["steam_turbine"]: + del h_dict["steam_turbine"]["fuel_density"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.fuel_density == 1000.0 + + +def test_default_efficiency_table(): + """Test that SteamTurbine provides default HHV net efficiency table. + + Default values are taken from [2,3] + """ + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["efficiency_table"] + st = SteamTurbine(h_dict, "steam_turbine") + np.testing.assert_array_equal( + st.efficiency_power_fraction, + np.array([0.3, 0.5, 1.0], dtype=hercules_float_type), + ) + np.testing.assert_array_equal( + st.efficiency_values, + np.array([0.30, 0.32, 0.35], dtype=hercules_float_type), + ) diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 0160f488..6e4071b0 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -178,6 +178,26 @@ }, } +steam_turbine = { + "component_type": "SteamTurbine", + "rated_capacity": 500000, # kW (500 MW) + "min_stable_load_fraction": 0.3, # 30% minimum operating point + "ramp_rate_fraction": 0.04, # 4%/min ramp rate + "run_up_rate_fraction": 0.02, # 2%/min run up rate + "hot_startup_time": 27000.0, # 7.5 hours + "warm_startup_time": 27000.0, # 7.5 hours + "cold_startup_time": 27000.0, # 7.5 hours + "min_up_time": 172800, # 48 hours + "min_down_time": 172800, # 48 hour + "hhv": 29310000000, # J/m³ for bituminous coal (29.31 MJ/m³) [4] + "fuel_density": 1000, # kg/m³ for bituminous coal + "initial_conditions": {"power": 1000}, # power > 0 implies ON state + "efficiency_table": { + "power_fraction": [1.0, 0.75, 0.3], + "efficiency": [0.35, 0.32, 0.30], + }, +} + electrolyzer = { "component_type": "ElectrolyzerPlant", "initial_conditions": { @@ -430,6 +450,19 @@ "hard_coal_steam_turbine": hard_coal_steam_turbine, } +h_dict_steam_turbine = { + "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, + "steam_turbine": steam_turbine, +} + h_dict_thermal_plant = { "dt": 1.0, "starttime": 0.0, @@ -448,3 +481,22 @@ "hard_coal_steam_turbine": hard_coal_steam_turbine, }, } + +h_dict_combined_cycle_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, + "combined_cycle_plant": { + "component_type": "CombinedCyclePlant", + "unit_names": ["OCGT", "ST"], + "units": ["open_cycle_gas_turbine", "steam_turbine"], + "open_cycle_gas_turbine": open_cycle_gas_turbine, + "steam_turbine": steam_turbine, + }, +} From c9ed1f46844b3be2a6188c34bc1e6e29bec3c454 Mon Sep 17 00:00:00 2001 From: Frederik Date: Wed, 18 Mar 2026 11:35:23 -0600 Subject: [PATCH 35/38] Clean up code, ruff --- .../07_thermal_plants/hercules_runscript.py | 22 ++- examples/07_thermal_plants/plot_outputs.py | 2 +- .../hercules_runscript.py | 60 ++++-- .../plot_outputs.py | 43 ++-- hercules/hybrid_plant.py | 2 +- .../plant_components/combined_cycle_plant.py | 184 ++++++++++++------ .../thermal_component_base.py | 3 +- hercules/utilities.py | 17 +- tests/combined_cycle_plant_test.py | 8 +- tests/steam_turbine_test.py | 4 +- 10 files changed, 230 insertions(+), 115 deletions(-) diff --git a/examples/07_thermal_plants/hercules_runscript.py b/examples/07_thermal_plants/hercules_runscript.py index 692ade07..d4bfaf00 100644 --- a/examples/07_thermal_plants/hercules_runscript.py +++ b/examples/07_thermal_plants/hercules_runscript.py @@ -1,15 +1,13 @@ """Example 07: Thermal power plant simulation. -This example demonstrates simple thermal units that follow a reference power setpoint. -The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the -controller follows that schedule. The outputs of the simulation are plotted in the +This example demonstrates simple thermal units that follow a reference power setpoint. +The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the +controller follows that schedule. The outputs of the simulation are plotted in the plot_outputs.py script. The following thermal power plants are currently available for simulation: - Hard Coal Steam Turbine (HCST) - Open Cycle Gas Turbine (OCGT) - Steam Turbine (ST) -- Combined Cycle Gas Turbine modeled as individual gas and steam turbines with a coupling constraint (MU-CCGT) -- Multi-unit thermal plant with 2 OCGTs (MUTP) """ from hercules.hercules_model import HerculesModel @@ -23,11 +21,12 @@ # - hercules_input_hcst.yaml: Hard coal steam turbine (HCST) # - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) # - hercules_input_st.yaml: Steam Turbine (ST) -# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as +# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as # individual gas and steam turbines with a coupling constraint # - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs hmodel = HerculesModel("input_files/hercules_input_hcst.yaml") + class ControllerOCGT: """Controller implementing the OCGT schedule described in the module docstring.""" @@ -54,8 +53,13 @@ def step(self, h_dict): current_time = h_dict["time"] # Determine power setpoint based on schedule provided in yaml file - time_index = sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 - power_setpoint = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] * self.rated_capacity + time_index = ( + sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 + ) + power_setpoint = ( + h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] + * self.rated_capacity + ) h_dict[self.component_name]["power_setpoint"] = power_setpoint @@ -68,4 +72,4 @@ def step(self, h_dict): # Run the simulation hmodel.run() -hmodel.logger.info("Process completed successfully") \ No newline at end of file +hmodel.logger.info("Process completed successfully") diff --git a/examples/07_thermal_plants/plot_outputs.py b/examples/07_thermal_plants/plot_outputs.py index fdcb4a55..2171145c 100644 --- a/examples/07_thermal_plants/plot_outputs.py +++ b/examples/07_thermal_plants/plot_outputs.py @@ -90,4 +90,4 @@ ax.set_xlabel("Time [minutes]") plt.tight_layout() -plt.show() \ No newline at end of file +plt.show() diff --git a/examples/08_multi_unit_thermal_plants/hercules_runscript.py b/examples/08_multi_unit_thermal_plants/hercules_runscript.py index 3bf92bef..e6c084fd 100644 --- a/examples/08_multi_unit_thermal_plants/hercules_runscript.py +++ b/examples/08_multi_unit_thermal_plants/hercules_runscript.py @@ -1,30 +1,32 @@ """Example 07: Thermal power plant simulation. -This example demonstrates simple thermal units that follow a reference power setpoint. -The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the -controller follows that schedule. The outputs of the simulation are plotted in the +This example demonstrates simple thermal units that follow a reference power setpoint. +The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the +controller follows that schedule. The outputs of the simulation are plotted in the plot_outputs.py script. The following thermal power plants are currently available for simulation: -- Combined Cycle Gas Turbine modeled as individual gas and steam turbines with a coupling constraint (MU-CCGT) +- Combined Cycle Gas Turbine modeled as individual gas and steam turbines + with a coupling constraint (MU-CCGT) - Multi-unit thermal plant with 2 OCGTs (MUTP) """ from hercules.hercules_model import HerculesModel from hercules.utilities_examples import prepare_output_directory -import copy prepare_output_directory() # Initialize the Hercules model # Select which thermal plant you want to simulate by changing the yaml file # Currenctly available: -# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as +# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as # individual gas and steam turbines with a coupling constraint # - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs -hmodel = HerculesModel("input_files/hercules_input_mutp.yaml") +hmodel = HerculesModel("input_files/hercules_input_mu-ccgt.yaml") + class ControllerTPP: - """Controller implementing the thermal power plant schedule described in the module docstring.""" + """Controller implementing the thermal power plant schedule + described in the module docstring.""" def __init__(self, h_dict): """Initialize the controller. @@ -34,8 +36,10 @@ def __init__(self, h_dict): """ self.component_name = h_dict["component_names"][0] - self.unit_capacities = [h_dict[self.component_name][unit_name]["rated_capacity"] - for unit_name in h_dict[self.component_name]["unit_names"]] + self.unit_capacities = [ + h_dict[self.component_name][unit_name]["rated_capacity"] + for unit_name in h_dict[self.component_name]["unit_names"] + ] self.rated_capacity = sum(self.unit_capacities) h_dict[self.component_name]["rated_capacity"] = self.rated_capacity @@ -52,20 +56,34 @@ def step(self, h_dict): current_time = h_dict["time"] # Determine power setpoint based on schedule provided in yaml file - time_index = sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 - - # If the power setpoint fraction is provided as a list for each unit, use it directly. Otherwise, assume it's a fraction of the - # total rated capacity and distribute it proportionally to the unit capacities. - if isinstance(h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index], (list, tuple)): - power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] - power_setpoints = [power_setpoint * unit_capacity - for power_setpoint, unit_capacity in - zip(power_setpoint_fraction, self.unit_capacities) + time_index = ( + sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 + ) + + # If the power setpoint fraction is provided as a list for each unit, use it directly. + # Otherwise, assume it's a fraction of the total rated capacity and distribute it + # proportionally to the unit capacities. + if isinstance( + h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index], + (list, tuple), + ): + power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"][ + "power_setpoint_fraction" + ][time_index] + power_setpoints = [ + power_setpoint * unit_capacity + for power_setpoint, unit_capacity in zip( + power_setpoint_fraction, self.unit_capacities + ) ] h_dict[self.component_name]["power_setpoints"] = power_setpoints else: - power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] - h_dict[self.component_name]["power_setpoint"] = power_setpoint_fraction * self.rated_capacity + power_setpoint_fraction = h_dict["plant"]["power_setpoint_schedule"][ + "power_setpoint_fraction" + ][time_index] + h_dict[self.component_name]["power_setpoint"] = ( + power_setpoint_fraction * self.rated_capacity + ) return h_dict diff --git a/examples/08_multi_unit_thermal_plants/plot_outputs.py b/examples/08_multi_unit_thermal_plants/plot_outputs.py index 4eb1dde7..b33e268f 100644 --- a/examples/08_multi_unit_thermal_plants/plot_outputs.py +++ b/examples/08_multi_unit_thermal_plants/plot_outputs.py @@ -32,25 +32,27 @@ time_minutes, df[f"{component_name}.{unit_name}.power_setpoint"] / 1000, label=f"Power setpoint ({unit_name})", - color="C"+str(k), + color="C" + str(k), linestyle="--", ) ax.plot( time_minutes, df[f"{component_name}.{unit_name}.power"] / 1000, label=f"Power output ({unit_name})", - color="C"+str(k), + color="C" + str(k), ) ax.axhline( - h_dict[component_name][unit_name]["rated_capacity"] / 1000, - color="gray", - linestyle=":", - label="Unit rated capacity", + h_dict[component_name][unit_name]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Unit rated capacity", ) # Plot the state of each unit ax = axarr[1] - ax.plot(time_minutes, df[f"{component_name}.{unit_name}.state"], label=unit_name, color="C"+str(k)) + ax.plot( + time_minutes, df[f"{component_name}.{unit_name}.state"], label=unit_name, color="C" + str(k) + ) 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"]) @@ -73,21 +75,36 @@ ax = axarr[2] try: for k, unit_name in enumerate(unit_names): - ax.plot(time_minutes, df[f"{component_name}.{unit_name}.efficiency"] * 100, label=unit_name, color="C"+str(k)) -except: + ax.plot( + time_minutes, + df[f"{component_name}.{unit_name}.efficiency"] * 100, + label=unit_name, + color="C" + str(k), + ) +except KeyError: ax.plot(time_minutes, df[f"{component_name}.efficiency"] * 100, label="Efficiency", color="g") # Fuel consumption ax = axarr[3] try: for k, unit_name in enumerate(unit_names): - ax.plot(time_minutes, df[f"{component_name}.{unit_name}.fuel_volume_rate"], label=unit_name, color="C"+str(k)) -except: - ax.plot(time_minutes, df[f"{component_name}.fuel_volume_rate"], label="Fuel Volume Rate", color="orange") + ax.plot( + time_minutes, + df[f"{component_name}.{unit_name}.fuel_volume_rate"], + label=unit_name, + color="C" + str(k), + ) +except KeyError: + ax.plot( + time_minutes, + df[f"{component_name}.fuel_volume_rate"], + label="Fuel Volume Rate", + color="orange", + ) ax.set_ylabel("Fuel [m³/s]") ax.grid(True) ax.legend() - + ax.set_ylabel("Thermal efficiency [%]") ax.grid(True) ax.legend() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 5d8919fb..e46495d1 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -2,6 +2,7 @@ from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon from hercules.plant_components.battery_simple import BatterySimple +from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant 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 @@ -9,7 +10,6 @@ from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts from hercules.plant_components.steam_turbine import SteamTurbine from hercules.plant_components.thermal_plant import ThermalPlant -from hercules.plant_components.combined_cycle_plant import CombinedCyclePlant from hercules.plant_components.wind_farm import WindFarm from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index fa5ee25c..5ece51dc 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -3,10 +3,11 @@ This plant has both an open cycle gas turbine and steam turbine. The steam turbine is modeled as a single unit with a power output that is a function of the open cycle gas turbine power output. """ + import copy -import numpy as np import hercules.hybrid_plant as hp +import numpy as np from hercules.plant_components.component_base import ComponentBase from hercules.plant_components.thermal_component_base import ThermalComponentBase from hercules.utilities import hercules_float_type @@ -25,23 +26,32 @@ def __init__(self, h_dict, component_name): self.unit_names = h_dict[component_name]["unit_names"] generic_units = h_dict[component_name]["units"] - if not "steam_turbine" in generic_units: - raise ValueError("For the combined cycle plant, one of the units must be a steam turbine.") - if not "open_cycle_gas_turbine" in generic_units: - raise ValueError("For the combined cycle plant, one of the units must be an open cycle gas turbine.") + if "steam_turbine" not in generic_units: + raise ValueError( + "For the combined cycle plant, one of the units must be a steam turbine." + ) + if "open_cycle_gas_turbine" not in generic_units: + raise ValueError( + "For the combined cycle plant, one of the units must be an open cycle gas turbine." + ) print(generic_units) print(len(generic_units)) if len(generic_units) != 2: print(generic_units) - raise ValueError("For the combined cycle plant, there must be exactly two units: " - "one steam turbine and one open cycle gas turbine.") + raise ValueError( + "For the combined cycle plant, there must be exactly two units: " + "one steam turbine and one open cycle gas turbine." + ) for unit, unit_name in zip(generic_units, self.unit_names): print(unit) if unit not in ["open_cycle_gas_turbine", "steam_turbine"]: print(unit) - raise ValueError("For the combined cycle plant, units must be either 'open_cycle_gas_turbine' or 'steam_turbine'.") + raise ValueError( + "For the combined cycle plant, units must be either " + "'open_cycle_gas_turbine' or 'steam_turbine'." + ) if unit_name not in h_dict[component_name]: h_dict[component_name][unit_name] = copy.deepcopy(h_dict[component_name][unit]) @@ -49,7 +59,7 @@ def __init__(self, h_dict, component_name): for unit in generic_units: if unit in h_dict[component_name]: del h_dict[component_name][unit] - + self.units = [] self.unit_types = [] for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): @@ -75,31 +85,58 @@ def __init__(self, h_dict, component_name): for unit_name in self.unit_names: initial_conditions = h_dict[component_name][unit_name]["initial_conditions"] self.power_output += initial_conditions["power"] # kW - + h_dict[component_name]["power"] = self.power_output self.steam_turbine_index = self.unit_types.index("SteamTurbine") self.gas_turbine_index = self.unit_types.index("OpenCycleGasTurbine") # Check that initial conditions are valid - if (self.units[self.gas_turbine_index].power_output == 0 and - self.units[self.steam_turbine_index].power_output > 0): + if ( + self.units[self.gas_turbine_index].power_output == 0 + and self.units[self.steam_turbine_index].power_output > 0 + ): raise ValueError( "Invalid initial conditions: steam turbine cannot be producing power if " "the open cycle gas turbine is not producing power." ) - - self.gas_power_ratio = ( - self.units[self.gas_turbine_index].rated_capacity / - (self.units[self.steam_turbine_index].rated_capacity + - self.units[self.gas_turbine_index].rated_capacity) + + self.gas_power_ratio = self.units[self.gas_turbine_index].rated_capacity / ( + self.units[self.steam_turbine_index].rated_capacity + + self.units[self.gas_turbine_index].rated_capacity ) # Default HHV net plant efficiency table based on [2]: if "efficiency_table" not in h_dict[component_name]: h_dict[component_name]["efficiency_table"] = { - "power_fraction": [1.0, 0.95, 0.90, 0.85, 0.80, 0.75, 0.7, 0.65, 0.6, 0.55, 0.50, 0.4], - "efficiency": [0.53, 0.515, 0.52, 0.52, 0.52, 0.52, 0.52, 0.515, 0.505, 0.5, 0.47, 0.47], + "power_fraction": [ + 1.0, + 0.95, + 0.90, + 0.85, + 0.80, + 0.75, + 0.7, + 0.65, + 0.6, + 0.55, + 0.50, + 0.4, + ], + "efficiency": [ + 0.53, + 0.515, + 0.52, + 0.52, + 0.52, + 0.52, + 0.52, + 0.515, + 0.505, + 0.5, + 0.47, + 0.47, + ], } efficiency_table = h_dict[component_name]["efficiency_table"] @@ -141,7 +178,10 @@ def __init__(self, h_dict, component_name): self.efficiency_power_fraction = self.efficiency_power_fraction[sort_idx] self.efficiency_values = self.efficiency_values[sort_idx] - self.rated_capacity = self.units[self.gas_turbine_index].rated_capacity + self.units[self.steam_turbine_index].rated_capacity + self.rated_capacity = ( + self.units[self.gas_turbine_index].rated_capacity + + self.units[self.steam_turbine_index].rated_capacity + ) h_dict[component_name]["rated_capacity"] = self.rated_capacity # Derive initial state from power: if power > 0 then ON, else OFF @@ -162,16 +202,14 @@ def __init__(self, h_dict, component_name): super().__init__(h_dict, component_name) def step(self, h_dict): - + power_setpoint = h_dict[self.component_name]["power_setpoint"] # Apply control self.power_output = sum(self.control(power_setpoint)) # Step each unit - for unit, unit_name in zip( - self.units, self.unit_names - ): + for unit, unit_name in zip(self.units, self.unit_names): h_dict_ccgt = h_dict[self.component_name] h_dict_ccgt[unit_name]["power_setpoint"] = unit.power_setpoint h_dict_ccgt = unit.step(h_dict_ccgt) @@ -179,7 +217,9 @@ def step(self, h_dict): self.efficiency = self.calculate_efficiency(self.power_output) self.fuel_volume_rate = self.calculate_fuel_volume_rate(self.power_output) - self.fuel_mass_rate = self.fuel_volume_rate * self.units[self.gas_turbine_index].fuel_density + self.fuel_mass_rate = ( + self.fuel_volume_rate * self.units[self.gas_turbine_index].fuel_density + ) # Update h_dict with outputs h_dict[self.component_name]["power"] = self.power_output @@ -211,51 +251,69 @@ def get_initial_conditions_and_meta_data(self, h_dict): def control(self, power_setpoint): """""" - + # Check that the power setpoint is a number if not isinstance(power_setpoint, (int, float)): raise ValueError("power_setpoint must be a number") # Set gas turbine power setpoint self.units[self.gas_turbine_index].power_setpoint = self.gas_power_ratio * power_setpoint - self.units[self.steam_turbine_index].power_setpoint = (1 - self.gas_power_ratio) * power_setpoint + self.units[self.steam_turbine_index].power_setpoint = ( + 1 - self.gas_power_ratio + ) * power_setpoint # TODO: we probably want to add an actual controller for the gas turbine - self.units[self.gas_turbine_index].power_output = self.units[self.gas_turbine_index]._control(self.units[self.gas_turbine_index].power_setpoint) - self.units[self.steam_turbine_index].power_output = self.control_steam_turbine(self.units[self.steam_turbine_index].power_setpoint) + self.units[self.gas_turbine_index].power_output = self.units[ + self.gas_turbine_index + ]._control(self.units[self.gas_turbine_index].power_setpoint) + self.units[self.steam_turbine_index].power_output = self.control_steam_turbine( + self.units[self.steam_turbine_index].power_setpoint + ) return [unit.power_output for unit in self.units] - + def control_steam_turbine(self, power_setpoint): """ - What I want to do: + Control the steam turbine based on the gas turbine's state and the desired power setpoint. + - If the gas turbine is off, or starting up, the steam turbine should be off. - - If the gas turbine goes from startup to on, the steam turbine startup process should begin. - - Can we use self.units[].time_in_state to delay the startup until the gas turbine is turned on? - - Current status: might actually be working already. Check what happens. + - If the gas turbine goes from startup to on, the steam turbine startup process should begin + - Otherwise, use regular control based on the power setpoint. """ if self.units[self.gas_turbine_index].state != ( - self.units[self.gas_turbine_index].STATES.ON or - self.units[self.gas_turbine_index].STATES.STOPPING): + self.units[self.gas_turbine_index].STATES.ON + or self.units[self.gas_turbine_index].STATES.STOPPING + ): # If the gas turbine is off or starting up, the steam turbine should be off self.units[self.steam_turbine_index].can_start = False self.units[self.steam_turbine_index]._control(0.0) - elif (self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0 - or self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.STOPPING): - # If the gas turbine is stopping but the steam turbine is still producing power, we need to turn off the steam turbine + elif ( + self.units[self.gas_turbine_index].state == "STOPPING" + and self.units[self.steam_turbine_index].power_output > 0 + or self.units[self.steam_turbine_index].state + == self.units[self.steam_turbine_index].STATES.STOPPING + ): + # If the gas turbine is stopping but the steam turbine is still producing power, + # we need to turn off the steam turbine self.units[self.steam_turbine_index]._control(0.0) - elif (self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and - self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF): - # If the gas turbine just turned on and the steam turbine is still off, we need to start up the steam turbine + elif ( + self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON + and self.units[self.steam_turbine_index].state + == self.units[self.steam_turbine_index].STATES.OFF + ): + # If the gas turbine just turned on and the steam turbine is still off, + # we need to start up the steam turbine self.units[self.steam_turbine_index].can_start = ( - self.units[self.steam_turbine_index].time_in_state >= self.units[self.steam_turbine_index].min_down_time ) + self.units[self.steam_turbine_index].time_in_state + >= self.units[self.steam_turbine_index].min_down_time + ) self.units[self.steam_turbine_index]._control(power_setpoint) else: # Normal operation self.units[self.steam_turbine_index]._control(power_setpoint) return self.units[self.steam_turbine_index].power_output - + def calculate_efficiency(self, power_output): """Calculate HHV net efficiency based on current power output. @@ -270,26 +328,38 @@ def calculate_efficiency(self, power_output): """ if self.units[self.gas_turbine_index].state == ( self.units[self.gas_turbine_index].STATES.OFF - ): + ): # Efficiency is not defined when off return np.nan elif self.units[self.gas_turbine_index].state == ( self.units[self.gas_turbine_index].STATES.STOPPING - ): + ): # Efficiency is not defined when stopping return np.nan elif power_output <= 0: # Efficiency is 0 when gas turbine not producing power (but not off) return 0.0 - elif self.units[self.steam_turbine_index].state == self.units[self.steam_turbine_index].STATES.OFF: - # If the steam turbine is not on, we are just running the gas turbine, so efficiency is based on gas turbine power output - return self.units[self.gas_turbine_index].calculate_efficiency(self.units[self.gas_turbine_index].power_output) + elif ( + self.units[self.steam_turbine_index].state + == self.units[self.steam_turbine_index].STATES.OFF + ): + # If the steam turbine is not on, we are just running the gas turbine, + # so efficiency is based on gas turbine power output + return self.units[self.gas_turbine_index].calculate_efficiency( + self.units[self.gas_turbine_index].power_output + ) elif self.units[self.steam_turbine_index].state != ( - self.units[self.steam_turbine_index].STATES.ON or - self.units[self.steam_turbine_index].STATES.STOPPING): - # If the steam turbine is starting up, it might be producing power, increasing the overall efficiency - efficiency_gas = self.units[self.gas_turbine_index].calculate_efficiency(self.units[self.gas_turbine_index].power_output) - fuel_used = (self.units[self.gas_turbine_index].power_output * 1000.0) / (efficiency_gas * self.units[self.gas_turbine_index].hhv) + self.units[self.steam_turbine_index].STATES.ON + or self.units[self.steam_turbine_index].STATES.STOPPING + ): + # If the steam turbine is starting up, it might be producing power, + # increasing the overall efficiency + efficiency_gas = self.units[self.gas_turbine_index].calculate_efficiency( + self.units[self.gas_turbine_index].power_output + ) + fuel_used = (self.units[self.gas_turbine_index].power_output * 1000.0) / ( + efficiency_gas * self.units[self.gas_turbine_index].hhv + ) return power_output * 1000.0 / (fuel_used * self.units[self.gas_turbine_index].hhv) # Calculate power fraction @@ -320,8 +390,8 @@ def calculate_fuel_volume_rate(self, power_output): # Calculate fuel volume rate using HHV net efficiency # fuel_volume_rate (m³/s) = power (W) / (efficiency * hhv (J/m³)) # Convert power from kW to W (multiply by 1000) - fuel_m3_per_s = (power_output * 1000.0) / (efficiency * self.units[self.gas_turbine_index].hhv) + fuel_m3_per_s = (power_output * 1000.0) / ( + efficiency * self.units[self.gas_turbine_index].hhv + ) return fuel_m3_per_s - - diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index bfb7fba5..4090191c 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -437,7 +437,8 @@ def _control(self, power_setpoint): self.state = self.STATES.COLD_STARTING self.n_cold_starts += 1 self.time_in_state = 0.0 - if hasattr(self, "can_start"): del self.can_start + if hasattr(self, "can_start"): + del self.can_start return 0.0 # Power is always 0 when off diff --git a/hercules/utilities.py b/hercules/utilities.py index 0613cd9d..0ea0bc77 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -273,7 +273,9 @@ def load_hercules_input(filename): if "power_setpoint_schedule" in h_dict["plant"]: schedule = h_dict["plant"]["power_setpoint_schedule"] if not isinstance(schedule, dict): - raise ValueError(f"power_setpoint_schedule must be a dictionary in input file {filename}") + raise ValueError( + f"power_setpoint_schedule must be a dictionary in input file {filename}" + ) if "time" not in schedule or "power_setpoint_fraction" not in schedule: raise ValueError( f"power_setpoint_schedule must contain 'time' and 'power_setpoint_fraction' keys " @@ -281,8 +283,8 @@ def load_hercules_input(filename): ) if len(schedule["time"]) != len(schedule["power_setpoint_fraction"]): raise ValueError( - f"'time' and 'power_setpoint_fraction' lists in power_setpoint_schedule must be the same length " - f"in input file {filename}" + f"'time' and 'power_setpoint_fraction' lists in power_setpoint_schedule " + f"must be the same length in input file {filename}" ) # Validate time and power_setpoint types if not all(isinstance(t, (float, int)) for t in schedule["time"]): @@ -290,13 +292,14 @@ def load_hercules_input(filename): f"All entries in power_setpoint_schedule 'time' list must be floats or ints " f"in input file {filename}" ) - if not all(isinstance(p, (float, int, list, tuple)) for p in schedule["power_setpoint_fraction"]): + if not all( + isinstance(p, (float, int, list, tuple)) for p in schedule["power_setpoint_fraction"] + ): raise ValueError( - f"All entries in power_setpoint_schedule 'power_setpoint_fraction' list must be floats, ints, lists, or tuples " - f"in input file {filename}" + f"All entries in power_setpoint_schedule 'power_setpoint_fraction' list " + f"must be floats, ints, lists, or tuples in input file {filename}" ) - # 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: diff --git a/tests/combined_cycle_plant_test.py b/tests/combined_cycle_plant_test.py index e56153da..68b7d373 100644 --- a/tests/combined_cycle_plant_test.py +++ b/tests/combined_cycle_plant_test.py @@ -24,7 +24,11 @@ def test_invalid_unit_type(): CombinedCyclePlant(h_dict, "combined_cycle_plant") # Additional units not part of combined cycle plant - h_dict["combined_cycle_plant"]["units"] = ["open_cycle_gas_turbine", "steam_turbine", "simple_battery"] + h_dict["combined_cycle_plant"]["units"] = [ + "open_cycle_gas_turbine", + "steam_turbine", + "simple_battery", + ] h_dict["combined_cycle_plant"]["simple_battery"] = copy.deepcopy(simple_battery) with pytest.raises(ValueError): CombinedCyclePlant(h_dict, "combined_cycle_plant") @@ -34,7 +38,7 @@ def test_invalid_unit_type(): h_dict["combined_cycle_plant"]["steam_turbine"]["component_type"] = "InvalidComponent" with pytest.raises(KeyError): CombinedCyclePlant(h_dict, "combined_cycle_plant") - + def test_h_dict_structure(): h_dict = copy.deepcopy(h_dict_combined_cycle_plant) diff --git a/tests/steam_turbine_test.py b/tests/steam_turbine_test.py index c74e408b..95b57964 100644 --- a/tests/steam_turbine_test.py +++ b/tests/steam_turbine_test.py @@ -11,9 +11,7 @@ def test_init_from_dict(): """Test that SteamTurbine can be initialized from a dictionary.""" - st = SteamTurbine( - copy.deepcopy(h_dict_steam_turbine), "steam_turbine" - ) + st = SteamTurbine(copy.deepcopy(h_dict_steam_turbine), "steam_turbine") assert st is not None From f857d04fc986b2f80deb799e9872bb97678283a4 Mon Sep 17 00:00:00 2001 From: Frederik Date: Wed, 18 Mar 2026 15:59:05 -0600 Subject: [PATCH 36/38] Fixed some time stepping bugs for CCGT --- .../input_files/hercules_input_mu-ccgt.yaml | 4 +-- .../plot_outputs.py | 8 ++--- .../plant_components/combined_cycle_plant.py | 34 +++++++++++-------- tests/combined_cycle_plant_test.py | 7 +++- tests/test_inputs/h_dict.py | 2 +- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml index 2dd6100a..dba9633b 100644 --- a/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml +++ b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml @@ -81,8 +81,8 @@ combined_cycle_plant: hot_startup_time: 3600.0 # 1 hour warm_startup_time: 7200.0 # 2 hours cold_startup_time: 14400.0 # 4 hours - # min_up_time: 3600 # 1 hour - # min_down_time: 3600 # 1 hour + min_up_time: 14400 # 4 hour + min_down_time: 7200 # 2 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] diff --git a/examples/08_multi_unit_thermal_plants/plot_outputs.py b/examples/08_multi_unit_thermal_plants/plot_outputs.py index b33e268f..c6c01faf 100644 --- a/examples/08_multi_unit_thermal_plants/plot_outputs.py +++ b/examples/08_multi_unit_thermal_plants/plot_outputs.py @@ -84,6 +84,10 @@ except KeyError: ax.plot(time_minutes, df[f"{component_name}.efficiency"] * 100, label="Efficiency", color="g") +ax.set_ylabel("Thermal efficiency [%]") +ax.grid(True) +ax.legend() + # Fuel consumption ax = axarr[3] try: @@ -105,9 +109,5 @@ ax.grid(True) ax.legend() -ax.set_ylabel("Thermal efficiency [%]") -ax.grid(True) -ax.legend() - plt.tight_layout() plt.show() diff --git a/hercules/plant_components/combined_cycle_plant.py b/hercules/plant_components/combined_cycle_plant.py index 5ece51dc..6993b8c9 100644 --- a/hercules/plant_components/combined_cycle_plant.py +++ b/hercules/plant_components/combined_cycle_plant.py @@ -35,19 +35,14 @@ def __init__(self, h_dict, component_name): "For the combined cycle plant, one of the units must be an open cycle gas turbine." ) - print(generic_units) - print(len(generic_units)) if len(generic_units) != 2: - print(generic_units) raise ValueError( "For the combined cycle plant, there must be exactly two units: " "one steam turbine and one open cycle gas turbine." ) for unit, unit_name in zip(generic_units, self.unit_names): - print(unit) if unit not in ["open_cycle_gas_turbine", "steam_turbine"]: - print(unit) raise ValueError( "For the combined cycle plant, units must be either " "'open_cycle_gas_turbine' or 'steam_turbine'." @@ -205,14 +200,17 @@ def step(self, h_dict): power_setpoint = h_dict[self.component_name]["power_setpoint"] + # Update time in state + for unit in self.units: + unit.time_in_state += unit.dt + # Apply control self.power_output = sum(self.control(power_setpoint)) - # Step each unit for unit, unit_name in zip(self.units, self.unit_names): h_dict_ccgt = h_dict[self.component_name] + h_dict_ccgt = unit.get_initial_conditions_and_meta_data(h_dict_ccgt) h_dict_ccgt[unit_name]["power_setpoint"] = unit.power_setpoint - h_dict_ccgt = unit.step(h_dict_ccgt) self.efficiency = self.calculate_efficiency(self.power_output) @@ -236,13 +234,11 @@ def get_initial_conditions_and_meta_data(self, h_dict): Args: h_dict (dict): Dictionary containing simulation parameters. """ - for unit in self.units: + for unit, unit_name in zip(self.units, self.unit_names): h_dict_ccgt = h_dict[self.component_name] h_dict_ccgt = unit.get_initial_conditions_and_meta_data(h_dict_ccgt) - h_dict[self.component_name]["power"] = sum( - h_dict_ccgt[unit.component_name]["power"] for unit in self.units - ) + h_dict[self.component_name]["power"] = self.power_output # TODO: we likely want to save off data for the individual units to the # h_dict as well. Will need to figure out how to do that. @@ -286,7 +282,9 @@ def control_steam_turbine(self, power_setpoint): ): # If the gas turbine is off or starting up, the steam turbine should be off self.units[self.steam_turbine_index].can_start = False - self.units[self.steam_turbine_index]._control(0.0) + self.units[self.steam_turbine_index].power_output = self.units[ + self.steam_turbine_index + ]._control(0.0) elif ( self.units[self.gas_turbine_index].state == "STOPPING" and self.units[self.steam_turbine_index].power_output > 0 @@ -295,7 +293,9 @@ def control_steam_turbine(self, power_setpoint): ): # If the gas turbine is stopping but the steam turbine is still producing power, # we need to turn off the steam turbine - self.units[self.steam_turbine_index]._control(0.0) + self.units[self.steam_turbine_index].power_output = self.units[ + self.steam_turbine_index + ]._control(0.0) elif ( self.units[self.gas_turbine_index].state == self.units[self.gas_turbine_index].STATES.ON and self.units[self.steam_turbine_index].state @@ -307,10 +307,14 @@ def control_steam_turbine(self, power_setpoint): self.units[self.steam_turbine_index].time_in_state >= self.units[self.steam_turbine_index].min_down_time ) - self.units[self.steam_turbine_index]._control(power_setpoint) + self.units[self.steam_turbine_index].power_output = self.units[ + self.steam_turbine_index + ]._control(power_setpoint) else: # Normal operation - self.units[self.steam_turbine_index]._control(power_setpoint) + self.units[self.steam_turbine_index].power_output = self.units[ + self.steam_turbine_index + ]._control(power_setpoint) return self.units[self.steam_turbine_index].power_output diff --git a/tests/combined_cycle_plant_test.py b/tests/combined_cycle_plant_test.py index 68b7d373..f3a77a6b 100644 --- a/tests/combined_cycle_plant_test.py +++ b/tests/combined_cycle_plant_test.py @@ -61,6 +61,9 @@ def test_h_dict_structure(): # Check that combined cycle plant conditions are recorded correctly assert h_dict["combined_cycle_plant"]["power"] == 1000 + 1000 + print(h_dict["combined_cycle_plant"]["rated_capacity"]) + print(h_dict["combined_cycle_plant"]["ST"]["rated_capacity"]) + def test_step(): h_dict = copy.deepcopy(h_dict_combined_cycle_plant) @@ -68,13 +71,15 @@ def test_step(): tp = CombinedCyclePlant(h_dict, "combined_cycle_plant") # Provide power setpoints to the two units - h_dict["combined_cycle_plant"]["power_setpoint"] = 1500 + h_dict["combined_cycle_plant"]["power_setpoint"] = 500 # Step the plant and check that power is updated correctly h_dict = tp.step(h_dict) power_ocgt = h_dict["combined_cycle_plant"]["OCGT"]["power"] power_steam = h_dict["combined_cycle_plant"]["ST"]["power"] + print(h_dict["combined_cycle_plant"]) + assert power_ocgt < 1000 # Reacts to power setpoint assert power_steam < 1000 # Reacts to power setpoint diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 6e4071b0..1a7ebed5 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -180,7 +180,7 @@ steam_turbine = { "component_type": "SteamTurbine", - "rated_capacity": 500000, # kW (500 MW) + "rated_capacity": 1000, # kW (500 MW) "min_stable_load_fraction": 0.3, # 30% minimum operating point "ramp_rate_fraction": 0.04, # 4%/min ramp rate "run_up_rate_fraction": 0.02, # 2%/min run up rate From e76537833d371f264639003f3f30831b5ae0da03 Mon Sep 17 00:00:00 2001 From: Frederik Date: Wed, 18 Mar 2026 16:06:41 -0600 Subject: [PATCH 37/38] Updated `thermal_component_base_test` to account for zero efficiency when off --- tests/thermal_component_base_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/thermal_component_base_test.py b/tests/thermal_component_base_test.py index ac9b0865..16d8b443 100644 --- a/tests/thermal_component_base_test.py +++ b/tests/thermal_component_base_test.py @@ -410,9 +410,9 @@ def test_efficiency_clamping(): eff_200 = tcb.calculate_efficiency(200) # 200 kW = 20% load (below table min of 0.25) assert eff_200 == pytest.approx(0.30) - # Test at zero power (should return first efficiency value) + # Test at zero power (should return zero efficiency, not extrapolate below the table) eff_0 = tcb.calculate_efficiency(0) - assert eff_0 == pytest.approx(0.30) + assert eff_0 == pytest.approx(0.0) def test_efficiency_interpolation(): From 9cee66d6dcbc3e2623934384578a8b069a19b1b7 Mon Sep 17 00:00:00 2001 From: Frederik Date: Thu, 19 Mar 2026 14:12:31 -0600 Subject: [PATCH 38/38] Added missing docs --- docs/combined_cycle_plant.md | 241 +++++++++++++++++++++++++++++++++++ docs/steam_turbine.md | 2 +- 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 docs/combined_cycle_plant.md diff --git a/docs/combined_cycle_plant.md b/docs/combined_cycle_plant.md new file mode 100644 index 00000000..c710e765 --- /dev/null +++ b/docs/combined_cycle_plant.md @@ -0,0 +1,241 @@ +# Combined Cycle Gas Turbine + +The `CombinedCyclePlant` class models an combined-cycle gas turbine (CCGT), which pairs a gas turbine (or sometimes 2 gas turbines) with a steam turbine to increase efficiency. It therefore combines the units of the {doc}`OpenCycleGasTurbine ` and {doc}`SteamTurbine `. It is a subclass of {doc}`ThermalPlant ` and inherits most state machine behavior, ramp constraints, and operational logic from the base class. What makes this class different from the regular `ThermalPlant`, is that it includes the dependencies between the gas and steam turbine associated with a CCGT (i.e., the steam turbine can only run if the gas turbine is producing power). + +Set `component_type: CombinedCyclePlant` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `combined_cycle_plant`); see [Component Names, Types, and Categories](component_types.md) for details. + +For details on the state machine, startup/shutdown behavior, and base parameters of the individual units, see {doc}`thermal_component_base`. + +## CCGT-Specific Parameters + +Similar to the `ThermalPlant` class, the `CombinedCyclePlant` class does not have many default parameters. Key attributes that must be provided in the YAML configuration file are the `OpenCycleGasTurbine` and `SteamTurbine` `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. + +However, unlike the base `ThermalPlant` class, it is recommended that some parameters are defined outside the individual thermal units. Since the gas turbine does not have its own individual fuel source, instead using the rest heat from gas turbine, it is not possible to specify the efficiency and fuel consumption of the steam turbine in the same way as done for individual components that are not linked. As a result, these outputs are instead calculated for the plant as a whole, necessitating an efficiency table for the unit as a whole. Note that this table is only used when both the gas and steam turbine are running. If only the gas turbine is running, the `OpenCycleGasTurbine` efficiency table is used instead. + +The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the CC1A curve in Exhibit ES-4 of [5] are used. All efficiency values are **HHV (Higher Heating Value) net plant efficiencies**. See {doc}`thermal_component_base` for details on the efficiency table format. + +## Default Parameter Values + +No default parameter values are currently defined, except for the aforementioned `efficiency_table`. See `examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml` for example parameter values. + +### Default Efficiency Table + +The default HHV net plant efficiency table is based on approximate readings from the CC1A (simple cycle) curve in Exhibit ES-4 of [5]: + +| Power Fraction | HHV Net Efficiency | +|---------------|-------------------| +| 1.0 | 0.53 | +| 0.95 | 0.515 | +| 0.90 | 0.52 | +| 0.85 | 0.52 | +| 0.80 | 0.52 | +| 0.75 | 0.52 | +| 0.7 | 0.52 | +| 0.65 | 0.515 | +| 0.6 | 0.505 | +| 0.55 | 0.5 | +| 0.50 | 0.49 | +| 0.4 | 0.47 | + +## OCGT Outputs + +The CCGT plant model provides the following outputs: + +| Output | Units | Description | +|--------|-------|-------------| +| `power` | kW | Actual power output | +| `efficiency` | fraction (0-1) | Current HHV net plant efficiency | +| `fuel_volume_rate` | m³/s | Fuel volume flow rate | +| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` [6]) | + +Subsequently, the individual `OpenCycleGasTurbine` and `SteamTurbine` units provide the following outputs: + +| Output | Units | Description | +|--------|-------|-------------| +| `power` | kW | Actual power output | +| `state` | integer | Operating state number (0-5), corresponding to the `STATES` enum | +| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` [6]) | + +### Efficiency and Fuel Rate + +HHV net plant efficiency varies with load based on the `efficiency_table`. The fuel volume rate is calculated as: + +$$ +\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}} +$$ + +Where: +- `power` is in W (converted from kW internally) +- `efficiency` is the HHV net efficiency interpolated from the efficiency table +- `hhv` is the higher heating value in J/m³ (default 39.05 MJ/m³ for natural gas [6]) +- Result is fuel volume rate in m³/s + +The fuel mass rate is then computed from the volume rate using the fuel density [6]: + +$$ +\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density} +$$ + +Where: +- `fuel_volume_rate` is in m³/s +- `fuel_density` is in kg/m³ (default 0.768 kg/m³ for natural gas [6]) +- Result is fuel mass rate in kg/s + +## YAML configuration + +The YAML configuration for the combined cycle plant includes list `units` and `unit_names`, as then as subdictionaries, list the configuration for each unit. The `component_type` of each unit must be `OpenCycleGasTurbine` or `SteamTurbine`. + +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 `units: ["open_cycle_gas_turbine", "steam_turbine"]`, the YAML file must include two subdictionaries with keys `open_cycle_gas_turbine:` and `steam_turbine:` that specify the parameters and initial conditions for each of the two units. 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: ["OCGT", "ST"]`, then the two gas turbines will be identified as `OCGT` and `ST` in the output file and in the `h_dict`. + +```yaml +plant: + interconnect_limit: 100000 # kW (100 MW) + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 600 + - 3600 + - 15600 + - 21600 + - 28800 + - 32400 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 + +combined_cycle_plant: + component_type: CombinedCyclePlant + units: ["open_cycle_gas_turbine", "steam_turbine"] + unit_names: ["OCGT", "ST"] + + open_cycle_gas_turbine: + component_type: OpenCycleGasTurbine + rated_capacity: 70000 # kW (70 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: 1800.0 # 30 minutes + warm_startup_time: 2700.0 # 45 minutes + cold_startup_time: 2700.0 # 45 minutes + min_up_time: 14400 # 4 hour + min_down_time: 7200 # 2 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 + - state + - power_setpoint + initial_conditions: + power: 70000 # Start ON at rated capacity (70 MW) + + steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 3600.0 # 1 hour + warm_startup_time: 7200.0 # 2 hours + cold_startup_time: 14400.0 # 4 hours + min_up_time: 14400 # 4 hour + min_down_time: 7200 # 2 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.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - state + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (30 MW) + + efficiency_table: + power_fraction: + - 1.0 + - 0.95 + - 0.90 + - 0.85 + - 0.80 + - 0.75 + - 0.7 + - 0.65 + - 0.6 + - 0.55 + - 0.50 + - 0.4 + efficiency: # HHV net plant efficiency, fractions (0-1), from CC1A-F curve in Exhibit ES-4 of [5] + - 0.53 + - 0.515 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.52 + - 0.515 + - 0.505 + - 0.5 + - 0.49 + - 0.47 + + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - efficiency +``` + +## Logging Configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. + +**Available Channels:** +- `power`: Actual power output in kW (always logged) +- `state`: Operating state number (0-5), corresponding to the `STATES` enum (only available for the individual units) +- `fuel_volume_rate`: Fuel volume flow rate in m³/s (only available for the unit as a whole) +- `fuel_mass_rate`: Fuel mass flow rate in kg/s (computed using `fuel_density` [6]) (only available for the unit as a whole) +- `efficiency`: Current HHV net plant efficiency (0-1) (only available for the unit as a whole) +- `power_setpoint`: Requested power setpoint in kW (only available for the unit as a whole) + +## References + +1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants." + +2. "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on Production Cost Simulation", NREL/CP-6A40-87554, National Renewable Energy Laboratory, 2024. + +3. Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly Modelling in Power Systems with Significant Levels of Renewable Generation." Applied Energy 113 (January 2014): 152–58. https://doi.org/10.1016/j.apenergy.2013.07.027. + +4. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi. + +5. M. Oakes, M. Turner, "Cost and Performance Baseline for Fossil Energy Plants, Volume 5: Natural Gas Electricity Generating Units for Flexible Operation," National Energy Technology Laboratory, Pittsburgh, May 5, 2023. + +6. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf diff --git a/docs/steam_turbine.md b/docs/steam_turbine.md index d4b7c61f..b2003a03 100644 --- a/docs/steam_turbine.md +++ b/docs/steam_turbine.md @@ -40,7 +40,7 @@ The default HHV net plant efficiency table is based on [2,3]: | Power Fraction | HHV Net Efficiency | |---------------|-------------------| | 1.00 | 0.35 (35%) | -| 0.5o | 0.32 (32%) | +| 0.50 | 0.32 (32%) | | 0.30 | 0.30 (30%) | ## HCST Outputs