Границы регионов без упрощения, чтобы Куркино не считалось по Екатеринбургу (#3527)
All checks were successful
Deploy Trade-In / changes (push) Successful in 23s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s

This commit is contained in:
lekss361 2026-09-15 07:22:31 +00:00
parent 2d2d9ebf17
commit 967f2b53ba
3 changed files with 270 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,10 @@ pathlib — стандартная библиотека, листовость н
from __future__ import annotations from __future__ import annotations
import json import json
import math
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import NamedTuple
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический # bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже). # geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
@ -105,6 +107,17 @@ def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый # Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
# вызов region_for_point был бы дороже самого ray casting. # вызов region_for_point был бы дороже самого ray casting.
# #
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
# областью. Сверка резолва против сохранённого region_code по проду (277 582
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
#
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра. # Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец # Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 — # (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
@ -148,14 +161,93 @@ def _load_region_boundaries() -> dict[int, list[Ring]]:
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries() _REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
def _point_in_ring(x: float, y: float, ring: Ring) -> bool: class _IndexedRing(NamedTuple):
"""PNPOLY (W. R. Franklin) ray casting для одного кольца.""" """Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
Наивный ray casting линеен по числу вершин, а вершин после отказа от
упрощения в 18 раз больше (42 755 против 2 388) прямым перебором это
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
lat_min/lat_max/lon_min/lon_max bbox САМОГО кольца (не региона): у
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
и точка почти всегда вне bbox большинства колец такое кольцо
отвергается четырьмя сравнениями вместо обхода вершин.
band_h/bands кольцо нарезано на горизонтальные полосы по широте; в
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
попадает во все свои полосы, поэтому пересечения не теряются).
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
своей полосы обход сокращается с «все вершины кольца» до «рёбра
одной полосы» (для внешнего контура области 23 511 максимум 724,
типично десятки).
points те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
нужны fallback'у по расстоянию до границы в region_for_point.
"""
lat_min: float
lat_max: float
lon_min: float
lon_max: float
band_h: float
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
points: Ring
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
# ограничивает память на самых длинных кольцах (внешний контур области).
_RING_BAND_DENSITY = 4
_RING_MAX_BANDS = 1024
def _index_ring(points: Ring) -> _IndexedRing:
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
lat_min = min(p[1] for p in points)
lat_max = max(p[1] for p in points)
lat_span = lat_max - lat_min
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
x_prev, y_prev = points[-1]
for x, y in points:
edge = (x, y, x_prev, y_prev)
lo = int((min(y, y_prev) - lat_min) / band_h)
hi = int((max(y, y_prev) - lat_min) / band_h)
lo = 0 if lo < 0 else min(lo, n_bands - 1)
hi = 0 if hi < 0 else min(hi, n_bands - 1)
for band in range(lo, hi + 1):
buckets[band].append(edge)
x_prev, y_prev = x, y
return _IndexedRing(
lat_min=lat_min,
lat_max=lat_max,
lon_min=min(p[0] for p in points),
lon_max=max(p[0] for p in points),
band_h=band_h,
bands=tuple(tuple(b) for b in buckets),
points=points,
)
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
}
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
return False
band = int((lat - lat_min) / band_h)
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
band = len(bands) - 1
inside = False inside = False
xj, yj = ring[-1] for xi, yi, xj, yj in bands[band]:
for xi, yi in ring: if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
inside = not inside inside = not inside
xj, yj = xi, yi
return inside return inside
@ -166,12 +258,55 @@ def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
Региона без загруженной границы граничит только по bbox (см. вызов в Региона без загруженной границы граничит только по bbox (см. вызов в
region_for_point), сюда такой код не попадает.""" region_for_point), сюда такой код не попадает."""
inside = False inside = False
for ring in _REGION_BOUNDARIES[code]: for ring in _REGION_RING_INDEX[code]:
if _point_in_ring(lon, lat, ring): if _point_in_ring(ring, lon, lat):
inside = not inside inside = not inside
return inside return inside
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
"""Расстояние от точки до ближайшего ребра границы региона `code`.
В градусах широты (1° 111 км) по ПЛОСКОМУ приближению с поправкой
cos(lat) на сжатие долготы: значение сравнивается только с таким же
значением другого региона-кандидата на тех же ~десятках метров, где
геодезия и плоскость расходятся на доли процента.
Путь редкий (только щель между полигонами соседних регионов, см.
region_for_point), поэтому обход честный по всем рёбрам, без полос.
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
дальше найденного минимума, не может дать ребра ближе.
"""
lat_scale = math.cos(math.radians(lat))
px = lon * lat_scale
best = math.inf
ranked: list[tuple[float, _IndexedRing]] = []
for ring in _REGION_RING_INDEX[code]:
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
ranked.append((dx * dx + dy * dy, ring))
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
if lower_bound >= best:
break
ax, ay = ring.points[-1]
ax *= lat_scale
for bx_raw, by in ring.points:
bx = bx_raw * lat_scale
dx, dy = bx - ax, by - ay
if dx or dy:
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
ex = ax + t * dx - px
ey = ay + t * dy - lat
else: # выродившееся в точку ребро (дубль вершины в OSM)
ex, ey = ax - px, ay - lat
dist = ex * ex + ey * ey
if dist < best:
best = dist
ax, ay = bx, by
return math.sqrt(best)
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator). # Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
TIER_AVITO_IMV = "avito_imv" TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation" TIER_YANDEX_VALUATION = "yandex_valuation"
@ -382,18 +517,38 @@ def region_for_point(lat: float, lon: float) -> Region | None:
прямоугольнике области, но не в её реальных границах то есть, прямоугольнике области, но не в её реальных границах то есть,
например, внутри вырезанной дыры Москвы), идём к следующему например, внутри вырезанной дыры Москвы), идём к следующему
кандидату, а не возвращаем None сразу. кандидату, а не возвращаем None сразу.
None ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
становится кандидат, к ГРАНИЦЕ которого точка ближе
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
как None, а тот (`trade_in.py`: `target_region.code if target_region else
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию адрес в
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
полигонами соседних регионов неизбежны (у Москвы и области это независимо
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
общая), и закрывать их возвратом к прямоугольнику нельзя прямоугольники
перекрываются и врут ровно там, ради чего #3052 и делался.
Региона без загруженного полигона в реестре нет (все REGIONS покрыты Региона без загруженного полигона в реестре нет (все REGIONS покрыты
boundaries.geojson.json) на practике до fallback-ветки дело не доходит, boundaries.geojson.json) на практике до fallback-ветки дело не доходит,
но она есть, чтобы новый регион без границы не «пропадал» молча, а но она есть, чтобы новый регион без границы не «пропадал» молча, а
работал по старому bbox-поведению до того, как для него добавят полигон. работал по старому bbox-поведению до того, как для него добавят полигон.
""" """
near_miss: list[int] = []
for code in _POINT_LOOKUP_ORDER: for code in _POINT_LOOKUP_ORDER:
region = REGIONS[code] region = REGIONS[code]
if not is_within_bbox(lat, lon, region.bbox_region): if not is_within_bbox(lat, lon, region.bbox_region):
continue continue
if code not in _REGION_BOUNDARIES or _point_in_region_polygon(code, lat, lon): if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
return region return region
return None near_miss.append(code)
if not near_miss:
return None
if len(near_miss) == 1:
return REGIONS[near_miss[0]]
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
return REGIONS[nearest]
def region_by_city(city: str | None) -> Region | None: def region_by_city(city: str | None) -> Region | None:

View file

@ -14,6 +14,7 @@ OSM/Nominatim из `region_boundaries/boundaries.geojson.json`), не по
from __future__ import annotations from __future__ import annotations
import os import os
import time
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -66,3 +67,105 @@ def test_bbox_prefilter_never_rejects_what_polygon_would_accept() -> None:
assert regions.is_within_bbox(lat, lon, bbox), ( assert regions.is_within_bbox(lat, lon, bbox), (
f"вершина полигона региона {code} ({lat}, {lon}) вне его bbox_region" f"вершина полигона региона {code} ({lat}, {lon}) вне его bbox_region"
) )
# ── Правка регрессии первой версии #3052 (упрощение 0.005 ≈ 500 м) ──────────
#
# Сверка резолва против сохранённого region_code по проду (277 582 объявления
# + 48 143 дома) на упрощённой геометрии: 69 точек агломерации не принял НИ
# один полигон (→ None → DEFAULT_REGION_CODE=66 у вызывающего кода), куски
# Москвы (Митино, Косино-Ухтомский, Можайский) уехали в область, 731
# объявление области — в Москву. Точки ниже — реальные адреса из прода.
#
# Кунцево, «ул. Липовой Рощи 1к3» (55.7990, 37.3787) в этом списке стоит как
# 50, хотя в исходной сверке значилось ожидание 77: улицы с таким именем в
# Москве нет вообще (Nominatim /search «улица Липовой Рощи» — единственное
# попадание: квартал «Спутник», Павшино, городской округ Красногорск,
# Московская область), а сама точка лежит в 119 м ВНУТРИ области от границы
# Москвы. Ожидание 77 там — след bbox-эпохи (прямоугольник Москвы тянется по
# долготе до 38.10 и накрывает Красногорск), а не факт. Оно ещё и неисполнимо
# вместе с соседней точкой той же сверки: Новоивановское (55.7013, 37.3606)
# лежит в 72 м снаружи Москвы и ожидается как 50 — никакое правило «ближе N
# метров к Москве ⇒ Москва» не отдаст 77 точке в 119 м и 50 точке в 72 м.
_BORDER_CONTROL_POINTS: tuple[tuple[str, float, float, int], ...] = (
("Куркино, Соловьиная Роща 8", 55.8917, 37.4008, 77),
("Митино, Муравская 46к1", 55.8617, 37.3769, 77),
("Косино-Ухтомский, Лухмановская 15к4", 55.7226, 37.8922, 77),
("Можайский, Сколковское ш. 40к4", 55.7022, 37.3920, 77),
("Новые Химки, Панфилова 12", 55.8796, 37.4294, 50),
("Химки, Юннатов 11", 55.8972, 37.4613, 50),
("Красногорск, Красногорский б-р 13к1", 55.8156, 37.3707, 50),
("Новоивановское, Одинцовский о.", 55.7013, 37.3606, 50),
("Красногорск, Липовой Рощи 1к3", 55.7990, 37.3787, 50),
)
def test_border_control_points_resolve_on_unsimplified_boundary() -> None:
for name, lat, lon, expected_code in _BORDER_CONTROL_POINTS:
res = regions.region_for_point(lat, lon)
got_code = res.code if res is not None else None
assert got_code == expected_code, (
f"{name} ({lat}, {lon}): ожидался регион {expected_code}, получен {got_code}"
)
def test_no_point_inside_a_bbox_resolves_to_none() -> None:
"""Главный инвариант правки: None законен ТОЛЬКО вне bbox всех регионов.
Прогон сеткой 0.005° по прямоугольнику Москвы: любая щель между
независимо оцифрованными в OSM линиями 50 и 77 всплывёт здесь как None, а
None у вызывающего кода (`trade_in.py`: `code if target_region else
DEFAULT_REGION_CODE`) означает молчаливую подстановку региона по
умолчанию коэффициенты Екатеринбурга московскому адресу."""
checked = 0
for lat_step in range(91):
lat = 55.55 + lat_step * 0.005
for lon_step in range(121):
lon = 37.30 + lon_step * 0.005
assert any(
regions.is_within_bbox(lat, lon, r.bbox_region) for r in regions.REGIONS.values()
), (lat, lon)
assert regions.region_for_point(lat, lon) is not None, (
f"точка ({lat}, {lon}) внутри bbox региона, но резолв дал None"
)
checked += 1
assert checked == 91 * 121
def test_nearest_boundary_fallback_prefers_the_closer_region() -> None:
"""Щель закрывается по расстоянию до границы, а не «первым в порядке».
Проверяем саму метрику в fallback-ветке region_for_point исход решает
она. Точка в Красногорске у самой границы Москвы: линия 50 и линия 77
здесь общая, поэтому расстояния до них совпадают до метров, и масштаб
сотня метров, а не километры (плоское приближение с поправкой cos(lat)).
"""
lat, lon = 55.7990, 37.3787
d_50 = regions._distance_to_boundary(50, lat, lon)
d_77 = regions._distance_to_boundary(77, lat, lon)
assert abs(d_50 - d_77) * 111_320 < 20.0
assert 50.0 < d_50 * 111_320 < 300.0
# Центр Москвы от своей границы — километры (метрика не схлопнута в ноль).
assert regions._distance_to_boundary(77, 55.7558, 37.6173) * 111_320 > 5_000.0
def _measure_resolve(probes: list[tuple[float, float]]) -> float:
started = time.perf_counter()
for _ in range(200):
for lat, lon in probes:
regions.region_for_point(lat, lon)
return (time.perf_counter() - started) / (200 * len(probes))
def test_region_for_point_stays_under_time_budget() -> None:
"""Точная геометрия не имеет права стоить дороже bbox-эпохи.
Потолок 150 мкс/вызов при фактических ~4 мкс (bbox каждого кольца +
lat-полосы рёбер, см. regions._IndexedRing): запас в 35 раз тест
краснеет от настоящей регрессии алгоритма, а не от шумного раннера."""
probes = [(lat, lon) for _n, lat, lon, _c in _BORDER_CONTROL_POINTS]
probes += [(lat, lon) for _n, lat, lon, _c in _CONTROL_POINTS]
for lat, lon in probes: # прогрев
regions.region_for_point(lat, lon)
best = min(_measure_resolve(probes) for _ in range(3))
assert best < 150e-6, f"region_for_point: {best * 1e6:.1f} мкс/вызов, потолок 150"