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 @@
+
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