diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..98b5f032
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+- package-ecosystem: "pip"
+ directory: "/" # Location of package manifests
+ target-branch: "develop"
+ schedule:
+ interval: "monthly"
+ labels:
+ - "package"
+ ignore:
+ - dependency-name: "*"
+ update-types: ["version-update:semver-minor", "version-update:semver-patch"]
\ No newline at end of file
diff --git a/.github/workflows/continuous-integration-workflow.yaml b/.github/workflows/continuous-integration-workflow.yaml
index a2a31086..cca0bf84 100644
--- a/.github/workflows/continuous-integration-workflow.yaml
+++ b/.github/workflows/continuous-integration-workflow.yaml
@@ -9,7 +9,7 @@ jobs:
strategy:
fail-fast: False
matrix:
- python-version: ["3.9", "3.10", "3.11"]
+ python-version: [ "3.11", "3.12", "3.13"]
os: [ubuntu-latest] #, macos-latest, windows-latest]
steps:
@@ -22,8 +22,6 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e ".[develop]"
- pip install git+https://github.com/NREL/electrolyzer.git
- pip install https://github.com/NREL/SEAS/blob/v1/SEAS.tar.gz?raw=true
# - uses: pre-commit/action@v3.0.0
- name: Run ruff
run: |
@@ -42,11 +40,10 @@ jobs:
pip install pytest
pip install pytest-cov
pytest --cov=./ --cov-report=xml tests/
- - name: Upload coverage to Codecov
+ - name: Upload coverage to Codecov
if: ${{ env.CODECOV_TOKEN }} # Don't attempt to upload if the codecov token is not configured
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: true
-
\ No newline at end of file
diff --git a/.github/workflows/deploy-pages.yaml b/.github/workflows/deploy-pages.yaml
index 552c353f..c79789c1 100644
--- a/.github/workflows/deploy-pages.yaml
+++ b/.github/workflows/deploy-pages.yaml
@@ -19,38 +19,11 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
- python-version: "3.10"
+ python-version: "3.12"
- name: Install dependencies
run: |
pip install -e ".[docs]"
-
- - name: Make copies of example docs
- run: |
- mkdir -vp docs/examples
-
- cp -v examples/00_wind_farm_only/README.md docs/examples/00_wind_farm_only.md
- cp -v examples/01_wind_farm_dof1_model/README.md docs/examples/01_wind_farm_dof1_model.md
- cp -v examples/02_wind_farm_realistic_inflow/README.md docs/examples/02_wind_farm_realistic_inflow.md
- cp -v examples/02b_wind_farm_realistic_inflow_precom_floris/README.md docs/examples/02b_wind_farm_realistic_inflow_precom_floris.md
- cp -v examples/03_wind_and_solar/README.md docs/examples/03_wind_and_solar.md
- cp -v examples/04_wind_and_storage/README.md docs/examples/04_wind_and_storage.md
- ls -l docs/examples
-
- - name: Debug directory structure
- run: |
- echo "Current directory: $(pwd)"
- echo "GitHub workspace: ${{github.workspace}}"
- echo "Runner workspace: ${{runner.workspace}}"
- ls -la
- ls -la docs/
- ls -la docs/examples/
-
- # # Build the book
- # - name: Build the book
- # working-directory: ${{runner.workspace}}/hercules/docs/
- # run: |
- # jupyter-book build .
# Build the book
- name: Build the book
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
new file mode 100644
index 00000000..3d849a8c
--- /dev/null
+++ b/.github/workflows/python-publish.yml
@@ -0,0 +1,34 @@
+# This workflows will upload a Python Package using Twine when a release is created
+# Published via GitHub Actions as a PyPI Trusted Publisher.
+# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
+# and: https://docs.pypi.org/trusted-publishers/
+
+name: Upload Python Package
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ deploy:
+ environment: release-pypi
+ if: github.repository_owner == 'NatLabRockies'
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.x'
+ - name: Install dependencies and build package
+ run: |
+ python -m pip install --upgrade pip
+ pip install build twine
+ python -m build
+ twine check --strict dist/*
+ - name: Publish package to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ verbose: True
diff --git a/.gitignore b/.gitignore
index 65d20905..4c17b317 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,78 +1,114 @@
-# Gitignore file
+# =============================================================================
+# Python
+# =============================================================================
*.pyc
-*.log
-*.egg-info
-data.db
+__pycache__/
+*.egg-info/
*.python-version
-*.DS_Store
-*.conda
-*.sqlite
-slices
-# macOS files
+# =============================================================================
+# macOS
+# =============================================================================
.DS_Store
+__MACOSX/
-# ide settings and files
-.idea
-.vscode
+# =============================================================================
+# IDE/Editor Settings
+# =============================================================================
+.idea/
+.vscode/
+.cursor/rules/
-# Some c++ stuff
-a.out
-*.plist
-test_client.dSYM
+# =============================================================================
+# Jupyter Notebooks
+# =============================================================================
*checkpoint.ipynb
-hercules/sampling00000.nc
-hercules/front_end.db
-hercules/control_center.db
-hercules/test_client_cpp/test_client
-hercules/df_flow.p
-hercules/flow_data.p
-
-t_*.txt
-hercules/sample_copy.nc
-hercules/t_0*
-hercules/local_amr_wind_demo/sample_copy.nc
+*.nbconvert.ipynb
+.ipynb_checkpoints/
-#Ignore csv files
+# =============================================================================
+# Data Files (large/generated)
+# =============================================================================
+# CSV files (except test inputs)
*.csv
!tests/test_inputs/*.csv
-!example_case_folders/00_wind_farm_only/inputs/floris_standin_data.csv
-!example_case_folders/00_wind_farm_only/inputs/wind_power_reference_data.csv
-# h5 files
+# Binary data formats
*.h5
*.hdf5
+*.feather
+*.ftr
+*.parquet
-# Larger wind input file
-wind_resource_rex
+# Pickle files (generated input data)
+*.p
+*.pkl
-# Wind input pickle files
-wind_input.p
+# Large wind resource data
+wind_resource_rex/
-# Solar input pickle files
-solar_input.p
+# =============================================================================
+# Hercules Simulation Outputs
+# =============================================================================
+# Output directories contain generated results
+outputs/
-# Intermediate notebook files
-*.nbconvert.ipynb
-*.ipynb_checkpoints
+# Log files (generated during simulation)
+*.log
-# Some output files to ignore
+# H_dict echo files (simulation state dumps)
+*.echo
+
+# =============================================================================
+# Documentation Build Artifacts
+# =============================================================================
+docs/_build/
+
+# =============================================================================
+# Testing/CI Artifacts
+# =============================================================================
+.pytest_cache/
+.ruff_cache/
+
+# =============================================================================
+# LEGACY HERCULES - May remove in future cleanup
+# =============================================================================
+# Old example folder path (renamed to examples/)
+!example_case_folders/00_wind_farm_only/inputs/floris_standin_data.csv
+!example_case_folders/00_wind_farm_only/inputs/wind_power_reference_data.csv
+
+# C++ build artifacts (from old AMR-Wind integration)
+a.out
+*.plist
+test_client.dSYM
+hercules/test_client_cpp/test_client
+
+# NetCDF files (from old AMR-Wind demos)
+hercules/sampling00000.nc
+hercules/sample_copy.nc
+hercules/local_amr_wind_demo/sample_copy.nc
+
+# Database files (from old front-end/control center)
+data.db
+*.sqlite
+*.conda
+hercules/front_end.db
+hercules/control_center.db
+
+# Old data/flow pickle files
+hercules/df_flow.p
+hercules/flow_data.p
+
+# AMR-Wind time step outputs
+t_*.txt
+hercules/t_0*
t_00*
+slices
+
+# Old log file patterns (now covered by *.log)
logdummy
loghelics
loghercules*
logstandin*
logfloris*
-*echo
*out-example.json
-
-.vscode/*
-
-# Doc builds
-docs/_build
-docs/examples
-
-.cursor/rules/
-*.feather
-*.ftr
-*.parquet
diff --git a/CITATION.cff b/CITATION.cff
index 9f95ef89..41a2c7e9 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -5,7 +5,7 @@ authors:
- family-names: NLR
given-names:
title: "HERCULES"
-version: 2
+version: 2.1
url: https://github.com/NatLabRockies/hercules
-date-released: 2025-12-31
+date-released: 2026-03-30
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 993017ea..ad0f76a7 100644
--- a/docs/_toc.yml
+++ b/docs/_toc.yml
@@ -18,14 +18,21 @@ parts:
- file: timing
- file: h_dict
- file: hybrid_plant
+ - file: component_types
+ - file: adding_components
- file: hercules_model
- file: output_files
- caption: Plant Components
chapters:
- file: wind
+ - file: power_playback
- file: solar_pv
- file: battery
- file: electrolyzer
+ - file: thermal_component_base
+ - file: open_cycle_gas_turbine
+ - file: hard_coal_steam_turbine
+ - file: thermal_plant
- caption: Inputs
chapters:
- file: hercules_input
@@ -35,10 +42,14 @@ parts:
chapters:
- file: examples_overview
- file: examples/00_wind_farm_only
+ - file: examples/00c_power_playback
- file: examples/01_wind_farm_dof1_model
- - file: examples/02_wind_farm_realistic_inflow
+ - file: examples/02a_wind_farm_realistic_inflow
- file: examples/02b_wind_farm_realistic_inflow_precom_floris
+ - file: examples/02c_wind_farm_realistic_inflow_direct
- file: examples/03_wind_and_solar
- file: examples/04_wind_and_storage
- file: examples/05_wind_and_storage_with_lmp
- file: examples/06_wind_and_hydrogen
+ - file: examples/07_open_cycle_gas_turbine
+ - file: examples/09_multiunit_thermal_plant
diff --git a/docs/adding_components.md b/docs/adding_components.md
new file mode 100644
index 00000000..6cf8b53d
--- /dev/null
+++ b/docs/adding_components.md
@@ -0,0 +1,149 @@
+# Adding a New Component
+
+This guide explains how to add a new plant component type to Hercules. The process involves three steps:
+
+1. Create the component class
+2. Register the component
+3. Document the component
+
+## Step 1: Create the Component Class
+
+Create a new Python file in `hercules/plant_components/` for your component. The class must:
+
+- Inherit from `ComponentBase`
+- Define a `component_category` class attribute
+- Implement `__init__`, `step`, and `get_initial_conditions_and_meta_data` methods
+
+### Minimal Example
+
+```python
+# hercules/plant_components/my_component.py
+
+from hercules.plant_components.component_base import ComponentBase
+
+
+class MyComponent(ComponentBase):
+ """Brief description of the component."""
+
+ component_category = "generator" # or "storage" or "load"
+
+ def __init__(self, h_dict, component_name):
+ """Initialize the component.
+
+ Args:
+ h_dict: Dictionary containing simulation parameters.
+ component_name: Unique name for this instance (the YAML key).
+ """
+ # Call base class init first
+ super().__init__(h_dict, component_name)
+
+ # Read component-specific parameters from h_dict
+ self.rated_power = h_dict[self.component_name]["rated_power"]
+
+ # Initialize internal state
+ self.power = 0.0
+
+ def get_initial_conditions_and_meta_data(self, h_dict):
+ """Add initial conditions and metadata to h_dict.
+
+ Called once during HybridPlant initialization.
+
+ Args:
+ h_dict: Dictionary containing simulation parameters.
+
+ Returns:
+ Updated h_dict with initial conditions.
+ """
+ h_dict[self.component_name]["power"] = self.power
+ return h_dict
+
+ def step(self, h_dict):
+ """Advance the simulation by one time step.
+
+ Args:
+ h_dict: Dictionary containing current simulation state.
+
+ Returns:
+ Updated h_dict with component outputs.
+ """
+ # Read inputs (e.g., setpoint from controller)
+ setpoint = h_dict[self.component_name].get("power_setpoint", 0.0)
+
+ # Compute outputs
+ self.power = min(setpoint, self.rated_power)
+
+ # Write outputs to h_dict
+ h_dict[self.component_name]["power"] = self.power
+
+ return h_dict
+```
+
+### Key Requirements
+
+| Requirement | Description |
+|---|---|
+| `component_category` | Must be `"generator"`, `"storage"`, or `"load"`. Generators contribute to `locally_generated_power`. |
+| `super().__init__()` | Sets `self.component_name`, `self.component_type`, `self.dt`, `self.starttime`, and configures logging. |
+| `power` output | All components must write a `power` value to `h_dict[self.component_name]["power"]` in the `step` method. |
+| Return `h_dict` | Both `get_initial_conditions_and_meta_data` and `step` must return the modified `h_dict`. |
+
+### Component Categories
+
+- **`generator`**: Produces power (wind, solar, gas turbine). Power is summed into `locally_generated_power`. Generator power should be positive signed to represent production.
+- **`storage`**: Stores and releases power (batteries). Sign convention is automatically handled by `HybridPlant` in the following way. It is assumed at the component model level, battery dischage is negatively signed. At the plant level `HybridPlant` inverts the sign of the setpoint going into the battery component model, and inverts the power output coming out of the battery component model. This way at the plant level, positive power represents discharge/production, consistent with the generator category.
+- **`load`**: Consumes power (electrolyzers). Power of loads should be negative signed to represent consumption.
+
+While only generator power is included in `locally_generated_power`, all categories' power are combined into the total plant power in the `HybridPlant` class.
+
+## Step 2: Register the Component
+
+Add the component to `COMPONENT_REGISTRY` in `hercules/component_registry.py` (see [Hybrid Plant Components](hybrid_plant.md)).
+
+
+The key string (e.g., `"MyComponent"`) is the `component_type` value users will specify in their YAML input files.
+
+## Testing
+
+Add unit tests in `tests/my_component_test.py`. Test at minimum:
+
+- Initialization with valid parameters
+- `step` method produces expected outputs
+- `get_initial_conditions_and_meta_data` sets initial state
+
+Run tests with:
+
+```bash
+pytest tests/my_component_test.py -v
+```
+
+## Step 3: Document the Component
+
+1. **Create a docs page**: Add `docs/my_component.md` with usage examples and parameter reference.
+
+2. **Update the table of contents**: Add the page to `docs/_toc.yml` under "Plant Components":
+
+ ```yaml
+ - caption: Plant Components
+ chapters:
+ - file: wind
+ - file: solar_pv
+ # ...
+ - file: my_component # Add your page
+ ```
+
+3. **Update reference tables**: Add your component to the tables in:
+ - [hybrid_plant.md](hybrid_plant.md) — Available Components table
+ - [component_types.md](component_types.md) — Complete Component Type Reference table
+
+
+## Summary Checklist
+
+- [ ] Create `hercules/plant_components/my_component.py`
+- [ ] Inherit from `ComponentBase`
+- [ ] Define `component_category` class attribute
+- [ ] Implement `__init__`, `step`, `get_initial_conditions_and_meta_data`
+- [ ] Import and add to `COMPONENT_REGISTRY` in `hercules/component_registry.py`
+- [ ] Create tests in `tests/my_component_test.py`
+- [ ] Create `docs/my_component.md`
+- [ ] Add to `docs/_toc.yml`
+- [ ] Update reference tables in `hybrid_plant.md` and `component_types.md`
diff --git a/docs/battery.md b/docs/battery.md
index 761790e2..6590cf78 100644
--- a/docs/battery.md
+++ b/docs/battery.md
@@ -12,7 +12,7 @@ for consistency with other components. This inversion applies to power_setpoint
### Parameters
-Battery parameters are defined in the hercules input yaml file used to initialize `HerculesModel`.
+Battery parameters are defined in the hercules input yaml file used to initialize `HerculesModel`. The YAML section key is a user-chosen `component_name` (e.g. `battery`, `battery_unit_1`); the examples below use `battery` as a conventional choice. See [Component Names, Types, and Categories](component_types.md) for details.
#### Required Parameters
- `component_type`: `"BatterySimple"` or `"BatteryLithiumIon"`
diff --git a/docs/component_types.md b/docs/component_types.md
new file mode 100644
index 00000000..cc67ad7d
--- /dev/null
+++ b/docs/component_types.md
@@ -0,0 +1,112 @@
+# Component Names, Types, and Categories
+
+Three related but distinct concepts govern how plant components are identified in Hercules: `component_name`, `component_type`, and `component_category`. Understanding the distinction is important for writing YAML input files and for programmatically working with `h_dict`.
+
+## The Three Concepts
+
+### `component_name`
+
+The **component name** is the top-level YAML key for the component section. It is user-chosen and becomes the key used to access the component's state in `h_dict` throughout the simulation.
+
+- **Source**: YAML input file (the key you choose)
+- **Can be**: Any valid YAML string — `"battery"`, `"battery_unit_1"`, `"my_wind_farm"`, etc.
+- **Available as**: `self.component_name` on the component object; `h_dict[component_name]` at runtime
+
+The name does not need to match the category. Using the category name (e.g. `battery:`) is a common convention for single-instance plants and is used throughout most examples in these docs.
+
+### `component_type`
+
+The **component type** is the string value of the `component_type` field inside the component's YAML section. It determines which Python class gets instantiated.
+
+- **Source**: `component_type:` field in the component's YAML block
+- **Must be**: Exactly one of the registered class name strings (see [reference table](#complete-component-type-reference) below)
+- **Available as**: `self.component_type` on the component object (set automatically from the class name — never needs to be hardcoded in component code)
+
+### `component_category`
+
+The **component category** is a class-level attribute defined in each component class. It is not read from YAML — it is part of the class definition itself.
+
+- **Source**: `component_category = "..."` class variable in the Python class
+- **Valid values**: `"generator"`, `"load"`, or `"storage"`
+- **Used by**: `HybridPlant` to classify components as generators vs. load/storage, and to apply the storage sign convention
+- **Available as**: `self.component_category` on the component object (and `ComponentBase.component_category` as a class attribute)
+
+Every `ComponentBase` subclass **must** define `component_category`; a `TypeError` is raised at class-definition time if it is missing.
+
+### Summary
+
+| Concept | Set by | Example value | Used for |
+|---|---|---|---|
+| `component_name` | User (YAML key) | `"battery_unit_1"` | Accessing `h_dict[name]`; unique instance ID |
+| `component_type` | User (`component_type:` field) | `"BatterySimple"` | Registry lookup to select the Python class |
+| `component_category` | Developer (class variable) | `"storage"` | Generator classification; sign convention |
+
+---
+
+## Complete Component Type Reference
+
+| `component_type` | `component_category` | Documentation |
+|---|---|---|
+| `WindFarm` | `generator` | [Wind](wind.md) |
+| `WindFarmSCADAPower` | `generator` | [Wind](wind.md) |
+| `PowerPlayback` | `generator` | [Power Playback](power_playback.md) |
+| `SolarPySAMPVWatts` | `generator` | [Solar PV](solar_pv.md) |
+| `BatterySimple` | `storage` | [Battery](battery.md) |
+| `BatteryLithiumIon` | `storage` | [Battery](battery.md) |
+| `ElectrolyzerPlant` | `load` | [Electrolyzer](electrolyzer.md) |
+| `OpenCycleGasTurbine` | `generator` | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) |
+| `HardCoalSteamTurbine` | `generator` | [Hard Coal Steam Turbine](hard_coal_steam_turbine.md) |
+| `ThermalPlant` | `generator` | [Thermal Plant](thermal_plant.md) |
+
+Components with `component_category == "generator"` contribute to `h_dict["plant"]["locally_generated_power"]`.
+
+For a guide on implementing new component types, see [Adding Components](adding_components.md).
+
+---
+
+## Multi-Instance Plants
+
+Because `component_name` is user-chosen, you can include multiple instances of the same `component_type` in one plant. Give each instance a unique YAML key:
+
+```yaml
+battery_unit_1:
+ component_type: BatterySimple
+ energy_capacity: 100.0 # kWh
+ charge_rate: 50.0 # kW
+ discharge_rate: 50.0 # kW
+ max_SOC: 0.9
+ min_SOC: 0.1
+ initial_conditions:
+ SOC: 0.5
+
+battery_unit_2:
+ component_type: BatterySimple
+ energy_capacity: 200.0 # kWh
+ charge_rate: 100.0 # kW
+ discharge_rate: 100.0 # kW
+ max_SOC: 0.95
+ min_SOC: 0.05
+ initial_conditions:
+ SOC: 0.8
+```
+
+In a controller, access each instance by its name:
+
+```python
+class MyController:
+ def step(self, h_dict):
+ power_1 = h_dict["battery_unit_1"]["power"]
+ power_2 = h_dict["battery_unit_2"]["power"]
+
+ h_dict["battery_unit_1"]["power_setpoint"] = 25.0
+ h_dict["battery_unit_2"]["power_setpoint"] = -50.0
+ return h_dict
+```
+
+`h_dict["component_names"]` contains the list of all discovered component names, e.g. `["battery_unit_1", "battery_unit_2"]`.
+
+---
+
+## Conventions
+
+For single-instance plants, it is conventional to use the `component_category` as the YAML key — e.g. `battery:`, `wind_farm:`, `solar_farm:`. This matches most examples in these docs and makes the input file easy to read. It is not required; the key is always user-chosen.
diff --git a/docs/electrolyzer.md b/docs/electrolyzer.md
index 991089f9..cbf43b8e 100644
--- a/docs/electrolyzer.md
+++ b/docs/electrolyzer.md
@@ -2,7 +2,7 @@
The hydrogen electrolyzer modules use the [electrolyzer](https://github.com/NREL/electrolyzer) package developed by the National Laboratory of the Rockies to predict hydrogen output of hydrogen electrolyzer plants. This repo contains models for PEM and Alkaline electrolyzer cell types.
-To create a hydrogen electrolyzer plant, set `component_type` = `ElectrolyzerPlant` in the input dictionary (.yaml file).
+To create a hydrogen electrolyzer plant, set `component_type: ElectrolyzerPlant` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `electrolyzer`); see [Component Names, Types, and Categories](component_types.md) for details.
## Inputs
diff --git a/examples/00_wind_farm_only/README.md b/docs/examples/00_wind_farm_only.md
similarity index 90%
rename from examples/00_wind_farm_only/README.md
rename to docs/examples/00_wind_farm_only.md
index c78df4a0..abfc3bc6 100644
--- a/examples/00_wind_farm_only/README.md
+++ b/docs/examples/00_wind_farm_only.md
@@ -18,7 +18,7 @@ python hercules_runscript.py
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/docs/examples/00b_wind_farm_scada_power.md b/docs/examples/00b_wind_farm_scada_power.md
new file mode 100644
index 00000000..f967e85f
--- /dev/null
+++ b/docs/examples/00b_wind_farm_scada_power.md
@@ -0,0 +1,25 @@
+# Example 00b: Wind Farm SCADA Power
+
+## Description
+
+This example demonstrates the use of `WindFarmSCADAPower` to simulate a wind farm using pre-recorded SCADA power data. `WindFarmSCADAPower` is primarily useful when the actual turbine powers are provided and we want to play back the pre-recorded power data. There is no option to control the turbine powers; they are simply played back at the pre-recorded power levels.
+
+## Setup
+
+As in example 00, the wind farm is a small 3 turbine farm and the input is automatically generated. For `WindFarmSCADAPower` this input is a history of pre-recorded turbine power data in `inputs/scada_input.ftr`.
+
+## Running
+
+To run the example, execute the following command in the terminal:
+
+```bash
+python hercules_runscript.py
+```
+
+## Outputs
+
+To plot the outputs, run the following command in the terminal:
+
+```bash
+python plot_outputs.py
+```
diff --git a/docs/examples/00c_power_playback.md b/docs/examples/00c_power_playback.md
new file mode 100644
index 00000000..99cba820
--- /dev/null
+++ b/docs/examples/00c_power_playback.md
@@ -0,0 +1,32 @@
+# Example 00c: Power Playback
+
+## Description
+
+This example demonstrates the use of `PowerPlayback` to replay pre-recorded power data as
+a generic power-generating unit in a Hercules simulation. `PowerPlayback` is useful when
+measured or externally modeled power output is available and no physics model is needed —
+the power values are simply played back at each time step with no control interface.
+
+The simulation runs for approximately 15 minutes and 50 seconds, playing back a step-function
+power profile: zero output for the first half, then rated output for the second half.
+
+## Setup
+
+No manual setup is required. The example automatically generates the necessary input file
+(`power_playback_input.ftr`) in the centralized `examples/inputs/` folder when first run.
+
+## Running
+
+To run the example, execute the following command in the terminal:
+
+```bash
+python hercules_runscript.py
+```
+
+## Outputs
+
+To plot the outputs, run the following command in the terminal:
+
+```bash
+python plot_outputs.py
+```
diff --git a/examples/01_wind_farm_dof1_model/README.md b/docs/examples/01_wind_farm_dof1_model.md
similarity index 90%
rename from examples/01_wind_farm_dof1_model/README.md
rename to docs/examples/01_wind_farm_dof1_model.md
index f2a3e55f..36fd5370 100644
--- a/examples/01_wind_farm_dof1_model/README.md
+++ b/docs/examples/01_wind_farm_dof1_model.md
@@ -15,9 +15,10 @@ To run the example, execute the following command in the terminal:
```bash
python hercules_runscript.py
```
+
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/examples/02a_wind_farm_realistic_inflow/README.md b/docs/examples/02a_wind_farm_realistic_inflow.md
similarity index 72%
rename from examples/02a_wind_farm_realistic_inflow/README.md
rename to docs/examples/02a_wind_farm_realistic_inflow.md
index ad8bff56..5ef24dc7 100644
--- a/examples/02a_wind_farm_realistic_inflow/README.md
+++ b/docs/examples/02a_wind_farm_realistic_inflow.md
@@ -1,14 +1,13 @@
-# Example 02: Wind Farm Realistic Inflow
+# Example 02a: Wind Farm Realistic Inflow
## Description
-In this case uses a large set of synthetic wind data.
+This example uses a large set of synthetic wind data.
## Setup
No manual setup is required. The example automatically generates the necessary input files (large-scale wind data, FLORIS configuration, and turbine model) in the centralized `examples/inputs/` folder when first run.
-
## Running
To run the example, execute the following command in the terminal:
@@ -16,9 +15,10 @@ To run the example, execute the following command in the terminal:
```bash
python hercules_runscript.py
```
+
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/docs/examples/02b_wind_farm_realistic_inflow_precom_floris.md b/docs/examples/02b_wind_farm_realistic_inflow_precom_floris.md
new file mode 100644
index 00000000..3c42c271
--- /dev/null
+++ b/docs/examples/02b_wind_farm_realistic_inflow_precom_floris.md
@@ -0,0 +1,38 @@
+# Example 02b: Wind Farm Realistic Inflow (Precomputed FLORIS)
+
+## Description
+
+This example is identical to `02a_wind_farm_realistic_inflow` with the exception that the `precomputed` wake method is used to speed up the simulation. The configuration sets `wake_method: precomputed` in the `wind_farm` component. This example automatically generates the necessary input files in the centralized `examples/inputs/` folder when first run.
+
+Note the caveats to using this method from the docs:
+
+
+> In contrast to `wake_method: dynamic`, this method pre-computes the FLORIS wake
+ deficits for all possible wind speeds and power setpoints. This is done by running for
+ all wind speeds and wind directions (but not over all power setpoints). This is valid
+ for cases where the wind farm is operating in the following ways:
+ - all turbines operating normally (maximum power)
+ - all turbines off
+ - following a wind-farm wide derating level
+
+ It is in practice conservative with respect to the wake deficits, but it is more efficient
+ than running FLORIS for each condition at each time step. In cases where turbines are:
+ - partially derated below the curtailment level
+ - some, but not all, turbines are off
+ this is not an appropriate model and the more general `wake_method: dynamic` should be used.
+
+## Running
+
+To run the example, execute the following command in the terminal:
+
+```bash
+python hercules_runscript.py
+```
+
+## Outputs
+
+To plot the outputs, run the following command in the terminal:
+
+```bash
+python plot_outputs.py
+```
diff --git a/examples/02c_wind_farm_realistic_inflow_direct/README.md b/docs/examples/02c_wind_farm_realistic_inflow_direct.md
similarity index 84%
rename from examples/02c_wind_farm_realistic_inflow_direct/README.md
rename to docs/examples/02c_wind_farm_realistic_inflow_direct.md
index fda307b8..13229660 100644
--- a/examples/02c_wind_farm_realistic_inflow_direct/README.md
+++ b/docs/examples/02c_wind_farm_realistic_inflow_direct.md
@@ -4,7 +4,7 @@
This example demonstrates the `"no_added_wakes"` wake method, which assumes that wake effects are already included in the input wind data and performs no additional wake modeling.
-In this example, the `WindFarm` component type uses `wake_method="no_added_wakes"`, which means:
+In this example, the `WindFarm` component uses `wake_method: no_added_wakes`, which means:
- No FLORIS calculations are performed during the simulation (only at initialization to read turbine properties)
- `wind_speeds_withwakes` equals `wind_speeds_background` at all times
- Wake deficits are always zero
@@ -22,10 +22,8 @@ python hercules_runscript.py
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
```
-
-
diff --git a/examples/03_wind_and_solar/README.md b/docs/examples/03_wind_and_solar.md
similarity index 55%
rename from examples/03_wind_and_solar/README.md
rename to docs/examples/03_wind_and_solar.md
index 8fdddb3c..3742d91d 100644
--- a/examples/03_wind_and_solar/README.md
+++ b/docs/examples/03_wind_and_solar.md
@@ -2,7 +2,7 @@
## Description
-In this setup, wind and solar are combined in a hybrid plant. For demonstration, the plant has a fixed interconnect limit of 3000 kW, which is much below the combined capacity of the wind and solar farms. A simple controller limits the solar power to keep the total power below the interconnect limit.
+In this setup, wind and solar are simulated together in a hybrid plant. A simple controller can be used to curtail the solar power to keep the total power below the interconnect limit.
## Setup
@@ -15,9 +15,10 @@ To run the example, execute the following command in the terminal:
```bash
python hercules_runscript.py
```
+
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/examples/04_wind_and_storage/README.md b/docs/examples/04_wind_and_storage.md
similarity index 89%
rename from examples/04_wind_and_storage/README.md
rename to docs/examples/04_wind_and_storage.md
index 5922a45f..0e99cbab 100644
--- a/examples/04_wind_and_storage/README.md
+++ b/docs/examples/04_wind_and_storage.md
@@ -2,13 +2,12 @@
## Description
-Example of a wind and storage hybrid plant where the storage is constrained to charge only using power produced by the wind farm.
+Example of a wind and storage hybrid plant where the storage is constrained to charge only using power produced by the wind farm.
## Setup
No manual setup is required. The example automatically generates the necessary input files (wind data, FLORIS configuration, and turbine model) in the centralized `examples/inputs/` folder when first run.
-
## Running
To run the example, execute the following command in the terminal:
@@ -16,9 +15,10 @@ To run the example, execute the following command in the terminal:
```bash
python hercules_runscript.py
```
+
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/examples/05_wind_and_storage_with_lmp/README.md b/docs/examples/05_wind_and_storage_with_lmp.md
similarity index 96%
rename from examples/05_wind_and_storage_with_lmp/README.md
rename to docs/examples/05_wind_and_storage_with_lmp.md
index 2bf9fe85..fd91cef7 100644
--- a/examples/05_wind_and_storage_with_lmp/README.md
+++ b/docs/examples/05_wind_and_storage_with_lmp.md
@@ -28,7 +28,7 @@ The simulation runs for 4 hours with the following characteristics:
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
diff --git a/examples/06_wind_and_hydrogen/README.md b/docs/examples/06_wind_and_hydrogen.md
similarity index 66%
rename from examples/06_wind_and_hydrogen/README.md
rename to docs/examples/06_wind_and_hydrogen.md
index 7aca39b7..ed962fe4 100644
--- a/examples/06_wind_and_hydrogen/README.md
+++ b/docs/examples/06_wind_and_hydrogen.md
@@ -2,13 +2,12 @@
## Description
-Example of a wind and hydrogen hybrid plant where power that the wind farm produces goes directly to hydrogen electrolysis
+Example of a wind and hydrogen hybrid plant where power that the wind farm produces goes directly to hydrogen electrolysis. The hydrogen output is then controlled by controlling the wind farm power to follow a hydrogen production reference signal.
## Setup
No manual setup is required. The example automatically generates the necessary input files (wind data, FLORIS configuration, and turbine model) in the centralized `examples/inputs/` folder when first run.
-
## Running
To run the example, execute the following command in the terminal:
@@ -16,10 +15,11 @@ To run the example, execute the following command in the terminal:
```bash
python hercules_runscript.py
```
+
## Outputs
-To plot the outputs run the following command in the terminal:
+To plot the outputs, run the following command in the terminal:
```bash
python plot_outputs.py
-```
\ No newline at end of file
+```
diff --git a/docs/examples/07_open_cycle_gas_turbine.md b/docs/examples/07_open_cycle_gas_turbine.md
new file mode 100644
index 00000000..eb342482
--- /dev/null
+++ b/docs/examples/07_open_cycle_gas_turbine.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/examples/09_multiunit_thermal_plant.md b/docs/examples/09_multiunit_thermal_plant.md
new file mode 100644
index 00000000..7614ebbc
--- /dev/null
+++ b/docs/examples/09_multiunit_thermal_plant.md
@@ -0,0 +1,27 @@
+# Example 09: Multi-unit Thermal Plant
+
+## Description
+
+Demonstrates a multi-unit thermal plant with three Open Cycle Gas Turbine (OCGT) units. Each unit has its own state machine and ramp behavior, but they share a common controller that issues power setpoints for all units simultaneously. The example illustrates how the plant responds to changes in setpoints while respecting constraints such as minimum up/down times, ramp rates, and minimum stable load of the individual units. The first two individual units are identical, but their commands and responses are tracked separately in the outputs. The third unit is also an Open Cycle Gas Turbine, but it has half the ramp rate of the first two units. It is given the same power set points as the second unit, demonstrating the effect of the ramp rate parameter. This example demonstrates how to both use the same unit definition for two units (OCGT1 & OCGT2), and how to define a unit with its own definition (OCGT3).
+
+## Running
+
+To run the example, execute the following command in the terminal:
+
+```bash
+python hercules_runscript.py
+```
+
+## Outputs
+
+To plot the outputs, run:
+
+```bash
+python plot_outputs.py
+```
+
+The plot shows (for the all units separately):
+- Power output over time (demonstrating ramp constraints and minimum stable load in response to setpoint changes for the individual units), as well as total plant power output
+- Operating state transitions
+- Fuel consumption tracking
+- Heat rate variation with load
diff --git a/docs/examples_overview.md b/docs/examples_overview.md
index 8f670b42..6c102dc6 100644
--- a/docs/examples_overview.md
+++ b/docs/examples_overview.md
@@ -5,6 +5,8 @@ Hercules includes several example cases that demonstrate different simulation co
## Available Examples
- [00: Wind Farm Only](../examples/00_wind_farm_only/) - Simple wind farm simulation with generated wind data
+- [00b: Wind Farm SCADA Power](../examples/00b_wind_farm_scada_power) - Playback of pre-recorded SCADA turbine power data
+- [00c: Power Playback](../examples/00c_power_playback/) - Playback of pre-recorded power data from a generic power-generating unit
- [01: Wind Farm DOF1 Model](../examples/01_wind_farm_dof1_model/) - 1-DOF long-duration wind simulation
- [02: Wind Farm Realistic Inflow](../examples/02_wind_farm_realistic_inflow/) - Large-scale wind farm with longer running wind data
- [02b: Wind Farm Realistic Inflow (Precomputed FLORIS)](../examples/02b_wind_farm_realistic_inflow_precom_floris/) - Optimized version using precomputed wake deficits
diff --git a/docs/h_dict.md b/docs/h_dict.md
index 58e8e778..58f24a95 100644
--- a/docs/h_dict.md
+++ b/docs/h_dict.md
@@ -30,7 +30,9 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac
| `controller` | dict | Controller configuration | - |
| **Hybrid Plant Components** |
-### Wind Farm (`wind_farm`)
+Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is auto-discovered as a plant component. The key is a user-chosen `component_name` (e.g. `wind_farm`, `battery_unit_1`) — it does not need to match the category name. See [Component Names, Types, and Categories](component_types.md) for details.
+
+### Wind Farm
| `component_type` | str | Must be "WindFarm" or "WindFarmSCADAPower" |
| `floris_input_file` | str | FLORIS input file path |
| `wind_input_filename` | str | Wind data input file |
@@ -39,7 +41,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac
| `log_channels` | list | List of channels to log (e.g., ["power", "wind_speed_mean_background", "turbine_powers"]) |
| `floris_update_time_s` | float | How often to update FLORIS wake calculations in seconds |
-### Solar Farm (`solar_farm`)
+### Solar Farm
| `component_type` | str | "SolarPySAMPVWatts" |
| **For SolarPySAMPVWatts:** |
| `pysam_model` | str | "pvwatts" |
@@ -52,7 +54,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac
| `log_channels` | list | List of channels to log (e.g., ["power", "dni", "poa", "aoi"]) |
| `initial_conditions` | dict | Initial power, DNI, POA |
-### Battery (`battery`)
+### Battery
| Key | Type | Description | Default |
|-----|------|-------------|---------|
| `component_type` | str | "BatterySimple" or "BatteryLithiumIon" | Required |
@@ -71,7 +73,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac
| `usage_lifetime` | float | Battery lifetime in years (BatterySimple only) | - |
| `usage_cycles` | int | Number of cycles until replacement (BatterySimple only) | - |
-### Electrolyzer (`electrolyzer`)
+### Electrolyzer
| Key | Type | Description |
|-----|------|-------------|
| `initialize` | bool | Initialize electrolyzer |
@@ -83,6 +85,19 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac
| `cell_params` | dict | Cell parameters |
| `degradation` | dict | Degradation parameters |
+### Open Cycle Gas Turbine
+
+Set `component_type: OpenCycleGasTurbine`. See {doc}`open_cycle_gas_turbine` for the full parameter reference.
+
+| Key | Type | Description | Default |
+|-----|------|-------------|---------|
+| `component_type` | str | `"OpenCycleGasTurbine"` | Required |
+| `rated_capacity` | float | Rated power output in kW | Required |
+| `initial_conditions` | dict | Initial state (`power`, `state`) | Required |
+| `min_stable_load_fraction` | float | Minimum stable load as fraction of rated capacity | 0.40 |
+| `ramp_rate_fraction` | float | Ramp rate as fraction of rated capacity per minute | 0.10 |
+| `log_channels` | list | List of channels to log | `["power"]` |
+
### External Data (`external_data`)
| Key | Type | Description | Default |
|-----|------|-------------|---------|
diff --git a/docs/hard_coal_steam_turbine.md b/docs/hard_coal_steam_turbine.md
new file mode 100644
index 00000000..c5df30c8
--- /dev/null
+++ b/docs/hard_coal_steam_turbine.md
@@ -0,0 +1,155 @@
+# Hard Coal Steam Turbine
+
+The `HardCoalSteamTurbine` (HCST) class models a hard coal power production plant using steam turbines. This class is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class.
+
+Set `component_type: HardCoalSteamTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `hard_coal_steam_turbine`); see [Component Names, Types, and Categories](component_types.md) for details.
+
+For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`.
+
+## HCST-Specific Parameters
+
+The HCST class provides default values for bituminous coal properties from [4]:
+
+| Parameter | Units | Default | Description |
+|-----------|-------|---------|-------------|
+| `hhv` | J/m³ | 29310000000 | Higher heating value of bituminous coal (29.31 MJ/kg) [4] |
+| `fuel_density` | kg/m³ | 1000 | Fuel density for mass calculations |
+
+The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the [2] are used. All efficiency values are **HHV (Higher Heating Value) net plant efficiencies**. See {doc}`thermal_component_base` for details on the efficiency table format.
+
+## Default Parameter Values
+
+The `HardCoalSteamTurbine` class provides default values for base class parameters based on References [1-4]. Only `rated_capacity` and `initial_conditions.power` are required in the YAML configuration.
+
+| Parameter | Default Value | Source |
+|-----------|---------------|--------|
+| `min_stable_load_fraction` | 0.30 (30%) | [2] |
+| `ramp_rate_fraction` | 0.03 (3%/min) | [1] |
+| `run_up_rate_fraction` | Same as `ramp_rate_fraction` | — |
+| `hot_startup_time` | 7.5 hours | [1] |
+| `warm_startup_time` | 7.5 hours | [1] |
+| `cold_startup_time` | 7.5 hours | [1] |
+| `min_up_time` | 48 hours | [2] |
+| `min_down_time` | 48 hours | [2] |
+| `efficiency_table` | Average plant efficiency | [2,3] |
+
+### Default Efficiency Table
+
+The default HHV net plant efficiency table is based on [2,3]:
+
+| Power Fraction | HHV Net Efficiency |
+|---------------|-------------------|
+| 1.00 | 0.35 (35%) |
+| 0.5o | 0.32 (32%) |
+| 0.30 | 0.30 (30%) |
+
+## HCST Outputs
+
+The HCST model provides the following outputs (inherited from base class):
+
+| Output | Units | Description |
+|--------|-------|-------------|
+| `power` | kW | Actual power output |
+| `state` | integer | Operating state number (0-5), corresponding to the `STATES` enum |
+| `efficiency` | fraction (0-1) | Current HHV net plant efficiency |
+| `fuel_volume_rate` | m³/s | Fuel volume flow rate |
+| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` |
+
+### Efficiency and Fuel Rate
+
+HHV net plant efficiency varies with load based on the `efficiency_table`. The fuel volume rate is calculated as:
+
+$$
+\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}}
+$$
+
+Where:
+- `power` is in W (converted from kW internally)
+- `efficiency` is the HHV net efficiency interpolated from the efficiency table
+- `hhv` is the higher heating value in J/m³
+- Result is fuel volume rate in m³/s
+
+The fuel mass rate is then computed from the volume rate using the fuel density:
+
+$$
+\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density}
+$$
+
+Where:
+- `fuel_volume_rate` is in m³/s
+- `fuel_density` is in kg/m³
+- Result is fuel mass rate in kg/s
+
+## YAML Configuration
+
+### Minimal Configuration
+
+Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters):
+
+```yaml
+hard_coal_steam_turbine:
+ component_type: HardCoalSteamTurbine
+ rated_capacity: 100000 # kW (100 MW)
+ initial_conditions:
+ power: 0 # 0 kW means OFF; power > 0 means ON
+```
+
+### Full Configuration
+
+All parameters explicitly specified:
+
+```yaml
+hard_coal_steam_turbine:
+ component_type: HardCoalSteamTurbine
+ rated_capacity: 500000 # kW (500 MW)
+ min_stable_load_fraction: 0.3 # 30% minimum operating point
+ ramp_rate_fraction: 0.03 # 3%/min ramp rate
+ run_up_rate_fraction: 0.02 # 2%/min run up rate
+ hot_startup_time: 27000.0 # 7.5 hours
+ warm_startup_time: 27000.0 # 7.5 hours
+ cold_startup_time: 27000.0 # 7.5 hours
+ min_up_time: 172800 # 48 hours
+ min_down_time: 172800 # 48 hour
+ hhv: 29310000000 # J/m³ for bituminous coal (29.31 MJ/m³) [4]
+ fuel_density: 1000 # kg/m³ for bituminous coal
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.50
+ - 0.30
+ efficiency: # HHV net plant efficiency, fractions (0-1)
+ - 0.35
+ - 0.32
+ - 0.32
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 0 # 0 kW means OFF; power > 0 means ON
+```
+
+## Logging Configuration
+
+The `log_channels` parameter controls which outputs are written to the HDF5 output file.
+
+**Available Channels:**
+- `power`: Actual power output in kW (always logged)
+- `state`: Operating state number (0-5), corresponding to the `STATES` enum
+- `fuel_volume_rate`: Fuel volume flow rate in m³/s
+- `fuel_mass_rate`: Fuel mass flow rate in kg/s
+- `efficiency`: Current HHV net plant efficiency (0-1)
+- `power_setpoint`: Requested power setpoint in kW
+
+## References
+
+1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants."
+
+2. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi.
+
+3. T. Schmitt, S. Leptinsky, M. Turner, A. Zoelle, C. White, S. Hughes, S. Homsy, et al. “Cost And Performance Baseline for Fossil Energy Plants Volume 1: Bituminous Coal and Natural Gas Electricity.” Pittsburgh, PA: National Energy Technology Laboratory, October 14, 2022b. https://doi.org/10.2172/1893822.
+
+4. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
diff --git a/docs/hercules_input.md b/docs/hercules_input.md
index 0ad7416c..0af8c995 100644
--- a/docs/hercules_input.md
+++ b/docs/hercules_input.md
@@ -12,7 +12,7 @@ The input file structure mirrors the `h_dict` structure documented in the [h_dic
- **Top level parameters**: `dt`, `starttime_utc`, `endtime_utc` (see [timing](timing.md) for details)
- **Plant configuration**: `interconnect_limit`
-- **Hybrid plant configurations**: `wind_farm`, `solar_farm`, `battery`, `electrolyzer`
+- **Plant component sections**: any number of user-named sections, each containing a `component_type` key that identifies the component class to use (see [Component Names, Types, and Categories](component_types.md))
- **External data**: `external_data` for external time series data (e.g., LMP prices, weather forecasts)
- **Optional settings**: `verbose`, `name`, `description`, `output_file`
@@ -45,7 +45,7 @@ verbose: False
plant:
interconnect_limit: 30000 # kW
-wind_farm:
+wind_farm: # User-chosen component_name; component_type determines the class
component_type: WindFarm
wake_method: dynamic
floris_input_file: inputs/floris_input.yaml
@@ -59,7 +59,7 @@ wind_farm:
- wind_direction_mean
floris_update_time_s: 30.0
-solar_farm:
+solar_farm: # User-chosen component_name
component_type: SolarPySAMPVWatts
solar_input_filename: inputs/solar_input.csv
lat: 39.7442
@@ -77,7 +77,7 @@ solar_farm:
dni: 1000
poa: 1000
-battery:
+battery: # User-chosen component_name
component_type: BatterySimple
energy_capacity: 100.0 # MWh
charge_rate: 50.0 # MW
diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md
index b764dc6d..531d2ba6 100644
--- a/docs/hybrid_plant.md
+++ b/docs/hybrid_plant.md
@@ -4,15 +4,44 @@ The `HybridPlant` class manages all plant components in Hercules. It handles ini
## Overview
-HybridPlant automatically detects and initializes components based on the [h_dict structure](h_dict.md). Each component is configured through its respective section in the h_dict (e.g., `wind_farm`, `solar_farm`, `battery`, `electrolyzer`).
+`HybridPlant` auto-discovers components from the [h_dict](h_dict.md) at initialization time. Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is treated as a plant component. The YAML key becomes the component's `component_name` (a user-chosen instance identifier), and the `component_type` value determines which Python class is instantiated.
+
+See [Component Names, Types, and Categories](component_types.md) for a full explanation of how `component_name`, `component_type`, and `component_category` relate to each other.
## Available Components
-| Component | Component Type | Description |
-|-----------|----------------|-------------|
-| `wind_farm` | `WindFarm` | FLORIS-based wind farm simulation |
-| `wind_farm` | `WindFarmSCADAPower` | Pass through wind farm SCADA |
-| `solar_farm` | `SolarPySAMPVWatts` | PySAM-based simplified solar simulation |
-| `battery` | `BatterySimple` | Basic battery storage model |
-| `battery` | `BatteryLithiumIon` | Detailed lithium-ion battery model |
-| `electrolyzer` | `ElectrolyzerPlant` | Hydrogen production system |
+| `component_type` | `component_category` | Generator? | Documentation |
+|---|---|---|---|
+| `WindFarm` | `generator` | Yes | [Wind](wind.md) |
+| `WindFarmSCADAPower` | `generator` | Yes | [Wind](wind.md) |
+| `SolarPySAMPVWatts` | `generator` | Yes | [Solar PV](solar_pv.md) |
+| `BatterySimple` | `storage` | No | [Battery](battery.md) |
+| `BatteryLithiumIon` | `storage` | No | [Battery](battery.md) |
+| `ElectrolyzerPlant` | `load` | No | [Electrolyzer](electrolyzer.md) |
+| `OpenCycleGasTurbine` | `generator` | Yes | [Open Cycle Gas Turbine](open_cycle_gas_turbine.md) |
+| `HardCoalSteamTurbine` | `generator` | [Hard Coal Steam Turbine](hard_coal_steam_turbine.md) |
+| `ThermalPlant` | `generator` | [Thermal Plant](thermal_plant.md) |
+
+The YAML key for each section is a user-chosen `component_name` and is not required to match the category name. For example, a `BatterySimple` component could be named `battery`, `battery_unit_1`, or anything else.
+
+## Generator Classification
+
+`HybridPlant` classifies components into generators and non-generators based on `component_category`. Components with `component_category == "generator"` have their power outputs summed into `h_dict["plant"]["locally_generated_power"]` each time step. Storage and load components are excluded from this sum.
+
+## Component Registry
+
+All available component types are defined in `COMPONENT_REGISTRY` in `hercules/component_registry.py`. This dictionary maps `component_type` strings to their Python classes:
+
+```python
+COMPONENT_REGISTRY = {
+ "WindFarm": WindFarm,
+ "WindFarmSCADAPower": WindFarmSCADAPower,
+ "SolarPySAMPVWatts": SolarPySAMPVWatts,
+ "BatterySimple": BatterySimple,
+ "BatteryLithiumIon": BatteryLithiumIon,
+ "ElectrolyzerPlant": ElectrolyzerPlant,
+ "OpenCycleGasTurbine": OpenCycleGasTurbine,
+}
+```
+
+When adding a new component type, it must be registered here. See [Adding Components](adding_components.md) for a complete guide.
diff --git a/docs/install.md b/docs/install.md
index b1a7aeb0..0049ebee 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -5,7 +5,7 @@
## Root Directory
-It is recommended to install Hercules into a root directory. This root directory can also contain other projects that are often used with Hercules such as Hycon and the Electrolyzer.
+It is recommended to install Hercules into a root directory. This root directory can also contain other projects that are often used with Hercules such as Hycon.
```bash
mkdir -p hercules_root
@@ -23,11 +23,11 @@ cd hercules
It is recommended that you install Hercules into a virtual environment.
-### CONDA
+### Conda
To create a new conda environment for hercules:
```bash
-conda create --name hercules python=3.11
+conda create --name hercules python=3.13
conda activate hercules
```
@@ -36,7 +36,7 @@ conda activate hercules
Alternatively, you can use uv to create a new environment for hercules. This will create a new environment in the current directory.
```bash
-uv venv
+uv venv --python 3.13
source .venv/bin/activate
```
@@ -54,7 +54,7 @@ Install Hercules in editible mode into the active virtual environment.
pip install -e .
```
-### With Developer and Documentation Dependencies
+### With developer and documentation Dependencies
```bash
pip install -e .[develop,docs]
@@ -62,11 +62,11 @@ pip install -e .[develop,docs]
## Setting branch
-Can optionally switch to the v2 branch for the latest stable version.
+Users can simply remain on the `main` branch. Developers should switch to the `develop` branch to get the latest code changes. To change to the `develop` branch, use
```bash
git fetch --all
-git switch v2
+git switch develop
```
## Hycon
@@ -75,25 +75,11 @@ NLR's Hycon software is used to implement controllers in the Hercules platform.
To install:
-Go back to root
```bash
-cd ..
+cd .. # To hercules_root
git clone git@github.com:NREL/hycon.git
cd hycon
git fetch --all
pip install -e .
```
-
-## Electrolyzer
-
-A python electrolyzer model is also required for hercules. To install
-the electrolyzer, use
-
-```bash
-cd ..
-git clone git@github.com:NREL/electrolyzer.git
-cd electrolyzer
-git fetch --all
-git switch main
-```
diff --git a/docs/open_cycle_gas_turbine.md b/docs/open_cycle_gas_turbine.md
new file mode 100644
index 00000000..41da2c4e
--- /dev/null
+++ b/docs/open_cycle_gas_turbine.md
@@ -0,0 +1,165 @@
+# 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. Since this class is focused on peaker plant behavior, this class was developed based on aeroderivative engines. It is a subclass of {doc}`ThermalComponentBase ` and inherits all state machine behavior, ramp constraints, and operational logic from the base class.
+
+Set `component_type: OpenCycleGasTurbine` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `open_cycle_gas_turbine`); see [Component Names, Types, and Categories](component_types.md) for details.
+
+For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`.
+
+## OCGT-Specific Parameters
+
+The OCGT class provides default values for natural gas properties from [6]:
+
+| Parameter | Units | Default | Description |
+|-----------|-------|---------|-------------|
+| `hhv` | J/m³ | 39050000 | Higher heating value of natural gas (39.05 MJ/m³) [6] |
+| `fuel_density` | kg/m³ | 0.768 | Fuel density for mass calculations [6] |
+
+The `efficiency_table` parameter is **optional**. If not provided, default values based on approximate readings from the SC1A curve in Exhibit ES-4 of [5] are used. All efficiency values are **HHV (Higher Heating Value) net plant efficiencies**. See {doc}`thermal_component_base` for details on the efficiency table format.
+
+## Default Parameter Values
+
+The `OpenCycleGasTurbine` class provides default values for base class parameters based on References [1-5]. Only `rated_capacity` and `initial_conditions.power` 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] |
+| `hhv` | 39050000 J/m³ (39.05 MJ/m³) | [6] |
+| `fuel_density` | 0.768 kg/m³ | [6] |
+| `efficiency_table` | SC1A HHV net efficiency (see below) | Exhibit ES-4 of [5] |
+
+### Default Efficiency Table
+
+The default HHV net plant efficiency table is based on approximate readings from the SC1A (simple cycle) curve in Exhibit ES-4 of [5]:
+
+| Power Fraction | HHV Net Efficiency |
+|---------------|-------------------|
+| 1.00 | 0.39 (39%) |
+| 0.75 | 0.37 (37%) |
+| 0.50 | 0.325 (32.5%) |
+| 0.25 | 0.245 (24.5%) |
+
+## OCGT Outputs
+
+The OCGT model provides the following outputs (inherited from base class):
+
+| Output | Units | Description |
+|--------|-------|-------------|
+| `power` | kW | Actual power output |
+| `state` | integer | Operating state number (0-5), corresponding to the `STATES` enum |
+| `efficiency` | fraction (0-1) | Current HHV net plant efficiency |
+| `fuel_volume_rate` | m³/s | Fuel volume flow rate |
+| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed using `fuel_density` [6]) |
+
+### Efficiency and Fuel Rate
+
+HHV net plant efficiency varies with load based on the `efficiency_table`. The fuel volume rate is calculated as:
+
+$$
+\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}}
+$$
+
+Where:
+- `power` is in W (converted from kW internally)
+- `efficiency` is the HHV net efficiency interpolated from the efficiency table
+- `hhv` is the higher heating value in J/m³ (default 39.05 MJ/m³ for natural gas [6])
+- Result is fuel volume rate in m³/s
+
+The fuel mass rate is then computed from the volume rate using the fuel density [6]:
+
+$$
+\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density}
+$$
+
+Where:
+- `fuel_volume_rate` is in m³/s
+- `fuel_density` is in kg/m³ (default 0.768 kg/m³ for natural gas [6])
+- Result is fuel mass rate in kg/s
+
+## YAML Configuration
+
+### Minimal Configuration
+
+Required parameters only (uses defaults for `hhv`, `efficiency_table`, and other parameters):
+
+```yaml
+open_cycle_gas_turbine:
+ component_type: OpenCycleGasTurbine
+ rated_capacity: 100000 # kW (100 MW)
+ initial_conditions:
+ power: 0 # 0 kW means OFF; power > 0 means ON
+```
+
+### Full Configuration
+
+All parameters explicitly specified:
+
+```yaml
+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
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6]
+ fuel_density: 0.768 # kg/m³ for natural gas [6]
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency: # HHV net plant efficiency from SC1A in Exhibit ES-4 of [5]
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 0 # 0 kW means OFF; power > 0 means ON
+ time_in_shutdown: 50000 # Adding this forces a warm startup (default initialization results in a hot start)
+```
+
+## Logging Configuration
+
+The `log_channels` parameter controls which outputs are written to the HDF5 output file.
+
+**Available Channels:**
+- `power`: Actual power output in kW (always logged)
+- `state`: Operating state number (0-5), corresponding to the `STATES` enum
+- `fuel_volume_rate`: Fuel volume flow rate in m³/s
+- `fuel_mass_rate`: Fuel mass flow rate in kg/s (computed using `fuel_density` [6])
+- `efficiency`: Current HHV net plant efficiency (0-1)
+- `power_setpoint`: Requested power setpoint in kW
+
+## References
+
+1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants."
+
+2. "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on Production Cost Simulation", NREL/CP-6A40-87554, National Renewable Energy Laboratory, 2024.
+
+3. Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly Modelling in Power Systems with Significant Levels of Renewable Generation." Applied Energy 113 (January 2014): 152–58. https://doi.org/10.1016/j.apenergy.2013.07.027.
+
+4. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi.
+
+5. M. Oakes, M. Turner, "Cost and Performance Baseline for Fossil Energy Plants, Volume 5: Natural Gas Electricity Generating Units for Flexible Operation," National Energy Technology Laboratory, Pittsburgh, May 5, 2023.
+
+6. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
diff --git a/docs/power_playback.md b/docs/power_playback.md
new file mode 100644
index 00000000..d231883c
--- /dev/null
+++ b/docs/power_playback.md
@@ -0,0 +1,72 @@
+# Power Playback
+
+`PowerPlayback` plays back pre-recorded power data from a file, treating the recorded values as the output of a generic power-generating unit. There is no control interface — the power values are replayed exactly as stored.
+
+This component is useful for incorporating measured or externally modeled power output into a hybrid plant simulation without needing to model the underlying physics of the generator.
+
+The PowerPlayback model is intended for use for using the power output from a generator component in a Hercules simulation. This component can be used for all generators, but will only take in the total plant power from a wind farm (and not individual turbine powers). For individual turbine power granularity, please use theWindFarmSCADAPower class.
+
+
+
+## Configuration
+
+### Required Parameters
+
+- `component_type`: Must be `"PowerPlayback"`
+- `scada_filename`: Path to the power data file (CSV, pickle, or feather format)
+- `log_channels`: List of output channels to log (see [Logging Configuration](#logging-configuration) below)
+
+See [timing](timing.md) for the time-related parameters (`dt`, `starttime_utc`, `endtime_utc`) that are set at the top level of the input YAML.
+
+### Example YAML Configuration
+
+```yaml
+power_unit_1:
+ component_type: PowerPlayback
+ scada_filename: ../inputs/power_playback_input.ftr
+ log_channels:
+ - power
+```
+
+### Input File Format
+
+The input file must contain the following columns:
+
+- `time_utc`: Timestamps in UTC (ISO 8601 format or parseable datetime strings)
+- `power`: Power output in kW
+
+Supported file formats: `.csv`, `.p`, `.pkl` (pickle), `.f`, `.ftr` (feather).
+
+The `time_utc` range in the input file must span at least the simulation's `starttime_utc` to `endtime_utc`. The data is interpolated onto the simulation's time grid at initialization.
+
+#### Example CSV
+
+```
+time_utc,power
+2020-01-01T00:00:00Z,0.0
+2020-01-01T00:07:30Z,15000.0
+2020-01-01T00:15:50Z,15000.0
+```
+
+## Outputs
+
+At each simulation step, `PowerPlayback` writes the following to `h_dict`:
+
+| Channel | Units | Description |
+|---|---|---|
+| `power` | kW | Power output at the current time step (interpolated from input file) |
+
+## Logging Configuration
+
+The `log_channels` parameter controls which outputs are written to the HDF5 output file. The `power` channel is always logged, even if not explicitly listed.
+
+```yaml
+log_channels:
+ - power
+```
+
+## Notes
+
+- `PowerPlayback` has `component_category = "generator"`, so its power contributes to `h_dict["plant"]["locally_generated_power"]`.
+- There is no setpoint or control interface; the output power is read-only.
+- The component is intended as a drop-in replacement when measured power data is available and no physics model is needed.
diff --git a/docs/resource_downloading.md b/docs/resource_downloading.md
index 4d0017dd..b1daba46 100644
--- a/docs/resource_downloading.md
+++ b/docs/resource_downloading.md
@@ -110,19 +110,19 @@ Arguments to the `upsample_wind_data` function used to specify the upsampling ar
- `turbulence_L`: The turbulence length scale to use for the Kaimal turbulence spectrum (m). Defaults to 340.2 m, the value specified in the IEC standard.
- `TI_ref`: The reference TI that will be assigned at the reference wind speed `TI_ws_ref`. Defaults to 0.1.
- `TI_ws_ref`: The reference wind speed at which the reference TI `TI_ref` is defined (m/s). Defaults to 8 m/s.
-- `save_individual_wds`: If `True`, upsampled wind directions will be saved in the output for each upsampled location. If `False`, only the mean wind direction over all locations will be saved. Defaults to `False`.
+- `save_individual_wds`: If `True`, upsampled wind directions for each individual location will be saved in the output in addition to the mean wind direction. If `False`, only the mean wind direction over all locations will be saved. Defaults to `False`.
### Output Format
-The function `upsample_wind_data` returns a pandas DataFrame containing the upsampled wind time series and saves the DataFrame as a `.feather` file. This DataFrame is in the format used for Hercules wind plant simulation inputs. An example illustrating the DataFrame columns is shown below for the case where `save_individual_wds` is `True`. Note that the suffixes "000", "001", etc. correspond to the locations specified in `x_locs_upsample` and `y_locs_upsample` (in order), and the `time` column contains the number of seconds from the start of the time series.
+The function `upsample_wind_data` returns a pandas DataFrame containing the upsampled wind time series and saves the DataFrame as a `.feather` file. This DataFrame is in the format used for Hercules wind plant simulation inputs. The mean wind direction `wd_mean` is always included in the output. An example illustrating the DataFrame columns is shown below for the case where `save_individual_wds` is `True`. Note that the suffixes "000", "001", etc. correspond to the locations specified in `x_locs_upsample` and `y_locs_upsample` (in order), and the `time` column contains the number of seconds from the start of the time series.
-| time | time_utc | ws_000 | wd_000 | ws_001 | wd_001 | ws_002 | ...
-|-----|-----|-----|-----|-----|-----|-----|-----|
-| 0.0 | 2020-01-01 00:00:00+00:00 | 5.7 | 256.2 | 5.7 | 256.0 | 6.4 | ... |
-| 1.0 | 2020-01-01 00:00:01+00:00 | 5.4 | 256.1 | 5.9 | 255.9 | 6.4 | ... |
-| 2.0 | 2020-01-01 00:00:02+00:00 | 5.7 | 256.0 | 5.8 | 255.8 | 5.7 | ... |
-| 3.0 | 2020-01-01 00:00:03+00:00 | 6.5 | 255.9 | 5.0 | 255.7 | 6.5 | ... |
-| ... | ... | ... | ... | ... | ... | ... | ... |
+| time | time_utc | wd_mean | ws_000 | wd_000 | ws_001 | wd_001 | ws_002 | ...
+|-----|-----|-----|-----|-----|-----|-----|-----|-----|
+| 0.0 | 2020-01-01 00:00:00+00:00 | 255.8 | 5.7 | 256.2 | 5.7 | 256.0 | 6.4 | ... |
+| 1.0 | 2020-01-01 00:00:01+00:00 | 255.8 | 5.4 | 256.1 | 5.9 | 255.9 | 6.4 | ... |
+| 2.0 | 2020-01-01 00:00:02+00:00 | 255.7 | 5.7 | 256.0 | 5.8 | 255.8 | 5.7 | ... |
+| 3.0 | 2020-01-01 00:00:03+00:00 | 255.6 | 6.5 | 255.9 | 5.0 | 255.7 | 6.5 | ... |
+| ... | ... | ... | ... | ... | ... | ... | ... | ... |
On the other hand, for the case where `save_individual_wds` is `False`, an example DataFrame is provided below.
diff --git a/docs/solar_pv.md b/docs/solar_pv.md
index d9a00311..ae84d729 100644
--- a/docs/solar_pv.md
+++ b/docs/solar_pv.md
@@ -4,7 +4,7 @@ The solar PV modules use the [PySAM](https://nrel-pysam.readthedocs.io/en/main/o
Presently only one solar simulator is available
-1. **`SolarPySAMPVWatts`** - Uses the [PVWatts model](https://sam.nrel.gov/photovoltaic.html) in [`Pvwattsv8`](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html), which calculates estimated PV electrical output with configurable efficiency and loss parameters. This model is less detailed but more time-efficient, making it suitable for longer duration simulations (approximately 1 year). Set `component_type` = `SolarPySAMPVWatts` in the input dictionary (.yaml file).
+1. **`SolarPySAMPVWatts`** - Uses the [PVWatts model](https://sam.nrel.gov/photovoltaic.html) in [`Pvwattsv8`](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html), which calculates estimated PV electrical output with configurable efficiency and loss parameters. This model is less detailed but more time-efficient, making it suitable for longer duration simulations (approximately 1 year). Set `component_type: SolarPySAMPVWatts` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `solar_farm`); see [Component Names, Types, and Categories](component_types.md) for details.
diff --git a/docs/thermal_component_base.md b/docs/thermal_component_base.md
new file mode 100644
index 00000000..a580c0b4
--- /dev/null
+++ b/docs/thermal_component_base.md
@@ -0,0 +1,212 @@
+# 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.
+
+**Note:** All efficiency values throughout this module are **HHV (Higher Heating Value) net plant efficiencies**, consistent with the data in Exhibit ES-4 of [5].
+
+## 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. State is derived automatically: power > 0 sets ON, power == 0 sets OFF. When ON, `time_in_state` = `min_up_time` (ready to stop). When OFF, by default `time_in_state` = `min_down_time` (ready to start). However, `time_in_shutdown` is an optional parameter to set the `time_in_state` variable if the component is OFF. This parameter is described in the next section|
+| `hhv` | J/m³ | Higher heating value of fuel |
+| `fuel_density` | kg/m³ | Fuel density for mass calculations |
+| `efficiency_table` | dict | Dictionary containing `power_fraction` and `efficiency` arrays (see below). Efficiency values must be HHV net plant efficiencies. |
+
+### Optional Parameters
+| Parameter | Units | Description |
+|-----------|-------|-------------|
+| `initial_conditions.time_in_shutdown` | s | An optional parameter to set the `time_in_state` variable if the component is OFF. This can be used to adjust the behavior of a component, e.g. force a warm start at the beginning of the simulation |
+
+### 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
+
+## Efficiency and Fuel Consumption
+
+The base class calculates HHV net plant efficiency and fuel consumption based on the `efficiency_table` and `hhv` parameters.
+
+### Efficiency Table Format
+
+The `efficiency_table` parameter specifies how HHV net plant efficiency varies with load. All efficiency values must be HHV (Higher Heating Value) net plant efficiencies:
+
+```yaml
+efficiency_table:
+ power_fraction: # fraction of rated_capacity (0-1)
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency: # HHV net plant efficiency, fraction (0-1), e.g., 0.39 = 39%
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+```
+
+Both arrays must have the same length and values must be in the range [0, 1]. The arrays are sorted by `power_fraction` internally.
+
+### Efficiency Interpolation
+
+HHV net efficiency is calculated by linear interpolation from the table based on current power fraction (`power_output / rated_capacity`). Values outside the table range are clamped to the nearest endpoint.
+
+### Fuel Rate Calculation
+
+Fuel volume rate is calculated as:
+
+$$
+\text{fuel\_volume\_rate} = \frac{\text{power}}{\text{efficiency} \times \text{hhv}}
+$$
+
+Where:
+- `power` is in W (converted from kW internally)
+- `efficiency` is the interpolated HHV net efficiency (0-1)
+- `hhv` is the higher heating value in J/m³
+- Result is fuel volume rate in m³/s
+
+The fuel mass rate is then computed from the volume rate using the fuel density:
+
+$$
+\text{fuel\_mass\_rate} = \text{fuel\_volume\_rate} \times \text{fuel\_density}
+$$
+
+Where:
+- `fuel_volume_rate` is in m³/s
+- `fuel_density` is in kg/m³
+- Result is fuel mass rate in kg/s
+
+## Outputs
+
+The base class outputs are returned in `h_dict`:
+
+| Output | Units | Description |
+|--------|-------|-------------|
+| `power` | kW | Actual power output |
+| `state` | integer | Current operating state (0-5), corresponding to the `STATES` enum |
+| `efficiency` | fraction (0-1) | Current HHV net plant efficiency |
+| `fuel_volume_rate` | m³/s | Fuel volume flow rate |
+| `fuel_mass_rate` | kg/s | Fuel mass flow rate (computed from volume rate using `fuel_density`) |
+
+## References
+
+1. Agora Energiewende (2017): "Flexibility in thermal power plants - With a focus on existing coal-fired power plants."
+
+2. "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on Production Cost Simulation", NREL/CP-6A40-87554, National Renewable Energy Laboratory, 2024.
+
+3. Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly Modelling in Power Systems with Significant Levels of Renewable Generation." Applied Energy 113 (January 2014): 152–58. https://doi.org/10.1016/j.apenergy.2013.07.027.
+
+4. IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants, International Renewable Energy Agency, Abu Dhabi.
+
+5. M. Oakes, M. Turner, "Cost and Performance Baseline for Fossil Energy Plants, Volume 5: Natural Gas Electricity Generating Units for Flexible Operation," National Energy Technology Laboratory, Pittsburgh, May 5, 2023.
+
+6. I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011. https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
diff --git a/docs/thermal_plant.md b/docs/thermal_plant.md
new file mode 100644
index 00000000..e961064b
--- /dev/null
+++ b/docs/thermal_plant.md
@@ -0,0 +1,93 @@
+# Thermal Plant
+
+The `ThermalPlant` class models generic single or multiunit thermal power plants. It expects to be assigned one or more thermal units, for example [`OpenCycleGasTurbine`s](open_cycle_gas_turbine.md). The individual units are established in the YAML configuration file, and may be repeats of the same type of units or heterogeneous units.
+
+In order to use the thermal plant model, set `component_type: ThermalPlant` in the component's YAML section. The section key is a user-chosen `component_name` (e.g. `my_thermal_plant`); see [Component Names, Types, and Categories](component_types.md) for details.
+
+For details on the state machine, startup/shutdown behavior, and base parameters, see {doc}`thermal_component_base`.
+
+## Parameters
+
+The `ThermalPlant` class does not have any default parameters. However, key attributes that must be provided in the YAML configuration file are `units`, which is a list that is used to instantiate the individual thermal units that make up the plant, and `unit_names`, which is a list of unique names for each unit. The number of entries in `units` and `unit_names` must match.
+
+See the [YAML Configuration](#yaml-configuration) section below for examples of how to specify these parameters in the input file.
+
+## YAML configuration
+
+The YAML configuration for the thermal plant includes lists `units` and `unit_names`, that define the configuration for each unit. The `component_type` of each unit must be a valid thermal component type, e.g. `OpenCycleGasTurbine`. See [Component Types](component_types.md) for the full list of available component types.
+
+The units listed under the `units` field are used to index the subdictionaries for each unit, which specify the parameters and initial conditions for each unit. For example, if `units: ["open_cycle_gas_turbine", "open_cycle_gas_turbine"]`, then the YAML file must include a subdictionary with the key `open_cycle_gas_turbine:` that specify the parameters and initial conditions that will be used for both of the two gas turbines. Different subdictionaries can be defined for each, or a subset, of units by adding a subdictionary defining the desired parameters and initial conditions, and adding it to the appropriate place in the `units` list. This is illustrated in the below example, where the first two units use the `large_ocgt` subdictionary and the last unit uses the `small_ocgt` subdictionary. The `unit_names` field is a list of unique names for each unit, which are used to identify the units in the HDF5 output file and in the `h_dict` passed to controllers. For example, if `unit_names: ["OCGT1", "OCGT2"]`, then the two gas turbines will be identified as `OCGT1` and `OCGT2` in the output file and in the `h_dict`.
+
+```yaml
+my_thermal_plant:
+ component_type: ThermalPlant
+ units: ["large_ocgt", "large_ocgt", "small_ocgt"]
+ unit_names: ["OCGT1", "OCGT2", "OCGT3"]
+
+ large_ocgt:
+ component_type: OpenCycleGasTurbine
+ rated_capacity: 100000 # kW (100 MW)
+ min_stable_load_fraction: 0.4 # 40% minimum operating point
+ ramp_rate_fraction: 0.1 # 10%/min ramp rate
+ run_up_rate_fraction: 0.05 # 5%/min run up rate
+ hot_startup_time: 420.0 # 7 minutes
+ warm_startup_time: 480.0 # 8 minutes
+ cold_startup_time: 480.0 # 8 minutes
+ min_up_time: 1800 # 30 minutes
+ min_down_time: 3600 # 1 hour
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³)
+ fuel_density: 0.768 # kg/m³ for natural gas
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency:
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 0
+
+ small_ocgt:
+ component_type: OpenCycleGasTurbine
+ rated_capacity: 50000 # kW (50 MW)
+ min_stable_load_fraction: 0.4 # 40% minimum operating point
+ ramp_rate_fraction: 0.15 # 15%/min ramp rate
+ run_up_rate_fraction: 0.1 # 10%/min run up rate
+ hot_startup_time: 300.0 # 5 minutes
+ warm_startup_time: 360.0 # 6 minutes
+ cold_startup_time: 420.0 # 7 minutes
+ min_up_time: 1200 # 20 minutes
+ min_down_time: 2400 # 40 minutes
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6]
+ fuel_density: 0.768 # kg/m
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency:
+ - 0.38
+ - 0.36
+ - 0.32
+ - 0.22
+ log_channels:
+ - power
+ initial_conditions:
+ power: 0
+```
+
+## Logging configuration
+
+The `log_channels` parameter controls which outputs are written to the HDF5 output file. Logging is configured separately for each unit, so the `log_channels` field is specified within each unit's subdictionary. For example, if `unit_names: ["OCGT1", "OCGT1"]`, then the log will have columns `my_thermal_plant.OCGT1.power`, `my_thermal_plant.OCGT1.fuel_volume_rate`, etc. for the first unit, and `my_thermal_plant.OCGT2.power`, `my_thermal_plant.OCGT2.fuel_volume_rate`, etc. for the second unit, assuming those channels are included in the `log_channels` list for each unit. The total power for the thermal plant is always logged to `my_thermal_plant.power`, which is the sum of the power outputs of each unit.
diff --git a/docs/wind.md b/docs/wind.md
index 559cba2f..69245e3a 100644
--- a/docs/wind.md
+++ b/docs/wind.md
@@ -31,21 +31,15 @@ Using `WindFarm` with `wake_method="no_added_wakes"` assumes that wake effects a
## WindFarmSCADAPower (SCADA Power Data)
-`WindFarmSCADAPower` uses SCADA power measurements directly rather than computing power from wind speeds and turbine models. This component applies a filter to the SCADA power data to simulate turbine response dynamics and respects power setpoint constraints.
-
-_This model is a beta feature and is not yet fully tested._
+`WindFarmSCADAPower` plays back pre-recorded SCADA turbine power data directly rather than computing power from wind speeds and turbine models. There is no filtering or control -- the turbine powers are simply replayed at the values present in the SCADA data file.
## Configuration
-### Common Required Parameters
+### WindFarm Required Parameters
-Required parameters for both components in [h_dict](h_dict.md) (see [timing](timing.md) for time-related parameters):
+Required parameters for WindFarm in [h_dict](h_dict.md) (see [timing](timing.md) for time-related parameters):
- `floris_input_file`: FLORIS farm configuration
- `wind_input_filename`: Wind resource data file
-
-### WindFarm Specific Parameters
-
-Required parameters for WindFarm:
- `wake_method`: One of `"dynamic"`, `"precomputed"`, or `"no_added_wakes"` (defaults to `"dynamic"`)
- `floris_update_time_s`: How often to update FLORIS (the last `floris_update_time_s` seconds are averaged as input). Required for `"dynamic"` and `"precomputed"` wake methods; for `"no_added_wakes"`, this parameter is not required and ignored if provided.
- `turbine_file_name`: Turbine model configuration
@@ -55,7 +49,6 @@ Required parameters for WindFarm:
Required parameters for WindFarmSCADAPower:
- `scada_filename`: Path to SCADA data file (CSV, pickle, or feather format)
-- `turbine_file_name`: Turbine model configuration (for filter parameters)
- `log_channels`: List of output channels to log. See [Logging Configuration](#logging-configuration) section below for details.
**SCADA File Format:**
@@ -74,7 +67,7 @@ The number of turbines and rated power are automatically inferred from the SCADA
## Turbine Models
-**Note:** WindFarmSCADAPower does not use a filter model as power values come directly from SCADA data rather than being computed from wind speedes.
+**Note:** WindFarmSCADAPower does not use turbine models as power values come directly from SCADA data rather than being computed from wind speeds.
### Filter Model
Simple first-order filter for power output smoothing with configurable time constants.
@@ -89,13 +82,17 @@ Advanced model with rotor dynamics, pitch control, and generator torque control.
All four components provide these outputs in the h_dict at each simulation step:
- `power`: Total wind farm power (kW)
- `turbine_powers`: Individual turbine power outputs (array, kW)
-- `turbine_power_setpoints`: Current power setpoint values (array, kW)
- `wind_speed_mean_background`: Farm-average background wind speed (m/s)
- `wind_speed_mean_withwakes`: Farm-average with-wakes wind speed (m/s)
- `wind_direction_mean`: Farm-average wind direction (degrees)
- `wind_speeds_background`: Per-turbine background wind speeds (array, m/s)
- `wind_speeds_withwakes`: Per-turbine with-wakes wind speeds (array, m/s)
+### WindFarm-Only Outputs
+
+The three `WindFarm` variants additionally provide:
+- `turbine_power_setpoints`: Current power setpoint values (array, kW)
+
**Note for WindFarm with no_added_wakes and WindFarmSCADAPower:** In these models (no wake modeling), `wind_speeds_withwakes` equals `wind_speeds_background` and `wind_speed_mean_withwakes` equals `wind_speed_mean_background`.
@@ -114,7 +111,7 @@ The `log_channels` parameter controls which outputs are written to the HDF5 outp
**Array Channels:**
- `turbine_powers`: Power output for all turbines (creates datasets like `wind_farm.turbine_powers.000`, `wind_farm.turbine_powers.001`, etc.)
-- `turbine_power_setpoints`: Power setpoints for all turbines
+- `turbine_power_setpoints`: Power setpoints for all turbines (WindFarm only)
- `wind_speeds_background`: Background wind speeds for all turbines
- `wind_speeds_withwakes`: With-wakes wind speeds for all turbines
diff --git a/examples/00b_wind_farm_scada_power/hercules_input.yaml b/examples/00b_wind_farm_scada_power/hercules_input.yaml
new file mode 100644
index 00000000..80ba56e2
--- /dev/null
+++ b/examples/00b_wind_farm_scada_power/hercules_input.yaml
@@ -0,0 +1,34 @@
+# Input YAML for hercules
+
+# Name
+name: example_00b
+
+###
+# Describe this simulation setup
+description: Wind Farm SCADA Power, Logging All Turbine Data
+
+dt: 1.0
+starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC (Zulu time)
+endtime_utc: "2020-01-01T00:15:50Z" # 15 minutes 50 seconds later
+verbose: False
+
+plant:
+ interconnect_limit: 15000 # kW
+
+wind_farm:
+ component_type: WindFarmSCADAPower
+ scada_filename: ../inputs/scada_input.ftr
+ log_file_name: outputs/log_wind_sim.log
+ log_channels:
+ - power
+ - turbine_powers
+
+
+controller:
+
+
+
+
+
+
+
diff --git a/examples/00b_wind_farm_scada_power/hercules_runscript.py b/examples/00b_wind_farm_scada_power/hercules_runscript.py
new file mode 100644
index 00000000..19f173f2
--- /dev/null
+++ b/examples/00b_wind_farm_scada_power/hercules_runscript.py
@@ -0,0 +1,44 @@
+from hercules.hercules_model import HerculesModel
+from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory
+
+prepare_output_directory()
+
+# Ensure example inputs exist
+ensure_example_inputs_exist()
+
+# Initialize the Hercules model
+hmodel = HerculesModel("hercules_input.yaml")
+
+
+# Define a controller that does nothing
+class ControllerPassThrough:
+ """A place holder controller that does nothing."""
+
+ def __init__(self, h_dict):
+ """Initialize the controller.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+ """
+ pass
+
+ def step(self, h_dict):
+ """Execute one control step.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ Returns:
+ dict: The updated hercules input dictionary.
+ """
+ # Simply return the unmodified h_dict
+ return h_dict
+
+
+# Instantiate the controller and assign to the Hercules model
+hmodel.assign_controller(ControllerPassThrough(hmodel.h_dict))
+
+# Run the simulation
+hmodel.run()
+
+hmodel.logger.info("Process completed successfully")
diff --git a/examples/00b_wind_farm_scada_power/plot_outputs.py b/examples/00b_wind_farm_scada_power/plot_outputs.py
new file mode 100644
index 00000000..145ad6ff
--- /dev/null
+++ b/examples/00b_wind_farm_scada_power/plot_outputs.py
@@ -0,0 +1,51 @@
+# Plot the outputs of the simulation
+
+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
+
+# Set number of turbines
+n_turbines = 3
+
+# Define a consistent color map with 3 entries
+colors = ["tab:blue", "tab:orange", "tab:green"]
+
+fig, ax = plt.subplots(1, 1, sharex=True)
+
+
+# Plot the power
+for t_idx in range(3):
+ if f"wind_farm.turbine_powers.{t_idx:03}" in df.columns:
+ ax.plot(
+ df["time"],
+ df[f"wind_farm.turbine_powers.{t_idx:03}"],
+ label=f"Turbine {t_idx}",
+ color=colors[t_idx],
+ )
+
+# Check if derating columns exist and plot them if they do
+for t_idx in range(3):
+ if f"wind_farm.turbine_power_setpoints.{t_idx:03}" in df.columns:
+ ax.plot(
+ df["time"],
+ df[f"wind_farm.turbine_power_setpoints.{t_idx:03}"],
+ label=f"Power Setpoint {t_idx}",
+ linestyle="--",
+ color=colors[t_idx],
+ )
+
+ax.grid(True)
+ax.legend()
+ax.set_xlabel("Time [s]")
+ax.set_ylabel("Power [kW]")
+plt.show()
diff --git a/examples/00c_power_playback/hercules_input.yaml b/examples/00c_power_playback/hercules_input.yaml
new file mode 100644
index 00000000..b21581f2
--- /dev/null
+++ b/examples/00c_power_playback/hercules_input.yaml
@@ -0,0 +1,33 @@
+# Input YAML for hercules
+
+# Name
+name: example_00c
+
+###
+# Describe this simulation setup
+description: Power Playback
+
+dt: 1.0
+starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC (Zulu time)
+endtime_utc: "2020-01-01T00:15:50Z" # 15 minutes 50 seconds later
+verbose: False
+
+plant:
+ interconnect_limit: 15000 # kW
+
+power_unit_1: # Name of the power unit
+ component_type: PowerPlayback
+ scada_filename: ../inputs/power_playback_input.ftr
+ log_file_name: outputs/log_power_playback.log
+ log_channels:
+ - power
+
+
+controller:
+
+
+
+
+
+
+
diff --git a/examples/00c_power_playback/hercules_runscript.py b/examples/00c_power_playback/hercules_runscript.py
new file mode 100644
index 00000000..19f173f2
--- /dev/null
+++ b/examples/00c_power_playback/hercules_runscript.py
@@ -0,0 +1,44 @@
+from hercules.hercules_model import HerculesModel
+from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory
+
+prepare_output_directory()
+
+# Ensure example inputs exist
+ensure_example_inputs_exist()
+
+# Initialize the Hercules model
+hmodel = HerculesModel("hercules_input.yaml")
+
+
+# Define a controller that does nothing
+class ControllerPassThrough:
+ """A place holder controller that does nothing."""
+
+ def __init__(self, h_dict):
+ """Initialize the controller.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+ """
+ pass
+
+ def step(self, h_dict):
+ """Execute one control step.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ Returns:
+ dict: The updated hercules input dictionary.
+ """
+ # Simply return the unmodified h_dict
+ return h_dict
+
+
+# Instantiate the controller and assign to the Hercules model
+hmodel.assign_controller(ControllerPassThrough(hmodel.h_dict))
+
+# Run the simulation
+hmodel.run()
+
+hmodel.logger.info("Process completed successfully")
diff --git a/examples/00c_power_playback/plot_outputs.py b/examples/00c_power_playback/plot_outputs.py
new file mode 100644
index 00000000..1e78dc34
--- /dev/null
+++ b/examples/00c_power_playback/plot_outputs.py
@@ -0,0 +1,34 @@
+# Plot the outputs of the simulation
+
+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
+
+
+_, ax = plt.subplots(1, 1, sharex=True)
+
+
+# Plot the power
+ax.plot(
+ df["time_utc"],
+ df["power_unit_1.power"],
+ label="Power Playback of Power Unit 1",
+ color="tab:blue",
+)
+
+
+ax.grid(True)
+ax.legend()
+ax.set_xlabel("Time (UTC)")
+ax.set_ylabel("Power [kW]")
+plt.show()
diff --git a/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md b/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md
deleted file mode 100644
index 20531969..00000000
--- a/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Example 02b: Wind Farm Realistic Inflow (Precomputed FLORIS)
-
-## Description
-
-This example is identical to `02_wind_farm_realistic_inflow` with the exception that the `precomputed` wake method of the `WindFarm` class is used to speed up the simulation. This example automatically generates the necessary input files in the centralized `examples/inputs/` folder when first run.
-
-Note the caveats to using this class from the docs:
-
-
-> In contrast to `wake_method="dynamic"`, this class pre-computes the FLORIS wake
- deficits for all possible wind speeds and power setpoints. This is done by running for
- all wind speeds and wind directions (but not over all power setpoints). This is valid
- for cases where the wind farm is operating:
- - all turbines operating normally
- - all turbines off
- - following a wind-farm wide derating level
-
- It is in practice conservative with respect to the wake deficits, but it is more efficient
- than running FLORIS for each condition. In cases where turbines are:
- - partially derated below the curtailment level
- - not uniformly curtailed or some turbines are off
- this is not an appropriate model and the more general `wake_method="dynamic"` version should be used.
-
-
-
-
-## Running
-
-To run the example, execute the following command in the terminal:
-
-```bash
-python hercules_runscript.py
-```
-## Outputs
-
-To plot the outputs run the following command in the terminal:
-
-```bash
-python plot_outputs.py
-```
\ No newline at end of file
diff --git a/examples/07_open_cycle_gas_turbine/hercules_input.yaml b/examples/07_open_cycle_gas_turbine/hercules_input.yaml
new file mode 100644
index 00000000..616ae5af
--- /dev/null
+++ b/examples/07_open_cycle_gas_turbine/hercules_input.yaml
@@ -0,0 +1,58 @@
+# 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
+ # Natural gas properties from [6] Staffell, "The Energy and Fuel Data Sheet", 2011
+ # HHV: 39.05 MJ/m³, Density: 0.768 kg/m³
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6]
+ fuel_density: 0.768 # kg/m³ for natural gas [6]
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5]
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 0 # Start OFF
+ time_in_shutdown: 50000 # Forces warm startup
+
+controller:
+
diff --git a/examples/07_open_cycle_gas_turbine/hercules_runscript.py b/examples/07_open_cycle_gas_turbine/hercules_runscript.py
new file mode 100644
index 00000000..e0d00879
--- /dev/null
+++ b/examples/07_open_cycle_gas_turbine/hercules_runscript.py
@@ -0,0 +1,85 @@
+"""Example 07: Open Cycle Gas Turbine (OCGT) simulation.
+
+This example demonstrates a simple open cycle gas turbine (OCGT) that:
+- Starts on at rated capacity (100 MW)
+- At 10 minutes, receives a shutdown command and begins ramping down
+- At ~20 minutes, reaches 0 MW and transitions to off
+- At 40 minutes, receives a turn-on command with a setpoint of 100% of rated capacity
+- At ~80 minutes, 1 hour down-time minimum is reached and the turbine begins hot starting
+- At ~87 minutes, hot start completes, continues ramping up to 100% of rated capacity
+- At 120 minutes, receives a command to reduce power to 50% of rated capacity
+- At 180 minutes, receives a command to reduce power to 10% of rated capacity
+ (note this is below the minimum stable load)
+- At 210 minutes, receives a command to increase power to 100% of rated capacity
+- At 240 minutes (4 hours), receives a shutdown command
+- Simulation runs for 6 hours total with 1 minute time steps
+"""
+
+from hercules.hercules_model import HerculesModel
+from hercules.utilities_examples import prepare_output_directory
+
+prepare_output_directory()
+
+# Initialize the Hercules model
+hmodel = HerculesModel("hercules_input.yaml")
+
+
+class ControllerOCGT:
+ """Controller implementing the OCGT schedule described in the module docstring."""
+
+ def __init__(self, h_dict):
+ """Initialize the controller.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ """
+ self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"]
+
+ def step(self, h_dict):
+ """Execute one control step.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ Returns:
+ dict: The updated hercules input dictionary.
+
+ """
+ current_time = h_dict["time"]
+
+ # Determine power setpoint based on time
+ if current_time < 10 * 60: # 10 minutes in seconds
+ # Before 10 minutes: run at full capacity
+ power_setpoint = self.rated_capacity
+ elif current_time < 40 * 60: # 40 minutes in seconds
+ # Between 10 and 40 minutes: shut down
+ power_setpoint = 0.0
+ elif current_time < 120 * 60: # 120 minutes in seconds
+ # Between 40 and 120 minutes: signal to run at full capacity
+ power_setpoint = self.rated_capacity
+ elif current_time < 180 * 60: # 180 minutes in seconds
+ # Between 120 and 180 minutes: reduce power to 50% of rated capacity
+ power_setpoint = 0.5 * self.rated_capacity
+ elif current_time < 210 * 60: # 210 minutes in seconds
+ # Between 180 and 210 minutes: reduce power to 10% of rated capacity
+ power_setpoint = 0.1 * self.rated_capacity
+ elif current_time < 240 * 60: # 240 minutes in seconds
+ # Between 210 and 240 minutes: increase power to 100% of rated capacity
+ power_setpoint = self.rated_capacity
+ else:
+ # After 240 minutes: shut down
+ power_setpoint = 0.0
+
+ h_dict["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint
+
+ return h_dict
+
+
+# Instantiate the controller and assign to the Hercules model
+hmodel.assign_controller(ControllerOCGT(hmodel.h_dict))
+
+# Run the simulation
+hmodel.run()
+
+hmodel.logger.info("Process completed successfully")
diff --git a/examples/07_open_cycle_gas_turbine/plot_outputs.py b/examples/07_open_cycle_gas_turbine/plot_outputs.py
new file mode 100644
index 00000000..292ec11c
--- /dev/null
+++ b/examples/07_open_cycle_gas_turbine/plot_outputs.py
@@ -0,0 +1,92 @@
+# 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"], label="State", color="k")
+ax.set_ylabel("State")
+ax.set_yticks([0, 1, 2, 3, 4, 5])
+ax.set_yticklabels(["Off", "Hot Starting", "Warm Starting", "Cold Starting", "On", "Stopping"])
+ax.set_title(
+ "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)"
+)
+ax.grid(True)
+
+# Plot the efficiency
+ax = axarr[2]
+ax.plot(
+ time_minutes,
+ df["open_cycle_gas_turbine.efficiency"] * 100,
+ label="Efficiency",
+ color="g",
+)
+ax.set_ylabel("Efficiency [%]")
+ax.set_title("Thermal Efficiency")
+ax.grid(True)
+
+# Plot the fuel consumption
+ax = axarr[3]
+ax.plot(
+ time_minutes,
+ df["open_cycle_gas_turbine.fuel_volume_rate"],
+ label="Fuel Volume Rate",
+ color="orange",
+)
+ax.set_ylabel("Fuel [m³/s]")
+ax.set_title("Fuel Volume Rate")
+ax.grid(True)
+
+ax.set_xlabel("Time [minutes]")
+
+plt.tight_layout()
+plt.show()
diff --git a/examples/08_hard_coal_steam_turbine/hercules_input.yaml b/examples/08_hard_coal_steam_turbine/hercules_input.yaml
new file mode 100644
index 00000000..7ed338a8
--- /dev/null
+++ b/examples/08_hard_coal_steam_turbine/hercules_input.yaml
@@ -0,0 +1,50 @@
+# Input YAML for hercules
+# Explicitly specify the parameters for demonstration purposes
+
+# Name
+name: example_08
+
+###
+# Describe this simulation setup
+description: Hard Coal Steam Turbine (HCST) Example
+
+dt: 60.0 # 1 minute time step
+starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC
+endtime_utc: "2020-01-10T00:00:00Z" # 10 days later
+verbose: False
+log_every_n: 1
+
+plant:
+ interconnect_limit: 500000 # kW (500 MW)
+
+hard_coal_steam_turbine:
+ component_type: HardCoalSteamTurbine
+ rated_capacity: 500000 # kW (500 MW)
+ min_stable_load_fraction: 0.3 # 30% minimum operating point
+ ramp_rate_fraction: 0.03 # 3%/min ramp rate
+ run_up_rate_fraction: 0.02 # 2%/min run up rate
+ hot_startup_time: 27000.0 # 7.5 hours
+ warm_startup_time: 27000.0 # 7.5 hours
+ cold_startup_time: 27000.0 # 7.5 hours
+ min_up_time: 172800 # 48 hours
+ min_down_time: 172800 # 48 hour
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.50
+ - 0.30
+ efficiency: # HHV net plant efficiency, fractions (0-1)
+ - 0.35
+ - 0.32
+ - 0.32
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 500000 # Start ON at rated capacity
+
+controller:
diff --git a/examples/08_hard_coal_steam_turbine/hercules_runscript.py b/examples/08_hard_coal_steam_turbine/hercules_runscript.py
new file mode 100644
index 00000000..853ca488
--- /dev/null
+++ b/examples/08_hard_coal_steam_turbine/hercules_runscript.py
@@ -0,0 +1,92 @@
+"""Example 08: Hard Coal Steam Turbine (HCST) simulation.
+
+This example demonstrates a simple hard coal steam turbine (HCST) that:
+- Starts on at rated capacity
+- Receives a shutdown command and begins ramping down
+- Transitions to off
+- Receives a turn-on command with a setpoint of 100% of rated capacity
+- Minimum down-time requirement is reached and the turbine begins ramping up
+- Ramps up to 100% of rated capacity
+- Receives a command to reduce power to 50% of rated capacity
+- Receives a command to reduce power to 10% of rated capacity
+ (note this is below the minimum stable load)
+- Receives a command to increase power to 100% of rated capacity
+- Receives a shutdown command
+- Simulation runs for 10 days total with 1 minute time steps
+"""
+
+from hercules.hercules_model import HerculesModel
+from hercules.utilities_examples import prepare_output_directory
+
+prepare_output_directory()
+
+# Initialize the Hercules model
+hmodel = HerculesModel("hercules_input.yaml")
+
+
+class ControllerHCST:
+ """Controller implementing the HCST schedule described in the module docstring."""
+
+ def __init__(self, h_dict, component_name="hard_coal_steam_turbine"):
+ """Initialize the controller.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ """
+ self.component_name = component_name
+ self.rated_capacity = h_dict[self.component_name]["rated_capacity"]
+
+ simulation_length = h_dict["endtime_utc"] - h_dict["starttime_utc"]
+ self.total_simulation_time = simulation_length.total_seconds()
+
+ def step(self, h_dict):
+ """Execute one control step.
+ This controller is scaled by the total simulation time, pulled from the h_dict
+ This preserves the relative distance between control actions, but changes the
+ simulation times that they are applied.
+
+ Args:
+ h_dict (dict): The hercules input dictionary.
+
+ Returns:
+ dict: The updated hercules input dictionary.
+
+ """
+ current_time = h_dict["time"]
+
+ # Determine power setpoint based on time
+ if current_time < 0.05 * self.total_simulation_time:
+ # First 5% of simulation time, run at full capacity
+ power_setpoint = self.rated_capacity
+ elif current_time < 0.15 * self.total_simulation_time:
+ # Between 5% and 15% of simulation time: shut down
+ power_setpoint = 0.0
+ elif current_time < 0.45 * self.total_simulation_time:
+ # Between 15% and 45% of simulation time: signal to run at full capacity
+ power_setpoint = self.rated_capacity
+ elif current_time < 0.65 * self.total_simulation_time:
+ # Between 45% and 65% of simulation time: reduce power to 50% of rated capacity
+ power_setpoint = 0.5 * self.rated_capacity
+ elif current_time < 0.75 * self.total_simulation_time:
+ # Between 65% and 75% of simulation time: reduce power to 10% of rated capacity
+ power_setpoint = 0.1 * self.rated_capacity
+ elif current_time < 0.9 * self.total_simulation_time: #
+ # Between 75% and 90% of simulation time: increase power to 100% of rated capacity
+ power_setpoint = self.rated_capacity
+ else:
+ # After 90% of simulation time: shut down
+ power_setpoint = 0.0
+
+ h_dict[self.component_name]["power_setpoint"] = power_setpoint
+
+ return h_dict
+
+
+# Instantiate the controller and assign to the Hercules model
+hmodel.assign_controller(ControllerHCST(hmodel.h_dict))
+
+# Run the simulation
+hmodel.run()
+
+hmodel.logger.info("Process completed successfully")
diff --git a/examples/08_hard_coal_steam_turbine/plot_outputs.py b/examples/08_hard_coal_steam_turbine/plot_outputs.py
new file mode 100644
index 00000000..f1d327bd
--- /dev/null
+++ b/examples/08_hard_coal_steam_turbine/plot_outputs.py
@@ -0,0 +1,95 @@
+# Plot the outputs of the simulation for the OCGT example
+
+import matplotlib.pyplot as plt
+from hercules import HerculesOutput
+
+# Read the Hercules output file using HerculesOutput
+ho = HerculesOutput("outputs/hercules_output.h5")
+component_name = "hard_coal_steam_turbine" # Change to "open_cycle_gas_turbine" if needed
+
+# Print metadata information
+print("Simulation Metadata:")
+ho.print_metadata()
+print()
+
+# Create a shortcut to the dataframe
+df = ho.df
+
+# Get the h_dict from metadata
+h_dict = ho.h_dict
+
+# Convert time to hours for easier reading
+time_hours = df["time"] / 60 / 60
+
+print("TONNES OF COAL USED:", df[component_name + ".fuel_volume_rate"].sum() * h_dict["dt"])
+
+fig, axarr = plt.subplots(4, 1, sharex=True, figsize=(10, 10))
+
+# Plot the power output and setpoint
+ax = axarr[0]
+ax.plot(time_hours, df[component_name + ".power"] / 1000, label="Power Output", color="b")
+ax.plot(
+ time_hours,
+ df[component_name + ".power_setpoint"] / 1000,
+ label="Power Setpoint",
+ color="r",
+ linestyle="--",
+)
+ax.axhline(
+ h_dict[component_name]["rated_capacity"] / 1000,
+ color="gray",
+ linestyle=":",
+ label="Rated Capacity",
+)
+ax.axhline(
+ h_dict[component_name]["min_stable_load_fraction"]
+ * h_dict[component_name]["rated_capacity"]
+ / 1000,
+ color="gray",
+ linestyle="--",
+ label="Minimum Stable Load",
+)
+ax.set_ylabel("Power [MW]")
+ax.set_title("Open Cycle Gas Turbine Power Output")
+ax.legend()
+ax.grid(True)
+
+# Plot the state
+ax = axarr[1]
+ax.plot(time_hours, df[component_name + ".state"], label="State", color="k")
+ax.set_ylabel("State")
+ax.set_yticks([0, 1, 2, 3, 4, 5])
+ax.set_yticklabels(["Off", "Hot Starting", "Warm Starting", "Cold Starting", "On", "Stopping"])
+ax.set_title(
+ "Turbine State (0=Off, 1=Hot Starting, 2=Warm Starting, 3=Cold Starting, 4=On, 5=Stopping)"
+)
+ax.grid(True)
+
+# Plot the efficiency
+ax = axarr[2]
+ax.plot(
+ time_hours,
+ df[component_name + ".efficiency"] * 100,
+ label="Efficiency",
+ color="g",
+)
+ax.set_ylabel("Efficiency [%]")
+ax.set_title("Thermal Efficiency")
+ax.grid(True)
+
+# Plot the fuel consumption
+ax = axarr[3]
+ax.plot(
+ time_hours,
+ df[component_name + ".fuel_volume_rate"],
+ label="Fuel Volume Rate",
+ color="orange",
+)
+ax.set_ylabel("Fuel [m³/s]")
+ax.set_title("Fuel Volume Rate")
+ax.grid(True)
+
+ax.set_xlabel("Time [hours]")
+
+plt.tight_layout()
+plt.show()
diff --git a/examples/09_multiunit_thermal_plant/hercules_input.yaml b/examples/09_multiunit_thermal_plant/hercules_input.yaml
new file mode 100644
index 00000000..64e88681
--- /dev/null
+++ b/examples/09_multiunit_thermal_plant/hercules_input.yaml
@@ -0,0 +1,97 @@
+# 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)
+
+thermal_power_plant:
+ component_type: ThermalPlant
+ units: ["open_cycle_gas_turbine1", "open_cycle_gas_turbine1", "open_cycle_gas_turbine3"]
+ unit_names: ["OCGT1", "OCGT2", "OCGT3"]
+
+ open_cycle_gas_turbine1:
+ component_type: OpenCycleGasTurbine
+ rated_capacity: 100000 # kW (100 MW)
+ min_stable_load_fraction: 0.2 # 20% minimum operating point
+ ramp_rate_fraction: 0.1 # 10%/min ramp rate
+ run_up_rate_fraction: 0.05 # 5%/min run up rate
+ hot_startup_time: 420.0 # 7 minutes
+ warm_startup_time: 480.0 # 8 minutes
+ cold_startup_time: 480.0 # 8 minutes
+ min_up_time: 3600 # 1 hour
+ min_down_time: 3600 # 1 hour
+ # Natural gas properties from [6] Staffell, "The Energy and Fuel Data Sheet", 2011
+ # HHV: 39.05 MJ/m³, Density: 0.768 kg/m³
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6]
+ fuel_density: 0.768 # kg/m³ for natural gas [6]
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5]
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 100000 # Start ON at rated capacity (100 MW)
+
+ open_cycle_gas_turbine3:
+ component_type: OpenCycleGasTurbine
+ rated_capacity: 100000 # kW (100 MW)
+ min_stable_load_fraction: 0.2 # 20% minimum operating point
+ ramp_rate_fraction: 0.05 # 10%/min ramp rate
+ run_up_rate_fraction: 0.05 # 5%/min run up rate
+ hot_startup_time: 420.0 # 7 minutes
+ warm_startup_time: 480.0 # 8 minutes
+ cold_startup_time: 480.0 # 8 minutes
+ min_up_time: 3600 # 1 hour
+ min_down_time: 3600 # 1 hour
+ # Natural gas properties from [6] Staffell, "The Energy and Fuel Data Sheet", 2011
+ # HHV: 39.05 MJ/m³, Density: 0.768 kg/m³
+ hhv: 39050000 # J/m³ for natural gas (39.05 MJ/m³) [6]
+ fuel_density: 0.768 # kg/m³ for natural gas [6]
+ efficiency_table:
+ power_fraction:
+ - 1.0
+ - 0.75
+ - 0.50
+ - 0.25
+ efficiency: # HHV net plant efficiency, fractions (0-1), from SC1A in Exhibit ES-4 of [5]
+ - 0.39
+ - 0.37
+ - 0.325
+ - 0.245
+ log_channels:
+ - power
+ - fuel_volume_rate
+ - fuel_mass_rate
+ - state
+ - efficiency
+ - power_setpoint
+ initial_conditions:
+ power: 100000 # Start ON at rated capacity (100 MW)
+
+controller:
diff --git a/examples/09_multiunit_thermal_plant/hercules_runscript.py b/examples/09_multiunit_thermal_plant/hercules_runscript.py
new file mode 100644
index 00000000..6684b3ef
--- /dev/null
+++ b/examples/09_multiunit_thermal_plant/hercules_runscript.py
@@ -0,0 +1,84 @@
+"""Example 09: Multiunit Thermal Plant
+
+This example demonstrates a thermal power plant constructed from two 50 MW OCGT units.
+The power setpoints are split unequally between the two units to demonstrate the ability of the
+model to specify setpoints of individual units.
+"""
+
+from hercules.hercules_model import HerculesModel
+from hercules.utilities_examples import prepare_output_directory
+
+prepare_output_directory()
+
+
+# Declare the open loop control setpoint sequence used for demonstration.
+class OpenLoopController:
+ """Controller implementing the unit power setpoints in open loop."""
+
+ def __init__(self, h_dict):
+ # Access total rated capacity from h_dict, as well as capacities of individual units
+ self.rated_capacity = h_dict["thermal_power_plant"]["rated_capacity"]
+ self.unit_1_capacity = h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"]
+ self.unit_2_capacity = h_dict["thermal_power_plant"]["OCGT2"]["rated_capacity"]
+ self.unit_3_capacity = h_dict["thermal_power_plant"]["OCGT3"]["rated_capacity"]
+
+ def step(self, h_dict):
+ current_time = h_dict["time"]
+
+ # Determine power setpoint based on time
+ if current_time < 10 * 60: # 10 minutes in seconds
+ # Before 10 minutes: run all three units at full capacity
+ self.power_setpoint_1 = self.unit_1_capacity
+ self.power_setpoint_2 = self.unit_2_capacity
+ self.power_setpoint_3 = self.unit_3_capacity
+ elif current_time < 20 * 60: # 20 minutes in seconds
+ # Between 10 and 20 minutes: shut down unit 1, leave units 2 & 3
+ self.power_setpoint_1 = 0.0
+ elif current_time < 40 * 60: # 40 minutes in seconds
+ # Shut down units 2 & 3
+ self.power_setpoint_2 = 0.0
+ self.power_setpoint_3 = 0.0
+ elif current_time < 120 * 60: # 120 minutes in seconds
+ # Between 40 and 120 minutes: signal to run at full capacity
+ self.power_setpoint_1 = self.unit_1_capacity
+ self.power_setpoint_2 = self.unit_2_capacity
+ self.power_setpoint_3 = self.unit_3_capacity
+ elif current_time < 180 * 60: # 180 minutes in seconds
+ # Between 120 and 180 minutes: reduce power of unit 1 to 50% of rated capacity
+ self.power_setpoint_1 = 0.5 * self.unit_1_capacity
+ elif current_time < 210 * 60: # 210 minutes in seconds
+ # Between 180 and 210 minutes: reduce power of unit 1 to 10% of rated capacity
+ self.power_setpoint_1 = 0.1 * self.unit_1_capacity
+ elif current_time < 240 * 60: # 240 minutes in seconds
+ # Between 210 and 240 minutes: move both units to 50% of rated capacity
+ self.power_setpoint_1 = 0.5 * self.unit_1_capacity
+ self.power_setpoint_2 = 0.5 * self.unit_2_capacity
+ self.power_setpoint_3 = 0.5 * self.unit_3_capacity
+ else:
+ # After 240 minutes: shut down
+ self.power_setpoint_1 = 0.0
+ self.power_setpoint_2 = 0.0
+ self.power_setpoint_3 = 0.0
+
+ # Update the h_dict with the power setpoints for each unit and return
+ h_dict["thermal_power_plant"]["power_setpoints"] = [
+ self.power_setpoint_1,
+ self.power_setpoint_2,
+ self.power_setpoint_3,
+ ]
+
+ return h_dict
+
+
+# Runscript
+if __name__ == "__main__":
+ # Initialize the Hercules model
+ hmodel = HerculesModel("hercules_input.yaml")
+
+ # Instantiate the controller and assign to the Hercules model
+ hmodel.assign_controller(OpenLoopController(hmodel.h_dict))
+
+ # Run the simulation
+ hmodel.run()
+
+ hmodel.logger.info("Process completed successfully")
diff --git a/examples/09_multiunit_thermal_plant/plot_outputs.py b/examples/09_multiunit_thermal_plant/plot_outputs.py
new file mode 100644
index 00000000..b9de2a71
--- /dev/null
+++ b/examples/09_multiunit_thermal_plant/plot_outputs.py
@@ -0,0 +1,113 @@
+# Plot the outputs of the simulation
+
+import matplotlib.pyplot as plt
+from hercules import HerculesOutput
+
+# Read the Hercules output file using HerculesOutput
+ho = HerculesOutput("outputs/hercules_output.h5")
+
+# Print metadata information
+ho.print_metadata()
+
+# Create a shortcut to the dataframe
+df = ho.df
+
+# Get the h_dict from metadata
+h_dict = ho.h_dict
+
+# 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["thermal_power_plant.power"] / 1000, label="Power Output", color="k")
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT1.power_setpoint"] / 1000,
+ label="Power setpoint (OCGT1)",
+ color="r",
+ linestyle="--",
+)
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT2.power_setpoint"] / 1000,
+ label="Power setpoint (OCGT2)",
+ color="b",
+ linestyle="--",
+)
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT3.power_setpoint"] / 1000,
+ label="Power setpoint (OCGT3)",
+ color="g",
+ linestyle="--",
+)
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT1.power"] / 1000,
+ label="Power output (OCGT1)",
+ color="r",
+)
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT2.power"] / 1000,
+ label="Power output (OCGT2)",
+ color="b",
+)
+ax.plot(
+ time_minutes,
+ df["thermal_power_plant.OCGT3.power"] / 1000,
+ label="Power output (OCGT3)",
+ color="g",
+)
+ax.axhline(
+ h_dict["thermal_power_plant"]["rated_capacity"] / 1000,
+ color="black",
+ linestyle=":",
+ label="Plant rated capacity",
+)
+ax.axhline(
+ h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] / 1000,
+ color="gray",
+ linestyle=":",
+ label="Unit rated capacity",
+)
+ax.set_ylabel("Power [MW]")
+ax.legend()
+ax.grid(True)
+ax.set_xlim(0, time_minutes.iloc[-1])
+
+# Plot the state of each unit
+ax = axarr[1]
+ax.plot(time_minutes, df["thermal_power_plant.OCGT1.state"], label="OCGT1", color="r")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT2.state"], label="OCGT2", color="b")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT3.state"], label="OCGT3", color="g")
+ax.set_ylabel("State")
+ax.set_yticks([0, 1, 2, 3, 4, 5])
+ax.set_yticklabels(["Off", "Hot Starting", "Warm Starting", "Cold Starting", "On", "Stopping"])
+ax.grid(True)
+ax.legend()
+
+# Plot the efficiency of each unit
+ax = axarr[2]
+ax.plot(time_minutes, df["thermal_power_plant.OCGT1.efficiency"] * 100, label="OCGT1", color="r")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT2.efficiency"] * 100, label="OCGT2", color="b")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT3.efficiency"] * 100, label="OCGT3", color="g")
+ax.set_ylabel("Thermal efficiency [%]")
+ax.grid(True)
+ax.legend()
+
+# Fuel consumption
+ax = axarr[3]
+ax.plot(time_minutes, df["thermal_power_plant.OCGT1.fuel_volume_rate"], label="OCGT1", color="r")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT2.fuel_volume_rate"], label="OCGT2", color="b")
+ax.plot(time_minutes, df["thermal_power_plant.OCGT3.fuel_volume_rate"], label="OCGT3", color="g")
+ax.set_ylabel("Fuel [m³/s]")
+ax.grid(True)
+ax.legend()
+ax.set_xlabel("Time [mins]")
+
+plt.tight_layout()
+plt.show()
diff --git a/examples/inputs/00_generate_wind_history_small.py b/examples/inputs/00_generate_wind_history_small.py
index f7d3a136..1528e0f3 100644
--- a/examples/inputs/00_generate_wind_history_small.py
+++ b/examples/inputs/00_generate_wind_history_small.py
@@ -1,5 +1,7 @@
# # Generate wind history for a small farm for early examples
# Generate a small demonstration wind history using the example FLORIS model
+# Additionally, generate a history of wind power data for a small farm for use
+# in the example with WindFarmSCADAPower.
import floris.layout_visualization as layoutviz
import matplotlib.pyplot as plt
import numpy as np
@@ -100,5 +102,22 @@
print(f"First time (UTC): {df['time_utc'].iloc[0]}")
print(f"Last time (UTC): {df['time_utc'].iloc[-1]}")
+# Now generate rough wind power approximations
+pow_000 = 4 * ws_0**3
+pow_001 = 4 * ws_1**3
+pow_002 = 4 * ws_2**3
+
+# Clip the powers to be less than the rated power
+rated_power = 5000
+pow_000 = np.minimum(pow_000, rated_power)
+pow_001 = np.minimum(pow_001, rated_power)
+pow_002 = np.minimum(pow_002, rated_power)
+
+df["pow_000"] = pow_000
+df["pow_001"] = pow_001
+df["pow_002"] = pow_002
+
+df.to_feather("scada_input.ftr")
+
if show_plots:
plt.show()
diff --git a/examples/inputs/07_generate_scada_power_playback.py b/examples/inputs/07_generate_scada_power_playback.py
new file mode 100644
index 00000000..b5590080
--- /dev/null
+++ b/examples/inputs/07_generate_scada_power_playback.py
@@ -0,0 +1,21 @@
+"""Generate a history of power playback data"""
+
+import pandas as pd
+
+rated_power = 15000
+starttime_utc = pd.to_datetime("2020-01-01T00:00:00Z", utc=True) # Midnight Jan 1, 2020 UTC
+endtime_utc = pd.to_datetime("2020-01-01T00:15:50Z", utc=True) # 15 minutes 50 seconds later
+
+# Create a dataframe with the power playback data
+df = pd.DataFrame(index=pd.date_range(start=starttime_utc, end=endtime_utc, freq="S"))
+df["power"] = rated_power
+
+# Set power to 0 for first half of the data
+df.loc[df.index < pd.to_datetime("2020-01-01T00:07:30Z", utc=True), "power"] = 0
+
+# Reset the index and name the time column "time_utc"
+df = df.reset_index()
+df = df.rename(columns={"index": "time_utc"})
+
+# Save to a feather file
+df.to_feather("power_playback_input.ftr")
diff --git a/examples/readme.txt b/examples/readme.txt
new file mode 100644
index 00000000..3bc2aca8
--- /dev/null
+++ b/examples/readme.txt
@@ -0,0 +1,3 @@
+See https://natlabrockies.github.io/hercules/examples_overview.html for documentation describing
+the Hercules examples.
+
diff --git a/hercules/component_registry.py b/hercules/component_registry.py
new file mode 100644
index 00000000..b6d2243f
--- /dev/null
+++ b/hercules/component_registry.py
@@ -0,0 +1,28 @@
+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.hard_coal_steam_turbine import HardCoalSteamTurbine
+from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine
+from hercules.plant_components.power_playback import PowerPlayback
+from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts
+from hercules.plant_components.thermal_plant import ThermalPlant
+from hercules.plant_components.wind_farm import WindFarm
+from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower
+
+# Registry mapping component_type strings to their classes.
+# Add new component types here to make them discoverable by HybridPlant.
+COMPONENT_REGISTRY = {
+ "WindFarm": WindFarm,
+ "WindFarmSCADAPower": WindFarmSCADAPower,
+ "SolarPySAMPVWatts": SolarPySAMPVWatts,
+ "BatterySimple": BatterySimple,
+ "BatteryLithiumIon": BatteryLithiumIon,
+ "ElectrolyzerPlant": ElectrolyzerPlant,
+ "OpenCycleGasTurbine": OpenCycleGasTurbine,
+ "ThermalPlant": ThermalPlant,
+ "HardCoalSteamTurbine": HardCoalSteamTurbine,
+ "PowerPlayback": PowerPlayback,
+}
+
+# Derived from registry keys for validation in utilities.py
+VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys())
diff --git a/hercules/hercules_model.py b/hercules/hercules_model.py
index dcb0450f..dfe78b18 100644
--- a/hercules/hercules_model.py
+++ b/hercules/hercules_model.py
@@ -212,6 +212,7 @@ def _read_external_data_file(self, filename):
self.starttime,
self.endtime + (2 * self.dt),
self.dt,
+ dtype=hercules_float_type,
)
# Interpolate using the utility function
@@ -376,6 +377,18 @@ def numpy_serializer(obj):
else:
raise ValueError(f"Output {c} not found in {component_name}")
+ if "units" in self.h_dict[component_name]:
+ for unit in component_obj.units:
+ unit_name = unit.component_name
+ for c in unit.log_channels:
+ dataset_name = f"{component_name}.{unit_name}.{c}"
+ self.hdf5_datasets[dataset_name] = components_group.create_dataset(
+ dataset_name,
+ shape=(total_rows,),
+ dtype=hercules_float_type,
+ **compression_params,
+ )
+
# Create external signals datasets
if "external_signals" in self.h_dict and self.h_dict["external_signals"]:
external_signals_group = data_group.create_group("external_signals")
@@ -711,6 +724,16 @@ def _log_data_to_hdf5(self):
if dataset_name in self.data_buffers:
self.data_buffers[dataset_name][self.buffer_row] = output_value
+ if "units" in self.h_dict[component_name]:
+ for unit in component_obj.units:
+ unit_name = unit.component_name
+ for c in unit.log_channels:
+ dataset_name = f"{component_name}.{unit_name}.{c}"
+ if dataset_name in self.data_buffers:
+ self.data_buffers[dataset_name][self.buffer_row] = self.h_dict[
+ component_name
+ ][unit_name][c]
+
# Buffer external signals (only those specified in log_channels)
if "external_signals" in self.h_dict and self.h_dict["external_signals"]:
for signal_name, signal_value in self.h_dict["external_signals"].items():
diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py
index c80fb8f8..1ea4ed87 100644
--- a/hercules/hybrid_plant.py
+++ b/hercules/hybrid_plant.py
@@ -1,12 +1,35 @@
import numpy as np
-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.solar_pysam_pvwatts import SolarPySAMPVWatts
-from hercules.plant_components.wind_farm import WindFarm
-from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower
-from hercules.utilities import get_available_component_names, get_available_generator_names
+from hercules.component_registry import COMPONENT_REGISTRY
+
+# 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.hard_coal_steam_turbine import HardCoalSteamTurbine
+# from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine
+# from hercules.plant_components.power_playback import PowerPlayback
+# from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts
+# from hercules.plant_components.thermal_plant import ThermalPlant
+# from hercules.plant_components.wind_farm import WindFarm
+# from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower
+
+# # Registry mapping component_type strings to their classes.
+# # Add new component types here to make them discoverable by HybridPlant.
+# COMPONENT_REGISTRY = {
+# "WindFarm": WindFarm,
+# "WindFarmSCADAPower": WindFarmSCADAPower,
+# "SolarPySAMPVWatts": SolarPySAMPVWatts,
+# "BatterySimple": BatterySimple,
+# "BatteryLithiumIon": BatteryLithiumIon,
+# "ElectrolyzerPlant": ElectrolyzerPlant,
+# "OpenCycleGasTurbine": OpenCycleGasTurbine,
+# "ThermalPlant": ThermalPlant,
+# "HardCoalSteamTurbine": HardCoalSteamTurbine,
+# "PowerPlayback": PowerPlayback,
+# }
+
+# # Derived from registry keys for validation in utilities.py
+# VALID_COMPONENT_TYPES = tuple(COMPONENT_REGISTRY.keys())
class HybridPlant:
@@ -28,20 +51,12 @@ def __init__(self, h_dict):
Raises:
Exception: If no plant components are found in the input dictionary.
"""
- # get a list of possible component names
- all_component_names = get_available_component_names()
-
- # get a list of possible generator names
- all_generator_names = get_available_generator_names()
-
- # Make a list of component names that are in the h_dict
+ # Discover components: any top-level h_dict entry whose value is a dict
+ # containing a "component_type" key is treated as a plant component.
+ # This allows user-chosen instance names (e.g. "battery_unit_1") while
+ # remaining backward compatible with conventional names (e.g. "battery").
self.component_names = [
- component_name for component_name in all_component_names if component_name in h_dict
- ]
-
- # Make a list of generator names that are in the h_dict
- self.generator_names = [
- generator_name for generator_name in all_generator_names if generator_name in h_dict
+ key for key, val in h_dict.items() if isinstance(val, dict) and "component_type" in val
]
# Add in the number of components
@@ -58,6 +73,13 @@ def __init__(self, h_dict):
component_name, h_dict
)
+ # Determine generator names from component_category after instantiation
+ self.generator_names = [
+ name
+ for name, obj in self.component_objects.items()
+ if obj.component_category == "generator"
+ ]
+
def add_plant_metadata_to_h_dict(self, h_dict):
"""Add plant component metadata to the h_dict.
@@ -97,26 +119,13 @@ def get_plant_component(self, component_name, h_dict):
"""
component_type = h_dict[component_name]["component_type"]
- # Handle wind farm component types with unified WindFarm class
- if component_type == "WindFarm":
- return WindFarm(h_dict)
-
- if component_type == "WindFarmSCADAPower":
- return WindFarmSCADAPower(h_dict)
-
- if component_type == "SolarPySAMPVWatts":
- return SolarPySAMPVWatts(h_dict)
-
- if component_type == "BatteryLithiumIon":
- return BatteryLithiumIon(h_dict)
-
- if component_type == "BatterySimple":
- return BatterySimple(h_dict)
-
- if component_type == "ElectrolyzerPlant":
- return ElectrolyzerPlant(h_dict)
-
- raise Exception("Unknown component_type: ", component_type)
+ cls = COMPONENT_REGISTRY.get(component_type)
+ if cls is None:
+ raise ValueError(
+ f"Unknown component_type '{component_type}' for component '{component_name}'. "
+ f"Available types: {sorted(COMPONENT_REGISTRY)}"
+ )
+ return cls(h_dict, component_name)
def step(self, h_dict):
"""Execute one simulation step for all plant components.
@@ -129,16 +138,16 @@ def step(self, h_dict):
"""
# Collect the component objects
for component_name in self.component_names:
- # If component_name is battery, invert the sign of the power_setpoint
- if component_name == "battery":
+ is_storage = self.component_objects[component_name].component_category == "storage"
+
+ # Storage sign convention: negate setpoint before step, restore after
+ if is_storage:
h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"]
# Update h_dict by calling the step method of each component object
h_dict = self.component_objects[component_name].step(h_dict)
- # If component_name is battery, invert the sign of the power_setpoint back
- # And invert the sign of the power output
- if component_name == "battery":
+ if is_storage:
h_dict[component_name]["power_setpoint"] = -h_dict[component_name]["power_setpoint"]
h_dict[component_name]["power"] = -h_dict[component_name]["power"]
diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py
index dd1b2fb2..e1a574ef 100644
--- a/hercules/plant_components/battery_lithium_ion.py
+++ b/hercules/plant_components/battery_lithium_ion.py
@@ -87,7 +87,9 @@ class BatteryLithiumIon(ComponentBase):
Nov. 2021, doi: 10.1016/j.est.2021.103252.
"""
- def __init__(self, h_dict):
+ component_category = "storage"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the BatteryLithiumIon class.
This model represents a detailed lithium-ion battery with diffusion transients
@@ -102,16 +104,11 @@ def __init__(self, h_dict):
- min_SOC: Minimum state of charge (0-1)
- initial_conditions: Dictionary with initial SOC
- allow_grid_power_consumption: Optional, defaults to False
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the name of this component
- self.component_name = "battery"
-
- # Store the type of this component
- self.component_type = "BatteryLithiumIon"
-
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
self.V_cell_nom = 3.3 # [V]
self.C_cell = 15.756 # [Ah] mean value from [1] Table 1
@@ -337,6 +334,8 @@ def step(self, h_dict):
"""
P_signal = h_dict[self.component_name]["power_setpoint"] # [kW] requested power
+ if np.isnan(P_signal):
+ raise ValueError(f"{self.component_name}: power_setpoint is NaN")
if self.allow_grid_power_consumption:
P_avail = np.inf
else:
diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py
index 8e77bf22..5ab955a6 100644
--- a/hercules/plant_components/battery_simple.py
+++ b/hercules/plant_components/battery_simple.py
@@ -81,7 +81,9 @@ class BatterySimple(ComponentBase):
All power units are in kW and energy units are in kWh.
"""
- def __init__(self, h_dict):
+ component_category = "storage"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the BatterySimple class.
This model represents a simple battery with energy storage and power constraints.
@@ -99,15 +101,11 @@ def __init__(self, h_dict):
- roundtrip_efficiency: Optional roundtrip efficiency (0-1)
- self_discharge_time_constant: Optional self-discharge time constant
- track_usage: Optional boolean to enable usage tracking
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the name of this component
- self.component_name = "battery"
-
- # Store the type of this component
- self.component_type = "BatterySimple"
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
# size = h_dict[self.component_name]["size"]
self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh]
@@ -240,7 +238,7 @@ def step(self, h_dict):
- plant.locally_generated_power: Available power for charging [kW]
Returns:
- dict: Updated h_dict with battery outputs:
+ dict: Updated h_dict with battery outputs stored under self.component_name:
- power: Actual charging/discharging power [kW]
- reject: Rejected power due to constraints [kW]
- soc: State of charge [0-1]
@@ -252,6 +250,8 @@ def step(self, h_dict):
# Power available for the battery to use for charging (should be >=0)
power_setpoint = h_dict[self.component_name]["power_setpoint"]
+ if np.isnan(power_setpoint):
+ raise ValueError(f"{self.component_name}: power_setpoint is NaN")
# Power signal desired by the controller
if self.allow_grid_power_consumption:
P_avail = np.inf
diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py
index 963fa3a4..600af4d4 100644
--- a/hercules/plant_components/component_base.py
+++ b/hercules/plant_components/component_base.py
@@ -1,6 +1,8 @@
# Base class for plant components in Hercules.
+from typing import ClassVar
+
from hercules.utilities import setup_logging
@@ -9,19 +11,54 @@ class ComponentBase:
Provides common functionality for all Hercules plant components including logging setup,
time step management, and shared configuration parameters.
+
+ Subclasses must define the class attribute ``component_category`` with one of three
+ values: ``"generator"``, ``"load"``, or ``"storage"``. The per-instance
+ ``component_name`` (the unique YAML key chosen by the user) is passed into ``__init__``
+ and may differ from the category when multiple instances of the same type are present.
+ ``component_type`` is always set automatically to the concrete class name.
"""
+ # Subclasses must override this with one of: "generator", "load", "storage"
+ component_category: ClassVar[str]
+
+ # Valid component categories
+ _ALLOWED_CATEGORIES = {"generator", "load", "storage"}
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ if not hasattr(cls, "component_category"):
+ raise TypeError(f"{cls.__name__} must define a class attribute 'component_category'")
+
+ value = cls.component_category
+ if not isinstance(value, str):
+ raise TypeError(
+ f"{cls.__name__}.component_category must be a string in "
+ f"{cls._ALLOWED_CATEGORIES}, got {type(value).__name__!r}: {value!r}"
+ )
+ if value not in cls._ALLOWED_CATEGORIES:
+ raise TypeError(
+ f"{cls.__name__}.component_category must be one of "
+ f"{cls._ALLOWED_CATEGORIES}, got {value!r}"
+ )
+
def __init__(self, h_dict, component_name):
"""Initialize the base component with a dictionary of parameters.
Args:
h_dict (dict): Dictionary containing simulation parameters.
- component_name (str): Name of the component.
+ component_name (str): Unique name for this component instance (the YAML top-level
+ key). For single-instance plants this is typically the category name (e.g.
+ ``"battery"``); for multi-instance plants it may be any user-chosen string
+ (e.g. ``"battery_unit_1"``).
"""
- # Store the component name
+ # Store the component name (unique instance identifier from the YAML key)
self.component_name = component_name
+ # Derive component_type from the concrete class name — no hardcoding needed
+ self.component_type = type(self).__name__
+
# Set up logging
# Check if log_file_name is defined in the h_dict[component_name]
if "log_file_name" in h_dict[component_name]:
@@ -100,3 +137,7 @@ def close_logging(self):
for handler in self.logger.handlers[:]:
handler.close()
self.logger.removeHandler(handler)
+
+ def step(self, h_dict):
+ """Raise error if step is called on the abstract base class."""
+ raise NotImplementedError("Components must implement the step() method")
diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py
index 3599be24..cd95e58f 100644
--- a/hercules/plant_components/electrolyzer_plant.py
+++ b/hercules/plant_components/electrolyzer_plant.py
@@ -14,7 +14,9 @@ class ElectrolyzerPlant(ComponentBase):
The Electrolyzer plant uses the electrolyzer model from https://github.com/NREL/electrolyzer
"""
- def __init__(self, h_dict):
+ component_category = "load"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the ElectrolyzerPlant class.
Args:
@@ -87,16 +89,11 @@ def __init__(self, h_dict):
- finances: Financial parameters including:
- discount_rate: Discount rate for financial calculations [%].
- install_factor: Installation factor for capital expenditure [0,1].
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the name of this component
- self.component_name = "electrolyzer"
-
- # Store the type of this component
- self.component_type = "ElectrolyzerPlant"
-
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
electrolyzer_dict = {}
# Check if general key exists in electrolyzer section, otherwise use top-level general
diff --git a/hercules/plant_components/hard_coal_steam_turbine.py b/hercules/plant_components/hard_coal_steam_turbine.py
new file mode 100644
index 00000000..96c66110
--- /dev/null
+++ b/hercules/plant_components/hard_coal_steam_turbine.py
@@ -0,0 +1,119 @@
+"""
+Hard Coal Steam Turbine Class.
+
+Hard coal steam turbine 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].
+
+References:
+
+[1] Agora Energiewende (2017): Flexibility in thermal power plants
+ With a focus on existing coal-fired power plants.
+[2] IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants,
+ International Renewable Energy Agency, Abu Dhabi.
+[3] Schmitt, Tommy, Sarah Leptinsky, Marc Turner, Alex Zoelle, Chuck White, Sydney Hughes,
+ Sally Homsy, et al. “Cost And Performance Baseline for Fossil Energy Plants Volume 1:
+ Bituminous Coal and Natural Gas to Electricity.” Pittsburgh, PA: National Energy Technology
+ Laboratory, October 14, 2022b. https://doi.org/10.2172/1893822.
+[4] I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011.
+ https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
+"""
+
+from hercules.plant_components.thermal_component_base import ThermalComponentBase
+
+
+class HardCoalSteamTurbine(ThermalComponentBase):
+ """Hard coal steam turbine model.
+
+ This model represents a hard coal steam turbine with state
+ management, ramp rate constraints, minimum stable load, and fuel consumption
+ tracking. Note it is a subclass of the ThermalComponentBase class.
+
+ All efficiency values are HHV (Higher Heating Value) net plant efficiencies.
+
+ NOTE: if minimum downtime is 48 hours, then hot start = warm start = cold start = 7.5 hours
+ as per [1].
+ """
+
+ def __init__(self, h_dict, component_name):
+ """Initialize the HardCoalSteamTurbine 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.30 (30%) [2]
+ - ramp_rate_fraction: Optional, maximum rate of power increase/decrease
+ as fraction of rated capacity per minute. Default: 0.03 (3%) [1,2]
+ - 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: 27000.0 s (7.5 hours) [1]
+ - warm_startup_time: Optional, time to reach min_stable_load_fraction from off
+ in s. Includes both readying time and ramping time.
+ Default: 27000.0 s (7.5 hours) [1]
+ - cold_startup_time: Optional, time to reach min_stable_load_fraction from off
+ in s. Includes both readying time and ramping time.
+ Default: 27000.0 s (7.5 hours) [1]
+ - min_up_time: Optional, minimum time unit must remain on in s.
+ Default: 172800.0 s (48 hours) [2]
+ - min_down_time: Optional, minimum time unit must remain off in s.
+ Default: 172800.0 s (48 hours) [2]
+ - initial_conditions: Dictionary with initial power (state is
+ derived automatically: power > 0 means ON, power == 0 means OFF)
+ - hhv: Optional, higher heating value of coal (Bituminous) in J/m³.
+ Default: 29,310,000,000 J/m³ (29.31 MJ/kg) [4]
+ - fuel_density: Optional, fuel density in kg/m³. https://www.engineeringtoolbox.com/classification-coal-d_164.html
+ Default: 1000 kg/m³
+ - efficiency_table: Optional, dictionary with power_fraction and
+ efficiency arrays (both as fractions 0-1). Efficiency values must
+ be HHV net plant efficiencies. Default values are taken from [2,3]:
+ power_fraction = [1.0, 0.5, 0.3],
+ efficiency = [0.35, 0.32, 0.30].
+
+ component_name (str): Unique name for this instance (the YAML top-level key).
+ """
+
+ # Apply fixed default parameters based on references
+ # back into the h_dict if they are not provided
+ if "min_stable_load_fraction" not in h_dict[component_name]:
+ h_dict[component_name]["min_stable_load_fraction"] = 0.30
+ if "ramp_rate_fraction" not in h_dict[component_name]:
+ h_dict[component_name]["ramp_rate_fraction"] = 0.03
+ if "hot_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["hot_startup_time"] = 27000.0
+ if "warm_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["warm_startup_time"] = 27000.0
+ if "cold_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["cold_startup_time"] = 27000.0
+ if "min_up_time" not in h_dict[component_name]:
+ h_dict[component_name]["min_up_time"] = 172800.0
+ if "min_down_time" not in h_dict[component_name]:
+ h_dict[component_name]["min_down_time"] = 172800.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[component_name]:
+ h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][
+ "ramp_rate_fraction"
+ ]
+
+ # Default HHV for coal (Bituminous)
+ if "hhv" not in h_dict[component_name]:
+ h_dict[component_name]["hhv"] = 29310000000 # J/m³ (29310 MJ/m³)
+
+ # Default fuel density for coal (Bituminous)
+ if "fuel_density" not in h_dict[component_name]:
+ h_dict[component_name]["fuel_density"] = 1000.0 # kg/m³
+
+ # Default HHV net plant efficiency table based on [2]:
+ if "efficiency_table" not in h_dict[component_name]:
+ h_dict[component_name]["efficiency_table"] = {
+ "power_fraction": [1.0, 0.5, 0.3],
+ "efficiency": [0.35, 0.32, 0.30],
+ }
+
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
diff --git a/hercules/plant_components/open_cycle_gas_turbine.py b/hercules/plant_components/open_cycle_gas_turbine.py
new file mode 100644
index 00000000..014f9368
--- /dev/null
+++ b/hercules/plant_components/open_cycle_gas_turbine.py
@@ -0,0 +1,129 @@
+"""
+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], [4], [5] and [6].
+
+Like other subclasses of ThermalComponentBase, it inherits the main control functions,
+and adds defaults for many variables based on [1], [2], [3], [4], [5] and [6].
+
+Note: All efficiency values are HHV (Higher Heating Value) net plant efficiencies.
+The default efficiency table is based on the SC1A curve from Exhibit ES-4 of [5].
+
+Note: This class is based on aeroderivative open cycle gas turbines,
+which are commonly used for flexible power generation.
+
+References:
+
+[1] Agora Energiewende (2017): Flexibility in thermal power plants
+ With a focus on existing coal-fired power plants.
+[2] "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on
+ Production Cost Simulation", NREL/CP-6A40-87554, National Renewable
+ Energy Laboratory, 2024.
+[3] Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly
+ Modelling in Power Systems with Significant Levels of Renewable Generation."
+ Applied Energy 113 (January 2014): 152–58.
+ https://doi.org/10.1016/j.apenergy.2013.07.027.
+[4] IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants,
+ International Renewable Energy Agency, Abu Dhabi.
+[5] M. Oakes, M. Turner, " Cost and Performance Baseline for Fossil Energy Plants, Volume 5:
+ Natural Gas Electricity Generating Units for Flexible Operation," National Energy
+ Technology Laboratory, Pittsburgh, May 5, 2023.
+[6] I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011.
+ https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
+"""
+
+from hercules.plant_components.thermal_component_base import ThermalComponentBase
+
+
+class 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.
+
+ All efficiency values are HHV (Higher Heating Value) net plant efficiencies.
+ """
+
+ def __init__(self, h_dict, component_name):
+ """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 (state is
+ derived automatically: power > 0 means ON, power == 0 means OFF)
+ - hhv: Optional, higher heating value of natural gas in J/m³.
+ Default: 39050000 J/m³ (39.05 MJ/m³) [6]
+ - fuel_density: Optional, fuel density in kg/m³.
+ Default: 0.768 kg/m³ [6]
+ - efficiency_table: Optional, dictionary with power_fraction and
+ efficiency arrays (both as fractions 0-1). Efficiency values must
+ be HHV net plant efficiencies. Default values are approximate
+ readings from the SC1A curve in Exhibit ES-4 of [5]:
+ power_fraction = [1.0, 0.75, 0.50, 0.25],
+ efficiency = [0.39, 0.37, 0.325, 0.245].
+ component_name (str): Unique name for this instance (the YAML top-level key).
+ """
+
+ # Apply fixed default parameters based on [1], [2] and [3]
+ # back into the h_dict if they are not provided
+ if "min_stable_load_fraction" not in h_dict[component_name]:
+ h_dict[component_name]["min_stable_load_fraction"] = 0.40
+ if "ramp_rate_fraction" not in h_dict[component_name]:
+ h_dict[component_name]["ramp_rate_fraction"] = 0.1
+ if "hot_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["hot_startup_time"] = 420.0
+ if "warm_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["warm_startup_time"] = 480.0
+ if "cold_startup_time" not in h_dict[component_name]:
+ h_dict[component_name]["cold_startup_time"] = 480.0
+ if "min_up_time" not in h_dict[component_name]:
+ h_dict[component_name]["min_up_time"] = 1800.0
+ if "min_down_time" not in h_dict[component_name]:
+ h_dict[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[component_name]:
+ h_dict[component_name]["run_up_rate_fraction"] = h_dict[component_name][
+ "ramp_rate_fraction"
+ ]
+
+ # Default HHV for natural gas (39.05 MJ/m³) from [6]
+ if "hhv" not in h_dict[component_name]:
+ h_dict[component_name]["hhv"] = 39050000 # J/m³ (39.05 MJ/m³)
+
+ # Default fuel density for natural gas (0.768 kg/m³) from [6]
+ if "fuel_density" not in h_dict[component_name]:
+ h_dict[component_name]["fuel_density"] = 0.768 # kg/m³
+
+ # Default HHV net plant efficiency table based on approximate readings from
+ # the SC1A curve in Exhibit ES-4 of [5]
+ if "efficiency_table" not in h_dict[component_name]:
+ h_dict[component_name]["efficiency_table"] = {
+ "power_fraction": [1.0, 0.75, 0.50, 0.25],
+ "efficiency": [0.39, 0.37, 0.325, 0.245],
+ }
+
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
diff --git a/hercules/plant_components/power_playback.py b/hercules/plant_components/power_playback.py
new file mode 100644
index 00000000..e12f4361
--- /dev/null
+++ b/hercules/plant_components/power_playback.py
@@ -0,0 +1,177 @@
+# PowerPlayback is based on WindFarmSCADAPower but models a generic power source
+# Note it is limited to playing back the prerecorded power,
+# there is no option to control.
+
+import numpy as np
+import pandas as pd
+from hercules.plant_components.component_base import ComponentBase
+from hercules.utilities import (
+ hercules_float_type,
+ interpolate_df,
+)
+
+
+class PowerPlayback(ComponentBase):
+ """Power playback model that plays back pre-recorded power data.
+ Note it is limited to playing back the prerecorded power,
+ there is no option to control.
+ """
+
+ component_category = "generator"
+
+ def __init__(self, h_dict, component_name):
+ """Initialize the PowerPlayback class.
+
+
+ Args:
+ h_dict (dict): Dictionary containing simulation parameters.
+ component_name (str): Unique name for this instance (the YAML top-level key).
+ """
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
+
+ self.logger.info("Initializing PowerPlayback")
+
+ # Read in the input file names
+ self.scada_filename = h_dict[self.component_name]["scada_filename"]
+
+ self.logger.info("Reading in SCADA power data...")
+
+ # Read in the scada file
+ if self.scada_filename.endswith(".csv"):
+ df_scada = pd.read_csv(self.scada_filename)
+ elif self.scada_filename.endswith((".p", ".pkl")):
+ df_scada = pd.read_pickle(self.scada_filename)
+ elif self.scada_filename.endswith((".f", ".ftr")):
+ df_scada = pd.read_feather(self.scada_filename)
+ else:
+ raise ValueError("SCADA file must be a .csv, .p, .pkl, .f, or .ftr file")
+
+ self.logger.info("Checking SCADA file...")
+
+ # Make sure the df_scada contains a column called "time_utc"
+ if "time_utc" not in df_scada.columns:
+ raise ValueError("SCADA file must contain a column called 'time_utc'")
+
+ # Check key columns for Nan values
+ nan_check_cols = ["time_utc", "power"]
+ if df_scada[nan_check_cols].isna().any().any():
+ raise ValueError("SCADA file contains NaN values in required columns (time_utc, power)")
+
+ # Convert time_utc to datetime if it's not already
+ if not pd.api.types.is_datetime64_any_dtype(df_scada["time_utc"]):
+ # Strip whitespace from time_utc values to handle CSV formatting issues
+ df_scada["time_utc"] = df_scada["time_utc"].astype(str).str.strip()
+ try:
+ df_scada["time_utc"] = pd.to_datetime(
+ df_scada["time_utc"], format="ISO8601", utc=True
+ )
+ except (ValueError, TypeError):
+ # If ISO8601 format fails, try parsing without specifying format
+ df_scada["time_utc"] = pd.to_datetime(df_scada["time_utc"], utc=True)
+
+ # Ensure time_utc is timezone-aware (UTC)
+ if not isinstance(df_scada["time_utc"].dtype, pd.DatetimeTZDtype):
+ df_scada["time_utc"] = df_scada["time_utc"].dt.tz_localize("UTC")
+
+ # Get starttime_utc and endtime_utc from h_dict
+ starttime_utc = h_dict["starttime_utc"]
+ endtime_utc = h_dict["endtime_utc"]
+
+ # Ensure starttime_utc is timezone-aware (UTC)
+ if not isinstance(starttime_utc, pd.Timestamp):
+ starttime_utc = pd.to_datetime(starttime_utc, utc=True)
+ elif starttime_utc.tz is None:
+ starttime_utc = starttime_utc.tz_localize("UTC")
+
+ # Ensure endtime_utc is timezone-aware (UTC)
+ if not isinstance(endtime_utc, pd.Timestamp):
+ endtime_utc = pd.to_datetime(endtime_utc, utc=True)
+ elif endtime_utc.tz is None:
+ endtime_utc = endtime_utc.tz_localize("UTC")
+
+ # Generate time column internally: time = 0 corresponds to starttime_utc
+ df_scada["time"] = (df_scada["time_utc"] - starttime_utc).dt.total_seconds()
+
+ # Validate that starttime_utc and endtime_utc are within the time_utc range
+ if df_scada["time_utc"].min() > starttime_utc:
+ min_time = df_scada["time_utc"].min()
+ raise ValueError(
+ f"Start time UTC {starttime_utc} is before the earliest time "
+ f"in the SCADA file ({min_time})"
+ )
+ if df_scada["time_utc"].max() < endtime_utc:
+ max_time = df_scada["time_utc"].max()
+ raise ValueError(
+ f"End time UTC {endtime_utc} is after the latest time "
+ f"in the SCADA file ({max_time})"
+ )
+
+ # Set starttime_utc
+ self.starttime_utc = starttime_utc
+
+ # Determine the dt implied by the weather file
+ self.dt_scada = df_scada["time"].iloc[1] - df_scada["time"].iloc[0]
+
+ # Log the values
+ if self.verbose:
+ self.logger.info(f"dt_scada = {self.dt_scada}")
+ self.logger.info(f"dt = {self.dt}")
+
+ self.logger.info("Interpolating SCADA file...")
+
+ # Interpolate df_scada on to the time steps
+ time_steps_all = np.arange(self.starttime, self.endtime, self.dt, dtype=hercules_float_type)
+ df_scada = interpolate_df(df_scada, time_steps_all)
+
+ # Confirm that there is a column called "power"
+ if "power" not in df_scada.columns:
+ raise ValueError("SCADA file must contain a column called 'power'")
+
+ # Collect the scada power
+ self.scada_power = df_scada["power"].to_numpy(dtype=hercules_float_type)
+
+ def get_initial_conditions_and_meta_data(self, h_dict):
+ """Add any initial conditions or meta data to the h_dict.
+
+ Meta data is data not explicitly in the input yaml but still useful for other
+ modules.
+
+ Args:
+ h_dict (dict): Dictionary containing simulation parameters.
+
+ Returns:
+ dict: Dictionary containing simulation parameters with initial conditions and meta data.
+ """
+ h_dict[self.component_name]["power"] = self.scada_power[0]
+
+ # Log the start time UTC if available
+ if hasattr(self, "starttime_utc"):
+ h_dict[self.component_name]["starttime_utc"] = self.starttime_utc
+
+ return h_dict
+
+ def step(self, h_dict):
+ """Execute one simulation step for the power playback component.
+
+ Updates power based on the pre-recorded power data.
+
+ Args:
+ h_dict (dict): Dictionary containing current simulation state including
+ step number
+
+ Returns:
+ dict: Updated simulation dictionary with power output.
+ """
+ # Get the current step
+ step = h_dict["step"]
+ if self.verbose:
+ self.logger.info(f"step = {step} (of {self.n_steps})")
+
+ # Update the power
+ self.power = self.scada_power[step]
+
+ # Update the h_dict with outputs
+ h_dict[self.component_name]["power"] = self.power
+
+ return h_dict
diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py
index dcfef032..01c24030 100644
--- a/hercules/plant_components/solar_pysam_base.py
+++ b/hercules/plant_components/solar_pysam_base.py
@@ -4,6 +4,7 @@
import pandas as pd
from hercules.plant_components.component_base import ComponentBase
from hercules.utilities import (
+ hercules_float_type,
interpolate_df,
)
@@ -17,17 +18,17 @@ class SolarPySAMBase(ComponentBase):
Note PVSam is no longer supported in Hercules.
"""
- def __init__(self, h_dict):
+ component_category = "generator"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the base solar PySAM simulator.
Args:
h_dict (dict): Dictionary containing simulation parameters.
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the name of this component
- self.component_name = "solar_farm"
-
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
# Load and process solar data
self._load_solar_data(h_dict)
@@ -124,7 +125,7 @@ def _load_solar_data(self, h_dict):
self.starttime_utc = starttime_utc
# Interpolate df_solar on to the time steps
- time_steps_all = np.arange(self.starttime, self.endtime, self.dt)
+ time_steps_all = np.arange(self.starttime, self.endtime, self.dt, dtype=hercules_float_type)
df_solar = interpolate_df(df_solar, time_steps_all)
# Can now save the input data as simple columns
@@ -167,16 +168,16 @@ def get_initial_conditions_and_meta_data(self, h_dict):
dict: Dictionary containing simulation parameters with initial conditions and meta data.
"""
# This is a bit of a hack but need this to exist
- h_dict["solar_farm"]["capacity"] = self.system_capacity
- h_dict["solar_farm"]["power"] = self.power
- h_dict["solar_farm"]["dc_power"] = self.dc_power
- h_dict["solar_farm"]["dni"] = self.dni
- h_dict["solar_farm"]["poa"] = self.poa
- h_dict["solar_farm"]["aoi"] = self.aoi
+ h_dict[self.component_name]["capacity"] = self.system_capacity
+ h_dict[self.component_name]["power"] = self.power
+ h_dict[self.component_name]["dc_power"] = self.dc_power
+ h_dict[self.component_name]["dni"] = self.dni
+ h_dict[self.component_name]["poa"] = self.poa
+ h_dict[self.component_name]["aoi"] = self.aoi
# Log the start time UTC if available
if hasattr(self, "starttime_utc"):
- h_dict["solar_farm"]["starttime_utc"] = self.starttime_utc
+ h_dict[self.component_name]["starttime_utc"] = self.starttime_utc
return h_dict
@@ -253,7 +254,10 @@ def step(self, h_dict):
self.power = self.power_uncurtailed[step]
# Apply control
- self.control(h_dict[self.component_name]["power_setpoint"])
+ power_setpoint = h_dict[self.component_name]["power_setpoint"]
+ if np.isnan(power_setpoint):
+ raise ValueError(f"{self.component_name}: power_setpoint is NaN")
+ self.control(power_setpoint)
if self.power < 0.0:
self.power = 0.0
diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py
index 1ad60444..60e15d2c 100644
--- a/hercules/plant_components/solar_pysam_pvwatts.py
+++ b/hercules/plant_components/solar_pysam_pvwatts.py
@@ -9,17 +9,15 @@
class SolarPySAMPVWatts(SolarPySAMBase):
"""Solar simulator using PySAM's simplified PV model (Pvwattsv8)."""
- def __init__(self, h_dict):
+ def __init__(self, h_dict, component_name):
"""Initialize the PVWatts solar simulator.
Args:
h_dict (dict): Dictionary containing simulation parameters.
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the type of this component
- self.component_type = "SolarPySAMPVWatts"
-
- # Call the base class init
- super().__init__(h_dict)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
# Set up PV system model parameters
self._setup_model_parameters(h_dict)
@@ -68,7 +66,6 @@ def _create_system_model(self):
system_model.assign(self.model_params)
system_model.AdjustmentFactors.adjust_constant = 0
- system_model.AdjustmentFactors.dc_adjust_constant = 0
# Save the system model
self.system_model = system_model
@@ -95,7 +92,6 @@ def _precompute_power_array(self):
# Assign the full solar resource data
self.system_model.SolarResource.assign({"solar_resource_data": solar_resource_data})
- self.system_model.AdjustmentFactors.assign({"constant": 0})
# Execute the model once for all time steps
self.system_model.execute()
diff --git a/hercules/plant_components/thermal_component_base.py b/hercules/plant_components/thermal_component_base.py
new file mode 100644
index 00000000..0262604b
--- /dev/null
+++ b/hercules/plant_components/thermal_component_base.py
@@ -0,0 +1,641 @@
+"""
+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.
+
+Note: All efficiency values in this module are HHV (Higher Heating Value) net plant
+efficiencies, consistent with the data in Exhibit ES-4 of [5].
+
+References:
+
+[1] Agora Energiewende (2017): Flexibility in thermal power plants
+ With a focus on existing coal-fired power plants.
+[2] "Impact of Detailed Parameter Modeling of Open-Cycle Gas Turbines on
+ Production Cost Simulation", NREL/CP-6A40-87554, National Renewable
+ Energy Laboratory, 2024.
+[3] Deane, J.P., G. Drayton, and B.P. Ó Gallachóir. "The Impact of Sub-Hourly
+ Modelling in Power Systems with Significant Levels of Renewable Generation."
+ Applied Energy 113 (January 2014): 152–58.
+ https://doi.org/10.1016/j.apenergy.2013.07.027.
+[4] IRENA (2019), Innovation landscape brief: Flexibility in conventional power plants,
+ International Renewable Energy Agency, Abu Dhabi.
+[5] M. Oakes, M. Turner, " Cost and Performance Baseline for Fossil Energy Plants, Volume 5:
+ Natural Gas Electricity Generating Units for Flexible Operation," National Energy
+ Technology Laboratory, Pittsburgh, May 5, 2023.
+[6] I. Staffell, "The Energy and Fuel Data Sheet," University of Birmingham, March 2011.
+ https://claverton-energy.com/cms4/wp-content/uploads/2012/08/the_energy_and_fuel_data_sheet.pdf
+
+"""
+
+from enum import IntEnum
+
+import numpy as np
+from hercules.plant_components.component_base import ComponentBase
+from hercules.utilities import hercules_float_type
+
+
+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 values (IntEnum) 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
+
+
+ """
+
+ component_category = "generator"
+
+ class STATES(IntEnum):
+ """Enumeration of thermal component operating states."""
+
+ OFF = 0
+ HOT_STARTING = 1
+ WARM_STARTING = 2
+ COLD_STARTING = 3
+ ON = 4
+ 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 (less than 8 hours triggers a hot start)
+ WARM_START_TIME = 48 * 60 * 60 # 48 hours (less than 48 hours triggers a warm start)
+
+ def __init__(self, h_dict, component_name):
+ """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 (state is
+ derived automatically: power > 0 means ON, power == 0 means OFF)
+ - hhv: Higher heating value of fuel in J/m³
+ - fuel_density: Fuel density in kg/m³
+ - efficiency_table: Dictionary with power_fraction and efficiency arrays
+ (both as fractions 0-1). Efficiency values must be HHV net plant
+ efficiencies.
+ component_name (str): Unique name for this instance (the YAML top-level key).
+ """
+
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, 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, hercules_float_type)):
+ raise ValueError("rated_capacity must be a number")
+ if not isinstance(self.min_stable_load_fraction, (int, float, hercules_float_type)):
+ raise ValueError("min_stable_load_fraction must be a number")
+ if not isinstance(self.ramp_rate_fraction, (int, float, hercules_float_type)):
+ raise ValueError("ramp_rate_fraction must be a number")
+ if not isinstance(self.run_up_rate_fraction, (int, float, hercules_float_type)):
+ raise ValueError("run_up_rate_fraction must be a number")
+ if not isinstance(self.hot_startup_time, (int, float, hercules_float_type)):
+ raise ValueError("hot_startup_time must be a number")
+ if not isinstance(self.warm_startup_time, (int, float, hercules_float_type)):
+ raise ValueError("warm_startup_time must be a number")
+ if not isinstance(self.cold_startup_time, (int, float, hercules_float_type)):
+ raise ValueError("cold_startup_time must be a number")
+ if not isinstance(self.min_up_time, (int, float, hercules_float_type)):
+ raise ValueError("min_up_time must be a number")
+ if not isinstance(self.min_down_time, (int, float, hercules_float_type)):
+ 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
+
+ # 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)"
+ )
+
+ # Derive initial state from power: if power > 0 then ON, else OFF
+ if self.power_output > 0:
+ self.state = self.STATES.ON
+ # Set time_in_state so the unit is immediately ready to stop
+ self.time_in_state = float(self.min_up_time) # s
+ else:
+ self.state = self.STATES.OFF
+ # Set time_in_state so the unit is immediately ready to start
+ if "time_in_shutdown" in initial_conditions:
+ self.time_in_state = float(initial_conditions["time_in_shutdown"]) # s
+ else:
+ self.time_in_state = float(self.min_down_time) # s
+
+ # Extract efficiency table (HHV net efficiency), HHV, and fuel density
+ # for fuel consumption calculations
+ self.hhv = component_dict["hhv"] # J/m³
+ self.fuel_density = component_dict["fuel_density"] # kg/m³
+ efficiency_table = component_dict["efficiency_table"]
+
+ # Validate hhv
+ if not isinstance(self.hhv, (int, float, hercules_float_type)):
+ raise ValueError("hhv must be a number")
+ if self.hhv <= 0:
+ raise ValueError("hhv must be greater than 0")
+
+ # Validate fuel_density
+ if not isinstance(self.fuel_density, (int, float, hercules_float_type)):
+ raise ValueError("fuel_density must be a number")
+ if self.fuel_density <= 0:
+ raise ValueError("fuel_density must be greater than 0")
+
+ # Validate efficiency_table structure
+ if not isinstance(efficiency_table, dict):
+ raise ValueError("efficiency_table must be a dictionary")
+ if "power_fraction" not in efficiency_table:
+ raise ValueError("efficiency_table must contain 'power_fraction'")
+ if "efficiency" not in efficiency_table:
+ raise ValueError("efficiency_table must contain 'efficiency'")
+
+ # Extract and convert to numpy arrays for interpolation
+ self.efficiency_power_fraction = np.array(
+ efficiency_table["power_fraction"], dtype=hercules_float_type
+ )
+ self.efficiency_values = np.array(efficiency_table["efficiency"], dtype=hercules_float_type)
+
+ # Validate array lengths match
+ if len(self.efficiency_power_fraction) != len(self.efficiency_values):
+ raise ValueError(
+ "efficiency_table power_fraction and efficiency arrays must have the same length"
+ )
+
+ # Validate array lengths are at least 1
+ if len(self.efficiency_power_fraction) < 1:
+ raise ValueError("efficiency_table must have at least one entry")
+
+ # Validate power_fraction values are in [0, 1]
+ if np.any(self.efficiency_power_fraction < 0) or np.any(self.efficiency_power_fraction > 1):
+ raise ValueError("efficiency_table power_fraction values must be between 0 and 1")
+
+ # Validate efficiency values are in (0, 1]
+ if np.any(self.efficiency_values <= 0) or np.any(self.efficiency_values > 1):
+ raise ValueError("efficiency_table efficiency values must be between 0 and 1")
+
+ # Sort arrays by power_fraction for proper interpolation
+ sort_idx = np.argsort(self.efficiency_power_fraction)
+ self.efficiency_power_fraction = self.efficiency_power_fraction[sort_idx]
+ self.efficiency_values = self.efficiency_values[sort_idx]
+
+ # Initialize HHV net efficiency and fuel consumption rate
+ self.efficiency = self.calculate_efficiency(self.power_output)
+ self.fuel_volume_rate = 0.0 # m³/s
+ self.fuel_mass_rate = 0.0 # kg/s
+
+ # Initialize number of starts
+ self.n_total_starts = 0
+ self.n_hot_starts = 0
+ self.n_warm_starts = 0
+ self.n_cold_starts = 0
+
+ def get_initial_conditions_and_meta_data(self, h_dict):
+ """Add initial conditions and meta data to the h_dict.
+
+ 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"] = self.state.value
+ h_dict[self.component_name]["efficiency"] = self.efficiency
+ h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate
+ h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate
+ h_dict[self.component_name]["n_total_starts"] = self.n_total_starts
+ h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts
+ h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts
+ h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts
+ return h_dict
+
+ def step(self, h_dict):
+ """Advance the thermal component simulation by one time step.
+
+ Updates the thermal component state including power output, state,
+ HHV net efficiency, and fuel consumption 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: Operating state number (0=off, 1=hot starting,
+ 2=warm starting, 3=cold starting, 4=on, 5=stopping)
+ - efficiency: Current HHV net efficiency as fraction (0-1)
+ - fuel_volume_rate: Fuel volume flow rate [m³/s]
+ - fuel_mass_rate: Fuel mass flow rate [kg/s]
+
+ """
+ # 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, hercules_float_type)):
+ raise ValueError("power_setpoint must be a number")
+ if np.isnan(power_setpoint):
+ raise ValueError(f"{self.component_name}: power_setpoint is NaN")
+
+ # 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)
+
+ # Calculate HHV net efficiency and fuel consumption rate
+ self.efficiency = self.calculate_efficiency(self.power_output)
+ self.fuel_volume_rate = self.calculate_fuel_volume_rate(self.power_output)
+ # Compute fuel mass rate from volume rate using density [6]
+ self.fuel_mass_rate = self.fuel_volume_rate * self.fuel_density
+
+ # Update h_dict with outputs
+ h_dict[self.component_name]["power"] = self.power_output
+ h_dict[self.component_name]["state"] = self.state.value
+ h_dict[self.component_name]["efficiency"] = self.efficiency
+ h_dict[self.component_name]["fuel_volume_rate"] = self.fuel_volume_rate
+ h_dict[self.component_name]["fuel_mass_rate"] = self.fuel_mass_rate
+
+ # Update start counts in h_dict for tracking purposes
+ h_dict[self.component_name]["n_total_starts"] = self.n_total_starts
+ h_dict[self.component_name]["n_hot_starts"] = self.n_hot_starts
+ h_dict[self.component_name]["n_warm_starts"] = self.n_warm_starts
+ h_dict[self.component_name]["n_cold_starts"] = self.n_cold_starts
+
+ return h_dict
+
+ def _control(self, power_setpoint):
+ """State machine for thermal component control.
+
+ Handles state transitions, startup/shutdown ramps, and power constraints
+ based on the current state (state) 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 == self.STATES.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:
+ self.n_total_starts += 1
+ # Check if hot, warm, or cold starting is implied
+ if self.time_in_state < self.HOT_START_TIME:
+ self.state = self.STATES.HOT_STARTING
+ self.n_hot_starts += 1
+ elif self.time_in_state < self.WARM_START_TIME:
+ self.state = self.STATES.WARM_STARTING
+ self.n_warm_starts += 1
+ else:
+ self.state = self.STATES.COLD_STARTING
+ self.n_cold_starts += 1
+ self.time_in_state = 0.0
+
+ return 0.0 # Power is always 0 when off
+
+ # ====================================================================
+ # STATE: HOT_STARTING
+ # ====================================================================
+ elif self.state == self.STATES.HOT_STARTING:
+ # Check if startup should be aborted
+ if power_setpoint <= 0:
+ self.state = self.STATES.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 = self.STATES.ON
+ self.time_in_state = 0.0
+ return startup_power
+
+ # Limit to below P_max (edge case)
+ startup_power = np.clip(startup_power, 0, self.P_max)
+
+ return startup_power
+
+ # ====================================================================
+ # STATE: WARM_STARTING
+ # ====================================================================
+ elif self.state == self.STATES.WARM_STARTING:
+ # Check if startup should be aborted
+ if power_setpoint <= 0:
+ self.state = self.STATES.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 = self.STATES.ON
+ self.time_in_state = 0.0
+ return startup_power
+
+ # Limit to below P_max (edge case)
+ startup_power = np.clip(startup_power, 0, self.P_max)
+
+ return startup_power
+
+ # ====================================================================
+ # STATE: COLD_STARTING
+ # ====================================================================
+ elif self.state == self.STATES.COLD_STARTING:
+ # Check if startup should be aborted
+ if power_setpoint <= 0:
+ self.state = self.STATES.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 = self.STATES.ON
+ self.time_in_state = 0.0
+ return startup_power
+
+ # Limit to below P_max (edge case)
+ startup_power = np.clip(startup_power, 0, self.P_max)
+
+ return startup_power
+
+ # ====================================================================
+ # STATE: ON
+ # ====================================================================
+ elif self.state == self.STATES.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 = self.STATES.STOPPING
+ self.time_in_state = 0.0
+
+ # 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 = self.STATES.OFF
+ self.time_in_state = 0.0
+ return 0.0
+
+ return shutdown_power
+
+ # Apply constraints for on operation
+ return self._apply_on_constraints(power_setpoint)
+
+ # ====================================================================
+ # STATE: STOPPING
+ # ====================================================================
+ elif self.state == self.STATES.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 = self.STATES.OFF
+ self.time_in_state = 0.0
+ return 0.0
+
+ return shutdown_power
+
+ else:
+ raise ValueError(f"Unexpected state in _control: {self.state}")
+
+ 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
+
+ def calculate_efficiency(self, power_output):
+ """Calculate HHV net efficiency based on current power output.
+
+ Uses linear interpolation from the efficiency table. Values outside the
+ table range are clamped to the nearest endpoint.
+
+ Args:
+ power_output (float): Current power output in kW.
+
+ Returns:
+ float: HHV net efficiency as a fraction (0-1).
+ """
+ if power_output <= 0:
+ # Return efficiency at lowest power fraction when off
+ return self.efficiency_values[0]
+
+ # Calculate power fraction
+ power_fraction = power_output / self.rated_capacity
+
+ # Interpolate efficiency (numpy.interp clamps to endpoints by default)
+ efficiency = np.interp(
+ power_fraction, self.efficiency_power_fraction, self.efficiency_values
+ )
+
+ return efficiency
+
+ def calculate_fuel_volume_rate(self, power_output):
+ """Calculate fuel volume flow rate based on power output and HHV net efficiency.
+
+ Args:
+ power_output (float): Current power output in kW.
+
+ Returns:
+ float: Fuel volume flow rate in m³/s.
+ """
+ if power_output <= 0:
+ return 0.0
+
+ # Calculate current HHV net efficiency
+ efficiency = self.calculate_efficiency(power_output)
+
+ # Calculate fuel volume rate using HHV net efficiency
+ # fuel_volume_rate (m³/s) = power (W) / (efficiency * hhv (J/m³))
+ # Convert power from kW to W (multiply by 1000)
+ fuel_m3_per_s = (power_output * 1000.0) / (efficiency * self.hhv)
+
+ return fuel_m3_per_s
diff --git a/hercules/plant_components/thermal_plant.py b/hercules/plant_components/thermal_plant.py
new file mode 100644
index 00000000..926f0baa
--- /dev/null
+++ b/hercules/plant_components/thermal_plant.py
@@ -0,0 +1,109 @@
+"""
+Multiunit thermal power plant.
+"""
+
+import copy
+
+from hercules.plant_components.component_base import ComponentBase
+from hercules.plant_components.thermal_component_base import ThermalComponentBase
+
+
+class ThermalPlant(ComponentBase):
+ """Thermal power plant comprising multiple units.
+
+ The thermal plant component is designed to represent a collection of thermal generation units
+ (e.g. gas turbines, steam turbines, RICEs) that are grouped together into a single Hercules
+ component. This allows users to model a thermal plant with multiple units with finer
+ granularity than a single aggregate component. Control setpoints can be specified for each unit.
+
+ """
+
+ component_category = "generator"
+
+ def __init__(self, h_dict, component_name):
+ # Instantiate individual units from the h_dict.
+
+ self.unit_names = h_dict[component_name]["unit_names"]
+ generic_units = h_dict[component_name]["units"]
+
+ # Check that unit_names are valid
+ if len(self.unit_names) != len(generic_units):
+ raise ValueError(
+ f"Length of unit_names ({len(self.unit_names)}) must match length of units "
+ f"({len(generic_units)})."
+ )
+ if len(set(self.unit_names)) != len(self.unit_names):
+ raise ValueError(f"unit_names must be unique. Found duplicates in {self.unit_names}.")
+
+ for unit, unit_name in zip(generic_units, self.unit_names):
+ if unit_name not in h_dict[component_name]:
+ h_dict[component_name][unit_name] = copy.deepcopy(h_dict[component_name][unit])
+
+ # Remove the template from the component dict since it's now copied into each unit dict
+ for unit in generic_units:
+ if unit in h_dict[component_name]:
+ del h_dict[component_name][unit]
+
+ # Load component registry here to define units in thermal plant
+ # NOTE: this breaks a circular dependency issue
+ from hercules.component_registry import COMPONENT_REGISTRY
+
+ self.units = []
+ for unit, unit_name in zip(h_dict[component_name]["units"], self.unit_names):
+ h_dict_thermal = h_dict[component_name]
+ h_dict_thermal["dt"] = h_dict["dt"]
+ h_dict_thermal["starttime"] = h_dict["starttime"]
+ h_dict_thermal["endtime"] = h_dict["endtime"]
+ h_dict_thermal["verbose"] = h_dict["verbose"]
+ unit_type = h_dict["thermal_power_plant"][unit_name]["component_type"]
+ unit_class = COMPONENT_REGISTRY[unit_type]
+ if unit_class is None:
+ raise ValueError(f"Unit type {unit_type} not found in component registry.")
+ elif not issubclass(unit_class, ThermalComponentBase):
+ raise ValueError(
+ f"Unit type {unit_type} must be a subclass of ThermalComponentBase."
+ )
+ else:
+ self.units.append(unit_class(h_dict_thermal, unit_name))
+
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
+
+ def step(self, h_dict):
+ """
+ Step the thermal plant by stepping each individual unit and summing their power outputs.
+ """
+ thermal_plant_power = 0.0
+
+ for unit, unit_name, power_setpoint in zip(
+ self.units, self.unit_names, h_dict[self.component_name]["power_setpoints"]
+ ):
+ h_dict_thermal = h_dict[self.component_name]
+ h_dict_thermal[unit_name]["power_setpoint"] = power_setpoint
+ h_dict_thermal = unit.step(h_dict_thermal)
+ thermal_plant_power += h_dict_thermal[unit_name]["power"]
+
+ h_dict[self.component_name]["power"] = thermal_plant_power
+
+ return h_dict
+
+ def get_initial_conditions_and_meta_data(self, h_dict):
+ """Get initial conditions and metadata for the thermal plant.
+
+ Args:
+ h_dict (dict): Dictionary containing simulation parameters.
+ """
+ # NOTE: h_dict is modified in place, so h_dict will be updated with the initial
+ # conditions and metadata for each unit.
+ for unit in self.units:
+ h_dict_thermal = h_dict[self.component_name]
+ unit.get_initial_conditions_and_meta_data(h_dict_thermal)
+
+ h_dict[self.component_name]["power"] = sum(
+ h_dict_thermal[unit.component_name]["power"] for unit in self.units
+ )
+ h_dict[self.component_name]["rated_capacity"] = sum(
+ h_dict_thermal[unit.component_name]["rated_capacity"] for unit in self.units
+ )
+
+ return h_dict
diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py
index fd61e7c5..4845d8f8 100644
--- a/hercules/plant_components/wind_farm.py
+++ b/hercules/plant_components/wind_farm.py
@@ -39,20 +39,20 @@ class WindFarm(ComponentBase):
All three strategies support detailed turbine dynamics (filter_model or dof1_model).
"""
- def __init__(self, h_dict):
+ component_category = "generator"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the WindFarm class.
Args:
h_dict (dict): Dictionary containing simulation parameters.
+ component_name (str): Unique name for this instance (the YAML top-level key).
Raises:
ValueError: If wake_method is invalid or required parameters are missing.
"""
- # Store the name of this component
- self.component_name = "wind_farm"
-
- # Get the wake_method from h_dict
- wake_method = h_dict[self.component_name].get("wake_method", "dynamic")
+ # Get the wake_method from h_dict (use parameter before super sets self.component_name)
+ wake_method = h_dict[component_name].get("wake_method", "dynamic")
# Validate wake_method
if wake_method not in ["dynamic", "precomputed", "no_added_wakes"]:
@@ -63,12 +63,8 @@ def __init__(self, h_dict):
self.wake_method = wake_method
- # Store the type of this component (for backward compatibility)
- component_type = h_dict[self.component_name].get("component_type", "WindFarm")
- self.component_type = component_type
-
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
self.logger.info(f"Initializing WindFarm with wake_method='{self.wake_method}'")
@@ -116,6 +112,16 @@ def __init__(self, h_dict):
if col not in ["time", "time_utc"] and pd.api.types.is_numeric_dtype(df_wi[col]):
df_wi[col] = df_wi[col].astype(hercules_float_type)
+ # Check key columns for NaN values
+ ws_cols = sorted(col for col in df_wi.columns if col.startswith("ws_"))
+ nan_check_cols = [
+ c for c in ["time_utc", "wd_mean", "ws_mean"] + ws_cols if c in df_wi.columns
+ ]
+ if df_wi[nan_check_cols].isna().any().any():
+ raise ValueError(
+ "wind input file contains NaN values in required columns (time_utc, wd_mean, ws_*)"
+ )
+
# Make sure the df_wi contains a column called "time_utc"
if "time_utc" not in df_wi.columns:
raise ValueError("Wind input file must contain a column called 'time_utc'")
@@ -181,7 +187,7 @@ def __init__(self, h_dict):
self.logger.info("Interpolating wind input file...")
# Interpolate df_wi on to the time steps
- time_steps_all = np.arange(self.starttime, self.endtime, self.dt)
+ time_steps_all = np.arange(self.starttime, self.endtime, self.dt, dtype=hercules_float_type)
df_wi = interpolate_df(df_wi, time_steps_all)
# INITIALIZE FLORIS BASED ON WAKE MODEL
@@ -574,17 +580,17 @@ def get_initial_conditions_and_meta_data(self, h_dict):
Returns:
dict: Dictionary containing simulation parameters with initial conditions and meta data.
"""
- h_dict["wind_farm"]["n_turbines"] = self.n_turbines
- h_dict["wind_farm"]["capacity"] = self.capacity
- h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power
- h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0]
- h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0]
- h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers
- h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers)
+ h_dict[self.component_name]["n_turbines"] = self.n_turbines
+ h_dict[self.component_name]["capacity"] = self.capacity
+ h_dict[self.component_name]["rated_turbine_power"] = self.rated_turbine_power
+ h_dict[self.component_name]["wind_direction_mean"] = self.wd_mat_mean[0]
+ h_dict[self.component_name]["wind_speed_mean_background"] = self.ws_mat_mean[0]
+ h_dict[self.component_name]["turbine_powers"] = self.turbine_powers
+ h_dict[self.component_name]["power"] = np.sum(self.turbine_powers)
# Log the start time UTC if available
if hasattr(self, "starttime_utc"):
- h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc
+ h_dict[self.component_name]["starttime_utc"] = self.starttime_utc
return h_dict
@@ -674,6 +680,8 @@ def step(self, h_dict):
# Grab the instantaneous turbine power setpoint signal
turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"]
+ if np.any(np.isnan(turbine_power_setpoints)):
+ raise ValueError(f"{self.component_name}: turbine_power_setpoints contains NaN")
# Update wind speeds based on wake model
if self.wake_method == "dynamic":
@@ -1065,25 +1073,34 @@ def load_perffile(self, perffile):
for line in pfile:
# Read Blade Pitch Angles (degrees)
if "Pitch angle" in line:
- pitch_initial = np.array([float(x) for x in pfile.readline().strip().split()])
+ pitch_initial = np.array(
+ [float(x) for x in pfile.readline().strip().split()],
+ dtype=hercules_float_type,
+ )
pitch_initial_rad = pitch_initial / RAD2DEG
# Read Tip Speed Ratios (rad)
if "TSR" in line:
- TSR_initial = np.array([float(x) for x in pfile.readline().strip().split()])
+ TSR_initial = np.array(
+ [float(x) for x in pfile.readline().strip().split()],
+ dtype=hercules_float_type,
+ )
# Read Power Coefficients
if "Power" in line:
pfile.readline()
- Cp = np.empty((len(TSR_initial), len(pitch_initial)))
+ Cp = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type)
for tsr_i in range(len(TSR_initial)):
- Cp[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()])
+ Cp[tsr_i] = np.array(
+ [float(x) for x in pfile.readline().strip().split()],
+ dtype=hercules_float_type,
+ )
perffuncs["Cp"] = RegularGridInterpolator(
(TSR_initial, pitch_initial_rad), Cp, bounds_error=False, fill_value=None
)
# Obtain a lookup table to calculate optimal pitch for derated simulations
- cpgrid = np.linspace(0, 0.6, 100)
+ cpgrid = np.linspace(0, 0.6, 100, dtype=hercules_float_type)
optpitchdata = []
for cp in cpgrid:
optpitchrow = []
@@ -1103,9 +1120,12 @@ def load_perffile(self, perffile):
# Read Thrust Coefficients
if "Thrust" in line:
pfile.readline()
- Ct = np.empty((len(TSR_initial), len(pitch_initial)))
+ Ct = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type)
for tsr_i in range(len(TSR_initial)):
- Ct[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()])
+ Ct[tsr_i] = np.array(
+ [float(x) for x in pfile.readline().strip().split()],
+ dtype=hercules_float_type,
+ )
perffuncs["Ct"] = RegularGridInterpolator(
(TSR_initial, pitch_initial_rad), Ct, bounds_error=False, fill_value=None
)
@@ -1113,9 +1133,12 @@ def load_perffile(self, perffile):
# Read Torque Coefficients
if "Torque" in line:
pfile.readline()
- Cq = np.empty((len(TSR_initial), len(pitch_initial)))
+ Cq = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type)
for tsr_i in range(len(TSR_initial)):
- Cq[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()])
+ Cq[tsr_i] = np.array(
+ [float(x) for x in pfile.readline().strip().split()],
+ dtype=hercules_float_type,
+ )
perffuncs["Cq"] = RegularGridInterpolator(
(TSR_initial, pitch_initial_rad), Cq, bounds_error=False, fill_value=None
)
diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py
index 48e3cc96..9ae544d7 100644
--- a/hercules/plant_components/wind_farm_scada_power.py
+++ b/hercules/plant_components/wind_farm_scada_power.py
@@ -1,4 +1,7 @@
-# Unified wind farm model for Hercules supporting multiple wake modeling strategies.
+# WindFarmSCADAPower is a wind farm model that uses SCADA
+# power data to simulate wind farm performance.
+# Note it is limited to playing back the prerecorded wind turbine powers,
+# there is no option to control.
import numpy as np
import pandas as pd
@@ -10,27 +13,26 @@
class WindFarmSCADAPower(ComponentBase):
- """Wind farm model that uses SCADA power data to simulate wind farm performance."""
+ """Wind farm model that uses SCADA power data to simulate wind farm performance.
+ Note it is limited to playing back the prerecorded wind turbine powers,
+ there is no option to control.
+ """
- def __init__(self, h_dict):
+ component_category = "generator"
+
+ def __init__(self, h_dict, component_name):
"""Initialize the WindFarm class.
+
Args:
h_dict (dict): Dictionary containing simulation parameters.
+ component_name (str): Unique name for this instance (the YAML top-level key).
"""
- # Store the name of this component
- self.component_name = "wind_farm"
-
- self.component_type = "WindFarmSCADAPower"
-
- # Call the base class init
- super().__init__(h_dict, self.component_name)
+ # Call the base class init (sets self.component_name and self.component_type)
+ super().__init__(h_dict, component_name)
self.logger.info("Initializing WindFarmSCADAPower")
- # Track the number of FLORIS calculations
- self.num_floris_calcs = 0
-
# Read in the input file names
self.scada_filename = h_dict[self.component_name]["scada_filename"]
@@ -48,6 +50,16 @@ def __init__(self, h_dict):
self.logger.info("Checking SCADA file...")
+ # Check key columns for NaN values
+ pow_cols = sorted(col for col in df_scada.columns if col.startswith("pow_"))
+ if not pow_cols:
+ raise ValueError(
+ "SCADA file must contain at least one power column with name starting with 'pow_'."
+ )
+ nan_check_cols = [c for c in ["time_utc"] + pow_cols if c in df_scada.columns]
+ if df_scada[nan_check_cols].isna().any().any():
+ raise ValueError("SCADA file contains NaN values in required columns (time_utc, pow_*)")
+
# Make sure the df_scada contains a column called "time_utc"
if "time_utc" not in df_scada.columns:
raise ValueError("SCADA file must contain a column called 'time_utc'")
@@ -115,7 +127,7 @@ def __init__(self, h_dict):
self.logger.info("Interpolating SCADA file...")
# Interpolate df_scada on to the time steps
- time_steps_all = np.arange(self.starttime, self.endtime, self.dt)
+ time_steps_all = np.arange(self.starttime, self.endtime, self.dt, dtype=hercules_float_type)
df_scada = interpolate_df(df_scada, time_steps_all)
# Get a list of power columns and infer number of turbines
@@ -186,11 +198,8 @@ def __init__(self, h_dict):
self.logger.info(f"Inferred rated turbine power: {self.rated_turbine_power}")
self.logger.info(f"Inferred capacity: {self.capacity / 1e3} MW")
- # Initialize the turbine array
- self.turbine_array = TurbineUpdateModelVectorizedSCADA(self.dt, self.scada_powers[0, :])
-
# Initialize the turbine powers to the starting row
- self.turbine_powers = self.turbine_array.prev_powers.copy()
+ self.turbine_powers = self.scada_powers[0, :].copy()
def get_initial_conditions_and_meta_data(self, h_dict):
"""Add any initial conditions or meta data to the h_dict.
@@ -204,17 +213,17 @@ def get_initial_conditions_and_meta_data(self, h_dict):
Returns:
dict: Dictionary containing simulation parameters with initial conditions and meta data.
"""
- h_dict["wind_farm"]["n_turbines"] = self.n_turbines
- h_dict["wind_farm"]["capacity"] = self.capacity
- h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power
- h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0]
- h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0]
- h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers
- h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers)
+ h_dict[self.component_name]["n_turbines"] = self.n_turbines
+ h_dict[self.component_name]["capacity"] = self.capacity
+ h_dict[self.component_name]["rated_turbine_power"] = self.rated_turbine_power
+ h_dict[self.component_name]["wind_direction_mean"] = self.wd_mat_mean[0]
+ h_dict[self.component_name]["wind_speed_mean_background"] = self.ws_mat_mean[0]
+ h_dict[self.component_name]["turbine_powers"] = self.turbine_powers
+ h_dict[self.component_name]["power"] = np.sum(self.turbine_powers)
# Log the start time UTC if available
if hasattr(self, "starttime_utc"):
- h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc
+ h_dict[self.component_name]["starttime_utc"] = self.starttime_utc
return h_dict
@@ -226,7 +235,7 @@ def step(self, h_dict):
Args:
h_dict (dict): Dictionary containing current simulation state including
- step number and power_setpoint values for each turbine.
+ step number
Returns:
dict: Updated simulation dictionary with wind farm outputs including
@@ -237,9 +246,6 @@ def step(self, h_dict):
if self.verbose:
self.logger.info(f"step = {step} (of {self.n_steps})")
- # Grab the instantaneous turbine power setpoint signal
- turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"]
-
# Update wind speeds based on wake model
# No wake modeling - use background speeds directly
@@ -249,10 +255,7 @@ def step(self, h_dict):
# Update the turbine powers (common for all wake models)
# Vectorized calculation for all turbines at once
- self.turbine_powers = self.turbine_array.step(
- self.scada_powers[step, :],
- turbine_power_setpoints,
- )
+ self.turbine_powers = self.scada_powers[step, :].copy()
# Update instantaneous wind direction and wind speed
self.wind_direction_mean = self.wd_mat_mean[step]
@@ -261,7 +264,6 @@ def step(self, h_dict):
# Update the h_dict with outputs
h_dict[self.component_name]["power"] = np.sum(self.turbine_powers)
h_dict[self.component_name]["turbine_powers"] = self.turbine_powers
- h_dict[self.component_name]["turbine_power_setpoints"] = turbine_power_setpoints
h_dict[self.component_name]["wind_direction_mean"] = self.wind_direction_mean
h_dict[self.component_name]["wind_speed_mean_background"] = self.wind_speed_mean_background
h_dict[self.component_name]["wind_speed_mean_withwakes"] = np.mean(
@@ -271,67 +273,3 @@ def step(self, h_dict):
h_dict[self.component_name]["wind_speeds_background"] = self.wind_speeds_background
return h_dict
-
-
-class TurbineUpdateModelVectorizedSCADA:
- """Vectorized wind turbine update model for power output simulation."""
-
- def __init__(self, dt, initial_scada_powers):
- """Initialize the vectorized turbine model.
-
- Args:
- dt (float): Time step for the simulation in seconds.
- initial_scada_powers (np.ndarray): Initial SCADA power values in kW for all turbines
- to initialize the simulation.
- """
- # Save the time step
- self.dt = dt
-
- # Number of turbines
- self.n_turbines = len(initial_scada_powers)
-
- # Initialize the previous powers for all turbines
- self.prev_powers = initial_scada_powers.copy()
-
- print("Filtering not yet implemented for SCADA-based turbine model. Use with caution.")
-
- def step(self, scada_powers, power_setpoints):
- """Simulate a single time step for all wind turbines simultaneously.
-
- This method calculates the power output of all wind turbines based on the
- given wind speeds and power setpoints.
-
- Args:
- scada_powers (np.ndarray): Current SCADA powers for all turbines.
- power_setpoints (np.ndarray): Maximum allowable power outputs in kW for all turbines.
-
- Returns:
- np.ndarray: Calculated power outputs of all wind turbines, constrained
- by the power setpoints.
- """
-
- # Vectorized limiting: current power not greater than power_setpoint
- instant_powers = np.minimum(scada_powers, power_setpoints)
-
- # Vectorized limiting: instant power not less than 0
- instant_powers = np.maximum(instant_powers, 0.0)
-
- # Handle NaNs by replacing with previous power values
- nan_mask = np.isnan(instant_powers)
- if np.any(nan_mask):
- instant_powers[nan_mask] = self.prev_powers[nan_mask]
-
- # Simple update without any filtering
- powers = instant_powers
-
- # Vectorized limiting: power not greater than power_setpoint
- powers = np.minimum(powers, power_setpoints)
-
- # Vectorized limiting: power not less than 0
- powers = np.maximum(powers, 0.0)
-
- # Update the previous powers for all turbines
- self.prev_powers = powers.copy()
-
- # Return the powers
- return powers
diff --git a/hercules/resource/upsample_wind_data.py b/hercules/resource/upsample_wind_data.py
index 228ce0a2..c53314a6 100644
--- a/hercules/resource/upsample_wind_data.py
+++ b/hercules/resource/upsample_wind_data.py
@@ -147,7 +147,9 @@ def _generate_uncorrelated_kaimal_stochastic_turbulence(
fs = 1.0 / timestep # Sampling frequency
- freqs = np.arange(0.0, 0.5 * fs + 0.5 * fs / N_samples, fs / N_samples) # Frequency array
+ freqs = np.arange(
+ 0.0, 0.5 * fs + 0.5 * fs / N_samples, fs / N_samples, dtype=hercules_float_type
+ ) # Frequency array
freq_mat = np.zeros((N_points, N_samples), dtype=hercules_complex_type) # Matrix of frequencies
@@ -261,9 +263,10 @@ def upsample_wind_data(
(fraction). Defaults to 0.1.
TI_ws_ref (float, optional): Reference wind speed at which the reference TI TI_ref is
defined (m/s). Defaults to 8 m/s.
- save_individual_wds (bool, optional): If True, upsampled wind directions will be saved
- in the output for each upsampled location. If False, only the mean wind direction
- over all locations will be saved. Defaults to False.
+ save_individual_wds (bool, optional): If True, upsampled wind directions for each
+ individual location will be saved in the output in addition to the mean wind
+ direction. If False, only the mean wind direction over all locations will be saved.
+ Defaults to False.
Returns:
pd.DataFrame: DataFrame containing the wind speeds and wind directions at each
@@ -406,12 +409,11 @@ def upsample_wind_data(
ws_cols = [f"ws_{i:03}" for i in range(N_locs_upsample)]
df_upsample = pd.DataFrame(ws_interp_upsample.T, columns=ws_cols)
- # Either save wind directions for each location or mean wind direction over all locations
+ # Always save mean wind direction; optionally save individual wind directions per location
+ df_upsample["wd_mean"] = wd_interp_upsample_mean
if save_individual_wds:
wd_cols = [f"wd_{i:03}" for i in range(N_locs_upsample)]
df_upsample[wd_cols] = wd_interp_upsample.T
- else:
- df_upsample["wd_mean"] = wd_interp_upsample_mean
# Convert numeric columns to float32 for memory efficiency
for c in df_upsample.columns:
@@ -429,7 +431,7 @@ def upsample_wind_data(
# Order columns by location
if save_individual_wds:
df_upsample = df_upsample[
- ["time", "time_utc"] + list(chain.from_iterable(zip(ws_cols, wd_cols)))
+ ["time", "time_utc", "wd_mean"] + list(chain.from_iterable(zip(ws_cols, wd_cols)))
]
else:
df_upsample = df_upsample[["time", "time_utc", "wd_mean"] + ws_cols]
diff --git a/hercules/utilities.py b/hercules/utilities.py
index d4fb47da..401e15bd 100644
--- a/hercules/utilities.py
+++ b/hercules/utilities.py
@@ -16,52 +16,6 @@
hercules_complex_type = np.csingle
-def get_available_component_names():
- """Return available component names.
-
- Returns:
- list: Available plant component names.
- """
- return [
- "wind_farm",
- "solar_farm",
- "battery",
- "electrolyzer",
- ]
-
-
-def get_available_generator_names():
- """Return available generator component names.
-
- Returns power generators (wind_farm, solar_farm), excluding storage and conversion
- components.
-
- Returns:
- list: Available generator component names.
- """
- return [
- "wind_farm",
- "solar_farm",
- ]
-
-
-def get_available_component_types():
- """Return available component types by component.
-
- Returns:
- dict: Component names mapped to available simulation types.
- """
- return {
- "wind_farm": [
- "WindFarm",
- "WindFarmSCADAPower",
- ],
- "solar_farm": ["SolarPySAMPVWatts"],
- "battery": ["BatterySimple", "BatteryLithiumIon"],
- "electrolyzer": ["ElectrolyzerPlant"],
- }
-
-
class Loader(yaml.SafeLoader):
"""Custom YAML loader supporting !include tags.
@@ -257,8 +211,10 @@ def load_hercules_input(filename):
# Define valid keys
required_keys = ["dt", "starttime_utc", "endtime_utc", "plant"]
- component_names = get_available_component_names()
- component_types = get_available_component_types()
+ # Lazy import to avoid circular dependency
+ from hercules.component_registry import VALID_COMPONENT_TYPES
+
+ valid_component_types = list(VALID_COMPONENT_TYPES)
other_keys = [
"name",
"description",
@@ -272,6 +228,11 @@ def load_hercules_input(filename):
"output_buffer_size",
]
+ # Discover component entries: any top-level dict entry containing "component_type"
+ component_names = [
+ key for key, val in h_dict.items() if isinstance(val, dict) and "component_type" in val
+ ]
+
# Validate required keys
for key in required_keys:
if key not in h_dict:
@@ -307,20 +268,23 @@ def load_hercules_input(filename):
if not isinstance(h_dict["plant"]["interconnect_limit"], (float, int)):
raise ValueError(f"Interconnect limit must be a float in input file {filename}")
- # Validate all keys are valid
+ # Validate all keys are valid: required, known other keys, or a discovered component entry
for key in h_dict:
if key not in required_keys + component_names + other_keys:
- raise ValueError(f'Key "{key}" not a valid key in input file {filename}')
+ raise ValueError(
+ f'Key "{key}" is not a recognised key in input file {filename}. '
+ "If this is a plant component, ensure it contains a 'component_type' field."
+ )
# Disallow pre-defined start/end; derive from UTC + dt policy
if ("starttime" in h_dict) or ("endtime" in h_dict):
raise ValueError("starttime/endtime must not be provided; they are derived from *_utc")
- # Validate component structures
+ # Validate component structures (component_names entries already have component_type,
+ # but we still check each is a dict for safety)
for key in component_names:
- if key in h_dict:
- if not isinstance(h_dict[key], dict):
- raise ValueError(f"{key} must be a dictionary in input file {filename}")
+ if not isinstance(h_dict[key], dict):
+ raise ValueError(f"{key} must be a dictionary in input file {filename}")
# Set verbose default and validate
if "verbose" not in h_dict:
@@ -335,21 +299,18 @@ def load_hercules_input(filename):
# Validate no components have verbose key
for key in component_names:
- if key in h_dict and "verbose" in h_dict[key]:
+ if "verbose" in h_dict[key]:
raise ValueError(f"{key} cannot include a verbose key in input file {filename}")
- # Validate component types
+ # Validate component types (component_type presence already guaranteed by discovery)
for key in component_names:
- if key in h_dict:
- if "component_type" not in h_dict[key]:
- raise ValueError(
- f"{key} must include a component_type key in input file {filename}"
- )
- if h_dict[key]["component_type"] not in component_types[key]:
- raise ValueError(
- f"{key} has an invalid component_type {h_dict[key]['component_type']} "
- f"in input file {filename}"
- )
+ if h_dict[key]["component_type"] not in valid_component_types:
+ raise ValueError(
+ f'"{key}" has an unrecognized component_type '
+ f'"{h_dict[key]["component_type"]}" in input file {filename}. '
+ f"Available types: {sorted(valid_component_types)}"
+ "(Did you forget to add a new component_type to the COMPONENT_REGISTRY?)"
+ )
# Handle external_data structure normalization
diff --git a/hercules/utilities_examples.py b/hercules/utilities_examples.py
index 7c85e457..ba3cf984 100644
--- a/hercules/utilities_examples.py
+++ b/hercules/utilities_examples.py
@@ -13,6 +13,7 @@ def generate_example_inputs():
produced under `examples/inputs`:
- examples/inputs/00_generate_wind_history_small.py -> wind_input_small.ftr
+ and scada_input.ftr
- examples/inputs/01_generate_wind_history_large.py -> wind_input_large.ftr
- examples/inputs/02_generate_solar_history.py -> solar_input.ftr
@@ -27,6 +28,7 @@ def generate_example_inputs():
inputs_dir / "00_generate_wind_history_small.py",
inputs_dir / "01_generate_wind_history_large.py",
inputs_dir / "02_generate_solar_history.py",
+ inputs_dir / "07_generate_scada_power_playback.py",
]
for script_path in scripts:
@@ -41,6 +43,7 @@ def ensure_example_inputs_exist():
- wind_input_small.ftr
- wind_input_large.ftr
- solar_input.ftr
+ - power_playback_input.ftr
If any are missing, all example inputs will be (re)generated by calling
`generate_example_inputs`.
@@ -53,6 +56,8 @@ def ensure_example_inputs_exist():
inputs_dir / "wind_input_small.ftr",
inputs_dir / "wind_input_large.ftr",
inputs_dir / "solar_input.ftr",
+ inputs_dir / "scada_input.ftr",
+ inputs_dir / "power_playback_input.ftr",
]
if not all(p.exists() for p in expected_files):
diff --git a/pyproject.toml b/pyproject.toml
index dfea8449..c4ca7d1c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "hercules"
-version = "2"
+version = "2.1"
description = "Hybrid plant emulation tool."
readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
authors = [
{ name = "Genevieve Starke", email = "Genevieve.Starke@nlr.gov" },
{ name = "Michael (Misha) Sinner", email = "Michael.Sinner@nlr.gov" },
@@ -29,7 +29,7 @@ dependencies = [
"matplotlib~=3.8",
"pandas~=2.0",
"floris~=4.3",
-"nrel-pysam~=6.0",
+"nrel-pysam>=6,<8",
"jupyter",
"netCDF4",
"rainflow~=3.2",
@@ -42,7 +42,8 @@ dependencies = [
"cartopy",
"openmeteo_requests",
"requests_cache",
-"retry_requests"
+"retry_requests",
+"electrolyzer"
]
[project.optional-dependencies]
diff --git a/tests/battery_simple_test.py b/tests/battery_simple_test.py
index b4318d70..6612c47d 100644
--- a/tests/battery_simple_test.py
+++ b/tests/battery_simple_test.py
@@ -11,12 +11,12 @@
def create_simple_battery():
test_h_dict = copy.deepcopy(h_dict_simple_battery)
- return BatterySimple(test_h_dict)
+ return BatterySimple(test_h_dict, "battery")
def create_LIB():
test_h_dict = copy.deepcopy(h_dict_lib_battery)
- return BatteryLithiumIon(test_h_dict)
+ return BatteryLithiumIon(test_h_dict, "battery")
@pytest.fixture
@@ -40,7 +40,7 @@ def step_inputs(P_avail, P_signal):
def test_SB_init():
test_h_dict = copy.deepcopy(h_dict_simple_battery)
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
assert SB.dt == test_h_dict["dt"]
assert SB.SOC == test_h_dict["battery"]["initial_conditions"]["SOC"]
@@ -67,7 +67,7 @@ def test_SB_init():
test_h_dict2["battery"]["usage_calc_interval"] = 100
test_h_dict2["battery"]["usage_lifetime"] = 0.1
test_h_dict2["battery"]["usage_cycles"] = 10
- SB = BatterySimple(test_h_dict2)
+ SB = BatterySimple(test_h_dict2, "battery")
assert SB.eta_charge == np.sqrt(0.9)
assert SB.eta_discharge == np.sqrt(0.9)
assert SB.tau_self_discharge == 100
@@ -121,7 +121,7 @@ def test_SB_step(SB: BatterySimple):
def test_LI_init():
"""Test init"""
test_h_dict = copy.deepcopy(h_dict_lib_battery)
- LI = BatteryLithiumIon(test_h_dict)
+ LI = BatteryLithiumIon(test_h_dict, "battery")
assert LI.dt == test_h_dict["dt"]
assert LI.SOC == test_h_dict["battery"]["initial_conditions"]["SOC"]
assert LI.SOC_min == test_h_dict["battery"]["min_SOC"]
@@ -134,7 +134,7 @@ def test_LI_init():
def test_LI_post_init():
test_h_dict = copy.deepcopy(h_dict_lib_battery)
- LI = BatteryLithiumIon(test_h_dict)
+ LI = BatteryLithiumIon(test_h_dict, "battery")
assert LI.SOH == 1
assert LI.T == 25
assert LI.x == 0
@@ -267,7 +267,7 @@ def test_allow_grid_power_consumption(SB: BatterySimple):
# Test with allow_grid_power_consumption = True
test_h_dict = copy.deepcopy(h_dict_simple_battery)
test_h_dict["battery"]["allow_grid_power_consumption"] = True
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
# Ask exceeds rated power
out = SB.step(step_inputs(P_avail=3e3, P_signal=2.5e3))
@@ -275,7 +275,7 @@ def test_allow_grid_power_consumption(SB: BatterySimple):
assert out["battery"]["reject"] == 0.5e3
test_h_dict["battery"]["allow_grid_power_consumption"] = False
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
out = SB.step(step_inputs(P_avail=3e3, P_signal=2.5e3))
assert out["battery"]["power"] == 2e3
@@ -287,13 +287,13 @@ def test_allow_grid_power_consumption(SB: BatterySimple):
# Ask is under rated power
test_h_dict["battery"]["allow_grid_power_consumption"] = True
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
out = SB.step(step_inputs(P_avail=0.25e3, P_signal=1e3))
assert out["battery"]["power"] == 1e3 # Ignores P_avail, as expected
assert out["battery"]["reject"] == 0
test_h_dict["battery"]["allow_grid_power_consumption"] = False
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
out = SB.step(step_inputs(P_avail=0.25e3, P_signal=1e3))
assert out["battery"]["power"] == 0.25e3 # Uses P_avail
assert out["battery"]["reject"] == 0.75e3 # "Rejects" the rest of the signal ask
@@ -310,7 +310,7 @@ def test_SB_roundtrip_efficiency():
test_h_dict["battery"]["roundtrip_efficiency"] = 0.9
test_h_dict["battery"]["allow_grid_power_consumption"] = True
test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
# Verify efficiency parameters are set correctly
assert_almost_equal(SB.eta_charge, np.sqrt(0.9), 6)
@@ -371,7 +371,7 @@ def test_SB_roundtrip_efficiency_perfect():
test_h_dict["battery"]["roundtrip_efficiency"] = 1.0
test_h_dict["battery"]["allow_grid_power_consumption"] = True
test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
# Verify perfect efficiency
assert SB.eta_charge == 1.0
@@ -406,7 +406,7 @@ def test_SB_roundtrip_efficiency_various_values():
test_h_dict["battery"]["roundtrip_efficiency"] = rte
test_h_dict["battery"]["allow_grid_power_consumption"] = True
test_h_dict["battery"]["initial_conditions"]["SOC"] = 0.5 # Start at middle SOC
- SB = BatterySimple(test_h_dict)
+ SB = BatterySimple(test_h_dict, "battery")
# Small charge-discharge cycle to avoid SOC limits
initial_energy = SB.current_batt_state
diff --git a/tests/electrolyzer_plant_test.py b/tests/electrolyzer_plant_test.py
index 634e4f2e..aa1b524a 100644
--- a/tests/electrolyzer_plant_test.py
+++ b/tests/electrolyzer_plant_test.py
@@ -8,7 +8,7 @@
def test_allow_grid_power_consumption():
# Test with allow_grid_power_consumption = False
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs = {
"plant": {
@@ -25,7 +25,7 @@ def test_allow_grid_power_consumption():
# Match locally available power
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs["electrolyzer"]["electrolyzer_signal"] = 3000
for _ in range(100): # Run 100 steps
out = electrolyzer.step(step_inputs)
@@ -35,7 +35,7 @@ def test_allow_grid_power_consumption():
# Ask exceeds locally available power
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs["electrolyzer"]["electrolyzer_signal"] = 4000
for _ in range(100): # Run 100 steps
out = electrolyzer.step(step_inputs)
@@ -45,7 +45,7 @@ def test_allow_grid_power_consumption():
# Now, allow grid charging and repeat tests
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs["electrolyzer"]["electrolyzer_signal"] = 2000
for _ in range(100): # Run 100 steps
@@ -54,7 +54,7 @@ def test_allow_grid_power_consumption():
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs["electrolyzer"]["electrolyzer_signal"] = 3000
for _ in range(100): # Run 100 steps
out = electrolyzer.step(step_inputs)
@@ -63,7 +63,7 @@ def test_allow_grid_power_consumption():
test_h_dict = copy.deepcopy(h_dict_electrolyzer)
test_h_dict["electrolyzer"]["allow_grid_power_consumption"] = True
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
step_inputs["electrolyzer"]["electrolyzer_signal"] = 4000
for _ in range(100): # Run 100 steps
out = electrolyzer.step(step_inputs)
diff --git a/tests/hard_coal_steam_turbine_test.py b/tests/hard_coal_steam_turbine_test.py
new file mode 100644
index 00000000..11484ef5
--- /dev/null
+++ b/tests/hard_coal_steam_turbine_test.py
@@ -0,0 +1,107 @@
+import copy
+
+import numpy as np
+from hercules.plant_components.hard_coal_steam_turbine import HardCoalSteamTurbine
+from hercules.utilities import hercules_float_type
+
+from .test_inputs.h_dict import (
+ h_dict_hard_coal_steam_turbine,
+)
+
+
+def test_init_from_dict():
+ """Test that HardCoalSteamTurbine can be initialized from a dictionary."""
+ hcst = HardCoalSteamTurbine(
+ copy.deepcopy(h_dict_hard_coal_steam_turbine), "hard_coal_steam_turbine"
+ )
+ assert hcst is not None
+
+
+def test_default_inputs():
+ """Test that HardCoalSteamTurbine uses default inputs when not provided."""
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+
+ # Test that the ramp_rate_fraction input is correct from input dict
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.ramp_rate_fraction == 0.04
+
+ # Test that the run_up_rate_fraction input is correct from input dict
+ assert hcst.run_up_rate_fraction == 0.02
+
+ # Test that if the run_up_rate_fraction is not provided,
+ # it defaults to the ramp_rate_fraction
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.run_up_rate_fraction == hcst.ramp_rate_fraction
+
+ # Now test that the default value of the ramp_rate_fraction is
+ # applied to both the ramp_rate_fraction and the run_up_rate_fraction
+ # if they are both not provided
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["ramp_rate_fraction"]
+ del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.ramp_rate_fraction == 0.03
+ assert hcst.run_up_rate_fraction == 0.03
+
+ # Test the remaining default values
+ # Delete startup times first, since changing min_stable_load_fraction and
+ # ramp rates affects ramp_time validation against startup times
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["ramp_rate_fraction"]
+ del h_dict["hard_coal_steam_turbine"]["run_up_rate_fraction"]
+ del h_dict["hard_coal_steam_turbine"]["cold_startup_time"]
+ del h_dict["hard_coal_steam_turbine"]["warm_startup_time"]
+ del h_dict["hard_coal_steam_turbine"]["hot_startup_time"]
+ del h_dict["hard_coal_steam_turbine"]["min_stable_load_fraction"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.min_stable_load_fraction == 0.30
+ assert hcst.hot_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds
+ assert hcst.warm_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds
+ assert hcst.cold_startup_time == 7.5 * 60.0 * 60.0 # 7.5 hours in seconds
+
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["min_up_time"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.min_up_time == 48 * 60.0 * 60.0 # 48 hours in seconds
+
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["min_down_time"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.min_down_time == 48 * 60.0 * 60.0 # 48 hours in seconds
+
+
+def test_default_hhv():
+ """Test that HardCoalSteamTurbine provides default HHV for bituminous coal from [4]."""
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["hhv"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.hhv == 29310000000
+
+
+def test_default_fuel_density():
+ """Test that HardCoalSteamTurbine provides default fuel density."""
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ if "fuel_density" in h_dict["hard_coal_steam_turbine"]:
+ del h_dict["hard_coal_steam_turbine"]["fuel_density"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ assert hcst.fuel_density == 1000.0
+
+
+def test_default_efficiency_table():
+ """Test that HardCoalSteamTurbine provides default HHV net efficiency table.
+
+ Default values are taken from [2,3]
+ """
+ h_dict = copy.deepcopy(h_dict_hard_coal_steam_turbine)
+ del h_dict["hard_coal_steam_turbine"]["efficiency_table"]
+ hcst = HardCoalSteamTurbine(h_dict, "hard_coal_steam_turbine")
+ np.testing.assert_array_equal(
+ hcst.efficiency_power_fraction,
+ np.array([0.3, 0.5, 1.0], dtype=hercules_float_type),
+ )
+ np.testing.assert_array_equal(
+ hcst.efficiency_values,
+ np.array([0.30, 0.32, 0.35], dtype=hercules_float_type),
+ )
diff --git a/tests/hybrid_plant_test.py b/tests/hybrid_plant_test.py
index 8e1754b7..bfa09f27 100644
--- a/tests/hybrid_plant_test.py
+++ b/tests/hybrid_plant_test.py
@@ -115,3 +115,55 @@ def test_add_plant_metadata_to_h_dict():
assert "component_names" in result
assert "generator_names" in result
assert "n_components" in result
+
+
+def test_component_category_attributes():
+ """Test that component objects expose the correct component_category class attribute."""
+ hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind_solar_battery))
+
+ assert hp.component_objects["wind_farm"].component_category == "generator"
+ assert hp.component_objects["solar_farm"].component_category == "generator"
+ assert hp.component_objects["battery"].component_category == "storage"
+
+
+def test_component_type_auto_set():
+ """Test that component_type is automatically derived from the class name."""
+ hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind_solar_battery))
+
+ assert hp.component_objects["wind_farm"].component_type == "WindFarm"
+ assert hp.component_objects["solar_farm"].component_type == "SolarPySAMPVWatts"
+ assert hp.component_objects["battery"].component_type == "BatterySimple"
+
+
+def test_multi_instance_batteries():
+ """Test that two BatterySimple instances with unique names can coexist in one plant."""
+ battery_cfg = {
+ "component_type": "BatterySimple",
+ "energy_capacity": 100.0,
+ "charge_rate": 50.0,
+ "discharge_rate": 50.0,
+ "max_SOC": 0.9,
+ "min_SOC": 0.1,
+ "log_channels": ["power"],
+ "initial_conditions": {"SOC": 0.5},
+ }
+ multi_battery_h_dict = copy.deepcopy(h_dict_battery)
+ multi_battery_h_dict["battery_unit_2"] = copy.deepcopy(battery_cfg)
+
+ hp = hybrid_plant.HybridPlant(multi_battery_h_dict)
+
+ assert hp.n_components == 2
+ assert "battery" in hp.component_names
+ assert "battery_unit_2" in hp.component_names
+ # Each instance carries its unique component_name
+ assert hp.component_objects["battery"].component_name == "battery"
+ assert hp.component_objects["battery_unit_2"].component_name == "battery_unit_2"
+ # Both have battery category → neither is a generator
+ assert len(hp.generator_names) == 0
+
+
+def test_custom_component_name_passed_through():
+ """Test that the YAML key becomes the component_name on the instantiated object."""
+ hp = hybrid_plant.HybridPlant(copy.deepcopy(h_dict_wind))
+
+ assert hp.component_objects["wind_farm"].component_name == "wind_farm"
diff --git a/tests/open_cycle_gas_turbine_test.py b/tests/open_cycle_gas_turbine_test.py
new file mode 100644
index 00000000..c3dff7a9
--- /dev/null
+++ b/tests/open_cycle_gas_turbine_test.py
@@ -0,0 +1,111 @@
+import copy
+
+import numpy as np
+from hercules.plant_components.open_cycle_gas_turbine import OpenCycleGasTurbine
+from hercules.utilities import hercules_float_type
+
+from .test_inputs.h_dict import (
+ h_dict_open_cycle_gas_turbine,
+)
+
+
+def test_init_from_dict():
+ """Test that OpenCycleGasTurbine can be initialized from a dictionary."""
+ ocgt = OpenCycleGasTurbine(
+ copy.deepcopy(h_dict_open_cycle_gas_turbine), "open_cycle_gas_turbine"
+ )
+ assert ocgt is not None
+
+
+def test_default_inputs():
+ """Test that OpenCycleGasTurbine uses default inputs when not provided."""
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+
+ # Test that the ramp_rate_fraction is 0.5 (from test fixture)
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ assert ocgt.ramp_rate_fraction == 0.5
+
+ # Test that the run_up_rate_fraction is 0.2 (from test fixture)
+ 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
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ 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
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ 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, "open_cycle_gas_turbine")
+ 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
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["ramp_rate_fraction"]
+ del h_dict["open_cycle_gas_turbine"]["run_up_rate_fraction"]
+ 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, "open_cycle_gas_turbine")
+ 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
+
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["min_up_time"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ assert ocgt.min_up_time == 30 * 60.0
+
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["min_down_time"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ assert ocgt.min_down_time == 60 * 60.0
+
+
+def test_default_hhv():
+ """Test that OpenCycleGasTurbine provides default HHV for natural gas from [6]."""
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["hhv"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ # Default HHV for natural gas is 39.05 MJ/m³ = 39,050,000 J/m³ from [6]
+ assert ocgt.hhv == 39050000
+
+
+def test_default_fuel_density():
+ """Test that OpenCycleGasTurbine provides default fuel density from [6]."""
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ if "fuel_density" in h_dict["open_cycle_gas_turbine"]:
+ del h_dict["open_cycle_gas_turbine"]["fuel_density"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ # Default fuel density for natural gas is 0.768 kg/m³ from [6]
+ assert ocgt.fuel_density == 0.768
+
+
+def test_default_efficiency_table():
+ """Test that OpenCycleGasTurbine provides default HHV net efficiency table from [5].
+
+ Default values are approximate readings from the SC1A curve in
+ Exhibit ES-4 of [5].
+ """
+ h_dict = copy.deepcopy(h_dict_open_cycle_gas_turbine)
+ del h_dict["open_cycle_gas_turbine"]["efficiency_table"]
+ ocgt = OpenCycleGasTurbine(h_dict, "open_cycle_gas_turbine")
+ # Default HHV net plant efficiency from SC1A curve in Exhibit ES-4 of [5]
+ np.testing.assert_array_equal(
+ ocgt.efficiency_power_fraction,
+ np.array([0.25, 0.50, 0.75, 1.0], dtype=hercules_float_type),
+ )
+ np.testing.assert_array_equal(
+ ocgt.efficiency_values,
+ np.array([0.245, 0.325, 0.37, 0.39], dtype=hercules_float_type),
+ )
diff --git a/tests/power_playback_test.py b/tests/power_playback_test.py
new file mode 100644
index 00000000..7448abf6
--- /dev/null
+++ b/tests/power_playback_test.py
@@ -0,0 +1,83 @@
+"""Tests for the PowerPlayback class."""
+
+import copy
+import os
+import tempfile
+
+import numpy as np
+import pandas as pd
+import pytest
+from hercules.plant_components.power_playback import PowerPlayback
+
+from tests.test_inputs.h_dict import h_dict_power_playback
+
+# Create a base test dictionary for PowerPlayback
+h_dict_power_playback = copy.deepcopy(h_dict_power_playback)
+
+
+def test_power_playback_initialization():
+ """Test that PowerPlayback initializes correctly with valid inputs."""
+ power_playback = PowerPlayback(h_dict_power_playback, "power_playback")
+
+ assert power_playback.component_name == "power_playback"
+ assert power_playback.component_type == "PowerPlayback"
+ assert power_playback.component_category == "generator"
+ assert power_playback.dt == 1.0
+ assert power_playback.starttime == 0.0
+ assert power_playback.endtime == 10.0
+
+
+def test_power_playback_step():
+ """Test that the step method works correctly."""
+ power_playback = PowerPlayback(h_dict_power_playback, "power_playback")
+
+ step_h_dict = {"step": 0}
+ step_h_dict["power_playback"] = {}
+
+ result = power_playback.step(step_h_dict)
+
+ # Verify outputs exist
+ assert "power" in result["power_playback"]
+
+ # Verify power
+ assert np.isclose(result["power_playback"]["power"], 1000.0)
+
+ # Step one more time
+ step_h_dict["step"] = 1
+ result = power_playback.step(step_h_dict)
+
+ # Verify power
+ assert np.isclose(result["power_playback"]["power"], 2000.0)
+
+
+def test_power_playback_raises_on_nan_in_power_columns():
+ """Test that PowerPlayback raises ValueError when power column contain NaN."""
+ scada_data = {
+ "time_utc": [
+ "2023-01-01T00:00:00Z",
+ "2023-01-01T00:00:01Z",
+ "2023-01-01T00:00:02Z",
+ "2023-01-01T00:00:03Z",
+ "2023-01-01T00:00:04Z",
+ ],
+ "power": [2500.0, np.nan, 4000.0, 4500.0, 5000.0],
+ }
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
+ pd.DataFrame(scada_data).to_csv(f.name, index=False)
+ temp_scada_file = f.name
+
+ try:
+ test_h_dict = copy.deepcopy(h_dict_power_playback)
+ test_h_dict["power_playback"]["scada_filename"] = temp_scada_file
+ test_h_dict["starttime"] = 0.0
+ test_h_dict["endtime"] = 4.0
+ test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z"
+ test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z"
+ test_h_dict["dt"] = 1.0
+
+ with pytest.raises(ValueError, match="SCADA file contains NaN values"):
+ PowerPlayback(test_h_dict, "power_playback")
+ finally:
+ if os.path.exists(temp_scada_file):
+ os.unlink(temp_scada_file)
diff --git a/tests/regression_tests/battery_regression_test.py b/tests/regression_tests/battery_regression_test.py
index 46005039..6c2a467a 100644
--- a/tests/regression_tests/battery_regression_test.py
+++ b/tests/regression_tests/battery_regression_test.py
@@ -130,7 +130,7 @@
def test_SimpleBattery_regression_():
- battery = BatterySimple(test_h_dict)
+ battery = BatterySimple(test_h_dict, "battery")
times_test = np.arange(0, 5.5, test_h_dict["dt"])
powers_test = np.zeros_like(times_test)
@@ -162,7 +162,7 @@ def test_SimpleBattery_regression_():
def test_LIB_regression_():
- battery = BatteryLithiumIon(test_h_dict)
+ battery = BatteryLithiumIon(test_h_dict, "battery")
times_test = np.arange(0, 5.5, test_h_dict["dt"])
powers_test = np.zeros_like(times_test)
@@ -210,7 +210,7 @@ def test_SimpleBattery_usage_calc_regression():
battery_dict["battery"]["usage_cycles"] = 5
battery_dict["battery"]["initial_conditions"] = {"SOC": 0.23}
- SB = BatterySimple(battery_dict)
+ SB = BatterySimple(battery_dict, "battery")
power_avail = 10e3 * np.ones(21)
power_signal = [
diff --git a/tests/regression_tests/electrolyzer_plant_regression_test.py b/tests/regression_tests/electrolyzer_plant_regression_test.py
index 2b6ee2e0..49d37f7e 100644
--- a/tests/regression_tests/electrolyzer_plant_regression_test.py
+++ b/tests/regression_tests/electrolyzer_plant_regression_test.py
@@ -172,7 +172,7 @@
def test_ElectrolyzerPlant_regression_():
- electrolyzer = ElectrolyzerPlant(test_h_dict)
+ electrolyzer = ElectrolyzerPlant(test_h_dict, "electrolyzer")
times_test = np.arange(0, 12.0, test_h_dict["dt"])
H2_output_test = np.zeros_like(times_test)
diff --git a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py
index f7edcf4b..19c2c966 100644
--- a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py
+++ b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py
@@ -109,7 +109,7 @@ def get_solar_params():
def test_SolarPySAM_regression_control():
solar_dict = get_solar_params()
- SPS = SolarPySAMPVWatts(solar_dict)
+ SPS = SolarPySAMPVWatts(solar_dict, "solar_farm")
power_setpoint = 13800.0 # Slightly below most of the base outputs.
diff --git a/tests/solar_pysam_pvwatts_test.py b/tests/solar_pysam_pvwatts_test.py
index e5fc0ccf..cf62a78c 100644
--- a/tests/solar_pysam_pvwatts_test.py
+++ b/tests/solar_pysam_pvwatts_test.py
@@ -14,7 +14,7 @@
def test_init():
# testing the `init` function: reading the inputs from input dictionary
test_h_dict = copy.deepcopy(h_dict_solar_pvwatts)
- SPS = SolarPySAMPVWatts(test_h_dict)
+ SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm")
assert SPS.dt == test_h_dict["dt"]
# Test that system_capacity is stored correctly
@@ -31,7 +31,7 @@ def test_return_outputs():
# Note: Current SolarPySAMPVWatts doesn't have return_outputs method,
# so we test the attributes directly
test_h_dict = copy.deepcopy(h_dict_solar_pvwatts)
- SPS = SolarPySAMPVWatts(test_h_dict)
+ SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm")
assert SPS.power == 25
assert SPS.dni == 1000
@@ -53,7 +53,7 @@ def test_return_outputs():
def test_step():
# testing the `step` function: calculating power based on inputs at first timestep
test_h_dict = copy.deepcopy(h_dict_solar_pvwatts)
- SPS = SolarPySAMPVWatts(test_h_dict)
+ SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm")
step_inputs = {"step": 0, "solar_farm": {"power_setpoint": 1e9}}
@@ -70,7 +70,7 @@ def test_step():
def test_control():
test_h_dict = copy.deepcopy(h_dict_solar_pvwatts)
- SPS = SolarPySAMPVWatts(test_h_dict)
+ SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm")
# Test curtailment - set power setpoint above uncurtailed power,
# should get uncurtailed power
diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py
index 70ac6060..0160f488 100644
--- a/tests/test_inputs/h_dict.py
+++ b/tests/test_inputs/h_dict.py
@@ -34,6 +34,12 @@
"floris_update_time_s": 30.0, # Required parameter for FLORIS updates
}
+power_playback = {
+ "component_type": "PowerPlayback",
+ "scada_filename": "tests/test_inputs/power_playback_input.csv",
+ "log_file_name": "outputs/power_playback.log",
+ "log_channels": ["power"],
+}
solar_farm_pysam = {
"component_type": "SolarPySAMPVWatts",
@@ -96,6 +102,82 @@
"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",
+ "efficiency",
+ "fuel_volume_rate",
+ "fuel_mass_rate",
+ ],
+ "initial_conditions": {"power": 1000}, # power > 0 implies ON state
+ "hhv": 40000000, # J/m³ (made up round number for testing, NOT realistic)
+ "fuel_density": 1.0, # kg/m³ (made up round number for testing, NOT realistic)
+ # HHV net efficiency values (made up round numbers for testing, NOT realistic)
+ "efficiency_table": {
+ "power_fraction": [1.0, 0.75, 0.50, 0.25],
+ "efficiency": [0.40, 0.38, 0.35, 0.30],
+ },
+}
+
+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",
+ "efficiency",
+ "fuel_volume_rate",
+ "fuel_mass_rate",
+ ],
+ "initial_conditions": {"power": 1000}, # power > 0 implies ON state
+ "hhv": 39050000, # J/m³ (natural gas HHV from [6])
+ # HHV net plant efficiency from SC1A curve in Exhibit ES-4 of [5]
+ "efficiency_table": {
+ "power_fraction": [1.0, 0.75, 0.50, 0.25],
+ "efficiency": [0.39, 0.37, 0.325, 0.245],
+ },
+}
+
+hard_coal_steam_turbine = {
+ "component_type": "HardCoalSteamTurbine",
+ "rated_capacity": 500000, # kW (500 MW)
+ "min_stable_load_fraction": 0.3, # 30% minimum operating point
+ "ramp_rate_fraction": 0.04, # 4%/min ramp rate
+ "run_up_rate_fraction": 0.02, # 2%/min run up rate
+ "hot_startup_time": 27000.0, # 7.5 hours
+ "warm_startup_time": 27000.0, # 7.5 hours
+ "cold_startup_time": 27000.0, # 7.5 hours
+ "min_up_time": 172800, # 48 hours
+ "min_down_time": 172800, # 48 hour
+ "hhv": 29310000000, # J/m³ for bituminous coal (29.31 MJ/m³) [4]
+ "fuel_density": 1000, # kg/m³ for bituminous coal
+ "initial_conditions": {"power": 1000}, # power > 0 implies ON state
+ "efficiency_table": {
+ "power_fraction": [1.0, 0.5, 0.3],
+ "efficiency": [0.35, 0.32, 0.30],
+ },
+}
+
electrolyzer = {
"component_type": "ElectrolyzerPlant",
"initial_conditions": {
@@ -183,6 +265,17 @@
"wind_farm": wind_farm,
}
+# h_dict with power_playback only
+h_dict_power_playback = {
+ "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,
+ "power_playback": power_playback,
+}
+
# h_dict with solar_farm only
# Time range: 0-6 seconds, starting at 2018-05-10 12:31:00
h_dict_solar = {
@@ -296,3 +389,62 @@
"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,
+}
+
+h_dict_hard_coal_steam_turbine = {
+ "dt": 1.0,
+ "starttime": 0.0,
+ "endtime": 10.0,
+ "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True),
+ "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True),
+ "verbose": False,
+ "step": 0,
+ "time": 0.0,
+ "plant": plant,
+ "hard_coal_steam_turbine": hard_coal_steam_turbine,
+}
+
+h_dict_thermal_plant = {
+ "dt": 1.0,
+ "starttime": 0.0,
+ "endtime": 10.0,
+ "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True),
+ "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True),
+ "verbose": False,
+ "step": 0,
+ "time": 0.0,
+ "plant": plant,
+ "thermal_power_plant": {
+ "component_type": "ThermalPlant",
+ "unit_names": ["OCGT1", "HARD_COAL1"],
+ "units": ["open_cycle_gas_turbine", "hard_coal_steam_turbine"],
+ "open_cycle_gas_turbine": open_cycle_gas_turbine,
+ "hard_coal_steam_turbine": hard_coal_steam_turbine,
+ },
+}
diff --git a/tests/test_inputs/power_playback_input.csv b/tests/test_inputs/power_playback_input.csv
new file mode 100644
index 00000000..1c7224a3
--- /dev/null
+++ b/tests/test_inputs/power_playback_input.csv
@@ -0,0 +1,12 @@
+time_utc,power
+2018-05-10 12:31:00,1000.0
+2018-05-10 12:31:01,2000.0
+2018-05-10 12:31:02,4000.0
+2018-05-10 12:31:03,4000.0
+2018-05-10 12:31:04,5000.0
+2018-05-10 12:31:05,6000.0
+2018-05-10 12:31:06,7000.0
+2018-05-10 12:31:07,8000.0
+2018-05-10 12:31:08,9000.0
+2018-05-10 12:31:09,10000.0
+2018-05-10 12:31:10,11000.0
\ No newline at end of file
diff --git a/tests/thermal_component_base_test.py b/tests/thermal_component_base_test.py
new file mode 100644
index 00000000..1ff3d180
--- /dev/null
+++ b/tests/thermal_component_base_test.py
@@ -0,0 +1,465 @@
+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), "thermal_component")
+ assert tpb is not None
+
+
+def test_invalid_inputs():
+ """Test that ThermalComponentBase raises an error for invalid inputs."""
+
+ # 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, "thermal_component")
+
+ # 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, "thermal_component")
+ h_dict["thermal_component"]["min_stable_load_fraction"] = -0.1
+ with pytest.raises(ValueError):
+ ThermalComponentBase(h_dict, "thermal_component")
+
+ # 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, "thermal_component")
+
+ # 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, "thermal_component")
+
+ # 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, "thermal_component")
+ h_dict["thermal_component"]["min_up_time"] = -1
+ with pytest.raises(ValueError):
+ ThermalComponentBase(h_dict, "thermal_component")
+
+ # 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, "thermal_component")
+ h_dict["thermal_component"]["min_down_time"] = -1
+ with pytest.raises(ValueError):
+ ThermalComponentBase(h_dict, "thermal_component")
+
+ # 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, "thermal_component")
+ h_dict["thermal_component"]["hot_startup_time"] = 60
+ ThermalComponentBase(h_dict, "thermal_component")
+
+ # 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)
+ 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, "thermal_component")
+ h_dict["thermal_component"]["cold_startup_time"] = 60
+ ThermalComponentBase(h_dict, "thermal_component")
+
+
+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, "thermal_component")
+ 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."""
+
+ # Test that power > 0 implies ON state
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 1000
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+ assert tcb.power_output == 1000
+ assert tcb.state == ThermalComponentBase.STATES.ON
+ # When ON, time_in_state should equal min_up_time (ready to stop)
+ assert tcb.time_in_state == tcb.min_up_time
+
+ # Test that power == 0 implies OFF state
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 0
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+ assert tcb.power_output == 0
+ assert tcb.state == ThermalComponentBase.STATES.OFF
+ # When OFF, time_in_state should equal min_down_time (ready to start)
+ assert tcb.time_in_state == tcb.min_down_time
+
+ # Check that invalid power values are rejected
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = -1
+ with pytest.raises(ValueError):
+ ThermalComponentBase(h_dict, "thermal_component")
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 1100
+ with pytest.raises(ValueError):
+ ThermalComponentBase(h_dict, "thermal_component")
+
+ # Test that the optional `time_in_shutdown` parameter is initialized correctly when
+ # the component is OFF
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 0
+ h_dict["thermal_component"]["initial_conditions"]["time_in_shutdown"] = 30
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+ assert tcb.power_output == 0
+ assert tcb.state == ThermalComponentBase.STATES.OFF
+ # When OFF, and time_in_shutdown is initialized, time_in_state should equal input value
+ assert tcb.time_in_state == 30
+
+ # Test that the optional `time_in_shutdown` parameter is initialized correctly when
+ # the component is ON
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 1000
+ h_dict["thermal_component"]["initial_conditions"]["time_in_shutdown"] = 30
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+ assert tcb.power_output == 1000
+ assert tcb.state == ThermalComponentBase.STATES.ON
+ # When ON, time_in_state should equal min_up_time (ready to stop)
+ # time_in_shutdown should not be initialized when unit is ON
+ assert tcb.time_in_state == tcb.min_up_time
+
+
+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 (implies ON state)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 500
+
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+
+ # 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"] == ThermalComponentBase.STATES.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 (implies ON state)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 500
+
+ # 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, "thermal_component")
+
+ # Initial state: ON with time_in_state = min_up_time (5s, ready to stop)
+ assert tcb.state == tcb.STATES.ON
+ assert tcb.power_output == 500
+ assert tcb.time_in_state == 5.0
+
+ # Force time_in_state to 0 to test the min_up_time wait behavior
+ tcb.time_in_state = 0.0
+
+ # 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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.STOPPING
+ assert out["thermal_component"]["power"] == 100
+
+ # Sixth step (Transition to off)
+ out = tcb.step(copy.deepcopy(h_dict))
+ assert tcb.time_in_state == 0.0
+ assert out["thermal_component"]["state"] == tcb.STATES.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 (implies OFF state)
+ h_dict["thermal_component"]["initial_conditions"]["power"] = 0
+
+ # 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, "thermal_component")
+
+ # Initial state: OFF with time_in_state = min_down_time (3s, ready to start)
+ assert tcb.state == tcb.STATES.OFF
+ assert tcb.power_output == 0
+ assert tcb.time_in_state == 3.0
+
+ # Force time_in_state to 0 to test the min_down_time wait behavior
+ tcb.time_in_state = 0.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 (still waiting for min_down_time)
+ out = tcb.step(copy.deepcopy(h_dict))
+ assert tcb.time_in_state == 1.0
+ assert out["thermal_component"]["state"] == tcb.STATES.OFF
+ assert out["thermal_component"]["power"] == 0
+
+ # Second step (still waiting for min_down_time)
+ out = tcb.step(copy.deepcopy(h_dict))
+ assert tcb.time_in_state == 2.0
+ assert out["thermal_component"]["state"] == tcb.STATES.OFF
+ assert out["thermal_component"]["power"] == 0
+
+ # Third step (min_down_time satisfied, transition to hot starting)
+ out = tcb.step(copy.deepcopy(h_dict))
+ assert tcb.time_in_state == 0.0
+ assert out["thermal_component"]["state"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.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"] == tcb.STATES.ON
+ assert out["thermal_component"]["power"] == 300
+
+
+def test_efficiency_clamping():
+ """Test clamping behavior at efficiency table boundaries."""
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ # Set up efficiency table that doesn't cover full range
+ h_dict["thermal_component"]["efficiency_table"] = {
+ "power_fraction": [0.25, 0.50, 0.75, 0.9],
+ "efficiency": [0.30, 0.35, 0.38, 0.40],
+ }
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+
+ # Test above highest power fraction (should clamp to 0.40)
+ # rated_capacity = 1000 kW, so 1000 kW = 100% load (above table max of 0.9)
+ eff_100 = tcb.calculate_efficiency(1000)
+ assert eff_100 == pytest.approx(0.40)
+
+ # Test as a value above 0 but below the lower defined power fraction (0.25)
+ eff_200 = tcb.calculate_efficiency(200) # 200 kW = 20% load (below table min of 0.25)
+ assert eff_200 == pytest.approx(0.30)
+
+ # Test at zero power (should return first efficiency value)
+ eff_0 = tcb.calculate_efficiency(0)
+ assert eff_0 == pytest.approx(0.30)
+
+
+def test_efficiency_interpolation():
+ """Test efficiency interpolation at various power levels."""
+ import numpy as np
+
+ h_dict = copy.deepcopy(h_dict_thermal_component)
+ # Set up a simple efficiency table for testing
+ # power_fraction: [0.25, 0.50, 0.75, 1.0]
+ # efficiency: [0.30, 0.35, 0.38, 0.40]
+ h_dict["thermal_component"]["efficiency_table"] = {
+ "power_fraction": [0.25, 0.50, 0.75, 1.0],
+ "efficiency": [0.30, 0.35, 0.38, 0.40],
+ }
+ tcb = ThermalComponentBase(h_dict, "thermal_component")
+
+ # Test at table points (rated_capacity = 1000 kW)
+ assert tcb.calculate_efficiency(1000) == pytest.approx(0.40) # 100% load
+ assert tcb.calculate_efficiency(750) == pytest.approx(0.38) # 75% load
+ assert tcb.calculate_efficiency(500) == pytest.approx(0.35) # 50% load
+ assert tcb.calculate_efficiency(250) == pytest.approx(0.30) # 25% load
+
+ # Test interpolation between points
+ # At 625 kW (62.5%), should be between 0.35 and 0.38
+ eff_625 = tcb.calculate_efficiency(625)
+ assert 0.35 < eff_625 < 0.38
+ np.testing.assert_allclose(eff_625, 0.365, rtol=1e-6)
diff --git a/tests/thermal_plant_test.py b/tests/thermal_plant_test.py
new file mode 100644
index 00000000..b84b9e02
--- /dev/null
+++ b/tests/thermal_plant_test.py
@@ -0,0 +1,109 @@
+import copy
+
+import pytest
+from hercules.plant_components.thermal_plant import ThermalPlant
+
+from .test_inputs.h_dict import (
+ h_dict_thermal_plant,
+ simple_battery,
+)
+
+
+def test_init_from_dict():
+ # Set up a system with one OCGT and one hard coal steam turbine.
+ h_dict = copy.deepcopy(h_dict_thermal_plant)
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+
+def test_invalid_unit_type():
+ h_dict = copy.deepcopy(h_dict_thermal_plant)
+
+ # Unspecified unit
+ h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "invalid_unit"]
+ with pytest.raises(KeyError):
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Non thermal-type unit
+ h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "simple_battery"]
+ h_dict["thermal_power_plant"]["simple_battery"] = copy.deepcopy(simple_battery)
+ with pytest.raises(ValueError):
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Incorrect component type
+ h_dict["thermal_power_plant"]["units"] = ["open_cycle_gas_turbine", "hard_coal_steam_turbine"]
+ h_dict["thermal_power_plant"]["hard_coal_steam_turbine"]["component_type"] = "InvalidComponent"
+ with pytest.raises(ValueError):
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+
+def test_unit_copies():
+ h_dict = copy.deepcopy(h_dict_thermal_plant)
+ h_dict["thermal_power_plant"]["units"] = [
+ "open_cycle_gas_turbine",
+ "hard_coal_steam_turbine",
+ "hard_coal_steam_turbine",
+ ]
+
+ # units and unit_names are unequal length
+ with pytest.raises(ValueError):
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Update unit_names with non-unique values
+ h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST1"]
+ with pytest.raises(ValueError):
+ ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Unique values
+ h_dict["thermal_power_plant"]["unit_names"] = ["OCGT1", "HST1", "HST2"]
+ tp = ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Check that there are three units of the correct types
+ assert len(tp.units) == 3
+ assert tp.units[0].component_type == "OpenCycleGasTurbine"
+ assert tp.units[1].component_type == "HardCoalSteamTurbine"
+ assert tp.units[2].component_type == "HardCoalSteamTurbine"
+
+
+def test_h_dict_structure():
+ h_dict = copy.deepcopy(h_dict_thermal_plant)
+
+ tp = ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Check that the unit dicts were copied correctly (and generic names removed)
+ assert "open_cycle_gas_turbine" not in h_dict["thermal_power_plant"]
+ assert "hard_coal_steam_turbine" not in h_dict["thermal_power_plant"]
+ assert "OCGT1" in h_dict["thermal_power_plant"]
+ assert "HARD_COAL1" in h_dict["thermal_power_plant"]
+ assert h_dict["thermal_power_plant"]["OCGT1"]["component_type"] == "OpenCycleGasTurbine"
+ assert h_dict["thermal_power_plant"]["HARD_COAL1"]["component_type"] == "HardCoalSteamTurbine"
+
+ # Check that the initial conditions of units are copied correctly
+ h_dict = tp.get_initial_conditions_and_meta_data(h_dict)
+ assert h_dict["thermal_power_plant"]["OCGT1"]["power"] == 1000 # From initial conditions
+ assert h_dict["thermal_power_plant"]["HARD_COAL1"]["power"] == 1000 # From initial conditions
+ assert h_dict["thermal_power_plant"]["OCGT1"]["rated_capacity"] == 1000
+ assert h_dict["thermal_power_plant"]["HARD_COAL1"]["rated_capacity"] == 500000
+
+ # Check that thermal plant conditions are recorded correctly
+ assert h_dict["thermal_power_plant"]["power"] == 1000 + 1000
+ assert h_dict["thermal_power_plant"]["rated_capacity"] == 500000 + 1000
+
+
+def test_step():
+ h_dict = copy.deepcopy(h_dict_thermal_plant)
+
+ tp = ThermalPlant(h_dict, "thermal_power_plant")
+
+ # Provide power setpoints to the two units
+ h_dict["thermal_power_plant"]["power_setpoints"] = [800, 400000]
+
+ # Step the plant and check that power is updated correctly
+ h_dict = tp.step(h_dict)
+ power_ocgt = h_dict["thermal_power_plant"]["OCGT1"]["power"]
+ power_hard_coal = h_dict["thermal_power_plant"]["HARD_COAL1"]["power"]
+
+ assert power_ocgt < 1000 # Reacts to power setpoint
+ assert power_hard_coal < 500000 # Reacts to power setpoint
+
+ # Total power computed correctly
+ assert h_dict["thermal_power_plant"]["power"] == power_ocgt + power_hard_coal
diff --git a/tests/utilities_test.py b/tests/utilities_test.py
index 91e72465..bd39efe1 100644
--- a/tests/utilities_test.py
+++ b/tests/utilities_test.py
@@ -217,7 +217,7 @@ def test_load_hercules_input_invalid_component_type():
temp_file = f.name
try:
- with pytest.raises(ValueError, match="wind_farm has an invalid component_type"):
+ with pytest.raises(ValueError, match="wind_farm.*unrecognized component_type"):
load_hercules_input(temp_file)
finally:
os.unlink(temp_file)
diff --git a/tests/wind_farm_direct_test.py b/tests/wind_farm_direct_test.py
index 75b97bab..34e237dd 100644
--- a/tests/wind_farm_direct_test.py
+++ b/tests/wind_farm_direct_test.py
@@ -1,8 +1,12 @@
"""Tests for the WindFarm class in direct wake mode (WindFarm with no_added_wakes)."""
import copy
+import os
+import tempfile
import numpy as np
+import pandas as pd
+import pytest
from hercules.plant_components.wind_farm import WindFarm
from hercules.utilities import hercules_float_type
@@ -16,7 +20,7 @@
def test_wind_farm_direct_initialization():
"""Test that WindFarm initializes correctly with wake_method='no_added_wakes'."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
assert wind_sim.component_name == "wind_farm"
assert wind_sim.component_type == "WindFarm"
@@ -32,7 +36,7 @@ def test_wind_farm_direct_initialization():
def test_wind_farm_direct_no_wakes():
"""Test that no wake deficits are applied in direct mode."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Verify initial wake deficits are zero
assert np.all(wind_sim.floris_wake_deficits == 0.0)
@@ -43,7 +47,7 @@ def test_wind_farm_direct_no_wakes():
def test_wind_farm_direct_step():
"""Test that the step method works correctly in direct mode."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Add power setpoint values to the step h_dict
step_h_dict = {"step": 1}
@@ -59,7 +63,7 @@ def test_wind_farm_direct_step():
assert len(result["wind_farm"]["turbine_powers"]) == 3
assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray)
assert "power" in result["wind_farm"]
- assert isinstance(result["wind_farm"]["power"], (int, float))
+ assert isinstance(result["wind_farm"]["power"], (int, float, hercules_float_type))
# Verify no wake deficits applied
assert np.all(wind_sim.floris_wake_deficits == 0.0)
@@ -71,7 +75,7 @@ def test_wind_farm_direct_step():
def test_wind_farm_direct_no_wake_deficits_over_time():
"""Test that wake deficits remain zero throughout simulation."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Run multiple steps
for step in range(5):
@@ -92,7 +96,7 @@ def test_wind_farm_direct_no_wake_deficits_over_time():
def test_wind_farm_direct_turbine_dynamics():
"""Test that turbine dynamics still work in direct mode."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Run a step with very low power setpoint
step_h_dict = {"step": 1}
@@ -108,7 +112,7 @@ def test_wind_farm_direct_turbine_dynamics():
def test_wind_farm_direct_power_setpoint_zero():
"""Test that turbine powers go to zero when setpoint is zero."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Run multiple steps with zero setpoint to ensure filter settles
for step in range(10):
@@ -124,7 +128,7 @@ def test_wind_farm_direct_power_setpoint_zero():
def test_wind_farm_direct_initial_conditions():
"""Test that initial conditions are correctly set in h_dict."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
initial_h_dict = copy.deepcopy(h_dict_wind_direct)
result_h_dict = wind_sim.get_initial_conditions_and_meta_data(initial_h_dict)
@@ -144,7 +148,7 @@ def test_wind_farm_direct_initial_conditions():
def test_wind_farm_direct_output_consistency():
"""Test that outputs are consistent with no wake modeling."""
- wind_sim = WindFarm(h_dict_wind_direct)
+ wind_sim = WindFarm(h_dict_wind_direct, "wind_farm")
# Run a step
step_h_dict = {"step": 2}
@@ -163,3 +167,40 @@ def test_wind_farm_direct_output_consistency():
# Total power should be sum of turbine powers
assert np.isclose(result["wind_farm"]["power"], np.sum(result["wind_farm"]["turbine_powers"]))
+
+
+def test_wind_farm_raises_on_nan_in_wind_input():
+ """Test that WindFarm raises ValueError when wind input file contains NaN values."""
+ wind_data = {
+ "time_utc": [
+ "2018-05-10 12:31:00",
+ "2018-05-10 12:31:01",
+ "2018-05-10 12:31:02",
+ "2018-05-10 12:31:03",
+ "2018-05-10 12:31:04",
+ "2018-05-10 12:31:05",
+ "2018-05-10 12:31:06",
+ "2018-05-10 12:31:07",
+ "2018-05-10 12:31:08",
+ "2018-05-10 12:31:09",
+ "2018-05-10 12:31:10",
+ ],
+ "wd_mean": [180.5, 185.2, 190.8, 175.3, 170.1, 165.7, 160.4, 155.9, 150.2, 145.6, 140.3],
+ "ws_000": [8.2, np.nan, 7.8, 6.5, 10.2, 11.5, 9.8, 8.7, 7.3, 6.9, 8.4],
+ "ws_001": [8.1, 9.0, 7.7, 6.4, 10.1, 11.4, 9.7, 8.6, 7.2, 6.8, 8.3],
+ "ws_002": [8.3, 9.2, 7.9, 6.6, 10.3, 11.6, 9.9, 8.8, 7.4, 7.0, 8.5],
+ }
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
+ pd.DataFrame(wind_data).to_csv(f.name, index=False)
+ temp_wind_file = f.name
+
+ try:
+ test_h_dict = copy.deepcopy(h_dict_wind_direct)
+ test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file
+
+ with pytest.raises(ValueError, match="wind input file contains NaN values"):
+ WindFarm(test_h_dict, "wind_farm")
+ finally:
+ if os.path.exists(temp_wind_file):
+ os.unlink(temp_wind_file)
diff --git a/tests/wind_meso_to_power_test.py b/tests/wind_farm_dynamic_floris_test.py
similarity index 91%
rename from tests/wind_meso_to_power_test.py
rename to tests/wind_farm_dynamic_floris_test.py
index 336aeb05..99064f53 100644
--- a/tests/wind_meso_to_power_test.py
+++ b/tests/wind_farm_dynamic_floris_test.py
@@ -13,9 +13,9 @@
from tests.test_inputs.h_dict import h_dict_wind
-def test_wind_meso_to_power_initialization():
+def test_wind_farm_initialization():
"""Test that WindFarm initializes correctly with valid inputs (dynamic mode)."""
- wind_sim = WindFarm(h_dict_wind)
+ wind_sim = WindFarm(h_dict_wind, "wind_farm")
assert wind_sim.component_name == "wind_farm"
assert wind_sim.component_type == "WindFarm"
@@ -27,7 +27,7 @@ def test_wind_meso_to_power_initialization():
assert wind_sim.floris_update_time_s == 30.0
-def test_wind_meso_to_power_precom_floris_ws_mean():
+def test_wind_farm_ws_mean():
"""Test that invalid component_type raises ValueError."""
current_dir = os.path.dirname(__file__)
@@ -41,7 +41,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
# Test that, since individual speed are specified, ws_mean is ignored
# Note that h_dict_wind specifies an end time of 10.
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
assert (
wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10]
).all()
@@ -56,7 +56,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"])
df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv")
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
assert (wind_sim.ws_mat_mean == 10.0).all()
assert (wind_sim.ws_mat[:, :] == 10.0).all()
@@ -64,7 +64,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
os.remove(current_dir + "/test_inputs/wind_input_temp.csv")
-def test_wind_meso_to_power_missing_floris_update_time():
+def test_wind_farm_missing_floris_update_time():
"""Test that missing floris_update_time_s raises ValueError."""
test_h_dict = copy.deepcopy(h_dict_wind)
del test_h_dict["wind_farm"]["floris_update_time_s"]
@@ -72,25 +72,25 @@ def test_wind_meso_to_power_missing_floris_update_time():
with pytest.raises(
ValueError, match="floris_update_time_s must be specified for wake_method='dynamic'"
):
- WindFarm(test_h_dict)
+ WindFarm(test_h_dict, "wind_farm")
-def test_wind_meso_to_power_invalid_update_time():
+def test_wind_farm_invalid_update_time():
"""Test that invalid update time raises ValueError."""
test_h_dict = copy.deepcopy(h_dict_wind)
test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5 # Less than 1 second
with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"):
- WindFarm(test_h_dict)
+ WindFarm(test_h_dict, "wind_farm")
-def test_wind_meso_to_power_step():
+def test_wind_farm_step():
"""Test that the step method updates outputs correctly."""
test_h_dict = copy.deepcopy(h_dict_wind)
# Set a shorter update time for testing
test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Add power setpoint values to the step h_dict
step_h_dict = {"step": 1}
@@ -105,12 +105,12 @@ def test_wind_meso_to_power_step():
assert len(result["wind_farm"]["turbine_powers"]) == 3
assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray)
assert "power" in result["wind_farm"]
- assert isinstance(result["wind_farm"]["power"], (int, float))
+ assert isinstance(result["wind_farm"]["power"], (int, float, hercules_float_type))
-def test_wind_meso_to_power_time_utc_conversion():
+def test_wind_farm_time_utc_conversion():
"""Test that time_utc column is properly converted to datetime."""
- wind_sim = WindFarm(h_dict_wind)
+ wind_sim = WindFarm(h_dict_wind, "wind_farm")
# Check that time_utc was converted to datetime type
# The wind_sim should have successfully processed the CSV with time_utc column
@@ -124,12 +124,12 @@ def test_wind_meso_to_power_time_utc_conversion():
assert wind_sim.ws_mat.shape[1] == 3 # 3 turbines
-def test_wind_meso_to_power_power_setpoint_too_high():
+def test_wind_farm_power_setpoint_too_high():
"""Test that turbine powers are below power setpoint when setpoint is very high."""
test_h_dict = copy.deepcopy(h_dict_wind)
test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Set very high power setpoint values that should not limit power output
step_h_dict = {"step": 1}
@@ -147,12 +147,12 @@ def test_wind_meso_to_power_power_setpoint_too_high():
assert power <= setpoint, f"Turbine {i} power {power} exceeds power setpoint {setpoint}"
-def test_wind_meso_to_power_power_setpoint_applies():
+def test_wind_farm_power_setpoint_applies():
"""Test that turbine powers equal power setpoint when setpoint is very low."""
test_h_dict = copy.deepcopy(h_dict_wind)
test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Set very low power setpoint values that should definitely limit power output
step_h_dict = {"step": 1}
@@ -172,9 +172,9 @@ def test_wind_meso_to_power_power_setpoint_applies():
)
-def test_wind_meso_to_power_get_initial_conditions_and_meta_data():
+def test_wind_farm_get_initial_conditions_and_meta_data():
"""Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict."""
- wind_sim = WindFarm(h_dict_wind)
+ wind_sim = WindFarm(h_dict_wind, "wind_farm")
# Create a copy of the input h_dict to avoid modifying the original
test_h_dict = copy.deepcopy(h_dict_wind)
@@ -212,7 +212,7 @@ def test_wind_meso_to_power_get_initial_conditions_and_meta_data():
assert "plant" in result
-def test_wind_meso_to_power_regular_floris_updates():
+def test_wind_farm_regular_floris_updates():
"""Test that FLORIS updates occur at regular intervals.
This test verifies that FLORIS calculations happen at the specified interval
@@ -253,7 +253,7 @@ def test_wind_meso_to_power_regular_floris_updates():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Run 5 steps with constant power setpoints
floris_calc_counts = []
@@ -278,7 +278,7 @@ def test_wind_meso_to_power_regular_floris_updates():
os.unlink(temp_wind_file)
-def test_wind_meso_to_power_power_setpoints_buffer():
+def test_wind_farm_power_setpoints_buffer():
"""Test that power setpoints buffer works correctly over time."""
# Create a temporary wind input file with constant conditions
wind_data = {
@@ -315,7 +315,7 @@ def test_wind_meso_to_power_power_setpoints_buffer():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Run steps with varying power setpoints
for step in range(5):
diff --git a/tests/wind_meso_to_power_precom_floris_test.py b/tests/wind_farm_precom_floris_test.py
similarity index 92%
rename from tests/wind_meso_to_power_precom_floris_test.py
rename to tests/wind_farm_precom_floris_test.py
index b9bc1888..356d0143 100644
--- a/tests/wind_meso_to_power_precom_floris_test.py
+++ b/tests/wind_farm_precom_floris_test.py
@@ -1,4 +1,4 @@
-"""Tests for the WindFarm class in with precomputed wakes."""
+"""Tests for the WindFarm class with precomputed wakes."""
import copy
import os
@@ -19,9 +19,9 @@
h_dict_wind_precom_floris["wind_farm"]["wake_method"] = "precomputed"
-def test_wind_meso_to_power_precom_floris_initialization():
+def test_wind_farm_precom_floris_initialization():
"""Test that WindFarm initializes correctly with valid inputs."""
- wind_sim = WindFarm(h_dict_wind_precom_floris)
+ wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm")
assert wind_sim.component_name == "wind_farm"
assert wind_sim.component_type == "WindFarm"
@@ -37,7 +37,7 @@ def test_wind_meso_to_power_precom_floris_initialization():
)
-def test_wind_meso_to_power_precom_floris_ws_mean():
+def test_wind_farm_precom_floris_ws_mean():
"""Test that invalid component_type raises ValueError."""
current_dir = os.path.dirname(__file__)
@@ -51,7 +51,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
# Test that, since individual speed are specified, ws_mean is ignored
# Note that h_dict_wind_precom_floris specifies an end time of 10.
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
assert (
wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10]
).all()
@@ -66,7 +66,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"])
df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv")
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
assert (wind_sim.ws_mat_mean == 10.0).all()
assert (wind_sim.ws_mat[:, :] == 10.0).all()
@@ -74,7 +74,7 @@ def test_wind_meso_to_power_precom_floris_ws_mean():
os.remove(current_dir + "/test_inputs/wind_input_temp.csv")
-def test_wind_meso_to_power_precom_floris_requires_floris_update_time():
+def test_wind_farm_precom_floris_requires_floris_update_time():
"""Test that missing floris_update_time_s raises ValueError."""
test_h_dict = copy.deepcopy(h_dict_wind_precom_floris)
del test_h_dict["wind_farm"]["floris_update_time_s"]
@@ -82,21 +82,21 @@ def test_wind_meso_to_power_precom_floris_requires_floris_update_time():
with pytest.raises(
ValueError, match="floris_update_time_s must be specified for wake_method='precomputed'"
):
- WindFarm(test_h_dict)
+ WindFarm(test_h_dict, "wind_farm")
-def test_wind_meso_to_power_precom_floris_invalid_update_time():
+def test_wind_farm_precom_floris_invalid_update_time():
"""Test that invalid floris_update_time_s (<1) raises ValueError."""
test_h_dict = copy.deepcopy(h_dict_wind_precom_floris)
test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5
with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"):
- WindFarm(test_h_dict)
+ WindFarm(test_h_dict, "wind_farm")
-def test_wind_meso_to_power_precom_floris_step():
+def test_wind_farm_precom_floris_step():
"""Test that the step method updates outputs correctly."""
- wind_sim = WindFarm(h_dict_wind_precom_floris)
+ wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm")
# Add power setpoint values to the step h_dict
step_h_dict = {"step": 1}
@@ -111,12 +111,12 @@ def test_wind_meso_to_power_precom_floris_step():
assert len(result["wind_farm"]["turbine_powers"]) == 3
assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray)
assert "power" in result["wind_farm"]
- assert isinstance(result["wind_farm"]["power"], (int, float))
+ assert isinstance(result["wind_farm"]["power"], (int, float, hercules_float_type))
-def test_wind_meso_to_power_precom_floris_power_setpoint_applies():
+def test_wind_farm_precom_floris_power_setpoint_applies():
"""Test that turbine powers equal power setpoint when setpoint is very low."""
- wind_sim = WindFarm(h_dict_wind_precom_floris)
+ wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm")
# Set very low power setpoint values that should definitely limit power output
step_h_dict = {"step": 1}
@@ -136,9 +136,9 @@ def test_wind_meso_to_power_precom_floris_power_setpoint_applies():
)
-def test_wind_meso_to_power_precom_floris_get_initial_conditions_and_meta_data():
+def test_wind_farm_precom_floris_get_initial_conditions_and_meta_data():
"""Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict."""
- wind_sim = WindFarm(h_dict_wind_precom_floris)
+ wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm")
# Create a copy of the input h_dict to avoid modifying the original
test_h_dict_copy = copy.deepcopy(h_dict_wind_precom_floris)
@@ -176,9 +176,9 @@ def test_wind_meso_to_power_precom_floris_get_initial_conditions_and_meta_data()
assert "plant" in result
-def test_wind_meso_to_power_precom_floris_precomputed_wake_deficits():
+def test_wind_farm_precom_floris_precomputed_wake_deficits():
"""Test that wake deficits are precomputed and stored correctly."""
- wind_sim = WindFarm(h_dict_wind_precom_floris)
+ wind_sim = WindFarm(h_dict_wind_precom_floris, "wind_farm")
# Verify that precomputed wake wind speeds exist
assert hasattr(wind_sim, "wind_speeds_withwakes_all")
@@ -197,7 +197,7 @@ def test_wind_meso_to_power_precom_floris_precomputed_wake_deficits():
assert np.all(wind_sim.floris_wake_deficits >= 0.0)
-def test_wind_meso_to_power_precom_floris_velocities_update_correctly():
+def test_wind_farm_precom_floris_velocities_update_correctly():
"""Test that wind speeds are updated correctly from precomputed arrays during simulation."""
# Create a temporary wind input file with varying conditions
wind_data = {
@@ -232,7 +232,7 @@ def test_wind_meso_to_power_precom_floris_velocities_update_correctly():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Store initial wind speeds
initial_background = wind_sim.wind_speeds_background.copy()
@@ -268,7 +268,7 @@ def test_wind_meso_to_power_precom_floris_velocities_update_correctly():
os.unlink(temp_wind_file)
-def test_wind_meso_to_power_precom_floris_time_utc_reconstruction():
+def test_wind_farm_precom_floris_time_utc_reconstruction():
"""Test that time_utc reconstruction works correctly from starttime_utc metadata
and both time_utc fields are properly set."""
# Create wind input data with time_utc columns
@@ -304,7 +304,7 @@ def test_wind_meso_to_power_precom_floris_time_utc_reconstruction():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Verify that starttime_utc is set correctly
assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set"
@@ -403,7 +403,7 @@ def test_wind_meso_to_power_precom_floris_time_utc_reconstruction():
os.unlink(temp_wind_file)
-def test_wind_meso_to_power_precom_floris_time_utc_different_starttime():
+def test_wind_farm_precom_floris_time_utc_different_starttime():
"""Test that starttime_utc is correctly set when using a different start time."""
# Create wind input data with time_utc columns
wind_data = {
@@ -441,7 +441,7 @@ def test_wind_meso_to_power_precom_floris_time_utc_different_starttime():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarm(test_h_dict)
+ wind_sim = WindFarm(test_h_dict, "wind_farm")
# Verify that starttime_utc is set correctly
assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set"
diff --git a/tests/wind_farm_scada_power_test.py b/tests/wind_farm_scada_power_test.py
index 5d7a449b..55f11c18 100644
--- a/tests/wind_farm_scada_power_test.py
+++ b/tests/wind_farm_scada_power_test.py
@@ -17,7 +17,6 @@
# Update component type and remove unneeded parameters
h_dict_wind_scada["wind_farm"]["component_type"] = "WindFarmSCADAPower"
h_dict_wind_scada["wind_farm"]["scada_filename"] = "tests/test_inputs/scada_input.csv"
-# Keep turbine_file_name for filter model parameters
# Remove FLORIS-specific parameters
del h_dict_wind_scada["wind_farm"]["floris_input_file"]
del h_dict_wind_scada["wind_farm"]["wind_input_filename"]
@@ -26,7 +25,7 @@
def test_wind_farm_scada_power_initialization():
"""Test that WindFarmSCADAPower initializes correctly with valid inputs."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
assert wind_sim.component_name == "wind_farm"
assert wind_sim.component_type == "WindFarmSCADAPower"
@@ -34,13 +33,11 @@ def test_wind_farm_scada_power_initialization():
assert wind_sim.dt == 1.0
assert wind_sim.starttime == 0.0
assert wind_sim.endtime == 10.0
- # No FLORIS calculations in SCADA power mode
- assert wind_sim.num_floris_calcs == 0
def test_wind_farm_scada_power_infers_n_turbines():
"""Test that number of turbines is correctly inferred from power columns."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
assert wind_sim.n_turbines == 3
assert len(wind_sim.power_columns) == 3
@@ -49,7 +46,7 @@ def test_wind_farm_scada_power_infers_n_turbines():
def test_wind_farm_scada_power_infers_rated_power():
"""Test that rated power is correctly inferred from 99th percentile."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
# Check that rated power is positive and reasonable
assert wind_sim.rated_turbine_power == 5000.0
@@ -58,7 +55,7 @@ def test_wind_farm_scada_power_infers_rated_power():
def test_wind_farm_scada_power_no_wakes():
"""Test that no wake deficits are applied in SCADA power mode."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
# Verify initial wake deficits are zero
assert np.all(wind_sim.floris_wake_deficits == 0.0)
@@ -69,13 +66,10 @@ def test_wind_farm_scada_power_no_wakes():
def test_wind_farm_scada_power_step():
"""Test that the step method works correctly."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
- # Add power setpoint values to the step h_dict
step_h_dict = {"step": 1}
- step_h_dict["wind_farm"] = {
- "turbine_power_setpoints": np.array([5000.0, 5000.0, 5000.0]),
- }
+ step_h_dict["wind_farm"] = {}
result = wind_sim.step(step_h_dict)
@@ -84,8 +78,7 @@ def test_wind_farm_scada_power_step():
assert "power" in result["wind_farm"]
assert len(result["wind_farm"]["turbine_powers"]) == 3
assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray)
- assert "power" in result["wind_farm"]
- assert isinstance(result["wind_farm"]["power"], (int, float))
+ assert isinstance(result["wind_farm"]["power"], (int, float, np.floating))
# Verify no wake deficits applied
assert np.all(wind_sim.floris_wake_deficits == 0.0)
@@ -99,48 +92,9 @@ def test_wind_farm_scada_power_step():
assert np.isclose(result["wind_farm"]["power"], 3200.0 + 3100.0 + 3300.0)
-def test_wind_farm_scada_power_power_setpoint_applies():
- """Test that turbine powers are limited by power setpoint when setpoint is low."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
-
- # Set very low power setpoint values that should definitely limit power output
- # Run multiple steps to let filter settle (within available data range 0-9)
- for step in range(wind_sim.n_steps):
- step_h_dict = {"step": step}
- step_h_dict["wind_farm"] = {
- "turbine_power_setpoints": np.array([100.0, 200.0, 300.0]), # Very low setpoints
- }
- result = wind_sim.step(step_h_dict)
-
- # Verify that turbine powers are at or below power setpoint limits
- turbine_powers = result["wind_farm"]["turbine_powers"]
- power_setpoints = [100.0, 200.0, 300.0]
-
- for i, (power, setpoint) in enumerate(zip(turbine_powers, power_setpoints)):
- assert power <= setpoint + 1e-6, (
- f"Turbine {i} power {power} exceeds power setpoint {setpoint}"
- )
-
-
-def test_wind_farm_scada_power_power_setpoint_zero():
- """Test that turbine powers go to zero when setpoint is zero."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
-
- # Run multiple steps with zero setpoint to ensure filter settles (within available data range)
- for step in range(wind_sim.n_steps):
- step_h_dict = {"step": step}
- step_h_dict["wind_farm"] = {
- "turbine_power_setpoints": np.zeros(3, dtype=hercules_float_type),
- }
- result = wind_sim.step(step_h_dict)
-
- # After multiple steps, powers should be effectively zero
- assert np.all(result["wind_farm"]["turbine_powers"] < 1.0)
-
-
def test_wind_farm_scada_power_get_initial_conditions_and_meta_data():
"""Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
# Create a copy of the input h_dict to avoid modifying the original
test_h_dict_copy = copy.deepcopy(h_dict_wind_scada)
@@ -215,7 +169,7 @@ def test_wind_farm_scada_power_time_utc_handling():
test_h_dict["dt"] = 1.0
# Initialize wind simulation
- wind_sim = WindFarmSCADAPower(test_h_dict)
+ wind_sim = WindFarmSCADAPower(test_h_dict, "wind_farm")
# Verify that starttime_utc is set correctly
assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set"
@@ -270,7 +224,7 @@ def test_wind_farm_scada_power_time_utc_validation_start_too_early():
test_h_dict["dt"] = 1.0
with pytest.raises(ValueError, match="Start time UTC .* is before the earliest time"):
- WindFarmSCADAPower(test_h_dict)
+ WindFarmSCADAPower(test_h_dict, "wind_farm")
finally:
if os.path.exists(temp_scada_file):
@@ -311,7 +265,7 @@ def test_wind_farm_scada_power_time_utc_validation_end_too_late():
test_h_dict["dt"] = 1.0
with pytest.raises(ValueError, match="End time UTC .* is after the latest time"):
- WindFarmSCADAPower(test_h_dict)
+ WindFarmSCADAPower(test_h_dict, "wind_farm")
finally:
if os.path.exists(temp_scada_file):
@@ -350,7 +304,7 @@ def test_wind_farm_scada_power_ws_mean_handling():
test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z"
test_h_dict["dt"] = 1.0
- wind_sim = WindFarmSCADAPower(test_h_dict)
+ wind_sim = WindFarmSCADAPower(test_h_dict, "wind_farm")
# Verify that ws_mat is properly tiled from ws_mean
assert wind_sim.ws_mat.shape == (4, 3)
@@ -365,13 +319,11 @@ def test_wind_farm_scada_power_ws_mean_handling():
def test_wind_farm_scada_power_output_consistency():
"""Test that outputs are consistent with no wake modeling."""
- wind_sim = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
# Run a step
step_h_dict = {"step": 2}
- step_h_dict["wind_farm"] = {
- "turbine_power_setpoints": np.ones(3, dtype=hercules_float_type) * 5000.0,
- }
+ step_h_dict["wind_farm"] = {}
result = wind_sim.step(step_h_dict)
@@ -389,7 +341,7 @@ def test_wind_farm_scada_power_output_consistency():
def test_wind_farm_scada_power_multiple_file_formats():
"""Test that SCADA data can be loaded from different file formats."""
# Test CSV (already tested above, but included for completeness)
- wind_sim_csv = WindFarmSCADAPower(h_dict_wind_scada)
+ wind_sim_csv = WindFarmSCADAPower(h_dict_wind_scada, "wind_farm")
assert wind_sim_csv.n_turbines == 3
# Test pickle format
@@ -405,7 +357,7 @@ def test_wind_farm_scada_power_multiple_file_formats():
test_h_dict = copy.deepcopy(h_dict_wind_scada)
test_h_dict["wind_farm"]["scada_filename"] = temp_pickle_file
- wind_sim_pkl = WindFarmSCADAPower(test_h_dict)
+ wind_sim_pkl = WindFarmSCADAPower(test_h_dict, "wind_farm")
assert wind_sim_pkl.n_turbines == 3
finally:
@@ -421,7 +373,7 @@ def test_wind_farm_scada_power_multiple_file_formats():
test_h_dict = copy.deepcopy(h_dict_wind_scada)
test_h_dict["wind_farm"]["scada_filename"] = temp_feather_file
- wind_sim_ftr = WindFarmSCADAPower(test_h_dict)
+ wind_sim_ftr = WindFarmSCADAPower(test_h_dict, "wind_farm")
assert wind_sim_ftr.n_turbines == 3
finally:
@@ -443,8 +395,47 @@ def test_wind_farm_scada_power_invalid_file_format():
test_h_dict["wind_farm"]["scada_filename"] = temp_file
with pytest.raises(ValueError, match="SCADA file must be a .csv or .p, .f or .ftr file"):
- WindFarmSCADAPower(test_h_dict)
+ WindFarmSCADAPower(test_h_dict, "wind_farm")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
+
+
+def test_wind_farm_scada_power_raises_on_nan_in_power_columns():
+ """Test that WindFarmSCADAPower raises ValueError when pow_NNN columns contain NaN."""
+ scada_data = {
+ "time_utc": [
+ "2023-01-01T00:00:00Z",
+ "2023-01-01T00:00:01Z",
+ "2023-01-01T00:00:02Z",
+ "2023-01-01T00:00:03Z",
+ "2023-01-01T00:00:04Z",
+ ],
+ "wd_mean": [270.0, 275.0, 280.0, 285.0, 290.0],
+ "ws_000": [8.0, 9.0, 10.0, 11.0, 12.0],
+ "ws_001": [8.5, 9.5, 10.5, 11.5, 12.5],
+ "ws_002": [9.0, 10.0, 11.0, 12.0, 13.0],
+ "pow_000": [2500.0, np.nan, 4000.0, 4500.0, 5000.0],
+ "pow_001": [2400.0, 3100.0, 3900.0, 4400.0, 4900.0],
+ "pow_002": [2600.0, 3300.0, 4100.0, 4600.0, 5000.0],
+ }
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
+ pd.DataFrame(scada_data).to_csv(f.name, index=False)
+ temp_scada_file = f.name
+
+ try:
+ test_h_dict = copy.deepcopy(h_dict_wind_scada)
+ test_h_dict["wind_farm"]["scada_filename"] = temp_scada_file
+ test_h_dict["starttime"] = 0.0
+ test_h_dict["endtime"] = 4.0
+ test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z"
+ test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z"
+ test_h_dict["dt"] = 1.0
+
+ with pytest.raises(ValueError, match="SCADA file contains NaN values"):
+ WindFarmSCADAPower(test_h_dict, "wind_farm")
+ finally:
+ if os.path.exists(temp_scada_file):
+ os.unlink(temp_scada_file)