fix(ptica): невозможные параметры регламента не превращаются в деньги (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 17m8s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 17m8s
`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 <noreply@anthropic.com>
This commit is contained in:
parent
0185889f72
commit
06b0063c67
2 changed files with 204 additions and 0 deletions
|
|
@ -118,6 +118,37 @@ def select_calibrated_price(
|
||||||
return None, "class_norm"
|
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(
|
def synthesize_teap_from_buildability(
|
||||||
*,
|
*,
|
||||||
area_m2: float | None,
|
area_m2: float | None,
|
||||||
|
|
@ -147,6 +178,30 @@ def synthesize_teap_from_buildability(
|
||||||
if area_m2 is None or area_m2 <= 0:
|
if area_m2 is None or area_m2 <= 0:
|
||||||
return None
|
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: предпочитаем КСИТ/max_far; иначе % застройки × этажность ───────────
|
||||||
gfa: float
|
gfa: float
|
||||||
if max_far is not None and max_far > 0:
|
if max_far is not None and max_far > 0:
|
||||||
|
|
@ -169,8 +224,25 @@ def synthesize_teap_from_buildability(
|
||||||
# Нет %застройки → пятно ≈ GFA / этажность.
|
# Нет %застройки → пятно ≈ GFA / этажность.
|
||||||
built_area = gfa / max_floors
|
built_area = gfa / max_floors
|
||||||
else:
|
else:
|
||||||
|
# Нет ни процента, ни этажности — пятно оцениваем как GFA (неявно «один этаж»).
|
||||||
built_area = 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 до расчёта жилой — точно
|
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно
|
||||||
# как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется.
|
# как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется.
|
||||||
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
|
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