feat(site_finder): routing POI decay (#41) + OSM utility distances (#44) + ЗОУИТ geom (#255bk)
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m42s
CI / backend-tests (pull_request) Successful in 7m40s
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m42s
CI / backend-tests (pull_request) Successful in 7m40s
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
#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
This commit is contained in:
parent
adc6dde015
commit
50555821fb
10 changed files with 922 additions and 22 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
138
backend/app/services/site_finder/ors_client.py
Normal file
138
backend/app/services/site_finder/ors_client.py
Normal 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
|
||||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
100
backend/tests/test_noise_loader.py
Normal file
100
backend/tests/test_noise_loader.py
Normal 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
|
||||
123
backend/tests/test_ors_client.py
Normal file
123
backend/tests/test_ors_client.py
Normal 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)])
|
||||
|
|
@ -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}×"
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue