Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new natural gas combustion turbine model (CombustionTurbineSimple) to Hercules with basic state management, ramp rate constraints, minimum stable load enforcement, and fuel consumption tracking. The implementation includes integration with the plant component system, a basic test, and a comprehensive example demonstrating turbine operation through various power setpoints and state transitions.
Key Changes:
- Adds
CombustionTurbineSimplecomponent with 4-state machine (off, starting, on, stopping) and configurable operational constraints - Integrates combustion turbine as a generator component in the utilities and hybrid plant infrastructure
- Provides Example 07 demonstrating a 6-hour simulation with complex control schedule
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
hercules/plant_components/combustion_turbine_simple.py |
New combustion turbine model with state machine, ramp constraints, fuel consumption, and part-load efficiency calculations |
hercules/hybrid_plant.py |
Adds component instantiation logic for CombustionTurbineSimple |
hercules/utilities.py |
Registers combustion_turbine as available component and generator type |
tests/combustion_turbine_simple_test.py |
Basic initialization test (requires missing test fixture to run) |
examples/07_ngct/hercules_input.yaml |
Configuration for 100 MW turbine with 20% minimum load |
examples/07_ngct/hercules_runscript.py |
Controller implementing multi-stage power schedule |
examples/07_ngct/plot_outputs.py |
Visualization script for power, state, and fuel consumption |
examples/07_ngct/README.md |
Documentation of example configuration and control schedule |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| dict: Updated h_dict with combustion turbine outputs: | ||
| - power: Actual power output [kW] | ||
| - state_num: Operating state number (0=off, 1=starting, 2=on, 3=stopping) | ||
| - state_name: Operating state string ("off", "starting", "on", "stopping") |
There was a problem hiding this comment.
The step method documentation lists 'state_name' as an output (line 189), but the actual code has this output commented out (line 208). This creates a discrepancy between documentation and implementation. Either uncomment the output if it should be included, or remove it from the documentation if it's intentionally excluded.
| - state_name: Operating state string ("off", "starting", "on", "stopping") |
There was a problem hiding this comment.
This output was removed because it created problems to have a string in the h_dict output, (and anyway the info is in statenum). Removing from docstring
|
|
||
| # Check that initial conditions are valid | ||
| if self.power_output < 0 or self.power_output > self.rated_capacity: | ||
| raise ValueError("power_output must be between 0 and rated_capacity (inclusive)") |
There was a problem hiding this comment.
The error message references 'power_output' but this is not a user-provided parameter - it comes from initial_conditions["power"]. The error message should reference 'initial power' or 'initial_conditions.power' to help users identify which configuration value is invalid.
| raise ValueError("power_output must be between 0 and rated_capacity (inclusive)") | |
| raise ValueError( | |
| "initial_conditions['power'] (initial power) must be between 0 and rated_capacity (inclusive)" | |
| ) |
| """Simple controller for combustion turbine on/off scheduling. | ||
|
|
||
| This controller turns the combustion turbine on at 60 minutes, | ||
| runs at full power for 3 hours, then shuts down at 240 minutes. |
There was a problem hiding this comment.
The class docstring description is inconsistent with the actual controller behavior. The docstring says "runs at full power for 3 hours" but the implementation has a more complex schedule with multiple power level changes (100% → 50% → 10% → 100% → off) as documented in lines 5-9 of the module docstring. Consider updating the class docstring to accurately reflect the control logic or simply reference the module docstring.
| """Simple controller for combustion turbine on/off scheduling. | |
| This controller turns the combustion turbine on at 60 minutes, | |
| runs at full power for 3 hours, then shuts down at 240 minutes. | |
| """Controller implementing the NGCT schedule described in the module docstring. | |
| The turbine starts off, then: | |
| - At 60 minutes, it is commanded to 100% of rated capacity. | |
| - At 120 minutes, it is reduced to 50% of rated capacity. | |
| - At 180 minutes, it is reduced to 10% of rated capacity. | |
| - At 210 minutes, it is increased back to 100% of rated capacity. | |
| - At 240 minutes, it is commanded off. |
| if self.state_num not in self.STATE_NAMES: | ||
| raise ValueError( | ||
| "state_num must be one of the following: " + str(self.STATE_NAMES.values()) | ||
| ) |
There was a problem hiding this comment.
The state_num validation is redundant. Lines 116-117 already check if state_num is between 0 and 3 (inclusive), which is identical to checking if it's in STATE_NAMES (which contains keys 0, 1, 2, 3). The check on line 118 will always pass if line 116 passes. Consider removing lines 118-121 to eliminate redundancy.
| if self.state_num not in self.STATE_NAMES: | |
| raise ValueError( | |
| "state_num must be one of the following: " + str(self.STATE_NAMES.values()) | |
| ) |
|
@misi9170 and @genevievestarke , update today is I merged back in develop to this PR and then went through the review comments from co-pilot (most of which were helpful!) |
| float: Fuel consumed this timestep in kJ. | ||
| """ | ||
|
|
||
| # TODO: Is this correct? Should we be getting to m^3 of natural gas? |
|
ok @genevievestarke and @misi9170 , I now wrote a set of tests and updated the docs so this one should be ready for review. |
|
ok @genevievestarke and @misi9170 , this PR is now fully reworked following our discussion. It most closely follows the model descriptions in: [1] Agora Energiewende (2017): Flexibility in thermal power plants I redid the top comment box on this PR and added 2 todo items if you don't mind to have a look, or can just talk with me about it. @brookeslawski I added you as a reviewer in case you have a few minutes would value your feedback, but understand if you don't! |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 12 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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 |
There was a problem hiding this comment.
When startup ramping reaches/exceeds P_min, _control transitions to STATE_ON but returns startup_power without any cap. With larger dt or high run_up_rate, this can exceed rated_capacity and propagate unrealistic power (and downstream fuel burn). Clamp the returned power at least to [0, P_max] (and typically return exactly P_min on the transition).
| # Store the type of this component | ||
| self.component_type = "OpenCycleGasTurbine" | ||
|
|
||
| # Apply fixeddefault parameters based on [1], [2] and [3] |
There was a problem hiding this comment.
Typo in comment: “fixeddefault” should be “fixed default”.
| # Apply fixeddefault parameters based on [1], [2] and [3] | |
| # Apply fixed default parameters based on [1], [2] and [3] |
| # Test cold_startup_time must be a number greater than the ramp_time | ||
| # determined by the ramp_rate_fraction |
There was a problem hiding this comment.
This comment says the cold startup constraint is “determined by the ramp_rate_fraction”, but the implementation enforces cold_startup_time >= hot_startup_time (and ramp time is based on run_up_rate_fraction). Consider updating the comment to reflect the actual validation being exercised.
| # Test cold_startup_time must be a number greater than the ramp_time | |
| # determined by the ramp_rate_fraction | |
| # Test cold_startup_time must be a number greater than or equal to the | |
| # hot_startup_time (which in this setup equals the ramp_time determined | |
| # by the run_up_rate_fraction) |
| # Transition to shutdown sequence | ||
| self.state_num = self.STATE_STOPPING | ||
| self.time_in_state = 0.0 | ||
|
|
There was a problem hiding this comment.
In the STATE_ON branch, you set state_num = STATE_STOPPING but still return _apply_on_constraints(...) (ON-state logic). This means the first timestep of STOPPING still enforces P_min/ON constraints, which can delay or distort shutdown behavior (especially for large dt). After transitioning to STATE_STOPPING, return stopping-state ramp-down power instead (or early-return and let the STOPPING branch handle output).
| # Immediately apply stopping-state ramp-down behavior | |
| shutdown_power = self.power_output - self.ramp_rate * self.dt | |
| # Check if shutdown is complete in this timestep | |
| if shutdown_power <= 0: | |
| self.state_num = self.STATE_OFF | |
| self.time_in_state = 0.0 | |
| return 0.0 | |
| return shutdown_power |
|
|
||
|
|
||
| def test_init_from_dict(): | ||
| """Test that ThermalComponentBase can be initialized from a dictionary.""" |
There was a problem hiding this comment.
Test docstring references ThermalComponentBase, but this test initializes OpenCycleGasTurbine. Update the docstring to match what’s being tested.
| """Test that ThermalComponentBase can be initialized from a dictionary.""" | |
| """Test that OpenCycleGasTurbine can be initialized from a dictionary.""" |
| # 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) |
There was a problem hiding this comment.
test_post_process doesn’t assert anything about the post-processed outputs, so it will pass even if _post_process is broken. Add assertions that fuel_consumption and heat_rate keys are written to h_dict and that values match expected first-principles calculations for a known power_output/dt (including an off/zero-power case).
examples/07_ocgt/plot_outputs.py
Outdated
| @@ -0,0 +1,84 @@ | |||
| # Plot the outputs of the simulation for the NGCT example | |||
There was a problem hiding this comment.
Header comment references an “NGCT example”, but this example is for an open-cycle gas turbine (OCGT). Consider renaming references (and possibly ControllerNGCT) to avoid confusion.
| # Plot the outputs of the simulation for the NGCT example | |
| # Plot the outputs of the simulation for the open-cycle gas turbine (OCGT) example |
| h_dict = copy.deepcopy(h_dict_thermal_component) | ||
|
|
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This PR adds a base class for thermal components, including both gas and coal plants. The modeling is based largely on 3 papers:
[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.
In this PR, in addition to the base class:
ThermalComponentBasea first subclass is added:OpenCycleGasTurbine, implementing an open-cycle (CT, peaker) NG component. The PR includes an example demonstratingOpenCycleGasTurbine, docs for the new code and tests for both.Still todo:
tests/open_cycle_gas_turbine_test.py(note there is TODO comment at the relavent location in the file)