Some checks failed
CI / changes (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (pull_request) Has been cancelled
When all winddirection_10m_dominant samples were None (or the key was
absent), atan2(0, 0) produced 0.0° (due north) as a fabricated value.
Root causes:
1. [None, None, ...] is truthy → entered circular-mean branch, but
filtered sums x=y=0 → atan2(0,0)=0 → 0.0°
2. Empty list → else 0.0 branch → same fabrication
Fix: filter None from wind_d before aggregation (consistent with t_max /
t_min / uv). Only compute circular mean when at least one valid sample
exists; otherwise dominant_direction_deg and dominant_direction_label are
None. Adds TestWindDirectionAllNone covering all-None, missing key, valid
samples sanity, and mixed None+valid cases.
373 lines
20 KiB
Python
373 lines
20 KiB
Python
"""TTL-кэш для Open-Meteo (PR #1130 Phase A+B — Site Finder analyze latency).
|
||
|
||
ПРОБЛЕМА: `analyze_parcel` в hot-path делал ТРИ внешних HTTP-вызова к Open-Meteo на
|
||
каждый запрос (forecast + climate normals + air-quality), без кэша. На проде backend в
|
||
private network с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay
|
||
висит полный timeout (5s + 15s + 5s = до 25с лишних на analyze). Профиль cProfile показал
|
||
~0.26с air-quality в успешном hit и +5с при DNS-fail (тот же антипаттерн, что был у
|
||
forecast/climate до Phase A).
|
||
|
||
РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам.
|
||
|
||
• Forecast (`get_weather_cached`) — 7-day погода, мало меняется в пределах часов.
|
||
TTL hit: 6 часов; TTL negative (failure): 5 минут — чтобы DNS-fail не повторял
|
||
`timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут.
|
||
• Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали,
|
||
обновляются ~раз в год. TTL hit: 7 суток; TTL negative: 5 минут.
|
||
• Air quality (`get_air_quality_cached`) — текущий час pm2_5/pm10/no2.
|
||
TTL hit: 1 час (фронт-API API hourly bucket, обновлять чаще forecast);
|
||
TTL negative: 5 минут.
|
||
|
||
Ключ — `(round(lat, 2), round(lon, 2))`: округление до сотых ≈ 1 км, ЕКБ-wide один cad
|
||
≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от
|
||
утечки при потенциальном дрейфе ключей; для одного города этот лимит не достигается.
|
||
|
||
КОРРЕКТНОСТЬ: контракт для caller'а не меняется — None по-прежнему допустим (caller в
|
||
`parcels.py:_build_environmental_score` обрабатывает `bool(weather)` как env_ok-флаг).
|
||
Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None.
|
||
|
||
THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш
|
||
защищён своим `threading.Lock`, который удерживается ТОЛЬКО на время check/store — сам
|
||
сетевой httpx-вызов идёт ВНЕ lock'а (#1370), иначе все analyze одного cache-типа
|
||
сериализуются на время вызова (lock — per-cache-type, не per-key). Trade-off: конкурентный
|
||
cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write wins),
|
||
что приемлемо — вызовы идемпотентны, а TTL длинный.
|
||
|
||
time.monotonic используется вместо time.time — устойчиво к скачкам системного времени
|
||
(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки
|
||
времени за expires_at без манки самого time.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# TTL в секундах (см. модульный docstring).
|
||
_WEATHER_TTL_S: float = 6 * 3600 # forecast — 6 часов
|
||
_SEASONAL_TTL_S: float = 7 * 86400 # climate normals — 7 суток
|
||
_AIR_TTL_S: float = 1 * 3600 # air quality — 1 час (текущий час, обновлять чаще forecast)
|
||
_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут все кэши
|
||
|
||
# Тайм-ауты httpx сокращены против оригинальных (5s/15s/5s) — open-meteo обычно отвечает
|
||
# за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker
|
||
# 15с в hot-path.
|
||
_WEATHER_TIMEOUT_S: float = 2.0
|
||
_SEASONAL_TIMEOUT_S: float = 3.0
|
||
_AIR_TIMEOUT_S: float = 2.0
|
||
|
||
# Soft-cap на размер каждой таблицы (округлённых координат для одного города мало).
|
||
_MAX_ENTRIES: int = 256
|
||
|
||
# (value | None, expires_at_monotonic_seconds). None в value = negative-cache.
|
||
_CacheEntry = tuple[dict[str, Any] | None, float]
|
||
|
||
_FORECAST_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
||
_FORECAST_LOCK = threading.Lock()
|
||
|
||
_CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
||
_CLIMATE_LOCK = threading.Lock()
|
||
|
||
_AIR_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
||
_AIR_LOCK = threading.Lock()
|
||
|
||
|
||
def _now() -> float:
|
||
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
|
||
return time.monotonic()
|
||
|
||
|
||
def _round_key(lat: float, lon: float) -> tuple[float, float]:
|
||
"""Ключ кэша — координаты, округлённые до 0.01° (~1 км). См. docstring модуля."""
|
||
return (round(lat, 2), round(lon, 2))
|
||
|
||
|
||
def _evict_one_if_full(cache: dict[tuple[float, float], _CacheEntry]) -> None:
|
||
"""Soft-cap eviction: при превышении `_MAX_ENTRIES` выбросить старейшую по вставке запись.
|
||
|
||
Фактически FIFO благодаря insertion-order dict в CPython 3.7+: `next(iter(cache))` —
|
||
первый по порядку вставки ключ, т.е. самый старый (с большой вероятностью stale).
|
||
Не LRU — достаточно простой защиты от утечки на случай дрейфа ключей; LRU потребовал
|
||
бы `OrderedDict.move_to_end` на каждый hit (over-engineering при 256 lim + 6h TTL).
|
||
Под lock'ом вызывающей стороны.
|
||
"""
|
||
if len(cache) >= _MAX_ENTRIES:
|
||
victim = next(iter(cache))
|
||
cache.pop(victim, None)
|
||
|
||
|
||
def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Forecast API (7-day) — собственно сетевой вызов и парсинг.
|
||
|
||
Любое исключение (httpx, json, KeyError) → logger.warning + None (caller обернёт
|
||
None в negative-cache на короткий TTL).
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=_WEATHER_TIMEOUT_S) as c:
|
||
r = c.get(
|
||
"https://api.open-meteo.com/v1/forecast",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"daily": (
|
||
"temperature_2m_max,temperature_2m_min,"
|
||
"precipitation_sum,uv_index_max,"
|
||
"winddirection_10m_dominant,windspeed_10m_max"
|
||
),
|
||
"timezone": "Europe/Moscow",
|
||
"forecast_days": 7,
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
daily = r.json().get("daily", {})
|
||
if not daily.get("time"):
|
||
return None
|
||
|
||
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
|
||
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
|
||
precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
|
||
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
|
||
wind_d = [v for v in (daily.get("winddirection_10m_dominant") or []) if v is not None]
|
||
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
|
||
|
||
# Circular mean направления ветра (vector sum) — избегает jump 359→1.
|
||
# Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
|
||
if wind_d:
|
||
x = sum(math.cos(math.radians(d)) for d in wind_d)
|
||
y = sum(math.sin(math.radians(d)) for d in wind_d)
|
||
dominant_deg: float | None = (math.degrees(math.atan2(y, x)) + 360) % 360
|
||
else:
|
||
dominant_deg = None
|
||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||
wind_label: str | None = (
|
||
rose[round(dominant_deg / 45) % 8] if dominant_deg is not None else None
|
||
)
|
||
|
||
return {
|
||
"forecast_days": len(daily.get("time", [])),
|
||
"temperature": {
|
||
"min_c": round(min(t_min), 1) if t_min else None,
|
||
"max_c": round(max(t_max), 1) if t_max else None,
|
||
"avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
|
||
"avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||
},
|
||
"precipitation_total_mm": round(sum(precip), 1) if precip else 0,
|
||
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
|
||
"uv_index_max": round(max(uv), 1) if uv else None,
|
||
"wind": {
|
||
"dominant_direction_deg": (
|
||
round(dominant_deg) if dominant_deg is not None else None
|
||
),
|
||
"dominant_direction_label": wind_label,
|
||
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
|
||
},
|
||
"source": "open-meteo",
|
||
"note": (
|
||
"7-day forecast. Для исторических норм и B2B-данных — "
|
||
"Yandex Business / Gismeteo (платно)."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("weather fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Climate API — 30-летние сезонные нормали (MRI-AGCM3.2-S).
|
||
|
||
Группирует 1995-2024 по четырём сезонам. Любое исключение → logger.warning + None
|
||
(caller обернёт None в negative-cache на короткий TTL).
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=_SEASONAL_TIMEOUT_S) as c:
|
||
r = c.get(
|
||
"https://climate-api.open-meteo.com/v1/climate",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"start_date": "1995-01-01",
|
||
"end_date": "2024-12-31",
|
||
"models": "MRI_AGCM3_2_S",
|
||
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
daily = data.get("daily", {})
|
||
times = daily.get("time") or []
|
||
t_max = daily.get("temperature_2m_max") or []
|
||
t_min = daily.get("temperature_2m_min") or []
|
||
precip = daily.get("precipitation_sum") or []
|
||
if not times:
|
||
return None
|
||
|
||
seasons_months = {
|
||
"winter": [12, 1, 2],
|
||
"spring": [3, 4, 5],
|
||
"summer": [6, 7, 8],
|
||
"autumn": [9, 10, 11],
|
||
}
|
||
buckets: dict[str, dict[str, list[float]]] = {
|
||
k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
|
||
}
|
||
for i, t in enumerate(times):
|
||
month = int(t[5:7]) # 'YYYY-MM-DD'
|
||
for season, months in seasons_months.items():
|
||
if month in months:
|
||
if i < len(t_max) and t_max[i] is not None:
|
||
buckets[season]["t_max"].append(t_max[i])
|
||
if i < len(t_min) and t_min[i] is not None:
|
||
buckets[season]["t_min"].append(t_min[i])
|
||
if i < len(precip) and precip[i] is not None:
|
||
buckets[season]["precip"].append(precip[i])
|
||
break
|
||
|
||
seasons: dict[str, Any] = {}
|
||
for season, vals in buckets.items():
|
||
if not vals["t_max"]:
|
||
seasons[season] = None
|
||
continue
|
||
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
|
||
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
|
||
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
|
||
# по пустому списку → None вместо падения всего сезонного ответа.
|
||
t_min = vals["t_min"]
|
||
precip = vals["precip"]
|
||
seasons[season] = {
|
||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||
"avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||
"max_t_c": round(max(vals["t_max"]), 1),
|
||
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||
"avg_precip_per_day_mm": (
|
||
round(sum(precip) / len(precip), 1) if precip else None
|
||
),
|
||
"total_precip_mm": round(sum(precip), 0) if precip else 0,
|
||
"days_observed": len(vals["t_max"]),
|
||
}
|
||
return {
|
||
"seasons": seasons,
|
||
"period": "1995-2024 (30 лет)",
|
||
"model": "MRI-AGCM3-2-S",
|
||
"source": "open-meteo-climate",
|
||
"note": ("Климатические нормали. Текущая погода — отдельный API."),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("seasonal weather fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
def get_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Forecast (7-day) с TTL-кэшем.
|
||
|
||
Hit (успех): кэш 6 часов. Negative (None при ошибке): кэш 5 минут. Ключ —
|
||
координаты, округлённые до 0.01° (~1 км). На любой Exception от httpx/json
|
||
возвращает None и кэширует None под коротким TTL — контракт для caller'а не
|
||
меняется (None по-прежнему допустим).
|
||
"""
|
||
key = _round_key(lat, lon)
|
||
now = _now()
|
||
with _FORECAST_LOCK:
|
||
entry = _FORECAST_CACHE.get(key)
|
||
if entry is not None and entry[1] > now:
|
||
return entry[0]
|
||
# MISS или истёк → сетевой вызов ВНЕ lock'а, иначе все analyze одного cache-типа
|
||
# (даже для разных координат) сериализуются на время httpx-вызова (#1370). Ценой —
|
||
# cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write
|
||
# wins при store ниже), что приемлемо: вызовы идемпотентны, TTL длинный.
|
||
value = _fetch_weather_remote(lat, lon)
|
||
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||
with _FORECAST_LOCK:
|
||
_evict_one_if_full(_FORECAST_CACHE)
|
||
_FORECAST_CACHE[key] = (value, _now() + ttl)
|
||
return value
|
||
|
||
|
||
def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Climate API (30-летние сезонные нормали) с TTL-кэшем.
|
||
|
||
Hit (успех): кэш 7 суток (нормали меняются ~раз в год). Negative (None при
|
||
ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км). На любой
|
||
Exception от httpx/json возвращает None и кэширует None под коротким TTL.
|
||
"""
|
||
key = _round_key(lat, lon)
|
||
now = _now()
|
||
with _CLIMATE_LOCK:
|
||
entry = _CLIMATE_CACHE.get(key)
|
||
if entry is not None and entry[1] > now:
|
||
return entry[0]
|
||
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||
value = _fetch_seasonal_remote(lat, lon)
|
||
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||
with _CLIMATE_LOCK:
|
||
_evict_one_if_full(_CLIMATE_CACHE)
|
||
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
||
return value
|
||
|
||
|
||
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
|
||
|
||
Использует `current=...` (bucket текущего часа), а не `hourly[0]` (=00:00 forecast-дня) —
|
||
docstring/UX обещают «качество воздуха сейчас» (#1377). Формат возвращаемого dict
|
||
(ключи pm2_5/pm10/no2/ts/source) сохранён — фронт зависит. Любое исключение →
|
||
logger.warning + None (caller обернёт None в negative-cache на короткий TTL).
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
|
||
r = c.get(
|
||
"https://air-quality-api.open-meteo.com/v1/air-quality",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
# `current` отдаёт bucket текущего часа (а не hourly[0]=00:00) —
|
||
# docstring/UX обещают «качество воздуха сейчас». См. #1377.
|
||
"current": "pm2_5,pm10,nitrogen_dioxide",
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
current = data.get("current", {})
|
||
if not current.get("time"):
|
||
return None
|
||
return {
|
||
"pm2_5": current.get("pm2_5"),
|
||
"pm10": current.get("pm10"),
|
||
"no2": current.get("nitrogen_dioxide"),
|
||
"ts": current["time"],
|
||
"source": "open-meteo",
|
||
}
|
||
except Exception as e:
|
||
logger.warning("air quality fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||
"""Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа) с TTL-кэшем.
|
||
|
||
Hit (успех): кэш 1 час (hourly bucket API — обновлять чаще forecast). Negative
|
||
(None при ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км).
|
||
На любой Exception от httpx/json возвращает None и кэширует None под коротким TTL.
|
||
"""
|
||
key = _round_key(lat, lon)
|
||
now = _now()
|
||
with _AIR_LOCK:
|
||
entry = _AIR_CACHE.get(key)
|
||
if entry is not None and entry[1] > now:
|
||
return entry[0]
|
||
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||
value = _fetch_air_quality_remote(lat, lon)
|
||
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||
with _AIR_LOCK:
|
||
_evict_one_if_full(_AIR_CACHE)
|
||
_AIR_CACHE[key] = (value, _now() + ttl)
|
||
return value
|