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.
This commit is contained in:
parent
555cce44ad
commit
b2b702dca7
7 changed files with 786 additions and 6 deletions
57
data/sql/195_tradein_osm_roads_view.sql
Normal file
57
data/sql/195_tradein_osm_roads_view.sql
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
-- 195_tradein_osm_roads_view.sql
|
||||
-- Улицы ЕКБ на витрину сделок МЕРЫ: сторона gendesign (PR 1 из 2).
|
||||
--
|
||||
-- Карточка игры на лэндинге показывала ПОЛИГОН РАЙОНА — единственную геометрию,
|
||||
-- которая у tradein была. Улицы живут здесь, в `osm_noise_sources_ekb`, а у
|
||||
-- tradein нет ни foreign table, ни гранта. Эта миграция открывает вьюху ровно
|
||||
-- по образцу соседей (`v_tradein_cad_buildings`, `v_tradein_osm_poi_ekb`,
|
||||
-- 185_tradein_osm_poi_view.sql): плоский срез нужных колонок + GRANT SELECT
|
||||
-- уже существующей роли `tradein_fdw_reader` (100_tradein_fdw_role.sql).
|
||||
-- Новых ролей и user mapping здесь нет.
|
||||
--
|
||||
-- ГРАНТ СТОИТ РЯДОМ С CREATE, В ЭТОМ ЖЕ ФАЙЛЕ — И ЭТО НЕ СТИЛЬ. 2026-08-29
|
||||
-- (#3227) объект пересоздали, а GRANT жил отдельной миграцией и потерялся:
|
||||
-- пересоздание снимает права молча, и чтение с той стороны падает на
|
||||
-- `permission denied`. То же было с `ekb_districts_geom` (194) и
|
||||
-- `mv_quarter_price_index` (188). Кто будет менять эту вьюху — меняйте файл
|
||||
-- целиком, вместе с грантом.
|
||||
--
|
||||
-- ЧТО В СРЕЗЕ И ЧЕГО В НЁМ НЕТ (замер на проде 2026-08-29):
|
||||
-- * 10 025 линий, 1377 различных названий улиц. Выгрузка собиралась как
|
||||
-- «источники шума», поэтому именованные улицы покрыты хорошо, а дворовые
|
||||
-- проезды и служебные ОТСУТСТВУЮТ. Потребитель обязан это знать: схема из
|
||||
-- этих данных — улицы района, а не полная уличная сеть.
|
||||
-- * `source_type IN ('highway','water')` — только то, что рисуется на схеме.
|
||||
-- Железные дороги, ЛЭП, трубопроводы, подстанции и промзона (ещё ~4 400
|
||||
-- строк) сюда НЕ едут: на карточке 320 px они дают шум, а не смысл.
|
||||
-- * ЗДАНИЙ ЗДЕСЬ НЕТ И НЕ БУДЕТ. `cad_buildings` — 18 307 контуров на весь
|
||||
-- город; в плотном центре это 51 здание на радиус 450 м, где их в разы
|
||||
-- больше. Нарисованная застройка заявляла бы полноту, которой в данных
|
||||
-- нет, и показывала бы пустые кварталы там, где стоят дома.
|
||||
--
|
||||
-- Идемпотентна: CREATE OR REPLACE VIEW + повторяемый GRANT.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752). CREATE OR REPLACE VIEW и GRANT берут лок на объект;
|
||||
-- ждать его выдачи за чужой сессией — значит собрать очередь перед приложением.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE OR REPLACE VIEW v_tradein_osm_roads_ekb AS
|
||||
SELECT
|
||||
name,
|
||||
road_class,
|
||||
geom
|
||||
FROM osm_noise_sources_ekb
|
||||
WHERE source_type IN ('highway', 'water');
|
||||
|
||||
GRANT SELECT ON v_tradein_osm_roads_ekb TO tradein_fdw_reader;
|
||||
|
||||
COMMENT ON VIEW v_tradein_osm_roads_ekb IS
|
||||
'FDW-источник для tradein-mvp: улицы и вода ЕКБ для схемы улицы на витрине '
|
||||
'сделок лэндинга МЕРЫ. Срез osm_noise_sources_ekb по source_type '
|
||||
'IN (highway, water) — 10 025 линий, 1377 названий. Выгрузка фильтрованная '
|
||||
'(«источники шума»): дворовых и служебных проездов в ней нет. Зданий нет '
|
||||
'намеренно, см. шапку 195_tradein_osm_roads_view.sql. GRANT живёт в том же '
|
||||
'файле — пересоздание вьюхи снимает права молча (#3227).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -393,8 +393,27 @@ class ShowcaseDeal(BaseModel):
|
|||
|
||||
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
|
||||
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
|
||||
правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома
|
||||
есть у 2.7% сделок (разбор в миграции 276).
|
||||
правдоподобную подстановку.
|
||||
|
||||
`street_name` / `street_scheme` — УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7%
|
||||
сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет.
|
||||
Оба поля НУЛЛАБЕЛЬНЫ, и null — штатный случай: название сматчилось с OSM у
|
||||
550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и
|
||||
фронт показывает район.
|
||||
|
||||
`street_scheme` — уже спроецированные SVG-пути окна 840×840 м вокруг центра
|
||||
улицы::
|
||||
|
||||
{"street": "улица Краснолесья", "w": 1000, "h": 1000,
|
||||
"target": ["M…L…"], # подсвеченная улица
|
||||
"roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги
|
||||
"water": ["M…L…"],
|
||||
"labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]}
|
||||
|
||||
Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя
|
||||
положить (lon, lat) в её систему координат, то есть нарисовать точку дома
|
||||
невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON
|
||||
того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29).
|
||||
"""
|
||||
|
||||
district: str | None
|
||||
|
|
@ -408,6 +427,8 @@ class ShowcaseDeal(BaseModel):
|
|||
err_pct: float
|
||||
n_analogs: int
|
||||
note: str
|
||||
street_name: str | None
|
||||
street_scheme: dict | None
|
||||
|
||||
|
||||
class ShowcaseStats(BaseModel):
|
||||
|
|
@ -458,7 +479,8 @@ _SHOWCASE_RUN_SQL = text(
|
|||
_SHOWCASE_SQL = text(
|
||||
"""
|
||||
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
street_name, street_scheme
|
||||
FROM landing_showcase_deals
|
||||
WHERE computed_at = CAST(:computed_at AS timestamptz)
|
||||
ORDER BY id
|
||||
|
|
@ -515,6 +537,8 @@ def public_showcase(
|
|||
err_pct=float(r["err_pct"]),
|
||||
n_analogs=int(r["n_analogs"]),
|
||||
note=r["note"],
|
||||
street_name=r["street_name"],
|
||||
street_scheme=r["street_scheme"],
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
|
|
|
|||
328
tradein-mvp/backend/app/services/street_scheme.py
Normal file
328
tradein-mvp/backend/app/services/street_scheme.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""Схема улицы сделки: готовые 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,
|
||||
}
|
||||
|
|
@ -56,6 +56,13 @@
|
|||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||
Неработающая проверка читается как работающая, поэтому её нет.
|
||||
* КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` — уровня улицы
|
||||
(«Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг
|
||||
ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно
|
||||
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
|
||||
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
|
||||
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
|
||||
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
|
||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||||
|
|
@ -74,6 +81,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
|
@ -82,6 +90,8 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.street_scheme import build_street_scheme, load_street_index
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||||
|
|
@ -102,7 +112,9 @@ NOTE = (
|
|||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
||||
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
||||
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
||||
)
|
||||
|
||||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||||
|
|
@ -255,17 +267,40 @@ _INSERT_SQL = text(
|
|||
"""
|
||||
INSERT INTO landing_showcase_deals
|
||||
(district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note)
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
street_name, street_scheme)
|
||||
VALUES
|
||||
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
|
||||
CAST(:floor AS integer), CAST(:total_floors AS integer),
|
||||
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
|
||||
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text))
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text),
|
||||
CAST(:street_name AS text), CAST(:street_scheme AS jsonb))
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
|
||||
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
|
||||
|
||||
Считаем после отбора, а не до: схема — это два пространственных запроса на
|
||||
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
|
||||
лишних запросов в чужую базу.
|
||||
|
||||
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
|
||||
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
|
||||
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
|
||||
OSM. Схема — украшение строки, а не причина её показать.
|
||||
"""
|
||||
index = load_street_index(db)
|
||||
out = {}
|
||||
for row in chosen:
|
||||
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
|
||||
if scheme is not None:
|
||||
out[row.deal_id] = scheme
|
||||
return out
|
||||
|
||||
|
||||
def refresh_landing_showcase_deals(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -335,13 +370,17 @@ def refresh_landing_showcase_deals(
|
|||
candidates.append(row)
|
||||
|
||||
chosen = select_rows(candidates, limit)
|
||||
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals})
|
||||
|
||||
db.execute(_DELETE_SQL)
|
||||
db.execute(_DELETE_RUNS_SQL)
|
||||
for row in chosen:
|
||||
scheme = schemes.get(row.deal_id)
|
||||
db.execute(
|
||||
_INSERT_SQL,
|
||||
{
|
||||
"street_name": scheme["street"] if scheme else None,
|
||||
"street_scheme": json.dumps(scheme, ensure_ascii=False) if scheme else None,
|
||||
"district": row.district,
|
||||
"rooms": row.rooms,
|
||||
"area_m2": row.area_m2,
|
||||
|
|
@ -364,6 +403,10 @@ def refresh_landing_showcase_deals(
|
|||
"written": len(chosen),
|
||||
"with_district": sum(1 for r in chosen if r.district is not None),
|
||||
}
|
||||
# Схем — не счётчик в `landing_showcase_runs` намеренно: у КАЖДОЙ строки
|
||||
# витрины street_scheme либо есть, либо null, и это едет на фронт вместе со
|
||||
# строкой. Отдельное число повторяло бы то, что посетитель и так видит.
|
||||
logger.info("схем улиц собрано: %d из %d показанных", len(schemes), len(chosen))
|
||||
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
|
||||
db.commit()
|
||||
|
||||
|
|
|
|||
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal file
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- 281_street_scheme.sql
|
||||
-- Улицы ЕКБ на витрине сделок: сторона tradein (PR 2 из 2).
|
||||
--
|
||||
-- ДВА ДЕЙСТВИЯ В ОДНОМ ФАЙЛЕ, И ЭТО ОСОЗНАННО:
|
||||
-- 1) foreign table над `v_tradein_osm_roads_ekb` (миграция gendesign 195);
|
||||
-- 2) колонки `street_name` / `street_scheme` в `landing_showcase_deals`.
|
||||
-- Порознь у них разные зависимости, но смысл один: без геометрии колонки некому
|
||||
-- заполнить, без колонок геометрию некуда положить. Один файл = один
|
||||
-- транзакционный исход: если 195 на той стороне ещё не задеплоена, падает
|
||||
-- ВСЁ и не остаётся полусостояния «колонка есть, данных не будет никогда».
|
||||
-- Файл идемпотентен, поэтому повтор после деплоя 195 доводит дело до конца.
|
||||
--
|
||||
-- ПОЧЕМУ КОЛОНКА В `landing_showcase_deals`, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Схема
|
||||
-- живёт ровно столько же, сколько строка витрины: пересчёт делает
|
||||
-- DELETE+INSERT всей витрины, и схема обязана умереть вместе со своей строкой.
|
||||
-- Отдельная таблица дала бы собственный жизненный цикл (то есть возможность
|
||||
-- пережить свой батч и уехать на фронт рядом с ЧУЖИМИ числами), join в
|
||||
-- публичной ручке и вторую точку отказа — в обмен ни на что: связь строго 1:1.
|
||||
--
|
||||
-- ЧТО ЛЕЖИТ В `street_scheme` — ГОТОВЫЕ SVG-ПУТИ, А НЕ ГЕОМЕТРИЯ. Окно
|
||||
-- 840×840 м в GeoJSON — это 7-8 КБ на сделку плюс проекция в рантайме у
|
||||
-- посетителя. Уже спроецированная схема того же окна — 2.8-2.9 КБ (замер
|
||||
-- 2026-08-29 на «улица Краснолесья» и «улица 8 Марта»).
|
||||
--
|
||||
-- ГРАНИЦА ЧЕСТНОСТИ, ЗАПИСАННАЯ ЗДЕСЬ, А НЕ ТОЛЬКО В ГОЛОВЕ АВТОРА:
|
||||
-- * ЭТО УЛИЦА, А НЕ ДОМ. `deals.address` — уровня улицы («Екатеринбург,
|
||||
-- Краснолесья»), номер дома известен у 2.7% сделок. В схеме намеренно НЕТ
|
||||
-- ни координат окна, ни констант проекции: по ней физически нельзя
|
||||
-- положить (lon, lat) в её систему координат, то есть нарисовать точку
|
||||
-- дома нельзя даже случайно.
|
||||
-- * `street_name` NULLABLE, и NULL — ШТАТНЫЙ СЛУЧАЙ. Нормализация названия
|
||||
-- даёт совпадение с OSM у 550 названий из 654, это 31 410 сделок из
|
||||
-- 34 021 — 92.3% (замер 2026-08-29 по той же выборке, из которой
|
||||
-- набирается витрина). Оставшиеся 8% схемы не получают, и фронт
|
||||
-- показывает район — запасной вариант, который для этого и оставлен.
|
||||
--
|
||||
-- Зависимости:
|
||||
-- - 060_postgres_fdw_extension.sql (SERVER gendesign_remote + USER MAPPING,
|
||||
-- пароль подставляется на старте бэкенда). Сервер объявлен с
|
||||
-- OPTIONS (extensions 'postgis') — именно поэтому `&&` с конвертом уходит
|
||||
-- на ту сторону в GiST-индекс, а не тянет сюда десять тысяч линий.
|
||||
-- - gendesign: вьюха public.v_tradein_osm_roads_ekb + GRANT SELECT роли
|
||||
-- tradein_fdw_reader (миграция 195, деплоится ПЕРВОЙ).
|
||||
-- - 276_landing_showcase_deals.sql (сама витрина).
|
||||
--
|
||||
-- Идемпотентна: DROP FOREIGN TABLE IF EXISTS + ADD COLUMN IF NOT EXISTS.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE и, ожидая его
|
||||
-- за чужой сессией, встаёт в очередь ПЕРЕД запросами приложения.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE landing_showcase_deals
|
||||
ADD COLUMN IF NOT EXISTS street_name text,
|
||||
ADD COLUMN IF NOT EXISTS street_scheme jsonb;
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.street_name IS
|
||||
'Улица сделки как она называется в OSM. NULL — штатно: адрес не сматчился '
|
||||
'(8% сделок), схемы нет, фронт показывает район. Это УЛИЦА, а не дом: '
|
||||
'номер дома известен у 2.7% сделок.';
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.street_scheme IS
|
||||
'Готовые SVG-пути окна 840x840 м вокруг центра улицы: {street, w, h, '
|
||||
'target[], roads[{c,d}], water[], labels[{t,x,y}]}. Координат окна и '
|
||||
'констант проекции в ней НЕТ намеренно — по схеме нельзя положить точку '
|
||||
'дома, которого в данных нет. Зданий нет тоже (cad_buildings покрывает '
|
||||
'город на 18 307 контуров и в центре показывает пустые кварталы).';
|
||||
|
||||
DROP FOREIGN TABLE IF EXISTS gendesign_osm_roads_ekb;
|
||||
|
||||
CREATE FOREIGN TABLE gendesign_osm_roads_ekb (
|
||||
name text,
|
||||
road_class text,
|
||||
geom geometry(Geometry, 4326)
|
||||
)
|
||||
SERVER gendesign_remote
|
||||
OPTIONS (schema_name 'public', table_name 'v_tradein_osm_roads_ekb');
|
||||
|
||||
COMMENT ON FOREIGN TABLE gendesign_osm_roads_ekb IS
|
||||
'Живой срез gendesign.osm_noise_sources_ekb (улицы и вода ЕКБ). Выгрузка '
|
||||
'фильтрованная («источники шума»): именованные улицы покрыты хорошо, '
|
||||
'дворовые и служебные проезды отсутствуют. Читает '
|
||||
'app/services/street_scheme.py при пересчёте витрины лэндинга; в публичной '
|
||||
'ручке НЕ используется — там читаются готовые схемы из landing_showcase_deals.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -179,6 +179,16 @@ _SHOWCASE_ROW = {
|
|||
"err_pct": 3.39,
|
||||
"n_analogs": 41,
|
||||
"note": "не point-in-time",
|
||||
"street_name": "улица Краснолесья",
|
||||
"street_scheme": {
|
||||
"street": "улица Краснолесья",
|
||||
"w": 1000,
|
||||
"h": 1000,
|
||||
"target": ["M10 20L30 40"],
|
||||
"roads": [{"c": "primary", "d": "M0 0L100 100"}],
|
||||
"water": [],
|
||||
"labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}],
|
||||
},
|
||||
}
|
||||
_SHOWCASE_RUN = {
|
||||
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
|
||||
|
|
@ -217,6 +227,24 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
|
|||
assert body["deals"][0]["fact_rub"] == 5_900_000
|
||||
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
|
||||
assert "address" not in body["deals"][0]
|
||||
# Схема улицы едет готовыми SVG-путями, а не геометрией.
|
||||
assert body["deals"][0]["street_name"] == "улица Краснолесья"
|
||||
assert body["deals"][0]["street_scheme"]["target"] == ["M10 20L30 40"]
|
||||
|
||||
|
||||
def test_showcase_row_without_street_stays_null(client: TestClient) -> None:
|
||||
"""Несматчившаяся улица — 8% сделок — проходит насквозь как null.
|
||||
|
||||
Ни подстановки соседней улицы, ни падения: фронт по этому null показывает
|
||||
район. Ломать так: сделать street_name/street_scheme обязательными в
|
||||
`ShowcaseDeal` — тест покраснеет на 500.
|
||||
"""
|
||||
row = {**_SHOWCASE_ROW, "street_name": None, "street_scheme": None}
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
|
||||
|
||||
body = client.get(f"{PREFIX}/showcase").json()
|
||||
assert body["deals"][0]["street_name"] is None
|
||||
assert body["deals"][0]["street_scheme"] is None
|
||||
|
||||
|
||||
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
|
||||
|
|
|
|||
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal file
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Схема улицы для витрины лэндинга: нормализация, отказ и размер.
|
||||
|
||||
Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не
|
||||
проверка, а декорация. Что именно ловится:
|
||||
* нормализация названий на РЕАЛЬНЫХ строках `deals.address` и OSM;
|
||||
* несматчившаяся улица не роняет пересчёт (штатные 8%);
|
||||
* схема не раздувается: без округления координат она вырастает в разы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import street_scheme as ss
|
||||
|
||||
|
||||
# ── Нормализация ────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
# Реальные значения deals.address (прод, ЕКБ).
|
||||
("Екатеринбург, Краснолесья", "краснолесья"),
|
||||
("Екатеринбург, 8 Марта", "8 марта"),
|
||||
("Екатеринбург, 22 Партсъезда", "22 партсъезда"),
|
||||
("Екатеринбург, Академика Сахарова", "академика сахарова"),
|
||||
# Перекрёсток: берём ПЕРВУЮ улицу (обоснование — в докстринге функции).
|
||||
("Екатеринбург, Челюскинцев/Шейнкмана", "челюскинцев"),
|
||||
("Екатеринбург, Шаумяна/Чкалова", "шаумяна"),
|
||||
("Екатеринбург, пр-кт Космонавтов/ Ильича", "космонавтов"),
|
||||
# Реальные значения name в osm_noise_sources_ekb.
|
||||
("улица Красных Командиров", "красных командиров"),
|
||||
("Амбулаторная улица", "амбулаторная"),
|
||||
("проспект Космонавтов", "космонавтов"),
|
||||
("Сибирский тракт", "сибирский"),
|
||||
# ё → е: адрес и OSM пишут по-разному, улица одна.
|
||||
("Екатеринбург, Королёва", "королева"),
|
||||
("улица Королева", "королева"),
|
||||
# Пусто — значит пусто, а не выдуманная улица.
|
||||
(None, None),
|
||||
("", None),
|
||||
("Екатеринбург, улица", None),
|
||||
],
|
||||
)
|
||||
def test_normalize_street(raw: str | None, expected: str | None) -> None:
|
||||
assert ss.normalize_street(raw) == expected
|
||||
|
||||
|
||||
def test_normalize_matches_address_to_osm_name() -> None:
|
||||
"""Смысл нормализации: адрес сделки и название OSM сходятся в один ключ."""
|
||||
assert ss.normalize_street("Екатеринбург, Краснолесья") == ss.normalize_street(
|
||||
"улица Краснолесья"
|
||||
)
|
||||
assert ss.normalize_street("Екатеринбург, Королёва") == ss.normalize_street("улица Королева")
|
||||
|
||||
|
||||
# ── Тестовый двойник сессии ─────────────────────────────────────────────────
|
||||
class _Result:
|
||||
def __init__(self, rows: list) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list:
|
||||
return self._rows
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Отвечает на три запроса модуля. `fail` — имитация недоступной вьюхи."""
|
||||
|
||||
def __init__(self, *, names: list[str], center=None, window=None, fail: bool = False) -> None:
|
||||
self.names = names
|
||||
self.center = center
|
||||
self.window = window or []
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self):
|
||||
yield
|
||||
|
||||
def execute(self, stmt, params=None):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("permission denied for view v_tradein_osm_roads_ekb")
|
||||
sql = str(stmt)
|
||||
if "DISTINCT name" in sql:
|
||||
return _Result([(n,) for n in self.names])
|
||||
if "ST_ClosestPoint" in sql:
|
||||
return _Result([self.center] if self.center else [])
|
||||
return _Result(self.window)
|
||||
|
||||
|
||||
def _line(n: int, lon0: float = 60.53, lat0: float = 56.78) -> str:
|
||||
pts = [[round(lon0 + i * 0.0002, 6), round(lat0 + i * 0.0001, 6)] for i in range(n)]
|
||||
return json.dumps({"type": "LineString", "coordinates": pts})
|
||||
|
||||
|
||||
# ── Несматчившаяся улица — штатный случай, а не авария ──────────────────────
|
||||
def test_unmatched_street_returns_none_without_touching_db() -> None:
|
||||
db = _FakeDB(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
before = db.calls
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Такой Улицы Нет") is None
|
||||
# Ни одного лишнего запроса: не сматчилось — не ходим в чужую базу.
|
||||
assert db.calls == before
|
||||
|
||||
|
||||
def test_missing_address_returns_none() -> None:
|
||||
db = _FakeDB(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
assert ss.build_street_scheme(db, index, None) is None
|
||||
|
||||
|
||||
def test_unavailable_view_gives_empty_index_not_exception() -> None:
|
||||
"""Грант на той стороне терялся уже трижды. Пересчёт обязан это пережить."""
|
||||
db = _FakeDB(names=[], fail=True)
|
||||
index = ss.load_street_index(db)
|
||||
assert index.by_norm == {}
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
|
||||
|
||||
|
||||
def test_query_failure_gives_none_not_exception() -> None:
|
||||
class _FailOnSecond(_FakeDB):
|
||||
def execute(self, stmt, params=None):
|
||||
if "DISTINCT name" not in str(stmt):
|
||||
raise RuntimeError("FDW connection refused")
|
||||
return super().execute(stmt, params)
|
||||
|
||||
db = _FailOnSecond(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
|
||||
|
||||
|
||||
# ── Форма схемы и её размер ─────────────────────────────────────────────────
|
||||
def _scheme_with(rows: list[dict]) -> dict:
|
||||
db = _FakeDB(
|
||||
names=["улица Краснолесья"],
|
||||
center={"lon": 60.537283, "lat": 56.784694},
|
||||
window=rows,
|
||||
)
|
||||
index = ss.load_street_index(db)
|
||||
scheme = ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья")
|
||||
assert scheme is not None
|
||||
return scheme
|
||||
|
||||
|
||||
def test_scheme_shape() -> None:
|
||||
scheme = _scheme_with(
|
||||
[
|
||||
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(5)},
|
||||
{"name": "улица Чкалова", "road_class": "primary", "gj": _line(4, 60.534)},
|
||||
{"name": "Патрушиха", "road_class": "river", "gj": _line(3, 60.535)},
|
||||
]
|
||||
)
|
||||
assert scheme["street"] == "улица Краснолесья"
|
||||
assert scheme["w"] == scheme["h"] == ss.VIEWBOX
|
||||
assert len(scheme["target"]) == 1 and scheme["target"][0].startswith("M")
|
||||
assert [r["c"] for r in scheme["roads"]] == ["primary"]
|
||||
assert len(scheme["water"]) == 1
|
||||
assert [label["t"] for label in scheme["labels"]] == ["улица Чкалова"]
|
||||
# Ни координат окна, ни констант проекции: по схеме нельзя поставить дом.
|
||||
assert not {"minLon", "maxLat", "k", "scale", "lat", "lon"} & set(scheme)
|
||||
|
||||
|
||||
def _dense_window() -> list[dict]:
|
||||
"""Плотность как в реальном окне (замер 2026-08-29 по «улица 8 Марта» и
|
||||
«улица Краснолесья»): 56-58 отрезков в кадре, 114-118 точек после упрощения
|
||||
на 3 м, то есть 2-2.7 точки на отрезок. Берём 3 — чуть плотнее замера."""
|
||||
rows = [
|
||||
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(3, 60.537 + i * 0.0002)}
|
||||
for i in range(16)
|
||||
]
|
||||
rows += [
|
||||
{
|
||||
"name": f"улица №{i}",
|
||||
"road_class": "residential",
|
||||
"gj": _line(3, 60.5340 + i * 0.0004, 56.7820 + i * 0.0002),
|
||||
}
|
||||
for i in range(40)
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def test_scheme_stays_small() -> None:
|
||||
"""Порядок величины — 4 КБ. Замер на проде 2026-08-29: 2.8-2.9 КБ.
|
||||
|
||||
Сломать: убрать `round(..., 1)` в `path()` — координаты станут длинными
|
||||
float'ами, и схема на том же окне перевалит за потолок.
|
||||
"""
|
||||
scheme = _scheme_with(_dense_window())
|
||||
size = len(json.dumps(scheme, ensure_ascii=False, separators=(",", ":")).encode())
|
||||
assert size < 4400, f"схема раздулась до {size} байт"
|
||||
assert len(scheme["labels"]) == ss.MAX_LABELS
|
||||
|
||||
|
||||
def test_coordinates_carry_one_decimal() -> None:
|
||||
"""Прямая проверка того, чем держится размер: одна цифра после точки.
|
||||
|
||||
Потолок в байтах ловит раздутие только на той плотности, что в тесте; этот
|
||||
ассерт ловит саму потерю округления на любой.
|
||||
"""
|
||||
scheme = _scheme_with(_dense_window())
|
||||
blob = " ".join(scheme["target"] + [r["d"] for r in scheme["roads"]])
|
||||
assert not re.search(r"\.\d\d", blob), "координаты перестали округляться до 0.1"
|
||||
for label in scheme["labels"]:
|
||||
assert not re.search(r"\.\d\d", f"{label['x']} {label['y']}")
|
||||
Loading…
Add table
Reference in a new issue