diff --git a/docs/h_dict.md b/docs/h_dict.md index 58f24a95..7626469d 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -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) | ### Battery | Key | Type | Description | Default | diff --git a/docs/output_files.md b/docs/output_files.md index 1f872792..6fc9996e 100644 --- a/docs/output_files.md +++ b/docs/output_files.md @@ -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) diff --git a/docs/solar_pv.md b/docs/solar_pv.md index c587266d..b52317a2 100644 --- a/docs/solar_pv.md +++ b/docs/solar_pv.md @@ -1,23 +1,21 @@ # 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. @@ -25,7 +23,12 @@ The system location (latitude, longitude, and elevation) is specified in the inp ## 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) @@ -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 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). @@ -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 @@ -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 diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 857ecc1a..80bf0533 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -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" @@ -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 @@ -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. @@ -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 @@ -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. @@ -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"] diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index 7fe7a07b..a3029d4d 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -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 "module_type": 0.0, # standard crystalline silicon } @@ -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.""" @@ -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) diff --git a/tests/example_regression_tests/example_03_regression_test.py b/tests/example_regression_tests/example_03_regression_test.py index 16d74c8f..bf812db7 100644 --- a/tests/example_regression_tests/example_03_regression_test.py +++ b/tests/example_regression_tests/example_03_regression_test.py @@ -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" diff --git a/tests/solar_pysam_pvwatts_test.py b/tests/solar_pysam_pvwatts_test.py index 32d00106..62e715aa 100644 --- a/tests/solar_pysam_pvwatts_test.py +++ b/tests/solar_pysam_pvwatts_test.py @@ -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 @@ -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) @@ -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