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.gate_verdict import compute_gate_verdict
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 (
get_connection_points,
get_quarter_dump_data,
@ -1895,7 +1899,8 @@ def analyze_parcel(
.mappings()
.all()
)
# Группировка по типу для compactness
# Группировка по типу для compactness. util_rows отсортированы по
# distance_m ASC → первый встреченный road_class = ближайший.
by_subtype: dict[str, dict[str, Any]] = {}
for r in util_rows:
sub = r["road_class"] or "other"
@ -1907,12 +1912,33 @@ def analyze_parcel(
"count_within_2km": 0,
}
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 = {
"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(
float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
),
"note": (
"OSM-покрытие инж.сетей в РФ неполное (особенно подземные трубы) — "
"nearest_*_m нижняя оценка по доступным OSM-объектам. "
"Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). "
"В зоне ОЗ нельзя строить капитальные объекты. "
"Точная классификация напряжения / магистральности — ЗОУИТ "
@ -3048,13 +3074,17 @@ async def get_poi_score(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight",
) -> 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м).
Отсортированы по weight DESC наиболее значимые объекты первыми.
POI берутся из osm_poi_ekb в заданном радиусе (default 2000м), отсортированы
по weight DESC.
"""
# Получить координаты центроида участка из геометрических таблиц.
# См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках
@ -3093,6 +3123,8 @@ async def get_poi_score(
lat = float(coord_row["lat"])
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)

View file

@ -45,29 +45,56 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
("power", "line", "utility", "power_line"),
("power", "minor_line", "utility", "power_line"),
("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", "wastewater_plant", "utility", "wastewater_plant"),
("man_made", "water_tower", "utility", "water_tower"),
("man_made", "storage_tank", "utility", "storage_tank"),
# Электроподстанции — потенциальные точки подключения 10/35/110 кВ
# Электроподстанции / трансформаторы — точки подключения 10/35/110 кВ (#44)
("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:
"""Запрос Overpass для одного типа элемента.
Для way используем `out geom` получаем полные координаты узлов (нужно
для построения LineString геометрии).
Для way / nwr используем `out geom` получаем полные координаты узлов
(нужно для построения LineString / Polygon геометрии way-площадок).
Для node `out body`.
"""
south, west, north, east = EKB_BBOX
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":
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]:
@ -81,8 +108,14 @@ async def fetch_overpass_noise() -> list[dict]:
all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
for key, value, source_type, road_class in _NOISE_QUERIES:
# aerodrome — node; остальное — way
el_type = "node" if key == "aeroway" else "way"
# aerodrome — node; точки подключения инж.сетей — nwr (node+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)
try:
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.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
from app.services.site_finder import ors_client
logger = logging.getLogger(__name__)
# Веса по категории — согласованы с _POI_WEIGHTS в parcels.py + новые из vault B6.
@ -157,3 +168,200 @@ def compute_poi_weighted_top7(
radius_m=radius_m,
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,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY id
@ -418,6 +419,9 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
},
"source": "cad_zouit",
"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))

View file

@ -496,8 +496,15 @@ def test_cad_zouit_fallback_fires_when_no_dump() -> None:
Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'.
"""
cad_zouit_rows: list[tuple[Any, ...]] = [
# (type_zone, category_name, name_by_doc, reg_numb_border, id)
("Охранная зона трубопровода", "Трубопровод", "ГВС ул. Ленина", "66-66-00/123", 42),
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
(
"Охранная зона трубопровода",
"Трубопровод",
"ГВС ул. Ленина",
"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)
@ -540,9 +547,16 @@ def test_cad_zouit_fallback_empty_when_no_overlaps() -> None:
def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
"""_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны."""
rows: list[tuple[Any, ...]] = [
# (type_zone, category_name, name_by_doc, reg_numb_border, id)
("Охранная зона ЛЭП 110кВ", "Электроснабжение", "ВЛ-110", "66-123", 7),
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8),
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
(
"Охранная зона ЛЭП 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)
@ -556,6 +570,10 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
assert lep["subcategory"] is None # всегда NULL в cad_zouit
assert lep["name"] == "ВЛ-110"
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:

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.
"""
import pytest
from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import (
CATEGORY_WEIGHTS,
PoiScoreResponse,
_category_radius_min,
_category_weight,
_decay_linear,
_decay_piecewise,
_decay_step,
compute_poi_routing_decay,
compute_poi_weighted_top7,
)
@ -166,3 +174,162 @@ def test_address_none_when_no_tags():
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
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 (
EMPTY_DUMP_RESULT,
_get_cad_zouit_overlaps,
derive_quarter_cad,
get_quarter_dump_data,
make_empty_result,
@ -305,6 +306,82 @@ def test_get_quarter_dump_includes_nspd_fields() -> None:
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 ─────────────────────────────────────