diff --git a/docs/_toc.yml b/docs/_toc.yml index e40a8940..5456adba 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -31,6 +31,8 @@ parts: - file: electrolyzer - file: thermal_component_base - file: open_cycle_gas_turbine + - file: steam_turbine + - file: combined_cycle_plant - caption: Inputs chapters: - file: hercules_input diff --git a/docs/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 2ddd1f60..9e14387f 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -55,6 +55,8 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro | `BatteryLithiumIon` | `storage` | [Battery](battery.md) | | `ElectrolyzerPlant` | `load` | [Electrolyzer](electrolyzer.md) | | `OpenCycleGasTurbine` | `generator` | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) | +| `SteamTurbine` | `generator` | [Steam Turbine](steam_turbine.md) | +| `CombinedCyclePlant` | `generator` | [Multi-unit Combined Cycle Gas Turbine](combined_cycle_plant.md) | Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`. diff --git a/docs/examples/09_multiunit_thermal_plant.md b/docs/examples/09_multiunit_thermal_plant.md new file mode 100644 index 00000000..d8da5f47 --- /dev/null +++ b/docs/examples/09_multiunit_thermal_plant.md @@ -0,0 +1,27 @@ +# Example 09: Multi-unit Thermal Plant + +## Description + +Demonstrates a multi-unit thermal plant with two Open Cycle Gas Turbine (OCGT) units. Each unit has its own state machine and ramp behavior, but they share a common controller that issues power setpoints for both units simultaneously. The example illustrates how the plant responds to changes in setpoints while respecting constraints such as minimum up/down times, ramp rates, and minimum stable load of the individual units. The individual units are identical, but their commands and responses are tracked separately in the outputs. + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` + +## Outputs + +To plot the outputs, run: + +```bash +python plot_outputs.py +``` + +The plot shows (for the two units separately): +- Power output over time (demonstrating ramp constraints and minimum stable load in response to setpoint changes for the individual units), as well as total plant power output +- Operating state transitions +- Fuel consumption tracking +- Heat rate variation with load diff --git a/docs/steam_turbine.md b/docs/steam_turbine.md new file mode 100644 index 00000000..b2003a03 --- /dev/null +++ b/docs/steam_turbine.md @@ -0,0 +1,155 @@ +# Hard Coal Steam Turbine + +The `SteamTurbine` (ST) class models a hard coal power production plant using steam turbines. This class is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class. + +Set `component_type: SteamTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `steam_turbine`); see [Component Names, Types, and Categories](component_types.md) for details. + +For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`. + +## ST-Specific Parameters + +The ST class provides default values for bituminous coal properties from [4]: + +| Parameter | Units | Default | Description | +|-----------|-------|---------|-------------| +| `hhv` | J/m³ | 29310000000 | Higher heating value of bituminous coal (29.31 MJ/m³) [4] | +| `fuel_density` | kg/m³ | 1000 | Fuel density for mass calculations | + +The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the [2] are used. All efficiency values are **HHV (Higher Heating Value) net plant efficiencies**. See {doc}`thermal_component_base` for details on the efficiency table format. + +## Default Parameter Values + +The `SteamTurbine` class provides default values for base class parameters based on References [1-4]. Only `rated_capacity` and `initial_conditions` are required in the YAML configuration. + +| Parameter | Default Value | Source | +|-----------|---------------|--------| +| `min_stable_load_fraction` | 0.30 (30%) | [2] | +| `ramp_rate_fraction` | 0.03 (3%/min) | [1] | +| `run_up_rate_fraction` | Same as `ramp_rate_fraction` | — | +| `hot_startup_time` | 7.5 hours | [1] | +| `warm_startup_time` | 7.5 hours | [1] | +| `cold_startup_time` | 7.5 hours | [1] | +| `min_up_time` | 48 hours | [2] | +| `min_down_time` | 48 hours | [2] | +| `efficiency_table` | Average plant efficiency | [2,3] | + +### Default Efficiency Table + +The default HHV net plant efficiency table is based on [2,3]: + +| Power Fraction | HHV Net Efficiency | +|---------------|-------------------| +| 1.00 | 0.35 (35%) | +| 0.50 | 0.32 (32%) | +| 0.30 | 0.30 (30%) | + +## HCST Outputs + +The HCST model provides the following outputs (inherited from base class): + +| Output | Units | Description | +|--------|-------|-------------| +| `power` | kW | Actual power output | +| `state` | integer | Operating state number (0-5), corresponding to the `STATES` enum | +| `efficiency` | fraction (0-1) | Current HHV net plant efficiency | +| `fuel_volume_rate` | m³/s | Fuel volume flow rate | +| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` | + +### Efficiency and Fuel Rate + +HHV net plant efficiency varies with load based on the `efficiency_table`. The fuel volume rate is calculated as: + +$$ +\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}} +$$ + +Where: +- `power` is in W (converted from kW internally) +- `efficiency` is the HHV net efficiency interpolated from the efficiency table +- `hhv` is the higher heating value in J/m³ +- Result is fuel volume rate in m³/s + +The fuel mass rate is then computed from the volume rate using the fuel density: + +$$ +\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density} +$$ + +Where: +- `fuel_volume_rate` is in m³/s +- `fuel_density` is in kg/m³ +- Result is fuel mass rate in kg/s + +## YAML Configuration + +### Minimal Configuration + +Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters): + +```yaml +steam_turbine: + component_type: SteamTurbine + rated_capacity: 100000 # kW (100 MW) + initial_conditions: + power: 0 # 0 kW means OFF; power > 0 means ON +``` + +### Full Configuration + +All parameters explicitly specified: + +```yaml +steam_turbine: + component_type: SteamTurbine + rated_capacity: 500000 # kW (500 MW) + min_stable_load_fraction: 0.3 # 30% minimum operating point + ramp_rate_fraction: 0.03 # 3%/min ramp rate + run_up_rate_fraction: 0.02 # 2%/min run up rate + hot_startup_time: 27000.0 # 7.5 hours + warm_startup_time: 27000.0 # 7.5 hours + cold_startup_time: 27000.0 # 7.5 hours + min_up_time: 172800 # 48 hours + min_down_time: 172800 # 48 hour + hhv: 29310000000 # J/m³ for bituminous coal (29.31 MJ/m³) [4] + fuel_density: 1000 # kg/m³ for bituminous coal + efficiency_table: + power_fraction: + - 1.0 + - 0.50 + - 0.30 + efficiency: # HHV net plant efficiency, fractions (0-1) + - 0.35 + - 0.32 + - 0.32 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 0 # 0 kW means OFF; power > 0 means ON +``` + +## Logging Configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. + +**Available Channels:** +- `power`: Actual power output in kW (always logged) +- `state`: Operating state number (0-5), corresponding to the `STATES` enum +- `fuel_volume_rate`: Fuel volume flow rate in m³/s +- `fuel_mass_rate`: Fuel mass flow rate in kg/s +- `efficiency`: Current HHV net plant efficiency (0-1) +- `power_setpoint`: Requested power setpoint in kW + +## References + +1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants." + +2. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi. + +3. T. Schmitt, S. Leptinsky, M. Turner, A. Zoelle, C. White, S. Hughes, S. Homsy, et al. “Cost And Performance Baseline for Fossil Energy Plants Volume 1: Bituminous Coal and Natural Gas Electricity.” Pittsburgh, PA: National Energy Technology Laboratory, October 14, 2022b. https://doi.org/10.2172/1893822. + +4. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf diff --git a/docs/thermal_plant.md b/docs/thermal_plant.md new file mode 100644 index 00000000..ec23b7d9 --- /dev/null +++ b/docs/thermal_plant.md @@ -0,0 +1,94 @@ +# Thermal Plant + +The `ThermalPlant` class models generic single or multiunit thermal power plants. It expects to be assigned one or more thermal units, for example [`OpenCycleGasTurbine`s](open_cycle_gas_turbine.md). The individual units are established in the YAML configuration file, +and may be repeats of the same type of units or heterogeneous units. + +Set `component_type: OpenCycleGasTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `open_cycle_gas_turbine`); see [Component Names, Types, and Categories](component_types.md) for details. + +For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`. + +## Parameters + +The `ThermalPlant` class does not have any default parameters. However, key attributes that must be provided in the YAML configuration file are `units`, which is a list that is used to instantiate the individual thermal units that make up the plant, and `unit_names`, which is a list of unique names for each unit. The number of entries in `units` and `unit_names` must match. + +See the [YAML Configuration](#yaml-configuration) section below for examples of how to specify these parameters in the input file. + +## YAML configuration + +The YAML configuration for the thermal plant includes list `units` and `unit_names`, as then as subdictionaries, list the configuration for each unit. The `component_type` of each unit must be a valid thermal component type, e.g. `OpenCycleGasTurbine`. See [Component Types](component_types.md) for the full list of available component types. + +The units listed under the `units` field are used to index the subdictionaries for each unit, which specify the parameters and initial conditions for each unit. For example, if `units: ["open_cycle_gas_turbine", "open_cycle_gas_turbine"]`, then the YAML file must include two subdictionaries with keys `open_cycle_gas_turbine:` that specify the parameters and initial conditions for each of the two gas turbines. The `unit_names` field is a list of unique names for each unit, which are used to identify the units in the HDF5 output file and in the `h_dict` passed to controllers. For example, if `unit_names: ["OCGT1", "OCGT2"]`, then the two gas turbines will be identified as `OCGT1` and `OCGT2` in the output file and in the `h_dict`. + +```yaml +my_thermal_plant: + component_type: ThermalPlant + units: ["large_ocgt", "large_ocgt", "small_ocgt"] + unit_names: ["OCGT1", "OCGT2", "OCGT3"] + + large_ocgt: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.1 # 10%/min ramp rate + run_up_rate_fraction: 0.05 # 5%/min run up rate + hot_startup_time: 420.0 # 7 minutes + warm_startup_time: 480.0 # 8 minutes + cold_startup_time: 480.0 # 8 minutes + min_up_time: 1800 # 30 minutes + min_down_time: 3600 # 1 hour + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) + fuel_density: 0.768 # kg/m³ for natural gas + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: + - 0.39 + - 0.37 + - 0.325 + - 0.245 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 0 + + small_ocgt: + component_type: OpenCycleGasTurbine + rated_capacity: 50000 # kW (50 MW) + min_stable_load_fraction: 0.4 # 40% minimum operating point + ramp_rate_fraction: 0.15 # 15%/min ramp rate + run_up_rate_fraction: 0.1 # 10%/min run up rate + hot_startup_time: 300.0 # 5 minutes + warm_startup_time: 360.0 # 6 minutes + cold_startup_time: 420.0 # 7 minutes + min_up_time: 1200 # 20 minutes + min_down_time: 2400 # 40 minutes + hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6] + fuel_density: 0.768 # kg/m + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: + - 0.38 + - 0.36 + - 0.32 + - 0.22 + log_channels: + - power + initial_conditions: + power: 0 +``` + +## Logging configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. Logging is configured separately for each unit, so the `log_channels` field is specified within each unit's subdictionary. For example, if `unit_names: ["OCGT1", "OCGT1"]`, then the log will have columns `my_thermal_plant.OCGT1.power`, `my_thermal_plant.OCGT1.fuel_volume_rate`, etc. for the first unit, and `my_thermal_plant.OCGT2.power`, `my_thermal_plant.OCGT2.fuel_volume_rate`, etc. for the second unit, assuming those channels are included in the `log_channels` list for each unit. The total power for the thermal plant is always logged to `my_thermal_plant.power`, which is the sum of the power outputs of each unit. diff --git a/examples/07_open_cycle_gas_turbine/hercules_runscript.py b/examples/07_open_cycle_gas_turbine/hercules_runscript.py deleted file mode 100644 index e0d00879..00000000 --- a/examples/07_open_cycle_gas_turbine/hercules_runscript.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. - -This example demonstrates a simple open cycle gas turbine (OCGT) that: -- Starts on at rated capacity (100 MW) -- At 10 minutes, receives a shutdown command and begins ramping down -- At ~20 minutes, reaches 0 MW and transitions to off -- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity -- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting -- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity -- At 120 minutes, receives a command to reduce power to 50% of rated capacity -- At 180 minutes, receives a command to reduce power to 10% of rated capacity - (note this is below the minimum stable load) -- At 210 minutes, receives a command to increase power to 100% of rated capacity -- At 240 minutes (4 hours), receives a shutdown command -- Simulation runs for 6 hours total with 1 minute time steps -""" - -from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory - -prepare_output_directory() - -# Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") - - -class ControllerOCGT: - """Controller implementing the OCGT schedule described in the module docstring.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - - """ - self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"] - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - - """ - current_time = h_dict["time"] - - # Determine power setpoint based on time - if current_time < 10 * 60: # 10 minutes in seconds - # Before 10 minutes: run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 40 * 60: # 40 minutes in seconds - # Between 10 and 40 minutes: shut down - power_setpoint = 0.0 - elif current_time < 120 * 60: # 120 minutes in seconds - # Between 40 and 120 minutes: signal to run at full capacity - power_setpoint = self.rated_capacity - elif current_time < 180 * 60: # 180 minutes in seconds - # Between 120 and 180 minutes: reduce power to 50% of rated capacity - power_setpoint = 0.5 * self.rated_capacity - elif current_time < 210 * 60: # 210 minutes in seconds - # Between 180 and 210 minutes: reduce power to 10% of rated capacity - power_setpoint = 0.1 * self.rated_capacity - elif current_time < 240 * 60: # 240 minutes in seconds - # Between 210 and 240 minutes: increase power to 100% of rated capacity - power_setpoint = self.rated_capacity - else: - # After 240 minutes: shut down - power_setpoint = 0.0 - - h_dict["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint - - return h_dict - - -# Instantiate the controller and assign to the Hercules model -hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) - -# Run the simulation -hmodel.run() - -hmodel.logger.info("Process completed successfully") diff --git a/examples/07_thermal_plants/hercules_runscript.py b/examples/07_thermal_plants/hercules_runscript.py new file mode 100644 index 00000000..d4bfaf00 --- /dev/null +++ b/examples/07_thermal_plants/hercules_runscript.py @@ -0,0 +1,75 @@ +"""Example 07: Thermal power plant simulation. + +This example demonstrates simple thermal units that follow a reference power setpoint. +The power setpoint schedule is defined in the hercules_input_[unit].yaml file and the +controller follows that schedule. The outputs of the simulation are plotted in the +plot_outputs.py script. +The following thermal power plants are currently available for simulation: +- Hard Coal Steam Turbine (HCST) +- Open Cycle Gas Turbine (OCGT) +- Steam Turbine (ST) +""" + +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory + +prepare_output_directory() + +# Initialize the Hercules model +# Select which thermal plant you want to simulate by changing the yaml file +# Currenctly available: +# - hercules_input_hcst.yaml: Hard coal steam turbine (HCST) +# - hercules_input_ocgt.yaml: Open Cycle Gas Turbine (OCGT) +# - hercules_input_st.yaml: Steam Turbine (ST) +# - hercules_input_mu-ccgt.yaml: Combined Cycle Gas Turbine (CCGT) modeled as +# individual gas and steam turbines with a coupling constraint +# - hercules_inputs_mutp.yaml: Multi-unit thermal plants with 2 OCGTs +hmodel = HerculesModel("input_files/hercules_input_hcst.yaml") + + +class ControllerOCGT: + """Controller implementing the OCGT schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.component_name = h_dict["component_names"][0] + self.rated_capacity = h_dict[self.component_name]["rated_capacity"] + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on schedule provided in yaml file + time_index = ( + sum(current_time >= t for t in h_dict["plant"]["power_setpoint_schedule"]["time"]) - 1 + ) + power_setpoint = ( + h_dict["plant"]["power_setpoint_schedule"]["power_setpoint_fraction"][time_index] + * self.rated_capacity + ) + + h_dict[self.component_name]["power_setpoint"] = power_setpoint + + return h_dict + + +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/08_hard_coal_steam_turbine/hercules_input.yaml b/examples/07_thermal_plants/input_files/hercules_input_hcst.yaml similarity index 78% 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..4bbc6f32 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,6 +16,24 @@ 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 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 81% rename from examples/07_open_cycle_gas_turbine/hercules_input.yaml rename to examples/07_thermal_plants/input_files/hercules_input_ocgt.yaml index a52ea403..29aacff5 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 @@ -14,9 +14,30 @@ endtime_utc: "2020-01-01T06:00:00Z" # 6 hours later verbose: False log_every_n: 1 + + plant: interconnect_limit: 100000 # kW (100 MW) + # Define the power setpoint schedule for the plant + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 600 + - 2400 + - 7200 + - 10800 + - 12600 + - 14400 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 + open_cycle_gas_turbine: component_type: OpenCycleGasTurbine rated_capacity: 100000 # kW (100 MW) diff --git a/examples/07_thermal_plants/input_files/hercules_input_st.yaml b/examples/07_thermal_plants/input_files/hercules_input_st.yaml new file mode 100644 index 00000000..a935974c --- /dev/null +++ b/examples/07_thermal_plants/input_files/hercules_input_st.yaml @@ -0,0 +1,75 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_07_2 + +### +# Describe this simulation setup +description: Steam Turbine (ST) Example + +dt: 60.0 # 1 minute time step +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC +endtime_utc: "2020-01-01T18:00:00Z" # 18 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 30000 # kW (30 MW) + # Define the power setpoint schedule for the plant + power_setpoint_schedule: + time: # Time in seconds from start + - 0 + - 1800 + - 7200 + - 28800 + - 37800 + - 54000 + - 61200 + power_setpoint_fraction: + - 1.0 + - 0.0 + - 1.0 + - 0.5 + - 0.1 + - 1.0 + - 0.0 + +steam_turbine: + component_type: SteamTurbine + rated_capacity: 30000 # kW (30 MW) + min_stable_load_fraction: 0.1 # 10% minimum operating point + ramp_rate_fraction: 0.05 # 5%/min ramp rate + run_up_rate_fraction: 0.03 # 3%/min run up rate + hot_startup_time: 7200.0 # 2 hours + warm_startup_time: 14400.0 # 4 hours + cold_startup_time: 28800.0 # 8 hours + min_up_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + min_down_time: 3600 # 1 hour, placeholder, should probably be longer for a steam turbine + # Carbon coal HHV from https://www.engineeringtoolbox.com/fuels-higher-calorific-values-d_169.html + # Conversion from MJ/kg to MJ/m³ using density of 1500 kg/m³ + hhv: 21860 # MJ/m³ for coal (32.8 MJ/kg) + fuel_density: 1500 # kg/m³ for coal (Googled, better source needed) + efficiency_table: + power_fraction: + - 1.0 + - 0.75 + - 0.50 + - 0.25 + efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5] + - 0.14 + - 0.15 + - 0.165 + - 0.17 + log_channels: + - power + - fuel_volume_rate + - fuel_mass_rate + - state + - efficiency + - power_setpoint + initial_conditions: + power: 30000 # Start ON at rated capacity (100 MW) + +controller: + diff --git a/examples/07_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 292ec11c..2171145c 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/hercules_model.py b/hercules/hercules_model.py index c1ad5cca..dfe78b18 100644 --- a/hercules/hercules_model.py +++ b/hercules/hercules_model.py @@ -377,6 +377,18 @@ def numpy_serializer(obj): else: raise ValueError(f"Output {c} not found in {component_name}") + if "units" in self.h_dict[component_name]: + for unit in component_obj.units: + unit_name = unit.component_name + for c in unit.log_channels: + dataset_name = f"{component_name}.{unit_name}.{c}" + self.hdf5_datasets[dataset_name] = components_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + # Create external signals datasets if "external_signals" in self.h_dict and self.h_dict["external_signals"]: external_signals_group = data_group.create_group("external_signals") @@ -712,6 +724,16 @@ def _log_data_to_hdf5(self): if dataset_name in self.data_buffers: self.data_buffers[dataset_name][self.buffer_row] = output_value + if "units" in self.h_dict[component_name]: + for unit in component_obj.units: + unit_name = unit.component_name + for c in unit.log_channels: + dataset_name = f"{component_name}.{unit_name}.{c}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = self.h_dict[ + component_name + ][unit_name][c] + # Buffer external signals (only those specified in log_channels) if "external_signals" in self.h_dict and self.h_dict["external_signals"]: for signal_name, signal_value in self.h_dict["external_signals"].items(): diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 77864aa7..e46495d1 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -2,11 +2,14 @@ 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 @@ -20,8 +23,11 @@ "BatteryLithiumIon": BatteryLithiumIon, "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/component_base.py b/hercules/plant_components/component_base.py index dae43879..600af4d4 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -137,3 +137,7 @@ def close_logging(self): for handler in self.logger.handlers[:]: handler.close() self.logger.removeHandler(handler) + + def step(self, h_dict): + """Raise error if step is called on the abstract base class.""" + raise NotImplementedError("Components must implement the step() method") diff --git a/hercules/plant_components/steam_turbine.py b/hercules/plant_components/steam_turbine.py new file mode 100644 index 00000000..c674e2ba --- /dev/null +++ b/hercules/plant_components/steam_turbine.py @@ -0,0 +1,54 @@ +""" +Steam turbine model, for use as a standalone component or part of a multi-unit thermal plant. +""" + +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class SteamTurbine(ThermalComponentBase): + """Steam turbine model. + + This model represents an steam turbine with state management, ramp rate constraints, minimum + stable load, and fuel consumption tracking. Note it is a subclass of the ThermalComponentBase + class. + + All efficiency values are HHV (Higher Heating Value) net plant efficiencies. + """ + + def __init__(self, h_dict, component_name): + """Initialize the SteamTurbine class. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + Defaults are specified below. + component_name (str): Unique name for this component instance. + """ + + # Specify default parameter values + default_parameters_steam_turbine = { + "min_stable_load_fraction": 0.30, + "ramp_rate_fraction": 0.03, + "hot_startup_time": 27000.0, + "warm_startup_time": 27000.0, + "cold_startup_time": 27000.0, + "min_up_time": 172800.0, + "min_down_time": 172800.0, + "hhv": 29310000000, # J/m³ + "fuel_density": 1000, # kg/m³ + "efficiency_table": { + "power_fraction": [1.0, 0.5, 0.3], + "efficiency": [0.35, 0.32, 0.30], + }, + } + + # Update the input dictionary with default values for any missing parameters + h_dict[component_name] = default_parameters_steam_turbine | h_dict[component_name] + + # If the run_up_rate_fraction is not provided, it defaults to the ramp_rate_fraction + if "run_up_rate_fraction" not in h_dict[component_name]: + h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][ + "ramp_rate_fraction" + ] + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py index 724cfffb..4090191c 100644 --- a/hercules/plant_components/thermal_component_base.py +++ b/hercules/plant_components/thermal_component_base.py @@ -283,6 +283,12 @@ def __init__(self, h_dict, component_name): self.fuel_volume_rate = 0.0 # m³/s self.fuel_mass_rate = 0.0 # kg/s + # Initialize number of starts + self.n_total_starts = 0 + self.n_hot_starts = 0 + self.n_warm_starts = 0 + self.n_cold_starts = 0 + def get_initial_conditions_and_meta_data(self, h_dict): """Add initial conditions and meta data to the h_dict. @@ -297,6 +303,10 @@ def get_initial_conditions_and_meta_data(self, h_dict): h_dict[self.component_name]["efficiency"] = self.efficiency h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate + h_dict[self.component_name]["n_total_starts"] = self.n_total_starts + h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts + h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts + h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts return h_dict def step(self, h_dict): @@ -345,6 +355,12 @@ def step(self, h_dict): h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate + # Update start counts in h_dict for tracking purposes + h_dict[self.component_name]["n_total_starts"] = self.n_total_starts + h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts + h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts + h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts + return h_dict def _control(self, power_setpoint): @@ -403,17 +419,26 @@ 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 # Check if hot, warm, or cold starting is implied if self.time_in_state < self.HOT_START_TIME: self.state = self.STATES.HOT_STARTING + self.n_hot_starts += 1 elif self.time_in_state < self.WARM_START_TIME: self.state = self.STATES.WARM_STARTING + self.n_warm_starts += 1 else: self.state = self.STATES.COLD_STARTING + self.n_cold_starts += 1 self.time_in_state = 0.0 + if hasattr(self, "can_start"): + del self.can_start return 0.0 # Power is always 0 when off @@ -579,9 +604,12 @@ def calculate_efficiency(self, power_output): Returns: float: HHV net efficiency as a fraction (0-1). """ - if power_output <= 0: - # Return efficiency at lowest power fraction when off - return self.efficiency_values[0] + if self.state == self.STATES.OFF: + # Efficiency is not defined when off + return np.nan + elif power_output <= 0: + # Efficiency is 0 when not producing power (but not off) + return 0.0 # Calculate power fraction power_fraction = power_output / self.rated_capacity diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py new file mode 100644 index 00000000..50eab1c8 --- /dev/null +++ b/hercules/plant_components/thermal_plant.py @@ -0,0 +1,106 @@ +""" +Multiunit thermal power plant. +""" + +import copy + +import hercules.hybrid_plant as hp +from hercules.plant_components.component_base import ComponentBase +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class ThermalPlant(ComponentBase): + """Thermal power plant comprising multiple units. + + The thermal plant component is designed to represent a collection of thermal generation units + (e.g. gas turbines, steam turbines, RICEs) that are grouped together into a single Hercules + component. This allows users to model a thermal plant with multiple units with finer + granularity than a single aggregate component. Control setpoints can be specified for each unit. + + """ + + component_category = "generator" + + def __init__(self, h_dict, component_name): + # Instantiate individual units from the h_dict. + + self.unit_names = h_dict[component_name]["unit_names"] + generic_units = h_dict[component_name]["units"] + + # Check that unit_names are valid + if len(self.unit_names) != len(generic_units): + raise ValueError( + f"Length of unit_names ({len(self.unit_names)}) must match length of units " + f"({len(generic_units)})." + ) + if len(set(self.unit_names)) != len(self.unit_names): + raise ValueError(f"unit_names must be unique. Found duplicates in {self.unit_names}.") + + for unit, unit_name in zip(generic_units, self.unit_names): + if unit_name not in h_dict[component_name]: + h_dict[component_name][unit_name] = copy.deepcopy(h_dict[component_name][unit]) + + # Remove the template from the component dict since it's now copied into each unit dict + for unit in generic_units: + if unit in h_dict[component_name]: + del h_dict[component_name][unit] + + self.units = [] + for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names): + h_dict_thermal = h_dict[component_name] + h_dict_thermal["dt"] = h_dict["dt"] + h_dict_thermal["starttime"] = h_dict["starttime"] + h_dict_thermal["endtime"] = h_dict["endtime"] + h_dict_thermal["verbose"] = h_dict["verbose"] + unit_type = h_dict["thermal_power_plant"][unit_name]["component_type"] + unit_class = 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_thermal, unit_name)) + + # Call the base class init (sets self.component_name and self.component_type) + super().__init__(h_dict, component_name) + + def step(self, h_dict): + """ + Step the thermal plant by stepping each individual unit and summing their power outputs. + """ + thermal_plant_power = 0.0 + + for unit, unit_name, power_setpoint in zip( + self.units, self.unit_names, h_dict[self.component_name]["power_setpoints"] + ): + h_dict_thermal = h_dict[self.component_name] + h_dict_thermal[unit_name]["power_setpoint"] = power_setpoint + h_dict_thermal = unit.step(h_dict_thermal) + thermal_plant_power += h_dict_thermal[unit_name]["power"] + + h_dict[self.component_name]["power"] = thermal_plant_power + + return h_dict + + def get_initial_conditions_and_meta_data(self, h_dict): + """Get initial conditions and metadata for the thermal plant. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + """ + # NOTE: h_dict is modified in place, so h_dict will be updated with the initial + # conditions and metadata for each unit. + for unit in self.units: + h_dict_thermal = h_dict[self.component_name] + unit.get_initial_conditions_and_meta_data(h_dict_thermal) + + h_dict[self.component_name]["power"] = sum( + h_dict_thermal[unit.component_name]["power"] for unit in self.units + ) + h_dict[self.component_name]["rated_capacity"] = sum( + h_dict_thermal[unit.component_name]["rated_capacity"] for unit in self.units + ) + + return h_dict diff --git a/hercules/utilities.py b/hercules/utilities.py index d8a9eb82..0ea0bc77 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -226,6 +226,7 @@ def load_hercules_input(filename): "external_data", "output_use_compression", "output_buffer_size", + "power_setpoint_schedule", ] # Discover component entries: any top-level dict entry containing "component_type" @@ -268,6 +269,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/steam_turbine_test.py b/tests/steam_turbine_test.py new file mode 100644 index 00000000..95b57964 --- /dev/null +++ b/tests/steam_turbine_test.py @@ -0,0 +1,105 @@ +import copy + +import numpy as np +from hercules.plant_components.steam_turbine import SteamTurbine +from hercules.utilities import hercules_float_type + +from .test_inputs.h_dict import ( + h_dict_steam_turbine, +) + + +def test_init_from_dict(): + """Test that SteamTurbine can be initialized from a dictionary.""" + st = SteamTurbine(copy.deepcopy(h_dict_steam_turbine), "steam_turbine") + assert st is not None + + +def test_default_inputs(): + """Test that SteamTurbine uses default inputs when not provided.""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + + # Test that the ramp_rate_fraction input is correct from input dict + st = SteamTurbine(h_dict, "steam_turbine") + assert st.ramp_rate_fraction == 0.04 + + # Test that the run_up_rate_fraction input is correct from input dict + assert st.run_up_rate_fraction == 0.02 + + # Test that if the run_up_rate_fraction is not provided, + # it defaults to the ramp_rate_fraction + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["run_up_rate_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.run_up_rate_fraction == st.ramp_rate_fraction + + # Now test that the default value of the ramp_rate_fraction is + # applied to both the ramp_rate_fraction and the run_up_rate_fraction + # if they are both not provided + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["ramp_rate_fraction"] + del h_dict["steam_turbine"]["run_up_rate_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.ramp_rate_fraction == 0.03 + assert st.run_up_rate_fraction == 0.03 + + # Test the remaining default values + # Delete startup times first, since changing min_stable_load_fraction and + # ramp rates affects ramp_time validation against startup times + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["ramp_rate_fraction"] + del h_dict["steam_turbine"]["run_up_rate_fraction"] + del h_dict["steam_turbine"]["cold_startup_time"] + del h_dict["steam_turbine"]["warm_startup_time"] + del h_dict["steam_turbine"]["hot_startup_time"] + del h_dict["steam_turbine"]["min_stable_load_fraction"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_stable_load_fraction == 0.30 + assert st.hot_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert st.warm_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + assert st.cold_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds + + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["min_up_time"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_up_time == 48 * 60.0 * 60.0 # 48 hours in seconds + + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["min_down_time"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.min_down_time == 48 * 60.0 * 60.0 # 48 hours in seconds + + +def test_default_hhv(): + """Test that SteamTurbine provides default HHV for bituminous coal from [4].""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["hhv"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.hhv == 29310000000 + + +def test_default_fuel_density(): + """Test that SteamTurbine provides default fuel density.""" + h_dict = copy.deepcopy(h_dict_steam_turbine) + if "fuel_density" in h_dict["steam_turbine"]: + del h_dict["steam_turbine"]["fuel_density"] + st = SteamTurbine(h_dict, "steam_turbine") + assert st.fuel_density == 1000.0 + + +def test_default_efficiency_table(): + """Test that SteamTurbine provides default HHV net efficiency table. + + Default values are taken from [2,3] + """ + h_dict = copy.deepcopy(h_dict_steam_turbine) + del h_dict["steam_turbine"]["efficiency_table"] + st = SteamTurbine(h_dict, "steam_turbine") + np.testing.assert_array_equal( + st.efficiency_power_fraction, + np.array([0.3, 0.5, 1.0], dtype=hercules_float_type), + ) + np.testing.assert_array_equal( + st.efficiency_values, + np.array([0.30, 0.32, 0.35], dtype=hercules_float_type), + ) diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 6a9591f8..1a7ebed5 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -178,6 +178,26 @@ }, } +steam_turbine = { + "component_type": "SteamTurbine", + "rated_capacity": 1000, # kW (500 MW) + "min_stable_load_fraction": 0.3, # 30% minimum operating point + "ramp_rate_fraction": 0.04, # 4%/min ramp rate + "run_up_rate_fraction": 0.02, # 2%/min run up rate + "hot_startup_time": 27000.0, # 7.5 hours + "warm_startup_time": 27000.0, # 7.5 hours + "cold_startup_time": 27000.0, # 7.5 hours + "min_up_time": 172800, # 48 hours + "min_down_time": 172800, # 48 hour + "hhv": 29310000000, # J/m³ for bituminous coal (29.31 MJ/m³) [4] + "fuel_density": 1000, # kg/m³ for bituminous coal + "initial_conditions": {"power": 1000}, # power > 0 implies ON state + "efficiency_table": { + "power_fraction": [1.0, 0.75, 0.3], + "efficiency": [0.35, 0.32, 0.30], + }, +} + electrolyzer = { "component_type": "ElectrolyzerPlant", "initial_conditions": { @@ -429,3 +449,54 @@ "plant": plant, "hard_coal_steam_turbine": hard_coal_steam_turbine, } + +h_dict_steam_turbine = { + "dt": 1.0, + "starttime": 0.0, + "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), + "verbose": False, + "step": 0, + "time": 0.0, + "plant": plant, + "steam_turbine": steam_turbine, +} + +h_dict_thermal_plant = { + "dt": 1.0, + "starttime": 0.0, + "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), + "verbose": False, + "step": 0, + "time": 0.0, + "plant": plant, + "thermal_power_plant": { + "component_type": "ThermalPlant", + "unit_names": ["OCGT1", "HARD_COAL1"], + "units": ["open_cycle_gas_turbine", "hard_coal_steam_turbine"], + "open_cycle_gas_turbine": open_cycle_gas_turbine, + "hard_coal_steam_turbine": hard_coal_steam_turbine, + }, +} + +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 ac9b0865..16d8b443 100644 --- a/tests/thermal_component_base_test.py +++ b/tests/thermal_component_base_test.py @@ -410,9 +410,9 @@ def test_efficiency_clamping(): eff_200 = tcb.calculate_efficiency(200) # 200 kW = 20% load (below table min of 0.25) assert eff_200 == pytest.approx(0.30) - # Test at zero power (should return first efficiency value) + # Test at zero power (should return zero efficiency, not extrapolate below the table) eff_0 = tcb.calculate_efficiency(0) - assert eff_0 == pytest.approx(0.30) + assert eff_0 == pytest.approx(0.0) def test_efficiency_interpolation(): diff --git a/tests/thermal_plant_test.py b/tests/thermal_plant_test.py new file mode 100644 index 00000000..b84b9e02 --- /dev/null +++ b/tests/thermal_plant_test.py @@ -0,0 +1,109 @@ +import copy + +import pytest +from hercules.plant_components.thermal_plant import ThermalPlant + +from .test_inputs.h_dict import ( + h_dict_thermal_plant, + simple_battery, +) + + +def test_init_from_dict(): + # Set up a system with one OCGT and one hard coal steam turbine. + h_dict = copy.deepcopy(h_dict_thermal_plant) + ThermalPlant(h_dict, "thermal_power_plant") + + +def test_invalid_unit_type(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + # Unspecified unit + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "invalid_unit"] + with pytest.raises(KeyError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Non thermal-type unit + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "simple_battery"] + h_dict["thermal_power_plant"]["simple_battery"] = copy.deepcopy(simple_battery) + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Incorrect component type + h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "hard_coal_steam_turbine"] + h_dict["thermal_power_plant"]["hard_coal_steam_turbine"]["component_type"] = "InvalidComponent" + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + +def test_unit_copies(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + h_dict["thermal_power_plant"]["units"] = [ + "open_cycle_gas_turbine", + "hard_coal_steam_turbine", + "hard_coal_steam_turbine", + ] + + # units and unit_names are unequal length + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Update unit_names with non-unique values + h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST1"] + with pytest.raises(ValueError): + ThermalPlant(h_dict, "thermal_power_plant") + + # Unique values + h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST2"] + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Check that there are three units of the correct types + assert len(tp.units) == 3 + assert tp.units[0].component_type == "OpenCycleGasTurbine" + assert tp.units[1].component_type == "HardCoalSteamTurbine" + assert tp.units[2].component_type == "HardCoalSteamTurbine" + + +def test_h_dict_structure(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Check that the unit dicts were copied correctly (and generic names removed) + assert "open_cycle_gas_turbine" not in h_dict["thermal_power_plant"] + assert "hard_coal_steam_turbine" not in h_dict["thermal_power_plant"] + assert "OCGT1" in h_dict["thermal_power_plant"] + assert "HARD_COAL1" in h_dict["thermal_power_plant"] + assert h_dict["thermal_power_plant"]["OCGT1"]["component_type"] == "OpenCycleGasTurbine" + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["component_type"] == "HardCoalSteamTurbine" + + # Check that the initial conditions of units are copied correctly + h_dict = tp.get_initial_conditions_and_meta_data(h_dict) + assert h_dict["thermal_power_plant"]["OCGT1"]["power"] == 1000 # From initial conditions + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["power"] == 1000 # From initial conditions + assert h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] == 1000 + assert h_dict["thermal_power_plant"]["HARD_COAL1"]["rated_capacity"] == 500000 + + # Check that thermal plant conditions are recorded correctly + assert h_dict["thermal_power_plant"]["power"] == 1000 + 1000 + assert h_dict["thermal_power_plant"]["rated_capacity"] == 500000 + 1000 + + +def test_step(): + h_dict = copy.deepcopy(h_dict_thermal_plant) + + tp = ThermalPlant(h_dict, "thermal_power_plant") + + # Provide power setpoints to the two units + h_dict["thermal_power_plant"]["power_setpoints"] = [800, 400000] + + # Step the plant and check that power is updated correctly + h_dict = tp.step(h_dict) + power_ocgt = h_dict["thermal_power_plant"]["OCGT1"]["power"] + power_hard_coal = h_dict["thermal_power_plant"]["HARD_COAL1"]["power"] + + assert power_ocgt < 1000 # Reacts to power setpoint + assert power_hard_coal < 500000 # Reacts to power setpoint + + # Total power computed correctly + assert h_dict["thermal_power_plant"]["power"] == power_ocgt + power_hard_coal