feat(site_finder): routing POI decay (#41) + OSM utilities (#44) + ЗОУИТ geom (#255bk) #1319

Merged
bot-backend merged 1 commit from feat/poi-routing-osm-utilities-zouit-geom into main 2026-06-13 17:41:02 +00:00
10 changed files with 922 additions and 22 deletions

View file

@ -65,7 +65,11 @@ from app.services.site_finder.custom_pois import (
from app.services.site_finder.developer_attribution import get_developer_attribution from app.services.site_finder.developer_attribution import get_developer_attribution
from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.ird_analyze import build_ird_analyze_block from app.services.site_finder.ird_analyze import build_ird_analyze_block
from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7 from app.services.site_finder.poi_score import (
PoiScoreResponse,
compute_poi_routing_decay,
compute_poi_weighted_top7,
)
from app.services.site_finder.quarter_dump_lookup import ( from app.services.site_finder.quarter_dump_lookup import (
get_connection_points, get_connection_points,
get_quarter_dump_data, get_quarter_dump_data,
@ -1895,7 +1899,8 @@ def analyze_parcel(
.mappings() .mappings()
.all() .all()
) )
# Группировка по типу для compactness # Группировка по типу для compactness. util_rows отсортированы по
# distance_m ASC → первый встреченный road_class = ближайший.
by_subtype: dict[str, dict[str, Any]] = {} by_subtype: dict[str, dict[str, Any]] = {}
for r in util_rows: for r in util_rows:
sub = r["road_class"] or "other" sub = r["road_class"] or "other"
@ -1907,12 +1912,33 @@ def analyze_parcel(
"count_within_2km": 0, "count_within_2km": 0,
} }
by_subtype[sub]["count_within_2km"] += 1 by_subtype[sub]["count_within_2km"] += 1
# I3 (#44): именованные расстояния до ближайшей инж.сети по типу.
# road_class сопоставлен в noise_loader._NOISE_QUERIES. None если в 2 км
# нет ни одного объекта данного типа.
def _nearest(*subtypes: str) -> int | None:
cands = [by_subtype[s]["nearest_m"] for s in subtypes if s in by_subtype]
return min(cands) if cands else None
# Водопровод: магистраль (pipeline=water) ИЛИ узловой объект ВКХ.
nearest_water_main_m = _nearest("water_main", "water_works", "water_tower")
# Электроснабжение: подстанция ИЛИ трансформатор.
nearest_substation_m = _nearest("substation", "transformer")
nearest_gas_m = _nearest("gas_pipeline")
nearest_heat_m = _nearest("heat_substation")
utilities = { utilities = {
"summary": list(by_subtype.values()), "summary": list(by_subtype.values()),
"nearest_water_main_m": nearest_water_main_m,
"nearest_substation_m": nearest_substation_m,
"nearest_gas_m": nearest_gas_m,
"nearest_heat_m": nearest_heat_m,
"power_line_охранная_зона_flag": any( "power_line_охранная_зона_flag": any(
float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
), ),
"note": ( "note": (
"OSM-покрытие инж.сетей в РФ неполное (особенно подземные трубы) — "
"nearest_*_m нижняя оценка по доступным OSM-объектам. "
"Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). " "Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). "
"В зоне ОЗ нельзя строить капитальные объекты. " "В зоне ОЗ нельзя строить капитальные объекты. "
"Точная классификация напряжения / магистральности — ЗОУИТ " "Точная классификация напряжения / магистральности — ЗОУИТ "
@ -3048,13 +3074,17 @@ async def get_poi_score(
cad_num: str, cad_num: str,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000, radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight",
) -> PoiScoreResponse: ) -> PoiScoreResponse:
"""Вернуть top-7 ближайших POI для участка, взвешенных по формуле: """Вернуть top-7 ближайших POI для участка.
weight = (1 / (distance_m + 100)) * category_weight decay=straight (default, B6): weight = (1 / (distance_m + 100)) * category_weight.
decay=routing (#41): время пешком (ORS /matrix) + per-category radius (минут) +
per-category decay-кривая. За порогом доступности категории POI выбывает.
ORS недоступен graceful fallback на straight-line оценку времени.
POI берутся из osm_poi_ekb в заданном радиусе (default 2000м). POI берутся из osm_poi_ekb в заданном радиусе (default 2000м), отсортированы
Отсортированы по weight DESC наиболее значимые объекты первыми. по weight DESC.
""" """
# Получить координаты центроида участка из геометрических таблиц. # Получить координаты центроида участка из геометрических таблиц.
# См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках # См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках
@ -3093,6 +3123,8 @@ async def get_poi_score(
lat = float(coord_row["lat"]) lat = float(coord_row["lat"])
lon = float(coord_row["lon"]) lon = float(coord_row["lon"])
if decay == "routing":
return compute_poi_routing_decay(db, cad_num, lat, lon, radius_m=radius_m)
return compute_poi_weighted_top7(db, cad_num, lat, lon, radius_m=radius_m) return compute_poi_weighted_top7(db, cad_num, lat, lon, radius_m=radius_m)

View file

@ -45,29 +45,56 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
("power", "line", "utility", "power_line"), ("power", "line", "utility", "power_line"),
("power", "minor_line", "utility", "power_line"), ("power", "minor_line", "utility", "power_line"),
("man_made", "pipeline", "utility", "pipeline"), ("man_made", "pipeline", "utility", "pipeline"),
# Магистральные трубопроводы по substance (way) — газ / вода / канализация (#44).
# OSM tag `pipeline` (не `man_made=pipeline`): pipeline=substance.
("pipeline", "gas", "utility", "gas_pipeline"),
("pipeline", "water", "utility", "water_main"),
("pipeline", "sewerage", "utility", "sewerage"),
# Сети ВКХ (водоснабжение / канализация) — узлы, не магистрали # Сети ВКХ (водоснабжение / канализация) — узлы, не магистрали
("man_made", "water_works", "utility", "water_works"), ("man_made", "water_works", "utility", "water_works"),
("man_made", "wastewater_plant", "utility", "wastewater_plant"), ("man_made", "wastewater_plant", "utility", "wastewater_plant"),
("man_made", "water_tower", "utility", "water_tower"), ("man_made", "water_tower", "utility", "water_tower"),
("man_made", "storage_tank", "utility", "storage_tank"), ("man_made", "storage_tank", "utility", "storage_tank"),
# Электроподстанции — потенциальные точки подключения 10/35/110 кВ # Электроподстанции / трансформаторы — точки подключения 10/35/110 кВ (#44)
("power", "substation", "utility", "substation"), ("power", "substation", "utility", "substation"),
("power", "transformer", "utility", "transformer"),
# ЦТП (центральный тепловой пункт) — точка подключения теплосети (#44)
("man_made", "heat_substation", "utility", "heat_substation"),
] ]
# Инж.-сетевые точки подключения (#44), которые в OSM маппят и как node, и как
# area (closed way): подстанции / трансформаторы / ЦТП / водозабор и т.п.
# Для них спрашиваем nwr (node + way) с `out geom`, чтобы поймать оба варианта
# маппинга и получить координаты узлов для way-площадок.
_UTILITY_POINT_VALUES: frozenset[str] = frozenset(
{
"substation",
"transformer",
"heat_substation",
"water_works",
"wastewater_plant",
"water_tower",
"storage_tank",
}
)
def _build_overpass_query(key: str, value: str, el_type: str) -> str: def _build_overpass_query(key: str, value: str, el_type: str) -> str:
"""Запрос Overpass для одного типа элемента. """Запрос Overpass для одного типа элемента.
Для way используем `out geom` получаем полные координаты узлов (нужно Для way / nwr используем `out geom` получаем полные координаты узлов
для построения LineString геометрии). (нужно для построения LineString / Polygon геометрии way-площадок).
Для node `out body`. Для node `out body`.
""" """
south, west, north, east = EKB_BBOX south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})" bbox = f"({south},{west},{north},{east})"
if el_type == "nwr":
# node + way: точки подключения бывают и точкой, и площадкой
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
if el_type == "way": if el_type == "way":
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;" return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
else: return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
async def fetch_overpass_noise() -> list[dict]: async def fetch_overpass_noise() -> list[dict]:
@ -81,8 +108,14 @@ async def fetch_overpass_noise() -> list[dict]:
all_elements: list[dict] = [] all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client: async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
for key, value, source_type, road_class in _NOISE_QUERIES: for key, value, source_type, road_class in _NOISE_QUERIES:
# aerodrome — node; остальное — way # aerodrome — node; точки подключения инж.сетей — nwr (node+way);
el_type = "node" if key == "aeroway" else "way" # остальное (дороги/ж-д/трубы/ЛЭП-линии) — way.
if key == "aeroway":
el_type = "node"
elif value in _UTILITY_POINT_VALUES:
el_type = "nwr"
else:
el_type = "way"
query = _build_overpass_query(key, value, el_type) query = _build_overpass_query(key, value, el_type)
try: try:
r = await client.post(OVERPASS_URL, data={"data": query}) r = await client.post(OVERPASS_URL, data={"data": query})

View file

@ -0,0 +1,138 @@
"""Тонкий клиент OpenRouteService (ORS) — переиспользуемый между фичами.
Сейчас ORS используется для:
- изохрон доступности (`/parcels/{cad}/isochrones`, см. parcels.get_isochrones)
- routing-based POI decay (#41, см. poi_score.compute_poi_routing_decay) — `/matrix`
Free tier: 2000 запросов/день. Поэтому:
- один HTTP-вызов на analyze (батч до ~1000 destinations в /matrix)
- явный httpx timeout (без него worker зависает)
- graceful: нет API-ключа / 429 / сетевой сбой `OrsUnavailableError`, caller делает
fallback на straight-line, а не валит analyze.
"""
from __future__ import annotations
import logging
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_ORS_MATRIX_BASE = "https://api.openrouteservice.org/v2/matrix"
# Профили ORS-routing. foot-walking — пеший радиус (метро/школа/магазин),
# driving-car — авто (для будущих авто-категорий).
VALID_PROFILES: frozenset[str] = frozenset(
{"foot-walking", "cycling-regular", "driving-car"}
)
# ORS /matrix ограничивает foot-walking ~2000 пар (sources×destinations) на free tier.
MAX_MATRIX_DESTINATIONS = 1000
_DEFAULT_TIMEOUT_S = 12.0
class OrsUnavailableError(Exception):
"""ORS недоступен (нет ключа / rate-limit / сетевой сбой / битый ответ).
Caller обязан поймать и сделать fallback (straight-line), не валя запрос.
"""
def is_configured() -> bool:
"""True если задан API-ключ ORS (иначе сразу fallback без HTTP)."""
return bool(settings.openrouteservice_api_key)
def matrix_durations_min(
origin_lon: float,
origin_lat: float,
destinations: list[tuple[float, float]],
*,
profile: str = "foot-walking",
timeout_s: float = _DEFAULT_TIMEOUT_S,
) -> list[float | None]:
"""Время в пути (минуты) от одного origin до каждого destination через ORS /matrix.
Args:
origin_lon, origin_lat: координаты источника (центроид участка).
destinations: список (lon, lat) POI. Усечётся до MAX_MATRIX_DESTINATIONS.
profile: ORS-профиль ('foot-walking' по умолчанию пешая доступность).
timeout_s: httpx timeout (обязателен иначе worker зависает).
Returns:
Список той же длины, что усечённый `destinations`. Элемент минуты в пути
(float) или None, если ORS не смог построить маршрут до этой точки
(например, POI на острове без пешеходной связи).
Raises:
OrsUnavailableError: нет ключа / mode невалиден / rate-limit / сетевой сбой /
неожиданный формат ответа. Caller делает straight-line fallback.
"""
if not is_configured():
raise OrsUnavailableError("OPENROUTESERVICE_API_KEY не задан")
if profile not in VALID_PROFILES:
raise OrsUnavailableError(f"Недопустимый ORS profile: {profile!r}")
if not destinations:
return []
dests = destinations[:MAX_MATRIX_DESTINATIONS]
if len(destinations) > MAX_MATRIX_DESTINATIONS:
logger.warning(
"ors matrix: %d destinations усечено до %d (free-tier limit)",
len(destinations),
MAX_MATRIX_DESTINATIONS,
)
# ORS /matrix: locations[0] — source, остальные — destinations.
locations: list[list[float]] = [[origin_lon, origin_lat]]
locations.extend([lon, lat] for lon, lat in dests)
body = {
"locations": locations,
"sources": [0],
"destinations": list(range(1, len(locations))),
"metrics": ["duration"],
"units": "m",
}
headers = {
"Authorization": settings.openrouteservice_api_key,
"Content-Type": "application/json",
"Accept": "application/json",
}
url = f"{_ORS_MATRIX_BASE}/{profile}"
try:
with httpx.Client(timeout=timeout_s) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 429:
raise OrsUnavailableError("ORS daily limit (2000 req) exceeded") from exc
raise OrsUnavailableError(
f"ORS matrix HTTP {exc.response.status_code}: {exc.response.text[:200]}"
) from exc
except Exception as exc: # сетевой сбой / JSON decode — fallback, не 500
raise OrsUnavailableError(f"ORS matrix request failed: {exc}") from exc
# Ответ: {"durations": [[d1, d2, ...]]} — секунды, одна строка (один source).
durations = data.get("durations")
if not isinstance(durations, list) or not durations:
raise OrsUnavailableError("ORS matrix: пустой/битый durations в ответе")
row = durations[0]
if not isinstance(row, list):
raise OrsUnavailableError("ORS matrix: durations[0] не список")
out: list[float | None] = []
for sec in row:
if sec is None:
out.append(None) # ORS не построил маршрут до этой точки
else:
out.append(float(sec) / 60.0)
# Длина durations должна совпадать с числом destinations — иначе zip(strict=True)
# у вызывающего бросит ValueError (не OrsUnavailableError) → 500. Закрываем как ORS-сбой.
if len(out) != len(dests):
raise OrsUnavailableError(
f"ORS matrix: durations ({len(out)}) != destinations ({len(dests)})"
)
return out

View file

@ -1,19 +1,30 @@
"""POI weighted score для Site Finder (B6). """POI weighted score для Site Finder (B6) + routing-radius decay (#41).
Формула: weight = (1 / (distance_m + 100)) * category_weight Два режима:
1. compute_poi_weighted_top7 (B6, legacy) straight-line:
weight = (1 / (distance_m + 100)) * category_weight
2. compute_poi_routing_decay (#41) — routing-based:
время пешком (ORS /matrix) вместо прямой линии + per-category radius (минут) +
per-category decay-кривая. Решает Limit_POI_Factor_Tiny (ratio 0.99-1.02 шире).
ORS недоступен (нет ключа / rate-limit) graceful fallback на straight-line с
эквивалентным минутным порогом.
Возвращает top-7 ближайших POI из osm_poi_ekb, отсортированных по weight DESC.
Категории и их веса согласованы с _POI_WEIGHTS в parcels.py. Категории и их веса согласованы с _POI_WEIGHTS в parcels.py.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from app.services.site_finder import ors_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Веса по категории — согласованы с _POI_WEIGHTS в parcels.py + новые из vault B6. # Веса по категории — согласованы с _POI_WEIGHTS в parcels.py + новые из vault B6.
@ -157,3 +168,200 @@ def compute_poi_weighted_top7(
radius_m=radius_m, radius_m=radius_m,
top_poi=top_items, top_poi=top_items,
) )
# ── #41: routing-radius decay ────────────────────────────────────────────────
#
# Per-category радиус доступности В МИНУТАХ ПЕШКОМ (не метров прямой линии).
# Сразу за порогом вес категории = 0 (POI не учитывается). Значения из vault A3
# (СП 42.13330 + 2GIS-практика пешей доступности). Категория без записи →
# _DEFAULT_RADIUS_MIN.
_CATEGORY_RADIUS_MIN: dict[str, float] = {
"metro_stop": 20.0,
"school": 15.0,
"kindergarten": 10.0,
"park": 10.0,
"pharmacy": 12.0,
"shop_supermarket": 12.0,
"shop_mall": 15.0,
"shop_small": 10.0,
"hospital": 20.0,
"bus_stop": 12.0,
"tram_stop": 15.0,
}
_DEFAULT_RADIUS_MIN: float = 12.0
# Средняя пешая скорость для straight-line fallback (м/мин). 5 км/ч ≈ 83 м/мин.
_WALK_SPEED_M_PER_MIN: float = 83.0
def _category_radius_min(category: str) -> float:
"""Порог доступности категории в минутах пешком."""
return _CATEGORY_RADIUS_MIN.get(category, _DEFAULT_RADIUS_MIN)
def _decay_linear(t_min: float, radius_min: float) -> float:
"""Линейный спад: 1.0 в нуле → 0.0 на radius_min. Для метро/остановок."""
if t_min >= radius_min:
return 0.0
return 1.0 - (t_min / radius_min)
def _decay_piecewise(t_min: float, radius_min: float) -> float:
"""Кусочный: полный вес 0..⅓ радиуса, линейный спад до радиуса, дальше 0.
Для школ/детсадов/поликлиник рядом одинаково удобно, "обрыв" только
ближе к краю изохроны.
"""
full_until = radius_min / 3.0
if t_min <= full_until:
return 1.0
if t_min >= radius_min:
return 0.0
return 1.0 - (t_min - full_until) / (radius_min - full_until)
def _decay_step(t_min: float, radius_min: float) -> float:
"""Ступенька: 1.0 если в радиусе, иначе 0.0. Для парков (есть/нет рядом)."""
return 1.0 if t_min <= radius_min else 0.0
# Какую кривую спада применять к категории. Дефолт — linear.
_CATEGORY_DECAY: dict[str, Callable[[float, float], float]] = {
"metro_stop": _decay_linear,
"bus_stop": _decay_linear,
"tram_stop": _decay_linear,
"school": _decay_piecewise,
"kindergarten": _decay_piecewise,
"hospital": _decay_piecewise,
"pharmacy": _decay_piecewise,
"park": _decay_step,
}
def _decay_for(category: str) -> Callable[[float, float], float]:
return _CATEGORY_DECAY.get(category, _decay_linear)
def compute_poi_routing_decay(
db: Any,
cad_num: str,
lat: float,
lon: float,
radius_m: int = 2000,
top_n: int = 7,
*,
profile: str = "foot-walking",
) -> PoiScoreResponse:
"""#41: POI-score по ВРЕМЕНИ В ПУТИ (ORS routing) + per-category decay.
Шаги:
1. Достать POI-кандидатов из osm_poi_ekb в radius_m (как B6), c lon/lat.
2. Один ORS /matrix вызов: время пешком origin все POI (минуты).
ORS недоступен straight-line fallback (метры/скорость минуты).
3. weight = category_weight * decay(t_min, _CATEGORY_RADIUS_MIN[category]).
За порогом радиуса decay=0 POI выбывает.
4. top_n по weight DESC.
Сигнатура совместима с compute_poi_weighted_top7 (drop-in для callers).
PoiScoreItem.distance_m здесь straight-line метры (для UI/отладки),
ранжирование идёт по routing-времени через weight.
"""
rows = (
db.execute(
text("""
SELECT
p.name,
p.category,
p.tags,
ST_X(p.geom) AS lon,
ST_Y(p.geom) AS lat,
CAST(
ST_Distance(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
) AS double precision
) AS distance_m
FROM osm_poi_ekb p
WHERE ST_DWithin(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT :limit
"""),
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"limit": ors_client.MAX_MATRIX_DESTINATIONS,
},
)
.mappings()
.all()
)
if not rows:
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
# ── routing-время пешком, с graceful fallback на straight-line ──
times_min: list[float | None]
routing_used = False
try:
times_min = ors_client.matrix_durations_min(
lon, lat, destinations, profile=profile
)
routing_used = True
except ors_client.OrsUnavailableError as exc:
logger.info(
"poi_routing_decay: ORS недоступен (%s) → straight-line fallback (cad=%s)",
exc,
cad_num,
)
times_min = [float(r["distance_m"]) / _WALK_SPEED_M_PER_MIN for r in rows]
logger.debug(
"poi_routing_decay: cad=%s %d candidates routing=%s",
cad_num,
len(rows),
routing_used,
)
items: list[PoiScoreItem] = []
for row, t_min in zip(rows, times_min, strict=True):
category = row["category"] or "default"
distance_m = float(row["distance_m"])
# ORS не построил маршрут (None) → fallback на straight-line оценку времени.
if t_min is None:
t_min = distance_m / _WALK_SPEED_M_PER_MIN
radius_min = _category_radius_min(category)
factor = _decay_for(category)(t_min, radius_min)
if factor <= 0.0:
continue # за порогом доступности — POI не учитывается
weight = _category_weight(category) * factor
tags: dict[str, str] = row["tags"] or {}
addr_parts = [tags.get("addr:street"), tags.get("addr:housenumber")]
address = ", ".join(p for p in addr_parts if p) or None
items.append(
PoiScoreItem(
name=row["name"],
category=category,
distance_m=round(distance_m, 1),
weight=round(weight, 6),
address=address,
)
)
items.sort(key=lambda x: x.weight, reverse=True)
return PoiScoreResponse(
cad_num=cad_num,
radius_m=radius_m,
top_poi=items[:top_n],
)

View file

@ -392,7 +392,8 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
category_name, category_name,
name_by_doc AS name, name_by_doc AS name,
reg_numb_border, reg_numb_border,
id AS zouit_id id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson
FROM cad_zouit FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY id ORDER BY id
@ -418,6 +419,9 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
}, },
"source": "cad_zouit", "source": "cad_zouit",
"type_zone": type_zone, "type_zone": type_zone,
# #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>).
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
"geom_geojson": r[5],
} }
) )
logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result)) logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result))

