feat(analyze): per-category OSRM routing (foot vs driving) (#39 A3)
Walk-relevant POIs (school/shop/park/kindergarten/pharmacy/stops) now route via a FOOT OSRM graph (osrm-walk service), car-relevant POIs (mall/hospital + unknown) keep the DRIVING graph. Validation showed driving overstated pedestrian-proximity distance — median 1.6-2.9x straight-line (#39). - config: osrm_walk_local_url + osrm_walk_categories (frozenset, 9 walk cats) - osrm_client_local: base_url override on get_road_distances_m (default unchanged) - _apply_osrm_road_distances: split POIs by category, per-group OSRM call with independent graceful fallback (one server down -> its group keeps straight-line), in-place write-back by original index; never raises; flag-OFF byte-identical - docker-compose: osrm-walk service (foot graph, internal, mem_limit 1.5g) - build_osrm.sh: CAR=0 gate for foot-only refresh (doesn't touch live car graph) - tests: per-category split, per-group fallback, asymmetric intra-group write-back Still flag-gated (use_osrm_distances OFF) — enabling is a product decision. Refs #39
This commit is contained in:
parent
6c8bf0e496
commit
8a3bae0cda
8 changed files with 411 additions and 59 deletions
|
|
@ -254,51 +254,101 @@ def _apply_osrm_road_distances(
|
||||||
origin_lat: float,
|
origin_lat: float,
|
||||||
label: str,
|
label: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""In-place заменить `distance_m` каждого POI на дорожное расстояние из OSRM (#39 A2).
|
"""In-place заменить `distance_m` каждого POI на дорожное расстояние из OSRM.
|
||||||
|
|
||||||
|
Per-category routing (#39 A3): walk-релевантные POI (category ∈
|
||||||
|
settings.osrm_walk_categories — школа/магазин/парк/остановки) маршрутизируются
|
||||||
|
по FOOT-графу (settings.osrm_walk_local_url, profile='foot'), потому что driving
|
||||||
|
переоценивает пешую доступность. Всё остальное (incl. shop_mall, hospital и
|
||||||
|
любая неизвестная/None категория) — по DRIVING-графу (settings.osrm_local_url,
|
||||||
|
profile='driving') как безопасный дефолт.
|
||||||
|
|
||||||
POI без валидных lon/lat и POI, до которых OSRM не построил маршрут (None),
|
POI без валидных lon/lat и POI, до которых OSRM не построил маршрут (None),
|
||||||
сохраняют свой straight-line `distance_m`. При ЛЮБОМ сбое OSRM
|
сохраняют свой straight-line `distance_m`. Каждая группа (walk/drive) дёргает
|
||||||
(OsrmLocalUnavailableError) — логируем и НИЧЕГО не меняем (straight-line по
|
свой OSRM-сервис в отдельном try/except: если FOOT-сервис лёг, walk-POI остаются
|
||||||
всем POI). Эта функция НИКОГДА не бросает исключение — /analyze не падает,
|
straight-line, а drive-POI всё равно получают дорожное расстояние (и наоборот).
|
||||||
даже если OSRM лежит.
|
Эта функция НИКОГДА не бросает исключение — /analyze не падает, даже если оба
|
||||||
|
OSRM лежат.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pois: список dict с ключами 'lon', 'lat', 'distance_m' (мутируется in-place).
|
pois: список dict с ключами 'lon', 'lat', 'distance_m', 'category'
|
||||||
|
(мутируется in-place).
|
||||||
origin_lon, origin_lat: центроид участка = OSRM sources=0.
|
origin_lon, origin_lat: центроид участка = OSRM sources=0.
|
||||||
label: контекст для логов (например, "osm cad=66:...").
|
label: контекст для логов (например, "osm cad=66:...").
|
||||||
"""
|
"""
|
||||||
# Берём только POI с валидными координатами; индексы для обратного маппинга.
|
# Берём только POI с валидными координатами; разбиваем на walk/drive по
|
||||||
routable: list[tuple[int, tuple[float, float]]] = [
|
# категории. Каждый элемент несёт ORIGINAL index → корректный write-back.
|
||||||
(i, (float(p["lon"]), float(p["lat"])))
|
walk: list[tuple[int, tuple[float, float]]] = []
|
||||||
for i, p in enumerate(pois)
|
drive: list[tuple[int, tuple[float, float]]] = []
|
||||||
if p.get("lon") is not None and p.get("lat") is not None
|
for i, p in enumerate(pois):
|
||||||
]
|
if p.get("lon") is None or p.get("lat") is None:
|
||||||
if not routable:
|
continue
|
||||||
|
coord = (float(p["lon"]), float(p["lat"]))
|
||||||
|
if p.get("category") in settings.osrm_walk_categories:
|
||||||
|
walk.append((i, coord))
|
||||||
|
else:
|
||||||
|
drive.append((i, coord))
|
||||||
|
|
||||||
|
if not walk and not drive:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _route_group(
|
||||||
|
group: list[tuple[int, tuple[float, float]]],
|
||||||
|
*,
|
||||||
|
base_url: str | None,
|
||||||
|
profile: str,
|
||||||
|
group_label: str,
|
||||||
|
) -> int:
|
||||||
|
"""Маршрутизировать одну группу, записать distance_m in-place, вернуть replaced.
|
||||||
|
|
||||||
|
Свой try/except на группу: сбой одного OSRM-сервиса не трогает другую группу.
|
||||||
|
"""
|
||||||
|
if not group:
|
||||||
|
return 0
|
||||||
try:
|
try:
|
||||||
road = get_road_distances_m(
|
road = get_road_distances_m(
|
||||||
origin_lon,
|
origin_lon,
|
||||||
origin_lat,
|
origin_lat,
|
||||||
[coord for _, coord in routable],
|
[coord for _, coord in group],
|
||||||
|
profile=profile,
|
||||||
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
except OsrmLocalUnavailableError as exc:
|
except OsrmLocalUnavailableError as exc:
|
||||||
# Graceful: OSRM недоступен → оставляем straight-line по всем POI.
|
# Graceful: этот OSRM-сервис недоступен → straight-line по группе.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"OSRM road-distance unavailable (%s) — straight-line fallback: %s", label, exc
|
"OSRM road-distance unavailable (%s, %s) — straight-line fallback: %s",
|
||||||
|
label,
|
||||||
|
group_label,
|
||||||
|
exc,
|
||||||
)
|
)
|
||||||
return
|
return 0
|
||||||
|
|
||||||
replaced = 0
|
replaced = 0
|
||||||
for (orig_idx, _coord), dist in zip(routable, road, strict=True):
|
for (orig_idx, _coord), dist in zip(group, road, strict=True):
|
||||||
if dist is not None:
|
if dist is not None:
|
||||||
pois[orig_idx]["distance_m"] = dist
|
pois[orig_idx]["distance_m"] = dist
|
||||||
replaced += 1
|
replaced += 1
|
||||||
|
return replaced
|
||||||
|
|
||||||
|
walk_replaced = _route_group(
|
||||||
|
walk,
|
||||||
|
base_url=settings.osrm_walk_local_url,
|
||||||
|
profile="foot",
|
||||||
|
group_label="walk",
|
||||||
|
)
|
||||||
|
drive_replaced = _route_group(
|
||||||
|
drive,
|
||||||
|
base_url=None, # → settings.osrm_local_url (DRIVING-граф)
|
||||||
|
profile="driving",
|
||||||
|
group_label="drive",
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"OSRM road-distance applied (%s): %d/%d POI заменены (остальные straight-line)",
|
"OSRM road-distance applied (%s): walk %d/%d, drive %d/%d POI заменены "
|
||||||
|
"(остальные straight-line)",
|
||||||
label,
|
label,
|
||||||
replaced,
|
walk_replaced,
|
||||||
len(pois),
|
len(walk),
|
||||||
|
drive_replaced,
|
||||||
|
len(drive),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,13 +194,39 @@ class Settings(BaseSettings):
|
||||||
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
||||||
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
||||||
use_osrm_distances: bool = False
|
use_osrm_distances: bool = False
|
||||||
# URL локального OSRM (docker-compose service `osrm`). /table endpoint:
|
# URL локального OSRM (docker-compose service `osrm`, DRIVING-граф). /table:
|
||||||
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
|
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
|
||||||
osrm_local_url: str = "http://osrm:5000"
|
osrm_local_url: str = "http://osrm:5000"
|
||||||
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
||||||
# сервисе — при превышении → fallback на straight-line.
|
# сервисе — при превышении → fallback на straight-line.
|
||||||
osrm_distance_timeout_s: float = 12.0
|
osrm_distance_timeout_s: float = 12.0
|
||||||
|
|
||||||
|
# ── Per-category OSRM routing (#39 A3) ────────────────────────────────────
|
||||||
|
# Валидация показала: DRIVING-расстояние ПЕРЕОЦЕНИВАЕТ пешеходно-релевантные
|
||||||
|
# POI (школа/магазин/парк) — медиана 1.6–2.9× straight-line. Фикс: walk-POI
|
||||||
|
# маршрутизируем по FOOT-графу (отдельный сервис `osrm-walk`), car-POI — по
|
||||||
|
# существующему DRIVING-графу.
|
||||||
|
#
|
||||||
|
# URL локального OSRM с FOOT-графом (docker-compose service `osrm-walk`):
|
||||||
|
# GET {url}/table/v1/foot/{coords}?sources=0&annotations=distance
|
||||||
|
osrm_walk_local_url: str = "http://osrm-walk:5000"
|
||||||
|
# Категории POI, маршрутизируемые по FOOT-графу (пешая доступность). Всё, чего
|
||||||
|
# НЕТ в этом множестве — incl. `shop_mall`, `hospital` и любая неизвестная/None
|
||||||
|
# категория — идёт через DRIVING-граф как безопасный дефолт (drive-side).
|
||||||
|
osrm_walk_categories: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"shop_small",
|
||||||
|
"pharmacy",
|
||||||
|
"shop_supermarket",
|
||||||
|
"kindergarten",
|
||||||
|
"school",
|
||||||
|
"park",
|
||||||
|
"bus_stop",
|
||||||
|
"tram_stop",
|
||||||
|
"metro_stop",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# ИРД-слой в 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 + прогрева
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,20 @@ def get_road_distances_m(
|
||||||
*,
|
*,
|
||||||
profile: str = "driving",
|
profile: str = "driving",
|
||||||
timeout_s: float | None = None,
|
timeout_s: float | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
) -> list[float | None]:
|
) -> list[float | None]:
|
||||||
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
|
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
|
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
|
||||||
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
|
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
|
||||||
profile: OSRM-профиль ('driving' по умолчанию).
|
profile: OSRM-профиль ('driving' по умолчанию; 'foot' для пешего графа).
|
||||||
timeout_s: httpx timeout (обязателен — иначе worker зависает).
|
timeout_s: httpx timeout (обязателен — иначе worker зависает).
|
||||||
None → settings.osrm_distance_timeout_s.
|
None → settings.osrm_distance_timeout_s.
|
||||||
|
base_url: базовый URL OSRM-сервиса. None → settings.osrm_local_url
|
||||||
|
(DRIVING-граф, поведение по умолчанию). Передаётся, например,
|
||||||
|
settings.osrm_walk_local_url для маршрутизации walk-POI по FOOT-графу
|
||||||
|
(#39 A3).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
|
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
|
||||||
|
|
@ -94,7 +99,7 @@ def get_road_distances_m(
|
||||||
[_fmt_coord(origin_lon, origin_lat)]
|
[_fmt_coord(origin_lon, origin_lat)]
|
||||||
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
|
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
|
||||||
)
|
)
|
||||||
base = settings.osrm_local_url.rstrip("/")
|
base = (base_url if base_url is not None else settings.osrm_local_url).rstrip("/")
|
||||||
url = f"{base}/table/v1/{profile}/{coords}"
|
url = f"{base}/table/v1/{profile}/{coords}"
|
||||||
params = {"sources": "0", "annotations": "distance"}
|
params = {"sources": "0", "annotations": "distance"}
|
||||||
|
|
||||||
|
|
|
||||||
204
backend/tests/api/v1/test_osrm_per_category.py
Normal file
204
backend/tests/api/v1/test_osrm_per_category.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""Тесты per-category OSRM routing в `_apply_osrm_road_distances` (#39 A3).
|
||||||
|
|
||||||
|
A3 чинит переоценку пешей доступности: walk-релевантные POI (school/shop/park/
|
||||||
|
остановки ∈ settings.osrm_walk_categories) маршрутизируются по FOOT-графу
|
||||||
|
(settings.osrm_walk_local_url, profile='foot'), всё остальное (incl. shop_mall,
|
||||||
|
hospital, неизвестная/None категория) — по DRIVING-графу (settings.osrm_local_url,
|
||||||
|
profile='driving') как безопасный дефолт.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
(a) разбиение по категориям: walk-POI → foot mock, drive-POI → car mock;
|
||||||
|
(b) per-group graceful fallback: один сервис лёг → его группа straight-line, другая
|
||||||
|
группа всё равно получает дорожное расстояние;
|
||||||
|
(c) None-/неизвестная категория → drive-группа (безопасный дефолт);
|
||||||
|
(d) функция НИКОГДА не бросает (оба сервиса легли → straight-line, нет исключения).
|
||||||
|
|
||||||
|
`get_road_distances_m` мокается на app.api.v1.parcels.get_road_distances_m
|
||||||
|
(реальный OSRM не дёргается), маршрутизация различается по kwargs base_url/profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.api.v1.parcels import _apply_osrm_road_distances
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.site_finder.osrm_client_local import OsrmLocalUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
def _poi(category: str | None, lon: float, lat: float, distance_m: float) -> dict[str, Any]:
|
||||||
|
return {"category": category, "lon": lon, "lat": lat, "distance_m": distance_m}
|
||||||
|
|
||||||
|
|
||||||
|
def test_splits_walk_and_drive_to_separate_servers() -> None:
|
||||||
|
"""walk-POI → foot-сервис/profile=foot; drive-POI → car-сервис/profile=driving."""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 200.0), # walk
|
||||||
|
_poi("shop_mall", 60.62, 56.85, 500.0), # drive (НЕ в walk-set)
|
||||||
|
_poi("park", 60.63, 56.86, 300.0), # walk
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
# FOOT-граф вернёт 111 для каждой walk-точки, DRIVING — 999.
|
||||||
|
if profile == "foot":
|
||||||
|
assert base_url == settings.osrm_walk_local_url
|
||||||
|
return [111.0] * len(dests)
|
||||||
|
assert profile == "driving"
|
||||||
|
assert base_url is None # → settings.osrm_local_url (DRIVING)
|
||||||
|
return [999.0] * len(dests)
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake) as mock:
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
# Ровно два вызова: один foot, один driving.
|
||||||
|
assert mock.call_count == 2
|
||||||
|
profiles = {c.kwargs["profile"] for c in mock.call_args_list}
|
||||||
|
assert profiles == {"foot", "driving"}
|
||||||
|
|
||||||
|
# walk-POI получили foot-расстояние, drive-POI — driving-расстояние.
|
||||||
|
assert pois[0]["distance_m"] == 111.0 # school → foot
|
||||||
|
assert pois[1]["distance_m"] == 999.0 # shop_mall → driving
|
||||||
|
assert pois[2]["distance_m"] == 111.0 # park → foot
|
||||||
|
|
||||||
|
|
||||||
|
def test_intra_group_writeback_is_position_correct() -> None:
|
||||||
|
"""Внутри группы расстояние пишется ИМЕННО своему POI (ловит транспозицию индексов).
|
||||||
|
|
||||||
|
Каждый mock возвращает значение, привязанное к КООРДИНАТЕ точки
|
||||||
|
(`round((lon-60.6)*10000)`), а не одинаковое на группу — поэтому перестановка
|
||||||
|
внутри walk- или drive-группы при write-back завалила бы ассерты.
|
||||||
|
"""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 1.0), # walk → ждём 100
|
||||||
|
_poi("shop_mall", 60.62, 56.85, 2.0), # drive → ждём 200
|
||||||
|
_poi("park", 60.63, 56.86, 3.0), # walk → ждём 300
|
||||||
|
_poi("hospital", 60.64, 56.87, 4.0), # drive → ждём 400
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
# Значение зависит от lon каждой точки → порядок внутри группы значим.
|
||||||
|
return [round((lon - 60.6) * 10000) for lon, _lat in dests]
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake):
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
assert pois[0]["distance_m"] == 100 # school (walk)
|
||||||
|
assert pois[1]["distance_m"] == 200 # shop_mall (drive)
|
||||||
|
assert pois[2]["distance_m"] == 300 # park (walk)
|
||||||
|
assert pois[3]["distance_m"] == 400 # hospital (drive)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_and_none_category_route_via_drive() -> None:
|
||||||
|
"""None / неизвестная категория → DRIVING-граф (безопасный дефолт)."""
|
||||||
|
pois = [
|
||||||
|
_poi(None, 60.61, 56.84, 200.0),
|
||||||
|
_poi("hospital", 60.62, 56.85, 400.0), # явно drive-side
|
||||||
|
_poi("totally_unknown", 60.63, 56.86, 600.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
# Не должно быть foot-вызова: все три POI — drive.
|
||||||
|
assert profile == "driving"
|
||||||
|
assert base_url is None
|
||||||
|
return [42.0] * len(dests)
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake) as mock:
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
assert mock.call_count == 1 # только driving-группа
|
||||||
|
assert all(p["distance_m"] == 42.0 for p in pois)
|
||||||
|
|
||||||
|
|
||||||
|
def test_foot_server_down_drive_still_replaced() -> None:
|
||||||
|
"""FOOT-сервис лёг → walk-POI остаются straight-line, drive-POI всё равно заменены."""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 200.0), # walk → foot (упадёт)
|
||||||
|
_poi("shop_mall", 60.62, 56.85, 500.0), # drive → ok
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
if profile == "foot":
|
||||||
|
raise OsrmLocalUnavailableError("foot down")
|
||||||
|
return [999.0] * len(dests)
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake):
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
# walk-POI: foot лёг → straight-line baseline сохранён.
|
||||||
|
assert pois[0]["distance_m"] == 200.0
|
||||||
|
# drive-POI: DRIVING-граф жив → дорожное расстояние применено.
|
||||||
|
assert pois[1]["distance_m"] == 999.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drive_server_down_walk_still_replaced() -> None:
|
||||||
|
"""DRIVING-сервис лёг → drive-POI straight-line, walk-POI всё равно заменены (foot жив)."""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 200.0), # walk → foot ok
|
||||||
|
_poi("shop_mall", 60.62, 56.85, 500.0), # drive (упадёт)
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
if profile == "driving":
|
||||||
|
raise OsrmLocalUnavailableError("driving down")
|
||||||
|
return [111.0] * len(dests)
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake):
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
# walk-POI: FOOT-граф жив → дорожное (пешее) расстояние применено.
|
||||||
|
assert pois[0]["distance_m"] == 111.0
|
||||||
|
# drive-POI: DRIVING лёг → straight-line baseline сохранён.
|
||||||
|
assert pois[1]["distance_m"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_servers_down_never_raises() -> None:
|
||||||
|
"""Оба OSRM легли → функция НЕ бросает, все POI остаются straight-line."""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 200.0),
|
||||||
|
_poi("shop_mall", 60.62, 56.85, 500.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_road_distances_m",
|
||||||
|
side_effect=OsrmLocalUnavailableError("all down"),
|
||||||
|
):
|
||||||
|
# Не должно поднять исключение — /analyze не падает.
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
assert pois[0]["distance_m"] == 200.0
|
||||||
|
assert pois[1]["distance_m"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_poi_without_coords_skipped() -> None:
|
||||||
|
"""POI без lon/lat не попадает ни в одну группу (сохраняет straight-line)."""
|
||||||
|
pois = [
|
||||||
|
_poi("school", 60.61, 56.84, 200.0), # walk
|
||||||
|
{"category": "shop_mall", "lon": None, "lat": None, "distance_m": 500.0}, # skip
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake(origin_lon, origin_lat, dests, *, profile, base_url):
|
||||||
|
# Только walk-группа с одной точкой; drive-группа пуста → нет driving-вызова.
|
||||||
|
assert profile == "foot"
|
||||||
|
assert len(dests) == 1
|
||||||
|
return [111.0]
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m", side_effect=fake) as mock:
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
assert mock.call_count == 1 # только foot (drive-группа пуста)
|
||||||
|
assert pois[0]["distance_m"] == 111.0
|
||||||
|
assert pois[1]["distance_m"] == 500.0 # без координат — не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_routable_pois_early_return() -> None:
|
||||||
|
"""Нет POI с координатами → ранний выход, OSRM не дёргается вообще."""
|
||||||
|
pois = [
|
||||||
|
{"category": "school", "lon": None, "lat": None, "distance_m": 200.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("app.api.v1.parcels.get_road_distances_m") as mock:
|
||||||
|
_apply_osrm_road_distances(pois, origin_lon=60.6, origin_lat=56.83, label="t")
|
||||||
|
|
||||||
|
assert mock.call_count == 0
|
||||||
|
assert pois[0]["distance_m"] == 200.0
|
||||||
|
|
@ -54,6 +54,46 @@ def test_builds_correct_url_and_parses_meters(monkeypatch):
|
||||||
assert seen["params"]["annotations"] == "distance"
|
assert seen["params"]["annotations"] == "distance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url_override_hits_foot_url(monkeypatch):
|
||||||
|
"""base_url override (#39 A3) → URL строится из переданного base + profile=foot."""
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["host"] = request.url.host
|
||||||
|
seen["path"] = request.url.path
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 321.0]]})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
out = osrm.get_road_distances_m(
|
||||||
|
60.6,
|
||||||
|
56.8,
|
||||||
|
[(60.61, 56.81)],
|
||||||
|
profile="foot",
|
||||||
|
base_url="http://osrm-walk:5000/", # trailing slash → должен быть срезан
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == [321.0]
|
||||||
|
# Хост — foot-сервис, не дефолтный osrm; trailing slash снят (нет //table).
|
||||||
|
assert seen["host"] == "osrm-walk"
|
||||||
|
assert seen["path"] == "/table/v1/foot/60.6,56.8;60.61,56.81"
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url_none_uses_default_driving(monkeypatch):
|
||||||
|
"""base_url=None (дефолт) → settings.osrm_local_url + profile=driving (старое поведение)."""
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["path"] = request.url.path
|
||||||
|
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 100.0]]})
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||||
|
|
||||||
|
assert out == [100.0]
|
||||||
|
# Дефолтный профиль driving, дефолтный base (settings.osrm_local_url).
|
||||||
|
assert seen["path"] == "/table/v1/driving/60.6,56.8;60.61,56.81"
|
||||||
|
|
||||||
|
|
||||||
def test_unreachable_poi_returns_none(monkeypatch):
|
def test_unreachable_poi_returns_none(monkeypatch):
|
||||||
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,26 @@ services:
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
networks: [default]
|
networks: [default]
|
||||||
|
|
||||||
|
# Пеший OSRM (#39 A3) — per-category routing: пешеходные POI (школа/магазин/парк/
|
||||||
|
# остановки) считаются по пешему графу, авто-POI (ТЦ/больница) — по `osrm` (car).
|
||||||
|
# Граф sverdlovsk-foot.osrm* собирается `CAR=0 WALK=1 bash scripts/build_osrm.sh`
|
||||||
|
# (или foot-only build_foot.sh). До сборки графа контейнер crash-loop'ит — безвредно
|
||||||
|
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
|
||||||
|
osrm-walk:
|
||||||
|
image: osrm/osrm-backend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
|
||||||
|
volumes:
|
||||||
|
- ./data/osrm:/data
|
||||||
|
mem_limit: 1.5g
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
networks: [default]
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -52,35 +52,37 @@ bbox Свердл (с запасом): `lon 57.2..66.2, lat 56.0..62.0`. ЕКБ-
|
||||||
(скрипт делает это через тот же `osrm/osrm-backend` образ). `osrm-contract` —
|
(скрипт делает это через тот же `osrm/osrm-backend` образ). `osrm-contract` —
|
||||||
это CH-алгоритм, он несовместим с `--algorithm mld`, поэтому НЕ используется.
|
это CH-алгоритм, он несовместим с `--algorithm mld`, поэтому НЕ используется.
|
||||||
|
|
||||||
### Walking-профиль (Phase-2, опционально)
|
### Walking-профиль + per-category routing (#39 A3 — LIVE)
|
||||||
|
|
||||||
Issue хочет оба профиля (car + foot) eventually. Этот PR **car-first**. Пеший граф:
|
Issue хочет оба профиля (car + foot). A3 их свёл в **per-category routing**: пешеходные
|
||||||
|
POI (школа, д/сад, магазины, аптека, парк, остановки) считаются по **пешему** графу
|
||||||
|
(`osrm-walk`), авто-POI (ТЦ, больница) — по **car** (`osrm`). Это убирает завышение
|
||||||
|
дистанций driving-профилем до близких-пешком POI (валидация #39: driving-медиана
|
||||||
|
1.6–2.9× straight-line). Маппинг категория→профиль: `settings.osrm_walk_categories`.
|
||||||
|
|
||||||
|
**Сборка пешего графа** (`sverdlovsk-foot.osrm*`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WALK=1 bash scripts/build_osrm.sh # дополнительно соберёт sverdlovsk-foot.osrm*
|
CAR=0 WALK=1 bash scripts/build_osrm.sh # только пеший граф, НЕ трогает живой car-граф
|
||||||
|
# (полный refresh обоих: WALK=1 bash scripts/build_osrm.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Для пешего routing нужен второй compose-service (он не добавлен в этом PR, чтобы не
|
`CAR=0` пропускает car-этап (foot-only refresh не перезаписывает работающий
|
||||||
плодить idle-контейнер пока /analyze не интегрирован). Блок для будущего:
|
`sverdlovsk.osrm`, который mmap'нут живым контейнером `osrm`).
|
||||||
|
|
||||||
```yaml
|
**Compose-service `osrm-walk`** добавлен в `docker-compose.prod.yml` (рядом с `osrm`,
|
||||||
osrm-walk:
|
internal-only, `mem_limit 1.5g`). Backend ходит к нему по `http://osrm-walk:5000`
|
||||||
image: osrm/osrm-backend:latest
|
(`settings.osrm_walk_local_url`). До сборки графа контейнер crash-loop'ит — безвредно
|
||||||
restart: unless-stopped
|
(backend не depends_on, флаг OFF).
|
||||||
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
|
|
||||||
volumes: [./data/osrm:/data]
|
Поднять после сборки:
|
||||||
mem_limit: 1g
|
|
||||||
healthcheck:
|
```bash
|
||||||
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
|
docker compose -p gendesign -f docker-compose.prod.yml up -d --no-deps osrm-walk
|
||||||
interval: 30s
|
docker run --rm --network gendesign_default curlimages/curl:8 -fsS \
|
||||||
timeout: 5s
|
'http://osrm-walk:5000/route/v1/foot/60.6,56.84;60.65,56.86' # ожидаем code:Ok
|
||||||
retries: 5
|
|
||||||
start_period: 40s
|
|
||||||
networks: [default]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend ходил бы к нему по `http://osrm-walk:5000`.
|
|
||||||
|
|
||||||
### Monthly refresh
|
### Monthly refresh
|
||||||
|
|
||||||
osm.pbf на Geofabrik обновляется ежедневно. Граф освежать ~ежемесячно:
|
osm.pbf на Geofabrik обновляется ежедневно. Граф освежать ~ежемесячно:
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ PBF_URL="${PBF_URL:-https://download.geofabrik.de/russia/ural-fed-district-lates
|
||||||
CLIP="${CLIP:-1}" # 1 = обрезать по bbox Свердл, 0 = весь Ural-FO
|
CLIP="${CLIP:-1}" # 1 = обрезать по bbox Свердл, 0 = весь Ural-FO
|
||||||
EKB_TIGHT="${EKB_TIGHT:-0}" # 1 = узкий bbox только ЕКБ-агломерация
|
EKB_TIGHT="${EKB_TIGHT:-0}" # 1 = узкий bbox только ЕКБ-агломерация
|
||||||
WALK="${WALK:-0}" # 1 = ещё и пеший профиль <region>-foot.osrm*
|
WALK="${WALK:-0}" # 1 = ещё и пеший профиль <region>-foot.osrm*
|
||||||
|
CAR="${CAR:-1}" # 0 = пропустить car-граф (foot-only refresh, #39 A3)
|
||||||
CAR_PROFILE="${CAR_PROFILE:-/opt/car.lua}" # встроенные профили в osrm-backend образе
|
CAR_PROFILE="${CAR_PROFILE:-/opt/car.lua}" # встроенные профили в osrm-backend образе
|
||||||
FOOT_PROFILE="${FOOT_PROFILE:-/opt/foot.lua}"
|
FOOT_PROFILE="${FOOT_PROFILE:-/opt/foot.lua}"
|
||||||
|
|
||||||
|
|
@ -132,8 +133,12 @@ build_profile() {
|
||||||
osrm osrm-customize "$extracted"
|
osrm osrm-customize "$extracted"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if [ "$CAR" = "1" ]; then
|
||||||
echo "=== 3. Build car profile → ${REGION}.osrm* ==="
|
echo "=== 3. Build car profile → ${REGION}.osrm* ==="
|
||||||
build_profile "$CAR_PROFILE" "$WORK_PBF"
|
build_profile "$CAR_PROFILE" "$WORK_PBF"
|
||||||
|
else
|
||||||
|
echo "=== 3. Skip car profile (CAR=0) — foot-only refresh, не трогаем живой ${REGION}.osrm ==="
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 4. MLD pipeline (foot, optional) --------------------------------------
|
# --- 4. MLD pipeline (foot, optional) --------------------------------------
|
||||||
if [ "$WALK" = "1" ]; then
|
if [ "$WALK" = "1" ]; then
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue