gendesign/backend/app/api/v1/concepts.py
Light1YT b1332d6b55
All checks were successful
CI / frontend-tests (pull_request) Successful in 1m3s
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 10m6s
feat(generative): calibrate finmodel sale price from Objective market data (epic #1881 PR-2)
Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную
медиану из Objective по локации участка. compute_financial остаётся ЧИСТЫМ
(без БД) — DB-lookup в API-слое, цена прокидывается параметром.

- compute_financial: +optional market_price_per_sqm/price_source; цена =
  рынок если есть, иначе класс-норма. Паркинг/СМР НЕ калибруем (только жильё).
- concepts.py _lookup_market_price(db, lon, lat): центроид → ближайший
  ekb_district (ST_DWithin 5км) → медиана objective_lots.price_per_m2_rub
  (n>=10, фильтр 30k-600k) → fallback ekb_districts.median_price_per_m2 →
  None. try/except → graceful (None, class_norm) при любой ошибке (вне ЕКБ/
  нет гео/SQL) без краха генерации. psycopg3 CAST.
- FinancialModel +price_per_sqm_used/price_is_calibrated/price_source (additive).
- Threading market_price через generate→placement→compute_financial (optional
  kwargs, backward-compat).
- UI/CSV: честный caption источника (рынок Objective / справочник района /
  норматив класса). Старый лживый footnote «не калиброванная модель» → условный.

Prod-verified: калибруется 4 главных ЕКБ-района по name-match (Академический
204k лотов, Ленинский 38k, Кировский, Орджоникидзевский); остальные 5 admin-
районов честно → district_reference. Гео-радиус matching (полное покрытие) —
follow-up.

api-types.ts регенерён авторитетно. mypy strict clean (generative.*), +14
тестов (калибровка/lookup 4 ветки/SQL-ошибки graceful/backward-compat).

Refs #1881
2026-06-23 21:10:25 +05:00

212 lines
9.7 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
from app.services.generative import geometry
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
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)