Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/h_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Any top-level `h_dict` entry whose value is a dict containing a `component_type`
| `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) |
| `use_resource_solar_dt` | bool | Optional; default `true`. If the weather file's resource dt is coarser than `dt`, run PySAM once at resource resolution and upsample its outputs to the Hercules grid — see [Solar PV](solar_pv.md#resource-resolution-pysam-execution-use_resource_solar_dt) |
| `lat` | float | Latitude |
| `lon` | float | Longitude |
| `elev` | float | Elevation in meters |
Expand Down
1 change: 1 addition & 0 deletions docs/hercules_input.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ solar_farm: # User-chosen component_name
elev: 1829
system_capacity: 10000 # kW (10 MW)
tilt: 0 # degrees
# use_resource_solar_dt: true # optional; default true. See solar_pv.md.
log_channels:
- power
- dni
Expand Down
50 changes: 50 additions & 0 deletions docs/solar_pv.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,56 @@ The solar component requires a weather time-series file. Supported formats are C

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

## Resource-resolution PySAM execution (`use_resource_solar_dt`)

By default, when the solar weather file is at a coarser time step than the
Hercules `dt`, PySAM is executed **once** at the resource weather resolution
and its power and diagnostic outputs are upsampled to the Hercules grid.
This avoids re-running PySAM tens of thousands of times against
essentially-identical interpolated weather, which becomes the dominant cost
once bifacial modeling is enabled.

The behavior is controlled by an opt-out flag:

```yaml
solar_farm:
use_resource_solar_dt: false # defaults to true, opt out by using false
```

- The feature only activates when the file's resource dt is strictly greater
than `dt`. When the resource dt equals `dt` (or the flag is set to `false`),
Hercules falls back to the existing path that runs PySAM once per Hercules
step.
- Resource dt is auto-detected from the loaded weather file (the difference
between consecutive sorted timestamps).
- The PySAM outputs are upsampled to Hercules dt using
`"averaged_to_instantaneous"` (the same midpoint-corrected interpolation
introduced in PR \#249). This is the single boundary crossing from the
raw start-of-period averaged convention to the Hercules instantaneous
convention - see [Time Interpretation](timing.md#time-interpretation-inputs-vs-internal-values).

### PVWatts time convention (why this is safe)

PVWatts is **convention-preserving**: each input row is interpreted as a
start-of-period average over the model's current time step, and each output
row is reported under the same convention. The only mid-step shift PVWatts
applies is internal sun-position math, which it computes at the midpoint of
each input time step (see the SAM help page on
[Time and Sun Position](https://samrepo.nlr.gov/help/weather_time_convention.html)
and the
[PVWatts Version 5 Manual](https://docs.nrel.gov/docs/fy14osti/62641.pdf)
§"Sun Position"). Because PVWatts does not shift the *values* of the
irradiance/temperature inputs, performing the single
"averaged → instantaneous" boundary crossing on the PVWatts *outputs* (at
Hercules dt) is numerically equivalent to performing it on the *inputs* (at
Hercules dt) and then running PVWatts at every Hercules step in the
linear-PVWatts limit.
The one remaining numerical effect of the toggle is that PVWatts'
internal sun-position half-step now operates at `dt_compute / 2` rather
than `dt_hercules / 2`. For hourly resource data near sunrise/sunset this
can introduce a small bias relative to the prior path; setting
`use_resource_solar_dt: false` recovers the prior behaviour exactly.


## Power Flow

Expand Down
7 changes: 7 additions & 0 deletions docs/timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ time value
Querying at 13:00 yields 150 (halfway between midpoints).
```

