В gendesign-БД есть cad_buildings — ~36к зданий ЕКБ с годом постройки и этажностью (Росреестр). Подключаем как ПРИОРИТЕТНЫЙ источник обогащения house_metadata, OSM Overpass остаётся fallback-ом. - data/sql/006_cad_buildings.sql — таблица cad_buildings в tradein-БД. - deploy/import-cadastre.sh — ETL: ЕКБ-срез cad_buildings из gendesign-postgres → tradein (по образцу import-rosreestr.sh). - house_metadata._cad_buildings_get — lookup по гео-близости (60 м), проверяется ПЕРВЫМ в get_house_metadata, до OSM. cad_buildings — кадастр ЗДАНИЙ, не квартир: поле ручного ввода кадастра (исходная идея #393) не делаем — гео-обогащение полезнее и работает автоматически для каждой оценки. Closes #393
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""House metadata service — обогащение данных дома по координатам (#392).
|
||
|
||
Когда пользователь не указал год постройки / тип дома, подтягиваем данные
|
||
из OpenStreetMap через Overpass API: building:levels, start_date, материал.
|
||
|
||
Стратегия:
|
||
- Cache lookup в `house_metadata` (Postgres) по близости координат — TTL 30 дней
|
||
- Cache miss → Overpass API → парсинг тегов → запись в кэш
|
||
- Best-effort: ЛЮБАЯ ошибка → None, оценка работает без обогащения (не 500-ит)
|
||
|
||
Используется в estimator.estimate_quality перед house-match аналогов (#6).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from dataclasses import dataclass
|
||
|
||
import httpx
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||
_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же
|
||
_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки
|
||
_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class HouseMetadata:
|
||
lat: float
|
||
lon: float
|
||
year_built: int | None
|
||
total_floors: int | None
|
||
house_type: str | None # panel / brick / monolith / monolith_brick / other
|
||
total_units: int | None
|
||
source: str # 'osm' / 'cache'
|
||
|
||
|
||
# ── Парсинг тегов OSM ────────────────────────────────────────────────────────
|
||
_YEAR_KEYS = (
|
||
"start_date", "construction_date", "building:year",
|
||
"year_of_construction", "building:start_date",
|
||
)
|
||
|
||
|
||
def _parse_year(tags: dict[str, str]) -> int | None:
|
||
"""Год постройки из OSM-тегов. Значение бывает '1975', '1975-06-01',
|
||
'1970s', '1960..1965', 'C20' — берём первое 4-значное число 1800–2100."""
|
||
for key in _YEAR_KEYS:
|
||
raw = tags.get(key)
|
||
if not raw:
|
||
continue
|
||
m = re.search(r"\b(1[89]\d\d|20\d\d)\b", raw)
|
||
if m:
|
||
year = int(m.group(1))
|
||
if 1800 <= year <= 2100:
|
||
return year
|
||
return None
|
||
|
||
|
||
def _parse_int(tags: dict[str, str], *keys: str) -> int | None:
|
||
"""Первое валидное целое (0 < n < 1000) из перечисленных тегов."""
|
||
for key in keys:
|
||
raw = (tags.get(key) or "").strip()
|
||
if raw.isdigit():
|
||
value = int(raw)
|
||
if 0 < value < 1000:
|
||
return value
|
||
return None
|
||
|
||
|
||
# OSM building:material → наш house_type. Покрытие в OSM неполное — best-effort.
|
||
_MATERIAL_TO_TYPE = {
|
||
"concrete_panels": "panel",
|
||
"panels": "panel",
|
||
"panel": "panel",
|
||
"brick": "brick",
|
||
"concrete": "monolith",
|
||
"reinforced_concrete": "monolith",
|
||
}
|
||
|
||
|
||
def _parse_house_type(tags: dict[str, str]) -> str | None:
|
||
material = (tags.get("building:material") or "").lower().strip()
|
||
return _MATERIAL_TO_TYPE.get(material)
|
||
|
||
|
||
# ── Cache (house_metadata, TTL 30 дней) ──────────────────────────────────────
|
||
def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT lat, lon, year_built, total_floors, house_type, total_units
|
||
FROM house_metadata
|
||
WHERE expires_at > NOW()
|
||
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
|
||
ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"lat": lat, "lon": lon, "radius": _CACHE_RADIUS_M},
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return HouseMetadata(
|
||
lat=row.lat, lon=row.lon,
|
||
year_built=row.year_built, total_floors=row.total_floors,
|
||
house_type=row.house_type, total_units=row.total_units,
|
||
source="cache",
|
||
)
|
||
|
||
|
||
def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None:
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO house_metadata
|
||
(lat, lon, year_built, total_floors, house_type,
|
||
total_units, source, raw_payload)
|
||
VALUES (:lat, :lon, :year, :floors, :htype,
|
||
:units, 'osm', CAST(:raw AS jsonb))
|
||
"""
|
||
),
|
||
{
|
||
"lat": meta.lat, "lon": meta.lon,
|
||
"year": meta.year_built, "floors": meta.total_floors,
|
||
"htype": meta.house_type, "units": meta.total_units,
|
||
"raw": json.dumps(raw, ensure_ascii=False),
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
|
||
# ── Источник #393: кадастр зданий (cad_buildings, импорт из gendesign-БД) ────
|
||
def _cad_buildings_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
|
||
"""Ближайшее здание из cad_buildings (данные Росреестра) по координатам.
|
||
|
||
Приоритетный источник house_metadata — год / этажность полнее, чем OSM
|
||
(~34к домов ЕКБ с годом постройки). Таблицу наполняет import-cadastre.sh.
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT year_built, floors
|
||
FROM cad_buildings
|
||
WHERE geom IS NOT NULL
|
||
AND (year_built IS NOT NULL OR floors IS NOT NULL)
|
||
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
|
||
ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"lat": lat, "lon": lon, "radius": _CAD_RADIUS_M},
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return HouseMetadata(
|
||
lat=lat, lon=lon,
|
||
year_built=row.year_built,
|
||
total_floors=row.floors,
|
||
house_type=None, # cad_buildings.purpose не маппится в panel/brick
|
||
total_units=None,
|
||
source="cadastre",
|
||
)
|
||
|
||
|
||
# ── Provider: OSM Overpass ───────────────────────────────────────────────────
|
||
async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict] | None:
|
||
"""Запрос здания у точки через Overpass API. (meta, raw_payload) или None."""
|
||
query = (
|
||
"[out:json][timeout:20];"
|
||
f'way(around:{_OVERPASS_RADIUS_M},{lat},{lon})["building"];'
|
||
"out tags center;"
|
||
)
|
||
headers = {"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})"}
|
||
async with httpx.AsyncClient(timeout=15.0, headers=headers) as client:
|
||
response = await client.post(OVERPASS_URL, data={"data": query})
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
elements = [e for e in data.get("elements", []) if e.get("tags")]
|
||
if not elements:
|
||
return None
|
||
|
||
# Ближайшее здание (по center) к геокодированной точке.
|
||
def _dist2(el: dict) -> float:
|
||
center = el.get("center") or {}
|
||
return (center.get("lat", 0.0) - lat) ** 2 + (center.get("lon", 0.0) - lon) ** 2
|
||
|
||
best = min(elements, key=_dist2)
|
||
tags = best.get("tags", {})
|
||
meta = HouseMetadata(
|
||
lat=lat, lon=lon,
|
||
year_built=_parse_year(tags),
|
||
total_floors=_parse_int(tags, "building:levels"),
|
||
house_type=_parse_house_type(tags),
|
||
total_units=_parse_int(tags, "building:flats", "flats"),
|
||
source="osm",
|
||
)
|
||
return meta, {"osm_way_id": best.get("id"), "tags": tags}
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────────────
|
||
async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetadata | None:
|
||
"""Метаданные дома по координатам: cache (TTL 30д) → Overpass.
|
||
|
||
Best-effort: при ЛЮБОЙ ошибке возвращает None и откатывает сессию —
|
||
оценка обязана работать даже если картография недоступна.
|
||
"""
|
||
try:
|
||
# #393: кадастр зданий (Росреестр, cad_buildings) — приоритетный источник.
|
||
cad = _cad_buildings_get(db, lat, lon)
|
||
if cad is not None:
|
||
logger.info(
|
||
"house_metadata: cad_buildings (%.5f, %.5f) → year=%s floors=%s",
|
||
lat, lon, cad.year_built, cad.total_floors,
|
||
)
|
||
return cad
|
||
|
||
cached = _cache_get(db, lat, lon)
|
||
if cached is not None:
|
||
logger.info("house_metadata cache hit: (%.5f, %.5f)", lat, lon)
|
||
return cached
|
||
|
||
result = await _overpass_lookup(lat, lon)
|
||
if result is None:
|
||
logger.info("house_metadata: OSM не нашёл здание у (%.5f, %.5f)", lat, lon)
|
||
return None
|
||
|
||
meta, raw = result
|
||
# Пустой результат (ни года, ни этажности) кэшировать смысла нет.
|
||
if meta.year_built is None and meta.total_floors is None:
|
||
return None
|
||
|
||
_cache_put(db, meta, raw)
|
||
logger.info(
|
||
"house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s",
|
||
lat, lon, meta.year_built, meta.total_floors, meta.house_type,
|
||
)
|
||
return meta
|
||
except Exception: # noqa: BLE001
|
||
logger.warning(
|
||
"house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True
|
||
)
|
||
try:
|
||
db.rollback()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return None
|