gendesign/backend/app/api/v1/concepts.py
Light1YT 94cf1f6217 feat(concept): house-type catalog + program-driven placement (#1965 Stage 3a)
Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).

- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
  tower_business, lowrise_comfort, townhouse) — sane-default catalog,
  promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
  ConceptInput.building_program (default None -> greedy). ConceptVariant
  gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
  from greedy sweep, reused — no duplication); place_program +
  place_program_variant; branch in place_all_strategies on
  building_program. Mixed-floor TEAP via exact per-floor-group aggregation
  (GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
  fit and report placed_count < requested_count (no hard-422); zero-fit
  still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
  422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
  TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
2026-06-28 01:29:04 +05:00

309 lines
16 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.catalog import available_section_types
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
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
# Район ЕКБ (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.
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
"""
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
if payload.building_program:
known = available_section_types()
unknown = sorted({item.section_type for item in payload.building_program} - known)
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"unknown house type(s): {', '.join(unknown)}; "
f"available: {', '.join(sorted(known))}"
),
)
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
# стратегий). 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-запросе. Источник
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
financial_estimate — objective_geo_radius / objective_district_median /
district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
* иначе, если передан ``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.
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
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)