Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

+ Use one timestep per day for daily forecasts.
+ Add twice-daily forecast option to split daily forecasts into day and night

## [0.11.0] - 2024-11-26

+ Correct elements to camelCase for daily forecasts.
Expand Down
78 changes: 47 additions & 31 deletions src/datapoint/Forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ class Forecast:

def __init__(self, frequency, api_data, convert_weather_code):
"""
:param frequency: Frequency of forecast: 'hourly', 'three-hourly' or 'daily'
:param frequency: Frequency of forecast: 'hourly', 'three-hourly',
'twice-daily', 'daily'
:param api_data: Data returned from API call
:param: convert_weather_code: Convert numeric weather codes to string description
:type frequency: string
Expand Down Expand Up @@ -149,14 +150,14 @@ def __init__(self, frequency, api_data, convert_weather_code):

forecasts = api_data["features"][0]["properties"]["timeSeries"]
parameters = api_data["parameters"][0]
if frequency == "daily":
self.timesteps = self._build_timesteps_from_daily(forecasts, parameters)
if frequency == "twice-daily":
self.timesteps = self._build_twice_daily_timesteps(forecasts, parameters)
else:
self.timesteps = []
for forecast in forecasts:
self.timesteps.append(self._build_timestep(forecast, parameters))

def _build_timesteps_from_daily(self, forecasts, parameters):
def _build_twice_daily_timesteps(self, forecasts, parameters):
"""Build individual timesteps from forecasts and metadata

Take the forecast data from DataHub and combine with unit information
Expand Down Expand Up @@ -188,38 +189,25 @@ def _build_timesteps_from_daily(self, forecasts, parameters):

for element, value in forecast.items():
if element.startswith("midday"):
trimmed_element = element.replace("midday", "")
case_corrected_element = (
trimmed_element[0].lower() + trimmed_element[1:]
)
day_step[case_corrected_element] = {
day_step[element] = {
"value": value,
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
"unit_symbol": parameters[element]["unit"]["symbol"]["type"],
}
elif element.startswith("midnight"):
trimmed_element = element.replace("midnight", "")
case_corrected_element = (
trimmed_element[0].lower() + trimmed_element[1:]
)
night_step[case_corrected_element] = {
night_step[element] = {
"value": value,
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
"unit_symbol": parameters[element]["unit"]["symbol"]["type"],
}
elif element.startswith("day"):
trimmed_element = element.replace("day", "")
case_corrected_element = (
trimmed_element[0].lower() + trimmed_element[1:]
)

if (
case_corrected_element == "significantWeatherCode"
element == "daySignificantWeatherCode"
and self.convert_weather_code
):
day_step[case_corrected_element] = {
day_step[element] = {
"value": WEATHER_CODES[str(value)],
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
Expand All @@ -229,7 +217,7 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
}

else:
day_step[case_corrected_element] = {
day_step[element] = {
"value": value,
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
Expand All @@ -238,16 +226,11 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
],
}
elif element.startswith("night"):
trimmed_element = element.replace("night", "")
case_corrected_element = (
trimmed_element[0].lower() + trimmed_element[1:]
)

if (
case_corrected_element == "significantWeatherCode"
element == "nightSignificantWeatherCode"
and self.convert_weather_code
):
night_step[case_corrected_element] = {
night_step[element] = {
"value": WEATHER_CODES[str(value)],
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
Expand All @@ -257,7 +240,7 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
}

else:
night_step[case_corrected_element] = {
night_step[element] = {
"value": value,
"description": parameters[element]["description"],
"unit_name": parameters[element]["unit"]["label"],
Expand Down Expand Up @@ -305,7 +288,14 @@ def _build_timestep(self, forecast, parameters):
forecast["time"], "%Y-%m-%dT%H:%M%z"
)

elif element == "significantWeatherCode" and self.convert_weather_code:
elif (
element
in (
"significantWeatherCode",
"daySignificantWeatherCode",
"nightSignificantWeatherCode",
)
) and self.convert_weather_code:
timestep[element] = {
"value": WEATHER_CODES[str(value)],
"description": parameters[element]["description"],
Expand Down Expand Up @@ -366,6 +356,19 @@ def _check_requested_time(self, target):

raise APIException(err_str)

# If we have a twice-daily forecast, check that the requested time is
# at most 6 hours before the first datetime we have a forecast for.
if self.frequency == "twice-daily" and target < self.timesteps[0][
"time"
] - datetime.timedelta(hours=6):
err_str = (
"There is no forecast available for the requested time. "
"The requested time is more than 6 hours before the first "
"available forecast."
)

raise APIException(err_str)

# If we have an hourly forecast, check that the requested time is at
# most 30 minutes after the final datetime we have a forecast for
if self.frequency == "hourly" and target > (
Expand Down Expand Up @@ -405,6 +408,19 @@ def _check_requested_time(self, target):

raise APIException(err_str)

# If we have a twice-daily forecast, then the target must be within 6 hours
# of the last timestep
if self.frequency == "twice-daily" and target > (
self.timesteps[-1]["time"] + datetime.timedelta(hours=6)
):
err_str = (
"There is no forecast available for the requested time. The "
"requested time is more than 6 hours after the first available "
"forecast."
)

raise APIException(err_str)

def at_datetime(self, target):
"""Return the timestep closest to the target datetime

Expand Down
13 changes: 9 additions & 4 deletions src/datapoint/Manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,15 @@ def get_forecast(
self, latitude, longitude, frequency="daily", convert_weather_code=True
):
"""
Get a forecast for the provided site
Get a forecast for the provided site. Three frequencies are supported
by DataHub: hourly, three-hourly and daily. The 'twice-daily' option is
for convenience and splits a daily forecast into two steps, one for day
and one for night.

:parameter latitude: Latitude of forecast location
:parameter longitude: Longitude of forecast location
:parameter frequency: Forecast frequency. One of 'hourly', 'three-hourly, 'daily'
:parameter frequency: Forecast frequency. One of 'hourly',
'three-hourly,'twice-daily', 'daily'
:parameter convert_weather_code: Convert numeric weather codes to string description
:type latitude: float
:type longitude: float
Expand All @@ -224,9 +228,10 @@ def get_forecast(
:return: :class: `Forecast <Forecast>` object
:rtype: datapoint.Forecast
"""
if frequency not in ["hourly", "three-hourly", "daily"]:
if frequency not in ["hourly", "three-hourly", "twice-daily", "daily"]:
raise ValueError(
"frequency must be set to one of 'hourly', 'three-hourly', 'daily'"
"frequency must be set to one of 'hourly', 'three-hourly', "
"'twice-daily', 'daily'"
)
data = self.__call_api(latitude, longitude, frequency)
forecast = Forecast(
Expand Down
39 changes: 39 additions & 0 deletions tests/integration/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,25 @@ def daily_forecast(_mock_response_daily):
return f


@pytest.fixture
def twice_daily_forecast(_mock_response_daily):
m = Manager(api_key="aaaaaaaaaaaaaaaaaaaaaaaaa")
f = m.get_forecast(
50.9992, 0.0154, frequency="twice-daily", convert_weather_code=True
)
return f


@pytest.fixture
def expected_first_daily_timestep():
return reference_data_test_forecast.EXPECTED_FIRST_DAILY_TIMESTEP


@pytest.fixture
def expected_first_twice_daily_timestep():
return reference_data_test_forecast.EXPECTED_FIRST_TWICE_DAILY_TIMESTEP


class TestHourly:
def test_location_name(self, hourly_forecast):
assert hourly_forecast.name == "Sheffield Park"
Expand Down Expand Up @@ -178,3 +192,28 @@ def test_forecast_first_timestep(
self, daily_forecast, expected_first_daily_timestep
):
assert daily_forecast.timesteps[0] == expected_first_daily_timestep


class TestTwiceDaily:
def test_forecast_frequency(self, twice_daily_forecast):
assert twice_daily_forecast.frequency == "twice-daily"

def test_forecast_location_name(self, twice_daily_forecast):
assert twice_daily_forecast.name == "Sheffield Park"

def test_forecast_location_latitude(self, twice_daily_forecast):
assert twice_daily_forecast.forecast_latitude == 50.9992

def test_forecast_location_longitude(self, twice_daily_forecast):
assert twice_daily_forecast.forecast_longitude == 0.0154

def test_forecast_distance_from_request(self, twice_daily_forecast):
assert twice_daily_forecast.distance_from_requested_location == 1081.5349

def test_forecast_elevation(self, twice_daily_forecast):
assert twice_daily_forecast.elevation == 37.0

def test_forecast_first_timestep(
self, twice_daily_forecast, expected_first_twice_daily_timestep
):
assert twice_daily_forecast.timesteps[0] == expected_first_twice_daily_timestep
Loading
Loading