View file

@ -496,8 +496,15 @@ def test_cad_zouit_fallback_fires_when_no_dump() -> None:
Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'. Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'.
""" """
cad_zouit_rows: list[tuple[Any, ...]] = [ cad_zouit_rows: list[tuple[Any, ...]] = [
# (type_zone, category_name, name_by_doc, reg_numb_border, id) # (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
("Охранная зона трубопровода", "Трубопровод", "ГВС ул. Ленина", "66-66-00/123", 42), (
"Охранная зона трубопровода",
"Трубопровод",
"ГВС ул. Ленина",
"66-66-00/123",
42,
'{"type":"Polygon","coordinates":[[[60,56],[61,56],[61,57],[60,56]]]}',
),
] ]
db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows) db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows)
@ -540,9 +547,16 @@ def test_cad_zouit_fallback_empty_when_no_overlaps() -> None:
def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
"""_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны.""" """_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны."""
rows: list[tuple[Any, ...]] = [ rows: list[tuple[Any, ...]] = [
# (type_zone, category_name, name_by_doc, reg_numb_border, id) # (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
("Охранная зона ЛЭП 110кВ", "Электроснабжение", "ВЛ-110", "66-123", 7), (
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8), "Охранная зона ЛЭП 110кВ",
"Электроснабжение",
"ВЛ-110",
"66-123",
7,
'{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
),
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8, None),
] ]
db = _make_mock_db_with_rows(rows) db = _make_mock_db_with_rows(rows)
@ -556,6 +570,10 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
assert lep["subcategory"] is None # всегда NULL в cad_zouit assert lep["subcategory"] is None # всегда NULL в cad_zouit
assert lep["name"] == "ВЛ-110" assert lep["name"] == "ВЛ-110"
assert lep["raw_props"]["zouit_id"] == 7 assert lep["raw_props"]["zouit_id"] == 7
# #255: geom_geojson проброшен для ZouitLayer (string из ST_AsGeoJSON)
assert "geom_geojson" in lep
assert lep["geom_geojson"].startswith('{"type":"Polygon"')
assert result[1]["geom_geojson"] is None # NULL geom не валит маппинг
def test_nspd_path_used_when_zouit_count_nonzero() -> None: def test_nspd_path_used_when_zouit_count_nonzero() -> None:

