"""Реестр регионов покрытия (#3051, часть 1/3 эпика #2989 «Москва»). ЕДИНСТВЕННОЕ место, где живут границы покрытия: bbox'ы, города, city-token и набор доступных тиров обогащения. До реестра границы лежали константами в трёх файлах (location_index / geocoder / matching.normalize), и каждая молча отвергла бы Москву; расширение покрытия означало правку россыпи литералов (ровно так дыра #2656 в свежести и появилась — «в главном месте есть, в соседних забыли»). Регион 50 (Московская область) отложен сознательно — обоснование в #2996: 10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области. Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают geocoder / location_index / matching.normalize, циклы недопустимы). """ from __future__ import annotations from dataclasses import dataclass # bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический # geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже). BBox = tuple[float, float, float, float] @dataclass(frozen=True) 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 считает свои медианы/POI только внутри него, точка вне — 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). У 50 — намеренно НЕ административный bbox (обещать охват там, где нет ни одного объявления, нельзя) — эмпирический пояс фактических данных, см. REGIONS[50]. city_token — нормализованный токен главного города (нижний регистр, е==ё нормализует потребитель — matching.normalize). У региона без единого центра (50) — самый объёмный по данным город, который ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50]. cities — узнаваемые города региона (для city_hint / prefix-логики геокодера). НЕ исчерпывающий список — основные центры. enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону. Регион без тира должен деградировать ЯВНО (потребитель спрашивает unsupported_tier_reason и логирует/маркирует), а не молча считать дальше без источника. canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city` строк, приходящих из источника без надёжного city-поля (Росреестр по Москве отдаёт муниципальный округ/поселение вместо города — «Раменки», «Сосенское» — а не «Москва»). None — источник несёт свой city как есть, без override (регион 66: byte-for-byte прежнее поведение). Not-None — потребитель (import_rosreestr_dkp) подставляет это имя вместо city источника и НЕ фильтрует по city IS NOT NULL (иначе на 77 теряется ~10% строк с пустым city). """ code: int name: str bbox_tight: BBox bbox_wide: BBox bbox_region: BBox bbox_product_core: BBox city_token: str cities: frozenset[str] enrichment_tiers: frozenset[str] canonical_city: str | None = None def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool: """True если (lat, lon) внутри bbox (inclusive).""" lat_min, lat_max, lon_min, lon_max = bbox return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max # Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator). TIER_AVITO_IMV = "avito_imv" TIER_YANDEX_VALUATION = "yandex_valuation" TIER_CIAN_VALUATION = "cian_valuation" TIER_QUARTER_INDEX = "quarter_index" TIER_SBER_INDEX = "sber_index" REGIONS: dict[int, Region] = { 66: Region( code=66, name="Свердловская область", # Значения — байт-в-байт прежние литералы из location_index/geocoder # (менять их здесь = менять поведение, это отдельное решение): # tight был geocoder.EKB_BBOX_TIGHT, wide — EKB_BBOX_WIDE, # region — OBLAST66_BBOX, product_core — bbox location_index. bbox_tight=(56.65, 56.95, 60.40, 60.85), bbox_wide=(56.6, 57.1, 60.3, 60.9), bbox_region=(55.8, 62.2, 56.7, 66.6), bbox_product_core=(56.70, 56.95, 60.50, 60.75), city_token="екатеринбург", cities=frozenset( { "екатеринбург", "нижний тагил", "каменск-уральский", "первоуральск", "верхняя пышма", "серов", "новоуральск", "асбест", "полевской", "ревда", "берёзовский", "березовский", "среднеуральск", "верхняя салда", "качканар", "краснотурьинск", "алапаевск", "ирбит", "сухой лог", "богданович", "заречный", "невьянск", "сысерть", "арамиль", } ), enrichment_tiers=frozenset( { TIER_AVITO_IMV, TIER_YANDEX_VALUATION, TIER_CIAN_VALUATION, TIER_QUARTER_INDEX, TIER_SBER_INDEX, } ), ), 77: Region( code=77, name="Москва", # Ядро — Москва в пределах МКАД (55.57..55.91 / 37.36..37.85) с тем же # запасом на фуззи, что у ЕКБ-tight относительно города. bbox_tight=(55.55, 55.95, 37.30, 37.90), # Продукт-ядро Москвы = tight (МКАД): медианы/POI считаем в ядре, # Новая Москва дозреет отдельным решением вместе с данными. # Wide — МКАД + ближний пояс (Зеленоград ловится region-bbox'ом). bbox_wide=(55.45, 56.05, 37.10, 38.10), # Генеральный — Москва в административных границах, включая Новую # Москву (юго-запад до ~55.14) и Зеленоград (север до ~56.05). bbox_region=(55.10, 56.10, 36.80, 38.10), bbox_product_core=(55.55, 55.95, 37.30, 37.90), city_token="москва", cities=frozenset({"москва", "зеленоград", "троицк", "щербинка", "московский"}), # Тиров обогащения у Москвы пока НЕТ ни одного: IMV/квартальный индекс/ # sber_index покрывают регион 66. Пустое множество здесь — не заглушка, # а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг). enrichment_tiers=frozenset(), # #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение # ("муниципальный округ Раменки", "поселение Сосенское"), не сам город — # 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 _POINT_LOOKUP_ORDER: if is_within_bbox(lat, lon, REGIONS[code].bbox_region): return REGIONS[code] return None def region_by_city(city: str | None) -> Region | None: """Регион, в чьём списке городов есть `city` (нормализованный нижний регистр, е/ё не различаются). 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()) for code in sorted(REGIONS): r = REGIONS[code] if token in r.cities or any(token == c.replace("ё", "е") for c in r.cities): return r return None def unsupported_tier_reason(region: Region, tier: str) -> str | None: """None — тир доступен региону; иначе готовая строка для лога/маркера. Смысл — «регион без тира деградирует явно»: вызывающий код обязан залогировать/пометить причину, а не молча посчитать без источника. """ if tier in region.enrichment_tiers: return None return ( f"тир '{tier}' недоступен региону {region.code} ({region.name}) — " f"оценка идёт без него; доступные тиры: " f"{sorted(region.enrichment_tiers) or 'нет ни одного'}" )