Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/h_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ Any top-level `h_dict` entry whose value is a dict containing a `component_type`
### Solar Farm
| `component_type` | str | "SolarPySAMPVWatts" |
| **For SolarPySAMPVWatts:** |
| `pysam_model` | str | "pvwatts" |
| `solar_input_filename` | str | Solar data file path |
| `system_capacity` | float | DC system capacity in kW as defined by PVWatts - under Standard Test Conditions|
| `system_capacity` | float | DC system capacity in kW (PVWatts STC) |
| `tilt` | float | Array tilt angle in degrees (required) |
| `losses` | float | System losses, % (0–100); see [Solar PV](solar_pv.md) |
| `pysam_options` | dict | Optional; e.g. `SystemDesign: {dc_ac_ratio, array_type, ...}` — see [Solar PV](solar_pv.md) |
| `lat` | float | Latitude |
| `lon` | float | Longitude |
| `elev` | float | Elevation in meters |
| `log_channels` | list | List of channels to log (e.g., ["power", "dni", "poa", "aoi"]) |
| `initial_conditions` | dict | Initial power, DNI, POA |
| `log_channels` | list | Channels to log (e.g. `power`, `dc_power_uncurtailed`, `dni`, `poa`, `aoi`) — see [Solar PV](solar_pv.md) |
| `initial_conditions` | dict | Initial `power`, `dni`, `poa` (placeholders; PVWatts overwrites with modeled values on init) |
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial_conditions description says PVWatts overwrites these with modeled values on init, but SolarPySAMPVWatts currently leaves self.power set to the configured placeholder until the first step() (only dc_power_uncurtailed gets set from the precomputed array during init). Please either adjust this documentation to match current behavior or update initialization/metadata so power reflects the modeled initial AC value.

Suggested change
| `initial_conditions` | dict | Initial `power`, `dni`, `poa` (placeholders; PVWatts overwrites with modeled values on init) |
| `initial_conditions` | dict | Initial `power`, `dni`, `poa` placeholders; modeled values are not all applied on init, and `power` is updated to the modeled AC value on the first `step()` |

Copilot uses AI. Check for mistakes.

### Battery
| Key | Type | Description | Default |
Expand Down
3 changes: 2 additions & 1 deletion docs/output_files.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ hercules_output.h5
│ │ ├── wind_farm.wind_direction_mean # Farm-average wind direction
│ │ ├── wind_farm.turbine_powers.000 # Turbine 0 power (if logged)
│ │ ├── wind_farm.turbine_powers.001 # Turbine 1 power (if logged)
│ │ ├── solar_farm.power # Solar farm power output
│ │ ├── solar_farm.power # Solar farm AC power (kW) (if logged)
│ │ ├── solar_farm.dc_power_uncurtailed # Pre-inverter DC (kW) (if logged)
│ │ ├── solar_farm.dni # Direct normal irradiance (if logged)
│ │ ├── solar_farm.poa # Plane-of-array irradiance (if logged)
│ │ ├── battery.power # Battery power (if present)
Expand Down
53 changes: 24 additions & 29 deletions docs/solar_pv.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
# Solar PV

