All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m34s
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Deploy Trade-In / build-backend (push) Successful in 1m19s
329 lines
21 KiB
Python
329 lines
21 KiB
Python
"""Реестр регионов покрытия (#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 'нет ни одного'}"
|
||
)
|