From 06b0063c6745129f02d78b5a39beb558f9cb13cc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 23:14:38 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=BD=D0=B5=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=BC=D0=BE=D0=B6=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80=D1=8B=20=D1=80=D0=B5=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `synthesize_teap_from_buildability` проверяла параметры только на `> 0`. Процент застройки 150 давал пятно БОЛЬШЕ участка (10 000 м² → 15 000 м²), а дальше — жилую площадь, число квартир и выручку: физически невозможные числа, поданные как обычные цифры финмодели. КСИТ 500 давал GFA 5 000 000 м² на гектаре. Параметры приходят из ПЗЗ-регламента (`zone_regulation_cache`) — внешние разобранные данные, то есть граница доверия. Невозможное значение ОТБРАСЫВАЕТСЯ, а не роняет расчёт: если рядом есть КСИТ, GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем неверный. Если вменяемых не осталось — None, и caller штатно показывает отсутствие финоценки с caveat, а не ноль. Границы взяты с запасом к реальным данным прода 20.08.2026 (33 строки zone_regulation_cache: pct 0..100, far 1..4, floors 0..5): pct ≤ 100, far ≤ 30, этажей ≤ 100 — сито против порчи разбора, а не норматив. ВТОРОЙ дефект, найденный этими же тестами и существовавший до правки: ветка «нет ни процента, ни этажности → пятно = GFA» неявно предполагает один этаж, и при КСИТ > 1 давала пятно больше участка (10 000 м² с far=2 → 20 000 м²). Добавлен физический инвариант «пятно ≤ участок» — не эвристика, а геометрия, и стоит он ОДИН раз после всех ветвей, чтобы держаться и для будущих способов оценки пятна. GFA при этом не меняется. Двусторонне: против origin/main четыре теста красные с конкретными невозможными значениями («пятно 15000.0 больше участка 10000.0», «GFA=5000000.0»). Восемь контролей зелёные с обеих сторон — среди них пять сочетаний (pct, far, floors), взятых ДОСЛОВНО с прода, и граница 100 % застройки, которая законна и на проде есть. pytest test_parcel_financial + services/generative + новый файл — 185 passed. Co-Authored-By: Claude Opus 5 --- .../services/site_finder/parcel_financial.py | 72 ++++++++++ .../test_2464_teap_input_sanity.py | 132 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 backend/tests/services/site_finder/test_2464_teap_input_sanity.py diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py index 0e207ca4..e3e8a2e5 100644 --- a/backend/app/services/site_finder/parcel_financial.py +++ b/backend/app/services/site_finder/parcel_financial.py @@ -118,6 +118,37 @@ def select_calibrated_price( return None, "class_norm" +# Потолки правдоподобия для параметров градрегламента (#2464). Не нормативные +# лимиты, а сито против порчи разбора: самый плотный жилой КСИТ в РФ — единицы, +# самый высокий жилой дом — меньше 100 этажей. Прод 20.08.2026: far 1..4, +# floors 0..5 — запас больше чем семикратный. +_MAX_PLAUSIBLE_FAR: float = 30.0 +_MAX_PLAUSIBLE_FLOORS: int = 100 + + +def _sane(value: float | None, low: float, high: float, name: str) -> float | None: + """Вернуть значение, если оно в (low, high]; иначе None с предупреждением. + + Ноль и отрицательные отбрасываются молча — их отсутствие уже штатно + обрабатывается ветвями ниже, и логировать «в регламенте нет параметра» + незачем. Предупреждаем только о значениях ВНЕ верхней границы: это признак + порчи разбора, и его нужно видеть. + """ + if value is None or value <= low: + return None + if value > high: + logger.warning( + "synthesize_teap: %s=%s вне правдоподобного диапазона (%s, %s] — " + "параметр отброшен, расчёт продолжен по остальным", + name, + value, + low, + high, + ) + return None + return float(value) + + def synthesize_teap_from_buildability( *, area_m2: float | None, @@ -147,6 +178,30 @@ def synthesize_teap_from_buildability( if area_m2 is None or area_m2 <= 0: return None + # ── Санитария входа (#2464) ──────────────────────────────────────────────── + # Параметры приходят из ПЗЗ-регламента (zone_regulation_cache) — это внешние + # разобранные данные, а не наши вычисления. Проверялось только `> 0`, поэтому + # процент застройки 150 дал бы пятно БОЛЬШЕ участка, а дальше — жилую площадь, + # число квартир и выручку, физически невозможные, но поданные как обычные + # цифры финмодели. + # + # Невозможное значение ОТБРАСЫВАЕМ, а не роняем расчёт: если рядом есть КСИТ, + # GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем + # неверный — тот же принцип, что в остальных правках этого эпика. + # + # Границы взяты с запасом к реальным данным прода 20.08.2026 + # (33 строки zone_regulation_cache: pct 0..100, far 1..4, floors 0..5), + # чтобы ловить порчу разбора, а не отсекать законные значения. + max_building_pct = _sane(max_building_pct, 0.0, 100.0, "max_building_pct") + max_far = _sane(max_far, 0.0, _MAX_PLAUSIBLE_FAR, "max_far") + max_floors_f = _sane( + float(max_floors) if max_floors is not None else None, + 0.0, + float(_MAX_PLAUSIBLE_FLOORS), + "max_floors", + ) + max_floors = int(max_floors_f) if max_floors_f is not None else None + # ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ─────────── gfa: float if max_far is not None and max_far > 0: @@ -169,8 +224,25 @@ def synthesize_teap_from_buildability( # Нет %застройки → пятно ≈ GFA / этажность. built_area = gfa / max_floors else: + # Нет ни процента, ни этажности — пятно оцениваем как GFA (неявно «один этаж»). built_area = gfa + # Пятно застройки физически не может превышать участок (#2464). Это не эвристика, + # а геометрия. Ветка выше (`built_area = gfa`) нарушала её при КСИТ > 1: участок + # 10 000 м² с far=2 давал пятно 20 000 м². Ограничение вводится ЗДЕСЬ, а не в + # каждой ветке, чтобы инвариант держался и для будущих способов оценки пятна. + if built_area > area_m2: + logger.warning( + "synthesize_teap: пятно %.0f м² превысило участок %.0f м² — ограничено " + "площадью участка (far=%s, pct=%s, floors=%s)", + built_area, + area_m2, + max_far, + max_building_pct, + max_floors, + ) + built_area = area_m2 + # Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно # как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется. office_share = _OFFICE_SHARE_OF_GFA[housing_class] diff --git a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py new file mode 100644 index 00000000..16a86ff1 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py @@ -0,0 +1,132 @@ +"""Невозможные параметры регламента не превращаются в финансовые цифры (#2464). + +`synthesize_teap_from_buildability` проверяла только `> 0`. Процент застройки 150 +давал пятно БОЛЬШЕ участка, а дальше — жилую площадь, число квартир и выручку, +физически невозможные, но поданные как обычные цифры финмодели. + +Параметры приходят из ПЗЗ-регламента (`zone_regulation_cache`) — это внешние +разобранные данные, то есть граница доверия. + +Невозможное значение теперь отбрасывается, а не роняет расчёт: если рядом есть +КСИТ, GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем +неверный. + +Границы взяты с запасом к реальным данным прода 20.08.2026 (33 строки +zone_regulation_cache: pct 0..100, far 1..4, floors 0..5). +""" + +from __future__ import annotations + +import pytest + +from app.services.site_finder.parcel_financial import synthesize_teap_from_buildability + +_КЛАСС = "comfort" +_ПЛОЩАДЬ = 10_000.0 + + +def _teap(**kw): + params = dict( + area_m2=_ПЛОЩАДЬ, + max_far=None, + max_building_pct=None, + max_floors=None, + housing_class=_КЛАСС, + ) + params.update(kw) + return synthesize_teap_from_buildability(**params) # type: ignore[arg-type] + + +def test_building_pct_above_100_does_not_exceed_the_parcel() -> None: + """Головной: пятно застройки не может быть больше самого участка. + + На origin/main pct=150 даёт built = 15 000 м² на участке 10 000 м². + """ + t = _teap(max_building_pct=150.0, max_far=2.0) + assert t is not None, "расчёт не должен падать — КСИТ задан, GFA выводима" + assert t.built_area_sqm <= _ПЛОЩАДЬ, ( + f"пятно {t.built_area_sqm} больше участка {_ПЛОЩАДЬ} — " + "физически невозможное число подано как обычная цифра финмодели" + ) + + +def test_far_is_used_when_pct_is_impossible() -> None: + """Отбрасываем только испорченный параметр, остальной расчёт остаётся верным.""" + t = _teap(max_building_pct=150.0, max_far=2.0) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx( + _ПЛОЩАДЬ * 2.0 + ), f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}" + + +def test_impossible_far_is_dropped() -> None: + """КСИТ 500 — признак порчи разбора, а не сверхплотной застройки.""" + t = _teap(max_far=500.0, max_building_pct=40.0, max_floors=10) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx( + _ПЛОЩАДЬ * 0.4 * 10 + ), f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}" + + +def test_impossible_floors_is_dropped() -> None: + """Этажность 900 отбрасывается; при живом КСИТ расчёт продолжается.""" + t = _teap(max_far=2.0, max_floors=900) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0) + assert t.built_area_sqm <= _ПЛОЩАДЬ + + +def test_all_inputs_impossible_returns_none_not_a_number() -> None: + """Контроль честности: если вменяемых параметров не осталось — None. + + Не «ноль» и не выдуманное значение: caller штатно показывает отсутствие + финоценки с caveat. + """ + assert _teap(max_building_pct=150.0, max_far=500.0, max_floors=900) is None + + +@pytest.mark.parametrize( + "pct,far,floors", + [ + (80.0, 2.4, 2), + (100.0, 4.0, 5), + (80.0, 2.5, None), + (None, 4.0, None), + (40.0, None, 10), + ], +) +def test_real_prod_combinations_unchanged(pct, far, floors) -> None: + """Контроль от переусердствования: сочетания С ПРОДА считаются как раньше. + + Взяты из zone_regulation_cache 20.08.2026 — если защита отсечёт хоть одно, + она отсекает законные значения, а не порчу. + """ + t = _teap(max_building_pct=pct, max_far=far, max_floors=floors) + assert t is not None, f"законное сочетание отвергнуто: pct={pct} far={far} floors={floors}" + assert t.total_floor_area_sqm > 0 + + +def test_boundary_100_pct_is_allowed() -> None: + """Граница включительно: 100 % застройки законно (плотная городская зона, есть на проде).""" + t = _teap(max_building_pct=100.0, max_floors=5) + assert t is not None + assert t.built_area_sqm == pytest.approx(_ПЛОЩАДЬ) + + +def test_footprint_never_exceeds_parcel_even_without_pct_and_floors() -> None: + """Второй дефект той же функции, найденный этими тестами (#2464). + + Ветка «нет ни процента, ни этажности → пятно = GFA» неявно предполагает один + этаж. При КСИТ > 1 она давала пятно БОЛЬШЕ участка: 10 000 м² с far=2 → 20 000 м². + Дефект существовал и до правки санитарии — на origin/main этот тест красный + сам по себе, без всяких невозможных входных значений. + """ + t = _teap(max_far=2.0) # только КСИТ: ни процента, ни этажности + assert t is not None + assert t.built_area_sqm <= _ПЛОЩАДЬ, ( + f"пятно {t.built_area_sqm} на участке {_ПЛОЩАДЬ} — нарушена геометрия, " + "а не только правдоподобие регламента" + ) + assert t.total_floor_area_sqm == pytest.approx( + _ПЛОЩАДЬ * 2.0 + ), "GFA не должна меняться от ограничения пятна" -- 2.45.3