gendesign/backend/app/api/v1/concepts.py
Light1YT d38d5d43c5 feat(generative): LIVE financial recompute from massing program (#1965 Stage 2a)
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).

- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
  site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
  aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
  reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
  / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
  market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
  the existing pure compute_financial. FAST path uses body market_price_per_sqm
  (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
  efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
  skips DB, DB fallback, class-norm default, floors validation).
2026-06-28 00:27:25 +05:00

279 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from shapely.geometry import Point
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.concept import (
ConceptInput,
ConceptOutput,
MassingProgram,
MassingRecomputeOutput,
)
from app.services.generative import geometry
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program
logger = logging.getLogger(__name__)
router = APIRouter()
# Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья.
# n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и
# выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на
# справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её
# отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других
# district-агрегатов проекта (см. parcels.py district_price_block).
_MIN_OBJECTIVE_SAMPLE: int = 10
# Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из
# parcels.py district-context — участок может лежать у границы полигона района.
_DISTRICT_SEARCH_RADIUS_M: int = 5000
# Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective
# (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py.
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
_DISTRICT_FOR_POINT_SQL = text(
"""
SELECT d.district_name,
d.median_price_per_m2
FROM ekb_districts d
WHERE d.geom IS NOT NULL
AND ST_DWithin(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
CAST(:radius AS double precision)
)
ORDER BY ST_Distance(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
) ASC
LIMIT 1
"""
)
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
_OBJECTIVE_MEDIAN_SQL = text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
"""
)
def _parcel_centroid_wkt(payload: ConceptInput) -> str:
"""Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup."""
polygon = _parse_polygon(payload.parcel_geojson)
c = polygon.centroid
wkt: str = Point(c.x, c.y).wkt
return wkt
def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]:
"""Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник.
Алгоритм (от точного к общему):
1. Центроид → ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км).
2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE)
→ ("objective_district_median"). Свежайший рыночный сигнал.
3. Иначе справочная ekb_districts.median_price_per_m2 → ("district_reference").
4. Иначе (нет района / нет данных) → (None, "class_norm") — финмодель возьмёт
норматив класса.
Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой
БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm").
Read-only — SAVEPOINT не нужен.
"""
try:
district_row = (
db.execute(
_DISTRICT_FOR_POINT_SQL,
{"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc)
return None, "class_norm"
if not district_row or not district_row["district_name"]:
logger.info("concept market-price: parcel outside ЕКБ districts → class_norm")
return None, "class_norm"
district_name: str = district_row["district_name"]
district_reference = district_row["median_price_per_m2"]
# 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог).
try:
obj_row = (
db.execute(
_OBJECTIVE_MEDIAN_SQL,
{"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning(
"concept market-price: objective median failed (district=%s), "
"trying district reference: %s",
district_name,
exc,
)
obj_row = None
if (
obj_row
and obj_row["sample_size"]
and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE
and obj_row["median_ppm2"]
):
price = float(obj_row["median_ppm2"])
logger.info(
"concept market-price: objective median %.0f руб/м² (district=%s, n=%d)",
price,
district_name,
int(obj_row["sample_size"]),
)
return price, "objective_district_median"
# 3) Справочная медиана района (24-мес окно ekb_districts).
if district_reference is not None:
price = float(district_reference)
logger.info(
"concept market-price: district reference %.0f руб/м² (district=%s, n<%d)",
price,
district_name,
_MIN_OBJECTIVE_SAMPLE,
)
return price, "district_reference"
logger.info(
"concept market-price: district=%s has no price reference → class_norm", district_name
)
return None, "class_norm"
@router.post("", response_model=ConceptOutput)
async def create_concept(
payload: ConceptInput,
db: Annotated[Session, Depends(get_db)],
) -> ConceptOutput:
"""Generate 3 building variants for the given parcel polygon.
Stage 1a: Shapely parse + buildable area (setback) + placement grid.
Stage 1b: greedy section placement with STRtree collisions (3 strategies).
Stage 1c: real ТЭП + financial model attached to each variant. The housing
sale price is calibrated to real market data (Objective / district reference)
when available, falling back to the per-class norm with an honest source flag
(PR-2, эпик #1881).
A degenerate parcel (setback consumes everything, malformed geometry) yields a
422 rather than empty variants — that is a bad request, not a valid empty result.
"""
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
market_price: float | None = None
price_source: str = "class_norm"
try:
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, wkt_point
)
except ParcelGeometryError:
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
pass
except Exception as exc:
# Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500.
logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc)
try:
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
variants = await run_in_threadpool(
geometry.generate,
payload,
market_price_per_sqm=market_price,
price_source=price_source,
)
except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants)
@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
payload: MassingProgram,
db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
→ пересчитанный ТЭП + финмодель.
Цена продажи жилья:
* ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе.
* иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
* иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``).
"""
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
market_price: float | None = payload.market_price_per_sqm
price_source: str = "class_norm"
if market_price is not None:
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
price_source = "objective_district_median"
elif payload.parcel_centroid_wkt:
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
try:
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, payload.parcel_centroid_wkt
)
except Exception as exc:
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
market_price, price_source = None, "class_norm"
teap = synthesize_teap_from_program(
total_footprint_sqm=payload.total_footprint_sqm,
floors=payload.floors,
site_area_sqm=payload.site_area_sqm,
housing_class=payload.housing_class,
sections=payload.sections,
)
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
financial = compute_financial(
teap=teap,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price,
price_source=price_source,
development_type=payload.development_type,
)
return MassingRecomputeOutput(teap=teap, financial=financial)