diff --git a/docs/_toc.yml b/docs/_toc.yml index f80d98f7..a133165c 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -32,7 +32,8 @@ parts: - file: electrolyzer - file: thermal_component_base - file: open_cycle_gas_turbine - - file: hard_coal_steam_turbine + - file: steam_turbine + - file: combined_cycle_plant - file: thermal_plant - caption: Inputs chapters: 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/component_types.md b/docs/component_types.md index cc67ad7d..945a387c 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -55,7 +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) | -| `HardCoalSteamTurbine` | `generator` | [Hard Coal Steam Turbine](hard_coal_steam_turbine.md) | +| `SteamTurbine` | `generator` | [Steam Turbine](steam_turbine.md) | +| `CombinedCyclePlant` | `generator` | [Multi-unit Combined Cycle Gas Turbine](combined_cycle_plant.md) | | `ThermalPlant` | `generator` | [Thermal Plant](thermal_plant.md) | Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index 531d2ba6..88f4a13f 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -19,7 +19,7 @@ See [Component Names, Types, and Categories](component_types.md) for a full expl | `BatteryLithiumIon` | `storage` | No | [Battery](battery.md) | | `ElectrolyzerPlant` | `load` | No | [Electrolyzer](electrolyzer.md) | | `OpenCycleGasTurbine` | `generator` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | -| `HardCoalSteamTurbine` | `generator` | [Hard Coal Steam Turbine](hard_coal_steam_turbine.md) | +| `SteamTurbine` | `generator` | [Steam Turbine](steam_turbine.md) | | `ThermalPlant` | `generator` | [Thermal Plant](thermal_plant.md) | The YAML key for each section is a user-chosen `component_name` and is not required to match the category name. For example, a `BatterySimple` component could be named `battery`, `battery_unit_1`, or anything else. diff --git a/docs/hard_coal_steam_turbine.md b/docs/steam_turbine.md similarity index 85% rename from docs/hard_coal_steam_turbine.md rename to docs/steam_turbine.md index c5df30c8..4d9592db 100644 --- a/docs/hard_coal_steam_turbine.md +++ b/docs/steam_turbine.md @@ -1,8 +1,8 @@ -# Hard Coal Steam Turbine +# Steam Turbine -The `HardCoalSteamTurbine` (HCST) 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. +The `SteamTurbine` (HCST) class models a power production plant using steam turbines, for example using hard coal as fuel. 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: HardCoalSteamTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `hard_coal_steam_turbine`); see [Component Names, Types, and Categories](component_types.md) for details. +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`. @@ -19,7 +19,7 @@ The `efficiency_table` parameter is **optional**. If not provided, default value ## Default Parameter Values -The `HardCoalSteamTurbine` class provides default values for base class parameters based on References [1-4]. Only `rated_capacity` and `initial_conditions.power` are required in the YAML configuration. +The `SteamTurbine` class provides default values for base class parameters based on References [1-4]. Only `rated_capacity` and `initial_conditions.power` are required in the YAML configuration. | Parameter | Default Value | Source | |-----------|---------------|--------| @@ -87,8 +87,8 @@ Where: Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters): ```yaml -hard_coal_steam_turbine: - component_type: HardCoalSteamTurbine +steam_turbine: + component_type: SteamTurbine rated_capacity: 100000 # kW (100 MW) initial_conditions: power: 0 # 0 kW means OFF; power > 0 means ON @@ -99,8 +99,8 @@ hard_coal_steam_turbine: All parameters explicitly specified: ```yaml -hard_coal_steam_turbine: - component_type: HardCoalSteamTurbine +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 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 c50c55b6..00000000 --- a/examples/07_open_cycle_gas_turbine/hercules_runscript.py +++ /dev/null @@ -1,90 +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 -""" - -import os -from pathlib import Path - -from hercules.hercules_model import HerculesModel -from hercules.utilities import load_yaml - -os.chdir(os.path.dirname(__file__)) - -hercules_dict = load_yaml(Path(__file__).parent / "hercules_input.yaml") - -# Initialize the Hercules model -hmodel = HerculesModel(hercules_dict) - - -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_thermal_plants/hercules_runscript.py b/examples/07_thermal_plants/hercules_runscript.py new file mode 100644 index 00000000..209f4a89 --- /dev/null +++ b/examples/07_thermal_plants/hercules_runscript.py @@ -0,0 +1,70 @@ +"""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: +- Open Cycle Gas Turbine (OCGT) +- Steam Turbine (ST) +""" + +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: Steam turbine (ST) using hard coal as fuel +# - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) +hmodel = HerculesModel("input_files/hercules_input_ocgt.yaml") + + +class ControllerPassthrough: + """Controller implementing the turbine 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(ControllerPassthrough(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/08_hard_coal_steam_turbine/hercules_input.yaml b/examples/07_thermal_plants/input_files/hercules_input_hcst.yaml similarity index 75% rename from examples/08_hard_coal_steam_turbine/hercules_input.yaml rename to examples/07_thermal_plants/input_files/hercules_input_hcst.yaml index 7ed338a8..069b159a 100644 --- a/examples/08_hard_coal_steam_turbine/hercules_input.yaml +++ b/examples/07_thermal_plants/input_files/hercules_input_hcst.yaml @@ -2,7 +2,7 @@ # Explicitly specify the parameters for demonstration purposes # Name -name: example_08 +name: example_07_1 ### # Describe this simulation setup @@ -16,9 +16,27 @@ 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 +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 diff --git a/examples/07_open_cycle_gas_turbine/hercules_input.yaml b/examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml similarity index 85% rename from examples/07_open_cycle_gas_turbine/hercules_input.yaml rename to examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml index 92e8a913..3135ddba 100644 --- a/examples/07_open_cycle_gas_turbine/hercules_input.yaml +++ b/examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml @@ -2,7 +2,7 @@ # Explicitly specify the parameters for demonstration purposes # Name -name: example_07 +name: example_07_2 ### # Describe this simulation setup @@ -23,6 +23,25 @@ logging: 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) diff --git a/examples/07_open_cycle_gas_turbine/plot_outputs.py b/examples/07_thermal_plants/plot_outputs.py similarity index 74% rename from examples/07_open_cycle_gas_turbine/plot_outputs.py rename to examples/07_thermal_plants/plot_outputs.py index 20cbd362..81c804c0 100644 --- a/examples/07_open_cycle_gas_turbine/plot_outputs.py +++ b/examples/07_thermal_plants/plot_outputs.py @@ -16,6 +16,7 @@ # 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 @@ -24,36 +25,36 @@ # 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[f"{component_name}.power"] / 1000, label="Power Output", color="b") ax.plot( time_minutes, - df["open_cycle_gas_turbine.power_setpoint"] / 1000, + df[f"{component_name}.power_setpoint"] / 1000, label="Power Setpoint", color="r", linestyle="--", ) ax.axhline( - h_dict["open_cycle_gas_turbine"]["rated_capacity"] / 1000, + h_dict[component_name]["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"] + 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.set_title("Thermal Power Plant 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.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"]) @@ -66,7 +67,7 @@ ax = axarr[2] ax.plot( time_minutes, - df["open_cycle_gas_turbine.efficiency"] * 100, + df[f"{component_name}.efficiency"] * 100, label="Efficiency", color="g", ) @@ -78,7 +79,7 @@ ax = axarr[3] ax.plot( time_minutes, - df["open_cycle_gas_turbine.fuel_volume_rate"], + df[f"{component_name}.fuel_volume_rate"], label="Fuel Volume Rate", color="orange", ) 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..e6c084fd --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/hercules_runscript.py @@ -0,0 +1,97 @@ +"""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: +- 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_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_mu-ccgt.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/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 new file mode 100644 index 00000000..dba9633b --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mu-ccgt.yaml @@ -0,0 +1,143 @@ +# 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) + 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 + +controller: + diff --git a/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml new file mode 100644 index 00000000..e7ae3d9b --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/input_files/hercules_input_mutp.yaml @@ -0,0 +1,81 @@ +# 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) + 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 + 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_multi_unit_thermal_plants/plot_outputs.py b/examples/08_multi_unit_thermal_plants/plot_outputs.py new file mode 100644 index 00000000..c6c01faf --- /dev/null +++ b/examples/08_multi_unit_thermal_plants/plot_outputs.py @@ -0,0 +1,113 @@ +# 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 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: + 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 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() + +plt.tight_layout() +plt.show() diff --git a/hercules/component_registry.py b/hercules/component_registry.py index b6d2243f..49a78829 100644 --- a/hercules/component_registry.py +++ b/hercules/component_registry.py @@ -1,7 +1,8 @@ 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.steam_turbine import SteamTurbine 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 @@ -20,8 +21,9 @@ "ElectrolyzerPlant": ElectrolyzerPlant, "OpenCycleGasTurbine": OpenCycleGasTurbine, "ThermalPlant": ThermalPlant, - "HardCoalSteamTurbine": HardCoalSteamTurbine, + "SteamTurbine": SteamTurbine, "PowerPlayback": PowerPlayback, + "CombinedCyclePlant": CombinedCyclePlant, } # Derived from registry keys for validation in utilities.py diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 1ea4ed87..42ead285 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -4,11 +4,12 @@ # 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 # 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 @@ -24,8 +25,9 @@ # "ElectrolyzerPlant": ElectrolyzerPlant, # "OpenCycleGasTurbine": OpenCycleGasTurbine, # "ThermalPlant": ThermalPlant, -# "HardCoalSteamTurbine": HardCoalSteamTurbine, # "PowerPlayback": PowerPlayback, +# "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..6993b8c9 --- /dev/null +++ b/hercules/plant_components/combined_cycle_plant.py @@ -0,0 +1,401 @@ +""" +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 + +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 + + +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 "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." + ) + + if len(generic_units) != 2: + 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): + 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_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.") + elif not issubclass(unit_class, ThermalComponentBase): + raise ValueError( + f"Unit type {unit_type} must be a subclass of ThermalComponentBase." + ) + else: + self.units.append(unit_class(h_dict_ccgt, unit_name)) + self.unit_types.append(unit_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 + ) + + # 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 + 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 + ) + 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: + 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"] + + # 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)) + + 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 + + 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 + + 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, 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"] = 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. + + return 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 + + # 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_setpoint): + """ + 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 + - 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 + ): + # 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].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 + 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].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 + == 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].power_output = self.units[ + self.steam_turbine_index + ]._control(power_setpoint) + else: + # Normal operation + 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 + + 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 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.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 + + # Interpolate efficiency (numpy.interp clamps to endpoints by default) + efficiency = np.interp( + power_fraction, self.efficiency_power_fraction, self.efficiency_values + ) + + 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 diff --git a/hercules/plant_components/hard_coal_steam_turbine.py b/hercules/plant_components/steam_turbine.py similarity index 92% rename from hercules/plant_components/hard_coal_steam_turbine.py rename to hercules/plant_components/steam_turbine.py index 96c66110..6928f703 100644 --- a/hercules/plant_components/hard_coal_steam_turbine.py +++ b/hercules/plant_components/steam_turbine.py @@ -1,7 +1,10 @@ """ -Hard Coal Steam Turbine Class. +Steam Turbine Class. -Hard coal steam turbine model is a subclass of the ThermalComponentBase class. +Steam turbine model is a subclass of the ThermalComponentBase class. +The default values represent a hard coal steam turbine, based on the literature on coal plant +flexibility and typical coal plant parameters. However, the subclass can also be used for +other types of steam turbines by changing the default values as desired. It implements the model as presented in [1], [2], [3], and [4]. Like other subclasses of ThermalComponentBase, it inherits the main control functions, @@ -24,10 +27,10 @@ from hercules.plant_components.thermal_component_base import ThermalComponentBase -class HardCoalSteamTurbine(ThermalComponentBase): - """Hard coal steam turbine model. +class SteamTurbine(ThermalComponentBase): + """Steam turbine model. - This model represents a hard coal steam turbine with state + This model represents a steam turbine with state management, ramp rate constraints, minimum stable load, and fuel consumption tracking. Note it is a subclass of the ThermalComponentBase class. @@ -38,7 +41,7 @@ class HardCoalSteamTurbine(ThermalComponentBase): """ def __init__(self, h_dict, component_name): - """Initialize the HardCoalSteamTurbine class. + """Initialize the SteamTurbine class. Args: h_dict (dict): Dictionary containing simulation parameters including: diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 37d9ff25..eddfa41a 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -428,7 +428,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 @@ -443,6 +446,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 return 0.0 # Power is always 0 when off @@ -624,6 +629,14 @@ def interpolate_efficiency(self, power_output): Returns: float: HHV net efficiency as a fraction (0-1). """ + # NOTE: Not sure if we need this code + # 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 diff --git a/hercules/utilities.py b/hercules/utilities.py index 1ecb0e1c..a75bc48c 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -266,6 +266,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" @@ -308,6 +309,37 @@ 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 " + 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"]): + 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, list, tuple)) for p in schedule["power_setpoint_fraction"] + ): + raise ValueError( + 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 new file mode 100644 index 00000000..f3a77a6b --- /dev/null +++ b/tests/combined_cycle_plant_test.py @@ -0,0 +1,87 @@ +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 + + 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) + + tp = CombinedCyclePlant(h_dict, "combined_cycle_plant") + + # Provide power setpoints to the two units + 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 + + # Total power computed correctly + assert h_dict["combined_cycle_plant"]["power"] == power_ocgt + power_steam diff --git a/tests/hard_coal_steam_turbine_test.py b/tests/hard_coal_steam_turbine_test.py deleted file mode 100644 index 11484ef5..00000000 --- a/tests/hard_coal_steam_turbine_test.py +++ /dev/null @@ -1,107 +0,0 @@ -import copy - -import numpy as np -from hercules.plant_components.hard_coal_steam_turbine import HardCoalSteamTurbine -from hercules.utilities import hercules_float_type - -from .test_inputs.h_dict import ( - h_dict_hard_coal_steam_turbine, -) - - -def test_init_from_dict(): - """Test that HardCoalSteamTurbine can be initialized from a dictionary.""" - hcst = HardCoalSteamTurbine( - copy.deepcopy(h_dict_hard_coal_steam_turbine), "hard_coal_steam_turbine" - ) - assert hcst is not None - - -def test_default_inputs(): - """Test that HardCoalSteamTurbine uses default inputs when not provided.""" - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - - # Test that the ramp_rate_fraction input is correct from input dict - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.ramp_rate_fraction == 0.04 - - # Test that the run_up_rate_fraction input is correct from input dict - assert hcst.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_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.run_up_rate_fraction == hcst.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_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["ramp_rate_fraction"] - del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.ramp_rate_fraction == 0.03 - assert hcst.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_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["ramp_rate_fraction"] - del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"] - del h_dict["hard_coal_steam_turbine"]["cold_startup_time"] - del h_dict["hard_coal_steam_turbine"]["warm_startup_time"] - del h_dict["hard_coal_steam_turbine"]["hot_startup_time"] - del h_dict["hard_coal_steam_turbine"]["min_stable_load_fraction"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.min_stable_load_fraction == 0.30 - assert hcst.hot_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds - assert hcst.warm_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds - assert hcst.cold_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds - - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["min_up_time"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.min_up_time == 48 * 60.0 * 60.0 # 48 hours in seconds - - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["min_down_time"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.min_down_time == 48 * 60.0 * 60.0 # 48 hours in seconds - - -def test_default_hhv(): - """Test that HardCoalSteamTurbine provides default HHV for bituminous coal from [4].""" - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["hhv"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.hhv == 29310000000 - - -def test_default_fuel_density(): - """Test that HardCoalSteamTurbine provides default fuel density.""" - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - if "fuel_density" in h_dict["hard_coal_steam_turbine"]: - del h_dict["hard_coal_steam_turbine"]["fuel_density"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - assert hcst.fuel_density == 1000.0 - - -def test_default_efficiency_table(): - """Test that HardCoalSteamTurbine provides default HHV net efficiency table. - - Default values are taken from [2,3] - """ - h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine) - del h_dict["hard_coal_steam_turbine"]["efficiency_table"] - hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine") - np.testing.assert_array_equal( - hcst.efficiency_power_fraction, - np.array([0.3, 0.5, 1.0], dtype=hercules_float_type), - ) - np.testing.assert_array_equal( - hcst.efficiency_values, - np.array([0.30, 0.32, 0.35], dtype=hercules_float_type), - ) diff --git a/tests/steam_turbine_test.py b/tests/steam_turbine_test.py new file mode 100644 index 00000000..d2d58f88 --- /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.""" + hcst = SteamTurbine( + copy.deepcopy(h_dict_steam_turbine), "steam_turbine" + ) + assert hcst 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 + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.ramp_rate_fraction == 0.04 + + # Test that the run_up_rate_fraction input is correct from input dict + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.run_up_rate_fraction == hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.ramp_rate_fraction == 0.03 + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.min_stable_load_fraction == 0.30 + assert hcst.hot_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert hcst.warm_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + assert hcst.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"] + hcst = SteamTurbine(h_dict, "steam_turbine") + np.testing.assert_array_equal( + hcst.efficiency_power_fraction, + np.array([0.3, 0.5, 1.0], dtype=hercules_float_type), + ) + np.testing.assert_array_equal( + hcst.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..4af96c73 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -158,8 +158,8 @@ }, } -hard_coal_steam_turbine = { - "component_type": "HardCoalSteamTurbine", +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 @@ -417,7 +417,7 @@ "open_cycle_gas_turbine": open_cycle_gas_turbine, } -h_dict_hard_coal_steam_turbine = { +h_dict_steam_turbine = { "dt": 1.0, "starttime": 0.0, "endtime": 10.0, @@ -427,7 +427,7 @@ "step": 0, "time": 0.0, "plant": plant, - "hard_coal_steam_turbine": hard_coal_steam_turbine, + "steam_turbine": steam_turbine, } h_dict_thermal_plant = { @@ -442,9 +442,28 @@ "plant": plant, "thermal_power_plant": { "component_type": "ThermalPlant", - "unit_names": ["OCGT1", "HARD_COAL1"], - "units": ["open_cycle_gas_turbine", "hard_coal_steam_turbine"], + "unit_names": ["OCGT1", "ST1"], + "units": ["open_cycle_gas_turbine", "steam_turbine"], "open_cycle_gas_turbine": open_cycle_gas_turbine, - "hard_coal_steam_turbine": hard_coal_steam_turbine, + "steam_turbine": 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, }, } diff --git a/tests/thermal_component_base_test.py b/tests/thermal_component_base_test.py index 0bc94690..fb430a3b 100644 --- a/tests/thermal_component_base_test.py +++ b/tests/thermal_component_base_test.py @@ -434,7 +434,7 @@ 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 np.isnan(eff_0) diff --git a/tests/thermal_plant_test.py b/tests/thermal_plant_test.py index b84b9e02..31770eb0 100644 --- a/tests/thermal_plant_test.py +++ b/tests/thermal_plant_test.py @@ -10,7 +10,7 @@ def test_init_from_dict(): - # Set up a system with one OCGT and one hard coal steam turbine. + # Set up a system with one OCGT and one steam turbine. h_dict = copy.deepcopy(h_dict_thermal_plant) ThermalPlant(h_dict, "thermal_power_plant") @@ -30,8 +30,8 @@ def test_invalid_unit_type(): 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" + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "steam_turbine"] + h_dict["thermal_power_plant"]["steam_turbine"]["component_type"] = "InvalidComponent" with pytest.raises(ValueError): ThermalPlant(h_dict, "thermal_power_plant") @@ -40,8 +40,8 @@ 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", + "steam_turbine", + "steam_turbine", ] # units and unit_names are unequal length @@ -60,8 +60,8 @@ def test_unit_copies(): # 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" + assert tp.units[1].component_type == "SteamTurbine" + assert tp.units[2].component_type == "SteamTurbine" def test_h_dict_structure(): @@ -71,18 +71,18 @@ def test_h_dict_structure(): # 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 "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 "ST1" 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" + assert h_dict["thermal_power_plant"]["ST1"]["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["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"]["ST1"]["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 + assert h_dict["thermal_power_plant"]["ST1"]["rated_capacity"] == 500000 # Check that thermal plant conditions are recorded correctly assert h_dict["thermal_power_plant"]["power"] == 1000 + 1000 @@ -100,10 +100,10 @@ def test_step(): # 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"] + power_steam = h_dict["thermal_power_plant"]["ST1"]["power"] assert power_ocgt < 1000 # Reacts to power setpoint - assert power_hard_coal < 500000 # Reacts to power setpoint + assert power_steam < 500000 # Reacts to power setpoint # Total power computed correctly - assert h_dict["thermal_power_plant"]["power"] == power_ocgt + power_hard_coal + assert h_dict["thermal_power_plant"]["power"] == power_ocgt + power_steam