View file

@ -0,0 +1,100 @@
"""Unit tests для noise_loader — OSM-источники шума + инж.сети (#44).
Mock-based / pure без Overpass и БД. Покрывает:
- расширенный набор инж.-сетевых категорий (#44: gas/water/sewerage/transformer/
heat_substation)
- _build_overpass_query: node / way / nwr варианты
- el_type dispatch для point-like utility (nwr) vs линейных (way)
"""
from __future__ import annotations
from app.services.site_finder.noise_loader import (
_NOISE_QUERIES,
_UTILITY_POINT_VALUES,
_build_overpass_query,
_way_to_linestring_wkt,
)
# ── расширенные инж.-сетевые категории (#44) ─────────────────────────────────
def _road_classes() -> set[str | None]:
return {road_class for _k, _v, src, road_class in _NOISE_QUERIES if src == "utility"}
def test_new_utility_categories_present() -> None:
"""#44: добавлены недостающие инж.сети — газ/вода/канализация/трансформатор/ЦТП."""
classes = _road_classes()
for expected in (
"gas_pipeline",
"water_main",
"sewerage",
"transformer",
"heat_substation",
):
assert expected in classes, f"road_class {expected!r} должен быть в _NOISE_QUERIES"
def test_existing_utility_categories_retained() -> None:
"""Старые категории не потеряны при расширении (regression guard)."""
classes = _road_classes()
for kept in ("power_line", "pipeline", "water_works", "wastewater_plant", "substation"):
assert kept in classes
def test_pipeline_queries_use_pipeline_key() -> None:
"""pipeline=gas/water/sewerage маппится на OSM tag `pipeline`, не man_made."""
pipeline_entries = [(k, v) for k, v, src, _rc in _NOISE_QUERIES if k == "pipeline"]
values = {v for _k, v in pipeline_entries}
assert values == {"gas", "water", "sewerage"}
def test_utility_point_values_cover_new_nodes() -> None:
"""Точечные/площадные инж.объекты помечены для nwr-запроса."""
for v in ("substation", "transformer", "heat_substation", "water_works"):
assert v in _UTILITY_POINT_VALUES
# ── _build_overpass_query ─────────────────────────────────────────────────────
def test_build_query_node() -> None:
q = _build_overpass_query("aeroway", "aerodrome", "node")
assert q.startswith("[out:json][timeout:30];")
assert 'node["aeroway"="aerodrome"]' in q
assert "out body;" in q
def test_build_query_way_uses_out_geom() -> None:
"""way → out geom (нужны координаты узлов для LineString)."""
q = _build_overpass_query("highway", "motorway", "way")
assert 'way["highway"="motorway"]' in q
assert "out geom;" in q
def test_build_query_nwr_for_utility_points() -> None:
"""nwr ловит и node, и way (точка/площадка), с out geom."""
q = _build_overpass_query("power", "transformer", "nwr")
assert 'nwr["power"="transformer"]' in q
assert "out geom;" in q
def test_build_query_bbox_present() -> None:
"""Bbox ЕКБ присутствует во всех вариантах запроса."""
q = _build_overpass_query("power", "substation", "nwr")
# south,west,north,east из EKB_BBOX
assert "(56.7,60.5,56.95,60.75)" in q
# ── _way_to_linestring_wkt (используется для way-результатов nwr) ────────────
def test_way_to_linestring_valid() -> None:
nodes = [{"lat": 56.8, "lon": 60.6}, {"lat": 56.81, "lon": 60.61}]
wkt = _way_to_linestring_wkt(nodes)
assert wkt == "LINESTRING(60.6 56.8, 60.61 56.81)"
def test_way_to_linestring_too_few_points() -> None:
assert _way_to_linestring_wkt([{"lat": 56.8, "lon": 60.6}]) is None

