diff --git a/docs/_static/solar_power_flow.svg b/docs/_static/solar_power_flow.svg new file mode 100644 index 00000000..78c274af --- /dev/null +++ b/docs/_static/solar_power_flow.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + Solar + Arrays + + + + Inverters + + + + Controller + + + + dc_power_available + + + + ac_power_available + + + + power + diff --git a/docs/h_dict.md b/docs/h_dict.md index 58f24a95..b6e82402 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`, `ac_power_available`, `dc_power_available`, `dni`, `poa`, `aoi`) — see [Solar PV](solar_pv.md) | +| `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()` | ### Battery | Key | Type | Description | Default | diff --git a/docs/output_files.md b/docs/output_files.md index 1f872792..72408ffb 100644 --- a/docs/output_files.md +++ b/docs/output_files.md @@ -26,7 +26,9 @@ 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 after control (kW) +│ │ ├── solar_farm.ac_power_available # Post-inverter AC potential (kW) (if logged) +│ │ ├── solar_farm.dc_power_available # Pre-inverter DC potential (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..82765fdf 100644 --- a/docs/solar_pv.md +++ b/docs/solar_pv.md @@ -1,31 +1,58 @@ # 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 NLR [PySAM](https://nrel-pysam.readthedocs.io/en/main/overview.html) to drive NLR [System Advisor Model (SAM)](https://sam.nlr.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.nlr.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. +## Power Flow + +The solar component models three distinct power quantities at each time step, +corresponding to successive stages along the plant's electrical path: + +```{image} _static/solar_power_flow.svg +:alt: Solar power flow from arrays through inverters to the controller +:width: 700px +:align: center +``` + +- **`dc_power_available`** (kW): the full **DC potential** of the arrays, before + the inverters. This is the modeled DC output (e.g. PVWatts `Outputs.dc`, + W → kW) and reflects irradiance, temperature, and DC-side losses, but not + inverter behavior or control. +- **`ac_power_available`** (kW): the **post-inverter AC potential** of the + plant. It includes inverter inefficiency and AC clipping at the inverter + nameplate (per PVWatts `Outputs.ac`, W → kW), but does not include any + Hercules control-based curtailment. +- **`power`** (kW): the **delivered AC** power after the Hercules controller. + When the controller imposes an AC setpoint below `ac_power_available`, + `power` reflects the curtailed value; otherwise `power` equals + `ac_power_available`. + ## 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`, +`ac_power_available`, and `dc_power_available` (all in kW), as well as the +weather/geometry diagnostics (`dni`, `poa`, `aoi`). All three power quantities +can be selected for HDF5 logging via `log_channels` (see below), though the `power` variable is always logged by default. + +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,19 +71,21 @@ 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 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). -The array tilt angle must be specified in the input configuration file. +The **`tilt`** is the array tilt angle in degrees, measured from horizontal, and must be specified in the input configuration file. Together with **`system_capacity`** and **`losses`** (see below), it is one of the three required top-level keys for the solar component; all other PVWatts parameters fall back to Hercules defaults or can be overridden under `pysam_options`. ## Logging Configuration 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`: delivered AC plant power in kW after control (always logged; same quantity as in `h_dict` after the step) +- `ac_power_available`: post-inverter AC potential in kW, before control curtailment (add to the list to include in the HDF5 output) +- `dc_power_available`: pre-inverter DC potential of the arrays 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 +105,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 loss terms. The loss/efficiency parameters 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 (NLR). https://github.com/NatLabRockies/pysam diff --git a/examples/hercules_input_example.yaml b/examples/hercules_input_example.yaml index b2d56956..05766179 100644 --- a/examples/hercules_input_example.yaml +++ b/examples/hercules_input_example.yaml @@ -79,7 +79,9 @@ wind_farm: # losses: 0 # System losses as a percentage (0-100, default: 0) # 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 # DC power output in kW (always logged) +# - power # Delivered AC power in kW after control (always logged) +# - ac_power_available # Post-inverter AC potential in kW (before control curtailment) +# - dc_power_available # Pre-inverter DC potential of the arrays in kW # - poa # Plane-of-array irradiance in W/m² # - dni # Direct normal irradiance in W/m² # - aoi # Angle of incidence in degrees diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 857ecc1a..2494df02 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,10 @@ 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.ac_power_available = h_dict[self.component_name]["initial_conditions"]["power"] + self.dc_power_available = 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 +168,23 @@ 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]["ac_power_available"] = self.ac_power_available + h_dict[self.component_name]["dc_power_available"] = self.dc_power_available 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``. The pre-control AC potential remains in + ``ac_power_available`` and the pre-inverter DC potential remains in + ``dc_power_available`` (both exposed in ``h_dict`` after each step). Args: power_setpoint (float, optional): Desired total PV plant output in kW. @@ -207,11 +203,19 @@ def control(self, power_setpoint): def _update_outputs(self, h_dict): """Update the h_dict with outputs. + ``ac_power_available`` is the post-inverter AC potential (kW) and + ``dc_power_available`` is the pre-inverter DC potential (kW) for the + current step. Both are reported before any control-based curtailment. + ``dc_power_available`` is populated when the subclass precomputes + ``dc_power_available_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]["ac_power_available"] = self.ac_power_available + h_dict[self.component_name]["dc_power_available"] = self.dc_power_available 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 +242,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. @@ -252,8 +255,11 @@ def step(self, h_dict): if self.verbose: 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] + # Get the pre-computed available (pre-control) power for this step (already in kW) + self.ac_power_available = self.ac_power_available_array[step] + self.power = self.ac_power_available + if hasattr(self, "dc_power_available_array"): + self.dc_power_available = self.dc_power_available_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..f97080ed 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, "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,16 @@ 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: 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 ) + 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) 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..a067c6d8 100644 --- a/tests/solar_pysam_pvwatts_test.py +++ b/tests/solar_pysam_pvwatts_test.py @@ -20,7 +20,8 @@ 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.ac_power_available == SPS.ac_power_available_array[0] + assert SPS.dc_power_available == SPS.dc_power_available_array[0] assert SPS.dni == test_h_dict["solar_farm"]["initial_conditions"]["dni"] assert SPS.aoi == 0 @@ -151,9 +152,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) @@ -164,16 +165,16 @@ def test_control(): test_h_dict = copy.deepcopy(h_dict_solar_pvwatts) SPS = SolarPySAMPVWatts(test_h_dict, "solar_farm") - # Test curtailment - set power setpoint above uncurtailed power, - # should get uncurtailed power - power_setpoint = 100000 # Above uncurtailed power + # Test curtailment - set power setpoint above available power, + # should get available power + power_setpoint = 100000 # Above available power step_inputs = {"step": 0, "solar_farm": {"power_setpoint": power_setpoint}} SPS.step(step_inputs) - uncurtailed_power = SPS.power_uncurtailed[0] - assert_almost_equal(SPS.power, uncurtailed_power, decimal=8) # uncurtailed power + ac_power_available = SPS.ac_power_available_array[0] + assert_almost_equal(SPS.power, ac_power_available, decimal=8) # available power - # Test curtailment - set power below uncurtailed power, should get setpoint - power_setpoint = 100 # Below uncurtailed power + # Test curtailment - set power below available power, should get setpoint + power_setpoint = 100 # Below available power step_inputs = {"step": 0, "solar_farm": {"power_setpoint": power_setpoint}} SPS.step(step_inputs) assert_almost_equal(SPS.power, power_setpoint, decimal=8)