feat(analyze): per-category OSRM routing (foot vs driving) (#39 A3)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / backend-tests (pull_request) Successful in 11m55s

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:
Light1YT 2026-06-27 04:30:25 +05:00
parent 6c8bf0e496
commit 8a3bae0cda
8 changed files with 411 additions and 59 deletions

View file

@ -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),
) )

View file

@ -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.62.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 + прогрева

View file

@ -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"}

View 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

View file

@ -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)."""

View file

@ -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

View file

@ -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.62.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 обновляется ежедневно. Граф освежать ~ежемесячно:

View file

@ -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