View file

@ -0,0 +1,123 @@
"""Тесты ORS-клиента (#41) — /matrix durations.
Сеть НЕ дёргается (ORS free tier 2000/день): подменяем httpx.Client на
MockTransport. Покрывает: парсинг durations, усечение до лимита, 429 OrsUnavailableError,
нет ключа OrsUnavailableError, None-маршруты.
"""
from __future__ import annotations
import httpx
import pytest
from app.core.config import settings
from app.services.site_finder import ors_client
@pytest.fixture
def with_key(monkeypatch: pytest.MonkeyPatch):
"""Задать фиктивный ORS-ключ на время теста."""
monkeypatch.setattr(settings, "openrouteservice_api_key", "test-key-123")
yield
def _install_transport(monkeypatch: pytest.MonkeyPatch, handler) -> dict:
"""Подменить httpx.Client в модуле ors_client на MockTransport(handler).
Захватываем реальный httpx.Client ДО патча иначе factory рекурсивно зовёт
сама себя (ors_client.httpx общий модуль httpx).
"""
captured: dict = {}
real_client = httpx.Client
def client_factory(*_args, **kwargs):
kwargs.pop("transport", None)
return real_client(transport=httpx.MockTransport(handler), **kwargs)
monkeypatch.setattr(ors_client.httpx, "Client", client_factory)
return captured
def test_matrix_durations_parses_minutes(with_key, monkeypatch):
"""durations (секунды) → минуты, длина = числу destinations."""
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/foot-walking")
return httpx.Response(200, json={"durations": [[0.0, 300.0, 600.0]]})
_install_transport(monkeypatch, handler)
out = ors_client.matrix_durations_min(
60.6, 56.8, [(60.61, 56.81), (60.62, 56.82), (60.63, 56.83)]
)
assert out == [0.0, 5.0, 10.0]
def test_matrix_none_route_preserved(with_key, monkeypatch):
"""ORS вернул null (нет маршрута) → None в результате."""
def handler(_request):
return httpx.Response(200, json={"durations": [[120.0, None]]})
_install_transport(monkeypatch, handler)
out = ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
assert out == [2.0, None]
def test_matrix_no_key_raises():
"""Нет API-ключа → OrsUnavailableError (caller сделает straight-line fallback)."""
# settings по умолчанию пустой ключ (testing). Явно убедимся.
if settings.openrouteservice_api_key:
pytest.skip("ключ задан в окружении")
with pytest.raises(ors_client.OrsUnavailableError):
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])
def test_matrix_429_raises_unavailable(with_key, monkeypatch):
"""429 (rate-limit) → OrsUnavailableError, не пробрасываем HTTPStatusError."""
def handler(_request):
return httpx.Response(429, json={"error": "rate limit"})
_install_transport(monkeypatch, handler)
with pytest.raises(ors_client.OrsUnavailableError, match="daily limit"):
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])
def test_matrix_empty_destinations_returns_empty(with_key):
assert ors_client.matrix_durations_min(60.6, 56.8, []) == []
def test_matrix_invalid_profile_raises(with_key):
with pytest.raises(ors_client.OrsUnavailableError, match="profile"):
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)], profile="teleport")
def test_matrix_truncates_to_limit(with_key, monkeypatch):
""">MAX_MATRIX_DESTINATIONS усекается; body.locations = 1 source + лимит."""
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen["n_locations"] = len(body["locations"])
n_dest = len(body["destinations"])
return httpx.Response(200, json={"durations": [[60.0] * n_dest]})
_install_transport(monkeypatch, handler)
dests = [(60.0 + i * 0.001, 56.0) for i in range(ors_client.MAX_MATRIX_DESTINATIONS + 50)]
out = ors_client.matrix_durations_min(60.6, 56.8, dests)
# 1 source + MAX destinations
assert seen["n_locations"] == ors_client.MAX_MATRIX_DESTINATIONS + 1
assert len(out) == ors_client.MAX_MATRIX_DESTINATIONS
def test_matrix_bad_response_raises(with_key, monkeypatch):
"""Битый ответ без durations → OrsUnavailableError."""
def handler(_request):
return httpx.Response(200, json={"unexpected": True})
_install_transport(monkeypatch, handler)
with pytest.raises(ors_client.OrsUnavailableError):
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])

View file

@ -3,10 +3,18 @@
Юнит-тесты для чистой функции без DB. Юнит-тесты для чистой функции без DB.
""" """
import pytest
from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import ( from app.services.site_finder.poi_score import (
CATEGORY_WEIGHTS, CATEGORY_WEIGHTS,
PoiScoreResponse, PoiScoreResponse,
_category_radius_min,
_category_weight, _category_weight,
_decay_linear,
_decay_piecewise,
_decay_step,
compute_poi_routing_decay,
compute_poi_weighted_top7, compute_poi_weighted_top7,
) )
@ -166,3 +174,162 @@ def test_address_none_when_no_tags():
db = _MockDb(rows) db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605) result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
assert result.top_poi[0].address is None assert result.top_poi[0].address is None
# ── #41: per-category decay curves (pure) ────────────────────────────────────
def test_decay_linear_bounds():
assert _decay_linear(0.0, 20.0) == pytest.approx(1.0)
assert _decay_linear(10.0, 20.0) == pytest.approx(0.5)
assert _decay_linear(20.0, 20.0) == 0.0
assert _decay_linear(25.0, 20.0) == 0.0 # за порогом → 0
def test_decay_piecewise_full_then_drop():
"""Школа: полный вес близко, спад к краю, 0 за порогом."""
r = 15.0
assert _decay_piecewise(2.0, r) == pytest.approx(1.0) # 0..5 мин полный
assert _decay_piecewise(5.0, r) == pytest.approx(1.0)
mid = _decay_piecewise(10.0, r)
assert 0.0 < mid < 1.0
assert _decay_piecewise(15.0, r) == 0.0
assert _decay_piecewise(99.0, r) == 0.0
def test_decay_step_binary():
"""Парк: есть в радиусе (1.0) либо нет (0.0)."""
assert _decay_step(9.0, 10.0) == 1.0
assert _decay_step(10.0, 10.0) == 1.0
assert _decay_step(10.1, 10.0) == 0.0
def test_category_radius_min_defaults():
assert _category_radius_min("metro_stop") == 20.0
assert _category_radius_min("park") == 10.0
# неизвестная категория → дефолт
assert _category_radius_min("unknown_xyz") == poi_score._DEFAULT_RADIUS_MIN
# ── #41: compute_poi_routing_decay (mock DB + monkeypatched ORS) ─────────────
def _make_routing_row(name: str, category: str, distance_m: float, lon=60.6, lat=56.8) -> dict:
return {
"name": name,
"category": category,
"tags": {},
"lon": lon,
"lat": lat,
"distance_m": distance_m,
}
def test_routing_decay_drops_poi_beyond_radius(monkeypatch):
"""POI за порогом доступности (минут) выбывает из результата."""
rows = [
_make_routing_row("Близкое метро", "metro_stop", 400.0),
_make_routing_row("Далёкая школа", "school", 1800.0),
]
db = _MockDb(rows)
# ORS: метро 5 мин (в пределах 20), школа 30 мин (за порогом 15) → школа выбывает.
def fake_matrix(_lon, _lat, dests, **_kw):
return [5.0, 30.0]
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "66:41:0204016:10", 56.838, 60.605)
cats = [i.category for i in result.top_poi]
assert "metro_stop" in cats
assert "school" not in cats, "школа в 30 мин (> 15 мин порог) должна выбыть"
def test_routing_decay_closer_time_higher_weight(monkeypatch):
"""При одинаковой категории меньшее время в пути → больший вес."""
rows = [
_make_routing_row("Школа дальняя", "school", 900.0),
_make_routing_row("Школа ближняя", "school", 200.0),
]
db = _MockDb(rows)
def fake_matrix(_lon, _lat, dests, **_kw):
return [12.0, 3.0] # дальняя 12 мин, ближняя 3 мин
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
assert result.top_poi[0].name == "Школа ближняя"
def test_routing_decay_falls_back_to_straight_line(monkeypatch):
"""ORS недоступен → straight-line fallback, без исключения наружу."""
rows = [_make_routing_row("Метро", "metro_stop", 300.0)]
db = _MockDb(rows)
def raising_matrix(*_a, **_kw):
raise ors_client.OrsUnavailableError("no key")
monkeypatch.setattr(ors_client, "matrix_durations_min", raising_matrix)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
# 300м / 83 м/мин ≈ 3.6 мин — в пределах 20 мин радиуса метро → остаётся.
assert len(result.top_poi) == 1
assert result.top_poi[0].category == "metro_stop"
def test_routing_decay_none_route_fallback(monkeypatch):
"""ORS вернул None (нет маршрута) для точки → straight-line оценка времени."""
rows = [_make_routing_row("Парк", "park", 400.0)]
db = _MockDb(rows)
def fake_matrix(_lon, _lat, dests, **_kw):
return [None] # ORS не построил маршрут
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
# 400м / 83 ≈ 4.8 мин < 10 мин (park step) → парк остаётся.
assert len(result.top_poi) == 1
def test_routing_decay_empty_db(monkeypatch):
db = _MockDb([])
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
assert result.top_poi == []
def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
"""#41 acceptance: routing-decay даёт более широкий разброс между хорошим и
плохим участком, чем straight-line (1/(d+100)).
Хороший участок: метро 4 мин, школа 6 мин. Плохой: метро 18 мин, школа 14 мин.
"""
def total(rows, times):
db = _MockDb(rows)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
monkeypatch.setattr(
ors_client, "matrix_durations_min", lambda *_a, **_k: list(times)
)
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
return sum(i.weight for i in res.top_poi)
good_rows = [
_make_routing_row("Метро", "metro_stop", 300.0),
_make_routing_row("Школа", "school", 450.0),
]
bad_rows = [
_make_routing_row("Метро", "metro_stop", 1400.0),
_make_routing_row("Школа", "school", 1100.0),
]
good = total(good_rows, [4.0, 6.0])
bad = total(bad_rows, [18.0, 14.0])
assert good > 0 and bad > 0
# routing-decay усиливает контраст: хороший минимум вдвое выше плохого.
assert good / bad > 2.0, f"ожидали разброс >2×, получили {good / bad:.2f}×"

