diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index f79c44fb..179f2245 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -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, }, diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index 241e7719..f04c2322 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -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"] == "Восток"