From cef872ace1e402fbcd94d89387f5c5164ce6a9aa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 12 Sep 2026 13:59:43 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D0=BE=D0=B2,=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=20=E2=80=94=20=D0=BF=D0=BE=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=86=D0=B8=D1=84=D0=B8=D1=87=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20(#3501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/regions.py | 140 ++++++++++++++- .../tests/test_3051_region_registry.py | 36 +++- ...test_3051_region_registry_moscow_oblast.py | 159 ++++++++++++++++++ 3 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3051_region_registry_moscow_oblast.py diff --git a/tradein-mvp/backend/app/services/regions.py b/tradein-mvp/backend/app/services/regions.py index 380c7ea0..70837a26 100644 --- a/tradein-mvp/backend/app/services/regions.py +++ b/tradein-mvp/backend/app/services/regions.py @@ -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()) diff --git a/tradein-mvp/backend/tests/test_3051_region_registry.py b/tradein-mvp/backend/tests/test_3051_region_registry.py index eb3de64d..5722b714 100644 --- a/tradein-mvp/backend/tests/test_3051_region_registry.py +++ b/tradein-mvp/backend/tests/test_3051_region_registry.py @@ -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 закреплено байт-в-байт ─────────────────────────── diff --git a/tradein-mvp/backend/tests/test_3051_region_registry_moscow_oblast.py b/tradein-mvp/backend/tests/test_3051_region_registry_moscow_oblast.py new file mode 100644 index 00000000..720f7d7e --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_region_registry_moscow_oblast.py @@ -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