Compare commits
1 commit
ba82cf557e
...
44885152b5
Author | SHA1 | Date | |
---|---|---|---|
44885152b5 |
1 changed files with 67 additions and 143 deletions
210
main.py
210
main.py
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import TypedDict, Union
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
@ -11,6 +11,7 @@ from cachetools import TTLCache
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
|
@ -21,98 +22,6 @@ POLLEN_MAX_INDEX = 12.0
|
||||||
POLLEN_PERCENTAGE_SCALE = 100
|
POLLEN_PERCENTAGE_SCALE = 100
|
||||||
HTTP_TIMEOUT = 10.0
|
HTTP_TIMEOUT = 10.0
|
||||||
|
|
||||||
class WeatherCondition(TypedDict):
|
|
||||||
description: str
|
|
||||||
main: str
|
|
||||||
id: int
|
|
||||||
icon: str
|
|
||||||
|
|
||||||
class CurrentWeather(TypedDict):
|
|
||||||
dt: int
|
|
||||||
sunrise: int
|
|
||||||
sunset: int
|
|
||||||
temp: float
|
|
||||||
feels_like: float
|
|
||||||
pressure: int
|
|
||||||
humidity: int
|
|
||||||
weather: list[WeatherCondition]
|
|
||||||
|
|
||||||
class DailyTemperature(TypedDict):
|
|
||||||
min: float
|
|
||||||
max: float
|
|
||||||
|
|
||||||
class DailyWeather(TypedDict):
|
|
||||||
dt: int
|
|
||||||
sunrise: int
|
|
||||||
sunset: int
|
|
||||||
temp: DailyTemperature
|
|
||||||
humidity: int
|
|
||||||
pressure: int
|
|
||||||
weather: list[WeatherCondition]
|
|
||||||
|
|
||||||
class WeatherApiResponse(TypedDict):
|
|
||||||
current: CurrentWeather
|
|
||||||
daily: list[DailyWeather]
|
|
||||||
|
|
||||||
class PollenLocation(TypedDict):
|
|
||||||
periods: list[dict[str, Union[str, int, float]]]
|
|
||||||
|
|
||||||
class PollenApiResponse(TypedDict):
|
|
||||||
ForecastDate: str
|
|
||||||
Location: PollenLocation
|
|
||||||
|
|
||||||
class WeatherPeriod(TypedDict):
|
|
||||||
low: int
|
|
||||||
high: int
|
|
||||||
desc: str
|
|
||||||
humidity: int
|
|
||||||
sunrise: str
|
|
||||||
sunset: str
|
|
||||||
pressure: int
|
|
||||||
period: str
|
|
||||||
|
|
||||||
class PollenPeriod(TypedDict):
|
|
||||||
index: int
|
|
||||||
period: str
|
|
||||||
|
|
||||||
class WeatherReport(TypedDict):
|
|
||||||
forecast_date: str
|
|
||||||
current_temp: int
|
|
||||||
current_feels_like: int
|
|
||||||
current_humidity: int
|
|
||||||
sunrise: str
|
|
||||||
sunset: str
|
|
||||||
current_pressure: int
|
|
||||||
current_desc: str
|
|
||||||
periods: list[WeatherPeriod]
|
|
||||||
|
|
||||||
class PollenReport(TypedDict):
|
|
||||||
forecast_date: str
|
|
||||||
periods: list[PollenPeriod]
|
|
||||||
|
|
||||||
class CurrentWeatherData(TypedDict):
|
|
||||||
temp: int
|
|
||||||
feels_like: int
|
|
||||||
desc: str
|
|
||||||
humidity: int
|
|
||||||
pressure: int
|
|
||||||
|
|
||||||
class DailyData(TypedDict, total=False):
|
|
||||||
pollen: int
|
|
||||||
low: int
|
|
||||||
high: int
|
|
||||||
desc: str
|
|
||||||
humidity: int
|
|
||||||
sunrise: str
|
|
||||||
sunset: str
|
|
||||||
pressure: int
|
|
||||||
|
|
||||||
class FinalReport(TypedDict):
|
|
||||||
fetched_at: str
|
|
||||||
current: CurrentWeatherData
|
|
||||||
today: DailyData
|
|
||||||
tomorrow: DailyData
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(title="TRMNL Weather & Pollen Report")
|
app = FastAPI(title="TRMNL Weather & Pollen Report")
|
||||||
|
|
||||||
|
@ -140,6 +49,7 @@ class Config:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Global config instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
@ -184,7 +94,7 @@ class DateTimeFormatter:
|
||||||
class WeatherData:
|
class WeatherData:
|
||||||
"""Data model for weather information."""
|
"""Data model for weather information."""
|
||||||
|
|
||||||
def __init__(self, raw_data: WeatherApiResponse):
|
def __init__(self, raw_data: Dict[str, Any]):
|
||||||
self.raw_data = raw_data
|
self.raw_data = raw_data
|
||||||
self._validate_data()
|
self._validate_data()
|
||||||
|
|
||||||
|
@ -196,12 +106,12 @@ class WeatherData:
|
||||||
raise ValueError(f"Missing required field in weather data: {field}")
|
raise ValueError(f"Missing required field in weather data: {field}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current(self) -> CurrentWeather:
|
def current(self) -> Dict[str, Any]:
|
||||||
"""Get current weather data."""
|
"""Get current weather data."""
|
||||||
return self.raw_data["current"]
|
return self.raw_data["current"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def daily_periods(self) -> list[DailyWeather]:
|
def daily_periods(self) -> List[Dict[str, Any]]:
|
||||||
"""Get daily forecast periods (limited to next 2 days)."""
|
"""Get daily forecast periods (limited to next 2 days)."""
|
||||||
return self.raw_data["daily"][:2]
|
return self.raw_data["daily"][:2]
|
||||||
|
|
||||||
|
@ -209,7 +119,7 @@ class WeatherData:
|
||||||
class PollenData:
|
class PollenData:
|
||||||
"""Data model for pollen information."""
|
"""Data model for pollen information."""
|
||||||
|
|
||||||
def __init__(self, raw_data: PollenApiResponse):
|
def __init__(self, raw_data: Dict[str, Any]):
|
||||||
self.raw_data = raw_data
|
self.raw_data = raw_data
|
||||||
self._validate_data()
|
self._validate_data()
|
||||||
|
|
||||||
|
@ -227,22 +137,22 @@ class PollenData:
|
||||||
return DateTimeFormatter.format_datetime(dt)
|
return DateTimeFormatter.format_datetime(dt)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def periods(self) -> list[PollenPeriod]:
|
def periods(self) -> List[Dict[str, Any]]:
|
||||||
"""Get pollen periods for today and tomorrow."""
|
"""Get pollen periods for today and tomorrow."""
|
||||||
periods = self.raw_data["Location"].get("periods", [])
|
periods = self.raw_data["Location"].get("periods", [])
|
||||||
valid_periods: list[PollenPeriod] = []
|
valid_periods = []
|
||||||
|
|
||||||
for period in periods:
|
for period in periods:
|
||||||
period_type = str(period.get("Type", "")).lower().strip()
|
period_type = period.get("Type", "").lower().strip()
|
||||||
if period_type in ["today", "tomorrow"]:
|
if period_type in ["today", "tomorrow"]:
|
||||||
index_value = float(period.get("Index", 0))
|
index_value = period.get("Index", 0)
|
||||||
# Convert pollen index to percentage scale (0-100)
|
# Convert pollen index to percentage scale (0-100)
|
||||||
pollen_percentage = int(index_value / POLLEN_MAX_INDEX * POLLEN_PERCENTAGE_SCALE)
|
pollen_percentage = int(index_value / POLLEN_MAX_INDEX * POLLEN_PERCENTAGE_SCALE)
|
||||||
|
|
||||||
valid_periods.append(PollenPeriod({
|
valid_periods.append({
|
||||||
"index": pollen_percentage,
|
"index": pollen_percentage,
|
||||||
"period": DateTimeFormatter.relative_day_to_date(period_type),
|
"period": DateTimeFormatter.relative_day_to_date(period_type),
|
||||||
}))
|
})
|
||||||
|
|
||||||
return valid_periods
|
return valid_periods
|
||||||
|
|
||||||
|
@ -264,7 +174,7 @@ class WeatherService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@error_handler
|
@error_handler
|
||||||
async def fetch_weather(latitude: str, longitude: str, api_key: str) -> list[WeatherReport]:
|
async def fetch_weather(latitude: str, longitude: str, api_key: str) -> List[Dict[str, Any]]:
|
||||||
"""Fetch weather data from OpenWeatherMap API."""
|
"""Fetch weather data from OpenWeatherMap API."""
|
||||||
cache_key = (latitude, longitude)
|
cache_key = (latitude, longitude)
|
||||||
|
|
||||||
|
@ -288,14 +198,14 @@ class WeatherService:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_weather_data(weather_data: WeatherData) -> list[WeatherReport]:
|
def _format_weather_data(weather_data: WeatherData) -> List[Dict[str, Any]]:
|
||||||
"""Format weather data for API response."""
|
"""Format weather data for API response."""
|
||||||
current = weather_data.current
|
current = weather_data.current
|
||||||
daily_periods = weather_data.daily_periods
|
daily_periods = weather_data.daily_periods
|
||||||
|
|
||||||
current_dt = datetime.fromtimestamp(current["dt"])
|
current_dt = datetime.fromtimestamp(current["dt"])
|
||||||
|
|
||||||
return [WeatherReport({
|
return [{
|
||||||
"forecast_date": DateTimeFormatter.format_datetime(current_dt),
|
"forecast_date": DateTimeFormatter.format_datetime(current_dt),
|
||||||
"current_temp": int(round(current["temp"])),
|
"current_temp": int(round(current["temp"])),
|
||||||
"current_feels_like": int(round(current["feels_like"])),
|
"current_feels_like": int(round(current["feels_like"])),
|
||||||
|
@ -308,14 +218,14 @@ class WeatherService:
|
||||||
WeatherService._format_daily_period(period)
|
WeatherService._format_daily_period(period)
|
||||||
for period in daily_periods
|
for period in daily_periods
|
||||||
],
|
],
|
||||||
})]
|
}]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_daily_period(period: DailyWeather) -> WeatherPeriod:
|
def _format_daily_period(period: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Format a single daily weather period."""
|
"""Format a single daily weather period."""
|
||||||
period_dt = datetime.fromtimestamp(period["dt"])
|
period_dt = datetime.fromtimestamp(period["dt"])
|
||||||
|
|
||||||
return WeatherPeriod({
|
return {
|
||||||
"low": int(round(period["temp"]["min"])),
|
"low": int(round(period["temp"]["min"])),
|
||||||
"high": int(round(period["temp"]["max"])),
|
"high": int(round(period["temp"]["max"])),
|
||||||
"desc": period["weather"][0]["description"],
|
"desc": period["weather"][0]["description"],
|
||||||
|
@ -324,7 +234,7 @@ class WeatherService:
|
||||||
"sunset": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunset"])),
|
"sunset": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunset"])),
|
||||||
"pressure": period["pressure"],
|
"pressure": period["pressure"],
|
||||||
"period": DateTimeFormatter.format_date(period_dt),
|
"period": DateTimeFormatter.format_date(period_dt),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
class PollenService:
|
class PollenService:
|
||||||
|
@ -332,7 +242,7 @@ class PollenService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@error_handler
|
@error_handler
|
||||||
async def fetch_pollen(zip_code: str) -> list[PollenReport]:
|
async def fetch_pollen(zip_code: str) -> List[Dict[str, Any]]:
|
||||||
"""Fetch pollen data from pollen.com API."""
|
"""Fetch pollen data from pollen.com API."""
|
||||||
if zip_code in pollen_cache:
|
if zip_code in pollen_cache:
|
||||||
return pollen_cache[zip_code]
|
return pollen_cache[zip_code]
|
||||||
|
@ -355,10 +265,10 @@ class PollenService:
|
||||||
raw_data = response.json()
|
raw_data = response.json()
|
||||||
|
|
||||||
pollen_data = PollenData(raw_data)
|
pollen_data = PollenData(raw_data)
|
||||||
result = [PollenReport({
|
result = [{
|
||||||
"forecast_date": pollen_data.forecast_date,
|
"forecast_date": pollen_data.forecast_date,
|
||||||
"periods": pollen_data.periods,
|
"periods": pollen_data.periods,
|
||||||
})]
|
}]
|
||||||
|
|
||||||
pollen_cache[zip_code] = result
|
pollen_cache[zip_code] = result
|
||||||
return result
|
return result
|
||||||
|
@ -370,11 +280,11 @@ class DataAggregator:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_daily_data(
|
def build_daily_data(
|
||||||
date: str,
|
date: str,
|
||||||
pollen_periods: dict[str, PollenPeriod],
|
pollen_periods: Dict[str, Dict[str, Any]],
|
||||||
weather_periods: dict[str, WeatherPeriod]
|
weather_periods: Dict[str, Dict[str, Any]]
|
||||||
) -> DailyData:
|
) -> Dict[str, Any]:
|
||||||
"""Build daily data combining weather and pollen information."""
|
"""Build daily data combining weather and pollen information."""
|
||||||
daily_data: DailyData = {}
|
daily_data = {}
|
||||||
|
|
||||||
if date in pollen_periods:
|
if date in pollen_periods:
|
||||||
daily_data["pollen"] = pollen_periods[date]["index"]
|
daily_data["pollen"] = pollen_periods[date]["index"]
|
||||||
|
@ -395,47 +305,44 @@ class DataAggregator:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_periods_lookup(
|
def create_periods_lookup(
|
||||||
pollen_data: list[PollenReport],
|
pollen_data: List[Dict[str, Any]],
|
||||||
weather_data: list[WeatherReport]
|
weather_data: List[Dict[str, Any]]
|
||||||
) -> tuple[dict[str, PollenPeriod], dict[str, WeatherPeriod], CurrentWeatherData]:
|
) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
|
||||||
"""Create lookup dictionaries for pollen and weather periods."""
|
"""Create lookup dictionaries for pollen and weather periods."""
|
||||||
pollen_periods: dict[str, PollenPeriod] = {}
|
pollen_periods = {}
|
||||||
weather_periods: dict[str, WeatherPeriod] = {}
|
weather_periods = {}
|
||||||
|
|
||||||
|
# Process pollen data
|
||||||
if pollen_data and pollen_data[0].get("periods"):
|
if pollen_data and pollen_data[0].get("periods"):
|
||||||
pollen_periods = {
|
pollen_periods = {
|
||||||
p["period"]: p for p in pollen_data[0]["periods"]
|
p["period"]: p for p in pollen_data[0]["periods"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Process weather data
|
||||||
if weather_data and weather_data[0].get("periods"):
|
if weather_data and weather_data[0].get("periods"):
|
||||||
weather_periods = {
|
weather_periods = {
|
||||||
p["period"]: p for p in weather_data[0]["periods"]
|
p["period"]: p for p in weather_data[0]["periods"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add current weather data as a separate structure
|
# Add current weather data
|
||||||
current_weather_info = CurrentWeatherData({
|
|
||||||
"temp": 0,
|
|
||||||
"feels_like": 0,
|
|
||||||
"desc": "",
|
|
||||||
"humidity": 0,
|
|
||||||
"pressure": 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
if weather_data and weather_data[0]:
|
if weather_data and weather_data[0]:
|
||||||
data = weather_data[0]
|
data = weather_data[0]
|
||||||
current_weather_info = CurrentWeatherData({
|
weather_periods["current"] = {
|
||||||
|
"period": "current",
|
||||||
"temp": data["current_temp"],
|
"temp": data["current_temp"],
|
||||||
"feels_like": data["current_feels_like"],
|
"feels_like": data["current_feels_like"],
|
||||||
"desc": data["current_desc"],
|
|
||||||
"humidity": data["current_humidity"],
|
"humidity": data["current_humidity"],
|
||||||
"pressure": data["current_pressure"],
|
"pressure": data["current_pressure"],
|
||||||
})
|
"desc": data["current_desc"],
|
||||||
|
"sunrise": data["sunrise"],
|
||||||
|
"sunset": data["sunset"],
|
||||||
|
}
|
||||||
|
|
||||||
return pollen_periods, weather_periods, current_weather_info
|
return pollen_periods, weather_periods
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def get_weather_pollen_report(token: str) -> FinalReport:
|
async def get_weather_pollen_report(token: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get weather and pollen report.
|
Get weather and pollen report.
|
||||||
|
|
||||||
|
@ -448,34 +355,51 @@ async def get_weather_pollen_report(token: str) -> FinalReport:
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If authentication fails
|
HTTPException: If authentication fails
|
||||||
"""
|
"""
|
||||||
|
# Validate authentication
|
||||||
if token != config.auth_token:
|
if token != config.auth_token:
|
||||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||||
|
|
||||||
|
# Fetch data concurrently
|
||||||
pollen_data, weather_data = await asyncio.gather(
|
pollen_data, weather_data = await asyncio.gather(
|
||||||
PollenService.fetch_pollen(config.zip_code),
|
PollenService.fetch_pollen(config.zip_code),
|
||||||
WeatherService.fetch_weather(config.latitude, config.longitude, config.weather_api_key),
|
WeatherService.fetch_weather(config.latitude, config.longitude, config.weather_api_key),
|
||||||
)
|
)
|
||||||
|
|
||||||
pollen_periods, weather_periods, current_weather_info = DataAggregator.create_periods_lookup(
|
# Create period lookups
|
||||||
|
pollen_periods, weather_periods = DataAggregator.create_periods_lookup(
|
||||||
pollen_data, weather_data
|
pollen_data, weather_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calculate date strings
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
today_date = DateTimeFormatter.format_date(now)
|
today_date = DateTimeFormatter.format_date(now)
|
||||||
tomorrow_date = DateTimeFormatter.format_date(now + timedelta(days=1))
|
tomorrow_date = DateTimeFormatter.format_date(now + timedelta(days=1))
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
result: FinalReport = FinalReport({
|
result = {
|
||||||
"fetched_at": DateTimeFormatter.format_datetime(now),
|
"fetched_at": DateTimeFormatter.format_datetime(now),
|
||||||
"current": current_weather_info,
|
"current": {},
|
||||||
"today": DailyData({}),
|
"today": {},
|
||||||
"tomorrow": DailyData({}),
|
"tomorrow": {},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
# Add current weather data
|
||||||
|
if "current" in weather_periods:
|
||||||
|
current_weather = weather_periods["current"]
|
||||||
|
result["current"] = {
|
||||||
|
"temp": current_weather["temp"],
|
||||||
|
"feels_like": current_weather["feels_like"],
|
||||||
|
"desc": current_weather["desc"],
|
||||||
|
"humidity": current_weather["humidity"],
|
||||||
|
"pressure": current_weather["pressure"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add today's data
|
||||||
today_data = DataAggregator.build_daily_data(today_date, pollen_periods, weather_periods)
|
today_data = DataAggregator.build_daily_data(today_date, pollen_periods, weather_periods)
|
||||||
if today_data:
|
if today_data:
|
||||||
result["today"] = today_data
|
result["today"] = today_data
|
||||||
|
|
||||||
|
# Add tomorrow's data
|
||||||
tomorrow_data = DataAggregator.build_daily_data(tomorrow_date, pollen_periods, weather_periods)
|
tomorrow_data = DataAggregator.build_daily_data(tomorrow_date, pollen_periods, weather_periods)
|
||||||
if tomorrow_data:
|
if tomorrow_data:
|
||||||
result["tomorrow"] = tomorrow_data
|
result["tomorrow"] = tomorrow_data
|
||||||
|
@ -484,6 +408,6 @@ async def get_weather_pollen_report(token: str) -> FinalReport:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue