gendesign/tradein-mvp/backend/app/services/regions.py
lekss361 cef872ace1
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
Московская область в реестре регионов, обход — по специфичности вместо кода (#3501)
2026-09-12 13:59:43 +00:00

329 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Реестр регионов покрытия (#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 'нет ни одного'}"
)