fix(weather): wind_d returns None when no valid samples, not fabricated 0.0° (#weather-cache)
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.
This commit is contained in:
bot-backend 2026-06-17 21:22:31 +03:00
parent f55e83a150
commit 6cf65eb26b
2 changed files with 114 additions and 7 deletions

View file

@ -137,15 +137,21 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | 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 = daily.get("winddirection_10m_dominant") or []
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
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
# 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 = rose[round(dominant / 45) % 8]
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", [])),
@ -159,7 +165,9 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
"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),
"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,
},

View file

@ -449,3 +449,102 @@ class TestAirQualityCache:
clock[0] += weather_cache._AIR_TTL_S + 1
weather_cache.get_air_quality_cached(56.84, 60.59)
assert get.call_count == 2
# ──────────────────────────────────────────────────────────────────────────────
# 7. wind_d all-None → dominant_direction_deg/label must be None, not 0.0°
# ──────────────────────────────────────────────────────────────────────────────
def _make_forecast_response_wind_all_none() -> dict[str, Any]:
"""Forecast где все winddirection_10m_dominant = null (Open-Meteo штатно возвращает null)."""
return {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [None, None, None], # все null
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
def _make_forecast_response_wind_empty() -> dict[str, Any]:
"""Forecast где winddirection_10m_dominant отсутствует (ключ не пришёл)."""
return {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
# winddirection_10m_dominant отсутствует
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
class TestWindDirectionAllNone:
"""Регрессия: когда нет валидных сэмплов wind_d, не должно возвращаться 0.0° (= север)."""
def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
"""Вспомогательный: патчим httpx, делаем один вызов get_weather_cached."""
get = MagicMock(return_value=_make_httpx_response(payload))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
return weather_cache.get_weather_cached(56.84, 60.59)
def test_all_none_wind_samples_gives_none_direction(self) -> None:
"""Все winddirection_10m_dominant = null → dominant_direction_deg/label = None, не 0.0."""
result = self._call_with_payload(_make_forecast_response_wind_all_none())
assert result is not None, "forecast должен вернуть dict (не None), даже без wind_d"
wind = result["wind"]
assert wind["dominant_direction_deg"] is None, (
f"ожидался None, получено {wind['dominant_direction_deg']!r}"
"вероятно, fabricated 0.0° из-за atan2(0,0)"
)
assert (
wind["dominant_direction_label"] is None
), f"ожидался None, получено {wind['dominant_direction_label']!r}"
def test_missing_wind_key_gives_none_direction(self) -> None:
"""winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None."""
result = self._call_with_payload(_make_forecast_response_wind_empty())
assert result is not None
wind = result["wind"]
assert wind["dominant_direction_deg"] is None
assert wind["dominant_direction_label"] is None
def test_valid_wind_samples_still_compute(self) -> None:
"""Санитарный тест: валидные сэмплы ветра по-прежнему дают числовой результат."""
result = self._call_with_payload(_make_forecast_response())
assert result is not None
wind = result["wind"]
assert wind["dominant_direction_deg"] is not None
assert isinstance(wind["dominant_direction_deg"], int)
assert wind["dominant_direction_label"] is not None
assert isinstance(wind["dominant_direction_label"], str)
def test_mixed_none_and_valid_wind_samples_compute(self) -> None:
"""Часть сэмплов None, часть валидные → только валидные участвуют в circular mean."""
payload = {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [None, 90.0, None], # только 90°=Восток
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
result = self._call_with_payload(payload)
assert result is not None
wind = result["wind"]
# Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток"
assert wind["dominant_direction_deg"] == 90
assert wind["dominant_direction_label"] == "Восток"