"""Реестр регионов покрытия (#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-фильтрация фуззи-матчей провайдеров (не принять соседний город за совпадение по опечатке). bbox_wide — город + легитимное приграничье: ingest-guard координат, ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит 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). city_token — нормализованный токен главного города (нижний регистр, е==ё нормализует потребитель — matching.normalize). cities — узнаваемые города региона (для city_hint / prefix-логики геокодера). НЕ исчерпывающий список — основные центры. enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону. Регион без тира должен деградировать ЯВНО (потребитель спрашивает unsupported_tier_reason и логирует/маркирует), а не молча считать дальше без источника. """ 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] 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(), ), } DEFAULT_REGION_CODE = 66 def region_for_point(lat: float, lon: float) -> Region | None: """Регион покрытия, которому принадлежит точка (по bbox_region), или None. Регионы географически не пересекаются; порядок обхода детерминирован кодом региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает — и это станет видно в тестах реестра, а не в проде). """ for code in sorted(REGIONS): 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 — город не узнан ни одним регионом.""" 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 'нет ни одного'}" )