View file

@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
from app.services.site_finder.quarter_dump_lookup import ( from app.services.site_finder.quarter_dump_lookup import (
EMPTY_DUMP_RESULT, EMPTY_DUMP_RESULT,
_get_cad_zouit_overlaps,
derive_quarter_cad, derive_quarter_cad,
get_quarter_dump_data, get_quarter_dump_data,
make_empty_result, make_empty_result,
@ -305,6 +306,82 @@ def test_get_quarter_dump_includes_nspd_fields() -> None:
assert "total_features" in dump assert "total_features" in dump
# ── _get_cad_zouit_overlaps — geom_geojson field (#255) ──────────────────────
def _make_zouit_row(
type_zone: str,
category_name: str,
name: str,
reg_numb_border: str | None,
zouit_id: int,
geom_geojson: str | None,
) -> tuple[Any, ...]:
"""Строка cad_zouit в порядке SELECT _get_cad_zouit_overlaps.
(type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
"""
return (type_zone, category_name, name, reg_numb_border, zouit_id, geom_geojson)
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
geojson_str = (
'{"type":"Polygon","coordinates":'
"[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
)
rows = [
_make_zouit_row(
"Охранная зона тепловой сети",
"ЗОУИТ general",
"Охранная зона тепловой сети с кад. № 66:41:0204016:57",
"66:41:0204016:57",
123,
geojson_str,
)
]
db = MagicMock()
execute_result = MagicMock()
execute_result.fetchall.return_value = rows
db.execute.return_value = execute_result
result = _get_cad_zouit_overlaps(db, "POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))")
assert len(result) == 1
overlap = result[0]
assert "geom_geojson" in overlap, "overlap должен нести geom_geojson для ZouitLayer"
assert overlap["geom_geojson"] == geojson_str
assert overlap["source"] == "cad_zouit"
assert overlap["type_zone"] == "Охранная зона тепловой сети"
assert overlap["raw_props"]["zouit_id"] == 123
def test_cad_zouit_overlaps_geom_geojson_nullable() -> None:
"""geom_geojson=None (битая/NULL geometry) не валит маппинг — поле = None."""
rows = [
_make_zouit_row("СЗЗ", "иные", "Санитарно-защитная зона", None, 7, None),
]
db = MagicMock()
execute_result = MagicMock()
execute_result.fetchall.return_value = rows
db.execute.return_value = execute_result
result = _get_cad_zouit_overlaps(db, "POLYGON ((...))")
assert len(result) == 1
assert result[0]["geom_geojson"] is None
def test_cad_zouit_overlaps_query_failure_returns_empty() -> None:
"""Сбой запроса (например, отсутствие cad_zouit) → graceful [] (не пробрасываем)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("relation cad_zouit does not exist")
result = _get_cad_zouit_overlaps(db, "POLYGON ((...))")
assert result == []
# ── make_empty_result — mutation safety ───────────────────────────────────── # ── make_empty_result — mutation safety ─────────────────────────────────────