From ec6a72f399931e71a0bd69ec2612a1b6c3f80a65 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Thu, 28 May 2026 20:09:04 +0300 Subject: [PATCH 1/4] feat: smart relevant zone search v1 --- src/schemas/routing.py | 1549 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 1460 insertions(+), 89 deletions(-) diff --git a/src/schemas/routing.py b/src/schemas/routing.py index 9ceb230..764b472 100644 --- a/src/schemas/routing.py +++ b/src/schemas/routing.py @@ -1,127 +1,1498 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Literal +import math +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Annotated, Any, cast -from pydantic import BaseModel, Field, model_validator +import requests +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, or_, text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from ..database import get_db +from ..db_models import ( + Forecast, + GlobalRole, + ParkingZone, + Route, + RouteMode, + RouteStatus, + User, +) +from ..dependencies import require +from ..schemas.routing import ( + CreateRouteRequest, + GeoPoint, + RouteCandidate, + RouteListResponse, + RouteResponse, + SearchRoutingRequest, + SearchRoutingResponse, + UpdateRouteRequest, +) -RoutingProvider = Literal["geoapify", "external", "yandex", "internal"] +router = APIRouter(prefix="/routing", tags=["Routing"]) +GEOAPIFY_ROUTEMATRIX_URL = "https://api.geoapify.com/v1/routematrix" +GEOAPIFY_PROVIDER_NAME = "geoapify" +GEOAPIFY_MODE = "drive" -# --------------------------------------------------------------------------- -# Вспомогательные типы -# --------------------------------------------------------------------------- +# Ограничения для скорости. +# Мы НЕ отправляем в Geoapify все парковки из БД. +MAX_MATRIX_TARGETS = 80 +MIN_CHEAP_CANDIDATES_FOR_COMPARE = 40 + +# Радиусы расширения поиска вокруг anchor-точки. +# Для find_parking anchor = origin. +# Для route_to_destination anchor = destination. +RADIUS_STEPS_METERS = [ + 500, + 1_000, + 2_000, + 5_000, + 10_000, + 25_000, + 50_000, + 100_000, + 250_000, + 500_000, + 1_000_000, + None, # финальный fallback: взять все зоны, если рядом вообще ничего нет +] + +# Оценка пешего пути от парковки до destination. +# Geoapify Route Matrix здесь используем для поездки origin -> parking. +# А parking -> destination считаем быстро по haversine с коэффициентом. +WALKING_SPEED_METERS_PER_SECOND = 1.35 +WALKING_DETOUR_FACTOR = 1.35 + +# Если до парковки ехать меньше этого времени, а свободных мест нет, +# она считается почти бесполезной без сильного прогноза на освобождение. +SHORT_TRIP_SECONDS = 30 * 60 + +# Если прогноз показывает хотя бы столько мест, занятая сейчас парковка +# может стать нормальным кандидатом. +FORECAST_OPPORTUNITY_FREE_COUNT = 2 -class GeoPoint(BaseModel): - latitude: float = Field(ge=-90, le=90) - longitude: float = Field(ge=-180, le=180) +# Сколько времени вокруг arrival_time забираем прогнозы одним батчем. +FORECAST_LOOKAROUND = timedelta(hours=2) + +# Для публичного /routing/new без авторизации. +# Лучше задать в .env существующий user_id. +PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" # --------------------------------------------------------------------------- -# RouteCandidate +# Внутренние типы # --------------------------------------------------------------------------- -class RouteCandidate(BaseModel): - zone_id: int - camera_id: int | None - geometry: Any - zone_type: str - location_type: str | None - is_accessible: bool | None - pay: int - capacity: int +class RoutingProviderError(Exception): + pass + + +@dataclass(frozen=True) +class _ZoneTarget: + zone: ParkingZone + point: GeoPoint + anchor_distance_meters: int current_occupied: int current_free_count: int current_confidence: float - predicted_for_arrival: datetime - predicted_occupied: int | None - predicted_free_count: int | None - probability_free_space: float | None - forecast_confidence: float | None + + +@dataclass(frozen=True) +class _RoutedCandidate: + zone_target: _ZoneTarget distance_from_origin_meters: int duration_from_origin_seconds: int distance_to_destination_meters: int | None duration_to_destination_seconds: int | None - score: float - rank: int + arrival_time: datetime + + +@dataclass(frozen=True) +class _ForecastView: + predicted_occupied: int | None + predicted_free_count: int | None + probability_free_space: float | None + forecast_confidence: float | None + + +@dataclass(frozen=True) +class _CandidateSearchResult: + candidates: list[RouteCandidate] + total_candidates: int # --------------------------------------------------------------------------- -# Route +# Общие helpers # --------------------------------------------------------------------------- -class RouteResponse(BaseModel): - route_id: int - user_id: int - mode: str - provider: str - origin: GeoPoint - destination: GeoPoint | None - selected_zone_id: int | None - selected_candidate: RouteCandidate | None - eta_seconds: int | None - arrival_time: datetime | None - polyline: str | None - deeplink_url: str | None - status: str - created_at: datetime - updated_at: datetime +def _enum_value(value: Any) -> str | None: + if value is None: + return None + + enum_value = getattr(value, "value", None) + + if enum_value is not None: + return str(enum_value) + + return str(value) + + +def _to_utc_naive(value: datetime) -> datetime: + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value + + return value.astimezone(timezone.utc).replace(tzinfo=None) + + +def _datetime_timestamp(value: datetime | None) -> float: + if value is None: + return 0.0 + + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + value = value.replace(tzinfo=timezone.utc) + + return value.timestamp() + + +def _seconds_between(a: datetime, b: datetime) -> float: + return abs((_to_utc_naive(a) - _to_utc_naive(b)).total_seconds()) + + +def _serialize_route(route: Route) -> RouteResponse: + candidate: RouteCandidate | None = None + + if route.selected_candidate: + candidate = RouteCandidate.model_validate(route.selected_candidate) + destination: GeoPoint | None = None + + if route.destination_latitude is not None and route.destination_longitude is not None: + destination = GeoPoint( + latitude=route.destination_latitude, + longitude=route.destination_longitude, + ) + + return RouteResponse( + route_id=route.route_id, + user_id=route.user_id, + mode=_enum_value(route.mode) or str(route.mode), + provider=route.provider, + origin=GeoPoint( + latitude=route.origin_latitude, + longitude=route.origin_longitude, + ), + destination=destination, + selected_zone_id=route.selected_zone_id, + selected_candidate=candidate, + eta_seconds=route.eta_seconds, + arrival_time=route.arrival_time, + polyline=route.polyline, + deeplink_url=route.deeplink_url, + status=_enum_value(route.status) or str(route.status), + created_at=route.created_at, + updated_at=route.updated_at, + ) + + +def _get_route_or_404(db: Session, route_id: int) -> Route: + route = db.query(Route).filter(Route.route_id == route_id).one_or_none() + + if route is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": "Route not found"}, + ) + + return route + + +def _assert_owner_or_admin(route: Route, current_user: User) -> None: + if current_user.global_role != GlobalRole.admin and route.user_id != current_user.user_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={"error_description": "Access denied: not your route"}, + ) + + +def _provider_unavailable(exc: RoutingProviderError) -> HTTPException: + return HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"error_description": str(exc)}, + ) + + +def _get_public_routing_user_id(db: Session) -> int: + """ + /routing/new у тебя отключён от авторизации, но routes.user_id обычно NOT NULL. + Поэтому используем PUBLIC_ROUTING_USER_ID из .env, если он задан. + Если не задан — берём первого существующего пользователя. + """ + raw_user_id = os.getenv(PUBLIC_ROUTING_USER_ID_ENV) + + if raw_user_id: + try: + return int(raw_user_id) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error_description": f"{PUBLIC_ROUTING_USER_ID_ENV} must be integer" + }, + ) + + row = db.query(User.user_id).order_by(User.user_id.asc()).first() + + if row is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error_description": ( + "Cannot create public route: no users exist. " + f"Create a technical user or set {PUBLIC_ROUTING_USER_ID_ENV}." + ) + }, + ) + + return int(row[0]) + + +def _zone_centroid(zone: ParkingZone) -> GeoPoint | None: + geometry = zone.geometry + + if not isinstance(geometry, dict): + return None + + try: + coords = list(geometry["coordinates"][0]) -class RouteListResponse(BaseModel): - items: list[RouteResponse] - total: int - top: int - offset: int + if len(coords) > 1 and coords[0] == coords[-1]: + coords = coords[:-1] + + if not coords: + return None + + latitude = sum(float(point[1]) for point in coords) / len(coords) + longitude = sum(float(point[0]) for point in coords) / len(coords) + + return GeoPoint(latitude=latitude, longitude=longitude) + + except (KeyError, TypeError, ValueError, IndexError, ZeroDivisionError): + try: + return GeoPoint( + latitude=float(geometry["lat"]), + longitude=float(geometry["lon"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: + earth_radius_meters = 6_371_000 + + lat1 = math.radians(a.latitude) + lat2 = math.radians(b.latitude) + delta_lat = math.radians(b.latitude - a.latitude) + delta_lon = math.radians(b.longitude - a.longitude) + + h = ( + math.sin(delta_lat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 + ) + + return int(2 * earth_radius_meters * math.asin(math.sqrt(h))) + + +def _estimated_walking_seconds(distance_meters: int) -> int: + return int(distance_meters * WALKING_DETOUR_FACTOR / WALKING_SPEED_METERS_PER_SECOND) + + +def _build_map_deeplink(destination: GeoPoint) -> str: + return ( + "https://www.google.com/maps/dir/?api=1" + f"&destination={destination.latitude},{destination.longitude}" + ) # --------------------------------------------------------------------------- -# Запросы +# Geoapify Route Matrix # --------------------------------------------------------------------------- -class RoutingRequestBase(BaseModel): - mode: Literal["find_parking", "route_to_destination"] - origin: GeoPoint - destination: GeoPoint | None = None - max_pay: int | None = Field(None, ge=0) - min_free_count: int | None = Field(None, ge=0) - min_confidence: float | None = Field(None, ge=0.0, le=1.0) - max_distance_to_destination_meters: int | None = Field(None, ge=0) - max_duration_from_origin_seconds: int | None = Field(None, ge=0) - include_accessible: bool | None = None - limit: int = Field(10, ge=1, le=50) - use_forecast: bool = False - - # Оставляем provider для совместимости с ТЗ/фронтом, - # но фактически маршрутизация ниже всегда идёт через Geoapify. - provider: RoutingProvider = "geoapify" - - @model_validator(mode="after") - def destination_required_for_route_mode(self) -> "RoutingRequestBase": - if self.mode == "route_to_destination" and self.destination is None: - raise ValueError("destination is required for mode=route_to_destination") - return self - - -class SearchRoutingRequest(RoutingRequestBase): - pass +def _geoapify_api_key() -> str: + api_key = os.getenv("GEOAPIFY_API_KEY") + if not api_key: + raise RoutingProviderError("Geoapify API key is not configured") -class CreateRouteRequest(RoutingRequestBase): - selected_zone_id: int | None = None + return api_key -class SearchRoutingResponse(BaseModel): - mode: str - provider: str - generated_at: datetime - selected_zone_id: int | None - total_candidates: int - candidates: list[RouteCandidate] +def _geoapify_matrix( + sources: list[GeoPoint], + targets: list[GeoPoint], +) -> list[list[dict[str, Any]]]: + if not sources or not targets: + return [] + + api_key = _geoapify_api_key() + + payload = { + "mode": GEOAPIFY_MODE, + "sources": [ + {"location": [point.longitude, point.latitude]} + for point in sources + ], + "targets": [ + {"location": [point.longitude, point.latitude]} + for point in targets + ], + } + + try: + response = requests.post( + GEOAPIFY_ROUTEMATRIX_URL, + params={"apiKey": api_key}, + headers={"Content-Type": "application/json"}, + json=payload, + timeout=15, + ) + except requests.RequestException as exc: + raise RoutingProviderError("Geoapify Route Matrix API is unavailable") from exc + + if response.status_code >= 500: + raise RoutingProviderError( + f"Geoapify Route Matrix API is unavailable: HTTP {response.status_code}" + ) + + if response.status_code >= 400: + raise RoutingProviderError( + f"Geoapify Route Matrix API rejected request: " + f"HTTP {response.status_code}: {response.text[:300]}" + ) + + try: + data = response.json() + except ValueError as exc: + raise RoutingProviderError("Geoapify returned invalid JSON") from exc + + matrix = data.get("sources_to_targets") + + if not isinstance(matrix, list): + raise RoutingProviderError("Geoapify response does not contain sources_to_targets") + + return matrix + + +def _matrix_cell( + matrix: list[list[dict[str, Any]]], + source_index: int, + target_index: int, +) -> tuple[int, int] | None: + try: + cell = matrix[source_index][target_index] + except (IndexError, TypeError): + return None + + if not isinstance(cell, dict): + return None + + distance = cell.get("distance") + duration = cell.get("time", cell.get("duration")) + + if distance is None or duration is None: + return None + + try: + return int(round(float(distance))), int(round(float(duration))) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# Быстрый отбор зон до обращения в Geoapify +# --------------------------------------------------------------------------- + +def _query_active_zones( + db: Session, + max_pay: int | None, + include_accessible: bool | None, + selected_zone_id: int | None, +) -> list[ParkingZone]: + query = db.query(ParkingZone).filter(ParkingZone.is_active.is_(True)) + + if selected_zone_id is not None: + query = query.filter(ParkingZone.parking_zone_id == selected_zone_id) + + if max_pay is not None: + query = query.filter(ParkingZone.pay <= max_pay) + + if include_accessible is False: + query = query.filter( + or_( + ParkingZone.is_accessible.is_(False), + ParkingZone.is_accessible.is_(None), + ) + ) + + return query.all() + + +def _build_zone_targets( + zones: list[ParkingZone], + anchor: GeoPoint, +) -> list[_ZoneTarget]: + targets: list[_ZoneTarget] = [] + + for zone in zones: + point = _zone_centroid(zone) + + if point is None: + continue + + capacity = max(int(zone.capacity or 0), 0) + occupied = max(int(zone.occupied or 0), 0) + occupied = min(occupied, capacity) + free_count = max(capacity - occupied, 0) + confidence = max(0.0, min(float(zone.confidence or 0.0), 1.0)) + + targets.append( + _ZoneTarget( + zone=zone, + point=point, + anchor_distance_meters=_haversine_meters(anchor, point), + current_occupied=occupied, + current_free_count=free_count, + current_confidence=confidence, + ) + ) + + targets.sort(key=lambda item: item.anchor_distance_meters) + + return targets + + +def _choose_radius_pool( + targets: list[_ZoneTarget], + limit: int, + selected_zone_id: int | None, +) -> list[_ZoneTarget]: + if selected_zone_id is not None: + return targets[:1] + + if not targets: + return [] + + required_for_compare = max(MIN_CHEAP_CANDIDATES_FOR_COMPARE, limit * 8) + + for radius in RADIUS_STEPS_METERS: + if radius is None: + pool = targets + else: + pool = [ + target + for target in targets + if target.anchor_distance_meters <= radius + ] + + if len(pool) >= required_for_compare: + return pool[:MAX_MATRIX_TARGETS] + + return targets[:MAX_MATRIX_TARGETS] + + +# --------------------------------------------------------------------------- +# Прогнозы +# --------------------------------------------------------------------------- + +def _load_forecasts_for_candidates( + db: Session, + zone_ids: list[int], + min_arrival: datetime, + max_arrival: datetime, +) -> dict[int, list[Forecast]]: + if not zone_ids: + return {} + + from_time = _to_utc_naive(min_arrival - FORECAST_LOOKAROUND) + to_time = _to_utc_naive(max_arrival + FORECAST_LOOKAROUND) + + rows = ( + db.query(Forecast) + .filter(Forecast.zone_id.in_(zone_ids)) + .filter(Forecast.predicted_for >= from_time) + .filter(Forecast.predicted_for <= to_time) + .order_by( + Forecast.zone_id.asc(), + Forecast.predicted_for.asc(), + Forecast.generated_at.desc(), + Forecast.forecast_id.desc(), + ) + .all() + ) + + result: dict[int, list[Forecast]] = {} + + for forecast in rows: + result.setdefault(int(forecast.zone_id), []).append(forecast) + + return result + + +def _pick_forecast_for_arrival( + forecasts: list[Forecast], + arrival_time: datetime, +) -> Forecast | None: + if not forecasts: + return None + + return min( + forecasts, + key=lambda forecast: ( + _seconds_between(forecast.predicted_for, arrival_time), + -_datetime_timestamp(forecast.generated_at), + -int(forecast.forecast_id), + ), + ) + + +def _forecast_view( + zone_capacity: int, + forecast: Forecast | None, +) -> _ForecastView: + if forecast is None: + return _ForecastView( + predicted_occupied=None, + predicted_free_count=None, + probability_free_space=None, + forecast_confidence=None, + ) + + forecast_capacity = max(int(forecast.capacity or zone_capacity), 0) + predicted_occupied = max(int(forecast.predicted_occupied or 0), 0) + predicted_occupied = min(predicted_occupied, forecast_capacity) + + predicted_free_count = max(forecast_capacity - predicted_occupied, 0) + + probability_free_space = ( + max(0.0, min(float(forecast.probability_free_space), 1.0)) + if forecast.probability_free_space is not None + else None + ) + + forecast_confidence = ( + max(0.0, min(float(forecast.confidence), 1.0)) + if forecast.confidence is not None + else None + ) + + return _ForecastView( + predicted_occupied=predicted_occupied, + predicted_free_count=predicted_free_count, + probability_free_space=probability_free_space, + forecast_confidence=forecast_confidence, + ) + + +# --------------------------------------------------------------------------- +# Умная оценка кандидата +# --------------------------------------------------------------------------- + +def _effective_free_count( + current_free_count: int, + forecast_view: _ForecastView, + use_forecast: bool, +) -> int: + if use_forecast and forecast_view.predicted_free_count is not None: + return forecast_view.predicted_free_count + + return current_free_count + + +def _effective_confidence( + current_confidence: float, + forecast_view: _ForecastView, + use_forecast: bool, +) -> float: + if use_forecast and forecast_view.forecast_confidence is not None: + return forecast_view.forecast_confidence + + return current_confidence + + +def _availability_probability( + effective_free_count: int, + forecast_view: _ForecastView, + use_forecast: bool, +) -> float: + if use_forecast and forecast_view.probability_free_space is not None: + return forecast_view.probability_free_space + + if effective_free_count >= 3: + return 0.85 + + if effective_free_count == 2: + return 0.70 + + if effective_free_count == 1: + return 0.50 + + return 0.05 + + +def _candidate_tier( + current_free_count: int, + effective_free_count: int, + probability_free_space: float, + effective_confidence: float, + duration_from_origin_seconds: int, + requested_min_free_count: int | None, + use_forecast: bool, + forecast_view: _ForecastView, +) -> int: + """ + Чем меньше tier, тем лучше. + + 0 — отличный кандидат; + 1 — хороший кандидат; + 2 — сейчас занято, но прогноз к arrival_time хороший; + 3 — рискованный, но возможный; + 4 — запасной вариант; + 5 — почти бесполезный вариант. + """ + min_required = requested_min_free_count if requested_min_free_count is not None else 1 + + if effective_free_count >= max(3, min_required) and probability_free_space >= 0.65: + return 0 + + if effective_free_count >= min_required and probability_free_space >= 0.40: + return 1 + + if ( + use_forecast + and current_free_count == 0 + and forecast_view.predicted_free_count is not None + and forecast_view.predicted_free_count >= FORECAST_OPPORTUNITY_FREE_COUNT + and duration_from_origin_seconds >= 10 * 60 + and ( + forecast_view.probability_free_space is None + or forecast_view.probability_free_space >= 0.35 + ) + ): + return 2 + + if effective_free_count > 0: + return 3 + + if duration_from_origin_seconds <= SHORT_TRIP_SECONDS: + return 5 + + if effective_confidence < 0.35: + return 5 + + return 4 + + +def _price_bucket(pay: int) -> int: + if pay <= 0: + return 0 + + if pay <= 50: + return 1 + + if pay <= 150: + return 2 + + if pay <= 300: + return 3 + + return 4 + + +def _duration_bucket(seconds: int) -> int: + if seconds <= 10 * 60: + return 0 + + if seconds <= 20 * 60: + return 1 + + if seconds <= 40 * 60: + return 2 + + if seconds <= 60 * 60: + return 3 + + if seconds <= 2 * 60 * 60: + return 4 + + return 5 + + +def _walk_bucket(seconds: int | None) -> int: + if seconds is None: + return 0 + + if seconds <= 5 * 60: + return 0 + + if seconds <= 10 * 60: + return 1 + + if seconds <= 20 * 60: + return 2 + + if seconds <= 30 * 60: + return 3 + + return 4 + + +def _display_score( + tier: int, + effective_free_count: int, + probability_free_space: float, + effective_confidence: float, + duration_from_origin_seconds: int, + duration_to_destination_seconds: int | None, + pay: int, +) -> float: + base_by_tier = { + 0: 0.95, + 1: 0.82, + 2: 0.72, + 3: 0.55, + 4: 0.35, + 5: 0.12, + } + + base = base_by_tier.get(tier, 0.10) + + free_bonus = min(effective_free_count, 6) * 0.015 + probability_bonus = probability_free_space * 0.05 + confidence_bonus = effective_confidence * 0.03 + + duration_penalty = min(duration_from_origin_seconds / (2 * 60 * 60), 1.0) * 0.10 + + if duration_to_destination_seconds is None: + walk_penalty = 0.0 + else: + walk_penalty = min(duration_to_destination_seconds / (30 * 60), 1.0) * 0.08 + + price_penalty = min(pay / 500.0, 1.0) * 0.04 + + score = ( + base + + free_bonus + + probability_bonus + + confidence_bonus + - duration_penalty + - walk_penalty + - price_penalty + ) + + return round(max(0.0, min(score, 1.0)), 6) + + +def _ranking_key( + candidate: RouteCandidate, + tier: int, + effective_free_count: int, + probability_free_space: float, + effective_confidence: float, +) -> tuple[Any, ...]: + """ + Здесь важен порядок: + 1. качество доступности; + 2. грубая корзина времени; + 3. пеший путь до destination; + 4. запас свободных мест; + 5. цена; + 6. уверенность как tie-breaker. + """ + return ( + tier, + _duration_bucket(candidate.duration_from_origin_seconds), + _walk_bucket(candidate.duration_to_destination_seconds), + -effective_free_count, + -probability_free_space, + _price_bucket(candidate.pay), + -effective_confidence, + candidate.duration_from_origin_seconds, + candidate.distance_from_origin_meters, + candidate.distance_to_destination_meters + if candidate.distance_to_destination_meters is not None + else 0, + candidate.pay, + candidate.zone_id, + ) + + +def _remove_unreasonable_detours( + candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]], + limit: int, + selected_zone_id: int | None, +) -> list[tuple[RouteCandidate, tuple[Any, ...], int]]: + if selected_zone_id is not None: + return candidates_with_meta + + if not candidates_with_meta: + return [] + + primary = [ + item + for item in candidates_with_meta + if item[2] <= 3 + ] + + if len(primary) < max(3, min(limit, 5)): + return candidates_with_meta + + best_duration = min(item[0].duration_from_origin_seconds for item in primary) + + # Не показываем вариант на 5 часов, если есть сопоставимые варианты за час. + max_reasonable_duration = max( + best_duration + 30 * 60, + int(best_duration * 2.5), + ) + + max_reasonable_duration = min( + max_reasonable_duration, + best_duration + 2 * 60 * 60, + ) + + reasonable: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + backup: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + + for item in candidates_with_meta: + candidate = item[0] + tier = item[2] + + if tier <= 3 and candidate.duration_from_origin_seconds <= max_reasonable_duration: + reasonable.append(item) + else: + backup.append(item) + + if len(reasonable) >= limit: + return reasonable + + return reasonable + backup + + +# --------------------------------------------------------------------------- +# Основной поиск кандидатов +# --------------------------------------------------------------------------- + +def _route_zone_pool( + origin: GeoPoint, + destination: GeoPoint | None, + zone_targets: list[_ZoneTarget], +) -> list[_RoutedCandidate]: + if not zone_targets: + return [] + + matrix = _geoapify_matrix( + sources=[origin], + targets=[item.point for item in zone_targets], + ) + + now = datetime.now(timezone.utc) + routed: list[_RoutedCandidate] = [] + + for index, item in enumerate(zone_targets): + from_origin = _matrix_cell(matrix, 0, index) + + if from_origin is None: + continue + + distance_from_origin, duration_from_origin = from_origin + arrival_time = now + timedelta(seconds=duration_from_origin) + + distance_to_destination: int | None = None + duration_to_destination: int | None = None + + if destination is not None: + direct_distance = _haversine_meters(item.point, destination) + distance_to_destination = int(direct_distance * WALKING_DETOUR_FACTOR) + duration_to_destination = _estimated_walking_seconds(direct_distance) + + routed.append( + _RoutedCandidate( + zone_target=item, + distance_from_origin_meters=distance_from_origin, + duration_from_origin_seconds=duration_from_origin, + distance_to_destination_meters=distance_to_destination, + duration_to_destination_seconds=duration_to_destination, + arrival_time=arrival_time, + ) + ) + + return routed + + +def _search_candidates( + db: Session, + origin: GeoPoint, + destination: GeoPoint | None, + mode: str, + max_pay: int | None, + min_free_count: int | None, + min_confidence: float | None, + max_distance_to_destination_meters: int | None, + max_duration_from_origin_seconds: int | None, + include_accessible: bool | None, + use_forecast: bool, + limit: int, + selected_zone_id: int | None = None, +) -> _CandidateSearchResult: + if mode == "route_to_destination" and destination is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": "destination is required for mode=route_to_destination"}, + ) + + anchor = destination if mode == "route_to_destination" and destination is not None else origin + + zones = _query_active_zones( + db=db, + max_pay=max_pay, + include_accessible=include_accessible, + selected_zone_id=selected_zone_id, + ) + + zone_targets = _build_zone_targets(zones, anchor) + cheap_pool = _choose_radius_pool( + targets=zone_targets, + limit=limit, + selected_zone_id=selected_zone_id, + ) + + routed_candidates = _route_zone_pool( + origin=origin, + destination=destination, + zone_targets=cheap_pool, + ) + + if not routed_candidates: + return _CandidateSearchResult(candidates=[], total_candidates=0) + + filtered_by_route: list[_RoutedCandidate] = [] + + for routed in routed_candidates: + if ( + max_duration_from_origin_seconds is not None + and routed.duration_from_origin_seconds > max_duration_from_origin_seconds + ): + continue + + if ( + max_distance_to_destination_meters is not None + and routed.distance_to_destination_meters is not None + and routed.distance_to_destination_meters > max_distance_to_destination_meters + ): + continue + + filtered_by_route.append(routed) + + if not filtered_by_route: + return _CandidateSearchResult(candidates=[], total_candidates=0) + + min_arrival = min(item.arrival_time for item in filtered_by_route) + max_arrival = max(item.arrival_time for item in filtered_by_route) + + zone_ids = [ + int(item.zone_target.zone.parking_zone_id) + for item in filtered_by_route + ] + + forecasts_by_zone = ( + _load_forecasts_for_candidates( + db=db, + zone_ids=zone_ids, + min_arrival=min_arrival, + max_arrival=max_arrival, + ) + if use_forecast + else {} + ) + + candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + + for routed in filtered_by_route: + target = routed.zone_target + zone = target.zone + + capacity = max(int(zone.capacity or 0), 0) + pay = max(int(zone.pay or 0), 0) + + forecast = None + + if use_forecast: + forecast = _pick_forecast_for_arrival( + forecasts_by_zone.get(int(zone.parking_zone_id), []), + routed.arrival_time, + ) + + forecast_view = _forecast_view( + zone_capacity=capacity, + forecast=forecast, + ) + + effective_free_count = _effective_free_count( + current_free_count=target.current_free_count, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + + effective_confidence = _effective_confidence( + current_confidence=target.current_confidence, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + + probability_free_space = _availability_probability( + effective_free_count=effective_free_count, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + + # Явные пользовательские ограничения — жёсткие. + if min_free_count is not None and effective_free_count < min_free_count: + continue + + if min_confidence is not None and effective_confidence < min_confidence: + continue + + tier = _candidate_tier( + current_free_count=target.current_free_count, + effective_free_count=effective_free_count, + probability_free_space=probability_free_space, + effective_confidence=effective_confidence, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + requested_min_free_count=min_free_count, + use_forecast=use_forecast, + forecast_view=forecast_view, + ) + + score = _display_score( + tier=tier, + effective_free_count=effective_free_count, + probability_free_space=probability_free_space, + effective_confidence=effective_confidence, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + duration_to_destination_seconds=routed.duration_to_destination_seconds, + pay=pay, + ) + + candidate = RouteCandidate( + zone_id=int(zone.parking_zone_id), + camera_id=cast(int | None, zone.camera_id), + geometry=zone.geometry, + zone_type=_enum_value(zone.zone_type) or "unknown", + location_type=_enum_value(zone.location_type), + is_accessible=cast(bool | None, zone.is_accessible), + pay=pay, + capacity=capacity, + current_occupied=target.current_occupied, + current_free_count=target.current_free_count, + current_confidence=target.current_confidence, + predicted_for_arrival=routed.arrival_time, + predicted_occupied=forecast_view.predicted_occupied, + predicted_free_count=forecast_view.predicted_free_count, + probability_free_space=forecast_view.probability_free_space, + forecast_confidence=forecast_view.forecast_confidence, + distance_from_origin_meters=routed.distance_from_origin_meters, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + distance_to_destination_meters=routed.distance_to_destination_meters, + duration_to_destination_seconds=routed.duration_to_destination_seconds, + score=score, + rank=0, + ) + + ranking_key = _ranking_key( + candidate=candidate, + tier=tier, + effective_free_count=effective_free_count, + probability_free_space=probability_free_space, + effective_confidence=effective_confidence, + ) + + candidates_with_meta.append((candidate, ranking_key, tier)) + + candidates_with_meta.sort(key=lambda item: item[1]) + + candidates_with_meta = _remove_unreasonable_detours( + candidates_with_meta=candidates_with_meta, + limit=limit, + selected_zone_id=selected_zone_id, + ) + + total_candidates = len(candidates_with_meta) + + ranked = [ + item[0].model_copy(update={"rank": rank}) + for rank, item in enumerate(candidates_with_meta, start=1) + ] + + return _CandidateSearchResult( + candidates=ranked[:limit], + total_candidates=total_candidates, + ) + + +def _selected_candidate_or_422( + db: Session, + origin: GeoPoint, + destination: GeoPoint | None, + mode: str, + use_forecast: bool, + selected_zone_id: int, +) -> RouteCandidate: + result = _search_candidates( + db=db, + origin=origin, + destination=destination, + mode=mode, + max_pay=None, + min_free_count=None, + min_confidence=None, + max_distance_to_destination_meters=None, + max_duration_from_origin_seconds=None, + include_accessible=None, + use_forecast=use_forecast, + limit=1, + selected_zone_id=selected_zone_id, + ) + + if not result.candidates: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": "Cannot build route to the selected zone"}, + ) + + return result.candidates[0] + + +# --------------------------------------------------------------------------- +# POST /routing/search — публичный поиск без авторизации +# --------------------------------------------------------------------------- + +@router.post("/search", response_model=SearchRoutingResponse) +def search_routing( + body: SearchRoutingRequest, + db: Annotated[Session, Depends(get_db)], +): + try: + result = _search_candidates( + db=db, + origin=body.origin, + destination=body.destination, + mode=body.mode, + max_pay=body.max_pay, + min_free_count=body.min_free_count, + min_confidence=body.min_confidence, + max_distance_to_destination_meters=body.max_distance_to_destination_meters, + max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, + include_accessible=body.include_accessible, + use_forecast=body.use_forecast, + limit=body.limit, + ) + except RoutingProviderError as exc: + raise _provider_unavailable(exc) from exc + + selected_zone_id = result.candidates[0].zone_id if result.candidates else None + + return SearchRoutingResponse( + mode=body.mode, + provider=GEOAPIFY_PROVIDER_NAME, + generated_at=datetime.now(timezone.utc), + selected_zone_id=selected_zone_id, + total_candidates=result.total_candidates, + candidates=result.candidates, + ) + + +# --------------------------------------------------------------------------- +# POST /routing/new — публичное построение и сохранение маршрута +# --------------------------------------------------------------------------- + +@router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) +def create_route( + body: CreateRouteRequest, + db: Annotated[Session, Depends(get_db)], +): + if body.selected_zone_id is not None: + zone_exists = ( + db.query(ParkingZone.parking_zone_id) + .filter(ParkingZone.parking_zone_id == body.selected_zone_id) + .one_or_none() + ) + + if zone_exists is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": f"Zone {body.selected_zone_id} not found"}, + ) + + try: + result = _search_candidates( + db=db, + origin=body.origin, + destination=body.destination, + mode=body.mode, + max_pay=body.max_pay, + min_free_count=body.min_free_count, + min_confidence=body.min_confidence, + max_distance_to_destination_meters=body.max_distance_to_destination_meters, + max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, + include_accessible=body.include_accessible, + use_forecast=body.use_forecast, + limit=body.limit, + selected_zone_id=body.selected_zone_id, + ) + except RoutingProviderError as exc: + raise _provider_unavailable(exc) from exc + + if not result.candidates: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": "No suitable parking zones found"}, + ) + + best = result.candidates[0] + now = datetime.now(timezone.utc) + public_user_id = _get_public_routing_user_id(db) + + zone_point = GeoPoint( + latitude=body.origin.latitude, + longitude=body.origin.longitude, + ) + + selected_zone = ( + db.query(ParkingZone) + .filter(ParkingZone.parking_zone_id == best.zone_id) + .one_or_none() + ) + + if selected_zone is not None: + centroid = _zone_centroid(selected_zone) + + if centroid is not None: + zone_point = centroid + + route = Route( + user_id=public_user_id, + mode=RouteMode(body.mode), + provider=GEOAPIFY_PROVIDER_NAME, + origin_latitude=body.origin.latitude, + origin_longitude=body.origin.longitude, + destination_latitude=body.destination.latitude if body.destination else None, + destination_longitude=body.destination.longitude if body.destination else None, + selected_zone_id=best.zone_id, + selected_candidate=best.model_dump(mode="json"), + eta_seconds=best.duration_from_origin_seconds, + arrival_time=best.predicted_for_arrival, + polyline=None, + deeplink_url=_build_map_deeplink(zone_point), + status=RouteStatus.active, + created_at=now, + updated_at=now, + ) + + try: + db.add(route) + db.commit() + db.refresh(route) + except SQLAlchemyError as exc: + db.rollback() + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error_description": "Route could not be saved", + "error_type": exc.__class__.__name__, + }, + ) + + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# GET /routing — маршруты текущего пользователя +# --------------------------------------------------------------------------- + +@router.get("", response_model=RouteListResponse) +def list_routes( + current_user: Annotated[User, require("routing.view")], + db: Annotated[Session, Depends(get_db)], + route_status: Annotated[str | None, Query(alias="status")] = None, + mode: Annotated[str | None, Query()] = None, + top: Annotated[int, Query(ge=1, le=100)] = 20, + offset: Annotated[int, Query(ge=0)] = 0, +): + query = db.query(Route) + + if current_user.global_role != GlobalRole.admin: + query = query.filter(Route.user_id == current_user.user_id) + + if route_status is not None: + try: + query = query.filter(Route.status == RouteStatus(route_status)) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": f"Unknown status: {route_status}"}, + ) + + if mode is not None: + try: + query = query.filter(Route.mode == RouteMode(mode)) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": f"Unknown mode: {mode}"}, + ) + + total = query.count() + + routes = ( + query + .order_by(Route.created_at.desc()) + .offset(offset) + .limit(top) + .all() + ) + + return RouteListResponse( + items=[_serialize_route(route) for route in routes], + total=total, + top=top, + offset=offset, + ) + + +# --------------------------------------------------------------------------- +# GET /routing/{route_id} +# --------------------------------------------------------------------------- + +@router.get("/{route_id}", response_model=RouteResponse) +def get_route( + route_id: int, + current_user: Annotated[User, require("routing.view")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# PUT /routing/{route_id} +# --------------------------------------------------------------------------- + +@router.put("/{route_id}", response_model=RouteResponse) +def update_route( + route_id: int, + body: UpdateRouteRequest, + current_user: Annotated[User, require("routing.create")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + + if body.status is not None: + route.status = RouteStatus(body.status) + + if body.provider is not None: + route.provider = GEOAPIFY_PROVIDER_NAME + + if body.selected_zone_id is not None: + zone = ( + db.query(ParkingZone) + .filter(ParkingZone.parking_zone_id == body.selected_zone_id) + .one_or_none() + ) + + if zone is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": f"Zone {body.selected_zone_id} not found"}, + ) + + origin = GeoPoint( + latitude=route.origin_latitude, + longitude=route.origin_longitude, + ) + + destination: GeoPoint | None = None + + if route.destination_latitude is not None and route.destination_longitude is not None: + destination = GeoPoint( + latitude=route.destination_latitude, + longitude=route.destination_longitude, + ) + + try: + candidate = _selected_candidate_or_422( + db=db, + origin=origin, + destination=destination, + mode=_enum_value(route.mode) or "find_parking", + use_forecast=True, + selected_zone_id=body.selected_zone_id, + ) + except RoutingProviderError as exc: + raise _provider_unavailable(exc) from exc + + centroid = _zone_centroid(zone) + + route.provider = GEOAPIFY_PROVIDER_NAME + route.selected_zone_id = body.selected_zone_id + route.selected_candidate = candidate.model_dump(mode="json") + route.eta_seconds = candidate.duration_from_origin_seconds + route.arrival_time = candidate.predicted_for_arrival + route.polyline = None + + if centroid is not None: + route.deeplink_url = _build_map_deeplink(centroid) + + route.updated_at = datetime.now(timezone.utc) + + try: + db.commit() + db.refresh(route) + except SQLAlchemyError as exc: + db.rollback() + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error_description": "Route could not be updated", + "error_type": exc.__class__.__name__, + }, + ) + + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# DELETE /routing/{route_id} +# --------------------------------------------------------------------------- + +@router.delete("/{route_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_route( + route_id: int, + current_user: Annotated[User, require("routing.delete")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + + route.status = RouteStatus.cancelled + route.updated_at = datetime.now(timezone.utc) + db.commit() -class UpdateRouteRequest(BaseModel): - status: Literal["active", "completed", "cancelled", "replaced"] | None = None - selected_zone_id: int | None = None - provider: RoutingProvider | None = None \ No newline at end of file + return None \ No newline at end of file From 253883cba961286fa43a37758952c0a94c960db5 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Thu, 28 May 2026 20:27:34 +0300 Subject: [PATCH 2/4] feat: routers/routing.py in schemas/routing.py fix --- src/schemas/routing.py | 1542 +++------------------------------------- 1 file changed, 88 insertions(+), 1454 deletions(-) diff --git a/src/schemas/routing.py b/src/schemas/routing.py index 764b472..f9da266 100644 --- a/src/schemas/routing.py +++ b/src/schemas/routing.py @@ -1,1498 +1,132 @@ from __future__ import annotations -import math -import os -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from typing import Annotated, Any, cast +from datetime import datetime +from typing import Any, Literal -import requests -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import func, or_, text -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session +from pydantic import BaseModel, Field, model_validator -from ..database import get_db -from ..db_models import ( - Forecast, - GlobalRole, - ParkingZone, - Route, - RouteMode, - RouteStatus, - User, -) -from ..dependencies import require -from ..schemas.routing import ( - CreateRouteRequest, - GeoPoint, - RouteCandidate, - RouteListResponse, - RouteResponse, - SearchRoutingRequest, - SearchRoutingResponse, - UpdateRouteRequest, -) -router = APIRouter(prefix="/routing", tags=["Routing"]) +RoutingProvider = Literal["geoapify", "yandex", "internal", "external"] -GEOAPIFY_ROUTEMATRIX_URL = "https://api.geoapify.com/v1/routematrix" -GEOAPIFY_PROVIDER_NAME = "geoapify" -GEOAPIFY_MODE = "drive" -# Ограничения для скорости. -# Мы НЕ отправляем в Geoapify все парковки из БД. -MAX_MATRIX_TARGETS = 80 -MIN_CHEAP_CANDIDATES_FOR_COMPARE = 40 - -# Радиусы расширения поиска вокруг anchor-точки. -# Для find_parking anchor = origin. -# Для route_to_destination anchor = destination. -RADIUS_STEPS_METERS = [ - 500, - 1_000, - 2_000, - 5_000, - 10_000, - 25_000, - 50_000, - 100_000, - 250_000, - 500_000, - 1_000_000, - None, # финальный fallback: взять все зоны, если рядом вообще ничего нет -] - -# Оценка пешего пути от парковки до destination. -# Geoapify Route Matrix здесь используем для поездки origin -> parking. -# А parking -> destination считаем быстро по haversine с коэффициентом. -WALKING_SPEED_METERS_PER_SECOND = 1.35 -WALKING_DETOUR_FACTOR = 1.35 - -# Если до парковки ехать меньше этого времени, а свободных мест нет, -# она считается почти бесполезной без сильного прогноза на освобождение. -SHORT_TRIP_SECONDS = 30 * 60 - -# Если прогноз показывает хотя бы столько мест, занятая сейчас парковка -# может стать нормальным кандидатом. -FORECAST_OPPORTUNITY_FREE_COUNT = 2 - -# Сколько времени вокруг arrival_time забираем прогнозы одним батчем. -FORECAST_LOOKAROUND = timedelta(hours=2) +# --------------------------------------------------------------------------- +# Вспомогательные типы +# --------------------------------------------------------------------------- -# Для публичного /routing/new без авторизации. -# Лучше задать в .env существующий user_id. -PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" +class GeoPoint(BaseModel): + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) # --------------------------------------------------------------------------- -# Внутренние типы +# RouteCandidate # --------------------------------------------------------------------------- -class RoutingProviderError(Exception): - pass - - -@dataclass(frozen=True) -class _ZoneTarget: - zone: ParkingZone - point: GeoPoint - anchor_distance_meters: int +class RouteCandidate(BaseModel): + zone_id: int + camera_id: int | None + geometry: Any + zone_type: str + location_type: str | None + is_accessible: bool | None + pay: int + capacity: int current_occupied: int current_free_count: int current_confidence: float - - -@dataclass(frozen=True) -class _RoutedCandidate: - zone_target: _ZoneTarget - distance_from_origin_meters: int - duration_from_origin_seconds: int - distance_to_destination_meters: int | None - duration_to_destination_seconds: int | None - arrival_time: datetime - - -@dataclass(frozen=True) -class _ForecastView: + predicted_for_arrival: datetime predicted_occupied: int | None predicted_free_count: int | None probability_free_space: float | None forecast_confidence: float | None - - -@dataclass(frozen=True) -class _CandidateSearchResult: - candidates: list[RouteCandidate] - total_candidates: int - - -# --------------------------------------------------------------------------- -# Общие helpers -# --------------------------------------------------------------------------- - -def _enum_value(value: Any) -> str | None: - if value is None: - return None - - enum_value = getattr(value, "value", None) - - if enum_value is not None: - return str(enum_value) - - return str(value) - - -def _to_utc_naive(value: datetime) -> datetime: - if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: - return value - - return value.astimezone(timezone.utc).replace(tzinfo=None) - - -def _datetime_timestamp(value: datetime | None) -> float: - if value is None: - return 0.0 - - if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: - value = value.replace(tzinfo=timezone.utc) - - return value.timestamp() - - -def _seconds_between(a: datetime, b: datetime) -> float: - return abs((_to_utc_naive(a) - _to_utc_naive(b)).total_seconds()) - - -def _serialize_route(route: Route) -> RouteResponse: - candidate: RouteCandidate | None = None - - if route.selected_candidate: - candidate = RouteCandidate.model_validate(route.selected_candidate) - - destination: GeoPoint | None = None - - if route.destination_latitude is not None and route.destination_longitude is not None: - destination = GeoPoint( - latitude=route.destination_latitude, - longitude=route.destination_longitude, - ) - - return RouteResponse( - route_id=route.route_id, - user_id=route.user_id, - mode=_enum_value(route.mode) or str(route.mode), - provider=route.provider, - origin=GeoPoint( - latitude=route.origin_latitude, - longitude=route.origin_longitude, - ), - destination=destination, - selected_zone_id=route.selected_zone_id, - selected_candidate=candidate, - eta_seconds=route.eta_seconds, - arrival_time=route.arrival_time, - polyline=route.polyline, - deeplink_url=route.deeplink_url, - status=_enum_value(route.status) or str(route.status), - created_at=route.created_at, - updated_at=route.updated_at, - ) - - -def _get_route_or_404(db: Session, route_id: int) -> Route: - route = db.query(Route).filter(Route.route_id == route_id).one_or_none() - - if route is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"error_description": "Route not found"}, - ) - - return route - - -def _assert_owner_or_admin(route: Route, current_user: User) -> None: - if current_user.global_role != GlobalRole.admin and route.user_id != current_user.user_id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={"error_description": "Access denied: not your route"}, - ) - - -def _provider_unavailable(exc: RoutingProviderError) -> HTTPException: - return HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"error_description": str(exc)}, - ) - - -def _get_public_routing_user_id(db: Session) -> int: - """ - /routing/new у тебя отключён от авторизации, но routes.user_id обычно NOT NULL. - Поэтому используем PUBLIC_ROUTING_USER_ID из .env, если он задан. - Если не задан — берём первого существующего пользователя. - """ - raw_user_id = os.getenv(PUBLIC_ROUTING_USER_ID_ENV) - - if raw_user_id: - try: - return int(raw_user_id) - except ValueError: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error_description": f"{PUBLIC_ROUTING_USER_ID_ENV} must be integer" - }, - ) - - row = db.query(User.user_id).order_by(User.user_id.asc()).first() - - if row is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error_description": ( - "Cannot create public route: no users exist. " - f"Create a technical user or set {PUBLIC_ROUTING_USER_ID_ENV}." - ) - }, - ) - - return int(row[0]) - - -def _zone_centroid(zone: ParkingZone) -> GeoPoint | None: - geometry = zone.geometry - - if not isinstance(geometry, dict): - return None - - try: - coords = list(geometry["coordinates"][0]) - - if len(coords) > 1 and coords[0] == coords[-1]: - coords = coords[:-1] - - if not coords: - return None - - latitude = sum(float(point[1]) for point in coords) / len(coords) - longitude = sum(float(point[0]) for point in coords) / len(coords) - - return GeoPoint(latitude=latitude, longitude=longitude) - - except (KeyError, TypeError, ValueError, IndexError, ZeroDivisionError): - try: - return GeoPoint( - latitude=float(geometry["lat"]), - longitude=float(geometry["lon"]), - ) - except (KeyError, TypeError, ValueError): - return None - - -def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: - earth_radius_meters = 6_371_000 - - lat1 = math.radians(a.latitude) - lat2 = math.radians(b.latitude) - delta_lat = math.radians(b.latitude - a.latitude) - delta_lon = math.radians(b.longitude - a.longitude) - - h = ( - math.sin(delta_lat / 2) ** 2 - + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 - ) - - return int(2 * earth_radius_meters * math.asin(math.sqrt(h))) - - -def _estimated_walking_seconds(distance_meters: int) -> int: - return int(distance_meters * WALKING_DETOUR_FACTOR / WALKING_SPEED_METERS_PER_SECOND) - - -def _build_map_deeplink(destination: GeoPoint) -> str: - return ( - "https://www.google.com/maps/dir/?api=1" - f"&destination={destination.latitude},{destination.longitude}" - ) - - -# --------------------------------------------------------------------------- -# Geoapify Route Matrix -# --------------------------------------------------------------------------- - -def _geoapify_api_key() -> str: - api_key = os.getenv("GEOAPIFY_API_KEY") - - if not api_key: - raise RoutingProviderError("Geoapify API key is not configured") - - return api_key - - -def _geoapify_matrix( - sources: list[GeoPoint], - targets: list[GeoPoint], -) -> list[list[dict[str, Any]]]: - if not sources or not targets: - return [] - - api_key = _geoapify_api_key() - - payload = { - "mode": GEOAPIFY_MODE, - "sources": [ - {"location": [point.longitude, point.latitude]} - for point in sources - ], - "targets": [ - {"location": [point.longitude, point.latitude]} - for point in targets - ], - } - - try: - response = requests.post( - GEOAPIFY_ROUTEMATRIX_URL, - params={"apiKey": api_key}, - headers={"Content-Type": "application/json"}, - json=payload, - timeout=15, - ) - except requests.RequestException as exc: - raise RoutingProviderError("Geoapify Route Matrix API is unavailable") from exc - - if response.status_code >= 500: - raise RoutingProviderError( - f"Geoapify Route Matrix API is unavailable: HTTP {response.status_code}" - ) - - if response.status_code >= 400: - raise RoutingProviderError( - f"Geoapify Route Matrix API rejected request: " - f"HTTP {response.status_code}: {response.text[:300]}" - ) - - try: - data = response.json() - except ValueError as exc: - raise RoutingProviderError("Geoapify returned invalid JSON") from exc - - matrix = data.get("sources_to_targets") - - if not isinstance(matrix, list): - raise RoutingProviderError("Geoapify response does not contain sources_to_targets") - - return matrix - - -def _matrix_cell( - matrix: list[list[dict[str, Any]]], - source_index: int, - target_index: int, -) -> tuple[int, int] | None: - try: - cell = matrix[source_index][target_index] - except (IndexError, TypeError): - return None - - if not isinstance(cell, dict): - return None - - distance = cell.get("distance") - duration = cell.get("time", cell.get("duration")) - - if distance is None or duration is None: - return None - - try: - return int(round(float(distance))), int(round(float(duration))) - except (TypeError, ValueError): - return None - - -# --------------------------------------------------------------------------- -# Быстрый отбор зон до обращения в Geoapify -# --------------------------------------------------------------------------- - -def _query_active_zones( - db: Session, - max_pay: int | None, - include_accessible: bool | None, - selected_zone_id: int | None, -) -> list[ParkingZone]: - query = db.query(ParkingZone).filter(ParkingZone.is_active.is_(True)) - - if selected_zone_id is not None: - query = query.filter(ParkingZone.parking_zone_id == selected_zone_id) - - if max_pay is not None: - query = query.filter(ParkingZone.pay <= max_pay) - - if include_accessible is False: - query = query.filter( - or_( - ParkingZone.is_accessible.is_(False), - ParkingZone.is_accessible.is_(None), - ) - ) - - return query.all() - - -def _build_zone_targets( - zones: list[ParkingZone], - anchor: GeoPoint, -) -> list[_ZoneTarget]: - targets: list[_ZoneTarget] = [] - - for zone in zones: - point = _zone_centroid(zone) - - if point is None: - continue - - capacity = max(int(zone.capacity or 0), 0) - occupied = max(int(zone.occupied or 0), 0) - occupied = min(occupied, capacity) - free_count = max(capacity - occupied, 0) - confidence = max(0.0, min(float(zone.confidence or 0.0), 1.0)) - - targets.append( - _ZoneTarget( - zone=zone, - point=point, - anchor_distance_meters=_haversine_meters(anchor, point), - current_occupied=occupied, - current_free_count=free_count, - current_confidence=confidence, - ) - ) - - targets.sort(key=lambda item: item.anchor_distance_meters) - - return targets - - -def _choose_radius_pool( - targets: list[_ZoneTarget], - limit: int, - selected_zone_id: int | None, -) -> list[_ZoneTarget]: - if selected_zone_id is not None: - return targets[:1] - - if not targets: - return [] - - required_for_compare = max(MIN_CHEAP_CANDIDATES_FOR_COMPARE, limit * 8) - - for radius in RADIUS_STEPS_METERS: - if radius is None: - pool = targets - else: - pool = [ - target - for target in targets - if target.anchor_distance_meters <= radius - ] - - if len(pool) >= required_for_compare: - return pool[:MAX_MATRIX_TARGETS] - - return targets[:MAX_MATRIX_TARGETS] - - -# --------------------------------------------------------------------------- -# Прогнозы -# --------------------------------------------------------------------------- - -def _load_forecasts_for_candidates( - db: Session, - zone_ids: list[int], - min_arrival: datetime, - max_arrival: datetime, -) -> dict[int, list[Forecast]]: - if not zone_ids: - return {} - - from_time = _to_utc_naive(min_arrival - FORECAST_LOOKAROUND) - to_time = _to_utc_naive(max_arrival + FORECAST_LOOKAROUND) - - rows = ( - db.query(Forecast) - .filter(Forecast.zone_id.in_(zone_ids)) - .filter(Forecast.predicted_for >= from_time) - .filter(Forecast.predicted_for <= to_time) - .order_by( - Forecast.zone_id.asc(), - Forecast.predicted_for.asc(), - Forecast.generated_at.desc(), - Forecast.forecast_id.desc(), - ) - .all() - ) - - result: dict[int, list[Forecast]] = {} - - for forecast in rows: - result.setdefault(int(forecast.zone_id), []).append(forecast) - - return result - - -def _pick_forecast_for_arrival( - forecasts: list[Forecast], - arrival_time: datetime, -) -> Forecast | None: - if not forecasts: - return None - - return min( - forecasts, - key=lambda forecast: ( - _seconds_between(forecast.predicted_for, arrival_time), - -_datetime_timestamp(forecast.generated_at), - -int(forecast.forecast_id), - ), - ) - - -def _forecast_view( - zone_capacity: int, - forecast: Forecast | None, -) -> _ForecastView: - if forecast is None: - return _ForecastView( - predicted_occupied=None, - predicted_free_count=None, - probability_free_space=None, - forecast_confidence=None, - ) - - forecast_capacity = max(int(forecast.capacity or zone_capacity), 0) - predicted_occupied = max(int(forecast.predicted_occupied or 0), 0) - predicted_occupied = min(predicted_occupied, forecast_capacity) - - predicted_free_count = max(forecast_capacity - predicted_occupied, 0) - - probability_free_space = ( - max(0.0, min(float(forecast.probability_free_space), 1.0)) - if forecast.probability_free_space is not None - else None - ) - - forecast_confidence = ( - max(0.0, min(float(forecast.confidence), 1.0)) - if forecast.confidence is not None - else None - ) - - return _ForecastView( - predicted_occupied=predicted_occupied, - predicted_free_count=predicted_free_count, - probability_free_space=probability_free_space, - forecast_confidence=forecast_confidence, - ) - - -# --------------------------------------------------------------------------- -# Умная оценка кандидата -# --------------------------------------------------------------------------- - -def _effective_free_count( - current_free_count: int, - forecast_view: _ForecastView, - use_forecast: bool, -) -> int: - if use_forecast and forecast_view.predicted_free_count is not None: - return forecast_view.predicted_free_count - - return current_free_count - - -def _effective_confidence( - current_confidence: float, - forecast_view: _ForecastView, - use_forecast: bool, -) -> float: - if use_forecast and forecast_view.forecast_confidence is not None: - return forecast_view.forecast_confidence - - return current_confidence - - -def _availability_probability( - effective_free_count: int, - forecast_view: _ForecastView, - use_forecast: bool, -) -> float: - if use_forecast and forecast_view.probability_free_space is not None: - return forecast_view.probability_free_space - - if effective_free_count >= 3: - return 0.85 - - if effective_free_count == 2: - return 0.70 - - if effective_free_count == 1: - return 0.50 - - return 0.05 - - -def _candidate_tier( - current_free_count: int, - effective_free_count: int, - probability_free_space: float, - effective_confidence: float, - duration_from_origin_seconds: int, - requested_min_free_count: int | None, - use_forecast: bool, - forecast_view: _ForecastView, -) -> int: - """ - Чем меньше tier, тем лучше. - - 0 — отличный кандидат; - 1 — хороший кандидат; - 2 — сейчас занято, но прогноз к arrival_time хороший; - 3 — рискованный, но возможный; - 4 — запасной вариант; - 5 — почти бесполезный вариант. - """ - min_required = requested_min_free_count if requested_min_free_count is not None else 1 - - if effective_free_count >= max(3, min_required) and probability_free_space >= 0.65: - return 0 - - if effective_free_count >= min_required and probability_free_space >= 0.40: - return 1 - - if ( - use_forecast - and current_free_count == 0 - and forecast_view.predicted_free_count is not None - and forecast_view.predicted_free_count >= FORECAST_OPPORTUNITY_FREE_COUNT - and duration_from_origin_seconds >= 10 * 60 - and ( - forecast_view.probability_free_space is None - or forecast_view.probability_free_space >= 0.35 - ) - ): - return 2 - - if effective_free_count > 0: - return 3 - - if duration_from_origin_seconds <= SHORT_TRIP_SECONDS: - return 5 - - if effective_confidence < 0.35: - return 5 - - return 4 - - -def _price_bucket(pay: int) -> int: - if pay <= 0: - return 0 - - if pay <= 50: - return 1 - - if pay <= 150: - return 2 - - if pay <= 300: - return 3 - - return 4 - - -def _duration_bucket(seconds: int) -> int: - if seconds <= 10 * 60: - return 0 - - if seconds <= 20 * 60: - return 1 - - if seconds <= 40 * 60: - return 2 - - if seconds <= 60 * 60: - return 3 - - if seconds <= 2 * 60 * 60: - return 4 - - return 5 - - -def _walk_bucket(seconds: int | None) -> int: - if seconds is None: - return 0 - - if seconds <= 5 * 60: - return 0 - - if seconds <= 10 * 60: - return 1 - - if seconds <= 20 * 60: - return 2 - - if seconds <= 30 * 60: - return 3 - - return 4 - - -def _display_score( - tier: int, - effective_free_count: int, - probability_free_space: float, - effective_confidence: float, - duration_from_origin_seconds: int, - duration_to_destination_seconds: int | None, - pay: int, -) -> float: - base_by_tier = { - 0: 0.95, - 1: 0.82, - 2: 0.72, - 3: 0.55, - 4: 0.35, - 5: 0.12, - } - - base = base_by_tier.get(tier, 0.10) - - free_bonus = min(effective_free_count, 6) * 0.015 - probability_bonus = probability_free_space * 0.05 - confidence_bonus = effective_confidence * 0.03 - - duration_penalty = min(duration_from_origin_seconds / (2 * 60 * 60), 1.0) * 0.10 - - if duration_to_destination_seconds is None: - walk_penalty = 0.0 - else: - walk_penalty = min(duration_to_destination_seconds / (30 * 60), 1.0) * 0.08 - - price_penalty = min(pay / 500.0, 1.0) * 0.04 - - score = ( - base - + free_bonus - + probability_bonus - + confidence_bonus - - duration_penalty - - walk_penalty - - price_penalty - ) - - return round(max(0.0, min(score, 1.0)), 6) - - -def _ranking_key( - candidate: RouteCandidate, - tier: int, - effective_free_count: int, - probability_free_space: float, - effective_confidence: float, -) -> tuple[Any, ...]: - """ - Здесь важен порядок: - 1. качество доступности; - 2. грубая корзина времени; - 3. пеший путь до destination; - 4. запас свободных мест; - 5. цена; - 6. уверенность как tie-breaker. - """ - return ( - tier, - _duration_bucket(candidate.duration_from_origin_seconds), - _walk_bucket(candidate.duration_to_destination_seconds), - -effective_free_count, - -probability_free_space, - _price_bucket(candidate.pay), - -effective_confidence, - candidate.duration_from_origin_seconds, - candidate.distance_from_origin_meters, - candidate.distance_to_destination_meters - if candidate.distance_to_destination_meters is not None - else 0, - candidate.pay, - candidate.zone_id, - ) - - -def _remove_unreasonable_detours( - candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]], - limit: int, - selected_zone_id: int | None, -) -> list[tuple[RouteCandidate, tuple[Any, ...], int]]: - if selected_zone_id is not None: - return candidates_with_meta - - if not candidates_with_meta: - return [] - - primary = [ - item - for item in candidates_with_meta - if item[2] <= 3 - ] - - if len(primary) < max(3, min(limit, 5)): - return candidates_with_meta - - best_duration = min(item[0].duration_from_origin_seconds for item in primary) - - # Не показываем вариант на 5 часов, если есть сопоставимые варианты за час. - max_reasonable_duration = max( - best_duration + 30 * 60, - int(best_duration * 2.5), - ) - - max_reasonable_duration = min( - max_reasonable_duration, - best_duration + 2 * 60 * 60, - ) - - reasonable: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - backup: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - - for item in candidates_with_meta: - candidate = item[0] - tier = item[2] - - if tier <= 3 and candidate.duration_from_origin_seconds <= max_reasonable_duration: - reasonable.append(item) - else: - backup.append(item) - - if len(reasonable) >= limit: - return reasonable - - return reasonable + backup - - -# --------------------------------------------------------------------------- -# Основной поиск кандидатов -# --------------------------------------------------------------------------- - -def _route_zone_pool( - origin: GeoPoint, - destination: GeoPoint | None, - zone_targets: list[_ZoneTarget], -) -> list[_RoutedCandidate]: - if not zone_targets: - return [] - - matrix = _geoapify_matrix( - sources=[origin], - targets=[item.point for item in zone_targets], - ) - - now = datetime.now(timezone.utc) - routed: list[_RoutedCandidate] = [] - - for index, item in enumerate(zone_targets): - from_origin = _matrix_cell(matrix, 0, index) - - if from_origin is None: - continue - - distance_from_origin, duration_from_origin = from_origin - arrival_time = now + timedelta(seconds=duration_from_origin) - - distance_to_destination: int | None = None - duration_to_destination: int | None = None - - if destination is not None: - direct_distance = _haversine_meters(item.point, destination) - distance_to_destination = int(direct_distance * WALKING_DETOUR_FACTOR) - duration_to_destination = _estimated_walking_seconds(direct_distance) - - routed.append( - _RoutedCandidate( - zone_target=item, - distance_from_origin_meters=distance_from_origin, - duration_from_origin_seconds=duration_from_origin, - distance_to_destination_meters=distance_to_destination, - duration_to_destination_seconds=duration_to_destination, - arrival_time=arrival_time, - ) - ) - - return routed - - -def _search_candidates( - db: Session, - origin: GeoPoint, - destination: GeoPoint | None, - mode: str, - max_pay: int | None, - min_free_count: int | None, - min_confidence: float | None, - max_distance_to_destination_meters: int | None, - max_duration_from_origin_seconds: int | None, - include_accessible: bool | None, - use_forecast: bool, - limit: int, - selected_zone_id: int | None = None, -) -> _CandidateSearchResult: - if mode == "route_to_destination" and destination is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": "destination is required for mode=route_to_destination"}, - ) - - anchor = destination if mode == "route_to_destination" and destination is not None else origin - - zones = _query_active_zones( - db=db, - max_pay=max_pay, - include_accessible=include_accessible, - selected_zone_id=selected_zone_id, - ) - - zone_targets = _build_zone_targets(zones, anchor) - cheap_pool = _choose_radius_pool( - targets=zone_targets, - limit=limit, - selected_zone_id=selected_zone_id, - ) - - routed_candidates = _route_zone_pool( - origin=origin, - destination=destination, - zone_targets=cheap_pool, - ) - - if not routed_candidates: - return _CandidateSearchResult(candidates=[], total_candidates=0) - - filtered_by_route: list[_RoutedCandidate] = [] - - for routed in routed_candidates: - if ( - max_duration_from_origin_seconds is not None - and routed.duration_from_origin_seconds > max_duration_from_origin_seconds - ): - continue - - if ( - max_distance_to_destination_meters is not None - and routed.distance_to_destination_meters is not None - and routed.distance_to_destination_meters > max_distance_to_destination_meters - ): - continue - - filtered_by_route.append(routed) - - if not filtered_by_route: - return _CandidateSearchResult(candidates=[], total_candidates=0) - - min_arrival = min(item.arrival_time for item in filtered_by_route) - max_arrival = max(item.arrival_time for item in filtered_by_route) - - zone_ids = [ - int(item.zone_target.zone.parking_zone_id) - for item in filtered_by_route - ] - - forecasts_by_zone = ( - _load_forecasts_for_candidates( - db=db, - zone_ids=zone_ids, - min_arrival=min_arrival, - max_arrival=max_arrival, - ) - if use_forecast - else {} - ) - - candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - - for routed in filtered_by_route: - target = routed.zone_target - zone = target.zone - - capacity = max(int(zone.capacity or 0), 0) - pay = max(int(zone.pay or 0), 0) - - forecast = None - - if use_forecast: - forecast = _pick_forecast_for_arrival( - forecasts_by_zone.get(int(zone.parking_zone_id), []), - routed.arrival_time, - ) - - forecast_view = _forecast_view( - zone_capacity=capacity, - forecast=forecast, - ) - - effective_free_count = _effective_free_count( - current_free_count=target.current_free_count, - forecast_view=forecast_view, - use_forecast=use_forecast, - ) - - effective_confidence = _effective_confidence( - current_confidence=target.current_confidence, - forecast_view=forecast_view, - use_forecast=use_forecast, - ) - - probability_free_space = _availability_probability( - effective_free_count=effective_free_count, - forecast_view=forecast_view, - use_forecast=use_forecast, - ) - - # Явные пользовательские ограничения — жёсткие. - if min_free_count is not None and effective_free_count < min_free_count: - continue - - if min_confidence is not None and effective_confidence < min_confidence: - continue - - tier = _candidate_tier( - current_free_count=target.current_free_count, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - requested_min_free_count=min_free_count, - use_forecast=use_forecast, - forecast_view=forecast_view, - ) - - score = _display_score( - tier=tier, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - duration_to_destination_seconds=routed.duration_to_destination_seconds, - pay=pay, - ) - - candidate = RouteCandidate( - zone_id=int(zone.parking_zone_id), - camera_id=cast(int | None, zone.camera_id), - geometry=zone.geometry, - zone_type=_enum_value(zone.zone_type) or "unknown", - location_type=_enum_value(zone.location_type), - is_accessible=cast(bool | None, zone.is_accessible), - pay=pay, - capacity=capacity, - current_occupied=target.current_occupied, - current_free_count=target.current_free_count, - current_confidence=target.current_confidence, - predicted_for_arrival=routed.arrival_time, - predicted_occupied=forecast_view.predicted_occupied, - predicted_free_count=forecast_view.predicted_free_count, - probability_free_space=forecast_view.probability_free_space, - forecast_confidence=forecast_view.forecast_confidence, - distance_from_origin_meters=routed.distance_from_origin_meters, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - distance_to_destination_meters=routed.distance_to_destination_meters, - duration_to_destination_seconds=routed.duration_to_destination_seconds, - score=score, - rank=0, - ) - - ranking_key = _ranking_key( - candidate=candidate, - tier=tier, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - ) - - candidates_with_meta.append((candidate, ranking_key, tier)) - - candidates_with_meta.sort(key=lambda item: item[1]) - - candidates_with_meta = _remove_unreasonable_detours( - candidates_with_meta=candidates_with_meta, - limit=limit, - selected_zone_id=selected_zone_id, - ) - - total_candidates = len(candidates_with_meta) - - ranked = [ - item[0].model_copy(update={"rank": rank}) - for rank, item in enumerate(candidates_with_meta, start=1) - ] - - return _CandidateSearchResult( - candidates=ranked[:limit], - total_candidates=total_candidates, - ) - - -def _selected_candidate_or_422( - db: Session, - origin: GeoPoint, - destination: GeoPoint | None, - mode: str, - use_forecast: bool, - selected_zone_id: int, -) -> RouteCandidate: - result = _search_candidates( - db=db, - origin=origin, - destination=destination, - mode=mode, - max_pay=None, - min_free_count=None, - min_confidence=None, - max_distance_to_destination_meters=None, - max_duration_from_origin_seconds=None, - include_accessible=None, - use_forecast=use_forecast, - limit=1, - selected_zone_id=selected_zone_id, - ) - - if not result.candidates: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": "Cannot build route to the selected zone"}, - ) - - return result.candidates[0] - - -# --------------------------------------------------------------------------- -# POST /routing/search — публичный поиск без авторизации -# --------------------------------------------------------------------------- - -@router.post("/search", response_model=SearchRoutingResponse) -def search_routing( - body: SearchRoutingRequest, - db: Annotated[Session, Depends(get_db)], -): - try: - result = _search_candidates( - db=db, - origin=body.origin, - destination=body.destination, - mode=body.mode, - max_pay=body.max_pay, - min_free_count=body.min_free_count, - min_confidence=body.min_confidence, - max_distance_to_destination_meters=body.max_distance_to_destination_meters, - max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, - include_accessible=body.include_accessible, - use_forecast=body.use_forecast, - limit=body.limit, - ) - except RoutingProviderError as exc: - raise _provider_unavailable(exc) from exc - - selected_zone_id = result.candidates[0].zone_id if result.candidates else None - - return SearchRoutingResponse( - mode=body.mode, - provider=GEOAPIFY_PROVIDER_NAME, - generated_at=datetime.now(timezone.utc), - selected_zone_id=selected_zone_id, - total_candidates=result.total_candidates, - candidates=result.candidates, - ) + distance_from_origin_meters: int + duration_from_origin_seconds: int + distance_to_destination_meters: int | None + duration_to_destination_seconds: int | None + score: float + rank: int # --------------------------------------------------------------------------- -# POST /routing/new — публичное построение и сохранение маршрута +# Route # --------------------------------------------------------------------------- -@router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) -def create_route( - body: CreateRouteRequest, - db: Annotated[Session, Depends(get_db)], -): - if body.selected_zone_id is not None: - zone_exists = ( - db.query(ParkingZone.parking_zone_id) - .filter(ParkingZone.parking_zone_id == body.selected_zone_id) - .one_or_none() - ) +class RouteResponse(BaseModel): + route_id: int + user_id: int + mode: str + provider: str + origin: GeoPoint + destination: GeoPoint | None + selected_zone_id: int | None + selected_candidate: RouteCandidate | None + eta_seconds: int | None + arrival_time: datetime | None + polyline: str | None + deeplink_url: str | None + status: str + created_at: datetime + updated_at: datetime - if zone_exists is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"error_description": f"Zone {body.selected_zone_id} not found"}, - ) - try: - result = _search_candidates( - db=db, - origin=body.origin, - destination=body.destination, - mode=body.mode, - max_pay=body.max_pay, - min_free_count=body.min_free_count, - min_confidence=body.min_confidence, - max_distance_to_destination_meters=body.max_distance_to_destination_meters, - max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, - include_accessible=body.include_accessible, - use_forecast=body.use_forecast, - limit=body.limit, - selected_zone_id=body.selected_zone_id, - ) - except RoutingProviderError as exc: - raise _provider_unavailable(exc) from exc - - if not result.candidates: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": "No suitable parking zones found"}, - ) - - best = result.candidates[0] - now = datetime.now(timezone.utc) - public_user_id = _get_public_routing_user_id(db) - - zone_point = GeoPoint( - latitude=body.origin.latitude, - longitude=body.origin.longitude, - ) - - selected_zone = ( - db.query(ParkingZone) - .filter(ParkingZone.parking_zone_id == best.zone_id) - .one_or_none() - ) - - if selected_zone is not None: - centroid = _zone_centroid(selected_zone) - - if centroid is not None: - zone_point = centroid - - route = Route( - user_id=public_user_id, - mode=RouteMode(body.mode), - provider=GEOAPIFY_PROVIDER_NAME, - origin_latitude=body.origin.latitude, - origin_longitude=body.origin.longitude, - destination_latitude=body.destination.latitude if body.destination else None, - destination_longitude=body.destination.longitude if body.destination else None, - selected_zone_id=best.zone_id, - selected_candidate=best.model_dump(mode="json"), - eta_seconds=best.duration_from_origin_seconds, - arrival_time=best.predicted_for_arrival, - polyline=None, - deeplink_url=_build_map_deeplink(zone_point), - status=RouteStatus.active, - created_at=now, - updated_at=now, - ) - - try: - db.add(route) - db.commit() - db.refresh(route) - except SQLAlchemyError as exc: - db.rollback() - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error_description": "Route could not be saved", - "error_type": exc.__class__.__name__, - }, - ) - - return _serialize_route(route) +class RouteListResponse(BaseModel): + items: list[RouteResponse] + total: int + top: int + offset: int # --------------------------------------------------------------------------- -# GET /routing — маршруты текущего пользователя +# Запросы # --------------------------------------------------------------------------- -@router.get("", response_model=RouteListResponse) -def list_routes( - current_user: Annotated[User, require("routing.view")], - db: Annotated[Session, Depends(get_db)], - route_status: Annotated[str | None, Query(alias="status")] = None, - mode: Annotated[str | None, Query()] = None, - top: Annotated[int, Query(ge=1, le=100)] = 20, - offset: Annotated[int, Query(ge=0)] = 0, -): - query = db.query(Route) - - if current_user.global_role != GlobalRole.admin: - query = query.filter(Route.user_id == current_user.user_id) - - if route_status is not None: - try: - query = query.filter(Route.status == RouteStatus(route_status)) - except ValueError: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": f"Unknown status: {route_status}"}, - ) - - if mode is not None: - try: - query = query.filter(Route.mode == RouteMode(mode)) - except ValueError: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": f"Unknown mode: {mode}"}, - ) - - total = query.count() - - routes = ( - query - .order_by(Route.created_at.desc()) - .offset(offset) - .limit(top) - .all() - ) - - return RouteListResponse( - items=[_serialize_route(route) for route in routes], - total=total, - top=top, - offset=offset, - ) - - -# --------------------------------------------------------------------------- -# GET /routing/{route_id} -# --------------------------------------------------------------------------- +class RoutingRequestBase(BaseModel): + mode: Literal["find_parking", "route_to_destination"] + origin: GeoPoint + destination: GeoPoint | None = None + max_pay: int | None = Field(None, ge=0) + min_free_count: int | None = Field(None, ge=0) + min_confidence: float | None = Field(None, ge=0.0, le=1.0) + max_distance_to_destination_meters: int | None = Field(None, ge=0) + max_duration_from_origin_seconds: int | None = Field(None, ge=0) + include_accessible: bool | None = None + limit: int = Field(10, ge=1, le=50) + use_forecast: bool = False + provider: RoutingProvider = "geoapify" + + @model_validator(mode="after") + def destination_required_for_route_mode(self) -> "RoutingRequestBase": + if self.mode == "route_to_destination" and self.destination is None: + raise ValueError("destination is required for mode=route_to_destination") + return self + + +class SearchRoutingRequest(RoutingRequestBase): + pass -@router.get("/{route_id}", response_model=RouteResponse) -def get_route( - route_id: int, - current_user: Annotated[User, require("routing.view")], - db: Annotated[Session, Depends(get_db)], -): - route = _get_route_or_404(db, route_id) - _assert_owner_or_admin(route, current_user) - return _serialize_route(route) +class CreateRouteRequest(RoutingRequestBase): + selected_zone_id: int | None = None # --------------------------------------------------------------------------- -# PUT /routing/{route_id} +# Ответ /routing/search # --------------------------------------------------------------------------- -@router.put("/{route_id}", response_model=RouteResponse) -def update_route( - route_id: int, - body: UpdateRouteRequest, - current_user: Annotated[User, require("routing.create")], - db: Annotated[Session, Depends(get_db)], -): - route = _get_route_or_404(db, route_id) - _assert_owner_or_admin(route, current_user) - - if body.status is not None: - route.status = RouteStatus(body.status) - - if body.provider is not None: - route.provider = GEOAPIFY_PROVIDER_NAME - - if body.selected_zone_id is not None: - zone = ( - db.query(ParkingZone) - .filter(ParkingZone.parking_zone_id == body.selected_zone_id) - .one_or_none() - ) - - if zone is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"error_description": f"Zone {body.selected_zone_id} not found"}, - ) - - origin = GeoPoint( - latitude=route.origin_latitude, - longitude=route.origin_longitude, - ) - - destination: GeoPoint | None = None - - if route.destination_latitude is not None and route.destination_longitude is not None: - destination = GeoPoint( - latitude=route.destination_latitude, - longitude=route.destination_longitude, - ) - - try: - candidate = _selected_candidate_or_422( - db=db, - origin=origin, - destination=destination, - mode=_enum_value(route.mode) or "find_parking", - use_forecast=True, - selected_zone_id=body.selected_zone_id, - ) - except RoutingProviderError as exc: - raise _provider_unavailable(exc) from exc - - centroid = _zone_centroid(zone) - - route.provider = GEOAPIFY_PROVIDER_NAME - route.selected_zone_id = body.selected_zone_id - route.selected_candidate = candidate.model_dump(mode="json") - route.eta_seconds = candidate.duration_from_origin_seconds - route.arrival_time = candidate.predicted_for_arrival - route.polyline = None - - if centroid is not None: - route.deeplink_url = _build_map_deeplink(centroid) - - route.updated_at = datetime.now(timezone.utc) - - try: - db.commit() - db.refresh(route) - except SQLAlchemyError as exc: - db.rollback() - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error_description": "Route could not be updated", - "error_type": exc.__class__.__name__, - }, - ) - - return _serialize_route(route) +class SearchRoutingResponse(BaseModel): + mode: str + provider: str + generated_at: datetime + selected_zone_id: int | None + total_candidates: int + candidates: list[RouteCandidate] # --------------------------------------------------------------------------- -# DELETE /routing/{route_id} +# Обновление маршрута # --------------------------------------------------------------------------- -@router.delete("/{route_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_route( - route_id: int, - current_user: Annotated[User, require("routing.delete")], - db: Annotated[Session, Depends(get_db)], -): - route = _get_route_or_404(db, route_id) - _assert_owner_or_admin(route, current_user) - - route.status = RouteStatus.cancelled - route.updated_at = datetime.now(timezone.utc) - - db.commit() - - return None \ No newline at end of file +class UpdateRouteRequest(BaseModel): + status: Literal["active", "completed", "cancelled", "replaced"] | None = None + selected_zone_id: int | None = None + provider: RoutingProvider | None = None \ No newline at end of file From 2c518cf089379cbef9f5d64a8aa02715d76f024a Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Thu, 28 May 2026 21:08:30 +0300 Subject: [PATCH 3/4] feat: smart relevant zone search v2 --- ...000016_add_parking_zone_centroids.down.sql | 6 + .../000016_add_parking_zone_centroids.up.sql | 57 + .../000016_add_parking_zone_centroids.up.sql | 57 + src/db_models.py | 2 + src/routers/routing.py | 1133 +++++++++++++---- src/routers/zones.py | 89 ++ src/schemas/routing.py | 2 +- 7 files changed, 1103 insertions(+), 243 deletions(-) create mode 100644 migrations/000016_add_parking_zone_centroids.down.sql create mode 100644 migrations/000016_add_parking_zone_centroids.up.sql create mode 100644 migrations/up/000016_add_parking_zone_centroids.up.sql diff --git a/migrations/000016_add_parking_zone_centroids.down.sql b/migrations/000016_add_parking_zone_centroids.down.sql new file mode 100644 index 0000000..d240bb1 --- /dev/null +++ b/migrations/000016_add_parking_zone_centroids.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_parking_zones_active_centroid_lat; +DROP INDEX IF EXISTS idx_parking_zones_active_centroid_lon; + +ALTER TABLE parking_zones +DROP COLUMN IF EXISTS centroid_latitude, +DROP COLUMN IF EXISTS centroid_longitude; \ No newline at end of file diff --git a/migrations/000016_add_parking_zone_centroids.up.sql b/migrations/000016_add_parking_zone_centroids.up.sql new file mode 100644 index 0000000..d5e5e88 --- /dev/null +++ b/migrations/000016_add_parking_zone_centroids.up.sql @@ -0,0 +1,57 @@ +ALTER TABLE parking_zones +ADD COLUMN IF NOT EXISTS centroid_latitude DOUBLE PRECISION, +ADD COLUMN IF NOT EXISTS centroid_longitude DOUBLE PRECISION; + +-- Заполняем центроиды для уже существующих зон. +-- Берём среднее по точкам внешнего кольца GeoJSON Polygon. +WITH points AS ( + SELECT + pz.parking_zone_id, + (point.value ->> 0)::DOUBLE PRECISION AS longitude, + (point.value ->> 1)::DOUBLE PRECISION AS latitude + FROM parking_zones pz + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(pz.geometry) = 'object' + AND pz.geometry ->> 'type' = 'Polygon' + AND jsonb_typeof(pz.geometry -> 'coordinates') = 'array' + AND jsonb_array_length(pz.geometry -> 'coordinates') > 0 + AND jsonb_typeof(pz.geometry -> 'coordinates' -> 0) = 'array' + THEN pz.geometry -> 'coordinates' -> 0 + ELSE '[]'::jsonb + END + ) AS point(value) + WHERE jsonb_typeof(point.value) = 'array' + AND jsonb_array_length(point.value) >= 2 + AND (point.value ->> 0) ~ '^-?[0-9]+(\.[0-9]+)?$' + AND (point.value ->> 1) ~ '^-?[0-9]+(\.[0-9]+)?$' +), +centroids AS ( + SELECT + parking_zone_id, + AVG(latitude) AS centroid_latitude, + AVG(longitude) AS centroid_longitude + FROM points + WHERE latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + GROUP BY parking_zone_id +) +UPDATE parking_zones pz +SET + centroid_latitude = c.centroid_latitude, + centroid_longitude = c.centroid_longitude +FROM centroids c +WHERE pz.parking_zone_id = c.parking_zone_id; + +-- Индексы для быстрого радиусного поиска через bounding box. +CREATE INDEX IF NOT EXISTS idx_parking_zones_active_centroid_lat +ON parking_zones (centroid_latitude) +WHERE is_active = TRUE + AND centroid_latitude IS NOT NULL + AND centroid_longitude IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_parking_zones_active_centroid_lon +ON parking_zones (centroid_longitude) +WHERE is_active = TRUE + AND centroid_latitude IS NOT NULL + AND centroid_longitude IS NOT NULL; \ No newline at end of file diff --git a/migrations/up/000016_add_parking_zone_centroids.up.sql b/migrations/up/000016_add_parking_zone_centroids.up.sql new file mode 100644 index 0000000..d5e5e88 --- /dev/null +++ b/migrations/up/000016_add_parking_zone_centroids.up.sql @@ -0,0 +1,57 @@ +ALTER TABLE parking_zones +ADD COLUMN IF NOT EXISTS centroid_latitude DOUBLE PRECISION, +ADD COLUMN IF NOT EXISTS centroid_longitude DOUBLE PRECISION; + +-- Заполняем центроиды для уже существующих зон. +-- Берём среднее по точкам внешнего кольца GeoJSON Polygon. +WITH points AS ( + SELECT + pz.parking_zone_id, + (point.value ->> 0)::DOUBLE PRECISION AS longitude, + (point.value ->> 1)::DOUBLE PRECISION AS latitude + FROM parking_zones pz + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(pz.geometry) = 'object' + AND pz.geometry ->> 'type' = 'Polygon' + AND jsonb_typeof(pz.geometry -> 'coordinates') = 'array' + AND jsonb_array_length(pz.geometry -> 'coordinates') > 0 + AND jsonb_typeof(pz.geometry -> 'coordinates' -> 0) = 'array' + THEN pz.geometry -> 'coordinates' -> 0 + ELSE '[]'::jsonb + END + ) AS point(value) + WHERE jsonb_typeof(point.value) = 'array' + AND jsonb_array_length(point.value) >= 2 + AND (point.value ->> 0) ~ '^-?[0-9]+(\.[0-9]+)?$' + AND (point.value ->> 1) ~ '^-?[0-9]+(\.[0-9]+)?$' +), +centroids AS ( + SELECT + parking_zone_id, + AVG(latitude) AS centroid_latitude, + AVG(longitude) AS centroid_longitude + FROM points + WHERE latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + GROUP BY parking_zone_id +) +UPDATE parking_zones pz +SET + centroid_latitude = c.centroid_latitude, + centroid_longitude = c.centroid_longitude +FROM centroids c +WHERE pz.parking_zone_id = c.parking_zone_id; + +-- Индексы для быстрого радиусного поиска через bounding box. +CREATE INDEX IF NOT EXISTS idx_parking_zones_active_centroid_lat +ON parking_zones (centroid_latitude) +WHERE is_active = TRUE + AND centroid_latitude IS NOT NULL + AND centroid_longitude IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_parking_zones_active_centroid_lon +ON parking_zones (centroid_longitude) +WHERE is_active = TRUE + AND centroid_latitude IS NOT NULL + AND centroid_longitude IS NOT NULL; \ No newline at end of file diff --git a/src/db_models.py b/src/db_models.py index cf5c8f6..c06bce8 100644 --- a/src/db_models.py +++ b/src/db_models.py @@ -244,6 +244,8 @@ class ParkingZone(Base): pay = Column(Integer, nullable=False, default=0) geometry = Column(JSONB, nullable=False) image_polygon = Column(JSONB, nullable=False) + centroid_latitude = Column(Double, nullable=True) + centroid_longitude = Column(Double, nullable=True) partner_id = Column(Integer, ForeignKey("partners.partner_id", ondelete="SET NULL"), nullable=True) created_by_user_id = Column(Integer, ForeignKey("users.user_id", ondelete="SET NULL"), nullable=True) is_active = Column(Boolean, default=True) diff --git a/src/routers/routing.py b/src/routers/routing.py index c982873..ed16baa 100644 --- a/src/routers/routing.py +++ b/src/routers/routing.py @@ -8,7 +8,7 @@ import requests from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import func, or_, text +from sqlalchemy import func, or_ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -40,44 +40,43 @@ GEOAPIFY_PROVIDER_NAME = "geoapify" GEOAPIFY_MODE = "drive" -# Чтобы случайно не отправлять в Geoapify слишком большую матрицу. -# Сначала зоны грубо сортируются по прямому расстоянию, потом лучшие идут в API. -MAX_MATRIX_TARGETS = 200 - -PUBLIC_ROUTING_USER_EMAIL = "public-routing@parktrack.local" - - -def _get_public_routing_user_id(db: Session) -> int: - """ - /routing/new теперь публичный, но routes.user_id в БД NOT NULL. - Поэтому сохраняем публичные маршруты на технического пользователя. - """ - result = db.execute( - text( - """ - INSERT INTO users ( - full_name, - email, - hashed_password, - global_role, - is_active - ) - VALUES ( - 'Public Routing User', - :email, - 'disabled-public-routing-user', - CAST('user' AS global_roles), - TRUE - ) - ON CONFLICT (email) - DO UPDATE SET email = EXCLUDED.email - RETURNING user_id - """ - ), - {"email": PUBLIC_ROUTING_USER_EMAIL}, - ) - - return int(result.scalar_one()) +EARTH_RADIUS_METERS = 6_371_000 +METERS_PER_LATITUDE_DEGREE = 111_320 + +# Сколько парковок максимум отправляем в Geoapify Route Matrix. +# Это главный ограничитель скорости и стоимости внешнего API. +MAX_MATRIX_TARGETS = 80 +MIN_CHEAP_CANDIDATES_FOR_COMPARE = 40 + +# Расширяем радиус постепенно. Если рядом нет парковок, дойдём до очень больших +# радиусов и в самом конце до fallback без радиуса. +RADIUS_STEPS_METERS: list[int | None] = [ + 500, + 1_000, + 2_000, + 5_000, + 10_000, + 25_000, + 50_000, + 100_000, + 250_000, + 500_000, + 1_000_000, + None, +] + +# parking -> destination считаем как пешее расстояние. Route Matrix используем для +# origin -> parking, а не для полной матрицы parking -> destination, чтобы ускорить поиск. +WALKING_SPEED_METERS_PER_SECOND = 1.35 +WALKING_DETOUR_FACTOR = 1.35 + +SHORT_TRIP_SECONDS = 30 * 60 +FORECAST_OPPORTUNITY_FREE_COUNT = 2 +FORECAST_LOOKAROUND = timedelta(hours=2) + +# /routing/new публичный, но routes.user_id обычно NOT NULL. +# Лучше задать PUBLIC_ROUTING_USER_ID в .env. +PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" # --------------------------------------------------------------------------- @@ -92,11 +91,30 @@ class RoutingProviderError(Exception): class _ZoneTarget: zone: ParkingZone point: GeoPoint + anchor_distance_meters: int current_occupied: int current_free_count: int current_confidence: float +@dataclass(frozen=True) +class _RoutedCandidate: + zone_target: _ZoneTarget + distance_from_origin_meters: int + duration_from_origin_seconds: int + distance_to_destination_meters: int | None + duration_to_destination_seconds: int | None + arrival_time: datetime + + +@dataclass(frozen=True) +class _ForecastView: + predicted_occupied: int | None + predicted_free_count: int | None + probability_free_space: float | None + forecast_confidence: float | None + + @dataclass(frozen=True) class _CandidateSearchResult: candidates: list[RouteCandidate] @@ -112,18 +130,42 @@ def _enum_value(value: Any) -> str | None: return None enum_value = getattr(value, "value", None) + if enum_value is not None: return str(enum_value) return str(value) +def _to_utc_naive(value: datetime) -> datetime: + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value + + return value.astimezone(timezone.utc).replace(tzinfo=None) + + +def _datetime_timestamp(value: datetime | None) -> float: + if value is None: + return 0.0 + + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + value = value.replace(tzinfo=timezone.utc) + + return value.timestamp() + + +def _seconds_between(a: datetime, b: datetime) -> float: + return abs((_to_utc_naive(a) - _to_utc_naive(b)).total_seconds()) + + def _serialize_route(route: Route) -> RouteResponse: candidate: RouteCandidate | None = None + if route.selected_candidate: candidate = RouteCandidate.model_validate(route.selected_candidate) destination: GeoPoint | None = None + if route.destination_latitude is not None and route.destination_longitude is not None: destination = GeoPoint( latitude=route.destination_latitude, @@ -165,9 +207,7 @@ def _get_route_or_404(db: Session, route_id: int) -> Route: def _assert_owner_or_admin(route: Route, current_user: User) -> None: - is_admin = current_user.global_role == GlobalRole.admin - - if not is_admin and route.user_id != current_user.user_id: + if current_user.global_role != GlobalRole.admin and route.user_id != current_user.user_id: raise HTTPException( status.HTTP_403_FORBIDDEN, detail={"error_description": "Access denied: not your route"}, @@ -181,7 +221,35 @@ def _provider_unavailable(exc: RoutingProviderError) -> HTTPException: ) -def _zone_centroid(zone: ParkingZone) -> GeoPoint | None: +def _get_public_routing_user_id(db: Session) -> int: + raw_user_id = os.getenv(PUBLIC_ROUTING_USER_ID_ENV) + + if raw_user_id: + try: + return int(raw_user_id) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": f"{PUBLIC_ROUTING_USER_ID_ENV} must be integer"}, + ) + + row = db.query(User.user_id).order_by(User.user_id.asc()).first() + + if row is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error_description": ( + "Cannot create public route: no users exist. " + f"Create a technical user or set {PUBLIC_ROUTING_USER_ID_ENV}." + ) + }, + ) + + return int(row[0]) + + +def _zone_centroid_from_geometry(zone: ParkingZone) -> GeoPoint | None: geometry = zone.geometry if not isinstance(geometry, dict): @@ -211,9 +279,23 @@ def _zone_centroid(zone: ParkingZone) -> GeoPoint | None: return None -def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: - earth_radius_meters = 6_371_000 +def _zone_point(zone: ParkingZone) -> GeoPoint | None: + latitude = getattr(zone, "centroid_latitude", None) + longitude = getattr(zone, "centroid_longitude", None) + + if latitude is None or longitude is None: + return _zone_centroid_from_geometry(zone) + + try: + return GeoPoint( + latitude=float(latitude), + longitude=float(longitude), + ) + except (TypeError, ValueError): + return _zone_centroid_from_geometry(zone) + +def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: lat1 = math.radians(a.latitude) lat2 = math.radians(b.latitude) delta_lat = math.radians(b.latitude - a.latitude) @@ -224,11 +306,22 @@ def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 ) - return int(2 * earth_radius_meters * math.asin(math.sqrt(h))) + return int(2 * EARTH_RADIUS_METERS * math.asin(math.sqrt(h))) + + +def _estimated_walking_seconds(distance_meters: int) -> int: + return int(distance_meters * WALKING_DETOUR_FACTOR / WALKING_SPEED_METERS_PER_SECOND) + + +def _build_map_deeplink(destination: GeoPoint) -> str: + return ( + "https://www.google.com/maps/dir/?api=1" + f"&destination={destination.latitude},{destination.longitude}" + ) # --------------------------------------------------------------------------- -# Geoapify +# Geoapify Route Matrix # --------------------------------------------------------------------------- def _geoapify_api_key() -> str: @@ -310,7 +403,7 @@ def _matrix_cell( return None distance = cell.get("distance") - duration = cell.get("time") + duration = cell.get("time", cell.get("duration")) if distance is None or duration is None: return None @@ -322,23 +415,59 @@ def _matrix_cell( # --------------------------------------------------------------------------- -# Кандидаты +# Быстрый SQL-отбор зон по centroid_latitude / centroid_longitude # --------------------------------------------------------------------------- -def _query_zone_targets( +def _longitude_bounds(longitude: float, delta: float) -> tuple[float, float, bool]: + min_lon = longitude - delta + max_lon = longitude + delta + + if min_lon < -180: + return min_lon + 360, max_lon, True + + if max_lon > 180: + return min_lon, max_lon - 360, True + + return min_lon, max_lon, False + + +def _radius_bounding_box( + center: GeoPoint, + radius_meters: int, +) -> tuple[float, float, float, float, bool]: + lat_delta = math.degrees(radius_meters / EARTH_RADIUS_METERS) + + min_lat = max(-90.0, center.latitude - lat_delta) + max_lat = min(90.0, center.latitude + lat_delta) + + cos_lat = abs(math.cos(math.radians(center.latitude))) + + if cos_lat < 1e-6: + lon_delta = 180.0 + else: + lon_delta = min( + 180.0, + math.degrees(radius_meters / (EARTH_RADIUS_METERS * cos_lat)), + ) + + min_lon, max_lon, wraps = _longitude_bounds(center.longitude, lon_delta) + + return min_lat, max_lat, min_lon, max_lon, wraps + + +def _base_zone_query( db: Session, - origin: GeoPoint, max_pay: int | None, - min_free_count: int | None, - min_confidence: float | None, include_accessible: bool | None, selected_zone_id: int | None, - use_forecast: bool, -) -> list[_ZoneTarget]: +): query = db.query(ParkingZone).filter(ParkingZone.is_active.is_(True)) if selected_zone_id is not None: - query = query.filter(ParkingZone.parking_zone_id == selected_zone_id) + return query.filter(ParkingZone.parking_zone_id == selected_zone_id) + + query = query.filter(ParkingZone.centroid_latitude.is_not(None)) + query = query.filter(ParkingZone.centroid_longitude.is_not(None)) if max_pay is not None: query = query.filter(ParkingZone.pay <= max_pay) @@ -351,107 +480,591 @@ def _query_zone_targets( ) ) - zones = query.all() + return query - targets: list[_ZoneTarget] = [] - for zone in zones: - point = _zone_centroid(zone) +def _apply_bounding_box_filter(query, center: GeoPoint, radius_meters: int): + min_lat, max_lat, min_lon, max_lon, wraps = _radius_bounding_box( + center=center, + radius_meters=radius_meters, + ) - if point is None: - continue + query = query.filter(ParkingZone.centroid_latitude >= min_lat) + query = query.filter(ParkingZone.centroid_latitude <= max_lat) - capacity = max(int(zone.capacity or 0), 0) - occupied = max(int(zone.occupied or 0), 0) - free_count = max(capacity - occupied, 0) - confidence = float(zone.confidence or 0.0) - - # Если forecast включён, min_free_count/min_confidence лучше проверять - # после прогноза к arrival_time. Здесь фильтруем только для current-mode. - if not use_forecast: - if min_free_count is not None and free_count < min_free_count: - continue + if wraps: + query = query.filter( + or_( + ParkingZone.centroid_longitude >= min_lon, + ParkingZone.centroid_longitude <= max_lon, + ) + ) + else: + query = query.filter(ParkingZone.centroid_longitude >= min_lon) + query = query.filter(ParkingZone.centroid_longitude <= max_lon) + + return query - if min_confidence is not None and confidence < min_confidence: - continue - targets.append( - _ZoneTarget( - zone=zone, - point=point, - current_occupied=occupied, - current_free_count=free_count, - current_confidence=confidence, +def _approx_distance_expr(center: GeoPoint): + cos_lat = abs(math.cos(math.radians(center.latitude))) + + latitude_meters = ( + (ParkingZone.centroid_latitude - center.latitude) + * METERS_PER_LATITUDE_DEGREE + ) + + longitude_meters = ( + (ParkingZone.centroid_longitude - center.longitude) + * METERS_PER_LATITUDE_DEGREE + * cos_lat + ) + + return func.sqrt( + func.power(latitude_meters, 2) + + func.power(longitude_meters, 2) + ) + + +def _zone_to_target(zone: ParkingZone, anchor: GeoPoint) -> _ZoneTarget | None: + point = _zone_point(zone) + + if point is None: + return None + + capacity = max(int(zone.capacity or 0), 0) + occupied = max(int(zone.occupied or 0), 0) + occupied = min(occupied, capacity) + free_count = max(capacity - occupied, 0) + confidence = max(0.0, min(float(zone.confidence or 0.0), 1.0)) + + return _ZoneTarget( + zone=zone, + point=point, + anchor_distance_meters=_haversine_meters(anchor, point), + current_occupied=occupied, + current_free_count=free_count, + current_confidence=confidence, + ) + + +def _required_pool_size(limit: int) -> int: + return min( + MAX_MATRIX_TARGETS, + max(MIN_CHEAP_CANDIDATES_FOR_COMPARE, limit * 8), + ) + + +def _radius_steps_for_request( + mode: str, + max_distance_to_destination_meters: int | None, +) -> list[int | None]: + if mode != "route_to_destination" or max_distance_to_destination_meters is None: + return RADIUS_STEPS_METERS + + limited_steps: list[int | None] = [] + + for radius in RADIUS_STEPS_METERS: + if radius is None: + break + + if radius <= max_distance_to_destination_meters: + limited_steps.append(radius) + + if max_distance_to_destination_meters not in limited_steps: + limited_steps.append(max_distance_to_destination_meters) + + return limited_steps + + +def _query_zone_targets_near_anchor( + db: Session, + anchor: GeoPoint, + mode: str, + max_pay: int | None, + include_accessible: bool | None, + max_distance_to_destination_meters: int | None, + limit: int, + selected_zone_id: int | None, +) -> list[_ZoneTarget]: + required_count = _required_pool_size(limit) + + if selected_zone_id is not None: + zone = ( + _base_zone_query( + db=db, + max_pay=max_pay, + include_accessible=include_accessible, + selected_zone_id=selected_zone_id, ) + .one_or_none() ) - targets.sort(key=lambda item: _haversine_meters(origin, item.point)) + if zone is None: + return [] + + target = _zone_to_target(zone, anchor) + + return [target] if target is not None else [] - return targets[:MAX_MATRIX_TARGETS] + last_non_empty: list[_ZoneTarget] = [] + for radius in _radius_steps_for_request( + mode=mode, + max_distance_to_destination_meters=max_distance_to_destination_meters, + ): + query = _base_zone_query( + db=db, + max_pay=max_pay, + include_accessible=include_accessible, + selected_zone_id=None, + ) -def _find_forecast_for_arrival( + if radius is not None: + query = _apply_bounding_box_filter( + query=query, + center=anchor, + radius_meters=radius, + ) + + zones = ( + query + .order_by(_approx_distance_expr(anchor).asc()) + .limit(MAX_MATRIX_TARGETS) + .all() + ) + + targets: list[_ZoneTarget] = [] + + for zone in zones: + target = _zone_to_target(zone, anchor) + + if target is None: + continue + + # Bounding box шире круга, поэтому точно проверяем радиус в Python. + if radius is None or target.anchor_distance_meters <= radius: + targets.append(target) + + targets.sort(key=lambda item: item.anchor_distance_meters) + + if targets: + last_non_empty = targets + + if len(targets) >= required_count: + return targets[:MAX_MATRIX_TARGETS] + + return last_non_empty[:MAX_MATRIX_TARGETS] + + +# --------------------------------------------------------------------------- +# Прогнозы +# --------------------------------------------------------------------------- + +def _load_forecasts_for_candidates( db: Session, - zone_id: int, - arrival_time: datetime, -) -> Forecast | None: - time_distance = func.abs( - func.extract("epoch", Forecast.predicted_for - arrival_time) - ) + zone_ids: list[int], + min_arrival: datetime, + max_arrival: datetime, +) -> dict[int, list[Forecast]]: + if not zone_ids: + return {} - return ( + from_time = _to_utc_naive(min_arrival - FORECAST_LOOKAROUND) + to_time = _to_utc_naive(max_arrival + FORECAST_LOOKAROUND) + + rows = ( db.query(Forecast) - .filter(Forecast.zone_id == zone_id) + .filter(Forecast.zone_id.in_(zone_ids)) + .filter(Forecast.predicted_for >= from_time) + .filter(Forecast.predicted_for <= to_time) .order_by( - time_distance.asc(), + Forecast.zone_id.asc(), + Forecast.predicted_for.asc(), Forecast.generated_at.desc(), Forecast.forecast_id.desc(), ) - .first() + .all() ) + result: dict[int, list[Forecast]] = {} -def _score_candidate( - pay: int, - capacity: int, + for forecast in rows: + result.setdefault(int(forecast.zone_id), []).append(forecast) + + return result + + +def _pick_forecast_for_arrival( + forecasts: list[Forecast], + arrival_time: datetime, +) -> Forecast | None: + if not forecasts: + return None + + return min( + forecasts, + key=lambda forecast: ( + _seconds_between(forecast.predicted_for, arrival_time), + -_datetime_timestamp(forecast.generated_at), + -int(forecast.forecast_id), + ), + ) + + +def _forecast_view(zone_capacity: int, forecast: Forecast | None) -> _ForecastView: + if forecast is None: + return _ForecastView( + predicted_occupied=None, + predicted_free_count=None, + probability_free_space=None, + forecast_confidence=None, + ) + + forecast_capacity = max(int(forecast.capacity or zone_capacity), 0) + predicted_occupied = max(int(forecast.predicted_occupied or 0), 0) + predicted_occupied = min(predicted_occupied, forecast_capacity) + predicted_free_count = max(forecast_capacity - predicted_occupied, 0) + + probability_free_space = ( + max(0.0, min(float(forecast.probability_free_space), 1.0)) + if forecast.probability_free_space is not None + else None + ) + + forecast_confidence = ( + max(0.0, min(float(forecast.confidence), 1.0)) + if forecast.confidence is not None + else None + ) + + return _ForecastView( + predicted_occupied=predicted_occupied, + predicted_free_count=predicted_free_count, + probability_free_space=probability_free_space, + forecast_confidence=forecast_confidence, + ) + + +# --------------------------------------------------------------------------- +# Умная оценка кандидата +# --------------------------------------------------------------------------- + +def _effective_free_count( + current_free_count: int, + forecast_view: _ForecastView, + use_forecast: bool, +) -> int: + if use_forecast and forecast_view.predicted_free_count is not None: + return forecast_view.predicted_free_count + + return current_free_count + + +def _effective_confidence( + current_confidence: float, + forecast_view: _ForecastView, + use_forecast: bool, +) -> float: + if use_forecast and forecast_view.forecast_confidence is not None: + return forecast_view.forecast_confidence + + return current_confidence + + +def _availability_probability( + effective_free_count: int, + forecast_view: _ForecastView, + use_forecast: bool, +) -> float: + if use_forecast and forecast_view.probability_free_space is not None: + return forecast_view.probability_free_space + + if effective_free_count >= 3: + return 0.85 + + if effective_free_count == 2: + return 0.70 + + if effective_free_count == 1: + return 0.50 + + return 0.05 + + +def _candidate_tier( + current_free_count: int, effective_free_count: int, + probability_free_space: float, + effective_confidence: float, + duration_from_origin_seconds: int, + requested_min_free_count: int | None, + use_forecast: bool, + forecast_view: _ForecastView, +) -> int: + """ + Чем меньше tier, тем лучше. + + 0 — отличный кандидат; + 1 — хороший кандидат; + 2 — сейчас занято, но прогноз к arrival_time хороший; + 3 — рискованный, но возможный; + 4 — запасной вариант; + 5 — почти бесполезный вариант. + """ + min_required = requested_min_free_count if requested_min_free_count is not None else 1 + + if effective_free_count >= max(3, min_required) and probability_free_space >= 0.65: + return 0 + + if effective_free_count >= min_required and probability_free_space >= 0.40: + return 1 + + if ( + use_forecast + and current_free_count == 0 + and forecast_view.predicted_free_count is not None + and forecast_view.predicted_free_count >= FORECAST_OPPORTUNITY_FREE_COUNT + and duration_from_origin_seconds >= 10 * 60 + and ( + forecast_view.probability_free_space is None + or forecast_view.probability_free_space >= 0.35 + ) + ): + return 2 + + if effective_free_count > 0: + return 3 + + if duration_from_origin_seconds <= SHORT_TRIP_SECONDS: + return 5 + + if effective_confidence < 0.35: + return 5 + + return 4 + + +def _price_bucket(pay: int) -> int: + if pay <= 0: + return 0 + + if pay <= 50: + return 1 + + if pay <= 150: + return 2 + + if pay <= 300: + return 3 + + return 4 + + +def _duration_bucket(seconds: int) -> int: + if seconds <= 10 * 60: + return 0 + + if seconds <= 20 * 60: + return 1 + + if seconds <= 40 * 60: + return 2 + + if seconds <= 60 * 60: + return 3 + + if seconds <= 2 * 60 * 60: + return 4 + + return 5 + + +def _walk_bucket(seconds: int | None) -> int: + if seconds is None: + return 0 + + if seconds <= 5 * 60: + return 0 + + if seconds <= 10 * 60: + return 1 + + if seconds <= 20 * 60: + return 2 + + if seconds <= 30 * 60: + return 3 + + return 4 + + +def _display_score( + tier: int, + effective_free_count: int, + probability_free_space: float, effective_confidence: float, - probability_free_space: float | None, duration_from_origin_seconds: int, duration_to_destination_seconds: int | None, + pay: int, ) -> float: - free_ratio = effective_free_count / max(capacity, 1) - free_score = max(0.0, min(free_ratio, 1.0)) - - probability_score = ( - max(0.0, min(probability_free_space, 1.0)) - if probability_free_space is not None - else (1.0 if effective_free_count > 0 else 0.0) - ) + base_by_tier = { + 0: 0.95, + 1: 0.82, + 2: 0.72, + 3: 0.55, + 4: 0.35, + 5: 0.12, + } - confidence_score = max(0.0, min(effective_confidence, 1.0)) + base = base_by_tier.get(tier, 0.10) - # 15 минут — условно хорошее время до парковки. - origin_time_score = 1.0 / (1.0 + duration_from_origin_seconds / 900.0) + free_bonus = min(effective_free_count, 6) * 0.015 + probability_bonus = probability_free_space * 0.05 + confidence_bonus = effective_confidence * 0.03 + duration_penalty = min(duration_from_origin_seconds / (2 * 60 * 60), 1.0) * 0.10 if duration_to_destination_seconds is None: - destination_time_score = 1.0 + walk_penalty = 0.0 else: - # 5 минут — условно хорошее время от парковки до точки назначения. - destination_time_score = 1.0 / (1.0 + duration_to_destination_seconds / 300.0) + walk_penalty = min(duration_to_destination_seconds / (30 * 60), 1.0) * 0.08 - price_score = 1.0 / (1.0 + pay / 100.0) + price_penalty = min(pay / 500.0, 1.0) * 0.04 score = ( - 0.30 * probability_score - + 0.20 * free_score - + 0.20 * origin_time_score - + 0.15 * destination_time_score - + 0.10 * confidence_score - + 0.05 * price_score + base + + free_bonus + + probability_bonus + + confidence_bonus + - duration_penalty + - walk_penalty + - price_penalty + ) + + return round(max(0.0, min(score, 1.0)), 6) + + +def _ranking_key( + candidate: RouteCandidate, + tier: int, + effective_free_count: int, + probability_free_space: float, + effective_confidence: float, +) -> tuple[Any, ...]: + return ( + tier, + _duration_bucket(candidate.duration_from_origin_seconds), + _walk_bucket(candidate.duration_to_destination_seconds), + -effective_free_count, + -probability_free_space, + _price_bucket(candidate.pay), + -effective_confidence, + candidate.duration_from_origin_seconds, + candidate.distance_from_origin_meters, + candidate.distance_to_destination_meters + if candidate.distance_to_destination_meters is not None + else 0, + candidate.pay, + candidate.zone_id, + ) + + +def _remove_unreasonable_detours( + candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]], + limit: int, + selected_zone_id: int | None, +) -> list[tuple[RouteCandidate, tuple[Any, ...], int]]: + if selected_zone_id is not None: + return candidates_with_meta + + if not candidates_with_meta: + return [] + + primary = [item for item in candidates_with_meta if item[2] <= 3] + + if len(primary) < max(3, min(limit, 5)): + return candidates_with_meta + + best_duration = min(item[0].duration_from_origin_seconds for item in primary) + + # Не показываем вариант на 5 часов, если есть сопоставимые варианты за час. + max_reasonable_duration = max( + best_duration + 30 * 60, + int(best_duration * 2.5), ) - return round(score, 6) + max_reasonable_duration = min( + max_reasonable_duration, + best_duration + 2 * 60 * 60, + ) + + reasonable: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + backup: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + + for item in candidates_with_meta: + candidate = item[0] + tier = item[2] + + if tier <= 3 and candidate.duration_from_origin_seconds <= max_reasonable_duration: + reasonable.append(item) + else: + backup.append(item) + + if len(reasonable) >= limit: + return reasonable + + return reasonable + backup + + +# --------------------------------------------------------------------------- +# Основной поиск кандидатов +# --------------------------------------------------------------------------- + +def _route_zone_pool( + origin: GeoPoint, + destination: GeoPoint | None, + zone_targets: list[_ZoneTarget], +) -> list[_RoutedCandidate]: + if not zone_targets: + return [] + + matrix = _geoapify_matrix( + sources=[origin], + targets=[item.point for item in zone_targets], + ) + + now = datetime.now(timezone.utc) + routed: list[_RoutedCandidate] = [] + + for index, item in enumerate(zone_targets): + from_origin = _matrix_cell(matrix, 0, index) + + if from_origin is None: + continue + + distance_from_origin, duration_from_origin = from_origin + arrival_time = now + timedelta(seconds=duration_from_origin) + + distance_to_destination: int | None = None + duration_to_destination: int | None = None + + if destination is not None: + direct_distance = _haversine_meters(item.point, destination) + distance_to_destination = int(direct_distance * WALKING_DETOUR_FACTOR) + duration_to_destination = _estimated_walking_seconds(direct_distance) + + routed.append( + _RoutedCandidate( + zone_target=item, + distance_from_origin_meters=distance_from_origin, + duration_from_origin_seconds=duration_from_origin, + distance_to_destination_meters=distance_to_destination, + duration_to_destination_seconds=duration_to_destination, + arrival_time=arrival_time, + ) + ) + + return routed def _search_candidates( @@ -475,169 +1088,188 @@ def _search_candidates( detail={"error_description": "destination is required for mode=route_to_destination"}, ) - zone_targets = _query_zone_targets( + anchor = destination if mode == "route_to_destination" and destination is not None else origin + + cheap_pool = _query_zone_targets_near_anchor( db=db, - origin=origin, + anchor=anchor, + mode=mode, max_pay=max_pay, - min_free_count=min_free_count, - min_confidence=min_confidence, include_accessible=include_accessible, + max_distance_to_destination_meters=max_distance_to_destination_meters, + limit=limit, selected_zone_id=selected_zone_id, - use_forecast=use_forecast, ) - if not zone_targets: - return _CandidateSearchResult(candidates=[], total_candidates=0) - - origin_matrix = _geoapify_matrix( - sources=[origin], - targets=[item.point for item in zone_targets], + routed_candidates = _route_zone_pool( + origin=origin, + destination=destination, + zone_targets=cheap_pool, ) - destination_matrix: list[list[dict[str, Any]]] | None = None - if destination is not None: - destination_matrix = _geoapify_matrix( - sources=[item.point for item in zone_targets], - targets=[destination], - ) - - now = datetime.now(timezone.utc) - candidates: list[RouteCandidate] = [] + if not routed_candidates: + return _CandidateSearchResult(candidates=[], total_candidates=0) - for index, item in enumerate(zone_targets): - from_origin = _matrix_cell(origin_matrix, 0, index) + filtered_by_route: list[_RoutedCandidate] = [] - if from_origin is None: + for routed in routed_candidates: + if ( + max_duration_from_origin_seconds is not None + and routed.duration_from_origin_seconds > max_duration_from_origin_seconds + ): continue - distance_from_origin, duration_from_origin = from_origin - if ( - max_duration_from_origin_seconds is not None - and duration_from_origin > max_duration_from_origin_seconds + max_distance_to_destination_meters is not None + and routed.distance_to_destination_meters is not None + and routed.distance_to_destination_meters > max_distance_to_destination_meters ): continue - distance_to_destination: int | None = None - duration_to_destination: int | None = None + filtered_by_route.append(routed) - if destination_matrix is not None: - to_destination = _matrix_cell(destination_matrix, index, 0) + if not filtered_by_route: + return _CandidateSearchResult(candidates=[], total_candidates=0) - if to_destination is None: - continue + min_arrival = min(item.arrival_time for item in filtered_by_route) + max_arrival = max(item.arrival_time for item in filtered_by_route) - distance_to_destination, duration_to_destination = to_destination + zone_ids = [ + int(item.zone_target.zone.parking_zone_id) + for item in filtered_by_route + ] - if ( - max_distance_to_destination_meters is not None - and distance_to_destination > max_distance_to_destination_meters - ): - continue + forecasts_by_zone = ( + _load_forecasts_for_candidates( + db=db, + zone_ids=zone_ids, + min_arrival=min_arrival, + max_arrival=max_arrival, + ) + if use_forecast + else {} + ) - zone = item.zone - capacity = max(int(zone.capacity or 0), 0) - pay = max(int(zone.pay or 0), 0) + candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - predicted_for_arrival = now + timedelta(seconds=duration_from_origin) + for routed in filtered_by_route: + target = routed.zone_target + zone = target.zone - predicted_occupied: int | None = None - predicted_free_count: int | None = None - probability_free_space: float | None = None - forecast_confidence: float | None = None + capacity = max(int(zone.capacity or 0), 0) + pay = max(int(zone.pay or 0), 0) - effective_free_count = item.current_free_count - effective_confidence = item.current_confidence + forecast = None if use_forecast: - forecast = _find_forecast_for_arrival( - db=db, - zone_id=int(zone.parking_zone_id), - arrival_time=predicted_for_arrival, + forecast = _pick_forecast_for_arrival( + forecasts_by_zone.get(int(zone.parking_zone_id), []), + routed.arrival_time, ) - if forecast is not None: - forecast_capacity = int(forecast.capacity or capacity) - predicted_occupied = max(int(forecast.predicted_occupied or 0), 0) - predicted_free_count = max(forecast_capacity - predicted_occupied, 0) - probability_free_space = ( - float(forecast.probability_free_space) - if forecast.probability_free_space is not None - else None - ) - forecast_confidence = ( - float(forecast.confidence) - if forecast.confidence is not None - else None - ) + forecast_view = _forecast_view( + zone_capacity=capacity, + forecast=forecast, + ) - effective_free_count = predicted_free_count - effective_confidence = ( - forecast_confidence - if forecast_confidence is not None - else item.current_confidence - ) + effective_free_count = _effective_free_count( + current_free_count=target.current_free_count, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + + effective_confidence = _effective_confidence( + current_confidence=target.current_confidence, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + + probability_free_space = _availability_probability( + effective_free_count=effective_free_count, + forecast_view=forecast_view, + use_forecast=use_forecast, + ) + # Явные пользовательские ограничения — жёсткие. if min_free_count is not None and effective_free_count < min_free_count: continue if min_confidence is not None and effective_confidence < min_confidence: continue - score = _score_candidate( - pay=pay, - capacity=capacity, + tier = _candidate_tier( + current_free_count=target.current_free_count, effective_free_count=effective_free_count, + probability_free_space=probability_free_space, effective_confidence=effective_confidence, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + requested_min_free_count=min_free_count, + use_forecast=use_forecast, + forecast_view=forecast_view, + ) + + score = _display_score( + tier=tier, + effective_free_count=effective_free_count, probability_free_space=probability_free_space, - duration_from_origin_seconds=duration_from_origin, - duration_to_destination_seconds=duration_to_destination, - ) - - candidates.append( - RouteCandidate( - zone_id=int(zone.parking_zone_id), - camera_id=cast(int | None, zone.camera_id), - geometry=zone.geometry, - zone_type=_enum_value(zone.zone_type) or "unknown", - location_type=_enum_value(zone.location_type), - is_accessible=cast(bool | None, zone.is_accessible), - pay=pay, - capacity=capacity, - current_occupied=item.current_occupied, - current_free_count=item.current_free_count, - current_confidence=item.current_confidence, - predicted_for_arrival=predicted_for_arrival, - predicted_occupied=predicted_occupied, - predicted_free_count=predicted_free_count, - probability_free_space=probability_free_space, - forecast_confidence=forecast_confidence, - distance_from_origin_meters=distance_from_origin, - duration_from_origin_seconds=duration_from_origin, - distance_to_destination_meters=distance_to_destination, - duration_to_destination_seconds=duration_to_destination, - score=score, - rank=0, - ) + effective_confidence=effective_confidence, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + duration_to_destination_seconds=routed.duration_to_destination_seconds, + pay=pay, + ) + + candidate = RouteCandidate( + zone_id=int(zone.parking_zone_id), + camera_id=cast(int | None, zone.camera_id), + geometry=zone.geometry, + zone_type=_enum_value(zone.zone_type) or "unknown", + location_type=_enum_value(zone.location_type), + is_accessible=cast(bool | None, zone.is_accessible), + pay=pay, + capacity=capacity, + current_occupied=target.current_occupied, + current_free_count=target.current_free_count, + current_confidence=target.current_confidence, + predicted_for_arrival=routed.arrival_time, + predicted_occupied=forecast_view.predicted_occupied, + predicted_free_count=forecast_view.predicted_free_count, + probability_free_space=forecast_view.probability_free_space, + forecast_confidence=forecast_view.forecast_confidence, + distance_from_origin_meters=routed.distance_from_origin_meters, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + distance_to_destination_meters=routed.distance_to_destination_meters, + duration_to_destination_seconds=routed.duration_to_destination_seconds, + score=score, + rank=0, ) - candidates.sort( - key=lambda candidate: ( - -candidate.score, - candidate.duration_from_origin_seconds, - candidate.distance_from_origin_meters, + ranking_key = _ranking_key( + candidate=candidate, + tier=tier, + effective_free_count=effective_free_count, + probability_free_space=probability_free_space, + effective_confidence=effective_confidence, ) + + candidates_with_meta.append((candidate, ranking_key, tier)) + + candidates_with_meta.sort(key=lambda item: item[1]) + + candidates_with_meta = _remove_unreasonable_detours( + candidates_with_meta=candidates_with_meta, + limit=limit, + selected_zone_id=selected_zone_id, ) - total_candidates = len(candidates) + total_candidates = len(candidates_with_meta) - ranked_candidates = [ - candidate.model_copy(update={"rank": rank}) - for rank, candidate in enumerate(candidates, start=1) + ranked = [ + item[0].model_copy(update={"rank": rank}) + for rank, item in enumerate(candidates_with_meta, start=1) ] return _CandidateSearchResult( - candidates=ranked_candidates[:limit], + candidates=ranked[:limit], total_candidates=total_candidates, ) @@ -676,7 +1308,7 @@ def _selected_candidate_or_422( # --------------------------------------------------------------------------- -# POST /routing/search +# POST /routing/search — публичный поиск без авторизации # --------------------------------------------------------------------------- @router.post("/search", response_model=SearchRoutingResponse) @@ -715,7 +1347,7 @@ def search_routing( # --------------------------------------------------------------------------- -# POST /routing/new +# POST /routing/new — публичное построение и сохранение маршрута # --------------------------------------------------------------------------- @router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) @@ -765,6 +1397,20 @@ def create_route( now = datetime.now(timezone.utc) public_user_id = _get_public_routing_user_id(db) + selected_zone = ( + db.query(ParkingZone) + .filter(ParkingZone.parking_zone_id == best.zone_id) + .one_or_none() + ) + + zone_point = body.origin + + if selected_zone is not None: + centroid = _zone_point(selected_zone) + + if centroid is not None: + zone_point = centroid + route = Route( user_id=public_user_id, mode=RouteMode(body.mode), @@ -778,7 +1424,7 @@ def create_route( eta_seconds=best.duration_from_origin_seconds, arrival_time=best.predicted_for_arrival, polyline=None, - deeplink_url=None, + deeplink_url=_build_map_deeplink(zone_point), status=RouteStatus.active, created_at=now, updated_at=now, @@ -802,7 +1448,7 @@ def create_route( # --------------------------------------------------------------------------- -# GET /routing +# GET /routing — маршруты текущего пользователя # --------------------------------------------------------------------------- @router.get("", response_model=RouteListResponse) @@ -888,7 +1534,6 @@ def update_route( if body.status is not None: route.status = RouteStatus(body.status) - # provider в request оставляем для совместимости, но фактически работаем через Geoapify. if body.provider is not None: route.provider = GEOAPIFY_PROVIDER_NAME @@ -930,13 +1575,17 @@ def update_route( except RoutingProviderError as exc: raise _provider_unavailable(exc) from exc + centroid = _zone_point(zone) + route.provider = GEOAPIFY_PROVIDER_NAME route.selected_zone_id = body.selected_zone_id route.selected_candidate = candidate.model_dump(mode="json") route.eta_seconds = candidate.duration_from_origin_seconds route.arrival_time = candidate.predicted_for_arrival route.polyline = None - route.deeplink_url = None + + if centroid is not None: + route.deeplink_url = _build_map_deeplink(centroid) route.updated_at = datetime.now(timezone.utc) @@ -974,4 +1623,4 @@ def delete_route( db.commit() - return None \ No newline at end of file + return None diff --git a/src/routers/zones.py b/src/routers/zones.py index 6e07428..7e77cc3 100644 --- a/src/routers/zones.py +++ b/src/routers/zones.py @@ -240,6 +240,86 @@ def _get_partner_id_or_none(db: Session, raw_partner_id: Any) -> int | None: return partner.partner_id + +def _compute_geometry_centroid(geometry: Any) -> tuple[float | None, float | None]: + """ + Возвращает (latitude, longitude) для GeoJSON Polygon. + + GeoJSON хранит точки как [longitude, latitude]. + Если полигон плохой или вырожденный, fallback — среднее по валидным точкам. + """ + if not isinstance(geometry, dict): + return None, None + + if geometry.get("type") != "Polygon": + return None, None + + coordinates = geometry.get("coordinates") + + if not isinstance(coordinates, list) or not coordinates: + return None, None + + outer_ring = coordinates[0] + + if not isinstance(outer_ring, list): + return None, None + + points: list[tuple[float, float]] = [] + + for raw_point in outer_ring: + if not isinstance(raw_point, list | tuple) or len(raw_point) < 2: + continue + + try: + longitude = float(raw_point[0]) + latitude = float(raw_point[1]) + except (TypeError, ValueError): + continue + + if -180 <= longitude <= 180 and -90 <= latitude <= 90: + points.append((longitude, latitude)) + + if not points: + return None, None + + if len(points) > 1 and points[0] == points[-1]: + points = points[:-1] + + if not points: + return None, None + + # Если полигон нормальный, считаем геометрический центроид. + # Для маленьких парковочных зон приближение по lon/lat достаточно. + area2 = 0.0 + centroid_lon_sum = 0.0 + centroid_lat_sum = 0.0 + + if len(points) >= 3: + for index, current in enumerate(points): + next_point = points[(index + 1) % len(points)] + + lon1, lat1 = current + lon2, lat2 = next_point + + cross = lon1 * lat2 - lon2 * lat1 + + area2 += cross + centroid_lon_sum += (lon1 + lon2) * cross + centroid_lat_sum += (lat1 + lat2) * cross + + if abs(area2) > 1e-12: + centroid_longitude = centroid_lon_sum / (3.0 * area2) + centroid_latitude = centroid_lat_sum / (3.0 * area2) + + if -180 <= centroid_longitude <= 180 and -90 <= centroid_latitude <= 90: + return centroid_latitude, centroid_longitude + + # Fallback для вырожденных зон, например когда все точки одинаковые. + centroid_longitude = sum(point[0] for point in points) / len(points) + centroid_latitude = sum(point[1] for point in points) / len(points) + + return centroid_latitude, centroid_longitude + # --------------------------------------------------------------------------- # POST /zones/new # --------------------------------------------------------------------------- @@ -276,6 +356,8 @@ def create_zone( [], ) + centroid_latitude, centroid_longitude = _compute_geometry_centroid(geometry) + zone = ParkingZone( camera_id=camera.camera_id, zone_type=_normalize_zone_type(body.zone_type), @@ -286,6 +368,8 @@ def create_zone( pay=pay, geometry=geometry, image_polygon=image_polygon, + centroid_latitude=centroid_latitude, + centroid_longitude=centroid_longitude, partner_id=_get_partner_id_or_none(db, body.partner_id), created_by_user_id=current_user.user_id, is_active=_to_bool(body.is_active, default=True), @@ -346,6 +430,11 @@ def update_zone( for field, value in update_data.items(): setattr(zone, field, value) + if "geometry" in update_data: + centroid_latitude, centroid_longitude = _compute_geometry_centroid(zone.geometry) + zone.centroid_latitude = centroid_latitude + zone.centroid_longitude = centroid_longitude + now = datetime.now(timezone.utc) if occupied_changed: zone.occupancy_updated_at = now diff --git a/src/schemas/routing.py b/src/schemas/routing.py index f9da266..7437ce5 100644 --- a/src/schemas/routing.py +++ b/src/schemas/routing.py @@ -129,4 +129,4 @@ class SearchRoutingResponse(BaseModel): class UpdateRouteRequest(BaseModel): status: Literal["active", "completed", "cancelled", "replaced"] | None = None selected_zone_id: int | None = None - provider: RoutingProvider | None = None \ No newline at end of file + provider: RoutingProvider | None = None From d2ddbde40453a2ea1a9212d0284a8c29badd749c Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Thu, 28 May 2026 23:25:29 +0300 Subject: [PATCH 4/4] feat: smart relevant zone search v3 --- example.env | 1 + src/routers/routing.py | 1109 +++++++++++++++++++++++++++------------- src/schemas/routing.py | 51 +- 3 files changed, 804 insertions(+), 357 deletions(-) diff --git a/example.env b/example.env index f04bc6f..35ce6e3 100644 --- a/example.env +++ b/example.env @@ -13,6 +13,7 @@ DATABASE_HOST_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5 DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" GEOAPIFY_API_KEY=your_geoapify_key +PUBLIC_ROUTING_USER_ID=1 # Super token for trusted services. Send it as Authorization: Bearer ${API_TOKEN}. #API_TOKEN=change-me diff --git a/src/routers/routing.py b/src/routers/routing.py index ed16baa..02b630e 100644 --- a/src/routers/routing.py +++ b/src/routers/routing.py @@ -26,6 +26,7 @@ from ..schemas.routing import ( CreateRouteRequest, GeoPoint, + RankingExplanation, RouteCandidate, RouteListResponse, RouteResponse, @@ -36,6 +37,11 @@ router = APIRouter(prefix="/routing", tags=["Routing"]) + +# --------------------------------------------------------------------------- +# Константы +# --------------------------------------------------------------------------- + GEOAPIFY_ROUTEMATRIX_URL = "https://api.geoapify.com/v1/routematrix" GEOAPIFY_PROVIDER_NAME = "geoapify" GEOAPIFY_MODE = "drive" @@ -43,13 +49,10 @@ EARTH_RADIUS_METERS = 6_371_000 METERS_PER_LATITUDE_DEGREE = 111_320 -# Сколько парковок максимум отправляем в Geoapify Route Matrix. -# Это главный ограничитель скорости и стоимости внешнего API. +MAX_CLUSTER_CONTEXT_TARGETS = 300 MAX_MATRIX_TARGETS = 80 -MIN_CHEAP_CANDIDATES_FOR_COMPARE = 40 +MIN_CLUSTER_CONTEXT_FOR_COMPARE = 80 -# Расширяем радиус постепенно. Если рядом нет парковок, дойдём до очень больших -# радиусов и в самом конце до fallback без радиуса. RADIUS_STEPS_METERS: list[int | None] = [ 500, 1_000, @@ -65,17 +68,14 @@ None, ] -# parking -> destination считаем как пешее расстояние. Route Matrix используем для -# origin -> parking, а не для полной матрицы parking -> destination, чтобы ускорить поиск. +CLUSTER_RADIUS_METERS = 500 +GOOD_ALTERNATIVE_MIN_PROBABILITY = 0.35 + WALKING_SPEED_METERS_PER_SECOND = 1.35 WALKING_DETOUR_FACTOR = 1.35 -SHORT_TRIP_SECONDS = 30 * 60 -FORECAST_OPPORTUNITY_FREE_COUNT = 2 FORECAST_LOOKAROUND = timedelta(hours=2) -# /routing/new публичный, но routes.user_id обычно NOT NULL. -# Лучше задать PUBLIC_ROUTING_USER_ID в .env. PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" @@ -115,6 +115,41 @@ class _ForecastView: forecast_confidence: float | None +@dataclass(frozen=True) +class _ClusterMetrics: + cluster_strength: float + nearby_alternative_count: int + nearby_good_alternative_count: int + nearby_effective_free_count: int + best_nearby_probability: float + nearest_good_alternative_distance_meters: int | None + + +@dataclass +class _CandidateContext: + candidate: RouteCandidate + tier: int + tier_label: str + + effective_free_count: int + effective_confidence: float + availability_probability: float + availability_strength: float + cluster_metrics: _ClusterMetrics + + base_cost_seconds: float + generalized_cost_seconds: float + + price_penalty_seconds: float + scarcity_penalty_seconds: float + confidence_penalty_seconds: float + cluster_bonus_seconds: float + availability_bonus_seconds: float + + peer_better_availability_penalty_seconds: float = 0.0 + unreasonable_detour_penalty_seconds: float = 0.0 + + @dataclass(frozen=True) class _CandidateSearchResult: candidates: list[RouteCandidate] @@ -158,6 +193,24 @@ def _seconds_between(a: datetime, b: datetime) -> float: return abs((_to_utc_naive(a) - _to_utc_naive(b)).total_seconds()) +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _clamp_float(value: float, min_value: float, max_value: float) -> float: + return max(min_value, min(value, max_value)) + + def _serialize_route(route: Route) -> RouteResponse: candidate: RouteCandidate | None = None @@ -230,7 +283,9 @@ def _get_public_routing_user_id(db: Session) -> int: except ValueError: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"error_description": f"{PUBLIC_ROUTING_USER_ID_ENV} must be integer"}, + detail={ + "error_description": f"{PUBLIC_ROUTING_USER_ID_ENV} must be integer" + }, ) row = db.query(User.user_id).order_by(User.user_id.asc()).first() @@ -249,6 +304,35 @@ def _get_public_routing_user_id(db: Session) -> int: return int(row[0]) +def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: + lat1 = math.radians(a.latitude) + lat2 = math.radians(b.latitude) + delta_lat = math.radians(b.latitude - a.latitude) + delta_lon = math.radians(b.longitude - a.longitude) + + h = ( + math.sin(delta_lat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 + ) + + return int(2 * EARTH_RADIUS_METERS * math.asin(math.sqrt(h))) + + +def _estimated_walking_seconds(distance_meters: int) -> int: + return int(distance_meters * WALKING_DETOUR_FACTOR / WALKING_SPEED_METERS_PER_SECOND) + + +def _build_map_deeplink(destination: GeoPoint) -> str: + return ( + "https://www.google.com/maps/dir/?api=1" + f"&destination={destination.latitude},{destination.longitude}" + ) + + +# --------------------------------------------------------------------------- +# Центроид зоны +# --------------------------------------------------------------------------- + def _zone_centroid_from_geometry(zone: ParkingZone) -> GeoPoint | None: geometry = zone.geometry @@ -279,7 +363,7 @@ def _zone_centroid_from_geometry(zone: ParkingZone) -> GeoPoint | None: return None -def _zone_point(zone: ParkingZone) -> GeoPoint | None: +def _zone_point_from_centroid_columns(zone: ParkingZone) -> GeoPoint | None: latitude = getattr(zone, "centroid_latitude", None) longitude = getattr(zone, "centroid_longitude", None) @@ -295,33 +379,8 @@ def _zone_point(zone: ParkingZone) -> GeoPoint | None: return _zone_centroid_from_geometry(zone) -def _haversine_meters(a: GeoPoint, b: GeoPoint) -> int: - lat1 = math.radians(a.latitude) - lat2 = math.radians(b.latitude) - delta_lat = math.radians(b.latitude - a.latitude) - delta_lon = math.radians(b.longitude - a.longitude) - - h = ( - math.sin(delta_lat / 2) ** 2 - + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 - ) - - return int(2 * EARTH_RADIUS_METERS * math.asin(math.sqrt(h))) - - -def _estimated_walking_seconds(distance_meters: int) -> int: - return int(distance_meters * WALKING_DETOUR_FACTOR / WALKING_SPEED_METERS_PER_SECOND) - - -def _build_map_deeplink(destination: GeoPoint) -> str: - return ( - "https://www.google.com/maps/dir/?api=1" - f"&destination={destination.latitude},{destination.longitude}" - ) - - # --------------------------------------------------------------------------- -# Geoapify Route Matrix +# Geoapify # --------------------------------------------------------------------------- def _geoapify_api_key() -> str: @@ -340,8 +399,6 @@ def _geoapify_matrix( if not sources or not targets: return [] - api_key = _geoapify_api_key() - payload = { "mode": GEOAPIFY_MODE, "sources": [ @@ -357,7 +414,7 @@ def _geoapify_matrix( try: response = requests.post( GEOAPIFY_ROUTEMATRIX_URL, - params={"apiKey": api_key}, + params={"apiKey": _geoapify_api_key()}, headers={"Content-Type": "application/json"}, json=payload, timeout=15, @@ -415,7 +472,7 @@ def _matrix_cell( # --------------------------------------------------------------------------- -# Быстрый SQL-отбор зон по centroid_latitude / centroid_longitude +# SQL radius search # --------------------------------------------------------------------------- def _longitude_bounds(longitude: float, delta: float) -> tuple[float, float, bool]: @@ -483,7 +540,11 @@ def _base_zone_query( return query -def _apply_bounding_box_filter(query, center: GeoPoint, radius_meters: int): +def _apply_bounding_box_filter( + query, + center: GeoPoint, + radius_meters: int, +): min_lat, max_lat, min_lon, max_lon, wraps = _radius_bounding_box( center=center, radius_meters=radius_meters, @@ -527,16 +588,17 @@ def _approx_distance_expr(center: GeoPoint): def _zone_to_target(zone: ParkingZone, anchor: GeoPoint) -> _ZoneTarget | None: - point = _zone_point(zone) + point = _zone_point_from_centroid_columns(zone) if point is None: return None - capacity = max(int(zone.capacity or 0), 0) - occupied = max(int(zone.occupied or 0), 0) + capacity = max(_safe_int(zone.capacity), 0) + occupied = max(_safe_int(zone.occupied), 0) occupied = min(occupied, capacity) + free_count = max(capacity - occupied, 0) - confidence = max(0.0, min(float(zone.confidence or 0.0), 1.0)) + confidence = _clamp_float(_safe_float(zone.confidence), 0.0, 1.0) return _ZoneTarget( zone=zone, @@ -548,10 +610,10 @@ def _zone_to_target(zone: ParkingZone, anchor: GeoPoint) -> _ZoneTarget | None: ) -def _required_pool_size(limit: int) -> int: +def _required_cluster_pool_size(limit: int) -> int: return min( - MAX_MATRIX_TARGETS, - max(MIN_CHEAP_CANDIDATES_FOR_COMPARE, limit * 8), + MAX_CLUSTER_CONTEXT_TARGETS, + max(MIN_CLUSTER_CONTEXT_FOR_COMPARE, limit * 12), ) @@ -587,7 +649,7 @@ def _query_zone_targets_near_anchor( limit: int, selected_zone_id: int | None, ) -> list[_ZoneTarget]: - required_count = _required_pool_size(limit) + required_count = _required_cluster_pool_size(limit) if selected_zone_id is not None: zone = ( @@ -630,7 +692,7 @@ def _query_zone_targets_near_anchor( zones = ( query .order_by(_approx_distance_expr(anchor).asc()) - .limit(MAX_MATRIX_TARGETS) + .limit(MAX_CLUSTER_CONTEXT_TARGETS) .all() ) @@ -642,7 +704,6 @@ def _query_zone_targets_near_anchor( if target is None: continue - # Bounding box шире круга, поэтому точно проверяем радиус в Python. if radius is None or target.anchor_distance_meters <= radius: targets.append(target) @@ -652,16 +713,41 @@ def _query_zone_targets_near_anchor( last_non_empty = targets if len(targets) >= required_count: - return targets[:MAX_MATRIX_TARGETS] + return targets[:MAX_CLUSTER_CONTEXT_TARGETS] + + return last_non_empty[:MAX_CLUSTER_CONTEXT_TARGETS] + - return last_non_empty[:MAX_MATRIX_TARGETS] +def _matrix_preselection_key(target: _ZoneTarget) -> tuple[Any, ...]: + distance_band = target.anchor_distance_meters // 500 + + if target.current_free_count >= 3: + availability_band = 0 + elif target.current_free_count >= 1: + availability_band = 1 + else: + availability_band = 2 + + pay = max(_safe_int(target.zone.pay), 0) + + return ( + distance_band, + availability_band, + target.anchor_distance_meters, + pay, + int(target.zone.parking_zone_id), + ) + + +def _select_matrix_targets(cluster_pool: list[_ZoneTarget]) -> list[_ZoneTarget]: + return sorted(cluster_pool, key=_matrix_preselection_key)[:MAX_MATRIX_TARGETS] # --------------------------------------------------------------------------- -# Прогнозы +# Forecast helpers # --------------------------------------------------------------------------- -def _load_forecasts_for_candidates( +def _load_forecasts_for_zones( db: Session, zone_ids: list[int], min_arrival: datetime, @@ -712,7 +798,10 @@ def _pick_forecast_for_arrival( ) -def _forecast_view(zone_capacity: int, forecast: Forecast | None) -> _ForecastView: +def _forecast_view( + zone_capacity: int, + forecast: Forecast | None, +) -> _ForecastView: if forecast is None: return _ForecastView( predicted_occupied=None, @@ -721,19 +810,19 @@ def _forecast_view(zone_capacity: int, forecast: Forecast | None) -> _ForecastVi forecast_confidence=None, ) - forecast_capacity = max(int(forecast.capacity or zone_capacity), 0) - predicted_occupied = max(int(forecast.predicted_occupied or 0), 0) + forecast_capacity = max(_safe_int(forecast.capacity, zone_capacity), 0) + predicted_occupied = max(_safe_int(forecast.predicted_occupied), 0) predicted_occupied = min(predicted_occupied, forecast_capacity) predicted_free_count = max(forecast_capacity - predicted_occupied, 0) probability_free_space = ( - max(0.0, min(float(forecast.probability_free_space), 1.0)) + _clamp_float(float(forecast.probability_free_space), 0.0, 1.0) if forecast.probability_free_space is not None else None ) forecast_confidence = ( - max(0.0, min(float(forecast.confidence), 1.0)) + _clamp_float(float(forecast.confidence), 0.0, 1.0) if forecast.confidence is not None else None ) @@ -746,10 +835,6 @@ def _forecast_view(zone_capacity: int, forecast: Forecast | None) -> _ForecastVi ) -# --------------------------------------------------------------------------- -# Умная оценка кандидата -# --------------------------------------------------------------------------- - def _effective_free_count( current_free_count: int, forecast_view: _ForecastView, @@ -780,214 +865,555 @@ def _availability_probability( if use_forecast and forecast_view.probability_free_space is not None: return forecast_view.probability_free_space + if effective_free_count >= 5: + return 0.92 + if effective_free_count >= 3: - return 0.85 + return 0.82 if effective_free_count == 2: - return 0.70 + return 0.65 if effective_free_count == 1: - return 0.50 + return 0.42 + + return 0.04 + + +# --------------------------------------------------------------------------- +# Cluster / fallback alternatives +# --------------------------------------------------------------------------- - return 0.05 +def _effective_state_for_target_at_arrival( + target: _ZoneTarget, + arrival_time: datetime, + forecasts_by_zone: dict[int, list[Forecast]], + use_forecast: bool, +) -> tuple[int, float, float, _ForecastView]: + zone_id = int(target.zone.parking_zone_id) + capacity = max(_safe_int(target.zone.capacity), 0) + forecast = None + + if use_forecast: + forecast = _pick_forecast_for_arrival( + forecasts_by_zone.get(zone_id, []), + arrival_time, + ) -def _candidate_tier( + view = _forecast_view( + zone_capacity=capacity, + forecast=forecast, + ) + + effective_free = _effective_free_count( + current_free_count=target.current_free_count, + forecast_view=view, + use_forecast=use_forecast, + ) + + effective_confidence = _effective_confidence( + current_confidence=target.current_confidence, + forecast_view=view, + use_forecast=use_forecast, + ) + + probability = _availability_probability( + effective_free_count=effective_free, + forecast_view=view, + use_forecast=use_forecast, + ) + + return effective_free, effective_confidence, probability, view + + +def _cluster_metrics( + candidate_target: _ZoneTarget, + arrival_time: datetime, + cluster_pool: list[_ZoneTarget], + forecasts_by_zone: dict[int, list[Forecast]], + use_forecast: bool, +) -> _ClusterMetrics: + alternative_count = 0 + good_alternative_count = 0 + total_effective_free = 0 + best_probability = 0.0 + nearest_good_distance: int | None = None + + for alternative in cluster_pool: + if alternative.zone.parking_zone_id == candidate_target.zone.parking_zone_id: + continue + + distance = _haversine_meters(candidate_target.point, alternative.point) + + if distance > CLUSTER_RADIUS_METERS: + continue + + alternative_count += 1 + + effective_free, _, probability, _ = _effective_state_for_target_at_arrival( + target=alternative, + arrival_time=arrival_time, + forecasts_by_zone=forecasts_by_zone, + use_forecast=use_forecast, + ) + + total_effective_free += max(effective_free, 0) + best_probability = max(best_probability, probability) + + is_good = effective_free >= 1 or probability >= GOOD_ALTERNATIVE_MIN_PROBABILITY + + if is_good: + good_alternative_count += 1 + + if nearest_good_distance is None or distance < nearest_good_distance: + nearest_good_distance = distance + + cluster_strength = ( + 0.45 * min(good_alternative_count / 3.0, 1.0) + + 0.40 * min(total_effective_free / 10.0, 1.0) + + 0.15 * best_probability + ) + + return _ClusterMetrics( + cluster_strength=round(_clamp_float(cluster_strength, 0.0, 1.0), 6), + nearby_alternative_count=alternative_count, + nearby_good_alternative_count=good_alternative_count, + nearby_effective_free_count=total_effective_free, + best_nearby_probability=round(best_probability, 6), + nearest_good_alternative_distance_meters=nearest_good_distance, + ) + + +# --------------------------------------------------------------------------- +# Ranking +# --------------------------------------------------------------------------- + +def _availability_strength( + effective_free_count: int, + capacity: int, + probability_free_space: float, +) -> float: + if effective_free_count <= 0: + return round(probability_free_space * 0.20, 6) + + free_count_score = min(effective_free_count / 4.0, 1.0) + free_ratio_score = min(effective_free_count / max(capacity, 1), 1.0) + + strength = ( + 0.55 * free_count_score + + 0.30 * probability_free_space + + 0.15 * free_ratio_score + ) + + return round(_clamp_float(strength, 0.0, 1.0), 6) + + +def _tier_for_candidate( current_free_count: int, effective_free_count: int, probability_free_space: float, effective_confidence: float, duration_from_origin_seconds: int, - requested_min_free_count: int | None, + cluster_strength: float, use_forecast: bool, forecast_view: _ForecastView, -) -> int: - """ - Чем меньше tier, тем лучше. +) -> tuple[int, str]: + if effective_free_count >= 3 and probability_free_space >= 0.55: + return 0, "excellent" - 0 — отличный кандидат; - 1 — хороший кандидат; - 2 — сейчас занято, но прогноз к arrival_time хороший; - 3 — рискованный, но возможный; - 4 — запасной вариант; - 5 — почти бесполезный вариант. - """ - min_required = requested_min_free_count if requested_min_free_count is not None else 1 + if effective_free_count >= 2 and probability_free_space >= 0.40: + return 1, "good" - if effective_free_count >= max(3, min_required) and probability_free_space >= 0.65: - return 0 - - if effective_free_count >= min_required and probability_free_space >= 0.40: - return 1 + if effective_free_count == 1 and (probability_free_space >= 0.45 or cluster_strength >= 0.45): + return 2, "scarce_but_usable" if ( use_forecast and current_free_count == 0 and forecast_view.predicted_free_count is not None - and forecast_view.predicted_free_count >= FORECAST_OPPORTUNITY_FREE_COUNT - and duration_from_origin_seconds >= 10 * 60 - and ( - forecast_view.probability_free_space is None - or forecast_view.probability_free_space >= 0.35 - ) + and forecast_view.predicted_free_count >= 2 + and duration_from_origin_seconds >= 8 * 60 ): - return 2 + return 2, "forecast_opportunity" - if effective_free_count > 0: - return 3 + if effective_free_count >= 1: + return 3, "risky" - if duration_from_origin_seconds <= SHORT_TRIP_SECONDS: - return 5 + if cluster_strength >= 0.50: + return 4, "fallback_cluster_only" if effective_confidence < 0.35: - return 5 + return 5, "poor_low_confidence" - return 4 + return 5, "poor_no_spaces" -def _price_bucket(pay: int) -> int: - if pay <= 0: - return 0 +def _scarcity_penalty_seconds( + effective_free_count: int, + probability_free_space: float, + duration_from_origin_seconds: int, + cluster_strength: float, +) -> float: + cluster_relief = cluster_strength * 450.0 - if pay <= 50: - return 1 + if effective_free_count >= 4: + return 0.0 - if pay <= 150: - return 2 + if effective_free_count == 3: + return max(0.0, 120.0 - cluster_relief * 0.25) - if pay <= 300: - return 3 + if effective_free_count == 2: + return max(0.0, 360.0 - cluster_relief * 0.50) + + if effective_free_count == 1: + if duration_from_origin_seconds <= 10 * 60: + base = 600.0 + elif duration_from_origin_seconds <= 30 * 60: + base = 1_050.0 + else: + base = 1_500.0 + + probability_relief = probability_free_space * 350.0 + + return max(180.0, base - probability_relief - cluster_relief) + + base = 4_200.0 + + if duration_from_origin_seconds <= 10 * 60: + base = 5_400.0 - return 4 + probability_relief = probability_free_space * 1_200.0 + return max(1_200.0, base - probability_relief - cluster_relief) -def _duration_bucket(seconds: int) -> int: - if seconds <= 10 * 60: - return 0 - if seconds <= 20 * 60: - return 1 +def _price_penalty_seconds(pay: int) -> float: + if pay <= 0: + return 0.0 - if seconds <= 40 * 60: - return 2 + if pay <= 50: + return 90.0 - if seconds <= 60 * 60: - return 3 + if pay <= 150: + return 240.0 - if seconds <= 2 * 60 * 60: - return 4 + if pay <= 300: + return 480.0 - return 5 + return 720.0 -def _walk_bucket(seconds: int | None) -> int: - if seconds is None: - return 0 +def _confidence_penalty_seconds(confidence: float) -> float: + return (1.0 - _clamp_float(confidence, 0.0, 1.0)) * 300.0 - if seconds <= 5 * 60: - return 0 - if seconds <= 10 * 60: - return 1 +def _availability_bonus_seconds( + effective_free_count: int, + availability_strength: float, + probability_free_space: float, +) -> float: + free_bonus = min(max(effective_free_count - 1, 0) * 220.0, 900.0) + strength_bonus = availability_strength * 300.0 + probability_bonus = probability_free_space * 220.0 - if seconds <= 20 * 60: - return 2 + return free_bonus + strength_bonus + probability_bonus - if seconds <= 30 * 60: - return 3 - return 4 +def _cluster_bonus_seconds(cluster_strength: float) -> float: + return cluster_strength * 750.0 -def _display_score( - tier: int, +def _candidate_reasons( + tier_label: str, effective_free_count: int, + current_free_count: int, + forecast_view: _ForecastView, probability_free_space: float, - effective_confidence: float, duration_from_origin_seconds: int, duration_to_destination_seconds: int | None, pay: int, -) -> float: - base_by_tier = { - 0: 0.95, - 1: 0.82, - 2: 0.72, - 3: 0.55, - 4: 0.35, - 5: 0.12, - } - - base = base_by_tier.get(tier, 0.10) + cluster_metrics: _ClusterMetrics, + peer_penalty: float, + detour_penalty: float, +) -> list[str]: + reasons: list[str] = [] + + reasons.append(f"quality_tier={tier_label}") + + if duration_from_origin_seconds <= 10 * 60: + reasons.append("very_close_to_origin") + elif duration_from_origin_seconds <= 30 * 60: + reasons.append("reasonable_drive_time") + else: + reasons.append("long_drive_time") - free_bonus = min(effective_free_count, 6) * 0.015 - probability_bonus = probability_free_space * 0.05 - confidence_bonus = effective_confidence * 0.03 - duration_penalty = min(duration_from_origin_seconds / (2 * 60 * 60), 1.0) * 0.10 + if duration_to_destination_seconds is not None: + if duration_to_destination_seconds <= 5 * 60: + reasons.append("very_close_to_destination_after_parking") + elif duration_to_destination_seconds <= 15 * 60: + reasons.append("acceptable_distance_to_destination_after_parking") + else: + reasons.append("far_from_destination_after_parking") - if duration_to_destination_seconds is None: - walk_penalty = 0.0 + if effective_free_count >= 3: + reasons.append("good_free_space_buffer") + elif effective_free_count == 2: + reasons.append("moderate_free_space_buffer") + elif effective_free_count == 1: + reasons.append("only_one_effective_free_space") else: - walk_penalty = min(duration_to_destination_seconds / (30 * 60), 1.0) * 0.08 - - price_penalty = min(pay / 500.0, 1.0) * 0.04 - - score = ( - base - + free_bonus - + probability_bonus - + confidence_bonus - - duration_penalty - - walk_penalty - - price_penalty + reasons.append("no_effective_free_spaces") + + if forecast_view.predicted_free_count is not None: + if forecast_view.predicted_free_count > current_free_count: + reasons.append("forecast_expects_spaces_to_free_up") + elif forecast_view.predicted_free_count < current_free_count: + reasons.append("forecast_expects_spaces_to_be_taken") + + if probability_free_space >= 0.75: + reasons.append("high_probability_of_free_space") + elif probability_free_space < 0.30: + reasons.append("low_probability_of_free_space") + + if pay <= 0: + reasons.append("free_parking") + elif pay >= 300: + reasons.append("expensive_parking") + + if cluster_metrics.cluster_strength >= 0.60: + reasons.append("strong_nearby_fallback_cluster") + elif cluster_metrics.nearby_good_alternative_count == 0: + reasons.append("no_good_nearby_alternatives") + + if peer_penalty > 0: + reasons.append("similar_nearby_candidate_has_better_availability") + + if detour_penalty > 0: + reasons.append("penalized_as_unreasonable_detour") + + return reasons + + +def _build_ranking_context( + routed: _RoutedCandidate, + cluster_pool: list[_ZoneTarget], + forecasts_by_zone: dict[int, list[Forecast]], + use_forecast: bool, +) -> _CandidateContext: + target = routed.zone_target + zone = target.zone + zone_id = int(zone.parking_zone_id) + + capacity = max(_safe_int(zone.capacity), 0) + pay = max(_safe_int(zone.pay), 0) + + effective_free, effective_confidence, probability, forecast_view = ( + _effective_state_for_target_at_arrival( + target=target, + arrival_time=routed.arrival_time, + forecasts_by_zone=forecasts_by_zone, + use_forecast=use_forecast, + ) ) - return round(max(0.0, min(score, 1.0)), 6) + cluster = _cluster_metrics( + candidate_target=target, + arrival_time=routed.arrival_time, + cluster_pool=cluster_pool, + forecasts_by_zone=forecasts_by_zone, + use_forecast=use_forecast, + ) + availability_strength = _availability_strength( + effective_free_count=effective_free, + capacity=capacity, + probability_free_space=probability, + ) -def _ranking_key( - candidate: RouteCandidate, - tier: int, - effective_free_count: int, - probability_free_space: float, - effective_confidence: float, -) -> tuple[Any, ...]: - return ( - tier, - _duration_bucket(candidate.duration_from_origin_seconds), - _walk_bucket(candidate.duration_to_destination_seconds), - -effective_free_count, - -probability_free_space, - _price_bucket(candidate.pay), - -effective_confidence, - candidate.duration_from_origin_seconds, - candidate.distance_from_origin_meters, - candidate.distance_to_destination_meters - if candidate.distance_to_destination_meters is not None - else 0, - candidate.pay, - candidate.zone_id, + tier, tier_label = _tier_for_candidate( + current_free_count=target.current_free_count, + effective_free_count=effective_free, + probability_free_space=probability, + effective_confidence=effective_confidence, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + cluster_strength=cluster.cluster_strength, + use_forecast=use_forecast, + forecast_view=forecast_view, ) + scarcity_penalty = _scarcity_penalty_seconds( + effective_free_count=effective_free, + probability_free_space=probability, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + cluster_strength=cluster.cluster_strength, + ) -def _remove_unreasonable_detours( - candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]], - limit: int, - selected_zone_id: int | None, -) -> list[tuple[RouteCandidate, tuple[Any, ...], int]]: - if selected_zone_id is not None: - return candidates_with_meta + price_penalty = _price_penalty_seconds(pay) + confidence_penalty = _confidence_penalty_seconds(effective_confidence) - if not candidates_with_meta: - return [] + availability_bonus = _availability_bonus_seconds( + effective_free_count=effective_free, + availability_strength=availability_strength, + probability_free_space=probability, + ) + + cluster_bonus = _cluster_bonus_seconds(cluster.cluster_strength) + + walk_seconds = routed.duration_to_destination_seconds or 0 + + # Главная база стоимости — время/расстояние до парковки. + # Остальные факторы — секунды штрафа/бонуса. + base_cost = ( + float(routed.duration_from_origin_seconds) + + 0.55 * float(walk_seconds) + ) + + generalized_cost = ( + base_cost + + scarcity_penalty + + price_penalty + + confidence_penalty + - availability_bonus + - cluster_bonus + ) + + generalized_cost = max(0.0, generalized_cost) + + explanation = RankingExplanation( + tier=tier, + tier_label=tier_label, + generalized_cost_seconds=round(generalized_cost, 3), + base_drive_seconds=routed.duration_from_origin_seconds, + walk_to_destination_seconds=routed.duration_to_destination_seconds, + current_free_count=target.current_free_count, + predicted_free_count=forecast_view.predicted_free_count, + effective_free_count=effective_free, + availability_probability=round(probability, 6), + availability_strength=availability_strength, + cluster_strength=cluster.cluster_strength, + nearby_alternative_count=cluster.nearby_alternative_count, + nearby_good_alternative_count=cluster.nearby_good_alternative_count, + nearby_effective_free_count=cluster.nearby_effective_free_count, + nearest_good_alternative_distance_meters=cluster.nearest_good_alternative_distance_meters, + price_penalty_seconds=round(price_penalty, 3), + scarcity_penalty_seconds=round(scarcity_penalty, 3), + confidence_penalty_seconds=round(confidence_penalty, 3), + cluster_bonus_seconds=round(cluster_bonus, 3), + availability_bonus_seconds=round(availability_bonus, 3), + peer_better_availability_penalty_seconds=0.0, + unreasonable_detour_penalty_seconds=0.0, + reasons=[], + ) - primary = [item for item in candidates_with_meta if item[2] <= 3] + score = round(1.0 / (1.0 + generalized_cost / 1_800.0), 6) + + candidate = RouteCandidate( + zone_id=zone_id, + camera_id=cast(int | None, zone.camera_id), + geometry=zone.geometry, + zone_type=_enum_value(zone.zone_type) or "unknown", + location_type=_enum_value(zone.location_type), + is_accessible=cast(bool | None, zone.is_accessible), + pay=pay, + capacity=capacity, + current_occupied=target.current_occupied, + current_free_count=target.current_free_count, + current_confidence=target.current_confidence, + predicted_for_arrival=routed.arrival_time, + predicted_occupied=forecast_view.predicted_occupied, + predicted_free_count=forecast_view.predicted_free_count, + probability_free_space=forecast_view.probability_free_space, + forecast_confidence=forecast_view.forecast_confidence, + distance_from_origin_meters=routed.distance_from_origin_meters, + duration_from_origin_seconds=routed.duration_from_origin_seconds, + distance_to_destination_meters=routed.distance_to_destination_meters, + duration_to_destination_seconds=routed.duration_to_destination_seconds, + score=score, + rank=0, + ranking_explanation=explanation, + ) + + return _CandidateContext( + candidate=candidate, + tier=tier, + tier_label=tier_label, + effective_free_count=effective_free, + effective_confidence=effective_confidence, + availability_probability=probability, + availability_strength=availability_strength, + cluster_metrics=cluster, + base_cost_seconds=base_cost, + generalized_cost_seconds=generalized_cost, + price_penalty_seconds=price_penalty, + scarcity_penalty_seconds=scarcity_penalty, + confidence_penalty_seconds=confidence_penalty, + cluster_bonus_seconds=cluster_bonus, + availability_bonus_seconds=availability_bonus, + ) + + +def _apply_peer_availability_penalties(contexts: list[_CandidateContext]) -> None: + for context in contexts: + candidate = context.candidate + penalty = 0.0 + + for other in contexts: + if other is context: + continue + + other_candidate = other.candidate + + similar_drive_time = ( + other_candidate.duration_from_origin_seconds + <= candidate.duration_from_origin_seconds + 5 * 60 + ) - if len(primary) < max(3, min(limit, 5)): - return candidates_with_meta + current_walk = candidate.duration_to_destination_seconds or 0 + other_walk = other_candidate.duration_to_destination_seconds or 0 - best_duration = min(item[0].duration_from_origin_seconds for item in primary) + similar_walk = other_walk <= current_walk + 5 * 60 + similar_price = other_candidate.pay <= candidate.pay + 100 + + much_better_availability = ( + other.effective_free_count >= context.effective_free_count + 2 + or other.availability_strength >= context.availability_strength + 0.20 + ) + + not_weaker_cluster = ( + other.cluster_metrics.cluster_strength + >= context.cluster_metrics.cluster_strength - 0.15 + ) + + if ( + similar_drive_time + and similar_walk + and similar_price + and much_better_availability + and not_weaker_cluster + ): + penalty = max(penalty, 900.0) + + if penalty > 0: + context.peer_better_availability_penalty_seconds = penalty + context.generalized_cost_seconds += penalty + + +def _apply_unreasonable_detour_penalties(contexts: list[_CandidateContext]) -> None: + good_contexts = [ + context + for context in contexts + if context.tier <= 3 + ] + + if len(good_contexts) < 3: + return + + best_duration = min( + context.candidate.duration_from_origin_seconds + for context in good_contexts + ) - # Не показываем вариант на 5 часов, если есть сопоставимые варианты за час. max_reasonable_duration = max( best_duration + 30 * 60, int(best_duration * 2.5), @@ -998,26 +1424,99 @@ def _remove_unreasonable_detours( best_duration + 2 * 60 * 60, ) - reasonable: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - backup: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] + for context in contexts: + if ( + context.tier >= 3 + and context.candidate.duration_from_origin_seconds > max_reasonable_duration + ): + context.unreasonable_detour_penalty_seconds = 3_600.0 + context.generalized_cost_seconds += 3_600.0 - for item in candidates_with_meta: - candidate = item[0] - tier = item[2] - if tier <= 3 and candidate.duration_from_origin_seconds <= max_reasonable_duration: - reasonable.append(item) - else: - backup.append(item) +def _ranking_sort_key(context: _CandidateContext) -> tuple[Any, ...]: + candidate = context.candidate - if len(reasonable) >= limit: - return reasonable + poor_group = 1 if context.tier >= 5 else 0 - return reasonable + backup + return ( + poor_group, + context.generalized_cost_seconds, + -context.availability_strength, + -context.cluster_metrics.cluster_strength, + -context.effective_free_count, + candidate.pay, + candidate.duration_from_origin_seconds, + candidate.distance_from_origin_meters, + candidate.zone_id, + ) + + +def _finalize_contexts(contexts: list[_CandidateContext]) -> list[RouteCandidate]: + _apply_peer_availability_penalties(contexts) + _apply_unreasonable_detour_penalties(contexts) + + finalized: list[_CandidateContext] = [] + + for context in contexts: + candidate = context.candidate + explanation = candidate.ranking_explanation + + if explanation is not None: + reasons = _candidate_reasons( + tier_label=context.tier_label, + effective_free_count=context.effective_free_count, + current_free_count=candidate.current_free_count, + forecast_view=_ForecastView( + predicted_occupied=candidate.predicted_occupied, + predicted_free_count=candidate.predicted_free_count, + probability_free_space=candidate.probability_free_space, + forecast_confidence=candidate.forecast_confidence, + ), + probability_free_space=context.availability_probability, + duration_from_origin_seconds=candidate.duration_from_origin_seconds, + duration_to_destination_seconds=candidate.duration_to_destination_seconds, + pay=candidate.pay, + cluster_metrics=context.cluster_metrics, + peer_penalty=context.peer_better_availability_penalty_seconds, + detour_penalty=context.unreasonable_detour_penalty_seconds, + ) + + explanation = explanation.model_copy( + update={ + "generalized_cost_seconds": round(context.generalized_cost_seconds, 3), + "peer_better_availability_penalty_seconds": round( + context.peer_better_availability_penalty_seconds, + 3, + ), + "unreasonable_detour_penalty_seconds": round( + context.unreasonable_detour_penalty_seconds, + 3, + ), + "reasons": reasons, + } + ) + + score = round(1.0 / (1.0 + context.generalized_cost_seconds / 1_800.0), 6) + + context.candidate = candidate.model_copy( + update={ + "score": score, + "ranking_explanation": explanation, + } + ) + + finalized.append(context) + + finalized.sort(key=_ranking_sort_key) + + return [ + context.candidate.model_copy(update={"rank": rank}) + for rank, context in enumerate(finalized, start=1) + ] # --------------------------------------------------------------------------- -# Основной поиск кандидатов +# Route matrix # --------------------------------------------------------------------------- def _route_zone_pool( @@ -1067,6 +1566,10 @@ def _route_zone_pool( return routed +# --------------------------------------------------------------------------- +# Основной поиск +# --------------------------------------------------------------------------- + def _search_candidates( db: Session, origin: GeoPoint, @@ -1090,7 +1593,7 @@ def _search_candidates( anchor = destination if mode == "route_to_destination" and destination is not None else origin - cheap_pool = _query_zone_targets_near_anchor( + cluster_pool = _query_zone_targets_near_anchor( db=db, anchor=anchor, mode=mode, @@ -1101,16 +1604,18 @@ def _search_candidates( selected_zone_id=selected_zone_id, ) + matrix_targets = _select_matrix_targets(cluster_pool) + routed_candidates = _route_zone_pool( origin=origin, destination=destination, - zone_targets=cheap_pool, + zone_targets=matrix_targets, ) if not routed_candidates: return _CandidateSearchResult(candidates=[], total_candidates=0) - filtered_by_route: list[_RoutedCandidate] = [] + filtered_by_explicit_constraints: list[_RoutedCandidate] = [] for routed in routed_candidates: if ( @@ -1126,23 +1631,25 @@ def _search_candidates( ): continue - filtered_by_route.append(routed) + filtered_by_explicit_constraints.append(routed) - if not filtered_by_route: + if not filtered_by_explicit_constraints: return _CandidateSearchResult(candidates=[], total_candidates=0) - min_arrival = min(item.arrival_time for item in filtered_by_route) - max_arrival = max(item.arrival_time for item in filtered_by_route) + min_arrival = min(item.arrival_time for item in filtered_by_explicit_constraints) + max_arrival = max(item.arrival_time for item in filtered_by_explicit_constraints) - zone_ids = [ - int(item.zone_target.zone.parking_zone_id) - for item in filtered_by_route - ] + forecast_zone_ids = sorted( + { + int(target.zone.parking_zone_id) + for target in cluster_pool + } + ) forecasts_by_zone = ( - _load_forecasts_for_candidates( + _load_forecasts_for_zones( db=db, - zone_ids=zone_ids, + zone_ids=forecast_zone_ids, min_arrival=min_arrival, max_arrival=max_arrival, ) @@ -1150,126 +1657,32 @@ def _search_candidates( else {} ) - candidates_with_meta: list[tuple[RouteCandidate, tuple[Any, ...], int]] = [] - - for routed in filtered_by_route: - target = routed.zone_target - zone = target.zone - - capacity = max(int(zone.capacity or 0), 0) - pay = max(int(zone.pay or 0), 0) + contexts: list[_CandidateContext] = [] - forecast = None - - if use_forecast: - forecast = _pick_forecast_for_arrival( - forecasts_by_zone.get(int(zone.parking_zone_id), []), - routed.arrival_time, - ) - - forecast_view = _forecast_view( - zone_capacity=capacity, - forecast=forecast, - ) - - effective_free_count = _effective_free_count( - current_free_count=target.current_free_count, - forecast_view=forecast_view, + for routed in filtered_by_explicit_constraints: + context = _build_ranking_context( + routed=routed, + cluster_pool=cluster_pool, + forecasts_by_zone=forecasts_by_zone, use_forecast=use_forecast, ) - effective_confidence = _effective_confidence( - current_confidence=target.current_confidence, - forecast_view=forecast_view, - use_forecast=use_forecast, - ) - - probability_free_space = _availability_probability( - effective_free_count=effective_free_count, - forecast_view=forecast_view, - use_forecast=use_forecast, - ) - - # Явные пользовательские ограничения — жёсткие. - if min_free_count is not None and effective_free_count < min_free_count: + if min_free_count is not None and context.effective_free_count < min_free_count: continue - if min_confidence is not None and effective_confidence < min_confidence: + if min_confidence is not None and context.effective_confidence < min_confidence: continue - tier = _candidate_tier( - current_free_count=target.current_free_count, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - requested_min_free_count=min_free_count, - use_forecast=use_forecast, - forecast_view=forecast_view, - ) - - score = _display_score( - tier=tier, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - duration_to_destination_seconds=routed.duration_to_destination_seconds, - pay=pay, - ) - - candidate = RouteCandidate( - zone_id=int(zone.parking_zone_id), - camera_id=cast(int | None, zone.camera_id), - geometry=zone.geometry, - zone_type=_enum_value(zone.zone_type) or "unknown", - location_type=_enum_value(zone.location_type), - is_accessible=cast(bool | None, zone.is_accessible), - pay=pay, - capacity=capacity, - current_occupied=target.current_occupied, - current_free_count=target.current_free_count, - current_confidence=target.current_confidence, - predicted_for_arrival=routed.arrival_time, - predicted_occupied=forecast_view.predicted_occupied, - predicted_free_count=forecast_view.predicted_free_count, - probability_free_space=forecast_view.probability_free_space, - forecast_confidence=forecast_view.forecast_confidence, - distance_from_origin_meters=routed.distance_from_origin_meters, - duration_from_origin_seconds=routed.duration_from_origin_seconds, - distance_to_destination_meters=routed.distance_to_destination_meters, - duration_to_destination_seconds=routed.duration_to_destination_seconds, - score=score, - rank=0, - ) - - ranking_key = _ranking_key( - candidate=candidate, - tier=tier, - effective_free_count=effective_free_count, - probability_free_space=probability_free_space, - effective_confidence=effective_confidence, - ) - - candidates_with_meta.append((candidate, ranking_key, tier)) - - candidates_with_meta.sort(key=lambda item: item[1]) + contexts.append(context) - candidates_with_meta = _remove_unreasonable_detours( - candidates_with_meta=candidates_with_meta, - limit=limit, - selected_zone_id=selected_zone_id, - ) - - total_candidates = len(candidates_with_meta) + if not contexts: + return _CandidateSearchResult(candidates=[], total_candidates=0) - ranked = [ - item[0].model_copy(update={"rank": rank}) - for rank, item in enumerate(candidates_with_meta, start=1) - ] + ranked_candidates = _finalize_contexts(contexts) + total_candidates = len(ranked_candidates) return _CandidateSearchResult( - candidates=ranked[:limit], + candidates=ranked_candidates[:limit], total_candidates=total_candidates, ) @@ -1308,7 +1721,7 @@ def _selected_candidate_or_422( # --------------------------------------------------------------------------- -# POST /routing/search — публичный поиск без авторизации +# POST /routing/search — публичный # --------------------------------------------------------------------------- @router.post("/search", response_model=SearchRoutingResponse) @@ -1347,7 +1760,7 @@ def search_routing( # --------------------------------------------------------------------------- -# POST /routing/new — публичное построение и сохранение маршрута +# POST /routing/new — публичный # --------------------------------------------------------------------------- @router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) @@ -1403,13 +1816,13 @@ def create_route( .one_or_none() ) - zone_point = body.origin + deeplink_target = body.origin if selected_zone is not None: - centroid = _zone_point(selected_zone) + centroid = _zone_point_from_centroid_columns(selected_zone) if centroid is not None: - zone_point = centroid + deeplink_target = centroid route = Route( user_id=public_user_id, @@ -1424,7 +1837,7 @@ def create_route( eta_seconds=best.duration_from_origin_seconds, arrival_time=best.predicted_for_arrival, polyline=None, - deeplink_url=_build_map_deeplink(zone_point), + deeplink_url=_build_map_deeplink(deeplink_target), status=RouteStatus.active, created_at=now, updated_at=now, @@ -1448,7 +1861,7 @@ def create_route( # --------------------------------------------------------------------------- -# GET /routing — маршруты текущего пользователя +# GET /routing # --------------------------------------------------------------------------- @router.get("", response_model=RouteListResponse) @@ -1575,7 +1988,7 @@ def update_route( except RoutingProviderError as exc: raise _provider_unavailable(exc) from exc - centroid = _zone_point(zone) + centroid = _zone_point_from_centroid_columns(zone) route.provider = GEOAPIFY_PROVIDER_NAME route.selected_zone_id = body.selected_zone_id @@ -1623,4 +2036,4 @@ def delete_route( db.commit() - return None + return None \ No newline at end of file diff --git a/src/schemas/routing.py b/src/schemas/routing.py index 7437ce5..5d7738a 100644 --- a/src/schemas/routing.py +++ b/src/schemas/routing.py @@ -18,6 +18,38 @@ class GeoPoint(BaseModel): longitude: float = Field(ge=-180, le=180) +class RankingExplanation(BaseModel): + tier: int + tier_label: str + + generalized_cost_seconds: float + base_drive_seconds: int + walk_to_destination_seconds: int | None + + current_free_count: int + predicted_free_count: int | None + effective_free_count: int + + availability_probability: float + availability_strength: float + + cluster_strength: float + nearby_alternative_count: int + nearby_good_alternative_count: int + nearby_effective_free_count: int + nearest_good_alternative_distance_meters: int | None + + price_penalty_seconds: float + scarcity_penalty_seconds: float + confidence_penalty_seconds: float + cluster_bonus_seconds: float + availability_bonus_seconds: float + peer_better_availability_penalty_seconds: float + unreasonable_detour_penalty_seconds: float + + reasons: list[str] + + # --------------------------------------------------------------------------- # RouteCandidate # --------------------------------------------------------------------------- @@ -46,6 +78,10 @@ class RouteCandidate(BaseModel): score: float rank: int + # Для новых ответов поле всегда заполняется. + # Optional оставлен только чтобы старые сохранённые маршруты без этого поля не ломали GET. + ranking_explanation: RankingExplanation | None = None + # --------------------------------------------------------------------------- # Route @@ -84,14 +120,19 @@ class RoutingRequestBase(BaseModel): mode: Literal["find_parking", "route_to_destination"] origin: GeoPoint destination: GeoPoint | None = None + max_pay: int | None = Field(None, ge=0) min_free_count: int | None = Field(None, ge=0) min_confidence: float | None = Field(None, ge=0.0, le=1.0) max_distance_to_destination_meters: int | None = Field(None, ge=0) max_duration_from_origin_seconds: int | None = Field(None, ge=0) include_accessible: bool | None = None + limit: int = Field(10, ge=1, le=50) use_forecast: bool = False + + # Для совместимости с контрактом поле оставляем. + # Фактически текущая реализация маршрутизации использует Geoapify. provider: RoutingProvider = "geoapify" @model_validator(mode="after") @@ -109,10 +150,6 @@ class CreateRouteRequest(RoutingRequestBase): selected_zone_id: int | None = None -# --------------------------------------------------------------------------- -# Ответ /routing/search -# --------------------------------------------------------------------------- - class SearchRoutingResponse(BaseModel): mode: str provider: str @@ -122,11 +159,7 @@ class SearchRoutingResponse(BaseModel): candidates: list[RouteCandidate] -# --------------------------------------------------------------------------- -# Обновление маршрута -# --------------------------------------------------------------------------- - class UpdateRouteRequest(BaseModel): status: Literal["active", "completed", "cancelled", "replaced"] | None = None selected_zone_id: int | None = None - provider: RoutingProvider | None = None + provider: RoutingProvider | None = None \ No newline at end of file