337 lines
13 KiB
Python
337 lines
13 KiB
Python
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.config import get_settings
|
||
|
||
WEATHER_CODES: dict[int, str] = {
|
||
0: "ясно",
|
||
1: "преимущественно ясно",
|
||
2: "переменная облачность",
|
||
3: "пасмурно",
|
||
45: "туман",
|
||
48: "изморозь",
|
||
51: "морось",
|
||
53: "морось",
|
||
55: "морось",
|
||
61: "дождь",
|
||
63: "дождь",
|
||
65: "сильный дождь",
|
||
71: "снег",
|
||
73: "снег",
|
||
75: "сильный снег",
|
||
80: "ливень",
|
||
81: "ливень",
|
||
82: "сильный ливень",
|
||
95: "гроза",
|
||
96: "гроза с градом",
|
||
99: "гроза с градом",
|
||
}
|
||
|
||
_cache: dict[str, Any] = {
|
||
"data": None,
|
||
"fetched_at": 0.0,
|
||
"expires_at": 0.0,
|
||
"source": "local",
|
||
"local_coverage": {"current": [], "hourly": []},
|
||
}
|
||
|
||
CURRENT_FIELDS = (
|
||
"temperature_2m",
|
||
"apparent_temperature",
|
||
"relative_humidity_2m",
|
||
"precipitation",
|
||
"weather_code",
|
||
"wind_speed_10m",
|
||
)
|
||
HOURLY_FIELDS = (
|
||
"temperature_2m",
|
||
"precipitation_probability",
|
||
"precipitation",
|
||
"weather_code",
|
||
)
|
||
|
||
RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025"
|
||
RECOMMENDED_SYNC_VARIABLES = (
|
||
"temperature_2m,dew_point_2m,relative_humidity_2m,precipitation_probability,"
|
||
"precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m"
|
||
)
|
||
SYNC_HINT = (
|
||
"Контейнер open-meteo-sync, скорее всего, качает только temperature_2m. "
|
||
f"Задай SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} и "
|
||
f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). "
|
||
"Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api"
|
||
)
|
||
|
||
|
||
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
|
||
values = hourly.get(key)
|
||
return values if isinstance(values, list) else []
|
||
|
||
|
||
def _hourly_start_index(times: list[str], anchor_time: str | None) -> int:
|
||
if not times:
|
||
return 0
|
||
if not anchor_time:
|
||
return 0
|
||
best = 0
|
||
for i, t in enumerate(times):
|
||
if t <= anchor_time:
|
||
best = i
|
||
else:
|
||
break
|
||
return best
|
||
|
||
|
||
def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]:
|
||
"""Какие поля реально пришли от OpenMeteo (не null)."""
|
||
current = raw.get("current") or {}
|
||
hourly = raw.get("hourly") or {}
|
||
current_present = [
|
||
key for key in CURRENT_FIELDS if current.get(key) is not None
|
||
]
|
||
hourly_present = []
|
||
for key in HOURLY_FIELDS:
|
||
series = _hourly_series(hourly, key)
|
||
if any(v is not None for v in series):
|
||
hourly_present.append(key)
|
||
return {"current": current_present, "hourly": hourly_present}
|
||
|
||
|
||
def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool:
|
||
current = set(coverage.get("current") or [])
|
||
hourly = set(coverage.get("hourly") or [])
|
||
if "weather_code" not in current:
|
||
return False
|
||
if len(current) < 3:
|
||
return False
|
||
if "precipitation_probability" not in hourly and "weather_code" not in hourly:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _fmt_num(value: Any, *, suffix: str = "") -> str:
|
||
if value is None:
|
||
return "—"
|
||
if isinstance(value, float):
|
||
text = f"{value:.1f}".rstrip("0").rstrip(".")
|
||
else:
|
||
text = str(value)
|
||
return f"{text}{suffix}" if suffix else text
|
||
|
||
|
||
class OpenMeteoClient:
|
||
def __init__(self) -> None:
|
||
settings = get_settings()
|
||
self.base_url = settings.openmeteo_base_url.rstrip("/")
|
||
self.fallback_url = (settings.openmeteo_fallback_url or "").strip().rstrip("/")
|
||
self.fallback_on_partial = settings.openmeteo_fallback_on_partial
|
||
self.lat = settings.weather_lat
|
||
self.lon = settings.weather_lon
|
||
self.location_name = settings.weather_location_name
|
||
self.cache_ttl = settings.weather_cache_sec
|
||
|
||
def _request_params(self) -> dict[str, Any]:
|
||
return {
|
||
"latitude": self.lat,
|
||
"longitude": self.lon,
|
||
"current": ",".join(CURRENT_FIELDS),
|
||
"hourly": ",".join(HOURLY_FIELDS),
|
||
"timezone": "auto",
|
||
"forecast_days": 2,
|
||
}
|
||
|
||
def _fetch_from_url(self, base_url: str) -> dict[str, Any]:
|
||
with httpx.Client(timeout=20.0) as client:
|
||
response = client.get(f"{base_url.rstrip('/')}/v1/forecast", params=self._request_params())
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
def _fetch_raw(self) -> dict[str, Any]:
|
||
now = time.time()
|
||
if _cache["data"] and now < _cache["expires_at"]:
|
||
return _cache["data"]
|
||
|
||
local_raw = self._fetch_from_url(self.base_url)
|
||
local_coverage = _field_coverage(local_raw)
|
||
source = "local"
|
||
raw = local_raw
|
||
|
||
if (
|
||
self.fallback_on_partial
|
||
and self.fallback_url
|
||
and self.fallback_url.rstrip("/") != self.base_url
|
||
and not _coverage_sufficient(local_coverage)
|
||
):
|
||
try:
|
||
fallback_raw = self._fetch_from_url(self.fallback_url)
|
||
if _coverage_sufficient(_field_coverage(fallback_raw)):
|
||
raw = fallback_raw
|
||
source = "fallback"
|
||
except Exception:
|
||
pass
|
||
|
||
_cache["data"] = raw
|
||
_cache["fetched_at"] = now
|
||
_cache["expires_at"] = now + self.cache_ttl
|
||
_cache["source"] = source
|
||
_cache["local_coverage"] = local_coverage
|
||
return raw
|
||
|
||
def cache_status(self) -> dict[str, Any]:
|
||
now = time.time()
|
||
fetched_at = float(_cache.get("fetched_at") or 0)
|
||
expires_at = float(_cache.get("expires_at") or 0)
|
||
has_data = _cache.get("data") is not None
|
||
age_sec = int(now - fetched_at) if fetched_at else None
|
||
expires_in_sec = max(0, int(expires_at - now)) if expires_at else None
|
||
return {
|
||
"has_data": has_data,
|
||
"cached": bool(has_data and expires_at and now < expires_at),
|
||
"fetched_at": fetched_at or None,
|
||
"age_sec": age_sec,
|
||
"ttl_sec": self.cache_ttl,
|
||
"expires_in_sec": expires_in_sec,
|
||
"source": _cache.get("source") or "local",
|
||
}
|
||
|
||
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||
try:
|
||
raw = self._fetch_raw()
|
||
except Exception as exc:
|
||
return {"ok": False, "error": str(exc), "location": self.location_name}
|
||
|
||
current = raw.get("current") or {}
|
||
hourly = raw.get("hourly") or {}
|
||
times = hourly.get("time") or []
|
||
start = _hourly_start_index(times, current.get("time"))
|
||
end = min(start + hours_ahead, len(times))
|
||
hourly_slice = []
|
||
for i in range(start, end):
|
||
code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None
|
||
temp_series = _hourly_series(hourly, "temperature_2m")
|
||
precip_series = _hourly_series(hourly, "precipitation")
|
||
prob_series = _hourly_series(hourly, "precipitation_probability")
|
||
hourly_slice.append({
|
||
"time": times[i],
|
||
"temperature_c": temp_series[i] if i < len(temp_series) else None,
|
||
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
|
||
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
|
||
"weather_code": code,
|
||
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
|
||
})
|
||
|
||
code = current.get("weather_code")
|
||
coverage = _field_coverage(raw)
|
||
return {
|
||
"ok": True,
|
||
"location": self.location_name,
|
||
"data_source": _cache.get("source") or "local",
|
||
"local_field_coverage": _cache.get("local_coverage") or coverage,
|
||
"field_coverage": coverage,
|
||
"sync_hint": SYNC_HINT if not _coverage_sufficient(_cache.get("local_coverage") or coverage) else "",
|
||
"current": {
|
||
"time": current.get("time"),
|
||
"temperature_c": current.get("temperature_2m"),
|
||
"apparent_temperature_c": current.get("apparent_temperature"),
|
||
"humidity_pct": current.get("relative_humidity_2m"),
|
||
"precipitation_mm": current.get("precipitation"),
|
||
"wind_speed_kmh": current.get("wind_speed_10m"),
|
||
"weather_code": code,
|
||
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
|
||
},
|
||
"hourly": hourly_slice,
|
||
}
|
||
|
||
def rain_summary(self, hours_ahead: int = 12) -> str:
|
||
data = self.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||
if not data.get("ok"):
|
||
return f"Погода недоступна: {data.get('error', 'ошибка')}"
|
||
|
||
rainy_hours = []
|
||
for hour in data.get("hourly") or []:
|
||
prob = hour.get("precipitation_probability")
|
||
precip = hour.get("precipitation_mm") or 0
|
||
if (prob is not None and prob >= 40) or precip > 0:
|
||
time_str = (hour.get("time") or "")[11:16]
|
||
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
|
||
|
||
if rainy_hours:
|
||
return "Ожидаются осадки: " + ", ".join(rainy_hours[:6])
|
||
return "Существенных осадков в ближайшие часы не ожидается."
|
||
|
||
|
||
def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
||
client = OpenMeteoClient()
|
||
snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6)
|
||
|
||
lines = ["[Погода]"]
|
||
if not snapshot.get("ok"):
|
||
lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).")
|
||
lines.append("Для точного ответа вызови get_weather.")
|
||
return "\n".join(lines)
|
||
|
||
cur = snapshot.get("current") or {}
|
||
apparent = cur.get("apparent_temperature_c")
|
||
wind = cur.get("wind_speed_kmh")
|
||
apparent_part = f", ощущается {_fmt_num(apparent, suffix='°C')}" if apparent is not None else ""
|
||
wind_part = f", ветер {_fmt_num(wind, suffix=' км/ч')}" if wind is not None else ""
|
||
lines.append(
|
||
f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}"
|
||
f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}."
|
||
)
|
||
hourly = snapshot.get("hourly") or []
|
||
rainy_hours = []
|
||
for hour in hourly:
|
||
prob = hour.get("precipitation_probability")
|
||
precip = hour.get("precipitation_mm") or 0
|
||
if (prob is not None and prob >= 40) or precip > 0:
|
||
time_str = (hour.get("time") or "")[11:16]
|
||
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
|
||
if rainy_hours:
|
||
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
|
||
else:
|
||
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]:
|
||
"""Полный снимок для UI: данные OpenMeteo + контекст ассистента."""
|
||
client = OpenMeteoClient()
|
||
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||
settings = get_settings()
|
||
return {
|
||
"weather": weather,
|
||
"rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "",
|
||
"assistant_context": format_weather_snapshot(weather),
|
||
"cache": client.cache_status(),
|
||
"config": {
|
||
"location": client.location_name,
|
||
"latitude": client.lat,
|
||
"longitude": client.lon,
|
||
"openmeteo_base_url": client.base_url,
|
||
"cache_ttl_sec": client.cache_ttl,
|
||
"forecast_days": 2,
|
||
"timezone": "auto",
|
||
},
|
||
"available_fields": {
|
||
"current": list(CURRENT_FIELDS),
|
||
"hourly": list(HOURLY_FIELDS),
|
||
},
|
||
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": []},
|
||
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": []},
|
||
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
|
||
"sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT,
|
||
"recommended_sync": {
|
||
"domains": RECOMMENDED_SYNC_DOMAINS,
|
||
"variables": RECOMMENDED_SYNC_VARIABLES,
|
||
},
|
||
"assistant_tools": {
|
||
"get_weather": "Текущая погода и почасовой прогнос (hours_ahead до 48)",
|
||
"get_morning_briefing": "Погода + заголовки RSS-новостей",
|
||
},
|
||
"system_prompt": "Краткий блок [Погода] в system prompt каждого сообщения (6 ч почасово).",
|
||
}
|