diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 7a3c73f7..da6a9b8a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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), ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ea0031fb..b7ba02c0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 + прогрева diff --git a/backend/app/services/site_finder/osrm_client_local.py b/backend/app/services/site_finder/osrm_client_local.py index c6141c01..e50bb9b2 100644 --- a/backend/app/services/site_finder/osrm_client_local.py +++ b/backend/app/services/site_finder/osrm_client_local.py @@ -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"} diff --git a/backend/tests/api/v1/test_osrm_per_category.py b/backend/tests/api/v1/test_osrm_per_category.py new file mode 100644 index 00000000..b2b2ad28 --- /dev/null +++ b/backend/tests/api/v1/test_osrm_per_category.py @@ -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 diff --git a/backend/tests/test_osrm_client_local.py b/backend/tests/test_osrm_client_local.py index a7825dbf..fd53dab8 100644 --- a/backend/tests/test_osrm_client_local.py +++ b/backend/tests/test_osrm_client_local.py @@ -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).""" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c490c82e..1616b82f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 '-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