weatherhack-api / services /weather_service.py
Ig0tU
feat: add complete astronomical data from Weatherstack API
6b84c23
import httpx
import asyncio
import os
from datetime import datetime
from typing import Optional, Dict, Any, List
from async_lru import alru_cache
from services.api_client import get_client
from models.weather_models import CurrentWeatherModel, AstroModel, AirQualityModel
from utils.conversions import (
convert_temperature,
convert_wind_speed,
convert_precip,
convert_visibility,
)
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
# Weatherstack weather codes mapping (simplified)
# 113: Clear, 116: Partly Cloudy, 119: Cloudy, 122: Overcast, etc.
WMO_TO_WEATHERSTACK = {
0: (113, "Clear/Sunny"),
1: (116, "Partly Cloudy"),
2: (116, "Partly Cloudy"),
3: (119, "Cloudy"),
45: (248, "Fog"),
48: (260, "Freezing Fog"),
51: (263, "Patchy Light Drizzle"),
53: (266, "Light Drizzle"),
55: (284, "Heavy Freezing Drizzle"),
61: (296, "Light Rain"),
63: (302, "Moderate Rain"),
65: (308, "Heavy Rain"),
71: (326, "Light Snow"),
73: (332, "Moderate Snow"),
75: (338, "Heavy Snow"),
95: (389, "Moderate/Heavy Rain with Thunder"),
}
AIR_QUALITY_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
WEATHERSTACK_API_URL = "http://api.weatherstack.com"
@alru_cache(maxsize=100, ttl=86400) # Cache for 24 hours since astro data doesn't change frequently
async def fetch_astro_from_weatherstack(
lat: float, lon: float, date: str
) -> Optional[AstroModel]:
"""Fetch astronomical data from Weatherstack API."""
# Load API key from environment (checks both os.environ and .env file)
api_key = os.getenv("WEATHERSTACK_ACCESS_KEY")
if not api_key:
# Fallback to loading from .env file directly
try:
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv("WEATHERSTACK_ACCESS_KEY")
except:
pass
if not api_key:
return None
params = {
"access_key": api_key,
"query": f"{lat},{lon}",
"historical_date": date,
}
try:
client = get_client()
response = await client.get(f"{WEATHERSTACK_API_URL}/historical", params=params)
if response.status_code == 200:
data = response.json()
historical = data.get("historical", {})
day_data = historical.get(date, {})
astro_data = day_data.get("astro", {})
if astro_data:
return AstroModel(
sunrise=astro_data.get("sunrise", "Unknown"),
sunset=astro_data.get("sunset", "Unknown"),
moonrise=astro_data.get("moonrise", "Unknown"),
moonset=astro_data.get("moonset", "Unknown"),
moon_phase=astro_data.get("moon_phase", "Unknown"),
moon_illumination=int(astro_data.get("moon_illumination", 0)),
)
else:
print(f"Debug - Weatherstack response missing astro data. Response: {data}")
else:
print(f"Debug - Weatherstack API returned status {response.status_code}: {response.text}")
except Exception as e:
print(f"Error fetching Weatherstack astro data: {e}")
import traceback
traceback.print_exc()
return None
@alru_cache(maxsize=100, ttl=300)
async def fetch_current_weather(
lat: float, lon: float, unit: str = "m"
) -> CurrentWeatherModel:
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,uv_index,visibility",
"daily": "sunrise,sunset",
"timezone": "auto",
}
aq_params = {
"latitude": lat,
"longitude": lon,
"current": "carbon_monoxide,nitrogen_dioxide,ozone,sulphur_dioxide,pm2_5,pm10,us_aqi,european_aqi",
"timezone": "auto",
}
client = get_client()
weather_res, aq_res = await asyncio.gather(
client.get(OPEN_METEO_URL, params=params),
client.get(AIR_QUALITY_URL, params=aq_params),
)
if weather_res.status_code == 200:
data = weather_res.json()
current = data["current"]
daily = data.get("daily", {})
aq_data = None
if aq_res.status_code == 200:
aq_json = aq_res.json()["current"]
aq_data = AirQualityModel(
co=str(round(aq_json["carbon_monoxide"], 1)),
no2=str(round(aq_json["nitrogen_dioxide"], 1)),
o3=str(round(aq_json["ozone"], 1)),
so2=str(round(aq_json["sulphur_dioxide"], 1)),
pm2_5=str(round(aq_json["pm2_5"], 1)),
pm10=str(round(aq_json["pm10"], 1)),
us_epa_index=str(aq_json["us_aqi"]),
gb_defra_index=str(
aq_json["european_aqi"]
), # Using EU AQI as proxy for DEFRA
)
wstack_code, wstack_desc = WMO_TO_WEATHERSTACK.get(
current["weather_code"], (113, "Clear")
)
# Astro data from Weatherstack
astro = None
if daily:
current_date = datetime.fromisoformat(current["time"]).strftime("%Y-%m-%d")
astro = await fetch_astro_from_weatherstack(lat, lon, current_date)
# Fallback to Open-Meteo sunrise/sunset if Weatherstack fails
if not astro:
astro = AstroModel(
sunrise=datetime.fromisoformat(daily["sunrise"][0]).strftime(
"%I:%M %p"
),
sunset=datetime.fromisoformat(daily["sunset"][0]).strftime(
"%I:%M %p"
),
moonrise="Unknown",
moonset="Unknown",
moon_phase="Unknown",
moon_illumination=0,
)
return CurrentWeatherModel(
observation_time=datetime.fromisoformat(current["time"]).strftime(
"%I:%M %p"
),
temperature=convert_temperature(current["temperature_2m"], unit),
weather_code=wstack_code,
weather_icons=[
f"https://api.yourweatherservice.com/images/wsymbol_{wstack_code:04d}.png"
],
weather_descriptions=[wstack_desc],
wind_speed=convert_wind_speed(current["wind_speed_10m"], unit),
wind_degree=current["wind_direction_10m"],
wind_dir=get_wind_dir(current["wind_direction_10m"]),
pressure=int(current["pressure_msl"]),
precip=convert_precip(current["precipitation"], unit),
humidity=current["relative_humidity_2m"],
cloudcover=current["cloud_cover"],
feelslike=convert_temperature(current["apparent_temperature"], unit),
uv_index=int(current["uv_index"]),
visibility=convert_visibility(
current["visibility"] / 1000, unit
), # meters to km
is_day="yes" if current["is_day"] else "no",
astro=astro,
air_quality=aq_data,
)
raise Exception("Failed to fetch weather data")
def get_wind_dir(degrees: float) -> str:
directions = [
"N",
"NNE",
"NE",
"ENE",
"E",
"ESE",
"SE",
"SSE",
"S",
"SSW",
"SW",
"WSW",
"W",
"WNW",
"NW",
"NNW",
]
idx = int((degrees + 11.25) / 22.5) % 16
return directions[idx]
@alru_cache(maxsize=100, ttl=300)
async def fetch_weather_forecast(
lat: float, lon: float, days: int, unit: str = "m"
) -> Dict[str, Any]:
from models.weather_models import HistoricalDayModel, AstroModel
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset,uv_index_max,precipitation_sum,snowfall_sum,weather_code",
"forecast_days": days,
"timezone": "auto",
}
client = get_client()
response = await client.get(OPEN_METEO_URL, params=params)
if response.status_code == 200:
data = response.json()
daily = data["daily"]
forecast_data = {}
for i in range(len(daily["time"])):
date_str = daily["time"][i]
wstack_code, _ = WMO_TO_WEATHERSTACK.get(
daily["weather_code"][i], (113, "Clear")
)
# Fetch complete astro data from Weatherstack
astro = await fetch_astro_from_weatherstack(lat, lon, date_str)
# Fallback to Open-Meteo if Weatherstack fails
if not astro:
astro = AstroModel(
sunrise=datetime.fromisoformat(daily["sunrise"][i]).strftime(
"%I:%M %p"
),
sunset=datetime.fromisoformat(daily["sunset"][i]).strftime(
"%I:%M %p"
),
moonrise="Unknown",
moonset="Unknown",
moon_phase="Unknown",
moon_illumination=0,
)
forecast_data[date_str] = HistoricalDayModel(
date=date_str,
date_epoch=int(datetime.fromisoformat(date_str).timestamp()),
astro=astro,
mintemp=convert_temperature(daily["temperature_2m_min"][i], unit),
maxtemp=convert_temperature(daily["temperature_2m_max"][i], unit),
avgtemp=convert_temperature(
(
daily["temperature_2m_min"][i]
+ daily["temperature_2m_max"][i]
)
/ 2,
unit,
),
totalsnow=convert_precip(daily["snowfall_sum"][i], unit),
sunhour=10.0,
uv_index=int(
daily.get("uv_index_max", [3] * len(daily["time"]))[i]
),
)
return forecast_data
return {}
HISTORICAL_API_URL = "https://archive-api.open-meteo.com/v1/archive"
@alru_cache(maxsize=100, ttl=300)
async def fetch_historical_weather(
lat: float, lon: float, date: str, unit: str = "m"
) -> Dict[str, Any]:
from models.weather_models import HistoricalDayModel, AstroModel
params = {
"latitude": lat,
"longitude": lon,
"start_date": date,
"end_date": date,
"daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_sum,snowfall_sum,weather_code",
"timezone": "auto",
}
client = get_client()
response = await client.get(HISTORICAL_API_URL, params=params)
if response.status_code == 200:
data = response.json()
daily = data["daily"]
historical_data = {}
if daily["time"]:
date_str = daily["time"][0]
wstack_code, _ = WMO_TO_WEATHERSTACK.get(
daily["weather_code"][0], (113, "Clear")
)
# Fetch complete astro data from Weatherstack
astro = await fetch_astro_from_weatherstack(lat, lon, date_str)
# Fallback to Open-Meteo if Weatherstack fails
if not astro:
astro = AstroModel(
sunrise=datetime.fromisoformat(daily["sunrise"][0]).strftime(
"%I:%M %p"
),
sunset=datetime.fromisoformat(daily["sunset"][0]).strftime(
"%I:%M %p"
),
moonrise="Unknown",
moonset="Unknown",
moon_phase="Unknown",
moon_illumination=0,
)
historical_data[date_str] = HistoricalDayModel(
date=date_str,
date_epoch=int(datetime.fromisoformat(date_str).timestamp()),
astro=astro,
mintemp=convert_temperature(daily["temperature_2m_min"][0], unit),
maxtemp=convert_temperature(daily["temperature_2m_max"][0], unit),
avgtemp=convert_temperature(
(
daily["temperature_2m_min"][0]
+ daily["temperature_2m_max"][0]
)
/ 2,
unit,
),
totalsnow=convert_precip(daily["snowfall_sum"][0], unit),
sunhour=10.0,
uv_index=3,
)
return historical_data
return {}