The same `"averaged_to_instantaneous"` mechanism is also applied to the
**outputs** of the PySAM solar model when `use_resource_solar_dt` is active
(its default). In that mode PySAM is executed once on the resource weather
grid and its power/diagnostic outputs are upsampled to the Hercules grid
via this single boundary crossing - see
[Resource-resolution PySAM execution](solar_pv.md#resource-resolution-pysam-execution-use_resource_solar_dt).

#### `"instantaneous_to_instantaneous"`

Input values already represent instantaneous measurements at their
Expand Down
1 change: 1 addition & 0 deletions examples/hercules_input_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ wind_farm:
# system_capacity: 100000 # DC system capacity in kW under Standard Test Conditions (required)
# tilt: 0 # Array tilt angle in degrees (required)
# losses: 0 # System losses as a percentage (0-100, default: 0)
# use_resource_solar_dt: true # Optional (default: true). When the weather file's resource dt is coarser than `dt`, run PySAM once at resource resolution and upsample its outputs to the Hercules grid (see docs/solar_pv.md).
# log_file_name: outputs/log_solar_farm.log # Path to solar farm log file (default: outputs/log_solar_farm.log)
# log_channels: # List of output channels to log (power is always logged even if not specified)
# - power # Delivered AC power in kW after control (always logged)
Expand Down
88 changes: 85 additions & 3 deletions hercules/plant_components/solar_pysam_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,65 @@ def _load_solar_data(self, h_dict):
# Set starttime_utc (zero_time_utc is redundant since time=0 corresponds to starttime_utc)
self.starttime_utc = starttime_utc

# Interpolate df_solar on to the time steps
time_steps_all = np.arange(self.starttime, self.endtime, self.dt, dtype=hercules_float_type)
# Determine the dt implied by the weather file (after sorting to be safe)
df_solar = df_solar.sort_values("time").reset_index(drop=True)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

dt_solar is inferred via df_solar["time"].iloc[1] - iloc[0], which will raise an IndexError when the solar input file has fewer than 2 rows (and the resulting exception message won’t explain what’s wrong). Add an explicit length check with a clear ValueError (or define a fallback) before indexing iloc[1].

Suggested change
df_solar = df_solar.sort_values("time").reset_index(drop=True)
df_solar = df_solar.sort_values("time").reset_index(drop=True)
if len(df_solar) < 2:
raise ValueError(
"Solar input file must contain at least two rows to infer the native "
"solar timestep"
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, seems like the edgiest edge case but taking the suggestion

if len(df_solar) < 2:
raise ValueError(
"Solar input file must contain at least "
"two rows to infer the resource solar timestep"
)
self.dt_solar = float(df_solar["time"].iloc[1] - df_solar["time"].iloc[0])

# Read the use_resource_solar_dt option (default True). When True and the
# solar file is at a coarser dt than Hercules, PySAM is run on the
# resource-resolution grid and its outputs are upsampled to the Hercules
# grid via ``_upsample_outputs_to_hercules_dt`` (the av_to_instant
# happens there instead of on the weather inputs).
self.use_resource_solar_dt = h_dict[self.component_name].get("use_resource_solar_dt", True)

# Hercules-grid time steps (used by the upsample helper).
self._hercules_time_steps = np.arange(
self.starttime, self.endtime, self.dt, dtype=hercules_float_type
)

# Decide the compute (PySAM) grid. In the use_resource_solar_dt case runPySAM
# at the resource dt and upsample its outputs; use resource weather-file
# stamps directly (instead of start + n*dt) so start-of-period averages
# keep their original interval alignment when starttime_utc is offset
# from the resource reporting boundary. Include one point strictly before
# starttime and one point at/after endtime when available so the
# downstream ``averaged_to_instantaneous`` upsample has midpoints on
# both sides of every Hercules time step (no edge clamping). For
# i_start we use ``side="left"`` so that when ``starttime`` falls
# exactly on a resource stamp we still pick the previous one; this is a
# no-op for offsets falling strictly between resource stamps and is
# clamped to 0 when ``starttime`` matches the file's first stamp.
# In the fallback (compute_dt == dt) the compute grid equals the
# Hercules grid so downstream array lengths match step indexing
# exactly, preserving the pre-existing behaviour.
if self.use_resource_solar_dt and self.dt_solar > self.dt:
self._compute_dt = self.dt_solar
resource_time = df_solar["time"].to_numpy(dtype=hercules_float_type)
i_start = max(np.searchsorted(resource_time, self.starttime, side="left") - 1, 0)
i_end = min(
np.searchsorted(resource_time, self.endtime, side="left"), len(resource_time) - 1
)
self._compute_time_steps = resource_time[i_start : i_end + 1]
interpolation_method = "instantaneous_to_instantaneous"
else:
# Else compute at the Hercules dt
self._compute_dt = self.dt
self._compute_time_steps = self._hercules_time_steps
interpolation_method = "averaged_to_instantaneous"

# Interpolate df_solar onto the compute grid. The method is conditional:
# in the use_resource_solar_dt case (compute_dt > dt) we keep PVWatts-bound weather
# in the raw start-of-period averaged convention via ``i_to_i`` and
# defer the av_to_i to the post-PVWatts upsample. In the
# fallback (compute_dt == dt) we cross the av_to_i boundary here, exactly as
# the existing PR #249 path does.
df_solar = interpolate_df(
df_solar, time_steps_all, interpolation_method="averaged_to_instantaneous"
df_solar, self._compute_time_steps, interpolation_method=interpolation_method
)

# Can now save the input data as simple columns
Expand Down Expand Up @@ -153,6 +208,33 @@ def _get_solar_data_array(self, df_, column_substring):
return df_[column].values
raise ValueError(f"Could not find column with substring {column_substring} in df_solar")

def _upsample_outputs_to_hercules_dt(self, output_arrays):
"""Upsample model outputs from the compute dt to the Hercules dt.

PVWatts is convention-preserving: its outputs are start-of-period
averaged at the compute-grid stamps, so the single PR #249
``"averaged_to_instantaneous"`` boundary crossing happens here. When
the compute and Hercules grids match (feature off or resource_dt <= dt),
this is a no-op.

Args:
output_arrays (dict): Mapping of output name to 1-D array on the
compute grid (length ``len(self._compute_time_steps)``).

Returns:
dict: Same keys, arrays resampled onto ``self._hercules_time_steps``
and cast to ``hercules_float_type``.
"""
if self._compute_dt == self.dt:
return output_arrays
df = pd.DataFrame({"time": self._compute_time_steps, **output_arrays})
df_up = interpolate_df(
df,
self._hercules_time_steps,
interpolation_method="averaged_to_instantaneous",
)
return {k: df_up[k].values.astype(hercules_float_type) for k in output_arrays}

def get_initial_conditions_and_meta_data(self, h_dict):
"""Add any initial conditions or meta data to the h_dict.

Expand Down
40 changes: 26 additions & 14 deletions hercules/plant_components/solar_pysam_pvwatts.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,35 @@ def _precompute_power_array(self):
# Execute the model once for all time steps
self.system_model.execute()

# W -> kW: AC (post-inverter) and DC (pre-inverter) potential from PVWatts.
# Both are "available" power: the model output before any Hercules control curtailment.
self.ac_power_available_array = (
np.array(self.system_model.Outputs.ac, dtype=hercules_float_type) / 1000.0
)
self.dc_power_available_array = (
np.array(self.system_model.Outputs.dc, dtype=hercules_float_type) / 1000.0
)
# Pull all outputs at the compute-grid resolution, with W -> kW for the
# power channels. AC (post-inverter) and DC (pre-inverter) are
# "available" power: the model output before any Hercules control
# curtailment.
compute_outputs = {
"ac_power_available": np.array(self.system_model.Outputs.ac, dtype=hercules_float_type)
/ hercules_float_type(1000.0),
"dc_power_available": np.array(self.system_model.Outputs.dc, dtype=hercules_float_type)
/ hercules_float_type(1000.0),
"dni": np.array(self.system_model.Outputs.dn, dtype=hercules_float_type),
"dhi": np.array(self.system_model.Outputs.df, dtype=hercules_float_type),
"ghi": np.array(self.system_model.Outputs.gh, dtype=hercules_float_type),
"aoi": np.array(self.system_model.Outputs.aoi, dtype=hercules_float_type),
"poa": np.array(self.system_model.Outputs.poa, dtype=hercules_float_type),
}

# Upsample to the Hercules dt grid (no-op when compute_dt == dt).
hercules_outputs = self._upsample_outputs_to_hercules_dt(compute_outputs)

self.ac_power_available_array = hercules_outputs["ac_power_available"]
self.dc_power_available_array = hercules_outputs["dc_power_available"]
self.ac_power_available = self.ac_power_available_array[0]
self.dc_power_available = self.dc_power_available_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)
self.dhi_array_output = np.array(self.system_model.Outputs.df, dtype=hercules_float_type)
self.ghi_array_output = np.array(self.system_model.Outputs.gh, dtype=hercules_float_type)
self.aoi_array_output = np.array(self.system_model.Outputs.aoi, dtype=hercules_float_type)
self.poa_array_output = np.array(self.system_model.Outputs.poa, dtype=hercules_float_type)
self.dni_array_output = hercules_outputs["dni"]
self.dhi_array_output = hercules_outputs["dhi"]
self.ghi_array_output = hercules_outputs["ghi"]
self.aoi_array_output = hercules_outputs["aoi"]
self.poa_array_output = hercules_outputs["poa"]

def _get_step_outputs(self, step):
"""Get the outputs for a specific step from pre-computed arrays.
Expand Down
Loading
Loading