fix(ptica): «0 мм осадков» перестаёт означать «данных нет» (#2464) (#2947)
Some checks failed
Deploy / changes (push) Successful in 13s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m57s
Deploy / build-worker (push) Successful in 4m25s
Deploy / build-frontend (push) Successful in 4m31s
Deploy / deploy (push) Failing after 5s
Deploy / perimeter-smoke (push) Has been skipped
Deploy / deploy-status (push) Failing after 1s
Some checks failed
Deploy / changes (push) Successful in 13s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m57s
Deploy / build-worker (push) Successful in 4m25s
Deploy / build-frontend (push) Successful in 4m31s
Deploy / deploy (push) Failing after 5s
Deploy / perimeter-smoke (push) Has been skipped
Deploy / deploy-status (push) Failing after 1s
This commit is contained in:
parent
9e4b190303
commit
6bca4f7ead
7 changed files with 143 additions and 22 deletions
|
|
@ -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_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,
|
"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,
|
# #2464: было `if precip else 0`. Ноль здесь означал бы «осадков не
|
||||||
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
|
# ожидается» — утверждение о погоде. Но пустой `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,
|
"uv_index_max": round(max(uv), 1) if uv else None,
|
||||||
"wind": {
|
"wind": {
|
||||||
"dominant_direction_deg": (
|
"dominant_direction_deg": (
|
||||||
|
|
@ -251,7 +258,10 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
"avg_precip_per_day_mm": (
|
"avg_precip_per_day_mm": (
|
||||||
round(sum(precip) / len(precip), 1) if precip else None
|
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"]),
|
"days_observed": len(vals["t_max"]),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -594,3 +594,87 @@ class TestWindDirectionAllNone:
|
||||||
# Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток"
|
# Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток"
|
||||||
assert wind["dominant_direction_deg"] == 90
|
assert wind["dominant_direction_deg"] == 90
|
||||||
assert wind["dominant_direction_label"] == "Восток"
|
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
|
||||||
|
|
|
||||||
|
|
@ -361,16 +361,23 @@ function WeatherBlock({
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>Осадки</div>
|
<div style={{ fontSize: 11, color: "#6b7280" }}>Осадки</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>
|
{weather.precipitation_total_mm != null ? (
|
||||||
{weather.precipitation_total_mm} мм
|
<>
|
||||||
<span style={{ fontSize: 11, fontWeight: 400, color: "#6b7280" }}>
|
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>
|
||||||
{" "}
|
{weather.precipitation_total_mm} мм
|
||||||
(за {weather.forecast_days} дн.)
|
<span style={{ fontSize: 11, fontWeight: 400, color: "#6b7280" }}>
|
||||||
</span>
|
{" "}
|
||||||
</div>
|
(за {weather.forecast_days} дн.)
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
</span>
|
||||||
{weather.precipitation_days} дн. с осадками
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
|
{weather.precipitation_days} дн. с осадками
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* #2464: «0 мм» здесь читалось бы как «сухо». Данных нет — так и пишем. */
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>нет данных</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{weather.uv_index_max != null && (
|
{weather.uv_index_max != null && (
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,10 @@ function SeasonCard({
|
||||||
(экстремумы ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
(экстремумы ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#374151" }}>
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
{stats.total_precip_mm} мм осадков
|
{/* #2464: null — нормали по осадкам не пришли, это не «ноль миллиметров». */}
|
||||||
|
{stats.total_precip_mm != null
|
||||||
|
? `${stats.total_precip_mm} мм осадков`
|
||||||
|
: "осадки — нет данных"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -356,8 +356,9 @@ function WeatherWindBlock({
|
||||||
}: {
|
}: {
|
||||||
wind: ParcelAnalysisWind;
|
wind: ParcelAnalysisWind;
|
||||||
uvMax: number | null | undefined;
|
uvMax: number | null | undefined;
|
||||||
precipMm: number;
|
// #2464: null — ряда осадков не было. «0 мм» читалось бы как «сухо».
|
||||||
precipDays: number;
|
precipMm: number | null;
|
||||||
|
precipDays: number | null;
|
||||||
forecastDays: number;
|
forecastDays: number;
|
||||||
source: string;
|
source: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -397,7 +398,9 @@ function WeatherWindBlock({
|
||||||
style={{ display: "inline", marginRight: 4, verticalAlign: "middle" }}
|
style={{ display: "inline", marginRight: 4, verticalAlign: "middle" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн.
|
{precipMm != null
|
||||||
|
? `${precipMm} мм · ${precipDays} дн. с осадками за ${forecastDays} дн.`
|
||||||
|
: "осадки — нет данных"}
|
||||||
</div>
|
</div>
|
||||||
{uvMax != null && (
|
{uvMax != null && (
|
||||||
<div style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
|
<div style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
|
||||||
|
|
|
||||||
|
|
@ -1413,9 +1413,21 @@ export function adaptEnvironmentDrawer(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
k: "Осадки (сумма)",
|
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("нет данных погоды") }];
|
: [{ k: "Погода", field: notReal("нет данных погоды") }];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,9 @@ export interface ParcelAnalysisWeather {
|
||||||
avg_max_c: number | null;
|
avg_max_c: number | null;
|
||||||
avg_min_c: number | null;
|
avg_min_c: number | null;
|
||||||
};
|
};
|
||||||
precipitation_total_mm: number;
|
/** #2464: null — open-meteo не отдал ряд осадков. Ноль означал бы «сухо». */
|
||||||
precipitation_days: number;
|
precipitation_total_mm: number | null;
|
||||||
|
precipitation_days: number | null;
|
||||||
uv_index_max: number | null;
|
uv_index_max: number | null;
|
||||||
wind: ParcelAnalysisWind;
|
wind: ParcelAnalysisWind;
|
||||||
source: string;
|
source: string;
|
||||||
|
|
@ -120,7 +121,8 @@ export interface SeasonStats {
|
||||||
max_t_c: number;
|
max_t_c: number;
|
||||||
min_t_c: number;
|
min_t_c: number;
|
||||||
avg_precip_per_day_mm: number;
|
avg_precip_per_day_mm: number;
|
||||||
total_precip_mm: number;
|
/** #2464: null — данных по осадкам нет, это НЕ «ноль миллиметров». */
|
||||||
|
total_precip_mm: number | null;
|
||||||
days_observed: number;
|
days_observed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue