gendesign/tradein-mvp/backend/app/services/street_scheme.py
bot-backend ca3f073b0e feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
2026-08-29 23:25:44 +05:00

328 lines
15 KiB
Python
Raw Permalink 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.

"""Схема улицы сделки: готовые SVG-пути для карточки лэндинга.
ЗАЧЕМ ГОТОВАЯ СХЕМА, А НЕ ГЕОМЕТРИЯ. Отдать фронту GeoJSON окна 840×840 м —
это десятки килобайт на КАЖДУЮ строку витрины плюс проекция в рантайме. Здесь
всё считается один раз при пересчёте витрины и хранится строкой SVG-путей;
порядок величины на прототипе — 4 КБ на сделку.
ЧТО ЭТА СХЕМА ЗАЯВЛЯЕТ, А ЧТО НЕТ
---------------------------------
* ДОМА В НЕЙ НЕТ И БЫТЬ НЕ МОЖЕТ. `deals.address` — уровня улицы
(«Екатеринбург, Краснолесья»), номер дома известен у 2.7% сделок. Точка
дома была бы выдумана, поэтому её не рисует даже фронт: в схеме нет ни
координат окна, ни констант проекции — по ней физически нельзя положить
(lon, lat) в её систему координат. Это не забывчивость, а замок.
* ЗДАНИЙ В НЕЙ НЕТ. Замер 2026-08-29: `cad_buildings` содержит 18 307
контуров на весь ЕКБ, в плотном центре это 51 здание на радиус 450 м, где
их в разы больше. Нарисованная застройка заявляла бы полноту, которой в
данных нет, и показывала бы пустые кварталы там, где стоят дома.
* УЛИЦЫ — ФИЛЬТРОВАННАЯ ВЫГРУЗКА. `osm_noise_sources_ekb` собиралась как
«источники шума»: 10 025 линий, 1377 различных названий. Именованные улицы
покрыты хорошо, дворовые проезды и служебные отсутствуют. Схема — это
именно улицы района, а не полная уличная сеть.
* УЛИЦА СМАТЧИЛАСЬ НЕ ВСЕГДА. Нормализация даёт совпадение у 550 названий из
654 — это 31 410 сделок из 34 021 (92.3%, замер на проде 2026-08-29 по той
же выборке, из которой набирается витрина). Оставшиеся 8% — штатный
случай: схемы нет, фронт показывает район.
ПРОЕКЦИЯ — та же равнопромежуточная с поправкой cos(широты), что в
`scripts/export_ekb_districts_svg.py`. Второй проекции в проекте нет намеренно.
Окно квадратное в метрах, поэтому viewBox тоже квадратный: 1000×1000.
"""
from __future__ import annotations
import json
import logging
import math
import re
from dataclasses import dataclass
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Полуразмер окна в метрах. 420 м на 1000 единиц viewBox — это ~1.2 м на
# единицу: улица читается как улица, а соседние кварталы попадают в кадр.
RADIUS_M = 420.0
VIEWBOX = 1000.0
# Метров в градусе широты (сфера). Долгота — то же, делённое на cos(широты).
M_PER_DEG_LAT = 111_320.0
# Допуск упрощения в градусах ≈ 3 м: меньше трёх единиц viewBox, то есть тоньше
# линии. Смена значения обязана сопровождаться замером размера схемы.
SIMPLIFY_DEG = 0.00003
# Сколько подписей соседних улиц кладём в схему. Больше — каша на карточке
# 320 px и лишние байты; отбираем самые длинные в кадре.
MAX_LABELS = 7
# Родовые слова, которые в `deals.address` и в OSM пишутся по-разному
# («Краснолесья» ↔ «улица Краснолесья»). Снимаем с обеих сторон.
_STREET_TYPES = frozenset(
{
"улица",
"ул",
"проспект",
"пр",
"пр-кт",
"пркт",
"проспкт",
"переулок",
"пер",
"бульвар",
"бул",
"б-р",
"шоссе",
"ш",
"проезд",
"набережная",
"наб",
"тракт",
"аллея",
"площадь",
"пл",
"тупик",
}
)
_KEEP = re.compile(r"[^0-9a-zа\s-]")
def normalize_street(address: str | None) -> str | None:
"""`'Екатеринбург, ул. Краснолесья'` → ``'краснолесья'``. Нет улицы — None.
Что делает и почему именно так:
* берёт часть ПОСЛЕ последней запятой — в `deals.address` город стоит
первым («Екатеринбург, 8 Марта»);
* ПЕРЕКРЁСТОК берёт первой улицей. «Екатеринбург, Челюскинцев/Шейнкмана»
→ «челюскинцев». Таких адресов в выборке ТРИ на 34 021 сделку (по
одной сделке на каждый, замер 2026-08-29), и обе улицы одинаково
верны: адрес и так уровня улицы, а не дома. Рисовать перекрёсток
точкой значило бы заявить точность, которой в остальных 34 018 строках
нет, ради трёх;
* складывает ё→е: «Королёва» в адресе и «Королева» в OSM — одна улица;
* снимает родовое слово (улица/проспект/…), потому что в адресе его
обычно нет, а в OSM обычно есть;
* цифры оставляет: «8 Марта» и «22 Партсъезда» — названия, а не номера.
"""
if not address:
return None
tail = address.rsplit(",", 1)[-1]
tail = tail.split("/")[0]
tail = tail.replace("ё", "е").replace("Ё", "Е").lower()
tail = _KEEP.sub(" ", tail)
words = [w for w in tail.split() if w.strip("-") not in _STREET_TYPES]
return " ".join(words) or None
# ── Запросы ──────────────────────────────────────────────────────────────────
#
# Обе идут в foreign table `gendesign_osm_roads_ekb` (миграция 281) над вьюхой
# `v_tradein_osm_roads_ekb` чужой базы. Сервер `gendesign_remote` объявлен с
# OPTIONS (extensions 'postgis'), поэтому PostGIS-операторы shippable: `&&` с
# готовым конвертом уходит на ту сторону и попадает в GiST-индекс, а обратно
# едет уже обрезанный GeoJSON, а не десять тысяч линий.
_NAMES_SQL = text("SELECT DISTINCT name FROM gendesign_osm_roads_ekb WHERE name IS NOT NULL")
_CENTER_SQL = text(
"""
SELECT ST_X(p) AS lon, ST_Y(p) AS lat
FROM (
SELECT ST_ClosestPoint(g, ST_Centroid(g)) AS p
FROM (
SELECT ST_Collect(geom) AS g
FROM gendesign_osm_roads_ekb
WHERE name = ANY(CAST(:names AS text[]))
) t
WHERE g IS NOT NULL
) q
"""
)
_WINDOW_SQL = text(
"""
SELECT name, road_class,
ST_AsGeoJSON(
ST_SimplifyPreserveTopology(
ST_Intersection(
geom,
ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
),
CAST(:tol AS double precision)
), 6
) AS gj
FROM gendesign_osm_roads_ekb
WHERE geom && ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
"""
)
# Классы из `osm_noise_sources_ekb`, которые рисуются водой. Остальные (trunk /
# primary / secondary / tertiary / residential) — дороги. Разделение
# перечислением, а не «всё, что не дорога»: во вьюху могут доехать новые классы,
# и тогда лучше не нарисовать, чем нарисовать реку поверх улицы.
_WATER_CLASSES = frozenset({"river", "canal", "stream", "lake_or_pond"})
@dataclass(frozen=True)
class StreetIndex:
"""Нормализованное название → как эта улица называется в OSM.
Строится ОДИН раз на прогон: 1377 названий на всю базу, тянуть их на каждую
из двадцати сделок незачем.
"""
by_norm: dict[str, list[str]]
def lookup(self, address: str | None) -> tuple[str, list[str]] | None:
key = normalize_street(address)
if key is None:
return None
raw = self.by_norm.get(key)
return (key, raw) if raw else None
def load_street_index(db: Session) -> StreetIndex:
"""Список названий улиц из FDW-вьюхи. Недоступна — пустой индекс.
SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит
транзакцию в aborted, и следующий запрос упал бы уже не по своей вине.
Гранты на той стороне уже терялись при пересоздании объекта (#3227).
"""
try:
with db.begin_nested():
rows = db.execute(_NAMES_SQL).all()
except Exception as exc:
logger.warning("улицы не резолвятся (витрина будет без схем): %s", exc)
return StreetIndex(by_norm={})
by_norm: dict[str, list[str]] = {}
for (name,) in rows:
key = normalize_street(name)
if key:
by_norm.setdefault(key, []).append(name)
return StreetIndex(by_norm=by_norm)
def _parts(gj: dict[str, Any]) -> list[list[list[float]]]:
"""GeoJSON любой из встречающихся форм → список цепочек точек."""
t = gj.get("type")
c = gj.get("coordinates") or []
if t == "LineString":
return [c]
if t == "MultiLineString":
return list(c)
if t == "Polygon":
return list(c)
if t == "MultiPolygon":
return [ring for poly in c for ring in poly]
if t == "GeometryCollection":
return [p for g in gj.get("geometries", []) for p in _parts(g)]
return []
def _length(chain: list[list[float]]) -> float:
return sum(math.dist(chain[i], chain[i + 1]) for i in range(len(chain) - 1))
def build_street_scheme(db: Session, index: StreetIndex, address: str | None) -> dict | None:
"""Схема улицы для одной сделки, либо None — и None это ШТАТНО.
None означает ровно одно: «улицы сделки в геометрии нет» (нет адреса, не
сматчилось название, вьюха недоступна, у улицы нет геометрии). Витрина в
этом случае отдаёт строку без схемы, а фронт показывает район — запасной
вариант, который для этого и оставлен.
"""
found = index.lookup(address)
if found is None:
return None
key, raw_names = found
try:
with db.begin_nested():
center = db.execute(_CENTER_SQL, {"names": raw_names}).mappings().first()
if center is None:
return None
lat, lon = float(center["lat"]), float(center["lon"])
dlat = RADIUS_M / M_PER_DEG_LAT
k = math.cos(math.radians(lat))
dlon = dlat / k
bounds = {
"min_lon": lon - dlon,
"max_lon": lon + dlon,
"min_lat": lat - dlat,
"max_lat": lat + dlat,
"tol": SIMPLIFY_DEG,
}
rows = db.execute(_WINDOW_SQL, bounds).mappings().all()
except Exception as exc:
logger.warning("схема улицы %r не собралась: %s", key, exc)
return None
min_lon, max_lat = bounds["min_lon"], bounds["max_lat"]
# Окно квадратное в метрах, значит после поправки cos(широты) его ширина и
# высота в единицах viewBox равны: масштаб один на обе оси.
scale = VIEWBOX / (2 * dlat)
def path(chain: list[list[float]]) -> str:
out = []
for i, (plon, plat) in enumerate(chain):
x = round((plon - min_lon) * k * scale, 1)
y = round((max_lat - plat) * scale, 1)
out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
return "".join(out)
target: list[str] = []
roads: list[dict[str, str]] = []
water: list[str] = []
label_pool: list[tuple[float, str, list[float]]] = []
targets = set(raw_names)
for row in rows:
if not row["gj"]:
continue
chains = [c for c in _parts(json.loads(row["gj"])) if len(c) > 1]
if not chains:
continue
cls = row["road_class"]
is_target = row["name"] in targets
for chain in chains:
d = path(chain)
if is_target:
target.append(d)
elif cls in _WATER_CLASSES:
water.append(d)
else:
roads.append({"c": cls or "residential", "d": d})
if row["name"] and not is_target and cls not in _WATER_CLASSES:
longest = max(chains, key=_length)
label_pool.append((_length(longest), row["name"], longest[len(longest) // 2]))
if not target:
return None
# Подписи: самые длинные в кадре, по одной на название.
labels: list[dict[str, Any]] = []
seen: set[str] = set()
for _, name, point in sorted(label_pool, key=lambda t: -t[0]):
if name in seen:
continue
seen.add(name)
labels.append(
{
"t": name,
"x": round((point[0] - min_lon) * k * scale, 1),
"y": round((max_lat - point[1]) * scale, 1),
}
)
if len(labels) >= MAX_LABELS:
break
return {
"street": raw_names[0],
"w": VIEWBOX,
"h": VIEWBOX,
"target": target,
"roads": roads,
"water": water,
"labels": labels,
}