From 1e8811a5d217afde9d0b8f644e58fbf4d959cb3a Mon Sep 17 00:00:00 2001 From: Stream Date: Wed, 6 May 2026 18:50:01 +0300 Subject: [PATCH] auto-sync: 2026-05-06 18:50:01 --- tasks/enduro-trails/prototype/app.py | 151 +++ .../prototype/app.py.backup-20260506-154253 | 1066 +++++++++++++++++ 2 files changed, 1217 insertions(+) create mode 100644 tasks/enduro-trails/prototype/app.py.backup-20260506-154253 diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index dcba177..e688be4 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -12,6 +12,7 @@ import math import struct import sqlite3 import json +import itertools from pathlib import Path from shapely.geometry import LineString from typing import List @@ -886,6 +887,152 @@ async def post_scenic(req: ScenicRequest): raise HTTPException(500, f"Ошибка: {e}") +async def _build_segmented_route(req: RouteRequest) -> dict: + """Строит маршрут через промежуточные точки с альтернативами по сегментам.""" + waypoints = req.waypoints + segments_count = len(waypoints) - 1 + + segment_alternatives = [] # список списков маршрутов OSRM + + for i in range(segments_count): + wp_a = waypoints[i] + wp_b = waypoints[i + 1] + coords_str = f"{wp_a.lon},{wp_a.lat};{wp_b.lon},{wp_b.lat}" + radiuses_str = "5000;5000" + + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives=5&overview=full&geometries=geojson&annotations=false" + f"&radiuses={radiuses_str}" + ) + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # Retry TooBig → 3 → 1 + if data.get("code") == "TooBig": + url2 = url.replace("alternatives=5", "alternatives=3") + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url2) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + if data.get("code") == "TooBig": + url1 = url.replace("alternatives=5", "alternatives=false") + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url1) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # Retry NoSegment с radius 10km + if data.get("code") == "NoSegment": + url_wide = url.replace("5000;5000", "10000;10000") + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_wide) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") != "Ok" or not data.get("routes"): + raise HTTPException(404, f"Маршрут не найден на сегменте {i + 1}") + + segment_alternatives.append(data["routes"]) + + # Для каждого сегмента берём до 3 вариантов чтобы не взорвать комбинаторику + max_per_segment = 3 + trimmed = [alts[:max_per_segment] for alts in segment_alternatives] + + all_combos = list(itertools.product(*trimmed)) + + combined_routes = [] + for combo in all_combos: + total_distance = sum(r["distance"] for r in combo) + total_duration = sum(r["duration"] for r in combo) + + # Склеить геометрию, убирая дублирующую точку стыка + all_coords: list = [] + for r in combo: + coords = r["geometry"]["coordinates"] + if all_coords: + all_coords.extend(coords[1:]) + else: + all_coords.extend(coords) + + combined_routes.append({ + "distance": total_distance, + "duration": total_duration, + "geometry": {"type": "LineString", "coordinates": all_coords}, + "legs": [leg for r in combo for leg in r.get("legs", [])], + }) + + # Дедупликация: если дистанции отличаются менее чем на 2% — считать дублем + deduped: list = [] + for route in combined_routes: + is_dup = False + for existing in deduped: + if abs(route["distance"] - existing["distance"]) / max(existing["distance"], 1) < 0.02: + is_dup = True + break + if not is_dup: + deduped.append(route) + + # Топ-5 + deduped = deduped[:5] + + # Считаем статистику через существующую calc_route_stats + try: + conn = get_db() + except Exception: + conn = None + + routes_out = [] + for idx, route in enumerate(deduped): + stats = None + if conn is not None: + try: + stats = calc_route_stats(route["geometry"], conn) + except Exception: + stats = None + + routes_out.append({ + "index": idx, + "distance_m": round(route["distance"]), + "duration_s": round(route["duration"]), + "geometry": route["geometry"], + "stats": stats, + }) + + if conn is not None: + try: + conn.close() + except Exception: + pass + + if not routes_out: + raise HTTPException(404, "Маршрут не найден") + + # Сортировать по dirt_total_pct убывающий (больше грунта = лучше) + routes_out.sort(key=lambda r: (r["stats"] or {}).get("dirt_total_pct", 0), reverse=True) + for idx, r in enumerate(routes_out): + r["index"] = idx + + # Waypoints для ответа + waypoints_out = [] + for i, wp in enumerate(req.waypoints): + label = "Старт" if i == 0 else ("Финиш" if i == len(req.waypoints) - 1 else f"Точка {i}") + waypoints_out.append({"lon": wp.lon, "lat": wp.lat, "label": label}) + + return {"routes": routes_out, "waypoints": waypoints_out} + + @app.post("/api/route") async def post_route(req: RouteRequest): """ @@ -895,6 +1042,10 @@ async def post_route(req: RouteRequest): if len(req.waypoints) < 2: raise HTTPException(400, "Нужно минимум 2 точки") + # При промежуточных точках — сегментный подход + if len(req.waypoints) > 2: + return await _build_segmented_route(req) + # Строим строку координат для OSRM coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints) alternatives = max(1, min(5, req.alternatives)) diff --git a/tasks/enduro-trails/prototype/app.py.backup-20260506-154253 b/tasks/enduro-trails/prototype/app.py.backup-20260506-154253 new file mode 100644 index 0000000..dcba177 --- /dev/null +++ b/tasks/enduro-trails/prototype/app.py.backup-20260506-154253 @@ -0,0 +1,1066 @@ +#!/usr/bin/env python3 +""" +app.py — FastAPI сервер для Enduro Trails +- Раздаёт статику из static/ +- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite +- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия +- /api/health — статус БД +""" + +import os +import math +import struct +import sqlite3 +import json +from pathlib import Path +from shapely.geometry import LineString +from typing import List + +from functools import lru_cache +from fastapi import FastAPI, HTTPException, Response +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import httpx +import uvicorn + +# ─── Tile cache ────────────────────────────────────────────────────────────── + +_tile_cache: dict = {} +_TILE_CACHE_MAX = 512 + + +def get_cached_tile(z, x, y): + return _tile_cache.get((z, x, y)) + + +def set_cached_tile(z, x, y, data: bytes): + if len(_tile_cache) >= _TILE_CACHE_MAX: + # FIFO вытеснение — удаляем самый старый ключ + _tile_cache.pop(next(iter(_tile_cache))) + _tile_cache[(z, x, y)] = data + + +# ─── Конфиг ─────────────────────────────────────────────────────────────────── + +DATA_PATH = os.environ.get( + "DATA_PATH", + os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"), +) +OSRM_URL = os.environ.get("OSRM_URL", "http://172.22.0.1:5559") +DATA_PATH = os.path.abspath(DATA_PATH) +STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") +PORT = int(os.environ.get("PORT", 5558)) + +app = FastAPI(title="Enduro Trails API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# ─── DB ─────────────────────────────────────────────────────────────────────── + +def get_db(): + conn = sqlite3.connect(DATA_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# ─── Tile math ──────────────────────────────────────────────────────────────── + +def tile_to_bbox(z: int, x: int, y: int): + n = 2 ** z + west = x / n * 360.0 - 180.0 + east = (x + 1) / n * 360.0 - 180.0 + north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) + south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) + return west, south, east, north + + +# ─── WKB parser ─────────────────────────────────────────────────────────────── + +def wkb_to_coords(blob: bytes): + """Парсит WKB LineString с опциональным SRID, возвращает [(lon, lat), ...].""" + try: + b = bytes(blob) + if len(b) < 9: + return None + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + base_type = gtype & 0xFF + if base_type != 2: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 + npts = struct.unpack_from(endian + "I", b, offset)[0] + offset += 4 + coords = [] + for _ in range(npts): + lon, lat = struct.unpack_from(endian + "dd", b, offset) + offset += 16 + coords.append((lon, lat)) + return coords if len(coords) >= 2 else None + except Exception: + return None + + +def wkb_point_coords(blob: bytes): + """Парсит WKB Point, возвращает (lon, lat).""" + try: + b = bytes(blob) + if len(b) < 21: + return None + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + base_type = gtype & 0xFF + if base_type != 1: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 + lon, lat = struct.unpack_from(endian + "dd", b, offset) + return lon, lat + except Exception: + return None + + +# ─── MVT builder ────────────────────────────────────────────────────────────── + +def simplify_coords(coords, z): + """Упрощает геометрию трека по зуму через Douglas-Peucker.""" + if z >= 12: + return coords # без упрощения на детальных зумах + elif z >= 10: + tolerance = 0.0005 # ~50м + elif z >= 8: + tolerance = 0.002 # ~200м + else: + tolerance = 0.008 # ~800м на z7 и ниже + + if len(coords) < 3: + return coords + + line = LineString(coords) + simplified = line.simplify(tolerance, preserve_topology=False) + result = list(simplified.coords) + return result if len(result) >= 2 else coords + + +def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: + """Собирает MVT тайл.""" + import mapbox_vector_tile + + west, south, east, north = tile_to_bbox(z, x, y) + + trails_features = [] + for row in trails_rows: + coords = wkb_to_coords(row["geom"]) + if not coords: + continue + coords = simplify_coords(coords, z) + try: + props = { + "highway": row["highway_type"] or "", + "tracktype": row["track_type"] or "", + "surface": row["surface"] or "", + "name": row["name"] or "", + "length_m": row["length_m"] or 0, + "mtb_scale": row["mtb_scale"] or "", + } + trails_features.append({ + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": props, + }) + except Exception: + continue + + poi_features = [] + for row in poi_rows: + pt = wkb_point_coords(row["geom"]) + if not pt: + continue + try: + props = { + "poi_type": row["poi_type"] or "", + "name": row["name"] or "", + } + poi_features.append({ + "geometry": {"type": "Point", "coordinates": list(pt)}, + "properties": props, + }) + except Exception: + continue + + layers = [] + if trails_features: + layers.append({"name": "trails", "features": trails_features}) + if poi_features: + layers.append({"name": "poi", "features": poi_features}) + + if not layers: + return b"" + + return mapbox_vector_tile.encode( + layers, + quantize_bounds=(west, south, east, north), + extents=4096, + default_options={'y_coord_down': False}, + ) + + +# ─── Route stats ────────────────────────────────────────────────────────────── + +def haversine_m(lon1, lat1, lon2, lat2) -> float: + """Расстояние между двумя точками в метрах.""" + R = 6371000 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def calc_route_stats(geometry: dict, conn) -> dict | None: + """ + Считает статистику покрытия маршрута по типам дорог. + Оптимизированная версия: сэмплирует каждые ~500м, один запрос на сэмпл. + geometry — GeoJSON LineString {"type":"LineString","coordinates":[[lon,lat],...]} + """ + try: + coords = geometry.get("coordinates", []) + if len(coords) < 2: + return None + + # Считаем длины сегментов и общую длину + total_len = 0.0 + seg_lengths = [] + for i in range(len(coords) - 1): + d = haversine_m(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1]) + seg_lengths.append(d) + total_len += d + + if total_len < 1: + return None + + # Сэмплируем каждые ~500м (не чаще чем каждые 5 точек) + # Для маршрута 100км → ~200 сэмплов, для 500км → ~1000 сэмплов + avg_step = total_len / max(len(seg_lengths), 1) + # Шаг в точках для ~500м интервала + pts_per_500m = max(5, int(round(500.0 / avg_step))) if avg_step > 0 else 20 + + cur = conn.cursor() + + stats = { + "track_lev12_m": 0.0, + "track_lev345_m": 0.0, + "path_m": 0.0, + "asphalt_m": 0.0, + } + + # Кэш результатов по ячейкам сетки ~0.01° (~1км) + # Чтобы не делать повторные запросы для близких точек + grid_cache: dict = {} + + i = 0 + while i < len(coords) - 1: + end_i = min(i + pts_per_500m, len(coords) - 1) + + # Средняя точка сегмента + mid_lon = (coords[i][0] + coords[end_i][0]) / 2 + mid_lat = (coords[i][1] + coords[end_i][1]) / 2 + + # Длина этого сегмента + seg_len = sum(seg_lengths[i:end_i]) + + # Ключ кэша: ячейка 0.01° (~1км) + grid_key = (round(mid_lon * 100), round(mid_lat * 100)) + + if grid_key in grid_cache: + hw, tt = grid_cache[grid_key] + else: + # Tight bbox ~150m — index on (min_lon, max_lon, min_lat, max_lat) is used + # No ORDER BY to avoid full scan; first bbox hit is good enough for stats + delta = 0.0015 + try: + cur.execute(""" + SELECT highway_type, track_type + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + LIMIT 1 + """, ( + mid_lon + delta, mid_lon - delta, + mid_lat + delta, mid_lat - delta, + )) + row = cur.fetchone() + if row: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() + else: + # Widen search to ~500m if nothing found nearby + delta2 = 0.005 + cur.execute(""" + SELECT highway_type, track_type + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + LIMIT 1 + """, ( + mid_lon + delta2, mid_lon - delta2, + mid_lat + delta2, mid_lat - delta2, + )) + row = cur.fetchone() + if row: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() + else: + hw, tt = "asphalt", "" + except Exception: + hw, tt = "asphalt", "" + grid_cache[grid_key] = (hw, tt) + + if hw == "track": + if tt in ("grade1", "grade2"): + stats["track_lev12_m"] += seg_len + else: + stats["track_lev345_m"] += seg_len + elif hw in ("path", "bridleway", "footway"): + stats["path_m"] += seg_len + else: + stats["asphalt_m"] += seg_len + + i = end_i + + computed_total = ( + stats["track_lev12_m"] + stats["track_lev345_m"] + + stats["path_m"] + stats["asphalt_m"] + ) + if computed_total < 1: + return None + + def pct(v): + return round(v / computed_total * 100) + + dirt_total = stats["track_lev12_m"] + stats["track_lev345_m"] + stats["path_m"] + + return { + "track_lev12_m": round(stats["track_lev12_m"]), + "track_lev345_m": round(stats["track_lev345_m"]), + "path_m": round(stats["path_m"]), + "asphalt_m": round(stats["asphalt_m"]), + "track_lev12_pct": pct(stats["track_lev12_m"]), + "track_lev345_pct": pct(stats["track_lev345_m"]), + "path_pct": pct(stats["path_m"]), + "asphalt_pct": pct(stats["asphalt_m"]), + "dirt_total_pct": pct(dirt_total), + } + except Exception: + return None + + +# ─── Pydantic models ────────────────────────────────────────────────────────── + +class Waypoint(BaseModel): + lon: float + lat: float + + +class RouteRequest(BaseModel): + waypoints: List[Waypoint] + alternatives: int = 5 + + +class ReconRequest(BaseModel): + lon: float + lat: float + radius_km: float = 20 + + +class ScenicRequest(BaseModel): + lon: float + lat: float + target_km: float = 100 + + +# ─── API endpoints ──────────────────────────────────────────────────────────── + +@app.get("/api/cache/clear") +async def clear_cache(): + _tile_cache.clear() + return {"status": "ok", "cleared": True} + + +@app.get("/api/tiles/{z}/{x}/{y}.mvt") +async def get_tile(z: int, x: int, y: int): + if z < 0 or z > 22: + raise HTTPException(400, "Invalid z") + max_coord = 2 ** z + if x < 0 or x >= max_coord or y < 0 or y >= max_coord: + raise HTTPException(400, "Invalid x/y for zoom level") + + cached = get_cached_tile(z, x, y) + if cached is not None: + return Response( + content=cached, + media_type="application/x-protobuf", + headers={"Content-Encoding": "identity", "Access-Control-Allow-Origin": "*", "X-Cache": "HIT"}, + ) + + if not os.path.exists(DATA_PATH): + raise HTTPException(503, f"База данных не найдена: {DATA_PATH}") + + west, south, east, north = tile_to_bbox(z, x, y) + + buf_x = (east - west) * 0.15 + buf_y = (north - south) * 0.15 + q_west = west - buf_x + q_east = east + buf_x + q_south = south - buf_y + q_north = north + buf_y + + if z <= 7: + limit = 3000 + elif z <= 9: + limit = 8000 + elif z <= 11: + limit = 15000 + else: + limit = 25000 + + if z <= 7: + min_length = 2000 + elif z == 8: + min_length = 500 + elif z == 9: + min_length = 200 + elif z == 10: + min_length = 50 + else: + min_length = 0 + + try: + conn = get_db() + cur = conn.cursor() + + cur.execute(""" + SELECT osm_id, highway_type, track_type, surface, name, length_m, mtb_scale, geom + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + AND (length_m >= ? OR length_m IS NULL) + LIMIT ? + """, (q_east, q_west, q_north, q_south, min_length, limit)) + trails_rows = cur.fetchall() + + cur.execute(""" + SELECT osm_id, poi_type, name, geom + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + LIMIT 500 + """, (q_west, q_east, q_south, q_north)) + poi_rows = cur.fetchall() + + conn.close() + except Exception as e: + raise HTTPException(500, f"Ошибка БД: {e}") + + mvt = build_mvt(trails_rows, poi_rows, z, x, y) + + if mvt: + set_cached_tile(z, x, y, mvt) + + return Response( + content=mvt, + media_type="application/x-protobuf", + headers={ + "Content-Encoding": "identity", + "Access-Control-Allow-Origin": "*", + "X-Cache": "MISS", + }, + ) + + +@app.post("/api/recon") +async def post_recon(req: ReconRequest): + """ + Разведка: агрегация trails + POI в радиусе от точки. + """ + lon, lat, radius_km = req.lon, req.lat, req.radius_km + lat_rad = math.radians(lat) + delta_lat = radius_km / 111.0 + delta_lon = radius_km / (111.0 * math.cos(lat_rad)) + + try: + conn = get_db() + cur = conn.cursor() + + # Trails — агрегация по типам + cur.execute(""" + SELECT highway_type, track_type, SUM(length_m) as total_m, COUNT(*) as cnt + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + AND length_m >= 100 + GROUP BY highway_type, track_type + """, (lon + delta_lon, lon - delta_lon, lat + delta_lat, lat - delta_lat)) + trail_rows = cur.fetchall() + + # Агрегация по категориям + lev12_count, lev12_km = 0, 0.0 + lev345_count, lev345_km = 0, 0.0 + path_count, path_km = 0, 0.0 + + for row in trail_rows: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() if row["track_type"] else "" + cnt = row["cnt"] + km = (row["total_m"] or 0) / 1000.0 + + if hw == "track": + if tt in ("grade1", "grade2"): + lev12_count += cnt + lev12_km += km + else: + lev345_count += cnt + lev345_km += km + elif hw in ("path", "bridleway"): + path_count += cnt + path_km += km + + total_count = lev12_count + lev345_count + path_count + total_km = lev12_km + lev345_km + path_km + + # POI — подсчёт по типам + cur.execute(""" + SELECT poi_type, COUNT(*) as cnt + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + GROUP BY poi_type + """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat)) + poi_rows = cur.fetchall() + + poi_counts = {} + for row in poi_rows: + poi_counts[row["poi_type"]] = row["cnt"] + + conn.close() + + # POI types to report + poi_report = {} + for key in ["natural=water", "tourism=viewpoint", "historic=ruins", + "ford=yes", "natural=peak", "natural=cave_entrance"]: + poi_report[key] = poi_counts.get(key, 0) + + return { + "center": {"lon": lon, "lat": lat}, + "radius_km": radius_km, + "trails": { + "total_count": total_count, + "total_km": round(total_km, 1), + "lev12_count": lev12_count, + "lev12_km": round(lev12_km, 1), + "lev345_count": lev345_count, + "lev345_km": round(lev345_km, 1), + "path_count": path_count, + "path_km": round(path_km, 1), + }, + "poi": poi_report, + } + except Exception as e: + raise HTTPException(500, f"Ошибка БД: {e}") + + +# ─── Scenic route helpers ───────────────────────────────────────────────────── + +SCENIC_POI_SCORES = { + "natural=water": 10, + "tourism=viewpoint": 15, + "historic=ruins": 10, + "natural=peak": 12, + "natural=cave_entrance": 8, + "ford=yes": 5, +} + +SCENIC_POI_ICONS = { + "natural=water": "💧", + "tourism=viewpoint": "👁", + "historic=ruins": "🏚", + "natural=peak": "🔺", + "natural=cave_entrance": "🕳", + "ford=yes": "🌊", +} + +DIRECTION_NAMES = {0: "Северный", 1: "Восточный", 2: "Южный", 3: "Западный"} + + +def _angle_bucket(lon, lat, center_lon, center_lat): + """Возвращает сектор 0-3 (С, В, Ю, З) от центра.""" + a = math.degrees(math.atan2(lon - center_lon, lat - center_lat)) % 360 + return int(a // 90) + + +async def _osrm_route_waypoints(waypoints_lonlat: list[tuple[float, float]]) -> dict | None: + """Запрос к OSRM со списком waypoints. Возвращает полный ответ OSRM.""" + coords_str = ";".join(f"{lon},{lat}" for lon, lat in waypoints_lonlat) + radiuses_str = ";".join(["5000"] * len(waypoints_lonlat)) + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url) + data = resp.json() + except Exception: + return None + if data.get("code") != "Ok" or not data.get("routes"): + # Retry with wider snap + radiuses_wide = ";".join(["10000"] * len(waypoints_lonlat)) + url2 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_wide}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url2) + data = resp.json() + except Exception: + return None + if data.get("code") != "Ok" or not data.get("routes"): + return None + return data + + +def _merge_geometries(geometries: list[dict]) -> dict: + """Объединяет несколько LineString GeoJSON в одну.""" + all_coords = [] + for g in geometries: + if g.get("type") == "LineString": + all_coords.extend(g.get("coordinates", [])) + return {"type": "LineString", "coordinates": all_coords} + + +async def _build_scenic_for_cluster( + start_lon, start_lat, poi_list, target_km, conn +) -> dict | None: + """ + Строит кольцевой маршрут: start → POI1 → POI2 → ... → start. + poi_list: [{lon, lat, poi_type, name, score}, ...] + """ + if not poi_list: + return None + + # Ограничиваем до 5 POI + pois = poi_list[:5] + + # Waypoints: start → POI sequence → start + wp = [(start_lon, start_lat)] + for p in pois: + wp.append((p["lon"], p["lat"])) + wp.append((start_lon, start_lat)) + + osrm_data = await _osrm_route_waypoints(wp) + if not osrm_data or not osrm_data.get("routes"): + return None + + route = osrm_data["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Если слишком длинный (> target * 1.3) — убрать последний POI и перестроить + if distance_m > target_km * 1300 and len(pois) > 1: + pois = pois[:-1] + wp = [(start_lon, start_lat)] + for p in pois: + wp.append((p["lon"], p["lat"])) + wp.append((start_lon, start_lat)) + osrm_data2 = await _osrm_route_waypoints(wp) + if osrm_data2 and osrm_data2.get("routes"): + route = osrm_data2["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Статистика покрытия + stats = None + if conn: + try: + stats = calc_route_stats(geometry, conn) + except Exception: + pass + + # Waypoint labels для ответа + waypoints_out = [{"lon": start_lon, "lat": start_lat, "label": "Старт"}] + for p in pois: + icon = SCENIC_POI_ICONS.get(p["poi_type"], "📍") + name = p.get("name") or p["poi_type"] + waypoints_out.append({"lon": p["lon"], "lat": p["lat"], "label": f"{icon} {name}"}) + waypoints_out.append({"lon": start_lon, "lat": start_lat, "label": "Финиш"}) + + scenic_score = sum(p.get("score", 0) for p in pois) + scenic_pois_out = [ + { + "type": p["poi_type"], + "name": p.get("name", ""), + "lon": p["lon"], + "lat": p["lat"], + } + for p in pois + ] + + return { + "name": "", + "waypoints": waypoints_out, + "geometry": geometry, + "distance_m": round(distance_m), + "duration_s": round(duration_s), + "stats": stats, + "scenic_score": scenic_score, + "scenic_pois": scenic_pois_out, + } + + +@app.post("/api/scenic") +async def post_scenic(req: ScenicRequest): + """ + Красивый маршрут: кольцевой маршрут через живописные POI. + """ + lon, lat, target_km = req.lon, req.lat, req.target_km + if target_km < 20 or target_km > 500: + raise HTTPException(400, "Дистанция должна быть от 20 до 500 км") + + # Радиус поиска POI + search_radius_km = target_km * 0.6 + lat_rad = math.radians(lat) + delta_lat = search_radius_km / 111.0 + delta_lon = search_radius_km / (111.0 * math.cos(lat_rad)) + + try: + conn = get_db() + cur = conn.cursor() + + # Найти POI в радиусе + cur.execute(""" + SELECT poi_type, name, lon, lat + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + ORDER BY poi_type + """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat)) + poi_rows = cur.fetchall() + + # Фильтруем POI по score и назначаем баллы + scored_pois = [] + for row in poi_rows: + pt = row["poi_type"] or "" + if pt in SCENIC_POI_SCORES: + p_lon = row["lon"] + p_lat = row["lat"] + # Не берём POI ближе 3 км от старта + d = haversine_m(lon, lat, p_lon, p_lat) + if d < 3000: + continue + scored_pois.append({ + "poi_type": pt, + "name": row["name"] or "", + "lon": p_lon, + "lat": p_lat, + "score": SCENIC_POI_SCORES[pt], + "distance_m": d, + }) + + if not scored_pois: + # Нет красивых мест — строим просто кольцо через ближайшие грунтовки + # Пробуем кольцо: старт → точка на расстоянии ~target_km/3 → старт + angle = 0 + third_dist = target_km / 3.0 + mid_lat = lat + (third_dist / 111.0) + mid_lon = lon + osrm_data = await _osrm_route_waypoints([ + (lon, lat), (mid_lon, mid_lat), (lon, lat) + ]) + if osrm_data and osrm_data.get("routes"): + route = osrm_data["routes"][0] + geometry = route["geometry"] + stats = calc_route_stats(geometry, conn) if conn else None + conn.close() + return { + "routes": [{ + "name": "Маршрут по грунтовкам", + "waypoints": [ + {"lon": lon, "lat": lat, "label": "Старт"}, + {"lon": mid_lon, "lat": mid_lat, "label": "Разворот"}, + {"lon": lon, "lat": lat, "label": "Финиш"}, + ], + "geometry": geometry, + "distance_m": round(route["distance"]), + "duration_s": round(route["duration"]), + "stats": stats, + "scenic_score": 0, + "scenic_pois": [], + }] + } + conn.close() + raise HTTPException(404, "Не удалось построить маршрут") + + # Разделить POI на кластеры по азимуту (4 сектора) + clusters: dict[int, list] = {0: [], 1: [], 2: [], 3: []} + for p in scored_pois: + bucket = _angle_bucket(p["lon"], p["lat"], lon, lat) + clusters[bucket].append(p) + + # Для каждого кластера — жадный выбор POI + remaining_km = target_km * 0.8 # 80% дистанции на POI, 20% на возврат + + cluster_poi_lists = {} + for bucket, pois in clusters.items(): + if not pois: + cluster_poi_lists[bucket] = [] + continue + # Сортируем по score/distance (жадный) + for p in pois: + p["score_per_km"] = p["score"] / max(p["distance_m"] / 1000.0, 1.0) + pois.sort(key=lambda p: p["score_per_km"], reverse=True) + + selected = [] + used_km = 0.0 + for p in pois: + d_km = p["distance_m"] / 1000.0 + if d_km > remaining_km * 0.5: + continue + if d_km < 3: + continue + if used_km + d_km > remaining_km: + continue + selected.append(p) + used_km += d_km + if len(selected) >= 5: + break + + cluster_poi_lists[bucket] = selected + + # Строим маршруты для кластеров с POI (до 3 альтернативных) + routes_out = [] + used_buckets = 0 + for bucket in range(4): + if used_buckets >= 3: + break + pois = cluster_poi_lists.get(bucket, []) + if not pois: + continue + + result = await _build_scenic_for_cluster(lon, lat, pois, target_km, conn) + if result: + # Проверяем дистанцию: не < 50% target + if result["distance_m"] < target_km * 500: + continue + result["name"] = DIRECTION_NAMES.get(bucket, f"Вариант {used_buckets + 1}") + routes_out.append(result) + used_buckets += 1 + + # Если ни одного маршрута — попробовать все POI вместе + if not routes_out and scored_pois: + # Топ-5 POI по score_per_km + all_sorted = sorted(scored_pois, key=lambda p: p["score_per_km"], reverse=True)[:5] + result = await _build_scenic_for_cluster(lon, lat, all_sorted, target_km, conn) + if result: + result["name"] = "Маршрут" + routes_out.append(result) + + conn.close() + + if not routes_out: + raise HTTPException(404, "Не удалось построить красивый маршрут") + + return {"routes": routes_out} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Ошибка: {e}") + + +@app.post("/api/route") +async def post_route(req: RouteRequest): + """ + Роутинг через OSRM с альтернативными маршрутами и статистикой покрытия. + Принимает JSON: {"waypoints": [{"lon":..,"lat":..}, ...], "alternatives": 5} + """ + if len(req.waypoints) < 2: + raise HTTPException(400, "Нужно минимум 2 точки") + + # Строим строку координат для OSRM + coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints) + alternatives = max(1, min(5, req.alternatives)) + + # Увеличенный snap radius для длинных маршрутов (5 км) + radiuses_str = ";".join(["5000"] * len(req.waypoints)) + + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives={alternatives}&overview=full&geometries=geojson&annotations=false" + f"&radiuses={radiuses_str}" + ) + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # OSRM возвращает TooBig когда маршрут слишком длинный для N альтернатив + # Retry-цепочка: alternatives=5 → 3 → 1, ВСЕГДА с radiuses (snap radius критичен для длинных маршрутов) + if data.get("code") == "TooBig": + url_retry3 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives=3&overview=full&geometries=geojson&annotations=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_retry3) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") == "TooBig": + url_retry1 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_retry1) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # Если NoSegment — пробуем с увеличенным radius (10 км вместо 5) + if data.get("code") == "NoSegment": + radiuses_wide = ";".join(["10000"] * len(req.waypoints)) + url_wide = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_wide}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_wide) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") != "Ok" or not data.get("routes"): + osrm_code = data.get("code", "Unknown") + osrm_msg = data.get("message", "") + if osrm_code == "NoRoute": + raise HTTPException(404, "Маршрут не найден: нет пути между точками") + elif osrm_code == "NoSegment": + raise HTTPException(404, "Маршрут не найден: точки слишком далеко от дорог") + elif osrm_code == "InvalidValue": + raise HTTPException(400, f"Некорректные координаты: {osrm_msg}") + else: + raise HTTPException(404, f"Маршрут не найден ({osrm_code})") + + # Открываем БД один раз для всех маршрутов + try: + conn = get_db() + except Exception as e: + conn = None + + routes_out = [] + for idx, route in enumerate(data["routes"]): + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Считаем статистику покрытия + stats = None + if conn is not None: + try: + stats = calc_route_stats(geometry, conn) + except Exception: + stats = None + + routes_out.append({ + "index": idx, + "geometry": geometry, + "distance_m": round(distance_m), + "duration_s": round(duration_s), + "stats": stats, + }) + + if conn is not None: + try: + conn.close() + except Exception: + pass + + return {"routes": routes_out} + + +# Обратная совместимость — старый GET endpoint (для линейки и прочего) +@app.get("/api/route") +async def get_route( + from_lon: float, from_lat: float, + to_lon: float, to_lat: float +): + """Роутинг через OSRM (legacy GET). Параметры: from_lon, from_lat, to_lon, to_lat""" + url = ( + f"{OSRM_URL}/route/v1/driving/" + f"{from_lon},{from_lat};{to_lon},{to_lat}" + f"?overview=full&geometries=geojson&annotations=false" + ) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") != "Ok" or not data.get("routes"): + raise HTTPException(404, "Маршрут не найден") + + route = data["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + return { + "type": "Feature", + "geometry": geometry, + "properties": { + "distance_m": round(distance_m), + "distance_km": round(distance_m / 1000, 1), + "duration_min": round(duration_s / 60), + "duration_s": round(duration_s), + } + } + + +@app.get("/api/health") +async def health(): + return { + "status": "ok", + "db_path": DATA_PATH, + "db_exists": os.path.exists(DATA_PATH), + } + + +# ─── Static files ───────────────────────────────────────────────────────────── + +if os.path.exists(STATIC_DIR): + app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + + +if __name__ == "__main__": + print(f"==> Enduro Trails API DB={DATA_PATH} Port={PORT}") + uvicorn.run("app:app", host="0.0.0.0", port=PORT, workers=4)