fix(tradein): границы регионов без упрощения + индекс колец в region_for_point
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 17s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s

polygon_threshold=0.005 (~500 м) спрямлял ровно ту границу Москвы с областью,
ради которой полигоны и заводились: 69 точек агломерации не принимал ни один
полигон (None → DEFAULT_REGION_CODE=66), Митино/Косино/Можайский уезжали в
область, 731 объявление области — в Москву. Границы перекачаны без упрощения
(42 755 вершин против 2 388). Стоимость снята предпосчётом: bbox каждого
кольца + нарезка рёбер на lat-полосы — 3.5 мкс/вызов при потолке 100.
None теперь законен только вне bbox всех регионов; если прямоугольник принял,
а полигоны нет, выбирается регион с ближайшей границей.
This commit is contained in:
bot-backend 2026-09-15 10:15:50 +03:00
parent d7aaa00326
commit 48f5dffa2e
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
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# 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 на каждый
# вызов 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) точек: внешний контур ИЛИ дыра.
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
# (для 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()
def _point_in_ring(x: float, y: float, ring: Ring) -> bool:
"""PNPOLY (W. R. Franklin) ray casting для одного кольца."""
class _IndexedRing(NamedTuple):
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
Наивный 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
xj, yj = ring[-1]
for xi, yi in ring:
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
for xi, yi, xj, yj in bands[band]:
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
xj, yj = xi, yi
return inside
@ -166,12 +258,55 @@ def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
Региона без загруженной границы граничит только по bbox (см. вызов в
region_for_point), сюда такой код не попадает."""
inside = False
for ring in _REGION_BOUNDARIES[code]:
if _point_in_ring(lon, lat, ring):
for ring in _REGION_RING_INDEX[code]:
if _point_in_ring(ring, lon, lat):
inside = not 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).
TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation"
@ -382,18 +517,38 @@ def region_for_point(lat: float, lon: float) -> Region | None:
прямоугольнике области, но не в её реальных границах то есть,
например, внутри вырезанной дыры Москвы), идём к следующему
кандидату, а не возвращаем None сразу.
None ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
становится кандидат, к ГРАНИЦЕ которого точка ближе
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
как None, а тот (`trade_in.py`: `target_region.code if target_region else
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию адрес в
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
полигонами соседних регионов неизбежны (у Москвы и области это независимо
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
общая), и закрывать их возвратом к прямоугольнику нельзя прямоугольники
перекрываются и врут ровно там, ради чего #3052 и делался.
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
boundaries.geojson.json) на practике до fallback-ветки дело не доходит,
boundaries.geojson.json) на практике до fallback-ветки дело не доходит,
но она есть, чтобы новый регион без границы не «пропадал» молча, а
работал по старому bbox-поведению до того, как для него добавят полигон.
"""
near_miss: list[int] = []
for code in _POINT_LOOKUP_ORDER:
region = REGIONS[code]
if not is_within_bbox(lat, lon, region.bbox_region):
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 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:

View file

@ -14,6 +14,7 @@ OSM/Nominatim из `region_boundaries/boundaries.geojson.json`), не по
from __future__ import annotations
import os
import time
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), (
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"