Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы (#3532)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
Центроид строился по названию улицы без города — в области это схлопывало одноимённые улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ у области отбрасывается, а не склеивается с чужим городом. Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый --max-spread-km (5 км) отбрасывает бакет с разбросанными домами. geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва», DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области не приклеивался суффикс главного города).
This commit is contained in:
parent
39996ef3fd
commit
ce60fc214d
4 changed files with 545 additions and 116 deletions
|
|
@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
|
|||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||
# (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__)
|
||||
|
||||
|
|
@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
|||
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||||
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||||
# поэтому отдельная карта — по образцу `_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:
|
||||
|
|
@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware(
|
|||
)
|
||||
if city_specified:
|
||||
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`).
|
||||
default_city = _region_default_city(region_code)
|
||||
city_data = await _nominatim_query_multi(
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ class Region:
|
|||
Регион без тира должен деградировать ЯВНО (потребитель
|
||||
спрашивает 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`
|
||||
строк, приходящих из источника без надёжного city-поля
|
||||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||
|
|
@ -92,6 +101,7 @@ class Region:
|
|||
cities: frozenset[str]
|
||||
enrichment_tiers: frozenset[str]
|
||||
canonical_city: str | None = None
|
||||
has_city_core: bool = True
|
||||
|
||||
|
||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||
|
|
@ -460,6 +470,10 @@ REGIONS: dict[int, Region] = {
|
|||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||
# нечего и незачем, в отличие от 77.
|
||||
canonical_city=None,
|
||||
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
|
||||
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
|
||||
# Красногорске, и в Сабурово, и ещё в десятке НП области.
|
||||
has_city_core=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
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.
|
||||
try:
|
||||
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
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.regions import REGIONS
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
|
|||
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
||||
_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
|
||||
|
|
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
|
|||
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
||||
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
|
||||
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
||||
|
|
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
|
|||
return " ".join(keep).strip()
|
||||
|
||||
|
||||
def _street_key(address: str | None) -> str:
|
||||
"""Reduce any address to a bare-street-name key for the centroid join.
|
||||
def _is_admin_segment(seg: str) -> bool:
|
||||
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
|
||||
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:
|
||||
1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage).
|
||||
2. Drop a trailing district marker (' · р-н ...', ' | ...').
|
||||
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.
|
||||
def _is_numeric_segment(seg: str) -> bool:
|
||||
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
|
||||
return bool(_NUMERIC_SEGMENT_RE.match(seg))
|
||||
|
||||
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
|
||||
"""
|
||||
if not address:
|
||||
|
||||
def _normalize_place(value: str | None) -> str:
|
||||
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
|
||||
if not value:
|
||||
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 = _WS_RE.sub(" ", s).strip()
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
|
||||
return "", ""
|
||||
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()]
|
||||
street_seg = ""
|
||||
for seg in segments:
|
||||
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
|
||||
continue
|
||||
street_seg = seg
|
||||
break
|
||||
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
|
||||
if not non_admin:
|
||||
return "", ""
|
||||
|
||||
place = ""
|
||||
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:
|
||||
return ""
|
||||
return place, ""
|
||||
|
||||
# 4. Drop apartment/corpus/строение noise inside the street segment.
|
||||
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()
|
||||
|
||||
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
|
||||
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
|
||||
class Centroid:
|
||||
"""One street's centroid, averaged over all geocoded houses on it."""
|
||||
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
|
||||
|
||||
lat: float
|
||||
lon: float
|
||||
house_count: int
|
||||
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
|
||||
spread_km: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -266,6 +427,9 @@ class DealRow:
|
|||
|
||||
id: int
|
||||
address: str | None
|
||||
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
|
||||
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
|
||||
city: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -276,6 +440,8 @@ class Stats:
|
|||
geocoded: int = 0
|
||||
no_street_match: int = 0
|
||||
failed: int = 0
|
||||
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
|
||||
dropped_spread: int = 0
|
||||
# street_key → count of deals that had no house centroid (dry-run report).
|
||||
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
||||
|
||||
|
|
@ -285,14 +451,28 @@ class Stats:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
||||
"""Per-street centroid from houses WHERE geom IS NOT NULL.
|
||||
def _build_centroid_map(
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
|||
" AND lat IS NOT NULL "
|
||||
" AND lon 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()
|
||||
.all()
|
||||
)
|
||||
|
||||
# street_key → running [lat_sum, lon_sum, n]
|
||||
acc: dict[str, list[float]] = {}
|
||||
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
|
||||
for r in rows:
|
||||
key = _street_key(r["address"])
|
||||
if len(key) < _MIN_KEY_LEN:
|
||||
key = _address_key(r["address"], region_code)
|
||||
if key is None:
|
||||
continue
|
||||
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
|
||||
bucket[0] += float(r["lat"])
|
||||
bucket[1] += float(r["lon"])
|
||||
bucket[2] += 1.0
|
||||
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
|
||||
|
||||
return {
|
||||
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
|
||||
for key, (lat_sum, lon_sum, n) in acc.items()
|
||||
}
|
||||
out: dict[tuple[int, str, str], Centroid] = {}
|
||||
dropped = 0
|
||||
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]:
|
||||
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
|
||||
def _region_predicate(column: str = "region_code") -> str:
|
||||
"""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
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT id, address "
|
||||
"SELECT id, address, city "
|
||||
"FROM deals "
|
||||
"WHERE lat IS NULL "
|
||||
" AND address IS NOT NULL "
|
||||
" AND length(trim(address)) > 0 "
|
||||
" AND " + _region_predicate() + " "
|
||||
"ORDER BY id "
|
||||
"LIMIT CAST(:lim AS int)"
|
||||
),
|
||||
{"lim": limit},
|
||||
{"lim": limit, "rc": region_code},
|
||||
)
|
||||
.mappings()
|
||||
.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.
|
||||
|
||||
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), "
|
||||
" lon = CAST(:lon AS double precision), "
|
||||
" 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(
|
||||
db: Session,
|
||||
deals: list[DealRow],
|
||||
centroids: dict[str, Centroid],
|
||||
centroids: dict[tuple[int, str, str], Centroid],
|
||||
*,
|
||||
batch: str,
|
||||
dry_run: bool,
|
||||
region_code: int = _DEFAULT_REGION_CODE,
|
||||
) -> Stats:
|
||||
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
||||
|
||||
|
|
@ -399,14 +638,14 @@ def _run_backfill(
|
|||
stats = Stats()
|
||||
|
||||
for i, deal in enumerate(deals, start=1):
|
||||
key = _street_key(deal.address)
|
||||
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
|
||||
key = _address_key(deal.address, region_code, city=deal.city)
|
||||
centroid = centroids.get(key) if key is not None else None
|
||||
|
||||
if centroid is None:
|
||||
stats.no_street_match += 1
|
||||
# Track the raw key (or a sentinel) so the dry-run report can show
|
||||
# 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
|
||||
if dry_run and i % _LOG_EVERY == 0:
|
||||
logger.info(
|
||||
|
|
@ -423,7 +662,13 @@ def _run_backfill(
|
|||
else:
|
||||
try:
|
||||
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.
|
||||
db.commit()
|
||||
stats.geocoded += 1
|
||||
|
|
@ -454,7 +699,7 @@ def _run_backfill(
|
|||
|
||||
def _report_dry_run(
|
||||
stats: Stats,
|
||||
centroids: dict[str, Centroid],
|
||||
centroids: dict[tuple[int, str, str], Centroid],
|
||||
*,
|
||||
total_deals_null: int,
|
||||
candidates: int,
|
||||
|
|
@ -475,7 +720,7 @@ def _report_dry_run(
|
|||
|
||||
logger.info("─" * 60)
|
||||
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 matched to a centroid: %d", matched)
|
||||
logger.info("deals with no street match: %d", stats.no_street_match)
|
||||
|
|
@ -486,7 +731,7 @@ def _report_dry_run(
|
|||
projected,
|
||||
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):
|
||||
logger.info(" %6d %s", cnt, street)
|
||||
logger.info("─" * 60)
|
||||
|
|
@ -497,13 +742,15 @@ def _report_dry_run(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_deals_null(db: Session) -> int:
|
||||
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
|
||||
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
|
||||
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"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()
|
||||
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()}",
|
||||
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(
|
||||
"--dry-run",
|
||||
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."""
|
||||
args = _parse_args(argv)
|
||||
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.region_code,
|
||||
args.limit,
|
||||
args.max_spread_km,
|
||||
args.dry_run,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
centroids = _build_centroid_map(db)
|
||||
logger.info("built centroid map: %d distinct streets", len(centroids))
|
||||
centroids = _build_centroid_map(
|
||||
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:
|
||||
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
|
||||
|
||||
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))
|
||||
if not deals:
|
||||
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
||||
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:
|
||||
total_null = _count_deals_null(db)
|
||||
total_null = _count_deals_null(db, region_code=args.region_code)
|
||||
_report_dry_run(
|
||||
stats,
|
||||
centroids,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
|
|||
Centroid,
|
||||
DealRow,
|
||||
Stats,
|
||||
_address_key,
|
||||
_build_centroid_map,
|
||||
_run_backfill,
|
||||
_select_deals_without_coords,
|
||||
_street_key,
|
||||
_update_deal_coords,
|
||||
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."""
|
||||
house_rows = [
|
||||
{"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)
|
||||
|
||||
centroids = _build_centroid_map(db)
|
||||
|
||||
assert set(centroids) == {"малышева"}
|
||||
c = centroids["малышева"]
|
||||
assert c.lat == pytest.approx(56.85)
|
||||
assert c.lon == pytest.approx(60.60)
|
||||
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
||||
c = centroids[(66, "екатеринбург", "малышева")]
|
||||
assert c.lat == pytest.approx(56.81)
|
||||
assert c.lon == pytest.approx(60.52)
|
||||
assert c.house_count == 2
|
||||
|
||||
|
||||
|
|
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
|
|||
|
||||
centroids = _build_centroid_map(db)
|
||||
|
||||
assert set(centroids) == {"малышева", "ленина"}
|
||||
assert centroids["малышева"].house_count == 2
|
||||
assert centroids["ленина"].house_count == 1
|
||||
assert set(centroids) == {
|
||||
(66, "екатеринбург", "малышева"),
|
||||
(66, "екатеринбург", "ленина"),
|
||||
}
|
||||
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
|
||||
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
|
||||
# Малышева centroid = mean of the two Малышева rows.
|
||||
assert centroids["малышева"].lat == pytest.approx(56.81)
|
||||
assert centroids["ленина"].lat == pytest.approx(56.84)
|
||||
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
|
||||
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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():
|
||||
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()
|
||||
|
||||
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():
|
||||
"""Deal address with no house number / no type word still matches."""
|
||||
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()
|
||||
|
||||
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():
|
||||
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()
|
||||
|
||||
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 db.commit.call_count == 0
|
||||
# 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():
|
||||
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
||||
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()
|
||||
|
||||
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():
|
||||
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()
|
||||
|
||||
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=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()
|
||||
|
||||
# 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
|
||||
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
||||
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():
|
||||
house_rows = [
|
||||
{"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 = [
|
||||
{"id": 1, "address": "Екатеринбург, Малышева"},
|
||||
|
|
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
|
|||
assert len(updated) == 1
|
||||
assert updated[0]["id"] == 1
|
||||
# Centroid used = mean of the two house rows.
|
||||
assert updated[0]["lat"] == pytest.approx(56.85)
|
||||
assert updated[0]["lon"] == pytest.approx(60.60)
|
||||
assert updated[0]["lat"] == pytest.approx(56.81)
|
||||
assert updated[0]["lon"] == pytest.approx(60.52)
|
||||
|
||||
|
||||
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.failed == 0
|
||||
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