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
171 lines
10 KiB
Python
171 lines
10 KiB
Python
"""#3052: `region_for_point` резолвит по настоящей границе региона (полигон
|
||
OSM/Nominatim из `region_boundaries/boundaries.geojson.json`), не по
|
||
прямоугольнику `bbox_region`.
|
||
|
||
До фикса прямоугольники Москвы (77) и области (50) пересекались — Химки,
|
||
Реутов, Котельники, Люберцы резолвились в Москву (8 647 московских лотов
|
||
получали отказ по покрытию, 11 172 областные сделки ложно попадали в
|
||
московское ядро, при том что коридор ДКП фильтрует строго по коду региона).
|
||
|
||
Девять контрольных точек ниже — приёмка issue #3052, значения сверены
|
||
`region_for_point` на реальных полигонах до мержа (ray casting с поддержкой
|
||
дыр, см. regions._point_in_region_polygon)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import regions
|
||
|
||
# (название, lat, lon, ожидаемый code региона или None)
|
||
_CONTROL_POINTS: tuple[tuple[str, float, float, int | None], ...] = (
|
||
("Химки", 55.8970, 37.4297, 50),
|
||
("центр Москвы", 55.7558, 37.6173, 77),
|
||
("Ломоносовский проспект", 55.7020, 37.5140, 77),
|
||
("Серпухов", 54.9147, 37.4147, 50),
|
||
("Балашиха", 55.7965, 37.9391, 50),
|
||
("Зеленоград", 55.9840, 37.2140, 77),
|
||
("Троицк (Новая Москва)", 55.4750, 37.3000, 77),
|
||
("Екатеринбург", 56.8389, 60.6057, 66),
|
||
("Пермь", 58.0105, 56.2502, None),
|
||
)
|
||
|
||
|
||
def test_nine_control_points_resolve_by_real_boundary() -> None:
|
||
for name, lat, lon, expected_code in _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_point_outside_all_boundaries_is_none() -> None:
|
||
"""Пермь — вне всех трёх полигонов (не только вне bbox), инвариант отдельно
|
||
от общего прогона выше — чтобы регрессия на None была явной сама по себе."""
|
||
assert regions.region_for_point(58.0105, 56.2502) is None
|
||
|
||
|
||
def test_ekaterinburg_unaffected_by_polygon_switch() -> None:
|
||
"""Регион 66 физически за тысячи км от 50/77 — переход на полигон не должен
|
||
был задеть его резолв вообще."""
|
||
res = regions.region_for_point(56.8389, 60.6057)
|
||
assert res is not None and res.code == 66
|
||
|
||
|
||
def test_bbox_prefilter_never_rejects_what_polygon_would_accept() -> None:
|
||
"""Инвариант «полигон вложен в свой bbox_region»: если бы это было не так,
|
||
дешёвый bbox-предфильтр в region_for_point молча отбрасывал бы точки,
|
||
которые честный point-in-polygon принял бы — тихая потеря покрытия."""
|
||
for code, rings in regions._REGION_BOUNDARIES.items():
|
||
bbox = regions.REGIONS[code].bbox_region
|
||
for ring in rings:
|
||
for lon, lat in ring:
|
||
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"
|