daily
This commit is contained in:
@@ -29,42 +29,172 @@ WEATHER_CODES: dict[int, str] = {
|
||||
99: "гроза с градом",
|
||||
}
|
||||
|
||||
_cache: dict[str, Any] = {"data": None, "expires_at": 0.0}
|
||||
_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"]
|
||||
|
||||
params = {
|
||||
"latitude": self.lat,
|
||||
"longitude": self.lon,
|
||||
"current": (
|
||||
"temperature_2m,apparent_temperature,relative_humidity_2m,"
|
||||
"precipitation,weather_code,wind_speed_10m"
|
||||
),
|
||||
"hourly": "temperature_2m,precipitation_probability,precipitation,weather_code",
|
||||
"timezone": "auto",
|
||||
"forecast_days": 2,
|
||||
}
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(f"{self.base_url}/v1/forecast", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
local_raw = self._fetch_from_url(self.base_url)
|
||||
local_coverage = _field_coverage(local_raw)
|
||||
source = "local"
|
||||
raw = local_raw
|
||||
|
||||
_cache["data"] = data
|
||||
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
|
||||
return data
|
||||
_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:
|
||||
@@ -75,21 +205,32 @@ class OpenMeteoClient:
|
||||
current = raw.get("current") or {}
|
||||
hourly = raw.get("hourly") or {}
|
||||
times = hourly.get("time") or []
|
||||
limit = min(hours_ahead, len(times))
|
||||
start = _hourly_start_index(times, current.get("time"))
|
||||
end = min(start + hours_ahead, len(times))
|
||||
hourly_slice = []
|
||||
for i in range(limit):
|
||||
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": hourly.get("temperature_2m", [None])[i],
|
||||
"precipitation_mm": hourly.get("precipitation", [None])[i],
|
||||
"precipitation_probability": hourly.get("precipitation_probability", [None])[i],
|
||||
"weather_code": hourly.get("weather_code", [None])[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"),
|
||||
@@ -132,10 +273,13 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
||||
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')}: {cur.get('temperature_c')}°C "
|
||||
f"(ощущается {cur.get('apparent_temperature_c')}°C), "
|
||||
f"{cur.get('conditions')}, ветер {cur.get('wind_speed_kmh')} км/ч."
|
||||
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 = []
|
||||
@@ -151,3 +295,42 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
||||
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 ч почасово).",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user