diff --git a/docs/_static/thermal_startup_ramp.svg b/docs/_static/thermal_startup_ramp.svg new file mode 100644 index 00000000..52588071 --- /dev/null +++ b/docs/_static/thermal_startup_ramp.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + Power (kW) + + + time + + + + P_max = rated_capacity + + + + P_min = rated_capacity × + min_stable_load_fraction + + + + + + + + + + + + + + + + + + t = hot_readying_time + (derived) + + + t = hot_startup_time + (input) + + + + + + ramp_time (derived) + + + run_up_rate + + + + + + ramp_rate + + + + + + Note: Diagram shows hot startup. Cold startup uses cold_startup_time and cold_readying_time. + diff --git a/docs/_toc.yml b/docs/_toc.yml index f0477e30..29a10324 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -26,6 +26,8 @@ parts: - file: solar_pv - file: battery - file: electrolyzer + - file: thermal_component_base + - file: open_cycle_gas_turbine - caption: Inputs chapters: - file: hercules_input @@ -43,3 +45,4 @@ parts: - file: examples/04_wind_and_storage - file: examples/05_wind_and_storage_with_lmp - file: examples/06_wind_and_hydrogen + - file: examples/07_ocgt \ No newline at end of file diff --git a/docs/examples/07_ocgt.md b/docs/examples/07_ocgt.md new file mode 100644 index 00000000..eb342482 --- /dev/null +++ b/docs/examples/07_ocgt.md @@ -0,0 +1,66 @@ +# Example 07: Open Cycle Gas Turbine (OCGT) + +## Description + +This example demonstrates a standalone open-cycle gas turbine (OCGT) simulation. The example showcases the turbine's state machine behavior including startup sequences, power ramping, minimum stable load constraints, and shutdown sequences. + +For details on OCGT parameters and configuration, see {doc}`../open_cycle_gas_turbine`. For details on the underlying state machine and ramp behavior, see {doc}`../thermal_component_base`. + +## Scenario + +The simulation runs for 6 hours with 1-minute time steps. A controller commands the turbine through several operating phases. The table below shows both **control commands** (setpoint changes) and **state transitions** (responses to commands based on constraints). + +### Timeline + +| Time (min) | Event Type | Setpoint | State | Description | +|------------|------------|----------|-------|-------------| +| 0 | Initial | 0 | OFF (0) | Turbine starts off, `time_in_state` begins counting | +| 40 | Command | → 100 MW | OFF (0) | Setpoint changes to full power, but `min_down_time` (60 min) not yet satisfied—turbine remains off | +| 60 | State | 100 MW | → HOT STARTING (1) | `min_down_time` satisfied, turbine begins hot starting sequence | +| ~64 | State | 100 MW | HOT STARTING (1) | `hot_readying_time` (~4.2 min) complete, run-up ramp begins | +| ~68 | State | 100 MW | → ON (4) | Power reaches P_min (20 MW) after `hot_startup_time` (~8.2 min), turbine now operational | +| ~76 | Ramp | 100 MW | ON (4) | Power reaches 100 MW (ramped at 10 MW/min from P_min) | +| 120 | Command | → 50 MW | ON (4) | Setpoint reduced to 50% capacity | +| ~125 | Ramp | 50 MW | ON (4) | Power reaches 50 MW (ramped down at 10 MW/min) | +| 180 | Command | → 10 MW | ON (4) | Setpoint reduced to 10% (below P_min), power clamped to P_min | +| ~183 | Ramp | 10 MW | ON (4) | Power reaches P_min (20 MW), cannot go lower | +| 210 | Command | → 100 MW | ON (4) | Setpoint increased to full power | +| ~218 | Ramp | 100 MW | ON (4) | Power reaches 100 MW | +| 240 | Command + State | → 0 | → STOPPING (5) | Shutdown command; `min_up_time` satisfied (~172 min on), begins stopping sequence | +| ~250 | State | 0 | → OFF (0) | Power reaches 0 (ramped down at 10 MW/min), turbine off | +| 360 | End | 0 | OFF (0) | Simulation ends | + +### Key Behaviors Demonstrated + +- **Minimum down time**: The turbine cannot start until `min_down_time` (60 min) is satisfied, even though the command is issued at 40 min +- **Hot startup sequence**: After `min_down_time`, the turbine enters HOT STARTING, waits through `hot_readying_time`, then ramps to P_min using `run_up_rate` +- **Ramp rate constraints**: All power changes in ON state are limited by `ramp_rate` (10 MW/min) +- **Minimum stable load**: When commanded to 10 MW (below P_min = 20 MW), power is clamped to P_min +- **Minimum up time**: Shutdown is allowed immediately at 240 min because `min_up_time` (60 min) was satisfied long ago +- **Stopping sequence**: The turbine ramps down to zero at `ramp_rate` before transitioning to OFF + +## Setup + +No manual setup is required. The example uses only the OCGT component which requires no external data files. + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` + +## Outputs + +To plot the outputs, run: + +```bash +python plot_outputs.py +``` + +The plot shows: +- Power output over time (demonstrating ramp constraints and minimum stable load) +- Operating state transitions +- Fuel consumption tracking +- Heat rate variation with load diff --git a/docs/open_cycle_gas_turbine.md b/docs/open_cycle_gas_turbine.md new file mode 100644 index 00000000..5e606e96 --- /dev/null +++ b/docs/open_cycle_gas_turbine.md @@ -0,0 +1,121 @@ +# Open Cycle Gas Turbine + +The `OpenCycleGasTurbine` class models an open-cycle gas turbine (OCGT), also known as a peaker plant or simple-cycle gas turbine. It is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class. + +For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`. + +## OCGT-Specific Parameters + +In addition to the base class parameters, the OCGT model includes parameters for fuel consumption tracking: + +| Parameter | Units | Default | Description | +|-----------|-------|---------|-------------| +| `part_load_factor` | dimensionless | 1.0 | Heat rate penalty at minimum load. Range: 1.0-2.0. A value of 1.0 means no penalty; higher values indicate decreased efficiency at part load | +| `heat_rate_at_rated_load` | kJ/kWh | 10000 | Fuel consumption rate at rated load | + +## Default Parameter Values + +The `OpenCycleGasTurbine` class provides default values for base class parameters based on References [1-5]. Only `rated_capacity` and `initial_conditions` are required in the YAML configuration. + +| Parameter | Default Value | Source | +|-----------|---------------|--------| +| `min_stable_load_fraction` | 0.40 (40%) | [4] | +| `ramp_rate_fraction` | 0.10 (10%/min) | [1] | +| `run_up_rate_fraction` | Same as `ramp_rate_fraction` | — | +| `hot_startup_time` | 420 s (7 minutes) | [1], [5] | +| `warm_startup_time` | 480 s (8 minutes) | [1], [5] | +| `cold_startup_time` | 480 s (8 minutes) | [1], [5] | +| `min_up_time` | 1800 s (30 minutes) | [4] | +| `min_down_time` | 3600 s (1 hour) | [4] | + +## OCGT-Specific Outputs + +In addition to the base class outputs (`power`, `state_num`), the OCGT model provides: + +| Output | Units | Description | +|--------|-------|-------------| +| `fuel_consumption` | kJ | Fuel consumed during the timestep | +| `heat_rate` | kJ/kWh | Current heat rate (varies with load) | + +### Heat Rate Calculation + +The heat rate varies with load fraction to model part-load efficiency degradation: + +- At rated load: `heat_rate = heat_rate_at_rated_load` +- At minimum load: `heat_rate = heat_rate_at_rated_load × part_load_factor` +- Between: Linear interpolation + +Fuel consumption is calculated as: + +$$ +\text{fuel\_consumption} = \text{power} \times \text{heat\_rate} \times \frac{\Delta t}{3600} +$$ + +Where $\Delta t$ is the timestep in seconds. + +## YAML Configuration + +### Minimal Configuration + +Only required parameters (uses all defaults): + +```yaml +open_cycle_gas_turbine: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + initial_conditions: + power: 0 + state_num: 0 # 0 = off +``` + +### Full Configuration + +All parameters explicitly specified: + +```yaml +open_cycle_gas_turbine: + 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 + part_load_factor: 1.25 # 25% heat rate penalty at min load + heat_rate_at_rated_load: 10000 # kJ/kWh at rated load + log_channels: + - power + - fuel_consumption + - state_num + - heat_rate + - power_setpoint + initial_conditions: + power: 0 + state_num: 0 # 0 = off +``` + +## 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_num`: Operating state number (0-5) +- `fuel_consumption`: Fuel consumed per timestep in kJ +- `heat_rate`: Current heat rate in kJ/kWh +- `power_setpoint`: Requested power setpoint in kW + +## References + +1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants." + +2. "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on Production Cost Simulation", NREL/CP-6A40-87554, National Renewable Energy Laboratory, 2024. + +3. Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly Modelling in Power Systems with Significant Levels of Renewable Generation." Applied Energy 113 (January 2014): 152–58. https://doi.org/10.1016/j.apenergy.2013.07.027. + +4. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi. + +5. M. Oakes, M. Turner, "Cost and Performance Baseline for Fossil Energy Plants, Volume 5: Natural Gas Electricity Generating Units for Flexible Operation," National Energy Technology Laboratory, Pittsburgh, May 5, 2023. \ No newline at end of file diff --git a/docs/thermal_component_base.md b/docs/thermal_component_base.md new file mode 100644 index 00000000..6ae29bc0 --- /dev/null +++ b/docs/thermal_component_base.md @@ -0,0 +1,147 @@ +# Thermal Component Base + +The `ThermalComponentBase` class provides common functionality for thermal power plant components in Hercules. It serves as a base class for multiple thermal plant types including: + +- Reciprocating internal combustion engines (RICE) +- Open-cycle gas turbines (OCGT) +- Combined-cycle gas turbines (CCGT) +- Coal-fired power plants + +The parameterized model is based primarily on [1], with additional parameters and naming conventions from [2] and [3]. Table 1 on page 48 of [1] provides many of the default values used in subclasses. + +## State Machine + +The thermal component operates as a state machine with six states: + +```{mermaid} +stateDiagram-v2 + direction TB + + state "OFF (0)" as Off + state "HOT STARTING (1)" as Hot + state "WARM STARTING (2)" as Warm + state "COLD STARTING (3)" as Cold + state "ON (4)" as On + state "STOPPING (5)" as Stop + + [*] --> Off + + Off --> Hot: start (hot) + Off --> Warm: start (warm) + Off --> Cold: start (cold) + + Hot --> Off: abort + Hot --> On: P >= P_min + + Warm --> Off: abort + Warm --> On: P >= P_min + + Cold --> Off: abort + Cold --> On: P >= P_min + + On --> Stop: shutdown + + Stop --> Off: P = 0 +``` + +### State Transitions + +The decision between hot, warm, and cold starting is based on how long the unit has been off. The cutoff times are hardcoded based on reference [5]: less than 8 hours triggers a hot start, 8-48 hours triggers a warm start, and 48+ hours triggers a cold start. + +| From State | To State | Diagram Label | Condition | +|------------|----------|---------------|-----------| +| OFF (0) | HOT STARTING (1) | start (hot) | `power_setpoint > 0` AND `time_in_state >= min_down_time` AND `time_in_state < 8 hours` | +| OFF (0) | WARM STARTING (2) | start (warm) | `power_setpoint > 0` AND `time_in_state >= min_down_time` AND `time_in_state >= 8 hours` AND `time_in_state < 48 hours` | +| OFF (0) | COLD STARTING (3) | start (cold) | `power_setpoint > 0` AND `time_in_state >= min_down_time` AND `time_in_state >= 48 hours` | +| HOT STARTING (1) | OFF (0) | abort | `power_setpoint <= 0` | +| HOT STARTING (1) | ON (4) | P >= P_min | `power_output >= P_min` (after `hot_startup_time`) | +| WARM STARTING (2) | OFF (0) | abort | `power_setpoint <= 0` | +| WARM STARTING (2) | ON (4) | P >= P_min | `power_output >= P_min` (after `warm_startup_time`) | +| COLD STARTING (3) | OFF (0) | abort | `power_setpoint <= 0` | +| COLD STARTING (3) | ON (4) | P >= P_min | `power_output >= P_min` (after `cold_startup_time`) | +| ON (4) | STOPPING (5) | shutdown | `power_setpoint <= 0` AND `time_in_state >= min_up_time` | +| STOPPING (5) | OFF (0) | P = 0 | `power_output <= 0` | + +## Parameters + +All parameters below are defined in the Hercules input YAML file. The base class does **not** provide default values—subclasses (such as `OpenCycleGasTurbine`) supply defaults based on References [1-3]. + +### Required Parameters + +| Parameter | Units | Description | +|-----------|-------|-------------| +| `rated_capacity` | kW | Maximum power output (P_max) | +| `min_stable_load_fraction` | fraction (0-1) | Minimum operating point as fraction of rated capacity | +| `ramp_rate_fraction` | fraction/min | Maximum rate of power change during normal operation, as fraction of rated capacity per minute | +| `run_up_rate_fraction` | fraction/min | Maximum rate of power increase during startup ramp, as fraction of rated capacity per minute | +| `hot_startup_time` | s | Time to reach P_min from off (hot start). Includes both readying time and ramping time | +| `warm_startup_time` | s | Time to reach P_min from off (warm start). Includes both readying time and ramping time | +| `cold_startup_time` | s | Time to reach P_min from off (cold start). Includes both readying time and ramping time | +| `min_up_time` | s | Minimum time unit must remain on before shutdown is allowed | +| `min_down_time` | s | Minimum time unit must remain off before restart is allowed | +| `initial_conditions.power` | kW | Initial power output | +| `initial_conditions.state_num` | integer | Initial state (0=off, 1=hot starting, 2=warm starting, 3=cold starting, 4=on, 5=stopping) | + +### Derived Parameters + +The following parameters are computed from the input parameters: + +| Parameter | Formula | Description | +|-----------|---------|-------------| +| `P_max` | `rated_capacity` | Maximum power output | +| `P_min` | `min_stable_load_fraction × rated_capacity` | Minimum stable power output | +| `ramp_rate` | `ramp_rate_fraction × rated_capacity / 60` | Ramp rate in kW/s | +| `run_up_rate` | `run_up_rate_fraction × rated_capacity / 60` | Run-up rate in kW/s | +| `ramp_time` | `P_min / run_up_rate` | Time to ramp from 0 to P_min | +| `hot_readying_time` | `hot_startup_time - ramp_time` | Preparation time before hot start ramp begins | +| `warm_readying_time` | `warm_startup_time - ramp_time` | Preparation time before warm start ramp begins | +| `cold_readying_time` | `cold_startup_time - ramp_time` | Preparation time before cold start ramp begins | + +## Startup and Ramp Behavior + +The following diagram illustrates the startup sequence and ramp behavior, showing how the input and derived parameters relate to each other: + +```{image} _static/thermal_startup_ramp.svg +:alt: Thermal component startup and ramp behavior +:width: 700px +:align: center +``` + +During startup: +1. The unit receives a positive `power_setpoint` while in the OFF state +2. If `min_down_time` is satisfied, the unit transitions to HOT STARTING, WARM STARTING, or COLD STARTING (depending on how long it has been off: <8h = hot, 8-48h = warm, >48h = cold) +3. The unit remains at zero power during the readying time (`hot_readying_time`, `warm_readying_time`, or `cold_readying_time`) +4. After readying, the unit ramps up to P_min using `run_up_rate` +5. Once P_min is reached, the unit transitions to ON state + +During normal operation (ON state): +- Power changes are constrained by `ramp_rate` +- Power output is constrained between P_min and P_max +- The unit must remain on for at least `min_up_time` before shutdown is allowed + +During shutdown: +- The unit ramps down to zero using `ramp_rate` +- Once power reaches zero, the unit transitions to OFF + +## Outputs + +The base class outputs are returned in `h_dict`: + +| Output | Units | Description | +|--------|-------|-------------| +| `power` | kW | Actual power output | +| `state_num` | integer | Current operating state (0-5) | + +Subclasses may add additional outputs (e.g., fuel consumption for gas turbines). + +## 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. diff --git a/examples/07_ocgt/hercules_input.yaml b/examples/07_ocgt/hercules_input.yaml new file mode 100644 index 00000000..067bc412 --- /dev/null +++ b/examples/07_ocgt/hercules_input.yaml @@ -0,0 +1,44 @@ +# Input YAML for hercules +# Explicitly specify the parameters for demonstration purposes + +# Name +name: example_07 + +### +# Describe this simulation setup +description: Open Cycle Gas Turbine (OCGT) Example + +dt: 60.0 # 1 minute time step +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC +endtime_utc: "2020-01-01T06:00:00Z" # 6 hours later +verbose: False +log_every_n: 1 + +plant: + interconnect_limit: 100000 # kW (100 MW) + +open_cycle_gas_turbine: + component_type: OpenCycleGasTurbine + rated_capacity: 100000 # kW (100 MW) + min_stable_load_fraction: 0.2 # 20% minimum operating point + ramp_rate_fraction: 0.1 # 10%/min ramp rate + run_up_rate_fraction: 0.05 # 5%/min run up rate + hot_startup_time: 420.0 # 7 minutes + warm_startup_time: 480.0 # 8 minutes + cold_startup_time: 480.0 # 8 minutes + min_up_time: 3600 # 1 hour + min_down_time: 3600 # 1 hour + part_load_factor: 1.25 # 1.0 (no penalty) + heat_rate_at_rated_load: 10000 # 10,000 kJ/kWh at rated load + log_channels: + - power + - fuel_consumption + - state_num + - heat_rate + - power_setpoint + initial_conditions: + power: 0 + state_num: 0 # 0 = off + +controller: + diff --git a/examples/07_ocgt/hercules_runscript.py b/examples/07_ocgt/hercules_runscript.py new file mode 100644 index 00000000..d643aedd --- /dev/null +++ b/examples/07_ocgt/hercules_runscript.py @@ -0,0 +1,80 @@ +"""Example 07: Open Cycle Gas Turbine (OCGT) simulation. + +This example demonstrates a simple open cycle gas turbine (OCGT) that: +- Starts off (state=0, power=0) +- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity +- At 60 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting +- At 67 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 ControllerNGCT: + """Controller implementing the NGCT 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 < 40 * 60: # 40 minutes in seconds + # Before 40 minutes: keep turbine off + 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(ControllerNGCT(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/07_ocgt/plot_outputs.py b/examples/07_ocgt/plot_outputs.py new file mode 100644 index 00000000..6dd12f59 --- /dev/null +++ b/examples/07_ocgt/plot_outputs.py @@ -0,0 +1,86 @@ +# Plot the outputs of the simulation for the OCGT example + +import matplotlib.pyplot as plt +from hercules import HerculesOutput + +# Read the Hercules output file using HerculesOutput +ho = HerculesOutput("outputs/hercules_output.h5") + +# Print metadata information +print("Simulation Metadata:") +ho.print_metadata() +print() + +# Create a shortcut to the dataframe +df = ho.df + +# Get the h_dict from metadata +h_dict = ho.h_dict + +# Convert time to minutes for easier reading +time_minutes = df["time"] / 60 + +fig, axarr = plt.subplots(4, 1, sharex=True, figsize=(10, 10)) + +# Plot the power output and setpoint +ax = axarr[0] +ax.plot(time_minutes, df["open_cycle_gas_turbine.power"] / 1000, label="Power Output", color="b") +ax.plot( + time_minutes, + df["open_cycle_gas_turbine.power_setpoint"] / 1000, + label="Power Setpoint", + color="r", + linestyle="--", +) +ax.axhline( + h_dict["open_cycle_gas_turbine"]["rated_capacity"] / 1000, + color="gray", + linestyle=":", + label="Rated Capacity", +) +ax.axhline( + h_dict["open_cycle_gas_turbine"]["min_stable_load_fraction"] + * h_dict["open_cycle_gas_turbine"]["rated_capacity"] + / 1000, + color="gray", + linestyle="--", + label="Minimum Stable Load", +) +ax.set_ylabel("Power [MW]") +ax.set_title("Open Cycle Gas Turbine Power Output") +ax.legend() +ax.grid(True) + +# Plot the state +ax = axarr[1] +ax.plot(time_minutes, df["open_cycle_gas_turbine.state_num"], label="State Number", 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 heat rate +ax = axarr[2] +ax.plot(time_minutes, df["open_cycle_gas_turbine.heat_rate"], label="Heat Rate", color="g") +ax.set_ylabel("Heat Rate [kJ/kWh]") +ax.set_title("Heat Rate") +ax.grid(True) + +# Plot the fuel consumption +ax = axarr[3] +ax.plot( + time_minutes, + df["open_cycle_gas_turbine.fuel_consumption"] / 1000, + label="Fuel Consumption", + color="orange", +) +ax.set_ylabel("Fuel [MJ/timestep]") +ax.set_title("Fuel Consumption per Timestep") +ax.grid(True) + + +plt.tight_layout() +plt.show() diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index c80fb8f8..439f85c4 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -3,6 +3,7 @@ from hercules.plant_components.battery_lithium_ion import BatteryLithiumIon from hercules.plant_components.battery_simple import BatterySimple from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts from hercules.plant_components.wind_farm import WindFarm from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower @@ -116,7 +117,12 @@ def get_plant_component(self, component_name, h_dict): if component_type == "ElectrolyzerPlant": return ElectrolyzerPlant(h_dict) - raise Exception("Unknown component_type: ", component_type) + if component_type == "OpenCycleGasTurbine": + return OpenCycleGasTurbine(h_dict) + + raise Exception( + f"Unknown component_type '{component_type}' for component '{component_name}'" + ) def step(self, h_dict): """Execute one simulation step for all plant components. diff --git a/hercules/plant_components/open_cycle_gas_turbine.py b/hercules/plant_components/open_cycle_gas_turbine.py new file mode 100644 index 00000000..7a682bf1 --- /dev/null +++ b/hercules/plant_components/open_cycle_gas_turbine.py @@ -0,0 +1,224 @@ +""" +Open Cycle Gas Turbine Class. + +Open cycle gas turbine (OCGT) model is a subclass of the ThermalComponentBase class. +It implements the model as presented in [1], [2], [3] and [4]. + +Like other subclasses of ThermalComponentBase, it inherits the main control functions, +and adds defaults for many variables based on [1], [2], [3] and [4]. + +Finally the subclass implements several OCGT specific functions be called by the overloaded +_post_process() function. + +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. +""" + +import numpy as np +from hercules.plant_components.thermal_component_base import ThermalComponentBase + + +class OpenCycleGasTurbine(ThermalComponentBase): + """Open cycle gas turbine model. + + This model represents an open cycle gas turbine with state + management, ramp rate constraints, minimum stable load, and fuel consumption + tracking. Note it is a subclass of the ThermalComponentBase class. + """ + + def __init__(self, h_dict): + """Initialize the OpenCycleGasTurbine class. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - rated_capacity: Maximum power output in kW + - min_stable_load_fraction: Optional, minimum operating point as fraction (0-1). + Default: 0.40 (40%) [4] + - ramp_rate_fraction: Optional, maximum rate of power increase/decrease + as fraction of rated capacity per minute. Default: 0.1 (10%) + - run_up_rate_fraction: Optional, maximum rate of power increase during startup + as fraction of rated capacity per minute. Default: ramp_rate_fraction + - hot_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 420.0 s (7 minutes) [1, 5] + - warm_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 480.0 s (8 minutes) [1, 5] + - cold_startup_time: Optional, time to reach min_stable_load_fraction from off + in s. Includes both readying time and ramping time. + Default: 480.0 s (8 minutes) [1, 5] + - min_up_time: Optional, minimum time unit must remain on in s. + Default: 1800.0 s (30 minutes) [4] + - min_down_time: Optional, minimum time unit must remain off in s. + Default: 3600.0 s (1 hour) [4] + - initial_conditions: Dictionary with initial power and state_num + - part_load_factor: Optional, heat rate penalty at min load. + Default: 1.0 (no penalty) + - heat_rate_at_rated_load: Optional, fuel consumption rate at rated load + in kJ/kWh. Default: 10000 + """ + + # Store the name of this component + self.component_name = "open_cycle_gas_turbine" + + # Store the type of this component + self.component_type = "OpenCycleGasTurbine" + + # Apply fixeddefault parameters based on [1], [2] and [3] + # back into the h_dict if they are not provided + if "min_stable_load_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_stable_load_fraction"] = 0.40 + if "ramp_rate_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["ramp_rate_fraction"] = 0.1 + if "hot_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["hot_startup_time"] = 420.0 + if "warm_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["warm_startup_time"] = 480.0 + if "cold_startup_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["cold_startup_time"] = 480.0 + if "min_up_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_up_time"] = 1800.0 + if "min_down_time" not in h_dict[self.component_name]: + h_dict[self.component_name]["min_down_time"] = 3600.0 + + # If the run_up_rate_fraction is not provided, it defaults to the ramp_rate_fraction + if "run_up_rate_fraction" not in h_dict[self.component_name]: + h_dict[self.component_name]["run_up_rate_fraction"] = h_dict[self.component_name][ + "ramp_rate_fraction" + ] + + # Call the base class init + super().__init__(h_dict) + + # Extract parameters specific to OCGT + component_dict = h_dict[self.component_name] + self.part_load_factor = component_dict.get("part_load_factor", 1.0) + self.heat_rate_at_rated_load = component_dict.get( + "heat_rate_at_rated_load", 10000 + ) # kJ/kWh at rated load + + # Check parameters specific to OCGT + if self.part_load_factor < 1 or self.part_load_factor > 2: + raise ValueError("part_load_factor must be between 1.0 and 2.0") + if self.heat_rate_at_rated_load <= 0: + raise ValueError("heat_rate_at_rated_load must be greater than 0") + + # Initialize the heat rate + self.heat_rate = self._calc_heat_rate(self.power_output) + + # Initialize the fuel consumption + self.fuel_consumption = self._calc_fuel_consumption(self.power_output) + + # Overload get_initial_conditions_and_meta_data to add OCGT specific initial conditions + def get_initial_conditions_and_meta_data(self, h_dict): + """Add initial conditions and meta data to the h_dict. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Updated dictionary with initial conditions and meta data. + """ + h_dict[self.component_name]["power"] = self.power_output + h_dict[self.component_name]["state_num"] = self.state_num + h_dict[self.component_name]["fuel_consumption"] = 0.0 + h_dict[self.component_name]["heat_rate"] = self.heat_rate + return h_dict + + def _calc_fuel_consumption(self, power_output): + """Calculate fuel consumed based on power output and heat rate. + + Args: + power_output (float): Current power output in kW. + + Returns: + float: Fuel consumed this timestep in kJ. + """ + + # TODO: Is this correct? Should we be getting to m^3 of natural gas? + if power_output <= 0: + self.heat_rate = self.heat_rate_at_rated_load + return 0.0 + + # Calculate current heat rate with part-load penalty + self.heat_rate = self._calc_heat_rate(power_output) + + # Fuel = Power * Heat Rate * dt + # Units: kW * (kJ/kWh) * (s) * (h/3600s) = kJ + fuel_kJ = power_output * self.heat_rate * (self.dt / 3600.0) + + return fuel_kJ + + def _calc_heat_rate(self, power_output): + """Calculate heat rate accounting for part-load efficiency degradation. + + Uses linear interpolation between rated load (self.heat_rate_at_rated_load) and minimum + stable load (self.heat_rate_at_rated_load * self.part_load_factor). + + Args: + power_output (float): Current power output in kW. + + Returns: + float: Current heat rate in kJ/kWh. + """ + if power_output <= 0: + return self.heat_rate_at_rated_load + + if self.part_load_factor == 1.0: + return self.heat_rate_at_rated_load + + # Linear interpolation of efficiency penalty + # At rated load: heat_rate + # At min load: heat_rate * part_load_factor + load_fraction = power_output / self.rated_capacity + + # Avoid division by zero if min_stable_load_fraction is 1.0 + if self.min_stable_load_fraction >= 1.0: + return self.heat_rate_at_rated_load + + # Linear interpolation + # efficiency_penalty goes from part_load_factor at min_load to 1.0 at rated + normalized_load = (load_fraction - self.min_stable_load_fraction) / ( + 1.0 - self.min_stable_load_fraction + ) + normalized_load = np.clip(normalized_load, 0.0, 1.0) + + efficiency_penalty = self.part_load_factor - (self.part_load_factor - 1.0) * normalized_load + + return self.heat_rate_at_rated_load * efficiency_penalty + + # Overload _post_process to add OCGT specific post-processing + def _post_process(self, h_dict): + """Post-process the OCGT simulation. + + This is called by the base class after the control function. + Computes the fuel consumption and heat rate. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Updated dictionary with post-processed simulation state. + """ + # Calculate fuel consumption for this timestep + self.fuel_consumption = self._calc_fuel_consumption(self.power_output) + + # Update h_dict with outputs + h_dict[self.component_name]["fuel_consumption"] = self.fuel_consumption + h_dict[self.component_name]["heat_rate"] = self.heat_rate + + return h_dict diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py new file mode 100644 index 00000000..1159a91b --- /dev/null +++ b/hercules/plant_components/thermal_component_base.py @@ -0,0 +1,504 @@ +""" +Thermal Plant Base Class. + +A base class for thermal plant components. Based primarily on the parameterized model +presented in [1] but using some names and parameters from [2] and [3]. Table 1 +on page 48 of [1] provides many of the default values for the parameters. + +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. + +""" + +import numpy as np +from hercules.plant_components.component_base import ComponentBase + + +class ThermalComponentBase(ComponentBase): + """Base class for thermal power plant components. + + This class provides common functionality for all thermal plant components, + including power output calculation and ramp rate constraints. + + Note: All power units are in kW. + + Note: The base class does not provide default values of inputs. + Subclasses must provide these in the h_dict. + + State Machine: + state_num values and their meanings: + - 0: "off" - Thermal Component is off, no power output + - 1: "hot starting" - Thermal Component is readying or ramping up to minimum + stable load from off state (hot start) + - 2: "warm starting" - Thermal Component is readying or ramping up to minimum + stable load from off state (warm start) + - 3: "cold starting" - Thermal Component is readying or ramping up to minimum + stable load from off state (cold start) + - 4: "on" - Thermal Component is operating normally + - 5: "stopping" - Thermal Component is ramping down to shutdown + + + """ + + # State constants + STATE_OFF = 0 + STATE_HOT_STARTING = 1 + STATE_WARM_STARTING = 2 + STATE_COLD_STARTING = 3 + STATE_ON = 4 + STATE_STOPPING = 5 + + # Time constants + # Note the time definitions for cold versus warm versus hot starting are hard + # coded and based on the values in [5]. + HOT_START_TIME = 8 * 60 * 60 # 8 hours + WARM_START_TIME = 48 * 60 * 60 # 48 hours + + # Mapping from state number to state name + STATE_NAMES = { + STATE_OFF: "off", + STATE_HOT_STARTING: "hot starting", + STATE_WARM_STARTING: "warm starting", + STATE_COLD_STARTING: "cold starting", + STATE_ON: "on", + STATE_STOPPING: "stopping", + } + + @property + def state_name(self): + """Return the name of the current state. + + Returns: + str: Current state name ("off", "hot starting", "warm starting", "cold starting", + "on", or "stopping"). + """ + return self.STATE_NAMES[self.state_num] + + def __init__(self, h_dict): + """Initialize the ThermalComponentBase class. + + Args: + h_dict (dict): Dictionary containing simulation parameters including: + - rated_capacity: Maximum power output in kW + - min_stable_load_fraction: Minimum operating point as fraction (0-1) + - ramp_rate_fraction: Maximum rate of power increase/decrease + as fraction of rated capacity per minute + - run_up_rate_fraction: Maximum rate of power increase during startup + as fraction of rated capacity per minute. + - hot_startup_time: Time to reach min_stable_load_fraction from off in s. + Includes both readying time and ramping time. + - warm_startup_time: Time to reach min_stable_load_fraction from off in s. + Includes both readying time and ramping time. + - cold_startup_time: Time to reach min_stable_load_fraction from off in s. + Includes both readying time and ramping time. + - min_up_time: Minimum time unit must remain on in s. + - min_down_time: Minimum time unit must remain off in s. + - initial_conditions: Dictionary with initial power and state_num + """ + + # Both the component name and type are defined in the subclass + # But for testing purposes, we need to set the component name here + # if not defined in the subclass + if not hasattr(self, "component_name"): + self.component_name = "thermal_component" + if not hasattr(self, "component_type"): + self.component_type = "ThermalComponentBase" + + # Call the base class init + super().__init__(h_dict, self.component_name) + + # Extract parameters from the h_dict + component_dict = h_dict[self.component_name] + self.rated_capacity = component_dict["rated_capacity"] # kW + self.min_stable_load_fraction = component_dict["min_stable_load_fraction"] + self.ramp_rate_fraction = component_dict["ramp_rate_fraction"] + self.run_up_rate_fraction = component_dict["run_up_rate_fraction"] + self.hot_startup_time = component_dict["hot_startup_time"] # s + self.warm_startup_time = component_dict["warm_startup_time"] # s + self.cold_startup_time = component_dict["cold_startup_time"] # s + self.min_up_time = component_dict["min_up_time"] # s + self.min_down_time = component_dict["min_down_time"] # s + + # Check all required parameters are numbers + if not isinstance(self.rated_capacity, (int, float)): + raise ValueError("rated_capacity must be a number") + if not isinstance(self.min_stable_load_fraction, (int, float)): + raise ValueError("min_stable_load_fraction must be a number") + if not isinstance(self.ramp_rate_fraction, (int, float)): + raise ValueError("ramp_rate_fraction must be a number") + if not isinstance(self.run_up_rate_fraction, (int, float)): + raise ValueError("run_up_rate_fraction must be a number") + if not isinstance(self.hot_startup_time, (int, float)): + raise ValueError("hot_startup_time must be a number") + if not isinstance(self.warm_startup_time, (int, float)): + raise ValueError("warm_startup_time must be a number") + if not isinstance(self.cold_startup_time, (int, float)): + raise ValueError("cold_startup_time must be a number") + if not isinstance(self.min_up_time, (int, float)): + raise ValueError("min_up_time must be a number") + if not isinstance(self.min_down_time, (int, float)): + raise ValueError("min_down_time must be a number") + + # Check parameters + if self.rated_capacity <= 0: + raise ValueError("rated_capacity must be greater than 0") + if self.min_stable_load_fraction < 0 or self.min_stable_load_fraction > 1: + raise ValueError("min_stable_load_fraction must be between 0 and 1 (inclusive)") + if self.ramp_rate_fraction <= 0: + raise ValueError("ramp_rate_fraction must be greater than 0") + if self.run_up_rate_fraction <= 0: + raise ValueError("run_up_rate_fraction must be greater than 0") + if self.hot_startup_time < 0: + raise ValueError("hot_startup_time must be greater than or equal to 0") + if self.warm_startup_time < 0: + raise ValueError("warm_startup_time must be greater than or equal to 0") + if self.cold_startup_time < 0: + raise ValueError("cold_startup_time must be greater than or equal to 0") + if self.min_up_time < 0: + raise ValueError("min_up_time must be greater than or equal to 0") + if self.min_down_time < 0: + raise ValueError("min_down_time must be greater than or equal to 0") + + # Compute derived power limits + self.P_min = self.min_stable_load_fraction * self.rated_capacity # kW + self.P_max = self.rated_capacity # kW + + # Compute ramp_rate and run_up_rate in kW/s + self.ramp_rate = self.ramp_rate_fraction * self.rated_capacity / 60.0 # kW/s + self.run_up_rate = self.run_up_rate_fraction * self.rated_capacity / 60.0 # kW/s + + # Compute the ramp_time, which is the time to ramp from 0 to P_min + # using the run_up_rate + self.ramp_time = self.P_min / self.run_up_rate # s + + # Check that hot_startup_time is greater than or equal to the ramp_time + if self.hot_startup_time < self.ramp_time: + raise ValueError("hot_startup_time must be greater than or equal to the ramp_time") + + # Check that warm_startup_time is greater than or equal to the ramp_time + if self.warm_startup_time < self.ramp_time: + raise ValueError("warm_startup_time must be greater than or equal to the ramp_time") + + # Check that cold_startup_time is greater than or equal to the ramp_time + if self.cold_startup_time < self.ramp_time: + raise ValueError("cold_startup_time must be greater than or equal to the ramp_time") + + # Check that the cold_startup_time is at least as long as the warm_startup_time + if self.cold_startup_time < self.warm_startup_time: + raise ValueError("cold_startup_time must be greater than or equal to warm_startup_time") + + # Check that the warm_startup_time is at least as long as the hot_startup_time + if self.warm_startup_time < self.hot_startup_time: + raise ValueError("warm_startup_time must be greater than or equal to hot_startup_time") + + # Compute the hot, warm, and cold readying times, which is the startup time minus + # the ramp_time + self.hot_readying_time = self.hot_startup_time - self.ramp_time # s + self.warm_readying_time = self.warm_startup_time - self.ramp_time # s + self.cold_readying_time = self.cold_startup_time - self.ramp_time # s + + # Extract initial conditions + initial_conditions = h_dict[self.component_name]["initial_conditions"] + self.power_output = initial_conditions["power"] # kW + self.state_num = initial_conditions["state_num"] + + # Check that initial conditions are valid + if self.power_output < 0 or self.power_output > self.rated_capacity: + raise ValueError( + "initial_conditions['power'] (initial power) " + "must be between 0 and rated_capacity (inclusive)" + ) + if self.state_num not in self.STATE_NAMES: + raise ValueError( + f"initial_conditions['state_num'] must be one of {list(self.STATE_NAMES.keys())}" + ) + + # State tracking + self.time_in_state = 0.0 # s + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + This is an abstract method that must be implemented by subclasses. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Updated dictionary with initial conditions and meta data. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError( + "Subclasses must implement get_initial_conditions_and_meta_data()" + ) + + def step(self, h_dict): + """Advance the thermal component simulation by one time step. + + Updates the thermal component state including power output, state, and + based on the requested power setpoint. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - self.component_name.power_setpoint: Desired power output [kW] + + Returns: + dict: Updated h_dict with thermal component outputs: + - power: Actual power output [kW] + - state_num: Operating state number (0=off, 1=hot starting, + 2=cold starting, 3=on, 4=stopping) + + """ + # Get power setpoint from controller + power_setpoint = h_dict[self.component_name]["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") + + # Update time in current state + self.time_in_state += self.dt + + # Determine actual power output based on constraints and state + self.power_output = self._control(power_setpoint) + + # Apply post-processing specific to the sub-class + h_dict = self._post_process(h_dict) + + # Update h_dict with outputs + h_dict[self.component_name]["power"] = self.power_output + h_dict[self.component_name]["state_num"] = self.state_num + + return h_dict + + def _control(self, power_setpoint): + """State machine for thermal component control. + + Handles state transitions, startup/shutdown ramps, and power constraints + based on the current state (state_num) and time in that state. + + Note the time definitions for cold versus warm versus hot starting are hard + coded and based on the values in [5]. + + State Machine: + STATE_OFF (0): + - If setpoint > 0 and min_down_time satisfied and time_in_state < 8 hours: + begin HOT_STARTING + - If setpoint > 0 and min_down_time satisfied and time_in_state >= 48 hours: + begin COLD_STARTING + - If setpoint > 0 and min_down_time satisfied and time_in_state >= 8 hours + and time_in_state < 48 hours: begin WARM_STARTING + - Otherwise: remain OFF, output 0 + + STATE_HOT_STARTING (1): + - If setpoint <= 0: abort startup, return to OFF + - If time in state is less than hot_readying_time output 0 + - After hot_readying_time, ramp up to P_min using run_up_rate + - When power output >= P_min: transition to STATE_ON + + STATE_WARM_STARTING (2): + - If setpoint <= 0: abort startup, return to OFF + - If time in state is less than warm_readying_time output 0 + - After warm_readying_time, ramp up to P_min using run_up_rate + - When power output >= P_min: transition to STATE_ON + + STATE_COLD_STARTING (3): + - If setpoint <= 0: abort startup, return to OFF + - If time in state is less than cold_readying_time output 0 + - After cold_readying_time, ramp up to P_min using run_up_rate + - When power output >= P_min: transition to STATE_ON + + STATE_ON (4): + - If setpoint <= 0 and min_up_time satisfied: begin STOPPING + - Otherwise: apply power limits and ramp rate constraints + + STATE_STOPPING (5): + - Ramp to 0 using ramp_rate + - When power output <= 0: transition to STATE_OFF + + Args: + power_setpoint (float): Desired power output in kW. + + Returns: + float: Actual constrained power output in kW. + """ + # ==================================================================== + # STATE: OFF + # ==================================================================== + if self.state_num == self.STATE_OFF: + # Check if we can start (min_down_time satisfied) + can_start = self.time_in_state >= self.min_down_time + + if power_setpoint > 0 and can_start: + # Check if hot, warm, or cold starting is implied + if self.time_in_state < self.HOT_START_TIME: + self.state_num = self.STATE_HOT_STARTING + elif self.time_in_state < self.WARM_START_TIME: + self.state_num = self.STATE_WARM_STARTING + else: + self.state_num = self.STATE_COLD_STARTING + self.time_in_state = 0.0 + + return 0.0 # Power is always 0 when off + + # ==================================================================== + # STATE: HOT_STARTING + # ==================================================================== + elif self.state_num == self.STATE_HOT_STARTING: + # Check if startup should be aborted + if power_setpoint <= 0: + self.state_num = self.STATE_OFF + self.time_in_state = 0.0 + self.power_output = 0.0 + return 0.0 + + # Check if readying time is complete + if self.time_in_state < self.hot_readying_time: + return 0.0 + + # Ramp up using run_up_rate + startup_power = (self.time_in_state - self.hot_readying_time) * self.run_up_rate + + # Check if ramping is complete + if startup_power >= self.P_min: + self.state_num = self.STATE_ON + self.time_in_state = 0.0 + return startup_power + + return startup_power + + # ==================================================================== + # STATE: WARM_STARTING + # ==================================================================== + elif self.state_num == self.STATE_WARM_STARTING: + # Check if startup should be aborted + if power_setpoint <= 0: + self.state_num = self.STATE_OFF + self.time_in_state = 0.0 + self.power_output = 0.0 + return 0.0 + + # Check if readying time is complete + if self.time_in_state < self.warm_readying_time: + return 0.0 + + # Ramp up using run_up_rate + startup_power = (self.time_in_state - self.warm_readying_time) * self.run_up_rate + + # Check if ramping is complete + if startup_power >= self.P_min: + self.state_num = self.STATE_ON + self.time_in_state = 0.0 + return startup_power + + return startup_power + + # ==================================================================== + # STATE: COLD_STARTING + # ==================================================================== + elif self.state_num == self.STATE_COLD_STARTING: + # Check if startup should be aborted + if power_setpoint <= 0: + self.state_num = self.STATE_OFF + self.time_in_state = 0.0 + self.power_output = 0.0 + return 0.0 + + # Check if readying time is complete + if self.time_in_state < self.cold_readying_time: + return 0.0 + + # Ramp up using run_up_rate + startup_power = (self.time_in_state - self.cold_readying_time) * self.run_up_rate + + # Check if ramping is complete + if startup_power >= self.P_min: + self.state_num = self.STATE_ON + self.time_in_state = 0.0 + return startup_power + + return startup_power + + # ==================================================================== + # STATE: ON + # ==================================================================== + elif self.state_num == self.STATE_ON: + # Check if we can shut down (min_up_time satisfied) + can_shutdown = self.time_in_state >= self.min_up_time + + if power_setpoint <= 0 and can_shutdown: + # Transition to shutdown sequence + self.state_num = self.STATE_STOPPING + self.time_in_state = 0.0 + + # Apply constraints for on operation + return self._apply_on_constraints(power_setpoint) + + # ==================================================================== + # STATE: STOPPING + # ==================================================================== + elif self.state_num == self.STATE_STOPPING: + # Ramp the power output down using ramp_rate + shutdown_power = self.power_output - self.ramp_rate * self.dt + + # Check if shutdown is complete + if shutdown_power <= 0: + self.state_num = self.STATE_OFF + self.time_in_state = 0.0 + return 0.0 + + return shutdown_power + + else: + raise ValueError(f"Unexpected state_num in _control: {self.state_num}") + + def _apply_on_constraints(self, power_setpoint): + """Apply power and ramp rate constraints when unit is on. + + Args: + power_setpoint (float): Desired power output in kW. + + Returns: + float: Constrained power output in kW. + """ + # Apply power limits + P_constrained = np.clip(power_setpoint, self.P_min, self.P_max) + + # Apply ramp rate constraints + max_ramp_up = self.power_output + self.ramp_rate * self.dt + max_ramp_down = self.power_output - self.ramp_rate * self.dt + P_constrained = np.clip(P_constrained, max_ramp_down, max_ramp_up) + + return P_constrained + + # Define the _post_process as an abstract method that does nothing to be + # overridden by the subclass. However don't raise an error if it is not overridden. + def _post_process(self, h_dict): + """Post-process the thermal component simulation. + + This is an abstract method that can be implemented by subclasses. If not + overridden, the default behavior is to do nothing. + + Args: + h_dict (dict): Dictionary containing simulation state. + + Returns: + dict: Updated dictionary with post-processed simulation state. + """ + return h_dict diff --git a/hercules/utilities.py b/hercules/utilities.py index d4fb47da..b73a5173 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -27,14 +27,15 @@ def get_available_component_names(): "solar_farm", "battery", "electrolyzer", + "open_cycle_gas_turbine", ] def get_available_generator_names(): """Return available generator component names. - Returns power generators (wind_farm, solar_farm), excluding storage and conversion - components. + Returns power generators (wind_farm, solar_farm, combustion_turbine), excluding + storage and conversion components. Returns: list: Available generator component names. @@ -42,6 +43,7 @@ def get_available_generator_names(): return [ "wind_farm", "solar_farm", + "open_cycle_gas_turbine", ] @@ -59,6 +61,7 @@ def get_available_component_types(): "solar_farm": ["SolarPySAMPVWatts"], "battery": ["BatterySimple", "BatteryLithiumIon"], "electrolyzer": ["ElectrolyzerPlant"], + "open_cycle_gas_turbine": ["OpenCycleGasTurbine"], } diff --git a/tests/open_cycle_gas_turbine_test.py b/tests/open_cycle_gas_turbine_test.py new file mode 100644 index 00000000..2a3b42cb --- /dev/null +++ b/tests/open_cycle_gas_turbine_test.py @@ -0,0 +1,91 @@ +import copy + +import pytest +from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine + +from .test_inputs.h_dict import ( + h_dict_open_cycle_gas_turbine, +) + + +def test_init_from_dict(): + """Test that ThermalComponentBase can be initialized from a dictionary.""" + ocgt = OpenCycleGasTurbine(copy.deepcopy(h_dict_open_cycle_gas_turbine)) + assert ocgt is not None + + +def test_invalid_inputs(): + """Test that OpenCycleGasTurbine raises an error for invalid inputs.""" + h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) + + # Test only the parameters that are specific to OCGT + h_dict["open_cycle_gas_turbine"]["part_load_factor"] = 0.9 + with pytest.raises(ValueError): + OpenCycleGasTurbine(h_dict) + h_dict["open_cycle_gas_turbine"]["part_load_factor"] = 2.1 + with pytest.raises(ValueError): + OpenCycleGasTurbine(h_dict) + h_dict["open_cycle_gas_turbine"]["heat_rate_at_rated_load"] = 0 + with pytest.raises(ValueError): + OpenCycleGasTurbine(h_dict) + + +def test_default_inputs(): + """Test that OpenCycleGasTurbine uses default inputs when not provided.""" + h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) + del h_dict["open_cycle_gas_turbine"]["part_load_factor"] + del h_dict["open_cycle_gas_turbine"]["heat_rate_at_rated_load"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.part_load_factor == 1.0 + assert ocgt.heat_rate_at_rated_load == 10000 + + # Test that the ramp_rate_fraction is 0.5 + assert ocgt.ramp_rate_fraction == 0.5 + + # Test that the run_up_rate_fraction is 0.2 + assert ocgt.run_up_rate_fraction == 0.2 + + # Test that if the run_up_rate_fraction is not provided, + # it defaults to the ramp_rate_fraction + del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.run_up_rate_fraction == ocgt.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 + del h_dict["open_cycle_gas_turbine"]["ramp_rate_fraction"] + del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.ramp_rate_fraction == 0.1 + assert ocgt.run_up_rate_fraction == 0.1 + + # 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 + del h_dict["open_cycle_gas_turbine"]["cold_startup_time"] + del h_dict["open_cycle_gas_turbine"]["warm_startup_time"] + del h_dict["open_cycle_gas_turbine"]["hot_startup_time"] + del h_dict["open_cycle_gas_turbine"]["min_stable_load_fraction"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.min_stable_load_fraction == 0.40 + assert ocgt.hot_startup_time == 7 * 60.0 + assert ocgt.warm_startup_time == 8 * 60.0 + assert ocgt.cold_startup_time == 8 * 60.0 + + del h_dict["open_cycle_gas_turbine"]["min_up_time"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.min_up_time == 30 * 60.0 + + del h_dict["open_cycle_gas_turbine"]["min_down_time"] + ocgt = OpenCycleGasTurbine(h_dict) + assert ocgt.min_down_time == 60 * 60.0 + + +# TODO: Someone familiar with heat rate and fuel consumption please add tests based +# on first principles for the heat rate and fuel consumption. +def test_post_process(): + """Test that OpenCycleGasTurbine post-processes correctly.""" + h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine) + ocgt = OpenCycleGasTurbine(h_dict) + h_dict = ocgt._post_process(h_dict) diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index 70ac6060..39e3b7ff 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -96,6 +96,40 @@ "initial_conditions": {"SOC": 0.102}, } + +thermal_component = { + "component_type": "ThermalComponentBase", + "rated_capacity": 1000, # kW (1 MW) + "min_stable_load_fraction": 0.20, # 20% minimum operating point + "ramp_rate_fraction": 0.50, # 50% of rated capacity per minute + "run_up_rate_fraction": 0.20, # 20% of rated capacity per minute + "hot_startup_time": 120.0, # s (must be >= run_up_rate_fraction of 60s) + "warm_startup_time": 120.0, # s (must be >= ramp_time of 60s) + "cold_startup_time": 120.0, # s (must be >= ramp_time of 60s) + "min_up_time": 10.0, # s + "min_down_time": 10.0, # s + "log_channels": ["power", "state_num"], + "initial_conditions": {"power": 1000, "state_num": 4}, # 4 = on +} + +open_cycle_gas_turbine = { + "component_type": "OpenCycleGasTurbine", + "rated_capacity": 1000, # kW (1 MW) + "min_stable_load_fraction": 0.20, # 20% minimum operating point + "ramp_rate_fraction": 0.50, # 50% of rated capacity per minute + "run_up_rate_fraction": 0.20, # 20% of rated capacity per minute + "hot_startup_time": 120.0, # s (must be >= run_up_rate_fraction of 60s) + "warm_startup_time": 120.0, # s (must be >= ramp_time of 60s) + "cold_startup_time": 120.0, # s (must be >= ramp_time of 60s) + "min_up_time": 10.0, # s + "min_down_time": 10.0, # s + "log_channels": ["power", "state_num"], + "initial_conditions": {"power": 1000, "state_num": 4}, # 4 = on, + "part_load_factor": 1.0, + "heat_rate_at_rated_load": 10000, # kJ/kWh at rated load +} + + electrolyzer = { "component_type": "ElectrolyzerPlant", "initial_conditions": { @@ -296,3 +330,30 @@ "plant": plant, "electrolyzer": electrolyzer, } + + +h_dict_thermal_component = { + "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_component": thermal_component, +} + +h_dict_open_cycle_gas_turbine = { + "dt": 1.0, + "starttime": 0.0, + "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), + "verbose": False, + "step": 0, + "time": 0.0, + "plant": plant, + "open_cycle_gas_turbine": open_cycle_gas_turbine, +} diff --git a/tests/thermal_component_base_test.py b/tests/thermal_component_base_test.py new file mode 100644 index 00000000..e08a9148 --- /dev/null +++ b/tests/thermal_component_base_test.py @@ -0,0 +1,391 @@ +import copy + +import pytest +from hercules.plant_components.thermal_component_base import ThermalComponentBase + +from .test_inputs.h_dict import ( + h_dict_thermal_component, +) + + +def test_init_from_dict(): + """Test that ThermalComponentBase can be initialized from a dictionary.""" + tpb = ThermalComponentBase(copy.deepcopy(h_dict_thermal_component)) + assert tpb is not None + + +def test_invalid_inputs(): + """Test that ThermalComponentBase raises an error for invalid inputs.""" + h_dict = copy.deepcopy(h_dict_thermal_component) + + # Test input must be a number + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["rated_capacity"] = "1000" + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test min_stable_load_fraction must be between 0 and 1 + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_stable_load_fraction"] = 1.1 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["min_stable_load_fraction"] = -0.1 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test ramp_rate_fraction must be a number greater than 0 + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["ramp_rate_fraction"] = 0 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test run_up_rate_fraction must be a number greater than 0 + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["run_up_rate_fraction"] = 0 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test min_up_time must be a number greater than or equal to 0 + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_up_time"] = 0 + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["min_up_time"] = -1 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test min_down_time must be a number greater than or equal to 0 + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_down_time"] = 0 + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["min_down_time"] = -1 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Test hot_startup_time must be a number greater than the ramp_time + # determined by the run_up_rate_fraction + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 + h_dict["thermal_component"]["run_up_rate_fraction"] = 0.2 + + # The above implies a ramp_time of 60s + h_dict["thermal_component"]["hot_startup_time"] = 59 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["hot_startup_time"] = 60 + ThermalComponentBase(h_dict) + + # Test cold_startup_time must be a number greater than the ramp_time + # determined by the ramp_rate_fraction + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 + h_dict["thermal_component"]["run_up_rate_fraction"] = 0.2 + + # Lower hot and warm startup times to 60 seconds + h_dict["thermal_component"]["hot_startup_time"] = 60 + h_dict["thermal_component"]["warm_startup_time"] = 60 + + # The above implies a ramp_time of 60s + h_dict["thermal_component"]["cold_startup_time"] = 59 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["cold_startup_time"] = 60 + ThermalComponentBase(h_dict) + + +def test_compute_ramp_and_readying_times(): + """Test that the ramp_time and readying times are computed correctly.""" + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 + h_dict["thermal_component"]["run_up_rate_fraction"] = 0.2 + + # The above implies a ramp_time of 60s + h_dict["thermal_component"]["hot_startup_time"] = 60 + h_dict["thermal_component"]["cold_startup_time"] = 120 + tcb = ThermalComponentBase(h_dict) + assert tcb.ramp_time == 60 + assert tcb.hot_readying_time == 0 + assert tcb.cold_readying_time == 60 + + +def test_initial_conditions(): + """Test that the initial conditions are set correctly.""" + h_dict = copy.deepcopy(h_dict_thermal_component) + h_dict["thermal_component"]["initial_conditions"]["power"] = 1000 + h_dict["thermal_component"]["initial_conditions"]["state_num"] = 4 + tcb = ThermalComponentBase(h_dict) + assert tcb.power_output == 1000 + assert tcb.state_num == 4 + + # Test that the initial state maps to on + assert tcb.state_name == "on" + + # Check that initial conditions are valid + h_dict["thermal_component"]["initial_conditions"]["power"] = -1 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + h_dict["thermal_component"]["initial_conditions"]["power"] = 1100 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + # Set back to valid values + h_dict["thermal_component"]["initial_conditions"]["power"] = 1000 + ThermalComponentBase(h_dict) + + # Check that state_num is valid + h_dict["thermal_component"]["initial_conditions"]["state_num"] = 6 + with pytest.raises(ValueError): + ThermalComponentBase(h_dict) + + +def test_power_setpoint_in_normal_operation(): + """Test power setpoint control in normal operation.""" + h_dict = copy.deepcopy(h_dict_thermal_component) + + # Set the ramp rate to be 100 kW/s + # Since the rated capacity is 1000 kW, and the ramp rate fraction is + # fraction of rated capacity per minute we can compute the ramp rate fraction as + # 100 kW/s / 1000 kW * 60 = 6 + h_dict["thermal_component"]["ramp_rate_fraction"] = 6 + + # Set the initial conditions to be 500 kW in the on state + h_dict["thermal_component"]["initial_conditions"]["power"] = 500 + h_dict["thermal_component"]["initial_conditions"]["state_num"] = 4 # 4 = on + + tcb = ThermalComponentBase(h_dict) + + # Set the power setpoint to the initial condition + h_dict["thermal_component"]["power_setpoint"] = 500.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 500.0 + + # Set the power setpoint to change by an amount less than the ramp rate + h_dict["thermal_component"]["power_setpoint"] = 550 # kW + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 550.0 + + # Set the power setpoint to change by an amount less than the ramp rate + h_dict["thermal_component"]["power_setpoint"] = 500 # kW + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 500.0 + + # Set the power setpoint to change by an amount greater than the ramp rate + h_dict["thermal_component"]["power_setpoint"] = 650 # kW + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 600.0 + + # Set the power setpoint to change by an amount greater than the ramp rate + h_dict["thermal_component"]["power_setpoint"] = 400 # kW + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 500.0 + + # Set the power setpoint to above the rated capacity and test that + # it is constrained to the rated capacity + h_dict["thermal_component"]["power_setpoint"] = 1100 # kW + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 600.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 700.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 800.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 900.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 1000.0 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["power"] == 1000.0 + + # Test that setting power setpoint to a negative number triggers the shutdown sequence + h_dict["thermal_component"]["power_setpoint"] = -1 + out = tcb.step(copy.deepcopy(h_dict)) + assert out["thermal_component"]["state_num"] == 5 # 5 = stopping + + +def test_transition_on_to_off(): + """Test transition from on state to off state with ramp down and min_up_time.""" + h_dict = copy.deepcopy(h_dict_thermal_component) + + # Set the ramp rate to be 100 kW/s + # Since the rated capacity is 1000 kW, and the ramp rate fraction is + # fraction of rated capacity per minute we can compute the ramp rate fraction as + # 100 kW/s / 1000 kW * 60 = 6 + h_dict["thermal_component"]["ramp_rate_fraction"] = 6 + + # Set the initial conditions to be 500 kW in the on state + h_dict["thermal_component"]["initial_conditions"]["power"] = 500 + h_dict["thermal_component"]["initial_conditions"]["state_num"] = 4 + + # Set the min_up_time to 5s + h_dict["thermal_component"]["min_up_time"] = 5 + + # Set the min_stable_load_fraction to be 0.2 (200 kW) + h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 + + tcb = ThermalComponentBase(h_dict) + + # First time step should be below min_up_time (2s) + assert tcb.state_num == tcb.STATE_ON + assert tcb.power_output == 500 + + # Now assign power setpoint to 0, the expected behavior is that the + # power will ramp_down at the ramp rate until it reaches P_min + # It will hold there until min_up_time is satisfied, + # Then it will ramp to 0 at the ramp rate + # When it reaches 0 it will transition to off + h_dict["thermal_component"]["power_setpoint"] = 0 + + # First step + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 1.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 400 + + # Second step + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 2.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 300 + + # Third step + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 3.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 200 + + # Fourth step (Saturate at P_min) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 4.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 200 + + # Fifth step (Satisfy min_up_time, transition to stopping) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 0.0 # Just entered stopping state + assert out["thermal_component"]["state_num"] == tcb.STATE_STOPPING + assert out["thermal_component"]["power"] == 200 + + # Sixth step (Ramp down to 0) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 1.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_STOPPING + assert out["thermal_component"]["power"] == 100 + + # Seventh step (Transition to off) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 0.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_OFF + assert out["thermal_component"]["power"] == 0 + + +def test_transition_off_to_on(): + # Test off to on transition using a hot start + + h_dict = copy.deepcopy(h_dict_thermal_component) + + # Set the ramp rate to be 100 kW/s + # Since the rated capacity is 1000 kW, and the ramp rate fraction is + # fraction of rated capacity per minute we can compute the ramp rate fraction as + # 100 kW/s / 1000 kW * 60 = 6 + h_dict["thermal_component"]["ramp_rate_fraction"] = 6 + + # Set the initial conditions to be 0 kW in the off state + h_dict["thermal_component"]["initial_conditions"]["power"] = 0 + h_dict["thermal_component"]["initial_conditions"]["state_num"] = 0 # 0 = off + + # Set the min_down_time to 3 + h_dict["thermal_component"]["min_down_time"] = 3 + + # Set the min_stable_load_fraction to be 0.2 (200 kW) + h_dict["thermal_component"]["min_stable_load_fraction"] = 0.2 + + # Set the hot_startup_time to be 7s + h_dict["thermal_component"]["hot_startup_time"] = 7 + + # Set the run_up_rate_fraction to be 3 (implying 50 kW/s) + h_dict["thermal_component"]["run_up_rate_fraction"] = 3 + + # This run up time and min_stable_load_fraction imply a ramp_time of 4 seconds + # so the hot readying time should be 3 seconds + + tcb = ThermalComponentBase(h_dict) + + # First time step should be below min_up_time (2s) + assert tcb.state_num == tcb.STATE_OFF + assert tcb.power_output == 0 + + # Confirm that the hot readying time is 3 seconds + assert tcb.hot_readying_time == 3 + + # Now assign power setpoint to be 500, the expected behavior is that the + # the unit will stay in off state until min_down_time is satisfied + # Then it will transition to hot starting + # Power will remain at 0 until the hot readying time is satisfied + # Then it will ramp up at the run up rate (50 kW/s) + # When the power reaches P_min (200 kW) it will transition to on + # Then the ramp will increase to the ramp rate (100 kW/s) + h_dict["thermal_component"]["power_setpoint"] = 500 + + # First step + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 1.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_OFF + assert out["thermal_component"]["power"] == 0 + + # Second step + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 2.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_OFF + assert out["thermal_component"]["power"] == 0 + + # Third step (Transition to hot starting) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 0.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 0 + + # Fourth step (HOT START READYING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 1.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 0 + + # Fifth step (HOT START READYING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 2.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 0 + + # Sixth step (HOT START RAMPING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 3.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 0 + + # Seventh step (HOT START RAMPING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 4.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 50 + + # Eighth step (HOT START RAMPING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 5.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 100 + + # Ninth step (HOT START RAMPING) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 6.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_HOT_STARTING + assert out["thermal_component"]["power"] == 150 + + # Tenth step (Transition to on) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 0.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 200 + + # Eleventh step (Ramping in on state) + out = tcb.step(copy.deepcopy(h_dict)) + assert tcb.time_in_state == 1.0 + assert out["thermal_component"]["state_num"] == tcb.STATE_ON + assert out["thermal_component"]["power"] == 300