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/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..02b630e 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 @@ -26,6 +26,7 @@ from ..schemas.routing import ( CreateRouteRequest, GeoPoint, + RankingExplanation, RouteCandidate, RouteListResponse, RouteResponse, @@ -36,48 +37,46 @@ router = APIRouter(prefix="/routing", tags=["Routing"]) + +# --------------------------------------------------------------------------- +# Константы +# --------------------------------------------------------------------------- + GEOAPIFY_ROUTEMATRIX_URL = "https://api.geoapify.com/v1/routematrix" GEOAPIFY_PROVIDER_NAME = "geoapify" GEOAPIFY_MODE = "drive" -# Чтобы случайно не отправлять в Geoapify слишком большую матрицу. -# Сначала зоны грубо сортируются по прямому расстоянию, потом лучшие идут в API. -MAX_MATRIX_TARGETS = 200 +EARTH_RADIUS_METERS = 6_371_000 +METERS_PER_LATITUDE_DEGREE = 111_320 -PUBLIC_ROUTING_USER_EMAIL = "public-routing@parktrack.local" +MAX_CLUSTER_CONTEXT_TARGETS = 300 +MAX_MATRIX_TARGETS = 80 +MIN_CLUSTER_CONTEXT_FOR_COMPARE = 80 +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, +] -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}, - ) +CLUSTER_RADIUS_METERS = 500 +GOOD_ALTERNATIVE_MIN_PROBABILITY = 0.35 + +WALKING_SPEED_METERS_PER_SECOND = 1.35 +WALKING_DETOUR_FACTOR = 1.35 - return int(result.scalar_one()) +FORECAST_LOOKAROUND = timedelta(hours=2) + +PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" # --------------------------------------------------------------------------- @@ -92,11 +91,65 @@ 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 _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] @@ -112,18 +165,60 @@ 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 _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 + 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 +260,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 +274,66 @@ 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 _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 if not isinstance(geometry, dict): @@ -190,269 +342,1233 @@ def _zone_centroid(zone: ParkingZone) -> GeoPoint | None: try: coords = list(geometry["coordinates"][0]) - if len(coords) > 1 and coords[0] == coords[-1]: - coords = coords[:-1] + 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 _zone_point_from_centroid_columns(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) + + +# --------------------------------------------------------------------------- +# Geoapify +# --------------------------------------------------------------------------- + +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 [] + + 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": _geoapify_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 + + +# --------------------------------------------------------------------------- +# SQL radius search +# --------------------------------------------------------------------------- + +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, + max_pay: int | None, + include_accessible: bool | None, + selected_zone_id: int | None, +): + query = db.query(ParkingZone).filter(ParkingZone.is_active.is_(True)) + + if selected_zone_id is not None: + 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) + + if include_accessible is False: + query = query.filter( + or_( + ParkingZone.is_accessible.is_(False), + ParkingZone.is_accessible.is_(None), + ) + ) + + return query + + +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, + ) + + query = query.filter(ParkingZone.centroid_latitude >= min_lat) + query = query.filter(ParkingZone.centroid_latitude <= max_lat) + + 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 + + +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_from_centroid_columns(zone) + + if point is None: + return None + + 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 = _clamp_float(_safe_float(zone.confidence), 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_cluster_pool_size(limit: int) -> int: + return min( + MAX_CLUSTER_CONTEXT_TARGETS, + max(MIN_CLUSTER_CONTEXT_FOR_COMPARE, limit * 12), + ) + + +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_cluster_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() + ) + + if zone is None: + return [] + + target = _zone_to_target(zone, anchor) + + return [target] if target is not None else [] + + 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, + ) + + 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_CLUSTER_CONTEXT_TARGETS) + .all() + ) + + targets: list[_ZoneTarget] = [] + + for zone in zones: + target = _zone_to_target(zone, anchor) + + if target is None: + continue + + 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_CLUSTER_CONTEXT_TARGETS] + + return last_non_empty[:MAX_CLUSTER_CONTEXT_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_zones( + 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(_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 = ( + _clamp_float(float(forecast.probability_free_space), 0.0, 1.0) + if forecast.probability_free_space is not None + else None + ) + + forecast_confidence = ( + _clamp_float(float(forecast.confidence), 0.0, 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 >= 5: + return 0.92 + + if effective_free_count >= 3: + return 0.82 + + if effective_free_count == 2: + return 0.65 + + if effective_free_count == 1: + return 0.42 + + return 0.04 + + +# --------------------------------------------------------------------------- +# Cluster / fallback alternatives +# --------------------------------------------------------------------------- + +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, + ) + + 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, + cluster_strength: float, + use_forecast: bool, + forecast_view: _ForecastView, +) -> tuple[int, str]: + if effective_free_count >= 3 and probability_free_space >= 0.55: + return 0, "excellent" + + if effective_free_count >= 2 and probability_free_space >= 0.40: + return 1, "good" + + 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 >= 2 + and duration_from_origin_seconds >= 8 * 60 + ): + return 2, "forecast_opportunity" + + if effective_free_count >= 1: + return 3, "risky" + + if cluster_strength >= 0.50: + return 4, "fallback_cluster_only" + + if effective_confidence < 0.35: + return 5, "poor_low_confidence" + + return 5, "poor_no_spaces" + + +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 effective_free_count >= 4: + return 0.0 + + if effective_free_count == 3: + return max(0.0, 120.0 - cluster_relief * 0.25) + + 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 + + probability_relief = probability_free_space * 1_200.0 + + return max(1_200.0, base - probability_relief - cluster_relief) + + +def _price_penalty_seconds(pay: int) -> float: + if pay <= 0: + return 0.0 + + if pay <= 50: + return 90.0 + + if pay <= 150: + return 240.0 + + if pay <= 300: + return 480.0 + + return 720.0 + + +def _confidence_penalty_seconds(confidence: float) -> float: + return (1.0 - _clamp_float(confidence, 0.0, 1.0)) * 300.0 + + +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 + + return free_bonus + strength_bonus + probability_bonus + + +def _cluster_bonus_seconds(cluster_strength: float) -> float: + return cluster_strength * 750.0 + + +def _candidate_reasons( + tier_label: str, + effective_free_count: int, + current_free_count: int, + forecast_view: _ForecastView, + probability_free_space: float, + duration_from_origin_seconds: int, + duration_to_destination_seconds: int | None, + pay: int, + 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") + + 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 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: + 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 not coords: - return None + if pay <= 0: + reasons.append("free_parking") + elif pay >= 300: + reasons.append("expensive_parking") - latitude = sum(float(point[1]) for point in coords) / len(coords) - longitude = sum(float(point[0]) for point in coords) / len(coords) + 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") - return GeoPoint(latitude=latitude, longitude=longitude) + if peer_penalty > 0: + reasons.append("similar_nearby_candidate_has_better_availability") - except (KeyError, TypeError, ValueError, IndexError, ZeroDivisionError): - try: - return GeoPoint( - latitude=float(geometry["lat"]), - longitude=float(geometry["lon"]), - ) - except (KeyError, TypeError, ValueError): - return None + if detour_penalty > 0: + reasons.append("penalized_as_unreasonable_detour") + return reasons -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) +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, + ) + ) - h = ( - math.sin(delta_lat / 2) ** 2 - + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 + 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, ) - return int(2 * earth_radius_meters * math.asin(math.sqrt(h))) + availability_strength = _availability_strength( + effective_free_count=effective_free, + capacity=capacity, + probability_free_space=probability, + ) + 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, + ) -# --------------------------------------------------------------------------- -# Geoapify -# --------------------------------------------------------------------------- + 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 _geoapify_api_key() -> str: - api_key = os.getenv("GEOAPIFY_API_KEY") + price_penalty = _price_penalty_seconds(pay) + confidence_penalty = _confidence_penalty_seconds(effective_confidence) - if not api_key: - raise RoutingProviderError("Geoapify API key is not configured") + availability_bonus = _availability_bonus_seconds( + effective_free_count=effective_free, + availability_strength=availability_strength, + probability_free_space=probability, + ) - return api_key + cluster_bonus = _cluster_bonus_seconds(cluster.cluster_strength) + walk_seconds = routed.duration_to_destination_seconds or 0 -def _geoapify_matrix( - sources: list[GeoPoint], - targets: list[GeoPoint], -) -> list[list[dict[str, Any]]]: - if not sources or not targets: - return [] + # Главная база стоимости — время/расстояние до парковки. + # Остальные факторы — секунды штрафа/бонуса. + base_cost = ( + float(routed.duration_from_origin_seconds) + + 0.55 * float(walk_seconds) + ) - api_key = _geoapify_api_key() + generalized_cost = ( + base_cost + + scarcity_penalty + + price_penalty + + confidence_penalty + - availability_bonus + - cluster_bonus + ) - payload = { - "mode": GEOAPIFY_MODE, - "sources": [ - {"location": [point.longitude, point.latitude]} - for point in sources - ], - "targets": [ - {"location": [point.longitude, point.latitude]} - for point in targets - ], - } + 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=[], + ) - 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 + 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, + ) - if response.status_code >= 500: - raise RoutingProviderError( - f"Geoapify Route Matrix API is unavailable: HTTP {response.status_code}" - ) + 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, + ) - 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 +def _apply_peer_availability_penalties(contexts: list[_CandidateContext]) -> None: + for context in contexts: + candidate = context.candidate + penalty = 0.0 - matrix = data.get("sources_to_targets") + for other in contexts: + if other is context: + continue - if not isinstance(matrix, list): - raise RoutingProviderError("Geoapify response does not contain sources_to_targets") + other_candidate = other.candidate - return matrix + similar_drive_time = ( + other_candidate.duration_from_origin_seconds + <= candidate.duration_from_origin_seconds + 5 * 60 + ) + current_walk = candidate.duration_to_destination_seconds or 0 + other_walk = other_candidate.duration_to_destination_seconds or 0 -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 + similar_walk = other_walk <= current_walk + 5 * 60 + similar_price = other_candidate.pay <= candidate.pay + 100 - if not isinstance(cell, dict): - return None + much_better_availability = ( + other.effective_free_count >= context.effective_free_count + 2 + or other.availability_strength >= context.availability_strength + 0.20 + ) - distance = cell.get("distance") - duration = cell.get("time") + not_weaker_cluster = ( + other.cluster_metrics.cluster_strength + >= context.cluster_metrics.cluster_strength - 0.15 + ) - if distance is None or duration is None: - return None + if ( + similar_drive_time + and similar_walk + and similar_price + and much_better_availability + and not_weaker_cluster + ): + penalty = max(penalty, 900.0) - try: - return int(round(float(distance))), int(round(float(duration))) - except (TypeError, ValueError): - return None + 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 + ] -def _query_zone_targets( - 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 len(good_contexts) < 3: + return - if selected_zone_id is not None: - query = query.filter(ParkingZone.parking_zone_id == selected_zone_id) + best_duration = min( + context.candidate.duration_from_origin_seconds + for context in good_contexts + ) - if max_pay is not None: - query = query.filter(ParkingZone.pay <= max_pay) + max_reasonable_duration = max( + best_duration + 30 * 60, + int(best_duration * 2.5), + ) - if include_accessible is False: - query = query.filter( - or_( - ParkingZone.is_accessible.is_(False), - ParkingZone.is_accessible.is_(None), - ) - ) + max_reasonable_duration = min( + max_reasonable_duration, + best_duration + 2 * 60 * 60, + ) - zones = query.all() + 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 - targets: list[_ZoneTarget] = [] - for zone in zones: - point = _zone_centroid(zone) +def _ranking_sort_key(context: _CandidateContext) -> tuple[Any, ...]: + candidate = context.candidate - if point is None: - continue + poor_group = 1 if context.tier >= 5 else 0 - 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) + 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, + ) - # Если 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 min_confidence is not None and confidence < min_confidence: - continue +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, + ) - targets.append( - _ZoneTarget( - zone=zone, - point=point, - current_occupied=occupied, - current_free_count=free_count, - current_confidence=confidence, + 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, + } ) - targets.sort(key=lambda item: _haversine_meters(origin, item.point)) + finalized.append(context) - return targets[:MAX_MATRIX_TARGETS] + finalized.sort(key=_ranking_sort_key) + return [ + context.candidate.model_copy(update={"rank": rank}) + for rank, context in enumerate(finalized, start=1) + ] -def _find_forecast_for_arrival( - db: Session, - zone_id: int, - arrival_time: datetime, -) -> Forecast | None: - time_distance = func.abs( - func.extract("epoch", Forecast.predicted_for - arrival_time) - ) - return ( - db.query(Forecast) - .filter(Forecast.zone_id == zone_id) - .order_by( - time_distance.asc(), - Forecast.generated_at.desc(), - Forecast.forecast_id.desc(), - ) - .first() +# --------------------------------------------------------------------------- +# Route matrix +# --------------------------------------------------------------------------- + +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] = [] -def _score_candidate( - pay: int, - capacity: int, - effective_free_count: int, - effective_confidence: float, - probability_free_space: float | None, - duration_from_origin_seconds: int, - duration_to_destination_seconds: int | None, -) -> float: - free_ratio = effective_free_count / max(capacity, 1) - free_score = max(0.0, min(free_ratio, 1.0)) + for index, item in enumerate(zone_targets): + from_origin = _matrix_cell(matrix, 0, index) - 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) - ) + if from_origin is None: + continue - confidence_score = max(0.0, min(effective_confidence, 1.0)) + distance_from_origin, duration_from_origin = from_origin + arrival_time = now + timedelta(seconds=duration_from_origin) - # 15 минут — условно хорошее время до парковки. - origin_time_score = 1.0 / (1.0 + duration_from_origin_seconds / 900.0) + distance_to_destination: int | None = None + duration_to_destination: int | None = None - if duration_to_destination_seconds is None: - destination_time_score = 1.0 - else: - # 5 минут — условно хорошее время от парковки до точки назначения. - destination_time_score = 1.0 / (1.0 + duration_to_destination_seconds / 300.0) + 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) - price_score = 1.0 / (1.0 + pay / 100.0) + 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, + ) + ) - 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 - ) + return routed - return round(score, 6) +# --------------------------------------------------------------------------- +# Основной поиск +# --------------------------------------------------------------------------- def _search_candidates( db: Session, @@ -475,166 +1591,95 @@ 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 + + cluster_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) + matrix_targets = _select_matrix_targets(cluster_pool) - 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=matrix_targets, ) - 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] = [] - - for index, item in enumerate(zone_targets): - from_origin = _matrix_cell(origin_matrix, 0, index) - - if from_origin is None: - continue + if not routed_candidates: + return _CandidateSearchResult(candidates=[], total_candidates=0) - distance_from_origin, duration_from_origin = from_origin + filtered_by_explicit_constraints: list[_RoutedCandidate] = [] + for routed in routed_candidates: if ( max_duration_from_origin_seconds is not None - and duration_from_origin > max_duration_from_origin_seconds + and routed.duration_from_origin_seconds > max_duration_from_origin_seconds ): continue - distance_to_destination: int | None = None - duration_to_destination: int | None = None - - if destination_matrix is not None: - to_destination = _matrix_cell(destination_matrix, index, 0) - - if to_destination is None: - continue - - distance_to_destination, duration_to_destination = to_destination - - if ( - max_distance_to_destination_meters is not None - and distance_to_destination > max_distance_to_destination_meters - ): - 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 - zone = item.zone - capacity = max(int(zone.capacity or 0), 0) - pay = max(int(zone.pay or 0), 0) + filtered_by_explicit_constraints.append(routed) - predicted_for_arrival = now + timedelta(seconds=duration_from_origin) + if not filtered_by_explicit_constraints: + return _CandidateSearchResult(candidates=[], total_candidates=0) - predicted_occupied: int | None = None - predicted_free_count: int | None = None - probability_free_space: float | None = None - forecast_confidence: float | None = None + 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) - effective_free_count = item.current_free_count - effective_confidence = item.current_confidence + forecast_zone_ids = sorted( + { + int(target.zone.parking_zone_id) + for target in cluster_pool + } + ) - if use_forecast: - forecast = _find_forecast_for_arrival( - db=db, - zone_id=int(zone.parking_zone_id), - arrival_time=predicted_for_arrival, - ) + forecasts_by_zone = ( + _load_forecasts_for_zones( + db=db, + zone_ids=forecast_zone_ids, + min_arrival=min_arrival, + max_arrival=max_arrival, + ) + if use_forecast + else {} + ) - 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 - ) + contexts: list[_CandidateContext] = [] - effective_free_count = predicted_free_count - effective_confidence = ( - forecast_confidence - if forecast_confidence is not None - else item.current_confidence - ) + 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, + ) - 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 - score = _score_candidate( - pay=pay, - capacity=capacity, - effective_free_count=effective_free_count, - effective_confidence=effective_confidence, - 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, - ) - ) - - candidates.sort( - key=lambda candidate: ( - -candidate.score, - candidate.duration_from_origin_seconds, - candidate.distance_from_origin_meters, - ) - ) + contexts.append(context) - total_candidates = len(candidates) + if not contexts: + return _CandidateSearchResult(candidates=[], total_candidates=0) - ranked_candidates = [ - candidate.model_copy(update={"rank": rank}) - for rank, candidate in enumerate(candidates, start=1) - ] + ranked_candidates = _finalize_contexts(contexts) + total_candidates = len(ranked_candidates) return _CandidateSearchResult( candidates=ranked_candidates[:limit], @@ -676,7 +1721,7 @@ def _selected_candidate_or_422( # --------------------------------------------------------------------------- -# POST /routing/search +# POST /routing/search — публичный # --------------------------------------------------------------------------- @router.post("/search", response_model=SearchRoutingResponse) @@ -715,7 +1760,7 @@ def search_routing( # --------------------------------------------------------------------------- -# POST /routing/new +# POST /routing/new — публичный # --------------------------------------------------------------------------- @router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) @@ -765,6 +1810,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() + ) + + deeplink_target = body.origin + + if selected_zone is not None: + centroid = _zone_point_from_centroid_columns(selected_zone) + + if centroid is not None: + deeplink_target = centroid + route = Route( user_id=public_user_id, mode=RouteMode(body.mode), @@ -778,7 +1837,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(deeplink_target), status=RouteStatus.active, created_at=now, updated_at=now, @@ -888,7 +1947,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 +1988,17 @@ def update_route( except RoutingProviderError as exc: raise _provider_unavailable(exc) from exc + centroid = _zone_point_from_centroid_columns(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) 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 9ceb230..5d7738a 100644 --- a/src/schemas/routing.py +++ b/src/schemas/routing.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator -RoutingProvider = Literal["geoapify", "external", "yandex", "internal"] +RoutingProvider = Literal["geoapify", "yandex", "internal", "external"] # --------------------------------------------------------------------------- @@ -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,17 +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 - # Оставляем provider для совместимости с ТЗ/фронтом, - # но фактически маршрутизация ниже всегда идёт через Geoapify. + # Для совместимости с контрактом поле оставляем. + # Фактически текущая реализация маршрутизации использует Geoapify. provider: RoutingProvider = "geoapify" @model_validator(mode="after")