Московская область в реестре регионов, обход — по специфичности вместо кода #3501
3 changed files with 322 additions and 13 deletions
|
|
@ -28,9 +28,18 @@ class Region:
|
|||
"""Один регион покрытия продукта.
|
||||
|
||||
bbox_tight — ядро города: geocoder-фильтрация фуззи-матчей провайдеров
|
||||
(не принять соседний город за совпадение по опечатке).
|
||||
(не принять соседний город за совпадение по опечатке). У
|
||||
региона БЕЗ одного центрального города (50 — область, много
|
||||
сопоставимых по объёму городов, ни один не «ядро») — равен
|
||||
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
|
||||
наблюдались (перцентили 0.5..99.5 координат сырья), а не
|
||||
административная граница — см. REGIONS[50] и обоснование там.
|
||||
bbox_wide — город + легитимное приграничье: ingest-guard координат,
|
||||
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
|
||||
У 50 — полный наблюдённый диапазон координат (min..max, без
|
||||
перцентильной обрезки) вместо «город + отступ»: без своего
|
||||
города отступать не от чего, поэтому граница «легитимности»
|
||||
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
|
||||
bbox_region — генеральный bbox региона: fallback-accept для провайдеров без
|
||||
структурного region-поля. Содержит wide.
|
||||
bbox_product_core — гео-охват ПРОДУКТА в этом регионе: location_index
|
||||
|
|
@ -38,9 +47,14 @@ class Region:
|
|||
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
|
||||
location_index (56.70..56.95/60.50..60.75), синхронизирован с
|
||||
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
|
||||
(комментарий в обе стороны, см. site_finder/poi_loader.py).
|
||||
(комментарий в обе стороны, см. site_finder/poi_loader.py). У
|
||||
50 — намеренно НЕ административный bbox (обещать охват там,
|
||||
где нет ни одного объявления, нельзя) — эмпирический пояс
|
||||
фактических данных, см. REGIONS[50].
|
||||
city_token — нормализованный токен главного города (нижний регистр, е==ё
|
||||
нормализует потребитель — matching.normalize).
|
||||
нормализует потребитель — matching.normalize). У региона без
|
||||
единого центра (50) — самый объёмный по данным город, который
|
||||
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
|
||||
cities — узнаваемые города региона (для city_hint / prefix-логики
|
||||
геокодера). НЕ исчерпывающий список — основные центры.
|
||||
enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону.
|
||||
|
|
@ -159,19 +173,120 @@ REGIONS: dict[int, Region] = {
|
|||
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
|
||||
canonical_city="Москва",
|
||||
),
|
||||
50: Region(
|
||||
code=50,
|
||||
name="Московская область",
|
||||
# У области НЕТ города-ядра (в отличие от 66/77) — 20 сопоставимых по
|
||||
# объёму городов-спутников. Поэтому tight/wide/product_core здесь не
|
||||
# «город + отступ», а ЭМПИРИЧЕСКИЙ пояс данных: разброс координат
|
||||
# подмосковного сырья Циан (45 294 строки, отбор по городскому
|
||||
# поддомену ссылки ≠ www, замер на дату добавления региона):
|
||||
# полный диапазон: lat 54.673..56.762, lon 35.920..39.888
|
||||
# перцентили 0.5..99.5: lat 54.834..56.728, lon 36.193..39.545
|
||||
# tight = product_core = перцентильный пояс (без выбросов из хвоста
|
||||
# распределения — то немногое, что уверенно наблюдали). wide = полный
|
||||
# диапазон (min..max) — легитимное приграничье для ingest-guard шире
|
||||
# tight, но всё ещё эмпирическое, не административное.
|
||||
bbox_tight=(54.834, 56.728, 36.193, 39.545),
|
||||
bbox_wide=(54.673, 56.762, 35.920, 39.888),
|
||||
# region — административный bbox МО целиком (fallback-accept должен
|
||||
# покрывать всю область, а не только пояс, где уже есть данные):
|
||||
# lat 54.20..56.96, lon 35.14..40.21.
|
||||
bbox_region=(54.20, 56.96, 35.14, 40.21),
|
||||
# product_core НЕ равен bbox_region: location_index не должен обещать
|
||||
# медианы там, где по факту нет ни одного объявления (deals=0,
|
||||
# listings=0 на дату добавления — импорт 411 056 сделок Росреестра из
|
||||
# FDW идёт отдельным PR). Равен tight — см. выше.
|
||||
bbox_product_core=(54.834, 56.728, 36.193, 39.545),
|
||||
# Красногорск: и самый объёмный город по факту сырья (см. cities ниже,
|
||||
# по убыванию объёма), и де-факто административный центр региона —
|
||||
# Правительство Московской области физически размещается в Красногорске
|
||||
# с 2013 г. (Москва как формальный административный центр — экстра-
|
||||
# территориальна и уже занята регионом 77). Единственный кандидат,
|
||||
# обоснованный ОБОИМИ критериями сразу.
|
||||
city_token="красногорск",
|
||||
cities=frozenset(
|
||||
{
|
||||
"красногорск",
|
||||
"балашиха",
|
||||
"видное",
|
||||
"люберцы",
|
||||
"звенигород",
|
||||
"химки",
|
||||
"мытищи",
|
||||
"подольск",
|
||||
"одинцово",
|
||||
"солнечногорск",
|
||||
"домодедово",
|
||||
"королёв",
|
||||
"королев",
|
||||
"котельники",
|
||||
"дмитров",
|
||||
"электросталь",
|
||||
"реутов",
|
||||
"щёлково",
|
||||
"щелково",
|
||||
"серпухов",
|
||||
"ногинск",
|
||||
"железнодорожный",
|
||||
}
|
||||
),
|
||||
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
||||
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
||||
# намеренно, не заглушка). Ряд Сбериндекса по области загружен в
|
||||
# источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50
|
||||
# ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением.
|
||||
enrichment_tiers=frozenset(),
|
||||
# Источники по области несут настоящий city (Химки, Балашиха — не
|
||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||
# нечего и незачем, в отличие от 77.
|
||||
canonical_city=None,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_REGION_CODE = 66
|
||||
|
||||
|
||||
def _bbox_area(bbox: BBox) -> float:
|
||||
"""Грубая «площадь» bbox в кв. градусах (lat_range * lon_range).
|
||||
|
||||
Не учитывает сжатие долготы на широте (cos(lat)) — не нужно: значение
|
||||
используется ТОЛЬКО чтобы сравнить специфичность bbox'ов разного порядка
|
||||
(город vs область), не как настоящая площадь в км²."""
|
||||
lat_min, lat_max, lon_min, lon_max = bbox
|
||||
return (lat_max - lat_min) * (lon_max - lon_min)
|
||||
|
||||
|
||||
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
||||
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
||||
#
|
||||
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
|
||||
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
|
||||
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
|
||||
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
|
||||
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
|
||||
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
|
||||
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
|
||||
# наступил прямо при добавлении региона 50, не гипотетически.
|
||||
#
|
||||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
|
||||
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
|
||||
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
|
||||
# новый регион сам встанет в верную позицию по своей площади — правку этого
|
||||
# места повторять не придётся.
|
||||
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
||||
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
||||
)
|
||||
|
||||
|
||||
def region_for_point(lat: float, lon: float) -> Region | None:
|
||||
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
||||
|
||||
Регионы географически не пересекаются; порядок обхода детерминирован кодом
|
||||
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
|
||||
— и это станет видно в тестах реестра, а не в проде).
|
||||
Обход — `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
|
||||
не числовой код региона: код как ключ порядка ломается ровно на паре
|
||||
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
|
||||
"""
|
||||
for code in sorted(REGIONS):
|
||||
for code in _POINT_LOOKUP_ORDER:
|
||||
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
||||
return REGIONS[code]
|
||||
return None
|
||||
|
|
@ -179,7 +294,16 @@ def region_for_point(lat: float, lon: float) -> Region | None:
|
|||
|
||||
def region_by_city(city: str | None) -> Region | None:
|
||||
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
|
||||
регистр, е/ё не различаются). None — город не узнан ни одним регионом."""
|
||||
регистр, е/ё не различаются). None — город не узнан ни одним регионом.
|
||||
|
||||
В отличие от `region_for_point`, здесь нет геометрической вложенности —
|
||||
сравнение точное (токен строки), не bbox-containment, поэтому порядок по
|
||||
коду региона не создаёт баг ordering'а САМ ПО СЕБЕ. Он МОГ бы сломаться,
|
||||
если бы одно имя города оказалось в `cities` двух регионов (тогда побеждал
|
||||
бы меньший код) — список городов 50 сверен вручную с `cities` регионов 66
|
||||
и 77, пересечений нет (закреплено test_no_city_name_duplicated_across_regions
|
||||
в tests/test_3051_region_registry_moscow_oblast.py).
|
||||
"""
|
||||
if not city:
|
||||
return None
|
||||
token = " ".join(city.lower().replace("ё", "е").split())
|
||||
|
|
|
|||
|
|
@ -43,15 +43,41 @@ def test_bbox_nesting_invariants() -> None:
|
|||
|
||||
|
||||
def test_regions_do_not_overlap() -> None:
|
||||
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region."""
|
||||
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region —
|
||||
ЗА ИСКЛЮЧЕНИЕМ пары (50, 77): регион 50 (Московская область) admin-bbox'ом
|
||||
геометрически СОДЕРЖИТ регион 77 (Москва) целиком, это ожидаемая вложенность,
|
||||
не баг (см. #3051 Moscow-oblast, tests/test_3051_region_registry_moscow_oblast.py
|
||||
— там же настоящий инвариант для вложенной пары: `region_for_point` обязан
|
||||
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
|
||||
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 — для него
|
||||
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе."""
|
||||
nested_pairs = {(50, 77), (77, 50)}
|
||||
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
|
||||
# на допущение в докстринге: сначала проверяем, что bbox_region(50)
|
||||
# действительно содержит bbox_region(77) как множество, и только потом
|
||||
# разрешаем этой паре не соблюдать «центры не пересекаются». Если 77 когда-
|
||||
# нибудь выедет за границы области, тест упадёт ЗДЕСЬ — с понятной причиной,
|
||||
# а не молча пропустит настоящее пересечение через список исключений.
|
||||
oblast_lat_min, oblast_lat_max, oblast_lon_min, oblast_lon_max = regions.REGIONS[50].bbox_region
|
||||
msk_lat_min, msk_lat_max, msk_lon_min, msk_lon_max = regions.REGIONS[77].bbox_region
|
||||
assert oblast_lat_min <= msk_lat_min and msk_lat_max <= oblast_lat_max, (
|
||||
"bbox_region(77) выехал за широты bbox_region(50) — исключение (50,77) больше "
|
||||
"не обосновано вложенностью"
|
||||
)
|
||||
assert oblast_lon_min <= msk_lon_min and msk_lon_max <= oblast_lon_max, (
|
||||
"bbox_region(77) выехал за долготы bbox_region(50) — исключение (50,77) больше "
|
||||
"не обосновано вложенностью"
|
||||
)
|
||||
|
||||
for r in regions.REGIONS.values():
|
||||
core = r.bbox_product_core
|
||||
center = ((core[0] + core[1]) / 2, (core[2] + core[3]) / 2)
|
||||
for other in regions.REGIONS.values():
|
||||
if other.code != r.code:
|
||||
assert not regions.is_within_bbox(*center, other.bbox_region), (
|
||||
f"центр {r.code} внутри bbox_region {other.code}"
|
||||
)
|
||||
if other.code == r.code or (r.code, other.code) in nested_pairs:
|
||||
continue
|
||||
assert not regions.is_within_bbox(*center, other.bbox_region), (
|
||||
f"центр {r.code} внутри bbox_region {other.code}"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. Поведение региона 66 закреплено байт-в-байт ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
"""#3051, регион 50 (Московская область) + фикс порядка обхода реестра.
|
||||
|
||||
`region_for_point`/`region_by_city` раньше обходили `sorted(REGIONS)` — «первый
|
||||
по коду выигрывает». Это работало, пока регионы физически не пересекались
|
||||
(66 Урал и 77 Москва — тысячи км друг от друга). Регион 50 ломает допущение:
|
||||
bbox_region(50) (Московская область целиком) геометрически СОДЕРЖИТ
|
||||
bbox_region(77) (Москва) как прямоугольники, а 50 < 77 по числовому коду —
|
||||
наивный код-порядок отправил бы ВСЕ точки Москвы (включая центр) в регион 50.
|
||||
|
||||
Фикс — `_POINT_LOOKUP_ORDER`: обход по площади bbox_region по возрастанию
|
||||
(компактный регион проверяется раньше объемлющего), без ручного списка.
|
||||
|
||||
Область на дату этого PR — тир обогащения пуст (frozenset()), в deals/listings
|
||||
0 строк (импорт Росреестра из FDW — отдельный PR). Тесты ниже проверяют РЕЕСТР
|
||||
(геометрию/классификацию), не данные.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import regions
|
||||
|
||||
# ── 1. Регрессия на порядок обхода (главный тест) ───────────────────────────
|
||||
|
||||
|
||||
def test_lookup_order_is_by_bbox_area_not_by_code() -> None:
|
||||
"""77 (Москва, самый маленький bbox_region) проверяется первым, 66 (Урал,
|
||||
самый большой) — последним. Порядок НЕ (50, 66, 77) — это был бы код."""
|
||||
assert regions._POINT_LOOKUP_ORDER == (77, 50, 66)
|
||||
|
||||
|
||||
def test_moscow_center_resolves_to_77_not_50() -> None:
|
||||
"""Точка в центре Москвы лежит внутри bbox_region И 77, И 50 одновременно
|
||||
— регрессия на сам баг: наивный код-порядок (50 < 77) увёл бы её в 50."""
|
||||
res = regions.region_for_point(55.75, 37.62)
|
||||
assert res is not None
|
||||
assert res.code == 77
|
||||
# Инвариант, который делает это регрессией, а не совпадением:
|
||||
assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[50].bbox_region)
|
||||
assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[77].bbox_region)
|
||||
|
||||
|
||||
def test_novaya_moskva_resolves_to_77() -> None:
|
||||
"""Посёлок Птичное (Новая Москва) — административно Москва, не область."""
|
||||
res = regions.region_for_point(55.52, 37.21)
|
||||
assert res is not None
|
||||
assert res.code == 77
|
||||
|
||||
|
||||
def test_far_moscow_oblast_points_resolve_to_50() -> None:
|
||||
"""Точки вне генерального bbox 77 однозначно — дальнее Подмосковье."""
|
||||
for lat, lon in [
|
||||
(54.9152, 37.4166), # Серпухов
|
||||
(56.3430, 37.5228), # Дмитров
|
||||
(55.7889, 38.4458), # Электросталь
|
||||
]:
|
||||
res = regions.region_for_point(lat, lon)
|
||||
assert res is not None and res.code == 50, (lat, lon, res)
|
||||
|
||||
|
||||
def test_ekb_point_still_resolves_to_66() -> None:
|
||||
"""Регион 66 не сломан добавлением 50/переходом на area-порядок."""
|
||||
res = regions.region_for_point(56.8300, 60.6000)
|
||||
assert res is not None
|
||||
assert res.code == 66
|
||||
|
||||
|
||||
def test_point_outside_all_regions_is_none() -> None:
|
||||
res = regions.region_for_point(58.01, 56.25) # Пермь
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_known_limitation_khimki_balashikha_resolve_to_77_via_bbox() -> None:
|
||||
"""ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, не баг этого PR: bbox_region(77) — генеральный
|
||||
fallback-net Москвы, по долготе тянется до 38.10 (~13 км восточнее МКАД).
|
||||
Химки (55.91, 37.41) и Балашиха (55.7965, 37.9388) физически лежат внутри
|
||||
ЭТОГО прямоугольника, хотя административно это область. region_for_point
|
||||
(bbox-based) резолвит их в 77; region_by_city (точное имя) — правильно в
|
||||
50 (см. test_region_by_city_resolves_moscow_oblast_cities). Сузить
|
||||
bbox_region(77), чтобы это исправить, — отдельное решение вне скоупа
|
||||
ordering-фикса (риск задеть geocoder/estimator-потребителей 77 без
|
||||
возможности перепроверить их в этом PR)."""
|
||||
khimki = regions.region_for_point(55.91, 37.41)
|
||||
assert khimki is not None and khimki.code == 77
|
||||
balashikha = regions.region_for_point(55.7965, 37.9388)
|
||||
assert balashikha is not None and balashikha.code == 77
|
||||
|
||||
|
||||
# ── 2. region_by_city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_region_by_city_resolves_moscow_oblast_cities() -> None:
|
||||
for city in ("Красногорск", "Балашиха", "химки", "Серпухов", "Ногинск"):
|
||||
res = regions.region_by_city(city)
|
||||
assert res is not None and res.code == 50, city
|
||||
|
||||
|
||||
def test_region_by_city_moscow_and_ekb_unaffected() -> None:
|
||||
assert regions.region_by_city("Москва").code == 77
|
||||
assert regions.region_by_city("Екатеринбург").code == 66
|
||||
assert regions.region_by_city("Пермь") is None
|
||||
|
||||
|
||||
def test_no_city_name_duplicated_across_regions() -> None:
|
||||
"""Если бы Химки/Балашиха и т.п. попали в `cities` двух регионов —
|
||||
region_by_city резолвил бы их по меньшему коду. Список 50 сверен вручную
|
||||
с 66/77 — пересечений нет."""
|
||||
seen: dict[str, int] = {}
|
||||
for code, r in regions.REGIONS.items():
|
||||
for city in r.cities:
|
||||
key = city.replace("ё", "е")
|
||||
assert key not in seen or seen[key] == code, (
|
||||
f"'{city}' в cities и региона {seen.get(key)}, и региона {code}"
|
||||
)
|
||||
seen[key] = code
|
||||
|
||||
|
||||
# ── 3. Тиры обогащения и bbox-инварианты региона 50 ──────────────────────────
|
||||
|
||||
|
||||
def test_moscow_oblast_has_no_enrichment_tiers_and_degrades_loudly() -> None:
|
||||
r50 = regions.REGIONS[50]
|
||||
assert r50.enrichment_tiers == frozenset()
|
||||
for tier in (
|
||||
regions.TIER_AVITO_IMV,
|
||||
regions.TIER_YANDEX_VALUATION,
|
||||
regions.TIER_CIAN_VALUATION,
|
||||
regions.TIER_QUARTER_INDEX,
|
||||
regions.TIER_SBER_INDEX,
|
||||
):
|
||||
reason = regions.unsupported_tier_reason(r50, tier)
|
||||
assert reason is not None and "50" in reason and tier in reason
|
||||
|
||||
|
||||
def test_moscow_oblast_canonical_city_is_none() -> None:
|
||||
"""В отличие от 77 (Росреестр отдаёт округ/поселение) — источники по
|
||||
области несут настоящий city (Химки, Балашиха), перезаписывать нечего."""
|
||||
assert regions.REGIONS[50].canonical_city is None
|
||||
|
||||
|
||||
def test_moscow_oblast_bbox_nesting() -> None:
|
||||
r50 = regions.REGIONS[50]
|
||||
|
||||
def _contains(outer: regions.BBox, inner: regions.BBox) -> bool:
|
||||
return (
|
||||
outer[0] <= inner[0]
|
||||
and outer[1] >= inner[1]
|
||||
and outer[2] <= inner[2]
|
||||
and outer[3] >= inner[3]
|
||||
)
|
||||
|
||||
assert _contains(r50.bbox_wide, r50.bbox_tight)
|
||||
assert _contains(r50.bbox_region, r50.bbox_wide)
|
||||
assert _contains(r50.bbox_region, r50.bbox_product_core)
|
||||
# tight == product_core для 50 (нет отдельного «города-ядра» — см. docstring).
|
||||
assert r50.bbox_tight == r50.bbox_product_core
|
||||
Loading…
Add table
Reference in a new issue