diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index e748d72..578b62c 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -64,6 +64,7 @@ from_dataframe, from_pvlib, from_solcast, + from_src, ) __all__ = [ @@ -130,4 +131,5 @@ "from_pvlib", "from_solcast", "check_sequential_year_timestamps", + "from_src", ] diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index cd304af..2b1e279 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -116,6 +116,7 @@ "from_dataframe", "from_pvlib", "from_solcast", + "from_src", "shift_period_end_to_beginning", ] @@ -169,6 +170,13 @@ def check_sequential_year_timestamps(file_path: str | pathlib.Path) -> None: "pressure": "Pressure", } +SRC_COLUMN_MAP: dict[str, str] = { + "GHI": "GHI", + "DHI": "DHI", + "Tamb": "TAmb", + "Wspd": "WS", +} + SOLCAST_COLUMN_MAP: dict[str, str] = { "ghi": "GHI", "dhi": "DHI", @@ -294,6 +302,91 @@ def from_pvlib( ) +def from_src( + weather_hourly: list[dict], + output_path: str | pathlib.Path, + *, + year: int | None = None, +) -> pathlib.Path: + """Convert a DNV Solar Resource Compass (SRC) hourly dataset to a SolarFarmer TSV file. + + Accepts the ``weather_hourly`` list returned by + `WCompare` endpoint of DNV Solar Resource Compass API and writes a + SolarFarmer-compatible TSV weather file. + + SRC ``Timestamp`` values represent ``period_end``; SolarFarmer expects + ``period_beginning``. The time resolution is inferred automatically and + subtracted from every timestamp via :func:`shift_period_end_to_beginning`. + + The ``Timestamp`` field (e.g. ``"2059-01-01 01:00:00-07:00"``) is parsed + into a timezone-aware DatetimeIndex. Column names are mapped as follows: + + ========= =========== + SRC column SF column + ========= =========== + ``GHI`` ``GHI`` + ``DHI`` ``DHI`` + ``Tamb`` ``TAmb`` + ``Wspd`` ``WS`` + ========= =========== + + .. note:: Requires ``pandas``. Install with ``pip install 'dnv-solarfarmer[all]'``. + + Parameters + ---------- + weather_hourly : list[dict] + The ``weather_hourly`` attribute of the + `WCompare` endpoint response of DNV Solar Resource Compass API. + Each dict must contain a ``Timestamp`` key and + any subset of ``GHI``, ``DHI``, ``Tamb``, ``Wspd``. + output_path : str or Path + Destination file path. + year : int, optional + Remap all timestamps to this calendar year. Use ``year=1990`` when + the TMY source years are not chronologically sequential. + + Returns + ------- + pathlib.Path + + Raises + ------ + ValueError + If ``weather_hourly`` is empty or contains no ``Timestamp`` key. + ImportError + If pandas is not installed. + """ + try: + import pandas as pd + except ImportError: + raise ImportError(PANDAS_INSTALL_MSG) from None + + if not weather_hourly: + raise ValueError("weather_hourly is empty; nothing to write.") + + df = pd.DataFrame(weather_hourly) + + if "Timestamp" not in df.columns: + raise ValueError( + "weather_hourly records must contain a 'Timestamp' key. " + "Check that the WcompareResult (Solar Resource Compass API) was populated correctly." + ) + + df.index = pd.to_datetime(df["Timestamp"], utc=False) + df.index.name = "DateTime" + df = df.drop(columns=["Timestamp"]) + + # SRC Timestamps are period_end; SolarFarmer expects period_beginning. + df = shift_period_end_to_beginning(df) + + return from_dataframe( + df, + output_path, + column_map=SRC_COLUMN_MAP, + year=year, + ) + + def from_solcast( df: pd.DataFrame, output_path: str | pathlib.Path, diff --git a/tests/test_weather.py b/tests/test_weather.py index ee70973..40e17f7 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -8,10 +8,12 @@ from solarfarmer.weather import ( PVLIB_COLUMN_MAP, SOLCAST_COLUMN_MAP, + SRC_COLUMN_MAP, check_sequential_year_timestamps, from_dataframe, from_pvlib, from_solcast, + from_src, shift_period_end_to_beginning, ) @@ -285,6 +287,79 @@ def test_soiling_columns_mapped(self, tmp_path, soiling_col): assert float(first_data[soiling_idx]) == pytest.approx(0.01) +class TestFromSrc: + """Tests for from_src() convenience wrapper (DNV Solar Resource Compass).""" + + @pytest.fixture + def src_records(self): + """Hourly period_end records matching the SRC weather_hourly format.""" + return [ + { + "Timestamp": "1990-01-01 01:00:00+00:00", + "GHI": 0, + "DHI": 0, + "Tamb": -9.5, + "Wspd": 3.3, + }, + { + "Timestamp": "1990-01-01 02:00:00+00:00", + "GHI": 0, + "DHI": 0, + "Tamb": -9.9, + "Wspd": 3.0, + }, + { + "Timestamp": "1990-01-01 03:00:00+00:00", + "GHI": 50, + "DHI": 20, + "Tamb": -9.4, + "Wspd": 2.5, + }, + ] + + def test_columns_renamed(self, tmp_path, src_records): + """SRC columns must be mapped to SolarFarmer TSV column names.""" + pytest.importorskip("pandas") + out = from_src(src_records, tmp_path / "out.tsv") + header = out.read_text().splitlines()[0] + for sf_col in SRC_COLUMN_MAP.values(): + assert sf_col in header + + def test_timestamp_shifted_to_period_beginning(self, tmp_path, src_records): + """SRC period_end timestamps must be shifted back by the time resolution (1 h).""" + pytest.importorskip("pandas") + out = from_src(src_records, tmp_path / "out.tsv") + first_data = out.read_text().splitlines()[1] + # Original first record is 01:00; shifted by -1 h → 00:00 + assert "T00:00" in first_data + + def test_year_remap(self, tmp_path, src_records): + """year parameter remaps all timestamps to the given calendar year.""" + pytest.importorskip("pandas") + out = from_src(src_records, tmp_path / "out.tsv", year=1990) + first_data = out.read_text().splitlines()[1] + assert first_data.startswith("1990-") + + def test_empty_list_raises(self, tmp_path): + """Empty weather_hourly list must raise ValueError.""" + pytest.importorskip("pandas") + with pytest.raises(ValueError, match="empty"): + from_src([], tmp_path / "out.tsv") + + def test_missing_timestamp_key_raises(self, tmp_path): + """Records without a Timestamp key must raise ValueError.""" + pytest.importorskip("pandas") + records = [{"GHI": 0, "DHI": 0, "Tamb": 5.0, "Wspd": 2.0}] + with pytest.raises(ValueError, match="Timestamp"): + from_src(records, tmp_path / "out.tsv") + + def test_output_passes_validation(self, tmp_path, src_records): + """TSV written by from_src should pass check_sequential_year_timestamps.""" + pytest.importorskip("pandas") + out = from_src(src_records, tmp_path / "out.tsv", year=1990) + check_sequential_year_timestamps(out) # should not raise + + class TestShiftPeriodEndToBeginning: """Tests for shift_period_end_to_beginning()."""