147 lines
4.2 KiB
Python
147 lines
4.2 KiB
Python
import datetime
|
|
from math import inf
|
|
import os
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlencode
|
|
|
|
from cnet import HttpError, as_dict, as_float, as_int, as_list, as_str, fetch_json
|
|
from db_models import DailyForecast
|
|
from geo_locator import Geo
|
|
|
|
|
|
messages = {
|
|
401: "[401] Wrong WEATHER_API_KEY",
|
|
404: "[404] City not found",
|
|
429: "[429] Limit exceeded",
|
|
}
|
|
|
|
|
|
class WeatherAPIError(Exception): ...
|
|
|
|
|
|
@dataclass
|
|
class _Item:
|
|
date: datetime.date
|
|
hour: int
|
|
temp_min: float
|
|
temp_max: float
|
|
humidity: float
|
|
wind_speed: float
|
|
description: str
|
|
|
|
|
|
@dataclass
|
|
class _DayMetrics:
|
|
t_mins: float = inf
|
|
t_maxs: float = -inf
|
|
h_sum: float = 0
|
|
n: int = 0
|
|
wind_max: float = -inf
|
|
noon_dst: int | None = None
|
|
description: str = ""
|
|
|
|
|
|
def _timezone_offset(city: dict[str, object]) -> datetime.timedelta:
|
|
shift = as_float(city.get("timezone"), default=0.0) or 0.0
|
|
return datetime.timedelta(seconds=int(shift))
|
|
|
|
|
|
def _parse_item(raw: object, offset: datetime.timedelta) -> _Item | None:
|
|
entry = as_dict(raw)
|
|
main = as_dict(entry.get("main"))
|
|
wind = as_dict(entry.get("wind"))
|
|
weather_list = as_list(entry.get("weather"))
|
|
weather = as_dict(weather_list[0]) if weather_list else {}
|
|
|
|
dt = as_float(entry.get("dt"), default=0.0)
|
|
if dt is None or dt <= 0:
|
|
return None
|
|
time = datetime.datetime.fromtimestamp(dt, tz=datetime.timezone.utc) + offset
|
|
|
|
return _Item(
|
|
date=time.date(),
|
|
hour=time.hour,
|
|
temp_min=as_float(main.get("temp_min"), default=0.0) or 0.0,
|
|
temp_max=as_float(main.get("temp_max"), default=0.0) or 0.0,
|
|
humidity=as_float(main.get("humidity"), default=0.0) or 0.0,
|
|
wind_speed=as_float(wind.get("speed"), default=0.0) or 0.0,
|
|
description=as_str(weather.get("description"), default=""),
|
|
)
|
|
|
|
|
|
def _calc(intervals: list[_Item], city: str) -> list[DailyForecast]:
|
|
days_metrics: dict[datetime.date, _DayMetrics] = {}
|
|
for i in intervals:
|
|
m = days_metrics.setdefault(i.date, _DayMetrics())
|
|
m.t_mins = min(m.t_mins, i.temp_min)
|
|
m.t_maxs = max(m.t_maxs, i.temp_max)
|
|
|
|
m.h_sum += i.humidity
|
|
m.n += 1
|
|
m.wind_max = max(m.wind_max, i.wind_speed)
|
|
dst = abs(i.hour - 12)
|
|
if m.noon_dst is None or dst < m.noon_dst:
|
|
m.noon_dst = dst
|
|
m.description = i.description
|
|
|
|
result: list[DailyForecast] = []
|
|
for day, m in sorted(days_metrics.items()):
|
|
result.append(
|
|
DailyForecast(
|
|
city=city,
|
|
forecast_date=day,
|
|
temp_min=m.t_mins,
|
|
temp_max=m.t_maxs,
|
|
humidity=round(m.h_sum / m.n) if m.n else 0,
|
|
wind_speed=m.wind_max,
|
|
description=m.description,
|
|
)
|
|
)
|
|
return result[:4]
|
|
|
|
|
|
def get_forecast(geo: Geo) -> list[DailyForecast]:
|
|
api_key = os.getenv("WEATHER_API_KEY", "").strip()
|
|
if not api_key:
|
|
raise WeatherAPIError("no WEATHER_API_KEY")
|
|
base_url = os.getenv(
|
|
"WEATHER_API_BASE_URL", "https://api.openweathermap.org/data/2.5/forecast"
|
|
)
|
|
|
|
params: dict[str, str] = {"appid": api_key, "units": "metric", "lang": "ru"}
|
|
if geo.lat is not None and geo.lon is not None:
|
|
params["lat"] = f"{geo.lat}"
|
|
params["lon"] = f"{geo.lon}"
|
|
else:
|
|
params["q"] = geo.city
|
|
|
|
url = f"{base_url}?{urlencode(params)}"
|
|
|
|
try:
|
|
raw = fetch_json(url)
|
|
except HttpError as e:
|
|
if e.status_code == HttpError.NO_CODE:
|
|
raise WeatherAPIError(f"weather request failed: {e.body}") from e
|
|
raise WeatherAPIError(
|
|
messages.get(e.status_code, f"[{e.status_code}] weather error")
|
|
) from e
|
|
|
|
resp = as_dict(raw)
|
|
|
|
cod = as_int(resp.get("cod"), 200)
|
|
if cod != 200:
|
|
raise WeatherAPIError(messages.get(cod, f"[{cod}] weather error"))
|
|
|
|
resp_city = as_dict(resp.get("city"))
|
|
city = as_str(resp_city.get("name"), default=geo.city)
|
|
offset = _timezone_offset(resp_city)
|
|
items = [
|
|
i
|
|
for i in (_parse_item(entry, offset) for entry in as_list(resp.get("list")))
|
|
if i is not None
|
|
]
|
|
if not items:
|
|
raise WeatherAPIError("weather is empty")
|
|
|
|
return _calc(items, city)
|