diff --git a/docs/h_dict.md b/docs/h_dict.md index 98b35ebf..92e0c8eb 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -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 | diff --git a/docs/hercules_input.md b/docs/hercules_input.md index 982066ed..b9480f42 100644 --- a/docs/hercules_input.md +++ b/docs/hercules_input.md @@ -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 diff --git a/docs/solar_pv.md b/docs/solar_pv.md index 82765fdf..9bc26bdd 100644 --- a/docs/solar_pv.md +++ b/docs/solar_pv.md @@ -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 diff --git a/docs/timing.md b/docs/timing.md index 29e8f5f6..0c061c57 100644 --- a/docs/timing.md +++ b/docs/timing.md @@ -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 diff --git a/examples/hercules_input_example.yaml b/examples/hercules_input_example.yaml index c3f5fab0..5836ad1f 100644 --- a/examples/hercules_input_example.yaml +++ b/examples/hercules_input_example.yaml @@ -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) diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 2494df02..9b9ec70c 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -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) + 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 @@ -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. diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index f97080ed..456491f0 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -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. diff --git a/tests/solar_pysam_pvwatts_resource_dt_test.py b/tests/solar_pysam_pvwatts_resource_dt_test.py new file mode 100644 index 00000000..bcbb2afd --- /dev/null +++ b/tests/solar_pysam_pvwatts_resource_dt_test.py @@ -0,0 +1,318 @@ +"""Tests for the use_resource_solar_dt path in SolarPySAMPVWatts. + +These tests exercise the new feature added to ``SolarPySAMBase`` / +``SolarPySAMPVWatts`` that runs PySAM once on the resource-resolution weather +grid (when coarser than the Hercules dt) and upsamples its outputs to the +Hercules grid. They verify: + +1. Numerical agreement with the feature-off path for slowly-varying weather + (the "approach 2" claim from the design discussion: i_to_i + a_to_i is + numerically equivalent to the existing path in the linear-PVWatts limit). +2. The toggle correctly drives ``_compute_dt``. +3. The fallback path (``dt_solar == dt``) is a no-op. +""" + +import numpy as np +import pandas as pd +import pytest +from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts + + +def _build_synthetic_solar_file(path, starttime_utc, endtime_utc, dt_seconds): + """Write a synthetic solar feather file with slowly-varying weather. + + The weather is a smooth half-sine envelope so PVWatts is approximately + linear over each resource interval (this is what makes approach 2 + numerically agree with the existing path). + + Args: + path (Path): Destination file path (feather format). + starttime_utc (pd.Timestamp): UTC start time of the file. + endtime_utc (pd.Timestamp): UTC end time the file must cover. + dt_seconds (float): resource time-step of the synthetic file in seconds. + + Returns: + pd.DataFrame: The written DataFrame (also persisted to ``path``). + """ + duration = (endtime_utc - starttime_utc).total_seconds() + n_rows = int(np.ceil(duration / dt_seconds)) + 1 + t = np.arange(n_rows) * dt_seconds + time_utc = pd.date_range( + start=starttime_utc, periods=n_rows, freq=f"{int(dt_seconds)}s", tz="UTC" + ) + + envelope = np.sin(np.pi * t / duration) + ghi = 700.0 + 30.0 * envelope + dni = 800.0 + 20.0 * envelope + dhi = 100.0 + 10.0 * envelope + temp = 25.0 + 0.5 * envelope + wspd = 2.0 + 0.1 * envelope + + df = pd.DataFrame( + { + "time_utc": time_utc, + "SRRL BMS Global Horizontal Irradiance (W/m^2_irr)": ghi.astype(np.float32), + "SRRL BMS Direct Normal Irradiance (W/m^2_irr)": dni.astype(np.float32), + "SRRL BMS Diffuse Horizontal Irradiance (W/m^2_irr)": dhi.astype(np.float32), + "SRRL BMS Dry Bulb Temperature (C)": temp.astype(np.float32), + "SRRL BMS Wind Speed at 19' (m/s)": wspd.astype(np.float32), + } + ) + df.to_feather(path) + return df + + +def _build_h_dict(solar_input_filename, starttime_utc, endtime_utc, dt, use_resource_solar_dt): + """Build a minimal h_dict for instantiating SolarPySAMPVWatts. + + Args: + solar_input_filename (Path or str): Path to the solar weather file. + starttime_utc (pd.Timestamp): Simulation start (UTC). + endtime_utc (pd.Timestamp): Simulation end (UTC). + dt (float): Hercules time step in seconds. + use_resource_solar_dt (bool): Value of the new YAML toggle. + + Returns: + dict: An h_dict suitable for ``SolarPySAMPVWatts`` construction. + """ + endtime_seconds = (endtime_utc - starttime_utc).total_seconds() + return { + "dt": dt, + "starttime": 0.0, + "endtime": endtime_seconds, + "starttime_utc": starttime_utc, + "endtime_utc": endtime_utc, + "verbose": False, + "step": 0, + "time": 0.0, + "plant": {"interconnect_limit": 60000.0}, + "solar_farm": { + "component_type": "SolarPySAMPVWatts", + "solar_input_filename": str(solar_input_filename), + "lat": 39.7442, + "lon": -105.1778, + "elev": 1829, + "system_capacity": 30000.0, + "tilt": 0, + "losses": 0, + "use_resource_solar_dt": use_resource_solar_dt, + "log_channels": ["power"], + "initial_conditions": {"power": 0.0, "dni": 0.0, "poa": 0.0}, + }, + } + + +def test_resource_dt_matches_full_resolution_within_tolerance(tmp_path): + """Approach 2 should agree closely with the feature-off path. + + With slowly-varying weather (linear-PVWatts limit), the PVWatts-once-at- + resource-dt + upsample path is numerically equivalent to the existing + PVWatts-at-Hercules-dt path. The only residual difference comes from + PVWatts' internal sun-position half-step shift moving from + ``dt_hercules / 2`` to ``dt_compute / 2``, which is small for short + sim windows away from sunrise/sunset. + """ + starttime_utc = pd.to_datetime("2024-06-24T17:00:00Z") + endtime_utc = pd.to_datetime("2024-06-24T17:10:00Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, starttime_utc, endtime_utc, dt_resource) + + h_dict_resource = _build_h_dict( + solar_input, starttime_utc, endtime_utc, dt, use_resource_solar_dt=True + ) + spv_resource = SolarPySAMPVWatts(h_dict_resource, "solar_farm") + + h_dict_full = _build_h_dict( + solar_input, starttime_utc, endtime_utc, dt, use_resource_solar_dt=False + ) + spv_full = SolarPySAMPVWatts(h_dict_full, "solar_farm") + + assert spv_resource._compute_dt == pytest.approx(dt_resource) + assert spv_full._compute_dt == pytest.approx(dt) + + expected_n = int((spv_resource.endtime - spv_resource.starttime) / dt) + assert spv_resource.ac_power_available_array.shape[0] == expected_n + assert spv_full.ac_power_available_array.shape[0] == expected_n + + # Approach-2 numerical equivalence: AC and DC arrays match closely. + # The atol absorbs float32 rounding and the small sun-position-half-step + # bias on ~30 MW magnitudes. + np.testing.assert_allclose( + spv_resource.ac_power_available_array, + spv_full.ac_power_available_array, + rtol=1e-3, + atol=10.0, + ) + np.testing.assert_allclose( + spv_resource.dc_power_available_array, + spv_full.dc_power_available_array, + rtol=1e-3, + atol=10.0, + ) + + +def test_resource_dt_is_no_op_when_dt_solar_equals_dt(tmp_path): + """The fallback path should behave exactly like the pre-feature code. + + When the weather file's resource dt equals the Hercules dt, ``_compute_dt`` + must collapse to ``dt`` and the compute grid must coincide with the + Hercules grid; the upsample helper is then a no-op and outputs are + indexed directly by Hercules step. + """ + starttime_utc = pd.to_datetime("2024-06-24T17:00:00Z") + endtime_utc = pd.to_datetime("2024-06-24T17:00:30Z") + dt = 1.0 + dt_resource = 1.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, starttime_utc, endtime_utc, dt_resource) + + h_dict = _build_h_dict(solar_input, starttime_utc, endtime_utc, dt, use_resource_solar_dt=True) + spv = SolarPySAMPVWatts(h_dict, "solar_farm") + + assert spv._compute_dt == pytest.approx(dt) + expected_n = int((spv.endtime - spv.starttime) / dt) + assert spv.ac_power_available_array.shape[0] == expected_n + assert spv._compute_time_steps is spv._hercules_time_steps + + +def test_use_resource_solar_dt_false_forces_fallback(tmp_path): + """Explicit opt-out forces the existing path even on coarse-resource data. + + With ``use_resource_solar_dt: False``, ``_compute_dt`` must equal ``dt`` + regardless of how coarse the input file is. + """ + starttime_utc = pd.to_datetime("2024-06-24T17:00:00Z") + endtime_utc = pd.to_datetime("2024-06-24T17:01:00Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, starttime_utc, endtime_utc, dt_resource) + + h_dict = _build_h_dict(solar_input, starttime_utc, endtime_utc, dt, use_resource_solar_dt=False) + spv = SolarPySAMPVWatts(h_dict, "solar_farm") + + assert spv._compute_dt == pytest.approx(dt) + assert spv.dt_solar == pytest.approx(dt_resource) + + +def test_resource_dt_uses_resource_timestamp_alignment_for_offset_start(tmp_path): + """resource path should anchor compute stamps to weather-file boundaries. + + With an offset simulation start and coarse resource weather timestamps, the + compute grid should use the resource stamp immediately before starttime and + the resource stamp at/after endtime (rather than start + n * dt_resource). + """ + file_start_utc = pd.to_datetime("2024-06-24T17:00:00Z") + sim_start_utc = pd.to_datetime("2024-06-24T17:00:30Z") + sim_end_utc = pd.to_datetime("2024-06-24T17:05:30Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, file_start_utc, sim_end_utc, dt_resource) + + h_dict_resource = _build_h_dict( + solar_input, sim_start_utc, sim_end_utc, dt, use_resource_solar_dt=True + ) + spv_resource = SolarPySAMPVWatts(h_dict_resource, "solar_farm") + + assert spv_resource._compute_dt == pytest.approx(dt_resource) + assert spv_resource._compute_time_steps[0] == pytest.approx(-30.0) + assert spv_resource._compute_time_steps[-1] == pytest.approx(330.0) + np.testing.assert_allclose(np.diff(spv_resource._compute_time_steps), dt_resource) + + +def test_resource_dt_includes_previous_stamp_when_start_aligns_to_resource(tmp_path): + """Compute grid should include the resource stamp before an aligned start. + + When ``starttime_utc`` falls exactly on a resource weather timestamp and + the file extends earlier, the compute grid must include that previous + resource stamp so ``_upsample_outputs_to_hercules_dt`` has midpoints on + both sides of the first Hercules step (avoiding ``np.interp`` clamping + at the left edge). + """ + file_start_utc = pd.to_datetime("2024-06-24T17:00:00Z") + sim_start_utc = pd.to_datetime("2024-06-24T17:01:00Z") + sim_end_utc = pd.to_datetime("2024-06-24T17:05:00Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, file_start_utc, sim_end_utc, dt_resource) + + h_dict = _build_h_dict(solar_input, sim_start_utc, sim_end_utc, dt, use_resource_solar_dt=True) + spv = SolarPySAMPVWatts(h_dict, "solar_farm") + + assert spv._compute_dt == pytest.approx(dt_resource) + assert spv._compute_time_steps[0] == pytest.approx(-dt_resource) + assert spv._compute_time_steps[1] == pytest.approx(0.0) + np.testing.assert_allclose(np.diff(spv._compute_time_steps), dt_resource) + + +def test_resource_dt_clamps_when_start_equals_file_first_stamp(tmp_path): + """Compute grid must clamp to index 0 when starttime equals the first stamp. + + When ``starttime_utc`` equals the file's earliest timestamp there is no + earlier resource point to include, so the compute grid should start at the + first resource stamp (i.e. simulation time 0). + """ + starttime_utc = pd.to_datetime("2024-06-24T17:00:00Z") + endtime_utc = pd.to_datetime("2024-06-24T17:05:00Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, starttime_utc, endtime_utc, dt_resource) + + h_dict = _build_h_dict(solar_input, starttime_utc, endtime_utc, dt, use_resource_solar_dt=True) + spv = SolarPySAMPVWatts(h_dict, "solar_farm") + + assert spv._compute_time_steps[0] == pytest.approx(0.0) + + +def test_resource_dt_matches_full_resolution_with_offset_start_within_tolerance(tmp_path): + """resource path should still match feature-off path with offset start/end. + + This regression guards interval-alignment behavior when simulation start is + not on the weather file's resource reporting boundary. + """ + file_start_utc = pd.to_datetime("2024-06-24T17:00:00Z") + sim_start_utc = pd.to_datetime("2024-06-24T17:00:30Z") + sim_end_utc = pd.to_datetime("2024-06-24T17:10:30Z") + dt = 1.0 + dt_resource = 60.0 + + solar_input = tmp_path / "solar_input.ftr" + _build_synthetic_solar_file(solar_input, file_start_utc, sim_end_utc, dt_resource) + + h_dict_resource = _build_h_dict( + solar_input, sim_start_utc, sim_end_utc, dt, use_resource_solar_dt=True + ) + spv_resource = SolarPySAMPVWatts(h_dict_resource, "solar_farm") + + h_dict_full = _build_h_dict( + solar_input, sim_start_utc, sim_end_utc, dt, use_resource_solar_dt=False + ) + spv_full = SolarPySAMPVWatts(h_dict_full, "solar_farm") + + expected_n = int((spv_resource.endtime - spv_resource.starttime) / dt) + assert spv_resource.ac_power_available_array.shape[0] == expected_n + assert spv_full.ac_power_available_array.shape[0] == expected_n + + np.testing.assert_allclose( + spv_resource.ac_power_available_array, + spv_full.ac_power_available_array, + rtol=1e-3, + atol=10.0, + ) + np.testing.assert_allclose( + spv_resource.dc_power_available_array, + spv_full.dc_power_available_array, + rtol=1e-3, + atol=10.0, + )