feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2)
Заменяет straight-line ST_Distance в POI-скоринге /analyze реальным дорожным расстоянием центроид→POI из локального OSRM (/table GET с annotations=distance → метры), за флагом use_osrm_distances. Флаг default OFF → деплой поведенчески-нейтрален: при OFF OSRM не дёргается вообще, /analyze байт-в-байт как сегодня. Включение сдвигает все POI-score на ~20-30% (дорога длиннее прямой) — осознанное позднее решение после валидации, НЕ этот PR. Graceful fallback: любой сбой OSRM (HTTP / timeout / code!=Ok / битая форма) → OsrmLocalUnavailableError → straight-line по всем POI, /analyze НЕ падает в 500. Недостижимый POI (null от OSRM) → сохраняет straight-line. Покрыто и для OSM POI (osm_poi_ekb), и для custom POI (user_custom_pois). Refs #39
This commit is contained in:
parent
3753079dee
commit
1d367f5d8e
5 changed files with 620 additions and 4 deletions
|
|
@ -70,6 +70,10 @@ from app.services.site_finder.custom_pois import (
|
||||||
from app.services.site_finder.developer_attribution import get_developer_attribution
|
from app.services.site_finder.developer_attribution import get_developer_attribution
|
||||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||||
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||||||
|
from app.services.site_finder.osrm_client_local import (
|
||||||
|
OsrmLocalUnavailableError,
|
||||||
|
get_road_distances_m,
|
||||||
|
)
|
||||||
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
|
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
|
||||||
from app.services.site_finder.poi_score import (
|
from app.services.site_finder.poi_score import (
|
||||||
PoiScoreResponse,
|
PoiScoreResponse,
|
||||||
|
|
@ -243,6 +247,61 @@ def _verbal_for_poi(
|
||||||
return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
|
return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_osrm_road_distances(
|
||||||
|
pois: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
origin_lon: float,
|
||||||
|
origin_lat: float,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
"""In-place заменить `distance_m` каждого POI на дорожное расстояние из OSRM (#39 A2).
|
||||||
|
|
||||||
|
POI без валидных lon/lat и POI, до которых OSRM не построил маршрут (None),
|
||||||
|
сохраняют свой straight-line `distance_m`. При ЛЮБОМ сбое OSRM
|
||||||
|
(OsrmLocalUnavailableError) — логируем и НИЧЕГО не меняем (straight-line по
|
||||||
|
всем POI). Эта функция НИКОГДА не бросает исключение — /analyze не падает,
|
||||||
|
даже если OSRM лежит.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pois: список dict с ключами 'lon', 'lat', 'distance_m' (мутируется in-place).
|
||||||
|
origin_lon, origin_lat: центроид участка = OSRM sources=0.
|
||||||
|
label: контекст для логов (например, "osm cad=66:...").
|
||||||
|
"""
|
||||||
|
# Берём только POI с валидными координатами; индексы для обратного маппинга.
|
||||||
|
routable: list[tuple[int, tuple[float, float]]] = [
|
||||||
|
(i, (float(p["lon"]), float(p["lat"])))
|
||||||
|
for i, p in enumerate(pois)
|
||||||
|
if p.get("lon") is not None and p.get("lat") is not None
|
||||||
|
]
|
||||||
|
if not routable:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
road = get_road_distances_m(
|
||||||
|
origin_lon,
|
||||||
|
origin_lat,
|
||||||
|
[coord for _, coord in routable],
|
||||||
|
)
|
||||||
|
except OsrmLocalUnavailableError as exc:
|
||||||
|
# Graceful: OSRM недоступен → оставляем straight-line по всем POI.
|
||||||
|
logger.warning(
|
||||||
|
"OSRM road-distance unavailable (%s) — straight-line fallback: %s", label, exc
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
replaced = 0
|
||||||
|
for (orig_idx, _coord), dist in zip(routable, road, strict=True):
|
||||||
|
if dist is not None:
|
||||||
|
pois[orig_idx]["distance_m"] = dist
|
||||||
|
replaced += 1
|
||||||
|
logger.info(
|
||||||
|
"OSRM road-distance applied (%s): %d/%d POI заменены (остальные straight-line)",
|
||||||
|
label,
|
||||||
|
replaced,
|
||||||
|
len(pois),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||||
# Добавляй регионы по мере расширения географии продукта.
|
# Добавляй регионы по мере расширения географии продукта.
|
||||||
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||||||
|
|
@ -1566,9 +1625,12 @@ def analyze_parcel(
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3) POI в радиусе 1 км — список с distance_m
|
# 3) POI в радиусе 1 км — список с distance_m (straight-line, ST_Distance).
|
||||||
poi_rows = (
|
# В dict, а не RowMapping (read-only) — чтобы при включённом OSRM (#39 A2)
|
||||||
db.execute(
|
# можно было перезаписать distance_m реальным дорожным расстоянием.
|
||||||
|
poi_rows: list[dict[str, Any]] = [
|
||||||
|
dict(r)
|
||||||
|
for r in db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT category,
|
SELECT category,
|
||||||
name,
|
name,
|
||||||
|
|
@ -1591,7 +1653,31 @@ def analyze_parcel(
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
]
|
||||||
|
|
||||||
|
# 3a) OSRM road-distance (#39 A2) — flag-gated, default OFF.
|
||||||
|
# При use_osrm_distances=True заменяем straight-line distance_m на реальное
|
||||||
|
# дорожное (центроид→POI) из локального OSRM. Любой сбой OSRM → straight-line
|
||||||
|
# fallback (analyze НЕ падает). При OFF — байт-в-байт как раньше (нет вызова).
|
||||||
|
if settings.use_osrm_distances and poi_rows:
|
||||||
|
_osrm_origin = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
|
||||||
|
ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
|
||||||
|
"""),
|
||||||
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if _osrm_origin and _osrm_origin["lon"] is not None and _osrm_origin["lat"] is not None:
|
||||||
|
_apply_osrm_road_distances(
|
||||||
|
poi_rows,
|
||||||
|
origin_lon=float(_osrm_origin["lon"]),
|
||||||
|
origin_lat=float(_osrm_origin["lat"]),
|
||||||
|
label=f"osm cad={cad_num}",
|
||||||
|
)
|
||||||
|
|
||||||
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
||||||
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
|
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
|
||||||
|
|
@ -2904,6 +2990,15 @@ def analyze_parcel(
|
||||||
_custom_overlaps = _get_custom_poi_overlaps(
|
_custom_overlaps = _get_custom_poi_overlaps(
|
||||||
db, geom_wkt, _session_id, parcel_cad=cad_num
|
db, geom_wkt, _session_id, parcel_cad=cad_num
|
||||||
)
|
)
|
||||||
|
# OSRM road-distance (#39 A2) — flag-gated, default OFF. Тот же graceful
|
||||||
|
# fallback, что и для OSM POI: сбой OSRM → straight-line, analyze не падает.
|
||||||
|
if settings.use_osrm_distances and _custom_overlaps and not centroid_is_fallback:
|
||||||
|
_apply_osrm_road_distances(
|
||||||
|
_custom_overlaps,
|
||||||
|
origin_lon=centroid_lon,
|
||||||
|
origin_lat=centroid_lat,
|
||||||
|
label=f"custom cad={cad_num}",
|
||||||
|
)
|
||||||
for cp in _custom_overlaps:
|
for cp in _custom_overlaps:
|
||||||
_distance_m = cp["distance_m"]
|
_distance_m = cp["distance_m"]
|
||||||
_decay = max(0.0, 1.0 - _distance_m / 1000.0)
|
_decay = max(0.0, 1.0 - _distance_m / 1000.0)
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,24 @@ class Settings(BaseSettings):
|
||||||
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
||||||
openrouteservice_api_key: str = ""
|
openrouteservice_api_key: str = ""
|
||||||
|
|
||||||
|
# ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
|
||||||
|
# Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
|
||||||
|
# (self-hosted, /table GET с annotations=distance → метры) вместо
|
||||||
|
# straight-line ST_Distance в POI-скоринге.
|
||||||
|
#
|
||||||
|
# use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
|
||||||
|
# на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
|
||||||
|
# (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
|
||||||
|
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
||||||
|
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
||||||
|
use_osrm_distances: bool = False
|
||||||
|
# URL локального OSRM (docker-compose service `osrm`). /table endpoint:
|
||||||
|
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
|
||||||
|
osrm_local_url: str = "http://osrm:5000"
|
||||||
|
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
||||||
|
# сервисе — при превышении → fallback на straight-line.
|
||||||
|
osrm_distance_timeout_s: float = 12.0
|
||||||
|
|
||||||
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
|
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
|
||||||
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
|
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
|
||||||
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
|
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
|
||||||
|
|
|
||||||
140
backend/app/services/site_finder/osrm_client_local.py
Normal file
140
backend/app/services/site_finder/osrm_client_local.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Тонкий клиент локального OSRM — реальное дорожное расстояние для /analyze (#39 A2).
|
||||||
|
|
||||||
|
В отличие от ORS (`ors_client.py`, внешний SaaS с лимитом 2000/день и /matrix POST),
|
||||||
|
это self-hosted OSRM (docker-compose service `osrm`, `settings.osrm_local_url`). Его
|
||||||
|
table-API — это **GET** с координатами в URL и `annotations=distance`, отдающий
|
||||||
|
**реальное дорожное расстояние в МЕТРАХ** (а не durations×скорость — это была бы
|
||||||
|
ORS-семантика):
|
||||||
|
|
||||||
|
GET {url}/table/v1/driving/{lon0},{lat0};{lon1},{lat1};...?sources=0&annotations=distance
|
||||||
|
→ {"code":"Ok","distances":[[0, d1, d2, ...]]} # метры; row[0] = origin→каждый dest
|
||||||
|
|
||||||
|
origin = центроид участка (первая координата, sources=0); destinations = POI.
|
||||||
|
Читаем `distances[0][1:]` (пропускаем self origin→origin = 0). Недостижимый POI =
|
||||||
|
`null` → возвращаем None по этому индексу (caller оставит straight-line).
|
||||||
|
|
||||||
|
Graceful: при ЛЮБОМ сбое (HTTP, timeout, code!=Ok, битая форма) → `OsrmLocalUnavailableError`.
|
||||||
|
Caller обязан поймать и сделать fallback на straight-line, НЕ валя /analyze.
|
||||||
|
|
||||||
|
OSRM запускается с `--max-table-size 8000`, поэтому ≤100 POI на analyze проходят
|
||||||
|
без усечения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OSRM `--max-table-size` (число пар sources+destinations). Дефолт сервиса — 8000;
|
||||||
|
# с 1 origin это 7999 POI, что с запасом покрывает наши ≤100. Держим как guard.
|
||||||
|
MAX_TABLE_SIZE = 8000
|
||||||
|
|
||||||
|
|
||||||
|
class OsrmLocalUnavailableError(Exception):
|
||||||
|
"""Локальный OSRM недоступен (сетевой сбой / timeout / code!=Ok / битый ответ).
|
||||||
|
|
||||||
|
Caller обязан поймать и сделать fallback (straight-line), не валя /analyze.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_coord(lon: float, lat: float) -> str:
|
||||||
|
"""OSRM-координата `lon,lat`, URL-safe (на случай экзотических локалей/инпута)."""
|
||||||
|
return f"{quote(str(lon), safe='')},{quote(str(lat), safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_road_distances_m(
|
||||||
|
origin_lon: float,
|
||||||
|
origin_lat: float,
|
||||||
|
destinations: list[tuple[float, float]],
|
||||||
|
*,
|
||||||
|
profile: str = "driving",
|
||||||
|
timeout_s: float | None = None,
|
||||||
|
) -> list[float | None]:
|
||||||
|
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
|
||||||
|
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
|
||||||
|
profile: OSRM-профиль ('driving' по умолчанию).
|
||||||
|
timeout_s: httpx timeout (обязателен — иначе worker зависает).
|
||||||
|
None → settings.osrm_distance_timeout_s.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
|
||||||
|
дорожное расстояние в метрах (float) или None, если OSRM не смог построить
|
||||||
|
маршрут до этой точки (POI на острове/без дорожной связи) — caller оставит
|
||||||
|
straight-line по этому POI. Пустой `destinations` → [].
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OsrmLocalUnavailableError: сетевой сбой / timeout / HTTP!=2xx / code!=Ok /
|
||||||
|
неожиданный формат / число расстояний != числу destinations. Caller
|
||||||
|
делает straight-line fallback, /analyze не падает.
|
||||||
|
"""
|
||||||
|
if not destinations:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(destinations) > MAX_TABLE_SIZE - 1:
|
||||||
|
# OSRM отвергнет таблицу больше max-table-size — считаем это «недоступностью»
|
||||||
|
# для данного запроса (caller → straight-line по всем POI).
|
||||||
|
raise OsrmLocalUnavailableError(
|
||||||
|
f"OSRM table: {len(destinations)} destinations > max-table-size "
|
||||||
|
f"({MAX_TABLE_SIZE - 1} с одним origin)"
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_timeout = timeout_s if timeout_s is not None else settings.osrm_distance_timeout_s
|
||||||
|
|
||||||
|
# Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку.
|
||||||
|
coords = ";".join(
|
||||||
|
[_fmt_coord(origin_lon, origin_lat)]
|
||||||
|
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
|
||||||
|
)
|
||||||
|
base = settings.osrm_local_url.rstrip("/")
|
||||||
|
url = f"{base}/table/v1/{profile}/{coords}"
|
||||||
|
params = {"sources": "0", "annotations": "distance"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=effective_timeout) as client:
|
||||||
|
resp = client.get(url, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise OsrmLocalUnavailableError(
|
||||||
|
f"OSRM table HTTP {exc.response.status_code}: {exc.response.text[:200]}"
|
||||||
|
) from exc
|
||||||
|
except Exception as exc: # сетевой сбой / timeout / JSON decode → fallback, не 500
|
||||||
|
raise OsrmLocalUnavailableError(f"OSRM table request failed: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(data, dict) or data.get("code") != "Ok":
|
||||||
|
code = data.get("code") if isinstance(data, dict) else "<non-dict>"
|
||||||
|
raise OsrmLocalUnavailableError(f"OSRM table: code != Ok ({code!r})")
|
||||||
|
|
||||||
|
# Ответ: {"distances": [[0, d1, d2, ...]]} — метры, одна строка (один source).
|
||||||
|
distances = data.get("distances")
|
||||||
|
if not isinstance(distances, list) or not distances:
|
||||||
|
raise OsrmLocalUnavailableError("OSRM table: пустой/битый distances в ответе")
|
||||||
|
row = distances[0]
|
||||||
|
if not isinstance(row, list) or not row:
|
||||||
|
raise OsrmLocalUnavailableError("OSRM table: distances[0] не список / пуст")
|
||||||
|
|
||||||
|
# row[0] = origin→origin = 0 (self) — пропускаем, читаем 1:.
|
||||||
|
dest_distances = row[1:]
|
||||||
|
out: list[float | None] = []
|
||||||
|
for d in dest_distances:
|
||||||
|
if d is None:
|
||||||
|
out.append(None) # OSRM не построил маршрут до этой точки
|
||||||
|
else:
|
||||||
|
out.append(float(d))
|
||||||
|
|
||||||
|
# Длина должна совпадать с числом destinations — иначе zip у вызывающего
|
||||||
|
# рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback.
|
||||||
|
if len(out) != len(destinations):
|
||||||
|
raise OsrmLocalUnavailableError(
|
||||||
|
f"OSRM table: distances ({len(out)}) != destinations ({len(destinations)})"
|
||||||
|
)
|
||||||
|
return out
|
||||||
237
backend/tests/api/v1/test_analyze_osrm_distances.py
Normal file
237
backend/tests/api/v1/test_analyze_osrm_distances.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Тесты OSRM road-distance в POST /api/v1/parcels/{cad}/analyze (#39 A2).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
1. flag ON + mocked OSRM → distance_m в score_breakdown заменён дорожным расстоянием,
|
||||||
|
POI-вклад (score) отражает road-distance, а не straight-line.
|
||||||
|
2. flag ON + OSRM бросает OsrmLocalUnavailableError → fallback на straight-line,
|
||||||
|
analyze возвращает 200 (НЕ 500), score = straight-line baseline.
|
||||||
|
3. flag OFF (default) → get_road_distances_m НЕ вызывается ни разу (байт-в-байт
|
||||||
|
как сегодня).
|
||||||
|
|
||||||
|
Mock-стратегия как в test_analyze_inline_weights.py: DB через dependency_overrides,
|
||||||
|
тяжёлые service-вызовы через patch, OSRM-клиент через patch на
|
||||||
|
app.api.v1.parcels.get_road_distances_m.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.services.site_finder.osrm_client_local import OsrmLocalUnavailableError
|
||||||
|
|
||||||
|
_CAD = "66:41:0204016:10"
|
||||||
|
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
|
||||||
|
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.__getitem__ = lambda self, k: data[k]
|
||||||
|
m.get = lambda k, default=None: data.get(k, default)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
geom_row = _make_mapping(
|
||||||
|
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
|
||||||
|
)
|
||||||
|
wkt_row = _make_mapping({"wkt": _WKT})
|
||||||
|
district_row = _make_mapping(
|
||||||
|
{
|
||||||
|
"district_name": "Октябрьский",
|
||||||
|
"median_price_per_m2": 120000,
|
||||||
|
"dist_to_center": 1500.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||||
|
|
||||||
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
sql = " ".join(str(args[0]).split()) if args else ""
|
||||||
|
|
||||||
|
first_val: Any = None
|
||||||
|
all_val: list[Any] = []
|
||||||
|
|
||||||
|
if "AS geom_geojson" in sql:
|
||||||
|
first_val = geom_row
|
||||||
|
elif "AS wkt" in sql:
|
||||||
|
first_val = wkt_row
|
||||||
|
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||||
|
first_val = district_row
|
||||||
|
elif "AS lon" in sql and "AS lat" in sql:
|
||||||
|
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
|
||||||
|
first_val = centroid_row
|
||||||
|
elif "category" in sql and "ST_Distance" in sql and "p.geom" in sql:
|
||||||
|
all_val = poi_rows
|
||||||
|
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = first_val
|
||||||
|
r.mappings.return_value.all.return_value = all_val
|
||||||
|
r.scalar.return_value = 0
|
||||||
|
return r
|
||||||
|
|
||||||
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__enter__ = MagicMock(return_value=ctx)
|
||||||
|
ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.begin_nested.return_value = ctx
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
|
_PATCHES = [
|
||||||
|
patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
|
||||||
|
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||||
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.parcels.get_quarter_dump_data",
|
||||||
|
return_value={
|
||||||
|
"nspd_zoning": None,
|
||||||
|
"nspd_zouit_overlaps": [],
|
||||||
|
"nspd_engineering_nearby": [],
|
||||||
|
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch("app.api.v1.parcels.compute_velocity", return_value=None),
|
||||||
|
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.parcels.build_ird_analyze_block",
|
||||||
|
return_value={
|
||||||
|
"ird_overlaps": [],
|
||||||
|
"ird_by_kind": {},
|
||||||
|
"opportunity_overlaps": [],
|
||||||
|
"planning_projects": [],
|
||||||
|
"functional_zone": None,
|
||||||
|
"krt": [],
|
||||||
|
"zone_regulation": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _start_patches() -> None:
|
||||||
|
for p in _PATCHES:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_patches() -> None:
|
||||||
|
for p in _PATCHES:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _poi_row(category: str, name: str, lon: float, lat: float, distance_m: float) -> MagicMock:
|
||||||
|
"""POI-строка как RowMapping: parcels.py делает dict(r), поэтому нужен .keys()+items."""
|
||||||
|
data = {
|
||||||
|
"category": category,
|
||||||
|
"name": name,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"last_osm_edit_date": None,
|
||||||
|
}
|
||||||
|
m = MagicMock()
|
||||||
|
m.__getitem__ = lambda self, k: data[k]
|
||||||
|
m.keys = lambda: data.keys()
|
||||||
|
m.__iter__ = lambda self: iter(data)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _distance_in_breakdown(body: dict[str, Any], name: str) -> float:
|
||||||
|
"""Найти distance_m конкретного POI по имени в score_breakdown."""
|
||||||
|
for _cat, items in body["score_breakdown"].items():
|
||||||
|
for it in items:
|
||||||
|
if it["name"] == name:
|
||||||
|
return float(it["distance_m"])
|
||||||
|
raise AssertionError(f"POI {name!r} не найден в score_breakdown: {body['score_breakdown']}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
|
||||||
|
"""flag ON + mocked OSRM → distance_m заменён дорожным, score отражает road-distance."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
# straight-line 200м; OSRM вернёт 700м (дорога длиннее) → меньший decay → меньший score.
|
||||||
|
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||||
|
db = _make_db_for_analyze(poi_rows)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
|
||||||
|
) as mock_osrm:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# OSRM вызван ровно один раз (один POI-fetch).
|
||||||
|
assert mock_osrm.call_count >= 1
|
||||||
|
# distance_m в breakdown — дорожное (700), не straight-line (200).
|
||||||
|
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(700.0, abs=1.0)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_on_osrm_unavailable_falls_back(monkeypatch) -> None:
|
||||||
|
"""flag ON + OSRM бросает → straight-line fallback, analyze=200 (НЕ 500)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||||
|
db = _make_db_for_analyze(poi_rows)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_road_distances_m",
|
||||||
|
side_effect=OsrmLocalUnavailableError("osrm down"),
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
# КРИТИЧНО: /analyze НЕ падает в 500 когда OSRM лежит.
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# distance_m остаётся straight-line baseline (200), OSRM проигнорирован.
|
||||||
|
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_off_never_calls_osrm(monkeypatch) -> None:
|
||||||
|
"""flag OFF (default) → get_road_distances_m НЕ вызывается, distance = straight-line."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||||
|
db = _make_db_for_analyze(poi_rows)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
# Явно OFF (default, но фиксируем на случай env-override в окружении).
|
||||||
|
monkeypatch.setattr(settings, "use_osrm_distances", False)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m") as mock_osrm:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# OSRM НЕ дёрнут ни разу — байт-в-байт как сегодня.
|
||||||
|
assert mock_osrm.call_count == 0
|
||||||
|
# distance_m = straight-line baseline.
|
||||||
|
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
126
backend/tests/test_osrm_client_local.py
Normal file
126
backend/tests/test_osrm_client_local.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Тесты локального OSRM-клиента (#39 A2) — /table annotations=distance.
|
||||||
|
|
||||||
|
Сеть НЕ дёргается: подменяем httpx.Client на MockTransport. Покрывает:
|
||||||
|
- корректный URL (sources=0, annotations=distance, координаты в path);
|
||||||
|
- парсинг distances в МЕТРАХ + дроп self-index (row[0]);
|
||||||
|
- None для недостижимого POI;
|
||||||
|
- OsrmLocalUnavailableError на 500 / timeout / code!=Ok / битой форме;
|
||||||
|
- пустой destinations → [].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.site_finder import osrm_client_local as osrm
|
||||||
|
|
||||||
|
|
||||||
|
def _install_transport(monkeypatch: pytest.MonkeyPatch, handler) -> None:
|
||||||
|
"""Подменить httpx.Client в модуле osrm_client_local на MockTransport(handler).
|
||||||
|
|
||||||
|
Захватываем реальный httpx.Client ДО патча — иначе factory рекурсивно зовёт сама себя.
|
||||||
|
"""
|
||||||
|
real_client = httpx.Client
|
||||||
|
|
||||||
|
def client_factory(*_args, **kwargs):
|
||||||
|
kwargs.pop("transport", None)
|
||||||
|
return real_client(transport=httpx.MockTransport(handler), **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(osrm.httpx, "Client", client_factory)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builds_correct_url_and_parses_meters(monkeypatch):
|
||||||
|
"""URL: /table/v1/driving/{coords}?sources=0&annotations=distance; distances в метрах."""
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["path"] = request.url.path
|
||||||
|
seen["params"] = dict(request.url.params)
|
||||||
|
# distances[0] = origin→[origin, d1, d2] — метры; self = 0.
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 1234.5, 6789.0]]})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
out = osrm.get_road_distances_m(
|
||||||
|
60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дорожные расстояния (метры), self-index сброшен.
|
||||||
|
assert out == [1234.5, 6789.0]
|
||||||
|
# URL: профиль driving, координаты origin первой (lon,lat) и POI.
|
||||||
|
assert seen["path"] == "/table/v1/driving/60.6,56.8;60.61,56.81;60.62,56.82"
|
||||||
|
# Параметры sources=0, annotations=distance.
|
||||||
|
assert seen["params"]["sources"] == "0"
|
||||||
|
assert seen["params"]["annotations"] == "distance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_poi_returns_none(monkeypatch):
|
||||||
|
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0, None]]})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||||||
|
assert out == [500.0, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_destinations_returns_empty():
|
||||||
|
"""Пустой destinations → [] без HTTP-вызова."""
|
||||||
|
assert osrm.get_road_distances_m(60.6, 56.8, []) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_500_raises_unavailable(monkeypatch):
|
||||||
|
"""HTTP 500 → OsrmLocalUnavailableError (не пробрасываем HTTPStatusError)."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(500, text="boom")
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
with pytest.raises(osrm.OsrmLocalUnavailableError, match="HTTP 500"):
|
||||||
|
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_raises_unavailable(monkeypatch):
|
||||||
|
"""Timeout/сетевой сбой → OsrmLocalUnavailableError."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
raise httpx.ConnectTimeout("timed out")
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
with pytest.raises(osrm.OsrmLocalUnavailableError, match="request failed"):
|
||||||
|
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_not_ok_raises_unavailable(monkeypatch):
|
||||||
|
"""code != Ok (например NoRoute) → OsrmLocalUnavailableError."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(200, json={"code": "NoRoute", "distances": None})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
with pytest.raises(osrm.OsrmLocalUnavailableError, match="code != Ok"):
|
||||||
|
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_shape_raises_unavailable(monkeypatch):
|
||||||
|
"""Ответ без distances → OsrmLocalUnavailableError."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "unexpected": True})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
with pytest.raises(osrm.OsrmLocalUnavailableError):
|
||||||
|
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_length_mismatch_raises_unavailable(monkeypatch):
|
||||||
|
"""Число расстояний != числу destinations → OsrmLocalUnavailableError (рассинхрон)."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
# 2 destinations, но distances содержит только self + 1.
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0]]})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
with pytest.raises(osrm.OsrmLocalUnavailableError, match="!= destinations"):
|
||||||
|
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||||||
Loading…
Add table
Reference in a new issue