From 9ae7af5753be2c9c6c0b3a29d7de04b94b4e6337 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 11:10:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=C2=AB0=20=D0=BC=D0=BC=20=D0=BE?= =?UTF-8?q?=D1=81=D0=B0=D0=B4=D0=BA=D0=BE=D0=B2=C2=BB=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=BE=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D1=8C=20=C2=AB=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BD=D0=B5=D1=82=C2=BB=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weather_cache отдавал 0, когда open-meteo не прислал ряд осадков: "precipitation_total_mm": round(sum(precip), 1) if precip else 0 "total_precip_mm": round(sum(precip), 0) if precip else 0 (сезонные) Ноль здесь — утверждение о погоде («сухо»), а пустой ряд значит, что мы не знаем. Разница видна пользователю: ptica-adapt заворачивал значение в real(...) БЕЗУСЛОВНО, то есть подавал его как измеренную величину. Непоследовательность внутри одного литерала: шесть соседних агрегатов при пустых данных дают None (min_c / max_c / avg_max_c / avg_min_c / uv_index_max / max_speed_m_s). В сезонном блоке ещё нагляднее — avg_precip_per_day_mm и total_precip_mm считаются из ОДНОГО списка соседними строками, первый даёт None, второй давал 0. В репозитории уже был решён ровно этот класс: раздел 7 тестов weather_cache — «wind_d all-None → dominant_direction_deg = None, а не 0.0° (= север)». Новые тесты зеркалят его структуру, включая заголовок раздела. Фронт: оба поля стали nullable, и TypeScript сам нашёл всех потребителей — четыре ошибки в двух файлах плюс сезонный блок. Везде «нет данных» вместо числа. В ptica-adapt применён образец соседней строки avg_min_c: есть значение → real(), нет → notReal(). Тесты: 2 красных на origin/main — «AssertionError: ожидался None, получено 0 — ноль здесь читается как «осадков не будет»». Контроль отдельный: настоящая сухая неделя (precip=[0,0,0]) обязана давать ИЗМЕРЕННЫЙ 0.0, а не None — без него правку можно было бы «сделать» так, что осадки всегда None, и оба теста стали бы зелёными по неверной причине. pytest tests/services: 3089 passed, 14 skipped, rc=0 (перепрогнано после хуков) tsc --noEmit rc=0 · vitest 36 файлов / 279 тестов rc=0 --- backend/app/services/weather_cache.py | 16 +++- backend/tests/services/test_weather_cache.py | 84 +++++++++++++++++++ .../components/site-finder/EnvironmentTab.tsx | 27 +++--- .../site-finder/SeasonalWeatherBlock.tsx | 5 +- .../analysis/Section5Atmosphere.tsx | 9 +- .../site-finder/ptica/ptica-adapt.ts | 16 +++- frontend/src/types/site-finder.ts | 8 +- 7 files changed, 143 insertions(+), 22 deletions(-) diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index 179f2245..00fd366a 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -161,8 +161,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | 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), + # #2464: было `if precip else 0`. Ноль здесь означал бы «осадков не + # ожидается» — утверждение о погоде. Но пустой `precip` значит, что + # open-meteo не отдал ряд осадков вовсе, то есть мы НЕ ЗНАЕМ. Все шесть + # соседних агрегатов в этом же словаре при пустых данных дают None + # (min_c/max_c/avg_*/uv_index_max/max_speed_m_s) — осадки были + # единственным исключением, и именно они рисуются на фронте как + # измеренная величина (ptica-adapt заворачивает их в `real(...)`). + "precipitation_total_mm": round(sum(precip), 1) if precip else None, + "precipitation_days": (sum(1 for p in precip if p and p > 0.5) if precip else None), "uv_index_max": round(max(uv), 1) if uv else None, "wind": { "dominant_direction_deg": ( @@ -251,7 +258,10 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | 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, + # #2464: та же правка, что у прогноза выше. Особенно наглядно + # здесь: соседняя строка avg_precip_per_day_mm считается из ЭТОГО ЖЕ + # списка и при пустом даёт None, а сумма давала 0. + "total_precip_mm": round(sum(precip), 0) if precip else None, "days_observed": len(vals["t_max"]), } return { diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index c5cab6f1..ed5b5f71 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -594,3 +594,87 @@ class TestWindDirectionAllNone: # Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток" assert wind["dominant_direction_deg"] == 90 assert wind["dominant_direction_label"] == "Восток" + + +# ────────────────────────────────────────────────────────────────────────────── +# 8. осадков нет в ответе → precipitation_* = None, а не 0 (#2464) +# +# Тот же класс, что раздел 7 выше (wind_d all-None → None, не 0.0°): «ноль» здесь +# был бы утверждением о погоде («сухо»), тогда как пустой ряд значит, что мы просто +# не знаем. Все шесть соседних агрегатов того же словаря при пустых данных дают None +# — осадки были единственным исключением, и именно они рисуются на фронте как +# измеренная величина (ptica-adapt заворачивал их в `real(...)` безусловно). +# ────────────────────────────────────────────────────────────────────────────── + + +def _make_forecast_response_precip_all_none() -> dict[str, Any]: + """Forecast, где все precipitation_sum = null (Open-Meteo так делает штатно).""" + 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": [None, None, None], + "uv_index_max": [5.0, 5.0, 5.0], + "winddirection_10m_dominant": [180, 180, 180], + "windspeed_10m_max": [3.0, 4.0, 3.0], + } + } + + +def _make_forecast_response_precip_missing() -> dict[str, Any]: + """Forecast без ключа precipitation_sum вовсе.""" + 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], + "uv_index_max": [5.0, 5.0, 5.0], + "winddirection_10m_dominant": [180, 180, 180], + "windspeed_10m_max": [3.0, 4.0, 3.0], + } + } + + +class TestPrecipitationUnknownIsNotZero: + """Регрессия: без ряда осадков не должно возвращаться 0 (= «сухо»).""" + + def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: + 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_precip_gives_none_not_zero(self) -> None: + result = self._call_with_payload(_make_forecast_response_precip_all_none()) + + assert result is not None, "ответ должен строиться и без ряда осадков" + assert result["precipitation_total_mm"] is None, ( + f"ожидался None, получено {result['precipitation_total_mm']!r} — " + "ноль здесь читается как «осадков не будет»" + ) + assert result["precipitation_days"] is None + + def test_missing_precip_key_gives_none(self) -> None: + result = self._call_with_payload(_make_forecast_response_precip_missing()) + + assert result is not None + assert result["precipitation_total_mm"] is None + assert result["precipitation_days"] is None + + def test_real_dry_week_is_still_a_measured_zero(self) -> None: + """КОНТРОЛЬ: настоящая сухая неделя — это измеренный 0.0, а не None. + + Без этой проверки правку можно было бы «сделать» так, что осадки всегда + None, и оба теста выше стали бы зелёными по неверной причине. + """ + payload = _make_forecast_response_precip_all_none() + payload["daily"]["precipitation_sum"] = [0.0, 0.0, 0.0] + + result = self._call_with_payload(payload) + + assert result is not None + assert result["precipitation_total_mm"] == 0.0 + assert result["precipitation_days"] == 0 diff --git a/frontend/src/components/site-finder/EnvironmentTab.tsx b/frontend/src/components/site-finder/EnvironmentTab.tsx index 4d4a90fa..7b6228d5 100644 --- a/frontend/src/components/site-finder/EnvironmentTab.tsx +++ b/frontend/src/components/site-finder/EnvironmentTab.tsx @@ -361,16 +361,23 @@ function WeatherBlock({
Осадки
-
- {weather.precipitation_total_mm} мм - - {" "} - (за {weather.forecast_days} дн.) - -
-
- {weather.precipitation_days} дн. с осадками -
+ {weather.precipitation_total_mm != null ? ( + <> +
+ {weather.precipitation_total_mm} мм + + {" "} + (за {weather.forecast_days} дн.) + +
+
+ {weather.precipitation_days} дн. с осадками +
+ + ) : ( + /* #2464: «0 мм» здесь читалось бы как «сухо». Данных нет — так и пишем. */ +
нет данных
+ )}
{weather.uv_index_max != null && ( diff --git a/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx b/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx index 828614e5..59b7b567 100644 --- a/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx +++ b/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx @@ -50,7 +50,10 @@ function SeasonCard({ (экстремумы ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
- {stats.total_precip_mm} мм осадков + {/* #2464: null — нормали по осадкам не пришли, это не «ноль миллиметров». */} + {stats.total_precip_mm != null + ? `${stats.total_precip_mm} мм осадков` + : "осадки — нет данных"}
); diff --git a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx index bfaa31e5..d9899583 100644 --- a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx +++ b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx @@ -356,8 +356,9 @@ function WeatherWindBlock({ }: { wind: ParcelAnalysisWind; uvMax: number | null | undefined; - precipMm: number; - precipDays: number; + // #2464: null — ряда осадков не было. «0 мм» читалось бы как «сухо». + precipMm: number | null; + precipDays: number | null; forecastDays: number; source: string; }) { @@ -397,7 +398,9 @@ function WeatherWindBlock({ style={{ display: "inline", marginRight: 4, verticalAlign: "middle" }} aria-hidden /> - {precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн. + {precipMm != null + ? `${precipMm} мм · ${precipDays} дн. с осадками за ${forecastDays} дн.` + : "осадки — нет данных"} {uvMax != null && (
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 615dd149..ae994023 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -1413,9 +1413,21 @@ export function adaptEnvironmentDrawer( }, { k: "Осадки (сумма)", - field: real(`${formatInt(wt.precipitation_total_mm)} мм`), + // #2464: раньше стояло real(...) безусловно, и при отсутствии ряда осадков + // «0 мм» подавалось как ИЗМЕРЕННОЕ значение. Образец рядом — avg_min_c + // строкой выше: есть значение → real, нет → notReal. + field: + wt.precipitation_total_mm != null + ? real(`${formatInt(wt.precipitation_total_mm)} мм`) + : notReal("нет данных об осадках"), + }, + { + k: "Дней с осадками", + field: + wt.precipitation_days != null + ? real(formatInt(wt.precipitation_days)) + : notReal("нет данных об осадках"), }, - { k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) }, ] : [{ k: "Погода", field: notReal("нет данных погоды") }]; diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 758195c8..cd2f9e2c 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -95,8 +95,9 @@ export interface ParcelAnalysisWeather { avg_max_c: number | null; avg_min_c: number | null; }; - precipitation_total_mm: number; - precipitation_days: number; + /** #2464: null — open-meteo не отдал ряд осадков. Ноль означал бы «сухо». */ + precipitation_total_mm: number | null; + precipitation_days: number | null; uv_index_max: number | null; wind: ParcelAnalysisWind; source: string; @@ -120,7 +121,8 @@ export interface SeasonStats { max_t_c: number; min_t_c: number; avg_precip_per_day_mm: number; - total_precip_mm: number; + /** #2464: null — данных по осадкам нет, это НЕ «ноль миллиметров». */ + total_precip_mm: number | null; days_observed: number; }