Merge pull request 'feat(analyze): per-category OSRM routing — foot vs driving (#39 A3)' (#1935) from feat/39-osrm-per-category into main
This commit is contained in:
commit
94d3a981c8
8 changed files with 411 additions and 59 deletions
|
|
@ -254,51 +254,101 @@ def _apply_osrm_road_distances(
|
|||
origin_lat: float,
|
||||
label: str,
|
||||
) -> 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),
|
||||
сохраняют свой straight-line `distance_m`. При ЛЮБОМ сбое OSRM
|
||||
(OsrmLocalUnavailableError) — логируем и НИЧЕГО не меняем (straight-line по
|
||||
всем POI). Эта функция НИКОГДА не бросает исключение — /analyze не падает,
|
||||
даже если OSRM лежит.
|
||||
сохраняют свой straight-line `distance_m`. Каждая группа (walk/drive) дёргает
|
||||
свой OSRM-сервис в отдельном try/except: если FOOT-сервис лёг, walk-POI остаются
|
||||
straight-line, а drive-POI всё равно получают дорожное расстояние (и наоборот).
|
||||
Эта функция НИКОГДА не бросает исключение — /analyze не падает, даже если оба
|
||||
OSRM лежат.
|
||||
|
||||
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.
|
||||
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:
|
||||
# Берём только POI с валидными координатами; разбиваем на walk/drive по
|
||||
# категории. Каждый элемент несёт ORIGINAL index → корректный write-back.
|
||||
walk: list[tuple[int, tuple[float, float]]] = []
|
||||
drive: list[tuple[int, tuple[float, float]]] = []
|
||||
for i, p in enumerate(pois):
|
||||
if p.get("lon") is None or p.get("lat") is None:
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
Свой try/except на группу: сбой одного OSRM-сервиса не трогает другую группу.
|
||||
"""
|
||||
if not group:
|
||||
return 0
|
||||
try:
|
||||
road = get_road_distances_m(
|
||||
origin_lon,
|
||||
origin_lat,
|
||||
[coord for _, coord in group],
|
||||
profile=profile,
|
||||
base_url=base_url,
|
||||
)
|
||||
except OsrmLocalUnavailableError as exc:
|
||||
# Graceful: этот OSRM-сервис недоступен → straight-line по группе.
|
||||
logger.warning(
|
||||
"OSRM road-distance unavailable (%s, %s) — straight-line fallback: %s",
|
||||
label,
|
||||
group_label,
|
||||
exc,
|
||||
)
|
||||
return 0
|
||||
replaced = 0
|
||||
for (orig_idx, _coord), dist in zip(group, road, strict=True):
|
||||
if dist is not None:
|
||||
pois[orig_idx]["distance_m"] = dist
|
||||
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(
|
||||
"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,
|
||||
replaced,
|
||||
len(pois),
|
||||
walk_replaced,
|
||||
len(walk),
|
||||
drive_replaced,
|
||||
len(drive),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -194,13 +194,39 @@ class Settings(BaseSettings):
|
|||
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
||||
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
||||
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
|
||||
osrm_local_url: str = "http://osrm:5000"
|
||||
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
||||
# сервисе — при превышении → fallback на straight-line.
|
||||
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 —
|
||||
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
|
||||
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
|
||||
|
|
|
|||
|
|
@ -55,15 +55,20 @@ def get_road_distances_m(
|
|||
*,
|
||||
profile: str = "driving",
|
||||
timeout_s: float | None = None,
|
||||
base_url: str | 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' по умолчанию).
|
||||
profile: OSRM-профиль ('driving' по умолчанию; 'foot' для пешего графа).
|
||||
timeout_s: httpx timeout (обязателен — иначе worker зависает).
|
||||
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:
|
||||
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
|
||||
|
|
@ -94,7 +99,7 @@ def get_road_distances_m(
|
|||
[_fmt_coord(origin_lon, origin_lat)]
|
||||
+ [_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}"
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,26 @@ services:
|
|||
start_period: 40s
|
||||
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:
|
||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -52,35 +52,37 @@ bbox Свердл (с запасом): `lon 57.2..66.2, lat 56.0..62.0`. ЕКБ-
|
|||
(скрипт делает это через тот же `osrm/osrm-backend` образ). `osrm-contract` —
|
||||
это 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
|
||||
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, чтобы не
|
||||
плодить idle-контейнер пока /analyze не интегрирован). Блок для будущего:
|
||||
`CAR=0` пропускает car-этап (foot-only refresh не перезаписывает работающий
|
||||
`sverdlovsk.osrm`, который mmap'нут живым контейнером `osrm`).
|
||||
|
||||
```yaml
|
||||
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: 1g
|
||||
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]
|
||||
**Compose-service `osrm-walk`** добавлен в `docker-compose.prod.yml` (рядом с `osrm`,
|
||||
internal-only, `mem_limit 1.5g`). Backend ходит к нему по `http://osrm-walk:5000`
|
||||
(`settings.osrm_walk_local_url`). До сборки графа контейнер crash-loop'ит — безвредно
|
||||
(backend не depends_on, флаг OFF).
|
||||
|
||||
Поднять после сборки:
|
||||
|
||||
```bash
|
||||
docker compose -p gendesign -f docker-compose.prod.yml up -d --no-deps osrm-walk
|
||||
docker run --rm --network gendesign_default curlimages/curl:8 -fsS \
|
||||
'http://osrm-walk:5000/route/v1/foot/60.6,56.84;60.65,56.86' # ожидаем code:Ok
|
||||
```
|
||||
|
||||
Backend ходил бы к нему по `http://osrm-walk:5000`.
|
||||
|
||||
### Monthly refresh
|
||||
|
||||
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
|
||||
EKB_TIGHT="${EKB_TIGHT:-0}" # 1 = узкий bbox только ЕКБ-агломерация
|
||||
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 образе
|
||||
FOOT_PROFILE="${FOOT_PROFILE:-/opt/foot.lua}"
|
||||
|
||||
|
|
@ -132,8 +133,12 @@ build_profile() {
|
|||
osrm osrm-customize "$extracted"
|
||||
}
|
||||
|
||||
echo "=== 3. Build car profile → ${REGION}.osrm* ==="
|
||||
build_profile "$CAR_PROFILE" "$WORK_PBF"
|
||||
if [ "$CAR" = "1" ]; then
|
||||
echo "=== 3. Build car profile → ${REGION}.osrm* ==="
|
||||
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) --------------------------------------
|
||||
if [ "$WALK" = "1" ]; then
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue