Skip to content

Commit 8edca84

Browse files
Add twice-daily forecasts. One timestep per day for daily
1 parent 3b69b10 commit 8edca84

File tree

6 files changed

+1186
-143
lines changed

6 files changed

+1186
-143
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
## [Unreleased]
66

7+
+ Use one timestep per day for daily forecasts.
8+
+ Add twice-daily forecast option to split daily forecasts into day and night
9+
710
## [0.11.0] - 2024-11-26
811

912
+ Correct elements to camelCase for daily forecasts.

src/datapoint/Forecast.py

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ class Forecast:
108108

109109
def __init__(self, frequency, api_data, convert_weather_code):
110110
"""
111-
:param frequency: Frequency of forecast: 'hourly', 'three-hourly' or 'daily'
111+
:param frequency: Frequency of forecast: 'hourly', 'three-hourly',
112+
'twice-daily', 'daily'
112113
:param api_data: Data returned from API call
113114
:param: convert_weather_code: Convert numeric weather codes to string description
114115
:type frequency: string
@@ -149,14 +150,14 @@ def __init__(self, frequency, api_data, convert_weather_code):
149150

150151
forecasts = api_data["features"][0]["properties"]["timeSeries"]
151152
parameters = api_data["parameters"][0]
152-
if frequency == "daily":
153-
self.timesteps = self._build_timesteps_from_daily(forecasts, parameters)
153+
if frequency == "twice-daily":
154+
self.timesteps = self._build_twice_daily_timesteps(forecasts, parameters)
154155
else:
155156
self.timesteps = []
156157
for forecast in forecasts:
157158
self.timesteps.append(self._build_timestep(forecast, parameters))
158159

159-
def _build_timesteps_from_daily(self, forecasts, parameters):
160+
def _build_twice_daily_timesteps(self, forecasts, parameters):
160161
"""Build individual timesteps from forecasts and metadata
161162
162163
Take the forecast data from DataHub and combine with unit information
@@ -188,38 +189,25 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
188189

189190
for element, value in forecast.items():
190191
if element.startswith("midday"):
191-
trimmed_element = element.replace("midday", "")
192-
case_corrected_element = (
193-
trimmed_element[0].lower() + trimmed_element[1:]
194-
)
195-
day_step[case_corrected_element] = {
192+
day_step[element] = {
196193
"value": value,
197194
"description": parameters[element]["description"],
198195
"unit_name": parameters[element]["unit"]["label"],
199196
"unit_symbol": parameters[element]["unit"]["symbol"]["type"],
200197
}
201198
elif element.startswith("midnight"):
202-
trimmed_element = element.replace("midnight", "")
203-
case_corrected_element = (
204-
trimmed_element[0].lower() + trimmed_element[1:]
205-
)
206-
night_step[case_corrected_element] = {
199+
night_step[element] = {
207200
"value": value,
208201
"description": parameters[element]["description"],
209202
"unit_name": parameters[element]["unit"]["label"],
210203
"unit_symbol": parameters[element]["unit"]["symbol"]["type"],
211204
}
212205
elif element.startswith("day"):
213-
trimmed_element = element.replace("day", "")
214-
case_corrected_element = (
215-
trimmed_element[0].lower() + trimmed_element[1:]
216-
)
217-
218206
if (
219-
case_corrected_element == "significantWeatherCode"
207+
element == "daySignificantWeatherCode"
220208
and self.convert_weather_code
221209
):
222-
day_step[case_corrected_element] = {
210+
day_step[element] = {
223211
"value": WEATHER_CODES[str(value)],
224212
"description": parameters[element]["description"],
225213
"unit_name": parameters[element]["unit"]["label"],
@@ -229,7 +217,7 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
229217
}
230218

231219
else:
232-
day_step[case_corrected_element] = {
220+
day_step[element] = {
233221
"value": value,
234222
"description": parameters[element]["description"],
235223
"unit_name": parameters[element]["unit"]["label"],
@@ -238,16 +226,11 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
238226
],
239227
}
240228
elif element.startswith("night"):
241-
trimmed_element = element.replace("night", "")
242-
case_corrected_element = (
243-
trimmed_element[0].lower() + trimmed_element[1:]
244-
)
245-
246229
if (
247-
case_corrected_element == "significantWeatherCode"
230+
element == "nightSignificantWeatherCode"
248231
and self.convert_weather_code
249232
):
250-
night_step[case_corrected_element] = {
233+
night_step[element] = {
251234
"value": WEATHER_CODES[str(value)],
252235
"description": parameters[element]["description"],
253236
"unit_name": parameters[element]["unit"]["label"],
@@ -257,7 +240,7 @@ def _build_timesteps_from_daily(self, forecasts, parameters):
257240
}
258241

259242
else:
260-
night_step[case_corrected_element] = {
243+
night_step[element] = {
261244
"value": value,
262245
"description": parameters[element]["description"],
263246
"unit_name": parameters[element]["unit"]["label"],
@@ -305,7 +288,14 @@ def _build_timestep(self, forecast, parameters):
305288
forecast["time"], "%Y-%m-%dT%H:%M%z"
306289
)
307290

308-
elif element == "significantWeatherCode" and self.convert_weather_code:
291+
elif (
292+
element
293+
in (
294+
"significantWeatherCode",
295+
"daySignificantWeatherCode",
296+
"nightSignificantWeatherCode",
297+
)
298+
) and self.convert_weather_code:
309299
timestep[element] = {
310300
"value": WEATHER_CODES[str(value)],
311301
"description": parameters[element]["description"],
@@ -366,6 +356,19 @@ def _check_requested_time(self, target):
366356

367357
raise APIException(err_str)
368358

359+
# If we have a twice-daily forecast, check that the requested time is
360+
# at most 6 hours before the first datetime we have a forecast for.
361+
if self.frequency == "twice-daily" and target < self.timesteps[0][
362+
"time"
363+
] - datetime.timedelta(hours=6):
364+
err_str = (
365+
"There is no forecast available for the requested time. "
366+
"The requested time is more than 6 hours before the first "
367+
"available forecast."
368+
)
369+
370+
raise APIException(err_str)
371+
369372
# If we have an hourly forecast, check that the requested time is at
370373
# most 30 minutes after the final datetime we have a forecast for
371374
if self.frequency == "hourly" and target > (
@@ -405,6 +408,19 @@ def _check_requested_time(self, target):
405408

406409
raise APIException(err_str)
407410

411+
# If we have a twice-daily forecast, then the target must be within 6 hours
412+
# of the last timestep
413+
if self.frequency == "twice-daily" and target > (
414+
self.timesteps[-1]["time"] + datetime.timedelta(hours=6)
415+
):
416+
err_str = (
417+
"There is no forecast available for the requested time. The "
418+
"requested time is more than 6 hours after the first available "
419+
"forecast."
420+
)
421+
422+
raise APIException(err_str)
423+
408424
def at_datetime(self, target):
409425
"""Return the timestep closest to the target datetime
410426

src/datapoint/Manager.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,15 @@ def get_forecast(
210210
self, latitude, longitude, frequency="daily", convert_weather_code=True
211211
):
212212
"""
213-
Get a forecast for the provided site
213+
Get a forecast for the provided site. Three frequencies are supported
214+
by DataHub: hourly, three-hourly and daily. The 'twice-daily' option is
215+
for convenience and splits a daily forecast into two steps, one for day
216+
and one for night.
214217
215218
:parameter latitude: Latitude of forecast location
216219
:parameter longitude: Longitude of forecast location
217-
:parameter frequency: Forecast frequency. One of 'hourly', 'three-hourly, 'daily'
220+
:parameter frequency: Forecast frequency. One of 'hourly',
221+
'three-hourly,'twice-daily', 'daily'
218222
:parameter convert_weather_code: Convert numeric weather codes to string description
219223
:type latitude: float
220224
:type longitude: float
@@ -224,9 +228,10 @@ def get_forecast(
224228
:return: :class: `Forecast <Forecast>` object
225229
:rtype: datapoint.Forecast
226230
"""
227-
if frequency not in ["hourly", "three-hourly", "daily"]:
231+
if frequency not in ["hourly", "three-hourly", "twice-daily", "daily"]:
228232
raise ValueError(
229-
"frequency must be set to one of 'hourly', 'three-hourly', 'daily'"
233+
"frequency must be set to one of 'hourly', 'three-hourly', "
234+
"'twice-daily', 'daily'"
230235
)
231236
data = self.__call_api(latitude, longitude, frequency)
232237
forecast = Forecast(

tests/integration/test_manager.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,25 @@ def daily_forecast(_mock_response_daily):
9898
return f
9999

100100

101+
@pytest.fixture
102+
def twice_daily_forecast(_mock_response_daily):
103+
m = Manager(api_key="aaaaaaaaaaaaaaaaaaaaaaaaa")
104+
f = m.get_forecast(
105+
50.9992, 0.0154, frequency="twice-daily", convert_weather_code=True
106+
)
107+
return f
108+
109+
101110
@pytest.fixture
102111
def expected_first_daily_timestep():
103112
return reference_data_test_forecast.EXPECTED_FIRST_DAILY_TIMESTEP
104113

105114

115+
@pytest.fixture
116+
def expected_first_twice_daily_timestep():
117+
return reference_data_test_forecast.EXPECTED_FIRST_TWICE_DAILY_TIMESTEP
118+
119+
106120
class TestHourly:
107121
def test_location_name(self, hourly_forecast):
108122
assert hourly_forecast.name == "Sheffield Park"
@@ -178,3 +192,28 @@ def test_forecast_first_timestep(
178192
self, daily_forecast, expected_first_daily_timestep
179193
):
180194
assert daily_forecast.timesteps[0] == expected_first_daily_timestep
195+
196+
197+
class TestTwiceDaily:
198+
def test_forecast_frequency(self, twice_daily_forecast):
199+
assert twice_daily_forecast.frequency == "twice-daily"
200+
201+
def test_forecast_location_name(self, twice_daily_forecast):
202+
assert twice_daily_forecast.name == "Sheffield Park"
203+
204+
def test_forecast_location_latitude(self, twice_daily_forecast):
205+
assert twice_daily_forecast.forecast_latitude == 50.9992
206+
207+
def test_forecast_location_longitude(self, twice_daily_forecast):
208+
assert twice_daily_forecast.forecast_longitude == 0.0154
209+
210+
def test_forecast_distance_from_request(self, twice_daily_forecast):
211+
assert twice_daily_forecast.distance_from_requested_location == 1081.5349
212+
213+
def test_forecast_elevation(self, twice_daily_forecast):
214+
assert twice_daily_forecast.elevation == 37.0
215+
216+
def test_forecast_first_timestep(
217+
self, twice_daily_forecast, expected_first_twice_daily_timestep
218+
):
219+
assert twice_daily_forecast.timesteps[0] == expected_first_twice_daily_timestep

0 commit comments

Comments
 (0)