Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы #3532
4 changed files with 545 additions and 116 deletions
|
|
@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
|
||||||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||||
# без записи здесь получают `marker=None` → cross-check пропускается
|
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||||
# (fallback на bbox-only, прежнее поведение).
|
# (fallback на bbox-only, прежнее поведение).
|
||||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
|
||||||
|
# область"` для области и `state="Москва"` для города. Маркер "московская" не
|
||||||
|
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
|
||||||
|
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
|
||||||
|
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
||||||
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||||||
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||||||
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
||||||
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
|
_DADATA_REGION_NAMES: dict[int, str] = {
|
||||||
|
66: SVERDLOVSK_OBLAST_REGION,
|
||||||
|
77: "Москва",
|
||||||
|
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
|
||||||
|
50: "Московская",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dadata_region_name(region_code: int) -> str:
|
def _dadata_region_name(region_code: int) -> str:
|
||||||
|
|
@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware(
|
||||||
)
|
)
|
||||||
if city_specified:
|
if city_specified:
|
||||||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||||
# Город неизвестен — dual-query с суффиксом главного города региона
|
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
|
||||||
|
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
|
||||||
|
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
|
||||||
|
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
|
||||||
|
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
|
||||||
|
if not _ALL_REGIONS[region_code].has_city_core:
|
||||||
|
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||||
|
# Дальше — регион с ядром: dual-query с суффиксом главного города
|
||||||
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
||||||
default_city = _region_default_city(region_code)
|
default_city = _region_default_city(region_code)
|
||||||
city_data = await _nominatim_query_multi(
|
city_data = await _nominatim_query_multi(
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ class Region:
|
||||||
Регион без тира должен деградировать ЯВНО (потребитель
|
Регион без тира должен деградировать ЯВНО (потребитель
|
||||||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||||||
а не молча считать дальше без источника.
|
а не молча считать дальше без источника.
|
||||||
|
has_city_core — есть ли у региона ОДИН город-ядро, имя которого допустимо
|
||||||
|
молча подставлять в запрос геокодера, когда город не назван
|
||||||
|
(dual-query `_nominatim_query_city_aware`, ветка «город
|
||||||
|
неизвестен»). True у 66/77 (Екатеринбург / Москва — там это
|
||||||
|
majority-трафик). False у 50: у области 20 сопоставимых
|
||||||
|
городов и ~970 населённых пунктов в сырье, подстановка
|
||||||
|
«Красногорск» к «Сабурово, Луговая» — ровно та же ловушка
|
||||||
|
одноимённых улиц, от которой отказались в #2576.
|
||||||
|
|
||||||
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
||||||
строк, приходящих из источника без надёжного city-поля
|
строк, приходящих из источника без надёжного city-поля
|
||||||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||||
|
|
@ -92,6 +101,7 @@ class Region:
|
||||||
cities: frozenset[str]
|
cities: frozenset[str]
|
||||||
enrichment_tiers: frozenset[str]
|
enrichment_tiers: frozenset[str]
|
||||||
canonical_city: str | None = None
|
canonical_city: str | None = None
|
||||||
|
has_city_core: bool = True
|
||||||
|
|
||||||
|
|
||||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||||
|
|
@ -460,6 +470,10 @@ REGIONS: dict[int, Region] = {
|
||||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||||
# нечего и незачем, в отличие от 77.
|
# нечего и незачем, в отличие от 77.
|
||||||
canonical_city=None,
|
canonical_city=None,
|
||||||
|
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
|
||||||
|
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
|
||||||
|
# Красногорске, и в Сабурово, и ещё в десятке НП области.
|
||||||
|
has_city_core=False,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
@ -66,11 +67,13 @@ from sqlalchemy.orm import Session
|
||||||
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
||||||
try:
|
try:
|
||||||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||||||
|
from app.services.regions import REGIONS # type: ignore[import-not-found]
|
||||||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.regions import REGIONS
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
|
||||||
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
||||||
_MIN_KEY_LEN = 3
|
_MIN_KEY_LEN = 3
|
||||||
|
|
||||||
|
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
|
||||||
|
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
|
||||||
|
# без флага вели себя ровно как раньше.
|
||||||
|
_DEFAULT_REGION_CODE = 66
|
||||||
|
|
||||||
|
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
|
||||||
|
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
|
||||||
|
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
|
||||||
|
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
|
||||||
|
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
|
||||||
|
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
|
||||||
|
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
|
||||||
|
# сделка, посаженная в поле между Клином и Серпуховом.
|
||||||
|
_DEFAULT_MAX_SPREAD_KM = 5.0
|
||||||
|
|
||||||
|
# Классификация comma-сегмента адреса (см. `_split_place_street`).
|
||||||
|
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
|
||||||
|
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
|
||||||
|
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
|
||||||
|
_LOCALITY_TYPE_RE = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
|
||||||
|
r"|п|пос|поселок|посёлок|п/ст"
|
||||||
|
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
|
||||||
|
r")\.?\s+(?=[а-яё])",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
|
||||||
|
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
|
||||||
|
_NUMERIC_SEGMENT_RE = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
|
||||||
|
r"|пом|помещение|оф|офис|бокс|гараж"
|
||||||
|
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
|
||||||
|
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
|
||||||
|
# принято за номер дома и улица потерялась бы целиком.
|
||||||
|
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Street-key normalization — the crux of match rate
|
# Street-key normalization — the crux of match rate
|
||||||
|
|
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
|
||||||
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
||||||
flags=re.UNICODE,
|
flags=re.UNICODE,
|
||||||
),
|
),
|
||||||
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
|
|
||||||
re.compile(r"^екатеринбург$", flags=re.UNICODE),
|
|
||||||
]
|
]
|
||||||
|
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
|
||||||
|
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
|
||||||
|
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
|
||||||
|
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
|
||||||
|
|
||||||
# Street-type token at the START of the street segment. Stripped because
|
# Street-type token at the START of the street segment. Stripped because
|
||||||
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
||||||
|
|
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
|
||||||
return " ".join(keep).strip()
|
return " ".join(keep).strip()
|
||||||
|
|
||||||
|
|
||||||
def _street_key(address: str | None) -> str:
|
def _is_admin_segment(seg: str) -> bool:
|
||||||
"""Reduce any address to a bare-street-name key for the centroid join.
|
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
|
||||||
|
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
|
||||||
|
|
||||||
Both sides must produce the SAME key or the join under-matches:
|
|
||||||
'Екатеринбург, ул. Малышева, 125' → 'малышева'
|
|
||||||
'г Екатеринбург, улица Малышева' → 'малышева'
|
|
||||||
'Екатеринбург, Малышева' → 'малышева'
|
|
||||||
'Свердловская обл., Екатеринбург, ул. Большакова, 17' → 'большакова'
|
|
||||||
'улица Яскина, 12 · р-н Октябрьский' → 'яскина'
|
|
||||||
'г. Екатеринбург, проспект Ленина, 50' → 'ленина'
|
|
||||||
'Екатеринбург, ул. 8 Марта, 100' → '8 марта'
|
|
||||||
|
|
||||||
Steps:
|
def _is_numeric_segment(seg: str) -> bool:
|
||||||
1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage).
|
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
|
||||||
2. Drop a trailing district marker (' · р-н ...', ' | ...').
|
return bool(_NUMERIC_SEGMENT_RE.match(seg))
|
||||||
3. Split on commas; drop leading segments that are admin chunks
|
|
||||||
(Россия / region / district / город / Екатеринбург). The first
|
|
||||||
non-admin segment is the street segment.
|
|
||||||
4. Drop apartment/corpus/строение noise inside that segment.
|
|
||||||
5. Strip the street-type token at the start (ул/улица/проспект/...).
|
|
||||||
6. Strip the trailing house number, preserving numeric street names.
|
|
||||||
7. Collapse whitespace.
|
|
||||||
|
|
||||||
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
|
|
||||||
"""
|
def _normalize_place(value: str | None) -> str:
|
||||||
if not address:
|
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
|
||||||
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
|
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
|
||||||
|
v = _WS_RE.sub(" ", v).strip()
|
||||||
|
v = _LOCALITY_TYPE_RE.sub("", v)
|
||||||
|
return _WS_RE.sub(" ", v).strip(" .,")
|
||||||
|
|
||||||
|
|
||||||
|
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
|
||||||
|
"""Имя НП, если сегмент — населённый пункт, иначе None.
|
||||||
|
|
||||||
|
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») — имя берём
|
||||||
|
как есть; ИЛИ голое имя, которое реестр региона знает как город
|
||||||
|
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
|
||||||
|
НП — иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
|
||||||
|
ловит позиционный fallback в `_split_place_street`.
|
||||||
|
"""
|
||||||
|
m = _LOCALITY_TYPE_RE.match(seg)
|
||||||
|
if m:
|
||||||
|
name = seg[m.end() :]
|
||||||
|
else:
|
||||||
|
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
|
||||||
|
return None
|
||||||
|
if _WS_RE.sub(" ", seg).strip() not in cities:
|
||||||
|
return None
|
||||||
|
name = seg
|
||||||
|
name = _WS_RE.sub(" ", name).strip(" .,")
|
||||||
|
return name or None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_place_street(
|
||||||
|
address: str | None, region_code: int = _DEFAULT_REGION_CODE
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
|
||||||
|
|
||||||
|
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
|
||||||
|
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
|
||||||
|
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
|
||||||
|
области — тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
|
||||||
|
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
|
||||||
|
одно ведро, а среднее их координат — правдоподобная точка ПОСЕРЕДИНЕ между
|
||||||
|
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
|
||||||
|
замечает: координата валидная, внутри области, рядом есть дома.
|
||||||
|
|
||||||
|
Разбор:
|
||||||
|
1. NFC, lower, ё→е, снять хвост-район (' · р-н ...', ' | ...').
|
||||||
|
2. Порезать на comma-сегменты, выбросить страну/регион/район.
|
||||||
|
3. Первый сегмент-НП (`_locality_name`) → place, улицу ищем ПОСЛЕ него.
|
||||||
|
4. Улица — первый не-числовой сегмент из остатка.
|
||||||
|
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» — ровно
|
||||||
|
формат deals.address по области): если НП не нашёлся, а не-числовых
|
||||||
|
сегментов >= 2 и первый не начинается с типа улицы — первый считается
|
||||||
|
НП, второй улицей.
|
||||||
|
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
|
||||||
|
|
||||||
|
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
|
||||||
|
"""
|
||||||
|
cities = REGIONS[region_code].cities
|
||||||
|
if not address:
|
||||||
|
return "", ""
|
||||||
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
||||||
s = _WS_RE.sub(" ", s).strip()
|
s = _WS_RE.sub(" ", s).strip()
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return "", ""
|
||||||
|
|
||||||
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
|
|
||||||
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
||||||
|
|
||||||
# 3. Split on commas, drop leading admin segments. Each segment is matched
|
|
||||||
# whole, so a non-admin street segment is never partially eaten.
|
|
||||||
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
||||||
street_seg = ""
|
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
|
||||||
for seg in segments:
|
if not non_admin:
|
||||||
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
|
return "", ""
|
||||||
continue
|
|
||||||
street_seg = seg
|
place = ""
|
||||||
break
|
rest = non_admin
|
||||||
|
for i, seg in enumerate(non_admin):
|
||||||
|
name = _locality_name(seg, cities)
|
||||||
|
if name:
|
||||||
|
place = name
|
||||||
|
rest = non_admin[i + 1 :]
|
||||||
|
break
|
||||||
|
|
||||||
|
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
|
||||||
|
|
||||||
|
if not place:
|
||||||
|
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
|
||||||
|
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
|
||||||
|
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
|
||||||
|
street_seg = usable[1]
|
||||||
|
|
||||||
if not street_seg:
|
if not street_seg:
|
||||||
return ""
|
return place, ""
|
||||||
|
|
||||||
# 4. Drop apartment/corpus/строение noise inside the street segment.
|
|
||||||
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
# 5. Strip the street-type token if present.
|
|
||||||
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
|
|
||||||
street_seg = _strip_house_tail(street_seg)
|
street_seg = _strip_house_tail(street_seg)
|
||||||
|
return place, _WS_RE.sub(" ", street_seg).strip()
|
||||||
|
|
||||||
return _WS_RE.sub(" ", street_seg).strip()
|
|
||||||
|
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
|
||||||
|
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
|
||||||
|
(одноимённые улицы разных НП) — см. `_address_key`; оставлена для отчётов
|
||||||
|
и тестов нормализации улицы.
|
||||||
|
|
||||||
|
Примеры (region 66):
|
||||||
|
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
|
||||||
|
'г Екатеринбург, улица Малышева' -> 'малышева'
|
||||||
|
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
|
||||||
|
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
|
||||||
|
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
|
||||||
|
"""
|
||||||
|
return _split_place_street(address, region_code)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _address_key(
|
||||||
|
address: str | None,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
city: str | None = None,
|
||||||
|
) -> tuple[int, str, str] | None:
|
||||||
|
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
|
||||||
|
|
||||||
|
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
|
||||||
|
переопределяет НП, разобранный из адреса.
|
||||||
|
|
||||||
|
Пустой НП: у региона С городом-ядром (66) подставляется `city_token` —
|
||||||
|
ровно историческое поведение «всё, что без города, это Екатеринбург». У
|
||||||
|
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
|
||||||
|
распознанного НП лучше останется без координат, чем сядет в случайный
|
||||||
|
город области.
|
||||||
|
"""
|
||||||
|
place, street = _split_place_street(address, region_code)
|
||||||
|
from_city = _normalize_place(city)
|
||||||
|
if from_city:
|
||||||
|
place = from_city
|
||||||
|
if len(street) < _MIN_KEY_LEN:
|
||||||
|
return None
|
||||||
|
if not place:
|
||||||
|
region = REGIONS[region_code]
|
||||||
|
if not region.has_city_core:
|
||||||
|
return None
|
||||||
|
place = region.city_token
|
||||||
|
if len(place) < _MIN_KEY_LEN:
|
||||||
|
return None
|
||||||
|
return (region_code, place, street)
|
||||||
|
|
||||||
|
|
||||||
|
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
|
||||||
|
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
|
||||||
|
|
||||||
|
Проекция плоская — на масштабе одного НП (единицы километров) ошибка
|
||||||
|
сотые доли процента, а формула дешевле haversine на каждом из десятков
|
||||||
|
тысяч домов.
|
||||||
|
"""
|
||||||
|
worst = 0.0
|
||||||
|
cos_lat = math.cos(math.radians(lat_c))
|
||||||
|
for lat, lon in points:
|
||||||
|
dy = (lat - lat_c) * 111.32
|
||||||
|
dx = (lon - lon_c) * 111.32 * cos_lat
|
||||||
|
worst = max(worst, math.hypot(dx, dy))
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Centroid:
|
class Centroid:
|
||||||
"""One street's centroid, averaged over all geocoded houses on it."""
|
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
|
||||||
|
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
house_count: int
|
house_count: int
|
||||||
|
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
|
||||||
|
spread_km: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -266,6 +427,9 @@ class DealRow:
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
address: str | None
|
address: str | None
|
||||||
|
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
|
||||||
|
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
|
||||||
|
city: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -276,6 +440,8 @@ class Stats:
|
||||||
geocoded: int = 0
|
geocoded: int = 0
|
||||||
no_street_match: int = 0
|
no_street_match: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
|
||||||
|
dropped_spread: int = 0
|
||||||
# street_key → count of deals that had no house centroid (dry-run report).
|
# street_key → count of deals that had no house centroid (dry-run report).
|
||||||
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
||||||
|
|
||||||
|
|
@ -285,14 +451,28 @@ class Stats:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
def _build_centroid_map(
|
||||||
"""Per-street centroid from houses WHERE geom IS NOT NULL.
|
db: Session,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
|
||||||
|
) -> dict[tuple[int, str, str], Centroid]:
|
||||||
|
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
|
||||||
|
|
||||||
We read raw (address, lat, lon) and aggregate in Python so the street-key
|
Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
|
||||||
derivation is the SAME code path as the deals side — pushing it into SQL
|
и прогон по одному региону тихо тащит чужие координаты.
|
||||||
would require duplicating the regex logic in plpgsql and risk drift.
|
|
||||||
8,600 rows is trivial to hold in memory.
|
`houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
|
||||||
|
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
|
||||||
|
поэтому строка без региона принимается по географии — если её координаты
|
||||||
|
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
|
||||||
|
происхождении строки.
|
||||||
|
|
||||||
|
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
|
||||||
|
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
|
||||||
|
дрейф.
|
||||||
"""
|
"""
|
||||||
|
region = REGIONS[region_code]
|
||||||
|
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
||||||
" AND lat IS NOT NULL "
|
" AND lat IS NOT NULL "
|
||||||
" AND lon IS NOT NULL "
|
" AND lon IS NOT NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0"
|
" AND length(trim(address)) > 0 "
|
||||||
)
|
" AND ( region_code = CAST(:rc AS smallint) "
|
||||||
|
" OR ( region_code IS NULL "
|
||||||
|
" AND lat BETWEEN CAST(:lat_min AS double precision) "
|
||||||
|
" AND CAST(:lat_max AS double precision) "
|
||||||
|
" AND lon BETWEEN CAST(:lon_min AS double precision) "
|
||||||
|
" AND CAST(:lon_max AS double precision) ) )"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"rc": region_code,
|
||||||
|
"lat_min": lat_min,
|
||||||
|
"lat_max": lat_max,
|
||||||
|
"lon_min": lon_min,
|
||||||
|
"lon_max": lon_max,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# street_key → running [lat_sum, lon_sum, n]
|
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
|
||||||
acc: dict[str, list[float]] = {}
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
key = _street_key(r["address"])
|
key = _address_key(r["address"], region_code)
|
||||||
if len(key) < _MIN_KEY_LEN:
|
if key is None:
|
||||||
continue
|
continue
|
||||||
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
|
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
|
||||||
bucket[0] += float(r["lat"])
|
|
||||||
bucket[1] += float(r["lon"])
|
|
||||||
bucket[2] += 1.0
|
|
||||||
|
|
||||||
return {
|
out: dict[tuple[int, str, str], Centroid] = {}
|
||||||
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
|
dropped = 0
|
||||||
for key, (lat_sum, lon_sum, n) in acc.items()
|
for key, points in acc.items():
|
||||||
}
|
n = len(points)
|
||||||
|
lat_c = sum(lat for lat, _ in points) / n
|
||||||
|
lon_c = sum(lon for _, lon in points) / n
|
||||||
|
spread = _spread_km(points, lat_c, lon_c)
|
||||||
|
if spread > max_spread_km:
|
||||||
|
# Одна «улица» шириной в десятки километров — это не улица, а
|
||||||
|
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
|
||||||
|
# двумя способами). Среднее таких точек — координата в поле между
|
||||||
|
# городами; отдавать её сделке нельзя, ведро выбрасывается.
|
||||||
|
dropped += 1
|
||||||
|
logger.warning(
|
||||||
|
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
|
||||||
|
key,
|
||||||
|
n,
|
||||||
|
spread,
|
||||||
|
max_spread_km,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
|
||||||
|
|
||||||
|
if dropped:
|
||||||
|
logger.warning(
|
||||||
|
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
|
||||||
|
dropped,
|
||||||
|
len(acc),
|
||||||
|
max_spread_km,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
def _region_predicate(column: str = "region_code") -> str:
|
||||||
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
|
"""SQL-предикат «строка принадлежит региону :rc» для deals.
|
||||||
|
|
||||||
|
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
|
||||||
|
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
|
||||||
|
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
|
||||||
|
и только ему. Для любого другого региона NULL — не кандидат.
|
||||||
|
"""
|
||||||
|
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_deals_without_coords(
|
||||||
|
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
|
||||||
|
) -> list[DealRow]:
|
||||||
|
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
|
||||||
|
|
||||||
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
||||||
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
||||||
|
|
@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, address "
|
"SELECT id, address, city "
|
||||||
"FROM deals "
|
"FROM deals "
|
||||||
"WHERE lat IS NULL "
|
"WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0 "
|
" AND length(trim(address)) > 0 "
|
||||||
|
" AND " + _region_predicate() + " "
|
||||||
"ORDER BY id "
|
"ORDER BY id "
|
||||||
"LIMIT CAST(:lim AS int)"
|
"LIMIT CAST(:lim AS int)"
|
||||||
),
|
),
|
||||||
{"lim": limit},
|
{"lim": limit, "rc": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
|
return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None:
|
def _update_deal_coords(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
deal_id: int,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
) -> None:
|
||||||
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
||||||
|
|
||||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||||
|
|
@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
||||||
" SET lat = CAST(:lat AS double precision), "
|
" SET lat = CAST(:lat AS double precision), "
|
||||||
" lon = CAST(:lon AS double precision), "
|
" lon = CAST(:lon AS double precision), "
|
||||||
" geocode_tried_at = NOW() "
|
" geocode_tried_at = NOW() "
|
||||||
" WHERE id = CAST(:id AS bigint)"
|
" WHERE id = CAST(:id AS bigint) "
|
||||||
|
" AND " + _region_predicate()
|
||||||
),
|
),
|
||||||
{"id": deal_id, "lat": lat, "lon": lon},
|
{"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
||||||
def _run_backfill(
|
def _run_backfill(
|
||||||
db: Session,
|
db: Session,
|
||||||
deals: list[DealRow],
|
deals: list[DealRow],
|
||||||
centroids: dict[str, Centroid],
|
centroids: dict[tuple[int, str, str], Centroid],
|
||||||
*,
|
*,
|
||||||
batch: str,
|
batch: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
) -> Stats:
|
) -> Stats:
|
||||||
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
||||||
|
|
||||||
|
|
@ -399,14 +638,14 @@ def _run_backfill(
|
||||||
stats = Stats()
|
stats = Stats()
|
||||||
|
|
||||||
for i, deal in enumerate(deals, start=1):
|
for i, deal in enumerate(deals, start=1):
|
||||||
key = _street_key(deal.address)
|
key = _address_key(deal.address, region_code, city=deal.city)
|
||||||
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
|
centroid = centroids.get(key) if key is not None else None
|
||||||
|
|
||||||
if centroid is None:
|
if centroid is None:
|
||||||
stats.no_street_match += 1
|
stats.no_street_match += 1
|
||||||
# Track the raw key (or a sentinel) so the dry-run report can show
|
# Track the raw key (or a sentinel) so the dry-run report can show
|
||||||
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
||||||
stats.unmatched_streets[key or "<no-street-parsed>"] += 1
|
stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
|
||||||
stats.processed += 1
|
stats.processed += 1
|
||||||
if dry_run and i % _LOG_EVERY == 0:
|
if dry_run and i % _LOG_EVERY == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -423,7 +662,13 @@ def _run_backfill(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
|
_update_deal_coords(
|
||||||
|
db,
|
||||||
|
deal_id=deal.id,
|
||||||
|
lat=centroid.lat,
|
||||||
|
lon=centroid.lon,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
# Per-row commit so resume picks up exactly where we crashed.
|
# Per-row commit so resume picks up exactly where we crashed.
|
||||||
db.commit()
|
db.commit()
|
||||||
stats.geocoded += 1
|
stats.geocoded += 1
|
||||||
|
|
@ -454,7 +699,7 @@ def _run_backfill(
|
||||||
|
|
||||||
def _report_dry_run(
|
def _report_dry_run(
|
||||||
stats: Stats,
|
stats: Stats,
|
||||||
centroids: dict[str, Centroid],
|
centroids: dict[tuple[int, str, str], Centroid],
|
||||||
*,
|
*,
|
||||||
total_deals_null: int,
|
total_deals_null: int,
|
||||||
candidates: int,
|
candidates: int,
|
||||||
|
|
@ -475,7 +720,7 @@ def _report_dry_run(
|
||||||
|
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
||||||
logger.info("distinct streets with a house centroid: %d", distinct_streets)
|
logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
|
||||||
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
||||||
logger.info("deals matched to a centroid: %d", matched)
|
logger.info("deals matched to a centroid: %d", matched)
|
||||||
logger.info("deals with no street match: %d", stats.no_street_match)
|
logger.info("deals with no street match: %d", stats.no_street_match)
|
||||||
|
|
@ -486,7 +731,7 @@ def _report_dry_run(
|
||||||
projected,
|
projected,
|
||||||
match_rate * 100.0,
|
match_rate * 100.0,
|
||||||
)
|
)
|
||||||
logger.info("top-10 unmatched deal streets (by row count):")
|
logger.info("top-10 unmatched deal keys НП/улица (by row count):")
|
||||||
for street, cnt in stats.unmatched_streets.most_common(10):
|
for street, cnt in stats.unmatched_streets.most_common(10):
|
||||||
logger.info(" %6d %s", cnt, street)
|
logger.info(" %6d %s", cnt, street)
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
|
|
@ -497,13 +742,15 @@ def _report_dry_run(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _count_deals_null(db: Session) -> int:
|
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
|
||||||
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
|
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT count(*) AS n FROM deals "
|
"SELECT count(*) AS n FROM deals "
|
||||||
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
|
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 "
|
||||||
)
|
" AND " + _region_predicate()
|
||||||
|
),
|
||||||
|
{"rc": region_code},
|
||||||
).first()
|
).first()
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
default=f"deals_geo_{date.today().isoformat()}",
|
default=f"deals_geo_{date.today().isoformat()}",
|
||||||
help="Log batch label (does not affect DB filters — logs only).",
|
help="Log batch label (does not affect DB filters — logs only).",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--region-code",
|
||||||
|
type=int,
|
||||||
|
default=_DEFAULT_REGION_CODE,
|
||||||
|
choices=sorted(REGIONS),
|
||||||
|
help=(
|
||||||
|
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
|
||||||
|
"строки этого региона; входит в ключ join'а."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--max-spread-km",
|
||||||
|
type=float,
|
||||||
|
default=_DEFAULT_MAX_SPREAD_KM,
|
||||||
|
help=(
|
||||||
|
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
|
||||||
|
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
|
||||||
|
),
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point. Returns the number of deals geocoded this run."""
|
"""CLI entry point. Returns the number of deals geocoded this run."""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
logger.info(
|
logger.info(
|
||||||
"starting batch=%s limit=%s dry_run=%s",
|
"starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
|
||||||
args.batch,
|
args.batch,
|
||||||
|
args.region_code,
|
||||||
args.limit,
|
args.limit,
|
||||||
|
args.max_spread_km,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(
|
||||||
logger.info("built centroid map: %d distinct streets", len(centroids))
|
db, region_code=args.region_code, max_spread_km=args.max_spread_km
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
|
||||||
|
)
|
||||||
if not centroids:
|
if not centroids:
|
||||||
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
|
logger.warning(
|
||||||
|
"no house centroids for region %s — houses has no geocoded rows there; "
|
||||||
|
"nothing to do",
|
||||||
|
args.region_code,
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
deals = _select_deals_without_coords(db, args.limit)
|
deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
|
||||||
logger.info("loaded deals without coords: %d", len(deals))
|
logger.info("loaded deals without coords: %d", len(deals))
|
||||||
if not deals:
|
if not deals:
|
||||||
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
|
stats = _run_backfill(
|
||||||
|
db,
|
||||||
|
deals,
|
||||||
|
centroids,
|
||||||
|
batch=args.batch,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
region_code=args.region_code,
|
||||||
|
)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
total_null = _count_deals_null(db)
|
total_null = _count_deals_null(db, region_code=args.region_code)
|
||||||
_report_dry_run(
|
_report_dry_run(
|
||||||
stats,
|
stats,
|
||||||
centroids,
|
centroids,
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
|
||||||
Centroid,
|
Centroid,
|
||||||
DealRow,
|
DealRow,
|
||||||
Stats,
|
Stats,
|
||||||
|
_address_key,
|
||||||
_build_centroid_map,
|
_build_centroid_map,
|
||||||
_run_backfill,
|
_run_backfill,
|
||||||
|
_select_deals_without_coords,
|
||||||
_street_key,
|
_street_key,
|
||||||
_update_deal_coords,
|
_update_deal_coords,
|
||||||
main,
|
main,
|
||||||
|
|
@ -178,16 +180,16 @@ def test_build_centroid_map_averages_houses_on_one_street():
|
||||||
"""Two houses on Малышева → centroid is the mean of their coords."""
|
"""Two houses on Малышева → centroid is the mean of their coords."""
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
|
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54},
|
||||||
]
|
]
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {"малышева"}
|
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
||||||
c = centroids["малышева"]
|
c = centroids[(66, "екатеринбург", "малышева")]
|
||||||
assert c.lat == pytest.approx(56.85)
|
assert c.lat == pytest.approx(56.81)
|
||||||
assert c.lon == pytest.approx(60.60)
|
assert c.lon == pytest.approx(60.52)
|
||||||
assert c.house_count == 2
|
assert c.house_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {"малышева", "ленина"}
|
assert set(centroids) == {
|
||||||
assert centroids["малышева"].house_count == 2
|
(66, "екатеринбург", "малышева"),
|
||||||
assert centroids["ленина"].house_count == 1
|
(66, "екатеринбург", "ленина"),
|
||||||
|
}
|
||||||
|
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
|
||||||
|
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
|
||||||
# Малышева centroid = mean of the two Малышева rows.
|
# Малышева centroid = mean of the two Малышева rows.
|
||||||
assert centroids["малышева"].lat == pytest.approx(56.81)
|
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
|
||||||
assert centroids["ленина"].lat == pytest.approx(56.84)
|
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
|
||||||
|
|
||||||
|
|
||||||
def test_build_centroid_map_skips_unparseable_address():
|
def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
@ -218,7 +223,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
assert set(centroids) == {"малышева"}
|
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -228,7 +233,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
||||||
def test_run_backfill_matched_street_issues_update_with_centroid():
|
def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
||||||
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
||||||
|
|
@ -247,7 +252,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
def test_run_backfill_deal_with_only_street_name_matches():
|
def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
"""Deal address with no house number / no type word still matches."""
|
"""Deal address with no house number / no type word still matches."""
|
||||||
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -264,7 +269,7 @@ def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
|
|
||||||
def test_run_backfill_unmatched_street_no_update():
|
def test_run_backfill_unmatched_street_no_update():
|
||||||
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -275,13 +280,13 @@ def test_run_backfill_unmatched_street_no_update():
|
||||||
assert updated == []
|
assert updated == []
|
||||||
assert db.commit.call_count == 0
|
assert db.commit.call_count == 0
|
||||||
# Unmatched street tracked for the dry-run report.
|
# Unmatched street tracked for the dry-run report.
|
||||||
assert stats.unmatched_streets["несуществующая"] == 1
|
assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
||||||
deal = DealRow(id=5, address="Екатеринбург")
|
deal = DealRow(id=5, address="Екатеринбург")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -298,7 +303,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
|
|
||||||
def test_run_backfill_dry_run_issues_no_update():
|
def test_run_backfill_dry_run_issues_no_update():
|
||||||
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
||||||
|
|
@ -319,7 +324,7 @@ def test_run_backfill_db_write_failure_isolated_to_row():
|
||||||
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
||||||
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
||||||
]
|
]
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
# Make the FIRST UPDATE raise, the rest succeed.
|
# Make the FIRST UPDATE raise, the rest succeed.
|
||||||
|
|
@ -359,7 +364,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
assert "geocode_tried_at = NOW()" in sql_str
|
assert "geocode_tried_at = NOW()" in sql_str
|
||||||
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
||||||
assert "geom" not in sql_str
|
assert "geom" not in sql_str
|
||||||
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}
|
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -370,7 +375,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
def test_main_respects_limit_and_returns_geocoded():
|
def test_main_respects_limit_and_returns_geocoded():
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
|
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54},
|
||||||
]
|
]
|
||||||
deal_rows = [
|
deal_rows = [
|
||||||
{"id": 1, "address": "Екатеринбург, Малышева"},
|
{"id": 1, "address": "Екатеринбург, Малышева"},
|
||||||
|
|
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
|
||||||
assert len(updated) == 1
|
assert len(updated) == 1
|
||||||
assert updated[0]["id"] == 1
|
assert updated[0]["id"] == 1
|
||||||
# Centroid used = mean of the two house rows.
|
# Centroid used = mean of the two house rows.
|
||||||
assert updated[0]["lat"] == pytest.approx(56.85)
|
assert updated[0]["lat"] == pytest.approx(56.81)
|
||||||
assert updated[0]["lon"] == pytest.approx(60.60)
|
assert updated[0]["lon"] == pytest.approx(60.52)
|
||||||
|
|
||||||
|
|
||||||
def test_main_dry_run_writes_nothing():
|
def test_main_dry_run_writes_nothing():
|
||||||
|
|
@ -432,3 +437,114 @@ def test_stats_unmatched_streets_counter_defaults_empty():
|
||||||
assert s.no_street_match == 0
|
assert s.no_street_match == 0
|
||||||
assert s.failed == 0
|
assert s.failed == 0
|
||||||
assert s.unmatched_streets.most_common(3) == []
|
assert s.unmatched_streets.most_common(3) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_distinguishes_same_street_in_different_localities():
|
||||||
|
"""«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи.
|
||||||
|
|
||||||
|
Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы
|
||||||
|
сливались в одно ведро 'луговая', и центроид садился между городами.
|
||||||
|
"""
|
||||||
|
saburovo = _address_key("Сабурово, Луговая", 50)
|
||||||
|
krasnogorsk = _address_key("Красногорск, Луговая", 50)
|
||||||
|
assert saburovo == (50, "сабурово", "луговая")
|
||||||
|
assert krasnogorsk == (50, "красногорск", "луговая")
|
||||||
|
assert saburovo != krasnogorsk
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_distinguishes_same_street_in_different_regions():
|
||||||
|
"""Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге."""
|
||||||
|
assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина")
|
||||||
|
assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина")
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_region_without_city_core_refuses_nameless_locality():
|
||||||
|
"""У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»)."""
|
||||||
|
assert _address_key("Луговая, 5", 50) is None
|
||||||
|
# У 66 город-ядро есть — историческое поведение сохранено.
|
||||||
|
assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_centroid_map_separates_same_street_of_two_localities():
|
||||||
|
"""Дома с одноимённых улиц двух НП не усредняются в одну точку."""
|
||||||
|
house_rows = [
|
||||||
|
{"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60},
|
||||||
|
{"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61},
|
||||||
|
{"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43},
|
||||||
|
]
|
||||||
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
|
centroids = _build_centroid_map(db, region_code=50)
|
||||||
|
|
||||||
|
assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")}
|
||||||
|
assert centroids[(50, "сабурово", "луговая")].house_count == 2
|
||||||
|
assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405)
|
||||||
|
assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_centroid_map_drops_bucket_with_huge_spread():
|
||||||
|
"""Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком."""
|
||||||
|
house_rows = [
|
||||||
|
{"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43},
|
||||||
|
# Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП.
|
||||||
|
{"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20},
|
||||||
|
]
|
||||||
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
|
assert _build_centroid_map(db, region_code=50) == {}
|
||||||
|
# Порог поднят выше разброса → ведро остаётся.
|
||||||
|
kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0)
|
||||||
|
assert set(kept) == {(50, "химки", "ленина")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_centroid_query_filters_by_region():
|
||||||
|
"""Карта центроидов строится по домам ОДНОГО региона, а не по всем."""
|
||||||
|
db, _ = _make_db_mock(house_rows=[])
|
||||||
|
|
||||||
|
_build_centroid_map(db, region_code=50)
|
||||||
|
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
sql_str = str(args[0])
|
||||||
|
assert "FROM houses" in sql_str
|
||||||
|
assert "region_code = CAST(:rc AS smallint)" in sql_str
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_select_and_update_are_scoped_to_one_region():
|
||||||
|
"""Прогон по региону 50 не выбирает и не обновляет строки другого региона."""
|
||||||
|
db, _unused = _make_db_mock(
|
||||||
|
deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
deals = _select_deals_without_coords(db, 10, region_code=50)
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
select_sql = str(args[0])
|
||||||
|
assert "FROM deals" in select_sql
|
||||||
|
assert "region_code = CAST(:rc AS int)" in select_sql
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
assert deals[0].city == "Сабурово"
|
||||||
|
|
||||||
|
_update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50)
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
update_sql = str(args[0])
|
||||||
|
assert "UPDATE deals" in update_sql
|
||||||
|
assert "region_code = CAST(:rc AS int)" in update_sql
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_backfill_uses_deal_city_column_for_the_locality():
|
||||||
|
"""deals.city (заполнена на 100% по области) переопределяет НП из адреса."""
|
||||||
|
centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)}
|
||||||
|
db, updated = _make_db_mock()
|
||||||
|
deals = [DealRow(id=11, address="Луговая", city="Сабурово")]
|
||||||
|
|
||||||
|
stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50)
|
||||||
|
|
||||||
|
assert stats.geocoded == 1
|
||||||
|
assert updated[0]["lat"] == pytest.approx(55.4)
|
||||||
|
assert updated[0]["rc"] == 50
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue