diff --git a/backend/app/services/forecasting/normalize.py b/backend/app/services/forecasting/normalize.py index a3bdacdb..bbe81862 100644 --- a/backend/app/services/forecasting/normalize.py +++ b/backend/app/services/forecasting/normalize.py @@ -36,9 +36,10 @@ МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных): сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить случайным шумом одного-двух наблюдений: - • нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — иначе у каждого месяца - < 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд - возвращается без изменений); + • нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по + NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не + несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность» + неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений); • месяц без наблюдений → его фактор = 1.0 (нет базы для оценки); • overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и защита от деления на ноль); @@ -114,7 +115,8 @@ def seasonal_factors( None-значения в values пропускаются (нет наблюдения — не подмешиваем 0). GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума): - • полных лет < min_full_years → ВСЕ факторы 1.0, applied=False; + • полных лет (по NON-ZERO месяцам) < min_full_years → ВСЕ факторы 1.0, + applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения); • overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0); • месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0. @@ -167,15 +169,20 @@ def seasonal_factors( def _count_full_years(months: list[date], values: list[float | int | None]) -> int: - """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду. + """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение). - Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это + Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это «полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки - наблюдением НЕ считаются. PURE. + и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ + считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE. + + Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих + месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал + «полные годы» на фактически пустом ряду. """ months_seen: dict[int, set[int]] = {} for d, v in zip(months, values, strict=False): - if v is None: + if v is None or float(v) == 0.0: continue months_seen.setdefault(d.year, set()).add(d.month) return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR) diff --git a/backend/tests/services/forecasting/test_normalize.py b/backend/tests/services/forecasting/test_normalize.py index a33102f3..f85229b3 100644 --- a/backend/tests/services/forecasting/test_normalize.py +++ b/backend/tests/services/forecasting/test_normalize.py @@ -138,6 +138,50 @@ class TestSeasonalFactors: assert adj.applied is False assert all(f == 1.0 for f in adj.factors.values()) + # ── fix #1638: zero-filled series must fail the min-years guard ─────────── + + def test_zero_filled_36m_fails_min_years_guard(self) -> None: + """Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False. + + До fix #1638 _count_full_years считал нулевые значения за наблюдения + и возвращал n_full_years=3 → guard пропускал → overall_mean==0 спасал + от деления, но прохождение guard'а было семантически неверным. + После fix: 0 не считается наблюдением → n_full_years=0 → applied=False. + """ + months = _months(36) + adj = seasonal_factors(months, [0] * 36) + assert ( + adj.n_full_years == 0 + ), f"expected 0 full years on all-zero series, got {adj.n_full_years}" + assert adj.applied is False + assert all(f == 1.0 for f in adj.factors.values()) + + def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None: + """2 года месяцев, но только часть месяцев ненулевые — не 2 полных года. + + Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года + (остальные zero-fill). _count_full_years должен не считать это «полным годом». + """ + months = _months(24) # 2 календарных года + # Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес. + units = [10 if d.month <= 6 else 0 for d in months] + adj = seasonal_factors(months, units) + assert ( + adj.n_full_years == 0 + ), f"partial-coverage years should not count as full, got {adj.n_full_years}" + assert adj.applied is False + + def test_real_nonzero_series_passes_guard_and_applies(self) -> None: + """Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется. + + Страховка: fix #1638 не должен ломать штатный путь с настоящими данными. + """ + months = _months(36) + units = _seasonal_units(months) # baseline×паттерн, все > 0 + adj = seasonal_factors(months, units) + assert adj.n_full_years == 3 + assert adj.applied is True + def test_month_with_no_observations_factor_one(self) -> None: # Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст # (None). Чтобы пройти year-guard и реально дойти до per-month ветки, @@ -239,3 +283,17 @@ class TestNormalizeDemand: units = _seasonal_units(months) result = normalize_demand(_make_series(months, units)) assert all(isinstance(u, int) and u >= 0 for u in result.units) + + def test_zero_filled_series_returned_unchanged(self) -> None: + """fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений. + + До fix: guard считал 3 «полных года» на нулях → adjustment.applied=True + (пройдя через overall_mean==0 check) или мог пройти guard и вернуть + идентичный ряд через нейтральные факторы. После fix: n_full_years=0 → + applied=False → функция возвращает тот же объект (short-circuit). + """ + months = _months(36) + series = _make_series(months, [0] * 36) + result = normalize_demand(series) + assert result is series, "zero-filled series must be returned as-is (no adjustment)" + assert result.units == [0] * 36