API - refacto weather utils
This commit is contained in:
parent
64d770a016
commit
940f0a8416
10
.env.example
10
.env.example
@ -34,11 +34,7 @@ export SENDER_EMAIL=
|
|||||||
# export MAP_ATTRIBUTION=
|
# export MAP_ATTRIBUTION=
|
||||||
# export DEFAULT_STATICMAP=False
|
# export DEFAULT_STATICMAP=False
|
||||||
|
|
||||||
# Define one of the following API key values to lookup weather conditions
|
# Weather
|
||||||
# for the start and end of each workout:
|
# available weather API providers: darksky, visualcrossing
|
||||||
|
# export WEATHER_API_PROVIDER=
|
||||||
# DarkSky weather API key (deprecated):
|
|
||||||
# export WEATHER_API_KEY=
|
# export WEATHER_API_KEY=
|
||||||
|
|
||||||
# VisualCrossing.com weather API key:
|
|
||||||
# export VC_WEATHER_API_KEY=
|
|
||||||
|
11
fittrackee/tests/fixtures/fixtures_app.py
vendored
11
fittrackee/tests/fixtures/fixtures_app.py
vendored
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Generator, Optional, Union
|
from typing import Generator, Iterator, Optional, Union
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -8,6 +9,13 @@ from flask import current_app
|
|||||||
from fittrackee import create_app, db, limiter
|
from fittrackee import create_app, db, limiter
|
||||||
from fittrackee.application.models import AppConfig
|
from fittrackee.application.models import AppConfig
|
||||||
from fittrackee.application.utils import update_app_config_from_database
|
from fittrackee.application.utils import update_app_config_from_database
|
||||||
|
from fittrackee.workouts.utils.gpx import weather_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def default_weather_service(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||||
|
with patch.object(weather_service, 'get_weather', return_value=None):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def get_app_config(
|
def get_app_config(
|
||||||
@ -79,7 +87,6 @@ def get_app(
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
|
def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
|
||||||
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
|
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
|
||||||
monkeypatch.setenv('WEATHER_API_KEY', '')
|
|
||||||
if os.getenv('TILE_SERVER_URL'):
|
if os.getenv('TILE_SERVER_URL'):
|
||||||
monkeypatch.delenv('TILE_SERVER_URL')
|
monkeypatch.delenv('TILE_SERVER_URL')
|
||||||
if os.getenv('STATICMAP_SUBDOMAINS'):
|
if os.getenv('STATICMAP_SUBDOMAINS'):
|
||||||
|
344
fittrackee/tests/workouts/test_utils/test_weather_service.py
Normal file
344
fittrackee/tests/workouts/test_utils/test_weather_service.py
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from unittest.mock import Mock, patch, sentinel
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
from gpxpy.gpx import GPXTrackPoint
|
||||||
|
|
||||||
|
from fittrackee.tests.mixins import CallArgsMixin
|
||||||
|
from fittrackee.tests.utils import random_string
|
||||||
|
from fittrackee.workouts.utils.weather.dark_sky import DarkSky
|
||||||
|
from fittrackee.workouts.utils.weather.visual_crossing import VisualCrossing
|
||||||
|
from fittrackee.workouts.utils.weather.weather_service import WeatherService
|
||||||
|
|
||||||
|
VISUAL_CROSSING_RESPONSE = {
|
||||||
|
"queryCost": 1,
|
||||||
|
"latitude": 48.866667,
|
||||||
|
"longitude": 2.333333,
|
||||||
|
"resolvedAddress": "48.866667,2.333333",
|
||||||
|
"address": "48.866667,2.333333",
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"tzoffset": 1.0,
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"datetime": "2022-11-15",
|
||||||
|
"datetimeEpoch": 1668466800,
|
||||||
|
"temp": 10.4,
|
||||||
|
"humidity": 93.3,
|
||||||
|
"windspeed": 18.9,
|
||||||
|
"winddir": 179.4,
|
||||||
|
"conditions": "Rain, Partially cloudy",
|
||||||
|
"description": "Partly cloudy throughout the day with rain.",
|
||||||
|
"icon": "rain",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentConditions": {
|
||||||
|
"datetime": "13:00:00",
|
||||||
|
"datetimeEpoch": 1668513600,
|
||||||
|
"temp": 11.3,
|
||||||
|
"humidity": 93.1,
|
||||||
|
"windspeed": 14.0,
|
||||||
|
"winddir": 161.9,
|
||||||
|
"conditions": "Rain, Overcast",
|
||||||
|
"icon": "rain",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherTestCase:
|
||||||
|
api_key = random_string()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_gpx_point(time: Optional[datetime] = None) -> GPXTrackPoint:
|
||||||
|
return GPXTrackPoint(latitude=48.866667, longitude=2.333333, time=time)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDarksky(WeatherTestCase):
|
||||||
|
def test_it_calls_forecast_io_with_datetime_in_utc_when_naive_datetime_is_provided( # noqa
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
naive_point_time = datetime(
|
||||||
|
year=2022, month=6, day=11, hour=10, minute=23, second=00
|
||||||
|
)
|
||||||
|
point = self.get_gpx_point(naive_point_time)
|
||||||
|
darksky = DarkSky(api_key=self.api_key)
|
||||||
|
with patch(
|
||||||
|
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
|
||||||
|
) as forecast_io_mock:
|
||||||
|
|
||||||
|
darksky.get_weather(point)
|
||||||
|
|
||||||
|
forecast_io_mock.load_forecast.assert_called_with(
|
||||||
|
self.api_key,
|
||||||
|
point.latitude,
|
||||||
|
point.longitude,
|
||||||
|
time=datetime(
|
||||||
|
year=2022,
|
||||||
|
month=6,
|
||||||
|
day=11,
|
||||||
|
hour=10,
|
||||||
|
minute=23,
|
||||||
|
second=00,
|
||||||
|
tzinfo=pytz.utc,
|
||||||
|
),
|
||||||
|
units='si',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_calls_forecast_io_with_provided_datetime_when_with_timezone(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
paris_point_time = datetime(
|
||||||
|
year=2022,
|
||||||
|
month=6,
|
||||||
|
day=11,
|
||||||
|
hour=10,
|
||||||
|
minute=23,
|
||||||
|
second=00,
|
||||||
|
).astimezone(pytz.timezone('Europe/Paris'))
|
||||||
|
point = self.get_gpx_point(paris_point_time)
|
||||||
|
darksky = DarkSky(api_key=self.api_key)
|
||||||
|
with patch(
|
||||||
|
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
|
||||||
|
) as forecast_io_mock:
|
||||||
|
|
||||||
|
darksky.get_weather(point)
|
||||||
|
|
||||||
|
forecast_io_mock.load_forecast.assert_called_with(
|
||||||
|
self.api_key,
|
||||||
|
point.latitude,
|
||||||
|
point.longitude,
|
||||||
|
time=paris_point_time,
|
||||||
|
units='si',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_returns_forecast_currently_data(self) -> None:
|
||||||
|
darksky = DarkSky(api_key=self.api_key)
|
||||||
|
with patch(
|
||||||
|
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
|
||||||
|
) as forecast_io_mock:
|
||||||
|
forecast_io_mock.load_forecast().currently.return_value = sentinel
|
||||||
|
|
||||||
|
weather_data = darksky.get_weather(
|
||||||
|
self.get_gpx_point(datetime.utcnow())
|
||||||
|
)
|
||||||
|
|
||||||
|
assert weather_data == {
|
||||||
|
'icon': sentinel.icon,
|
||||||
|
'temperature': sentinel.temperature,
|
||||||
|
'humidity': sentinel.humidity,
|
||||||
|
'wind': sentinel.windSpeed,
|
||||||
|
'windBearing': sentinel.windBearing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisualCrossingGetTimestamp(WeatherTestCase):
|
||||||
|
def test_it_returns_expected_timestamp_as_integer(self) -> None:
|
||||||
|
time = datetime.utcnow()
|
||||||
|
visual_crossing = VisualCrossing(api_key=self.api_key)
|
||||||
|
|
||||||
|
timestamp = visual_crossing._get_timestamp(time)
|
||||||
|
|
||||||
|
assert isinstance(timestamp, int)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'input_datetime,expected_datetime',
|
||||||
|
[
|
||||||
|
('2020-12-15T13:00:00', '2020-12-15T13:00:00'),
|
||||||
|
('2020-12-15T13:29:59', '2020-12-15T13:00:00'),
|
||||||
|
('2020-12-15T13:30:00', '2020-12-15T14:00:00'),
|
||||||
|
('2020-12-15T13:59:59', '2020-12-15T14:00:00'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_it_returns_rounded_time(
|
||||||
|
self, input_datetime: str, expected_datetime: str
|
||||||
|
) -> None:
|
||||||
|
time = datetime.strptime(input_datetime, '%Y-%m-%dT%H:%M:%S')
|
||||||
|
visual_crossing = VisualCrossing(api_key=self.api_key)
|
||||||
|
|
||||||
|
timestamp = visual_crossing._get_timestamp(time)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
timestamp
|
||||||
|
== datetime.strptime(
|
||||||
|
expected_datetime, '%Y-%m-%dT%H:%M:%S'
|
||||||
|
).timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisualCrossingGetWeather(WeatherTestCase, CallArgsMixin):
|
||||||
|
@staticmethod
|
||||||
|
def get_response() -> Mock:
|
||||||
|
response_mock = Mock()
|
||||||
|
response_mock.raise_for_status = Mock()
|
||||||
|
response_mock.json = Mock()
|
||||||
|
response_mock.json.return_value = VISUAL_CROSSING_RESPONSE
|
||||||
|
return response_mock
|
||||||
|
|
||||||
|
def test_it_calls_api_with_time_and_point_location(self) -> None:
|
||||||
|
time = datetime(
|
||||||
|
year=2022,
|
||||||
|
month=11,
|
||||||
|
day=15,
|
||||||
|
hour=12,
|
||||||
|
minute=00,
|
||||||
|
second=00,
|
||||||
|
tzinfo=pytz.utc,
|
||||||
|
)
|
||||||
|
point = self.get_gpx_point(time)
|
||||||
|
visual_crossing = VisualCrossing(api_key=self.api_key)
|
||||||
|
with patch.object(requests, 'get') as get_mock:
|
||||||
|
|
||||||
|
visual_crossing.get_weather(point)
|
||||||
|
|
||||||
|
args = self.get_args(get_mock.call_args)
|
||||||
|
assert args[0] == (
|
||||||
|
'https://weather.visualcrossing.com/VisualCrossingWebServices/'
|
||||||
|
f'rest/services/timeline/{point.latitude},{point.longitude}/'
|
||||||
|
f'{int(point.time.timestamp())}' # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_calls_api_with_expected_params(self) -> None:
|
||||||
|
visual_crossing = VisualCrossing(api_key=self.api_key)
|
||||||
|
with patch.object(requests, 'get') as get_mock:
|
||||||
|
|
||||||
|
visual_crossing.get_weather(self.get_gpx_point(datetime.utcnow()))
|
||||||
|
|
||||||
|
kwargs = self.get_kwargs(get_mock.call_args)
|
||||||
|
assert kwargs.get('params') == {
|
||||||
|
'key': self.api_key,
|
||||||
|
'iconSet': 'icons1',
|
||||||
|
'unitGroup': 'metric',
|
||||||
|
'contentType': 'json',
|
||||||
|
'elements': (
|
||||||
|
'datetime,datetimeEpoch,temp,humidity,windspeed,'
|
||||||
|
'winddir,conditions,description,icon'
|
||||||
|
),
|
||||||
|
'include': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_it_returns_data_from_current_conditions(self) -> None:
|
||||||
|
point = self.get_gpx_point(
|
||||||
|
datetime(
|
||||||
|
year=2022,
|
||||||
|
month=11,
|
||||||
|
day=15,
|
||||||
|
hour=13,
|
||||||
|
minute=00,
|
||||||
|
second=00,
|
||||||
|
tzinfo=pytz.utc,
|
||||||
|
).astimezone(pytz.timezone('Europe/Paris'))
|
||||||
|
)
|
||||||
|
visual_crossing = VisualCrossing(api_key=self.api_key)
|
||||||
|
with patch.object(requests, 'get', return_value=self.get_response()):
|
||||||
|
|
||||||
|
weather_data = visual_crossing.get_weather(point)
|
||||||
|
|
||||||
|
current_conditions: Dict = VISUAL_CROSSING_RESPONSE[ # type: ignore
|
||||||
|
'currentConditions'
|
||||||
|
]
|
||||||
|
assert weather_data == {
|
||||||
|
'icon': current_conditions['icon'],
|
||||||
|
'temperature': current_conditions['temp'],
|
||||||
|
'humidity': current_conditions['humidity'] / 100,
|
||||||
|
'wind': (current_conditions['windspeed'] * 1000) / 3600,
|
||||||
|
'windBearing': current_conditions['winddir'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherService(WeatherTestCase):
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'input_api_key,input_provider',
|
||||||
|
[
|
||||||
|
('', 'darksky'),
|
||||||
|
('valid_api_key', ''),
|
||||||
|
('valid_api_key', 'invalid_provider'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_weather_api_is_none_when_configuration_is_invalid(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
input_api_key: str,
|
||||||
|
input_provider: str,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv('WEATHER_API_KEY', input_api_key)
|
||||||
|
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
|
||||||
|
|
||||||
|
weather_service = WeatherService()
|
||||||
|
|
||||||
|
assert weather_service.weather_api is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'input_provider',
|
||||||
|
['darksky', 'DARKSKY'],
|
||||||
|
)
|
||||||
|
def test_weather_api_is_darksky_when_configured(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
input_provider: str,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
|
||||||
|
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
|
||||||
|
|
||||||
|
weather_service = WeatherService()
|
||||||
|
|
||||||
|
assert isinstance(weather_service.weather_api, DarkSky)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'input_provider',
|
||||||
|
['visualcrossing', 'VisualCrossing'],
|
||||||
|
)
|
||||||
|
def test_weather_api_is_visualcrossing_when_configured(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
input_provider: str,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
|
||||||
|
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
|
||||||
|
|
||||||
|
weather_service = WeatherService()
|
||||||
|
|
||||||
|
assert isinstance(weather_service.weather_api, VisualCrossing)
|
||||||
|
|
||||||
|
def test_it_returns_none_when_no_weather_api(self) -> None:
|
||||||
|
weather_service = WeatherService()
|
||||||
|
weather_service.weather_api = None
|
||||||
|
point = self.get_gpx_point(datetime.utcnow())
|
||||||
|
|
||||||
|
weather_data = weather_service.get_weather(point)
|
||||||
|
|
||||||
|
assert weather_data is None
|
||||||
|
|
||||||
|
def test_it_returns_none_when_point_time_is_none(self) -> None:
|
||||||
|
weather_service = WeatherService()
|
||||||
|
weather_service.weather_api = DarkSky('api_key')
|
||||||
|
point = self.get_gpx_point(None)
|
||||||
|
|
||||||
|
weather_data = weather_service.get_weather(point)
|
||||||
|
|
||||||
|
assert weather_data is None
|
||||||
|
|
||||||
|
def test_it_returns_none_when_weather_api_raises_exception(self) -> None:
|
||||||
|
weather_api = Mock()
|
||||||
|
weather_api.get_weather = Mock()
|
||||||
|
weather_api.get_weather.side_effect = Exception()
|
||||||
|
weather_service = WeatherService()
|
||||||
|
weather_service.weather_api = weather_api
|
||||||
|
point = self.get_gpx_point(datetime.utcnow())
|
||||||
|
|
||||||
|
weather_data = weather_service.get_weather(point)
|
||||||
|
|
||||||
|
assert weather_data is None
|
||||||
|
|
||||||
|
def test_it_returns_weather_data(self) -> None:
|
||||||
|
weather_api = Mock()
|
||||||
|
weather_api.get_weather = Mock()
|
||||||
|
weather_api.get_weather.return_value = sentinel
|
||||||
|
weather_service = WeatherService()
|
||||||
|
weather_service.weather_api = weather_api
|
||||||
|
point = self.get_gpx_point(datetime.utcnow())
|
||||||
|
|
||||||
|
weather_data = weather_service.get_weather(point)
|
||||||
|
|
||||||
|
assert weather_data == sentinel
|
@ -4,7 +4,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
|||||||
import gpxpy.gpx
|
import gpxpy.gpx
|
||||||
|
|
||||||
from ..exceptions import WorkoutGPXException
|
from ..exceptions import WorkoutGPXException
|
||||||
from .weather import get_weather
|
from .weather import WeatherService
|
||||||
|
|
||||||
|
weather_service = WeatherService()
|
||||||
|
|
||||||
|
|
||||||
def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
|
def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
|
||||||
@ -99,7 +101,7 @@ def get_gpx_info(
|
|||||||
if start is None:
|
if start is None:
|
||||||
start = point.time
|
start = point.time
|
||||||
if point.time and update_weather_data:
|
if point.time and update_weather_data:
|
||||||
weather_data.append(get_weather(point))
|
weather_data.append(weather_service.get_weather(point))
|
||||||
|
|
||||||
# if a previous segment exists, calculate stopped time between
|
# if a previous segment exists, calculate stopped time between
|
||||||
# the two segments
|
# the two segments
|
||||||
@ -114,7 +116,7 @@ def get_gpx_info(
|
|||||||
|
|
||||||
# last gpx point => get weather
|
# last gpx point => get weather
|
||||||
if segment_idx == (segments_nb - 1) and update_weather_data:
|
if segment_idx == (segments_nb - 1) and update_weather_data:
|
||||||
weather_data.append(get_weather(point))
|
weather_data.append(weather_service.get_weather(point))
|
||||||
|
|
||||||
if update_map_data:
|
if update_map_data:
|
||||||
map_data.append([point.longitude, point.latitude])
|
map_data.append([point.longitude, point.latitude])
|
||||||
|
@ -1,129 +0,0 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Dict, Optional, Union
|
|
||||||
|
|
||||||
import forecastio
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
from gpxpy.gpx import GPXTrackPoint
|
|
||||||
|
|
||||||
from fittrackee import appLog
|
|
||||||
|
|
||||||
API_KEY = os.getenv('WEATHER_API_KEY')
|
|
||||||
VC_API_KEY = os.getenv('VC_WEATHER_API_KEY')
|
|
||||||
|
|
||||||
|
|
||||||
def get_weather(point: GPXTrackPoint) -> Optional[Dict]:
|
|
||||||
try:
|
|
||||||
if not point.time:
|
|
||||||
# if there's no time associated with the point;
|
|
||||||
# we cannot get weather
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
point_time = (
|
|
||||||
pytz.utc.localize(point.time)
|
|
||||||
if point.time.tzinfo is None
|
|
||||||
else point.time.astimezone(pytz.utc)
|
|
||||||
)
|
|
||||||
if API_KEY:
|
|
||||||
# if darksky api key is present, use that
|
|
||||||
forecast = forecastio.load_forecast(
|
|
||||||
API_KEY,
|
|
||||||
point.latitude,
|
|
||||||
point.longitude,
|
|
||||||
time=point_time,
|
|
||||||
units='si',
|
|
||||||
)
|
|
||||||
weather = forecast.currently()
|
|
||||||
return {
|
|
||||||
'summary': weather.summary,
|
|
||||||
'icon': weather.icon,
|
|
||||||
'temperature': weather.temperature,
|
|
||||||
'humidity': weather.humidity,
|
|
||||||
'wind': weather.windSpeed,
|
|
||||||
'windBearing': weather.windBearing,
|
|
||||||
}
|
|
||||||
elif VC_API_KEY:
|
|
||||||
# if visualcrossing API key is present, use that
|
|
||||||
return get_visual_crossing_weather(
|
|
||||||
VC_API_KEY, point.latitude, point.longitude, point.time
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
appLog.error(e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_visual_crossing_weather(
|
|
||||||
api_key: str, latitude: float, longitude: float, time: datetime
|
|
||||||
) -> Dict[str, Union[str, float]]:
|
|
||||||
# All requests to the Timeline Weather API use the following the form:
|
|
||||||
|
|
||||||
# https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services
|
|
||||||
# /timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY
|
|
||||||
|
|
||||||
# location (required) – is the address, partial address or
|
|
||||||
# latitude,longitude location for
|
|
||||||
# which to retrieve weather data. You can also use US ZIP Codes.
|
|
||||||
|
|
||||||
# date1 (optional) – is the start date for which to retrieve weather data.
|
|
||||||
# You can also request the information for a specific time for a single
|
|
||||||
# date by including time into the date1 field using the format
|
|
||||||
# yyyy-MM-ddTHH:mm:ss. For example 2020-10-19T13:00:00.
|
|
||||||
|
|
||||||
# The results are returned in the ‘currentConditions’ field and are
|
|
||||||
# truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return
|
|
||||||
# data at 2020-10-19T13:00:00).
|
|
||||||
|
|
||||||
# first, round datetime to nearest hour by truncating, and then adding an
|
|
||||||
# hour if the "real" time's number of minutes is 30 or more (we do this
|
|
||||||
# since the API only truncates)
|
|
||||||
trunc_time = time.replace(
|
|
||||||
second=0, microsecond=0, minute=0, hour=time.hour
|
|
||||||
) + timedelta(hours=time.minute // 30)
|
|
||||||
appLog.debug(
|
|
||||||
f'VC_weather: truncated time {time} ({time.timestamp()}) to '
|
|
||||||
f'{trunc_time} ({trunc_time.timestamp()})'
|
|
||||||
)
|
|
||||||
|
|
||||||
base_url = (
|
|
||||||
'https://weather.visualcrossing.com/'
|
|
||||||
+ 'VisualCrossingWebServices/rest/services'
|
|
||||||
)
|
|
||||||
url = (
|
|
||||||
f"{base_url}/timeline/{latitude},{longitude}"
|
|
||||||
f"/{int(trunc_time.timestamp())}?key={api_key}"
|
|
||||||
)
|
|
||||||
params = {
|
|
||||||
"unitGroup": "metric",
|
|
||||||
"contentType": "json",
|
|
||||||
"elements": (
|
|
||||||
"datetime,datetimeEpoch,temp,humidity,windspeed,"
|
|
||||||
"winddir,conditions,description,icon"
|
|
||||||
),
|
|
||||||
"include": "current",
|
|
||||||
}
|
|
||||||
appLog.debug(
|
|
||||||
f'VC_weather: getting weather from {url}'.replace(api_key, '*****')
|
|
||||||
)
|
|
||||||
r = requests.get(url, params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
res = r.json()
|
|
||||||
weather = res['currentConditions']
|
|
||||||
# FitTrackee expects the following units:
|
|
||||||
# temp: Celsius,
|
|
||||||
# humidity: in fraction (rather than percent)
|
|
||||||
# windSpeed: m/s
|
|
||||||
# windBearing: direction wind is from in degrees (0 is north)
|
|
||||||
# VC provides humidity in percent, wind in km/h
|
|
||||||
data = {
|
|
||||||
'summary': weather['conditions'],
|
|
||||||
'icon': f"vc-{weather['icon']}",
|
|
||||||
'temperature': weather['temp'],
|
|
||||||
'humidity': weather['humidity'] / 100,
|
|
||||||
'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s
|
|
||||||
'windBearing': weather['winddir'],
|
|
||||||
}
|
|
||||||
return data
|
|
5
fittrackee/workouts/utils/weather/__init__.py
Normal file
5
fittrackee/workouts/utils/weather/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .weather_service import WeatherService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'WeatherService',
|
||||||
|
]
|
50
fittrackee/workouts/utils/weather/base_weather.py
Normal file
50
fittrackee/workouts/utils/weather/base_weather.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from gpxpy.gpx import GPXTrackPoint
|
||||||
|
|
||||||
|
|
||||||
|
class BaseWeather(ABC):
|
||||||
|
def __init__(self, api_key: str) -> None:
|
||||||
|
self.api_key: str = api_key
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get_data(
|
||||||
|
self, latitude: float, longitude: float, time: datetime
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
# Expected dict:
|
||||||
|
# {
|
||||||
|
# "humidity": 0.69,
|
||||||
|
# "icon": "partly-cloudy-day",
|
||||||
|
# "temperature": 12.26,
|
||||||
|
# "wind": 3.49,
|
||||||
|
# "windBearing": 315
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# FitTrackee expects the following units:
|
||||||
|
# temperature: Celsius,
|
||||||
|
# humidity: in fraction (rather than percent)
|
||||||
|
# windSpeed: m/s
|
||||||
|
# windBearing: direction wind is from in degrees (0 is north)
|
||||||
|
#
|
||||||
|
# Expected icon values (for UI):
|
||||||
|
# - "clear-day",
|
||||||
|
# - "clear-night",
|
||||||
|
# - "cloudy",
|
||||||
|
# - "fog",
|
||||||
|
# - "partly-cloudy-day",
|
||||||
|
# - "partly-cloudy-night",
|
||||||
|
# - "rain",
|
||||||
|
# - "sleet",
|
||||||
|
# - "snow",
|
||||||
|
# - "wind"
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
|
||||||
|
if not point.time:
|
||||||
|
# if there's no time associated with the point,
|
||||||
|
# we cannot get weather
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._get_data(point.latitude, point.longitude, point.time)
|
37
fittrackee/workouts/utils/weather/dark_sky.py
Normal file
37
fittrackee/workouts/utils/weather/dark_sky.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import forecastio
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from .base_weather import BaseWeather
|
||||||
|
|
||||||
|
|
||||||
|
class DarkSky(BaseWeather):
|
||||||
|
# Deprecated (API will end on March 31st, 2023)
|
||||||
|
|
||||||
|
def _get_data(
|
||||||
|
self, latitude: float, longitude: float, time: datetime
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
# get point time in UTC
|
||||||
|
point_time = (
|
||||||
|
pytz.utc.localize(time)
|
||||||
|
if time.tzinfo is None # naive datetime
|
||||||
|
else time
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast = forecastio.load_forecast(
|
||||||
|
self.api_key,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
time=point_time,
|
||||||
|
units='si',
|
||||||
|
)
|
||||||
|
weather = forecast.currently()
|
||||||
|
return {
|
||||||
|
'humidity': weather.humidity,
|
||||||
|
'icon': weather.icon,
|
||||||
|
'temperature': weather.temperature,
|
||||||
|
'wind': weather.windSpeed,
|
||||||
|
'windBearing': weather.windBearing,
|
||||||
|
}
|
84
fittrackee/workouts/utils/weather/visual_crossing.py
Normal file
84
fittrackee/workouts/utils/weather/visual_crossing.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from fittrackee import appLog
|
||||||
|
|
||||||
|
from .base_weather import BaseWeather
|
||||||
|
|
||||||
|
|
||||||
|
class VisualCrossing(BaseWeather):
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.base_url = (
|
||||||
|
'https://weather.visualcrossing.com/'
|
||||||
|
'VisualCrossingWebServices/rest/services'
|
||||||
|
)
|
||||||
|
self.params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"iconSet": "icons1", # default value, same as Darksky
|
||||||
|
"unitGroup": "metric",
|
||||||
|
"contentType": "json",
|
||||||
|
"elements": (
|
||||||
|
"datetime,datetimeEpoch,temp,humidity,windspeed,"
|
||||||
|
"winddir,conditions,description,icon"
|
||||||
|
),
|
||||||
|
"include": "current", # to get only specific time data
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_timestamp(time: datetime) -> int:
|
||||||
|
# The results are returned in the ‘currentConditions’ field and are
|
||||||
|
# truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return
|
||||||
|
# data at 2020-10-19T13:00:00).
|
||||||
|
|
||||||
|
# first, round datetime to nearest hour by truncating, and then adding
|
||||||
|
# an hour if the "real" time's number of minutes is 30 or more (we do
|
||||||
|
# this since the API only truncates)
|
||||||
|
trunc_time = time.replace(
|
||||||
|
second=0, microsecond=0, minute=0, hour=time.hour
|
||||||
|
) + timedelta(hours=time.minute // 30)
|
||||||
|
appLog.debug(
|
||||||
|
f'VC_weather: truncated time {time} ({time.timestamp()})'
|
||||||
|
f' to {trunc_time} ({trunc_time.timestamp()})'
|
||||||
|
)
|
||||||
|
return int(trunc_time.timestamp())
|
||||||
|
|
||||||
|
def _get_data(
|
||||||
|
self, latitude: float, longitude: float, time: datetime
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
# All requests to the Timeline Weather API use the following the form:
|
||||||
|
|
||||||
|
# https://weather.visualcrossing.com/VisualCrossingWebServices/rest
|
||||||
|
# /services/timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY
|
||||||
|
|
||||||
|
# location (required) – is the address, partial address or
|
||||||
|
# latitude,longitude location for
|
||||||
|
# which to retrieve weather data. You can also use US ZIP Codes.
|
||||||
|
|
||||||
|
# date1 (optional) – is the start date for which to retrieve weather
|
||||||
|
# data. All dates and times are in local time of the **location**
|
||||||
|
# specified.
|
||||||
|
url = (
|
||||||
|
f"{self.base_url}/timeline/{latitude},{longitude}"
|
||||||
|
f"/{self._get_timestamp(time)}"
|
||||||
|
)
|
||||||
|
appLog.debug(
|
||||||
|
f'VC_weather: getting weather from {url}'.replace(
|
||||||
|
self.api_key, '*****'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r = requests.get(url, params=self.params)
|
||||||
|
r.raise_for_status()
|
||||||
|
res = r.json()
|
||||||
|
weather = res['currentConditions']
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'icon': weather['icon'],
|
||||||
|
'temperature': weather['temp'],
|
||||||
|
'humidity': weather['humidity'] / 100,
|
||||||
|
'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s
|
||||||
|
'windBearing': weather['winddir'],
|
||||||
|
}
|
||||||
|
return data
|
44
fittrackee/workouts/utils/weather/weather_service.py
Normal file
44
fittrackee/workouts/utils/weather/weather_service.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
from typing import Dict, Optional, Union
|
||||||
|
|
||||||
|
from gpxpy.gpx import GPXTrackPoint
|
||||||
|
|
||||||
|
from fittrackee import appLog
|
||||||
|
|
||||||
|
from .dark_sky import DarkSky
|
||||||
|
from .visual_crossing import VisualCrossing
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherService:
|
||||||
|
"""
|
||||||
|
Available API:
|
||||||
|
- DarkSky (deprecated, will end on March 31st, 2023)
|
||||||
|
- VisualCrossing
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.weather_api = self._get_weather_api()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_weather_api() -> Union[DarkSky, VisualCrossing, None]:
|
||||||
|
weather_api_key: str = os.getenv('WEATHER_API_KEY', '')
|
||||||
|
weather_api_provider: str = os.getenv(
|
||||||
|
'WEATHER_API_PROVIDER', ''
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
if not weather_api_key:
|
||||||
|
return None
|
||||||
|
if weather_api_provider == 'darksky': # deprecated
|
||||||
|
return DarkSky(weather_api_key)
|
||||||
|
if weather_api_provider == 'visualcrossing':
|
||||||
|
return VisualCrossing(weather_api_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
|
||||||
|
if not self.weather_api:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self.weather_api.get_weather(point)
|
||||||
|
except Exception as e:
|
||||||
|
appLog.error(e)
|
||||||
|
return None
|
Loading…
Reference in New Issue
Block a user