fix(ptica): невозможные параметры регламента не превращаются в деньги (#2464) #3000
2 changed files with 204 additions and 0 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 не должна меняться от ограничения пятна"
|
||||
Loading…
Add table
Reference in a new issue