Skip to content
Closed
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
2 changes: 2 additions & 0 deletions solarfarmer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from_dataframe,
from_pvlib,
from_solcast,
from_src,
)

__all__ = [
Expand Down Expand Up @@ -130,4 +131,5 @@
"from_pvlib",
"from_solcast",
"check_sequential_year_timestamps",
"from_src",
]
93 changes: 93 additions & 0 deletions solarfarmer/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"from_dataframe",
"from_pvlib",
"from_solcast",
"from_src",
"shift_period_end_to_beginning",
]

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions tests/test_weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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()."""

Expand Down
Loading