fix(ptica): невозможные параметры регламента не превращаются в деньги (#2464) #3000

Merged
bot-backend merged 1 commit from fix/2464-building-pct-guard into main 2026-08-20 18:39:10 +00:00
2 changed files with 204 additions and 0 deletions

View file

@ -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]

View file

@ -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 не должна меняться от ограничения пятна"