The solar PV modules use the [PySAM](https://nrel-pysam.readthedocs.io/en/main/overview.html) package for the National Laboratory of the Rockies's System Advisor Model (SAM) to predict the power output of the solar PV plant.
Hercules uses NREL [PySAM](https://nrel-pysam.readthedocs.io/en/main/overview.html) to drive NREL [System Advisor Model (SAM)](https://sam.nrel.gov) PV technology models.

Presently only one solar simulator is available
The only solar implementation currently in Hercules is:

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.
1. **`SolarPySAMPVWatts`** [PVWatts](https://sam.nrel.gov/photovoltaic.html) via PySAM [`Pvwattsv8`](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html). It is fast and suitable for long runs (e.g. about one year). Set `component_type: SolarPySAMPVWatts` in the component YAML. The section key is a user-chosen `component_name` (e.g. `solar_farm`); see [Component Names, Types, and Categories](component_types.md).



## Inputs

Both models require an input weather file:
1. A CSV file that specifies the weather conditions (e.g. NonAnnualSimulation-sample_data-interpolated-daytime.csv). This file should include:
- timestamp (see [timing](timing.md) for time format requirements). Each `time_utc` timestamp marks the **start of a reporting period**; irradiance and weather values on that row are treated as period averages. See [Time Interpretation](timing.md#time-interpretation-inputs-vs-internal-values) for how Hercules converts these to instantaneous values.
- direct normal irradiance (DNI)
- diffuse horizontal irradiance (DHI)
- global horizontal irradiance (GHI)
- wind speed
- air temperature (dry bulb temperature)
The solar component requires a weather time-series file. Supported formats are CSV, pickle (`.p`), Feather (`.f`/`.ftr`), and Parquet. The file should include:

- A `time_utc` column (see [timing](timing.md) for time format requirements). Each `time_utc` value marks the **start of a reporting period**; irradiance and weather on that row are period averages. See [Time Interpretation](timing.md#time-interpretation-inputs-vs-internal-values) for how Hercules converts them to instants.
- DNI, DHI, and GHI in columns whose names include the usual “Direct Normal…”, “Diffuse Horizontal…”, and “Global Horizontal…” substrings (see the solar module’s column lookup)
- Wind speed
- Air temperature (dry-bulb)


The system location (latitude, longitude, and elevation) is specified in the input `yaml` file.


## Outputs

The solar module output is the DC power (`power`) in kW of the PV plant at each timestep. Using DC power makes the parameters `inv_eff` and `dc_to_ac_ratio` irrelevant. The `system_capacity` parameter represents the DC system capacity under Standard Test Conditions.
At each time step, `h_dict[component_name]` is updated with:

- **`power`** (kW): **AC** plant power (PVWatts `Outputs.ac`, W → kW), then the AC setpoint is applied so this is the *delivered* AC when curtailment is active.
- **`dc_power_uncurtailed`** (kW): uncurtailed **pre-inverter** DC (PVWatts `Outputs.dc`, W → kW). It is not curtailed with the AC setpoint.

The YAML **`system_capacity`** is the **DC** array capacity at STC (kW), as in PVWatts. Inverter sizing and AC clipping follow PVWatts `SystemDesign` (including `dc_ac_ratio`); defaults can be changed under `pysam_options` (see below).

The PVWatts model is configured with the following default parameters for utility-scale installations:
- **Module type**: Standard crystalline silicon (module_type = 0)
Expand All @@ -44,7 +47,7 @@ solar_farm:
SystemDesign:
array_type: 3.0 # single axis backtracking
azimuth: 170.0
dc_ac_ratio: 1.0 # Force to 1.0
dc_ac_ratio: 1.0 # kWac nameplate / kWdc STC; common default
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The YAML example comment for dc_ac_ratio is likely inverted. PVWatts typically defines dc_ac_ratio as DC/AC (array kWdc STC ÷ inverter kWac), not kWac ÷ kWdc. Please correct the comment so users don’t misinterpret the sizing/clipping behavior.

Suggested change
dc_ac_ratio: 1.0 # kWac nameplate / kWdc STC; common default
dc_ac_ratio: 1.0 # kWdc STC / kWac nameplate; common default

Copilot uses AI. Check for mistakes.
module_type: 0.0 # standard crystalline silicon
```
You can specify some or all of these parameters and the `pysam_options` parameters will always overwrite the defaults. These parameters represent the minimum parameters needed to define the solar model. For an exhaustive list of additional parameters you can set using this method, see [this page](https://h2integrate.readthedocs.io/en/stable/technology_models/pvwattsv8_solar_pv.html).
Expand All @@ -56,7 +59,8 @@ The array tilt angle must be specified in the input configuration file.
The `log_channels` parameter controls which outputs are written to the HDF5 output file. This is a list of channel names. The `power` channel is always logged, even if not explicitly specified.

**Available Channels:**
- `power`: DC power output in kW (always logged)
- `power`: AC plant power in kW (always logged; same quantity as in `h_dict` after the step)
- `dc_power_uncurtailed`: uncurtailed pre-inverter DC in kW (add to the list to include in the HDF5 output)
- `poa`: Plane-of-array irradiance in W/m²
- `dni`: Direct normal irradiance in W/m²
- `aoi`: Angle of incidence in degrees
Expand All @@ -76,24 +80,15 @@ solar_farm:

If `log_channels` is not specified, only `power` will be logged.

## Efficiency and Loss Parameters

Although the pysam model `SolarPySAMPVWatts` model, technically includes efficiency terms:

- **`inv_eff`** - Inverter efficiency as a percentage (0-99.5). (No longer used in Hercules)
- **`losses`** - System losses as a percentage (0-100). Default recommended value: `0` (no losses). This parameter affects the DC power generated by the PV panels, before any conversion to AC by the inverter.
## Efficiency and loss parameters

The example folder `03_wind_and_solar` specifies:
- use of the `SolarPySAMPVWatts` model with `component_type: "SolarPySAMPVWatts"`
- weather conditions on May 10, 2018 measured at NLR's Flatirons Campus
- latitude, longitude, and elevation of Golden, CO
- system design information for a 100 MW single-axis PV tracking system (with backtracking)
- inverter efficiency of 99.5% and system losses of 0%
PVWatts `SolarPySAMPVWatts` includes lumped and inverter-related terms; the ones exposed in typical Hercules YAML are:

The system capacity can be changed in the `.yaml` file, but the DC/AC ratio is fixed at 1.0.
- **`losses`**: system losses as a percentage (0–100). Affects the modeled **DC** side before the inverter in PVWatts. A common default is `0`.
- **`pysam_options` → `SystemDesign`**: e.g. `dc_ac_ratio`, `array_type`, `azimuth`, `module_type` (see PySAM / SAM documentation for the full set).

For examples using the detailed `SolarPySAMPVSam` model, see the test files in the `tests/` directory.
The `examples/03_wind_and_solar` case uses `SolarPySAMPVWatts` with a 30 MW DC STC `system_capacity`, `losses: 0`, and default single-axis backtracking. Location and weather are set in that example’s input YAML and resource files. Override `dc_ac_ratio` in `pysam_options` if you need a nameplate/clip point different from the default.


## References
PySAM. National Laboratory of the Rockies. Golden, CO. https://github.com/nrel/pysam
PySAM (NREL). https://github.com/nrel/pysam
36 changes: 17 additions & 19 deletions hercules/plant_components/solar_pysam_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@


class SolarPySAMBase(ComponentBase):
"""Base class for PySAM-based solar simulators.
"""Base class for PySAM-based solar (PV) simulators.

This class provides common functionality for both PVSam and PVWatts models,
including weather data processing, solar resource assignment, and control logic.

Note PVSam is no longer supported in Hercules.
Subclasses run a PySAM model, load weather, and apply AC power setpoints. Weather
handling and stepping live here; model-specific precompute is in the subclass.
"""

component_category = "generator"
Expand All @@ -36,12 +34,9 @@ def __init__(self, h_dict, component_name):
# Save the system capacity (in kW - PVWatts DC system capacity)
self.system_capacity = h_dict[self.component_name]["system_capacity"]

# Save the target dc/ac ratio (Force to 1.0)
self.target_dc_ac_ratio = 1.0

# Save the initial condition
self.power = h_dict[self.component_name]["initial_conditions"]["power"]
self.dc_power = h_dict[self.component_name]["initial_conditions"]["power"]
self.dc_power_uncurtailed = h_dict[self.component_name]["initial_conditions"]["power"]
self.dni = h_dict[self.component_name]["initial_conditions"]["dni"]
self.poa = h_dict[self.component_name]["initial_conditions"]["poa"]
self.aoi = 0
Expand Down Expand Up @@ -172,23 +167,21 @@ def get_initial_conditions_and_meta_data(self, h_dict):
# This is a bit of a hack but need this to exist
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]["dc_power_uncurtailed"] = self.dc_power_uncurtailed
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[self.component_name]["starttime_utc"] = self.starttime_utc
h_dict[self.component_name]["starttime_utc"] = self.starttime_utc

return h_dict

def control(self, power_setpoint):
"""Controls the PV plant power output to meet a specified setpoint.

This low-level controller enforces power setpoints for the PV plant by
applying uniform curtailment across the entire plant. Note that DC power
output is not controlled as it is not utilized elsewhere in the code.
This low-level controller enforces AC power setpoints by uniform curtailment
of ``self.power``. Uncurtailed DC from the model remains in
``dc_power_uncurtailed`` (exposed in ``h_dict`` after each step).

Args:
power_setpoint (float, optional): Desired total PV plant output in kW.
Expand All @@ -207,11 +200,15 @@ def control(self, power_setpoint):
def _update_outputs(self, h_dict):
"""Update the h_dict with outputs.

``dc_power_uncurtailed`` is uncurtailed pre-inverter DC (kW) for the
current step when the subclass precomputes ``dc_power_uncurtailed_array``.

Args:
h_dict (dict): Dictionary containing simulation state.
"""
# Update the h_dict with outputs
h_dict[self.component_name]["power"] = self.power
h_dict[self.component_name]["dc_power_uncurtailed"] = self.dc_power_uncurtailed
h_dict[self.component_name]["dni"] = self.dni
h_dict[self.component_name]["poa"] = self.poa
h_dict[self.component_name]["aoi"] = self.aoi
Expand All @@ -238,8 +235,7 @@ def _get_step_outputs(self, step):
def step(self, h_dict):
"""Execute one simulation step.

This is the common step implementation that works for both PVWatts and PVSAM.
Subclasses only need to implement _precompute_power_array() and _get_step_outputs().
Subclasses must implement _precompute_power_array() and _get_step_outputs().

Args:
h_dict (dict): Dictionary containing current simulation state.
Expand All @@ -253,7 +249,9 @@ def step(self, h_dict):
self.logger.info(f"step = {step} (of {self.n_steps})")

# Get the pre-computed uncurtailed power for this step (already in kW)
self.power = self.power_uncurtailed[step]
self.power = self.power_uncurtailed_array[step]
if hasattr(self, "dc_power_uncurtailed_array"):
self.dc_power_uncurtailed = self.dc_power_uncurtailed_array[step]

# Apply control
power_setpoint = h_dict[self.component_name]["power_setpoint"]
Expand Down
17 changes: 8 additions & 9 deletions hercules/plant_components/solar_pysam_pvwatts.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def _setup_model_parameters(self, h_dict):
hercules_defaults = {
"array_type": 3.0, # single axis backtracking
"azimuth": 180.0,
"dc_ac_ratio": 1.0, # default is 1.0 so there are no inverter losses.
"dc_ac_ratio": 1.0, # inverter kWac nameplate / array kWdc STC; 1.0 is a common default
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline description of dc_ac_ratio appears reversed. In PVWatts/SAM this parameter is typically the DC/AC ratio (array kWdc STC divided by inverter kWac nameplate), so describing it as kWac / kWdc is misleading and could cause users to size the inverter incorrectly. Please update the comment to reflect the correct direction of the ratio (and keep terminology consistent with PVWatts docs).

Suggested change
"dc_ac_ratio": 1.0, # inverter kWac nameplate / array kWdc STC; 1.0 is a common default
"dc_ac_ratio": 1.0, # array kWdc STC / inverter kWac nameplate; 1.0 is a common default

Copilot uses AI. Check for mistakes.
"module_type": 0.0, # standard crystalline silicon
}

Expand All @@ -80,11 +80,7 @@ def _setup_model_parameters(self, h_dict):
| h_dict[self.component_name].get("pysam_options", {}).get("SystemDesign", {})
)

sys_design = {
"ModelParams": {"SystemDesign": model_dict},
}

self.model_params = sys_design["ModelParams"]
self.model_params = {"SystemDesign": model_dict}

def _create_system_model(self):
"""Create and configure the PySAM system model."""
Expand Down Expand Up @@ -123,11 +119,14 @@ def _precompute_power_array(self):
# Execute the model once for all time steps
self.system_model.execute()

# Store the pre-computed power array (convert from W to kW)
# Use DC power output directly from PVWatts
self.power_uncurtailed = (
# W -> kW: uncurtailed AC (plant output) and DC (pre-inverter) from PVWatts
self.power_uncurtailed_array = (
np.array(self.system_model.Outputs.ac, dtype=hercules_float_type) / 1000.0
)
self.dc_power_uncurtailed_array = (
np.array(self.system_model.Outputs.dc, dtype=hercules_float_type) / 1000.0
)
self.dc_power_uncurtailed = self.dc_power_uncurtailed_array[0]

# Store other outputs as arrays for efficient access
self.dni_array_output = np.array(self.system_model.Outputs.dn, dtype=hercules_float_type)
Expand Down
4 changes: 2 additions & 2 deletions tests/example_regression_tests/example_03_regression_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
# Test configuration
NUM_TIME_STEPS = 5
EXPECTED_FINAL_WIND_POWER = 14321 # Updated for midpoint interpolation correction
EXPECTED_FINAL_SOLAR_POWER = 21054 # Updated for midpoint interpolation correction
EXPECTED_FINAL_PLANT_POWER = 35375 # Wind + Solar (14321 + 21054)
EXPECTED_FINAL_SOLAR_POWER = 20165 # AC PVWatts output (post-inverter)
EXPECTED_FINAL_PLANT_POWER = 34486 # Wind + Solar (14321 + 20165)

# File names
INPUT_FILE = "hercules_input.yaml"
Expand Down
8 changes: 4 additions & 4 deletions tests/solar_pysam_pvwatts_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_init():
# Test that system_capacity is stored correctly
assert SPS.system_capacity == test_h_dict["solar_farm"]["system_capacity"]
assert SPS.power == test_h_dict["solar_farm"]["initial_conditions"]["power"]
assert SPS.dc_power == test_h_dict["solar_farm"]["initial_conditions"]["power"]
assert SPS.dc_power_uncurtailed == SPS.dc_power_uncurtailed_array[0]
assert SPS.dni == test_h_dict["solar_farm"]["initial_conditions"]["dni"]
assert SPS.aoi == 0

Expand Down Expand Up @@ -151,9 +151,9 @@ def test_step():

SPS.step(step_inputs)

# test the calculated power output (0° tilt)
# test the calculated power output (0° tilt, AC post-inverter)
# Using decimal=4 for float32 precision (hercules_float_type provides ~6-7 significant digits)
assert_almost_equal(SPS.power, 17092.157367793126, decimal=4)
assert_almost_equal(SPS.power, 16010.88671875, decimal=4)

# test the irradiance input
# Using decimal=4 for float32 precision (hercules_float_type provides ~6-7 significant digits)
Expand All @@ -169,7 +169,7 @@ def test_control():
power_setpoint = 100000 # Above uncurtailed power
step_inputs = {"step": 0, "solar_farm": {"power_setpoint": power_setpoint}}
SPS.step(step_inputs)
uncurtailed_power = SPS.power_uncurtailed[0]
uncurtailed_power = SPS.power_uncurtailed_array[0]
assert_almost_equal(SPS.power, uncurtailed_power, decimal=8) # uncurtailed power

# Test curtailment - set power below uncurtailed power, should get setpoint
Expand Down
Loading