gendesign/tradein-mvp/backend/app/services/regions.py
lekss361 967f2b53ba
All checks were successful
Deploy Trade-In / changes (push) Successful in 23s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Границы регионов без упрощения, чтобы Куркино не считалось по Екатеринбургу (#3527)
2026-09-15 07:22:31 +00:00

588 lines
38 KiB
Python
Raw 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 у Москвы, вся мина имён — в области.
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
прямоугольнику `bbox_region` — прямоугольники Москвы и области пересекались
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
geocoder / location_index / matching.normalize, циклы недопустимы). json/
pathlib — стандартная библиотека, листовость не нарушают.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
# 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
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
#
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
# вызов region_for_point был бы дороже самого ray casting.
#
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
# областью. Сверка резолва против сохранённого region_code по проду (277 582
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
#
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
# в одно кольцо — нечётность не портится соседними частями, которые точка не
# задевает) — без явного разделения "внешний контур минус дыры".
Ring = list[tuple[float, float]]
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
coords = geometry["coordinates"]
if geometry["type"] == "Polygon":
polygons = [coords]
elif geometry["type"] == "MultiPolygon":
polygons = coords # type: ignore[assignment]
else:
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
rings: list[Ring] = []
for polygon in polygons:
for ring in polygon: # type: ignore[union-attr]
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
return rings
def _load_region_boundaries() -> dict[int, list[Ring]]:
"""code региона → плоский список колец его полигона/мультиполигона."""
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
payload = json.load(f)
return {
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
}
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
class _IndexedRing(NamedTuple):
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
Наивный ray casting линеен по числу вершин, а вершин после отказа от
упрощения в 18 раз больше (42 755 против 2 388) — прямым перебором это
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
lat_min/lat_max/lon_min/lon_max — bbox САМОГО кольца (не региона): у
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
и точка почти всегда вне bbox большинства колец — такое кольцо
отвергается четырьмя сравнениями вместо обхода вершин.
band_h/bands — кольцо нарезано на горизонтальные полосы по широте; в
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
попадает во все свои полосы, поэтому пересечения не теряются).
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
своей полосы — обход сокращается с «все вершины кольца» до «рёбра
одной полосы» (для внешнего контура области — 23 511 → максимум 724,
типично десятки).
points — те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
нужны fallback'у по расстоянию до границы в region_for_point.
"""
lat_min: float
lat_max: float
lon_min: float
lon_max: float
band_h: float
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
points: Ring
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
# ограничивает память на самых длинных кольцах (внешний контур области).
_RING_BAND_DENSITY = 4
_RING_MAX_BANDS = 1024
def _index_ring(points: Ring) -> _IndexedRing:
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
lat_min = min(p[1] for p in points)
lat_max = max(p[1] for p in points)
lat_span = lat_max - lat_min
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
x_prev, y_prev = points[-1]
for x, y in points:
edge = (x, y, x_prev, y_prev)
lo = int((min(y, y_prev) - lat_min) / band_h)
hi = int((max(y, y_prev) - lat_min) / band_h)
lo = 0 if lo < 0 else min(lo, n_bands - 1)
hi = 0 if hi < 0 else min(hi, n_bands - 1)
for band in range(lo, hi + 1):
buckets[band].append(edge)
x_prev, y_prev = x, y
return _IndexedRing(
lat_min=lat_min,
lat_max=lat_max,
lon_min=min(p[0] for p in points),
lon_max=max(p[0] for p in points),
band_h=band_h,
bands=tuple(tuple(b) for b in buckets),
points=points,
)
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
}
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
return False
band = int((lat - lat_min) / band_h)
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
band = len(bands) - 1
inside = False
for xi, yi, xj, yj in bands[band]:
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
"""True если (lat, lon) внутри настоящей границы региона `code`.
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
Региона без загруженной границы — граничит только по bbox (см. вызов в
region_for_point), сюда такой код не попадает."""
inside = False
for ring in _REGION_RING_INDEX[code]:
if _point_in_ring(ring, lon, lat):
inside = not inside
return inside
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
"""Расстояние от точки до ближайшего ребра границы региона `code`.
В градусах широты (1° ≈ 111 км) по ПЛОСКОМУ приближению с поправкой
cos(lat) на сжатие долготы: значение сравнивается только с таким же
значением другого региона-кандидата на тех же ~десятках метров, где
геодезия и плоскость расходятся на доли процента.
Путь редкий (только щель между полигонами соседних регионов, см.
region_for_point), поэтому обход честный — по всем рёбрам, без полос.
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
дальше найденного минимума, не может дать ребра ближе.
"""
lat_scale = math.cos(math.radians(lat))
px = lon * lat_scale
best = math.inf
ranked: list[tuple[float, _IndexedRing]] = []
for ring in _REGION_RING_INDEX[code]:
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
ranked.append((dx * dx + dy * dy, ring))
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
if lower_bound >= best:
break
ax, ay = ring.points[-1]
ax *= lat_scale
for bx_raw, by in ring.points:
bx = bx_raw * lat_scale
dx, dy = bx - ax, by - ay
if dx or dy:
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
ex = ax + t * dx - px
ey = ay + t * dy - lat
else: # выродившееся в точку ребро (дубль вершины в OSM)
ex, ey = ax - px, ay - lat
dist = ex * ex + ey * ey
if dist < best:
best = dist
ax, ay = bx, by
return math.sqrt(best)
# Тиры обогащения (строковые ключи — по 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() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
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) по числовому коду.
#
# #3052: сам резолв региона идёт по настоящему полигону (see
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
# отсев: 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 < 77 по числовому коду.
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
# systematic overhead на самом частом случае (Москва/область — соседи).
#
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (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:
"""Регион покрытия, которому принадлежит точка, или None (вне охвата).
Два уровня отсева на каждого кандидата (порядок — `_POINT_LOOKUP_ORDER`,
компактный bbox_region раньше обширного, см. комментарий там):
1. `is_within_bbox` по `bbox_region` — дешёвый прямоугольный предфильтр,
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
2. Полигон (`_point_in_region_polygon`) — настоящая граница, решает
результат. Если bbox прошёл, а полигон точку не принял (точка в
прямоугольнике области, но не в её реальных границах — то есть,
например, внутри вырезанной дыры Москвы), идём к следующему
кандидату, а не возвращаем None сразу.
None — ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
становится кандидат, к ГРАНИЦЕ которого точка ближе
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
как None, а тот (`trade_in.py`: `target_region.code if target_region else
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию — адрес в
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
полигонами соседних регионов неизбежны (у Москвы и области это независимо
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
общая), и закрывать их возвратом к прямоугольнику нельзя — прямоугольники
перекрываются и врут ровно там, ради чего #3052 и делался.
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
boundaries.geojson.json) — на практике до fallback-ветки дело не доходит,
но она есть, чтобы новый регион без границы не «пропадал» молча, а
работал по старому bbox-поведению до того, как для него добавят полигон.
"""
near_miss: list[int] = []
for code in _POINT_LOOKUP_ORDER:
region = REGIONS[code]
if not is_within_bbox(lat, lon, region.bbox_region):
continue
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
return region
near_miss.append(code)
if not near_miss:
return None
if len(near_miss) == 1:
return REGIONS[near_miss[0]]
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
return REGIONS[nearest]
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 'нет ни одного'}"
)