diff --git a/docs/combined_cycle_gas_turbine.md b/docs/combined_cycle_gas_turbine.md new file mode 100644 index 00000000..9334a2cc --- /dev/null +++ b/docs/combined_cycle_gas_turbine.md @@ -0,0 +1,189 @@ +# Combined Cycle Gas Turbine + +The `CombinedCycleGasTurbine` class models a combined-cycle gas turbine (CCGT). It is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class. This class represents the combined gas turbine and steam turbine operation as an improvement in the fuel efficiency of the unit and does not model the individual generators. + +For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`. + +## CCGT-Specific Parameters + +The CCGT class provides default values for natural gas properties from [6]: + +| Parameter | Units | Default | Description | +|-----------|-------|---------|-------------| +| `hhv` | J/m³ | 39050000 | Higher heating value of natural gas (39.05 MJ/m³) [6] | +| `fuel_density` | kg/m³ | 0.768 | Fuel density for mass calculations [6] | + +The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the CC1A-F 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 + +The `CombinedCycleGasTurbine` class provides default values for base class parameters based on References [1-5]. Only `rated_capacity` and `initial_conditions` are required in the YAML configuration. + +| Parameter | Default Value | Source | +|-----------|---------------|--------| +| `min_stable_load_fraction` | 0.40 (40%) | [4] | +| `ramp_rate_fraction` | 0.03 (3%/min) | [1] | +| `run_up_rate_fraction` | Same as `ramp_rate_fraction` | — | +| `hot_startup_time` | 4500 s (75 minutes, 1.25 hours) | [1], [5] | +| `warm_startup_time` | 7200 s (120 minutes, 2 hours) | [1], [5] | +| `cold_startup_time` | 10800 s (180 minutes, 3 hours) | [1], [5] | +| `min_up_time` | 14400 s (240 minutes, 4 hours) | [4] | +| `min_down_time` | 7200 s (120 minutes, 2 hours) | [4] | +| `hhv` | 39050000 J/m³ (39.05 MJ/m³) | [6] | +| `fuel_density` | 0.768 kg/m³ | [6] | +| `efficiency_table` | SC1A HHV net efficiency (see below) | Exhibit ES-4 of [5] | + +### Default Efficiency Table + +The default HHV net plant efficiency table is based on approximate readings from the CC1A-F curve in Exhibit ES-4 of [5]: + +| Power Fraction | HHV Net Efficiency | +|---------------|-------------------| +| 1.00 | 0.53 (53%) | +| 0.95 | 0.515 (51.5%) | +| 0.90 | 0.52 (52%) | +| 0.85 | 0.52 (52%) | +| 0.80 | 0.52 (52%) | +| 0.75 | 0.52 (52%) | +| 0.70 | 0.52 (52%) | +| 0.65 | 0.515 (51.5%) | +| 0.60 | 0.505 (50.5%) | +| 0.55 | 0.5 (50%) | +| 0.50 | 0.49 (49%) | +| 0.4 | 0.47 (47%) | + +## CCGT Outputs + +The CCGT 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` [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 + +### Minimal Configuration + +Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters): + +```yaml +combined_cycle_gas_turbine: + component_type: CombinedCycleGasTurbine + 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 +combined_cycle_gas_turbine: + component_type: CombinedCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.4 # 40% 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: 4500.0 # 75 minutes + warm_startup_time: 7200.0 # 120 minutes + cold_startup_time: 10800.0 # 180 minutes + min_up_time: 14400 # 4 hours + min_down_time: 7200 # 2 hours + # 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.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 + - power_setpoint + initial_conditions: + power: 100000 # 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 (computed using `fuel_density` [6]) +- `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. "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/examples/07_open_cycle_gas_turbine.md b/docs/examples/07_open_cycle_gas_turbine.md index eb342482..30d96d5b 100644 --- a/docs/examples/07_open_cycle_gas_turbine.md +++ b/docs/examples/07_open_cycle_gas_turbine.md @@ -14,7 +14,9 @@ The simulation runs for 6 hours with 1-minute time steps. A controller commands | Time (min) | Event Type | Setpoint | State | Description | |------------|------------|----------|-------|-------------| -| 0 | Initial | 0 | OFF (0) | Turbine starts off, `time_in_state` begins counting | +| 0 | Initial | 100 MW | ON (4) | Turbine starts at rated power | +| 10 | Command + State | → 0 | → STOPPING (5) | Shutdown command; `min_up_time` satisfied (initialized to be dispatchable), begins stopping sequence | +| ~20 | State | 0 | → OFF (0) | Power reaches 0 (ramped down at 10 MW/min), turbine off | | 40 | Command | → 100 MW | OFF (0) | Setpoint changes to full power, but `min_down_time` (60 min) not yet satisfied—turbine remains off | | 60 | State | 100 MW | → HOT STARTING (1) | `min_down_time` satisfied, turbine begins hot starting sequence | | ~64 | State | 100 MW | HOT STARTING (1) | `hot_readying_time` (~4.2 min) complete, run-up ramp begins | diff --git a/docs/examples/08_combined_cycle_gas_turbine.md b/docs/examples/08_combined_cycle_gas_turbine.md new file mode 100644 index 00000000..abcbe445 --- /dev/null +++ b/docs/examples/08_combined_cycle_gas_turbine.md @@ -0,0 +1,68 @@ +# Example 08: Combined Cycle Gas Turbine (CCGT) + +## Description + +This example demonstrates a standalone combined-cycle gas turbine (CCGT) simulation. The example showcases the turbine's state machine behavior including startup sequences, power ramping, minimum stable load constraints, and shutdown sequences. + +For details on CCGT parameters and configuration, see {doc}`../combined_cycle_gas_turbine`. For details on the underlying state machine and ramp behavior, see {doc}`../thermal_component_base`. + +## Scenario + +The simulation runs for 10 hours with 1-minute time steps. A controller commands the turbine through several operating phases. The table below shows both **control commands** (setpoint changes) and **state transitions** (responses to commands based on constraints). + +### Timeline + +| Time (min) | Event Type | Setpoint | State | Description | +|------------|------------|----------|-------|-------------| +| 0 | Initial | 100 MW | ON (4) | Turbine starts on at rated power, `time_in_state` begins counting | +| 10 | Command + State | → 0 | → STOPPING (5) | Shutdown command; `min_up_time` satisfied (initialized to be dispatchable), begins stopping sequence | +| ~45 | State | 0 | → OFF (0) | Power reaches 0 (ramped down at 3 MW/min), turbine off | +| 60 | Command | → 100 MW | OFF (0) | Setpoint changes to full power, but `min_down_time` (120 min) not yet satisfied—turbine remains off | +| ~160 | State | 100 MW | → HOT STARTING (1) | `min_down_time` satisfied, turbine begins hot starting sequence | +| ~225 | State | 100 MW | HOT STARTING (1) | `hot_readying_time` (~65 min) complete, run-up ramp begins | +| ~235 | State | 100 MW | → ON (4) | Power reaches P_min (40 MW) after `hot_startup_time` (~75 min), turbine now operational | +| ~260 | Ramp | 100 MW | ON (4) | Power reaches 100 MW (ramped at 3 MW/min from P_min) | +| 260 | Command | → 50 MW | ON (4) | Setpoint reduced to 50% capacity | +| ~276 | Ramp | 50 MW | ON (4) | Power reaches 50 MW (ramped down at 3 MW/min) | +| 360 | Command | → 10 MW | ON (4) | Setpoint reduced to 10% (below P_min), power clamped to P_min (40 MW) | +| ~365 | Ramp | 10 MW | ON (4) | Power reaches P_min (40 MW), cannot go lower | +| 480 | Command | → 100 MW | ON (4) | Setpoint increased to full power | +| ~500 | Ramp | 100 MW | ON (4) | Power reaches 100 MW | +| 540 | Command + State | → 0 | → STOPPING (5) | Shutdown command; `min_up_time` satisfied (~305 min on), begins stopping sequence | +| ~570 | State | 0 | → OFF (0) | Power reaches 0 (ramped down at 3 MW/min), turbine off | +| 600 | End | 0 | OFF (0) | Simulation ends | + +### Key Behaviors Demonstrated + +- **Minimum down time**: The turbine cannot start until `min_down_time` (120 min) is satisfied, even though the command is issued at 60 min +- **Hot startup sequence**: After `min_down_time`, the turbine enters HOT STARTING, waits through `hot_readying_time`, then ramps to P_min using `run_up_rate` +- **Ramp rate constraints**: All power changes in ON state are limited by `ramp_rate` (3 MW/min) +- **Minimum stable load**: When commanded to 10 MW (below P_min = 40 MW), power is clamped to P_min +- **Minimum up time**: Shutdown is allowed immediately at 570 min because `min_up_time` (240 min) was satisfied long ago +- **Stopping sequence**: The turbine ramps down to zero at `ramp_rate` before transitioning to OFF + +## Setup + +No manual setup is required. The example uses only the CCGT component which requires no external data files. + +## 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: +- Power output over time (demonstrating ramp constraints and minimum stable load) +- Operating state transitions +- Fuel consumption tracking +- Heat rate variation with load diff --git a/examples/07_open_cycle_gas_turbine/hercules_input_ccgt.yaml b/examples/07_open_cycle_gas_turbine/hercules_input_ccgt.yaml new file mode 100644 index 00000000..16580a5d --- /dev/null +++ b/examples/07_open_cycle_gas_turbine/hercules_input_ccgt.yaml @@ -0,0 +1,72 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_08 + +### +# Describe this simulation setup +description: Combined 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-01T10:00:00Z" # 12 hours later +verbose: False +log_every_n: 1 +output_file: "outputs/hercules_output_ccgt" + +plant: + interconnect_limit: 100000 # kW (100 MW) + +combined_cycle_gas_turbine: + component_type: CombinedCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.03 # 3%/min ramp rate + hot_startup_time: 4500.0 # 75 minutes + warm_startup_time: 7200.0 # 120 minutes + cold_startup_time: 10800.0 # 180 minutes + min_up_time: 14400 # 4 hours + min_down_time: 7200 # 2 hours + # 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.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 + - power_setpoint + initial_conditions: + power: 100000 # Start ON at rated capacity (100 MW) + +controller: diff --git a/examples/07_open_cycle_gas_turbine/hercules_input.yaml b/examples/07_open_cycle_gas_turbine/hercules_input_ocgt.yaml similarity index 94% rename from examples/07_open_cycle_gas_turbine/hercules_input.yaml rename to examples/07_open_cycle_gas_turbine/hercules_input_ocgt.yaml index a52ea403..fb01ceef 100644 --- a/examples/07_open_cycle_gas_turbine/hercules_input.yaml +++ b/examples/07_open_cycle_gas_turbine/hercules_input_ocgt.yaml @@ -10,9 +10,10 @@ 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 +endtime_utc: "2020-01-01T10:00:00Z" # 6 hours later verbose: False log_every_n: 1 +output_file: "outputs/hercules_output_ocgt" plant: interconnect_limit: 100000 # kW (100 MW) @@ -54,4 +55,3 @@ open_cycle_gas_turbine: 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 index e0d00879..d68cfe0e 100644 --- a/examples/07_open_cycle_gas_turbine/hercules_runscript.py +++ b/examples/07_open_cycle_gas_turbine/hercules_runscript.py @@ -1,18 +1,18 @@ -"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. +""" +Example 07: Open Cycle Gas Turbine (OCGT) simulation. -This example demonstrates a simple open cycle gas turbine (OCGT) that: +This example demonstrates a simple open cycle gas turbine (OCGT) and a combined + cycle turbine (CCGT) in a simulation that compares their performance using the + following schedule of commands: - 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 +- At 60 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At 260 minutes, receives a command to reduce power to 50% of rated capacity +- At 360 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 +- At 480 minutes, receives a command to increase power to 100% of rated capacity +- At 540 minutes (9 hours), receives a shutdown command +- Simulation runs for 10 hours total with 1 minute time steps """ from hercules.hercules_model import HerculesModel @@ -21,10 +21,10 @@ prepare_output_directory() # Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") +hmodel = HerculesModel("hercules_input_ocgt.yaml") -class ControllerOCGT: +class OpenLoopController: """Controller implementing the OCGT schedule described in the module docstring.""" def __init__(self, h_dict): @@ -34,7 +34,13 @@ def __init__(self, h_dict): h_dict (dict): The hercules input dictionary. """ - self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"] + # TODO: Improve this once component-type reconfigured + if "open_cycle_gas_turbine" in h_dict: + self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"] + elif "combined_cycle_gas_turbine" in h_dict: + self.rated_capacity = h_dict["combined_cycle_gas_turbine"]["rated_capacity"] + else: + raise ValueError("No gas turbine component found in input dictionary.") def step(self, h_dict): """Execute one control step. @@ -52,34 +58,52 @@ 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 + 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 < 180 * 60: # 180 minutes in seconds - # Between 120 and 180 minutes: reduce power to 50% of 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["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint + # TODO: Improve this once component-type reconfigured + if "open_cycle_gas_turbine" in h_dict: + h_dict["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint + elif "combined_cycle_gas_turbine" in h_dict: + h_dict["combined_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)) +hmodel.assign_controller(OpenLoopController(hmodel.h_dict)) + +# Run the simulation +print("Running OCGT simulation...") +hmodel.run() + +hmodel.logger.info("Process completed successfully") + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input_ccgt.yaml") + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(OpenLoopController(hmodel.h_dict)) # Run the simulation +print("Running CCGT 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 index 292ec11c..84d1036b 100644 --- a/examples/07_open_cycle_gas_turbine/plot_outputs.py +++ b/examples/07_open_cycle_gas_turbine/plot_outputs.py @@ -4,72 +4,114 @@ from hercules import HerculesOutput # Read the Hercules output file using HerculesOutput -ho = HerculesOutput("outputs/hercules_output.h5") +ho_ocgt = HerculesOutput("outputs/hercules_output_ocgt.h5") +ho_ccgt = HerculesOutput("outputs/hercules_output_ccgt.h5") # Print metadata information print("Simulation Metadata:") -ho.print_metadata() +ho_ocgt.print_metadata() print() # Create a shortcut to the dataframe -df = ho.df +df_ocgt = ho_ocgt.df +df_ccgt = ho_ccgt.df + +col_ocgt = "darkred" +col_ccgt = "darkblue" # Get the h_dict from metadata -h_dict = ho.h_dict +h_dict_ocgt = ho_ocgt.h_dict +h_dict_ccgt = ho_ccgt.h_dict # Convert time to minutes for easier reading -time_minutes = df["time"] / 60 +time_hours_ocgt = df_ocgt["time"] / 60 / 60 +time_hours_ccgt = df_ccgt["time"] / 60 / 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, + h_dict_ocgt["open_cycle_gas_turbine"]["rated_capacity"] / 1000, color="gray", linestyle=":", - label="Rated Capacity", + label="Rated capacity (both)", ) ax.axhline( - h_dict["open_cycle_gas_turbine"]["min_stable_load_fraction"] - * h_dict["open_cycle_gas_turbine"]["rated_capacity"] + h_dict_ocgt["open_cycle_gas_turbine"]["min_stable_load_fraction"] + * h_dict_ocgt["open_cycle_gas_turbine"]["rated_capacity"] / 1000, - color="gray", + color=col_ocgt, + linestyle=":", + label="OCGT min. stable load", +) +ax.axhline( + h_dict_ccgt["combined_cycle_gas_turbine"]["min_stable_load_fraction"] + * h_dict_ccgt["combined_cycle_gas_turbine"]["rated_capacity"] + / 1000, + color=col_ccgt, + linestyle=":", + label="CCGT min. stable load", +) +ax.plot( + time_hours_ocgt, + df_ocgt["open_cycle_gas_turbine.power_setpoint"] / 1000, + label="Power setpoint", + color="k", linestyle="--", - label="Minimum Stable Load", +) +ax.plot( + time_hours_ocgt, + df_ocgt["open_cycle_gas_turbine.power"] / 1000, + label="OCGT output", + color=col_ocgt, +) +ax.plot( + time_hours_ccgt, + df_ccgt["combined_cycle_gas_turbine.power"] / 1000, + label="CCGT output", + color=col_ccgt, + linestyle="-.", ) ax.set_ylabel("Power [MW]") -ax.set_title("Open Cycle Gas Turbine Power Output") -ax.legend() +ax.legend(loc="upper right") 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_hours_ocgt, df_ocgt["open_cycle_gas_turbine.state"], label="OCGT State", color=col_ocgt +) +ax.plot( + time_hours_ccgt, + df_ccgt["combined_cycle_gas_turbine.state"], + label="CCGT State", + color=col_ccgt, + 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.legend(loc="upper right") ax.grid(True) # Plot the efficiency ax = axarr[2] ax.plot( - time_minutes, - df["open_cycle_gas_turbine.efficiency"] * 100, - label="Efficiency", - color="g", + time_hours_ocgt, + df_ocgt["open_cycle_gas_turbine.efficiency"] * 100, + label="OCGT Efficiency", + color=col_ocgt, +) +ax.plot( + time_hours_ccgt, + df_ccgt["combined_cycle_gas_turbine.efficiency"] * 100, + label="CCGT Efficiency", + color=col_ccgt, + linestyle="-.", ) +ax.legend(loc="upper right") +ax.set_ylim(0, 100) ax.set_ylabel("Efficiency [%]") ax.set_title("Thermal Efficiency") ax.grid(True) @@ -77,16 +119,25 @@ # 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", + time_hours_ocgt, + df_ocgt["open_cycle_gas_turbine.fuel_volume_rate"], + label="OCGT Fuel Volume Rate", + color=col_ocgt, +) +ax.plot( + time_hours_ccgt, + df_ccgt["combined_cycle_gas_turbine.fuel_volume_rate"], + label="CCGT Fuel Volume Rate", + color=col_ccgt, + linestyle="-.", ) +ax.legend(loc="upper right") ax.set_ylabel("Fuel [m³/s]") ax.set_title("Fuel Volume Rate") ax.grid(True) -ax.set_xlabel("Time [minutes]") +ax.set_xlabel("Time [hours]") +ax.set_xlim(0, 10) plt.tight_layout() plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 439f85c4..dfc995d3 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_gas_turbine import CombinedCycleGasTurbine 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 @@ -120,6 +121,9 @@ def get_plant_component(self, component_name, h_dict): if component_type == "OpenCycleGasTurbine": return OpenCycleGasTurbine(h_dict) + if component_type == "CombinedCycleGasTurbine": + return CombinedCycleGasTurbine(h_dict) + raise Exception( f"Unknown component_type '{component_type}' for component '{component_name}'" ) diff --git a/hercules/plant_components/combined_cycle_gas_turbine.py b/hercules/plant_components/combined_cycle_gas_turbine.py new file mode 100644 index 00000000..5c47e975 --- /dev/null +++ b/hercules/plant_components/combined_cycle_gas_turbine.py @@ -0,0 +1,158 @@ +""" +Combined Cycle Gas Turbine Class. + +Combined cycle gas turbine (CCGT) 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 CC1A-F curve from Exhibit ES-4 of [5]. + +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 CombinedCycleGasTurbine(ThermalComponentBase): + """Combined cycle gas turbine model. + + This model represents a combined cycle gas 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. + """ + + component_name = "combined_cycle_gas_turbine" + component_type = "CombinedCycleGasTurbine" + + def __init__(self, h_dict): + """Initialize the CombinedCycleGasTurbine 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.03 (3%) + - 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: 4500.0 s (75 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: 7200.0 s (120 minutes/2 hours) [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: 10800.0 s (180 minutes/3 hours) [1, 5] + - min_up_time: Optional, minimum time unit must remain on in s. + Default: 14400.0 s (4 hours) [4] + - min_down_time: Optional, minimum time unit must remain off in s. + Default: 7200.0 s (2 hours) [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 CC1A-F curve in Exhibit ES-4 of [5]: + 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.49, 0.47]. + F-class are typically smaller, older, less efficient: 250 MW + H-class are typically larger, newer, more efficient: 500 MW + """ + + # Apply fixed default parameters based on [1], [2] and [3] + # back into the h_dict if they are not provided + if "min_stable_load_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_stable_load_fraction"] = 0.40 + if "ramp_rate_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["ramp_rate_fraction"] = 0.03 + if "hot_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["hot_startup_time"] = 4500.0 + if "warm_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["warm_startup_time"] = 7200.0 + if "cold_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["cold_startup_time"] = 10800.0 + if "min_up_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_up_time"] = 14400.0 + if "min_down_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_down_time"] = 7200.0 + + # If the run_up_rate_fraction is not provided, it defaults to the ramp_rate_fraction + if "run_up_rate_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["run_up_rate_fraction"] = h_dict[self.component_name][ + "ramp_rate_fraction" + ] + + # Default HHV for natural gas (39.05 MJ/m³) from [6] + if "hhv" not in h_dict[self.component_name]: + h_dict[self.component_name]["hhv"] = 39050000 # J/m³ (39.05 MJ/m³) + + # Default fuel density for natural gas (0.768 kg/m³) from [6] + if "fuel_density" not in h_dict[self.component_name]: + h_dict[self.component_name]["fuel_density"] = 0.768 # kg/m³ + + # Default HHV net plant efficiency table based on approximate readings from + # the CC1A-F curve in Exhibit ES-4 of [5] + if "efficiency_table" not in h_dict[self.component_name]: + h_dict[self.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.49, + 0.47, + ], + } + + # Call the base class init + super().__init__(h_dict) diff --git a/hercules/utilities.py b/hercules/utilities.py index e6a340f0..f34ca880 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -28,14 +28,15 @@ def get_available_component_names(): "battery", "electrolyzer", "open_cycle_gas_turbine", + "combined_cycle_gas_turbine", ] def get_available_generator_names(): """Return available generator component names. - Returns power generators (wind_farm, solar_farm, open_cycle_gas_turbine), excluding - storage and conversion components. + Returns power generators (wind_farm, solar_farm, open_cycle_gas_turbine, + combined_cycle_gas_turbine), excluding storage and conversion components. Returns: list: Available generator component names. @@ -44,6 +45,7 @@ def get_available_generator_names(): "wind_farm", "solar_farm", "open_cycle_gas_turbine", + "combined_cycle_gas_turbine", ] @@ -62,6 +64,7 @@ def get_available_component_types(): "battery": ["BatterySimple", "BatteryLithiumIon"], "electrolyzer": ["ElectrolyzerPlant"], "open_cycle_gas_turbine": ["OpenCycleGasTurbine"], + "combined_cycle_gas_turbine": ["CombinedCycleGasTurbine"], } diff --git a/tests/combined_cycle_gas_turbine_test.py b/tests/combined_cycle_gas_turbine_test.py new file mode 100644 index 00000000..4daee652 --- /dev/null +++ b/tests/combined_cycle_gas_turbine_test.py @@ -0,0 +1,116 @@ +import copy + +import numpy as np +from hercules.plant_components.combined_cycle_gas_turbine import CombinedCycleGasTurbine +from hercules.utilities import hercules_float_type + +from .test_inputs.h_dict import ( + h_dict_combined_cycle_gas_turbine, +) + + +def test_init_from_dict(): + """Test that CombinedCycleGasTurbine can be initialized from a dictionary.""" + ccgt = CombinedCycleGasTurbine(copy.deepcopy(h_dict_combined_cycle_gas_turbine)) + assert ccgt is not None + + +def test_default_inputs(): + """Test that CombinedCycleGasTurbine uses default inputs when not provided.""" + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + + # Test that the ramp_rate_fraction is 0.1 (from test fixture) + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.ramp_rate_fraction == 0.1 + + # Test that the run_up_rate_fraction is 0.05 (from test fixture) + assert ccgt.run_up_rate_fraction == 0.05 + + # Test that if the run_up_rate_fraction is not provided, + # it defaults to the ramp_rate_fraction + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["run_up_rate_fraction"] + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.run_up_rate_fraction == ccgt.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_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["ramp_rate_fraction"] + del h_dict["combined_cycle_gas_turbine"]["run_up_rate_fraction"] + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.ramp_rate_fraction == 0.03 + assert ccgt.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_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["ramp_rate_fraction"] + del h_dict["combined_cycle_gas_turbine"]["run_up_rate_fraction"] + del h_dict["combined_cycle_gas_turbine"]["cold_startup_time"] + del h_dict["combined_cycle_gas_turbine"]["warm_startup_time"] + del h_dict["combined_cycle_gas_turbine"]["hot_startup_time"] + del h_dict["combined_cycle_gas_turbine"]["min_stable_load_fraction"] + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.min_stable_load_fraction == 0.40 + assert ccgt.hot_startup_time == 75 * 60.0 + assert ccgt.warm_startup_time == 120 * 60.0 + assert ccgt.cold_startup_time == 180 * 60.0 + + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["min_up_time"] + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.min_up_time == 4 * 60 * 60.0 + + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["min_down_time"] + ccgt = CombinedCycleGasTurbine(h_dict) + assert ccgt.min_down_time == 2 * 60 * 60.0 + + +def test_default_hhv(): + """Test that CombinedCycleGasTurbine provides default HHV for natural gas from [6].""" + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["hhv"] + ccgt = CombinedCycleGasTurbine(h_dict) + # Default HHV for natural gas is 39.05 MJ/m³ = 39,050,000 J/m³ from [6] + assert ccgt.hhv == 39050000 + + +def test_default_fuel_density(): + """Test that CombinedCycleGasTurbine provides default fuel density from [6].""" + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + if "fuel_density" in h_dict["combined_cycle_gas_turbine"]: + del h_dict["combined_cycle_gas_turbine"]["fuel_density"] + ccgt = CombinedCycleGasTurbine(h_dict) + # Default fuel density for natural gas is 0.768 kg/m³ from [6] + assert ccgt.fuel_density == 0.768 + + +def test_default_efficiency_table(): + """Test that CombinedCycleGasTurbine provides default HHV net efficiency table from [5]. + + Default values are approximate readings from the CC1A-F curve in + Exhibit ES-4 of [5]. + """ + + h_dict = copy.deepcopy(h_dict_combined_cycle_gas_turbine) + del h_dict["combined_cycle_gas_turbine"]["efficiency_table"] + ccgt = CombinedCycleGasTurbine(h_dict) + # Default HHV net plant efficiency from CC1A-F curve in Exhibit ES-4 of [5] + np.testing.assert_array_equal( + ccgt.efficiency_power_fraction, + np.array( + [0.4, 0.50, 0.55, 0.6, 0.65, 0.70, 0.75, 0.80, 0.85, 0.9, 0.95, 1.0], + dtype=hercules_float_type, + ), + ) + np.testing.assert_array_equal( + ccgt.efficiency_values, + np.array( + [0.47, 0.49, 0.5, 0.505, 0.515, 0.52, 0.52, 0.52, 0.52, 0.52, 0.515, 0.53], + dtype=hercules_float_type, + ), + ) diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 6b614fba..0bf59ea7 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -125,6 +125,7 @@ }, } + open_cycle_gas_turbine = { "component_type": "OpenCycleGasTurbine", "rated_capacity": 1000, # kW (1 MW) @@ -153,6 +154,34 @@ } +combined_cycle_gas_turbine = { + "component_type": "CombinedCycleGasTurbine", + "rated_capacity": 1000, # kW (1 MW) + "min_stable_load_fraction": 0.40, # 40% minimum operating point + "ramp_rate_fraction": 0.10, # 10% of rated capacity per minute + "run_up_rate_fraction": 0.05, # 5% of rated capacity per minute + "hot_startup_time": 1200.0, # s (must be >= run_up_rate_fraction of 60s) + "warm_startup_time": 1200.0, # s (must be >= ramp_time of 60s) + "cold_startup_time": 1200.0, # s (must be >= ramp_time of 60s) + "min_up_time": 10.0, # s + "min_down_time": 10.0, # s + "log_channels": [ + "power", + "state", + "efficiency", + "fuel_volume_rate", + "fuel_mass_rate", + ], + "initial_conditions": {"power": 1000}, # power > 0 implies ON state + "hhv": 39050000, # J/m³ (natural gas HHV from [6]) + # HHV net plant efficiency from SC1A curve in Exhibit ES-4 of [5] + "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.49, 0.47], + }, +} + + electrolyzer = { "component_type": "ElectrolyzerPlant", "initial_conditions": { @@ -380,3 +409,16 @@ "plant": plant, "open_cycle_gas_turbine": open_cycle_gas_turbine, } + +h_dict_combined_cycle_gas_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, + "combined_cycle_gas_turbine": combined_cycle_gas_turbine, +}