From 50555821fb9b8e71f45de61f0a3b794b819821b8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 22:31:56 +0500 Subject: [PATCH] =?UTF-8?q?feat(site=5Ffinder):=20routing=20POI=20decay=20?= =?UTF-8?q?(#41)=20+=20OSM=20utility=20distances=20(#44)=20+=20=D0=97?= =?UTF-8?q?=D0=9E=D0=A3=D0=98=D0=A2=20geom=20(#255bk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #41: per-category routing-radius decay через reusable ORS /matrix client (foot-walk время + per-category пороги + decay-curves), opt-in ?decay=routing, graceful straight-line fallback (analyze не 500 при ORS down) + length-guard durations==destinations. #44: +5 OSM-категорий (transformer/gas/water/sewerage/heat) via Overpass nwr; nearest_{water_main,substation,gas,heat}_m в analyze.utilities. #255bk: ST_AsGeoJSON(CAST(geom AS geometry)) в _get_cad_zouit_overlaps → geom_geojson. Closes #41 Closes #44 --- backend/app/api/v1/parcels.py | 44 +++- .../app/services/site_finder/noise_loader.py | 47 +++- .../app/services/site_finder/ors_client.py | 138 +++++++++++ backend/app/services/site_finder/poi_score.py | 214 +++++++++++++++++- .../site_finder/quarter_dump_lookup.py | 6 +- .../services/test_quarter_dump_lookup.py | 28 ++- backend/tests/test_noise_loader.py | 100 ++++++++ backend/tests/test_ors_client.py | 123 ++++++++++ backend/tests/test_poi_score.py | 167 ++++++++++++++ backend/tests/test_quarter_dump_lookup.py | 77 +++++++ 10 files changed, 922 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/site_finder/ors_client.py create mode 100644 backend/tests/test_noise_loader.py create mode 100644 backend/tests/test_ors_client.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 616702e2..77d09276 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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) diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index 30802325..666b089b 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -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}) diff --git a/backend/app/services/site_finder/ors_client.py b/backend/app/services/site_finder/ors_client.py new file mode 100644 index 00000000..48594de0 --- /dev/null +++ b/backend/app/services/site_finder/ors_client.py @@ -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 diff --git a/backend/app/services/site_finder/poi_score.py b/backend/app/services/site_finder/poi_score.py index 31a7032b..deeb4b52 100644 --- a/backend/app/services/site_finder/poi_score.py +++ b/backend/app/services/site_finder/poi_score.py @@ -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], + ) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index f066d9c7..10ed6210 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -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 → ). + # 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)) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 26165624..0b9da878 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -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: diff --git a/backend/tests/test_noise_loader.py b/backend/tests/test_noise_loader.py new file mode 100644 index 00000000..9dccd2b7 --- /dev/null +++ b/backend/tests/test_noise_loader.py @@ -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 diff --git a/backend/tests/test_ors_client.py b/backend/tests/test_ors_client.py new file mode 100644 index 00000000..5f7f0a07 --- /dev/null +++ b/backend/tests/test_ors_client.py @@ -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)]) diff --git a/backend/tests/test_poi_score.py b/backend/tests/test_poi_score.py index e633894c..43753ac0 100644 --- a/backend/tests/test_poi_score.py +++ b/backend/tests/test_poi_score.py @@ -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}×" diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 35b75d84..7d7b3c88 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -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 ───────────────────────────────────── -- 2.45.3