gendesign/tradein-mvp/backend/tests/test_3052_region_polygon_boundary.py
lekss361 967f2b53ba
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
Границы регионов без упрощения, чтобы Куркино не считалось по Екатеринбургу (#3527)
2026-09-15 07:22:31 +00:00

171 lines
10 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.

"""#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"