diff --git a/src/routers/analytics.py b/src/routers/analytics.py index eac17b1..106fa5d 100644 --- a/src/routers/analytics.py +++ b/src/routers/analytics.py @@ -1,4 +1,6 @@ from __future__ import annotations +from fastapi import Request +from urllib.parse import urlencode from collections import defaultdict from datetime import datetime, timezone @@ -572,21 +574,41 @@ def _detection_camera_id(db: Session, detection: OccupancyObservation) -> int: ) -def _artifact_url(detection_run_id: int, variant: str) -> str: - base = f"/api/v1/admin/analytics/detections/{detection_run_id}" +def _artifact_url( + request: Request, + detection_run_id: int, + variant: str, +) -> str: if variant == "labels": - return f"{base}/labels" - return f"{base}/snapshot?variant={variant}" + return str( + request.url_for( + "get_detection_labels", + detection_run_id=detection_run_id, + ) + ) + + return str( + request.url_for( + "get_detection_snapshot", + detection_run_id=detection_run_id, + ).include_query_params(variant=variant) + ) def _available_artifact_url( + request: Request, metadata: dict[str, Any], detection_run_id: int, variant: str, ) -> str | None: if snapshot_reference_from_metadata(metadata, variant) is None: return None - return _artifact_url(detection_run_id, variant) + + return _artifact_url( + request, + detection_run_id, + variant, + ) def _detection_artifact_response( @@ -852,6 +874,7 @@ def _detection_status(metadata: dict[str, Any]) -> str: def _serialize_detection_run( + request: Request, db: Session, observation: OccupancyObservation, feedback_ids: set[int], @@ -906,13 +929,22 @@ def _serialize_detection_run( error_message=_to_str_or_none(_metadata_value(metadata, "error_message", "error")), has_feedback=observation.observation_id in feedback_ids, raw_snapshot_url=_available_artifact_url( - metadata, observation.observation_id, "raw" + request, + metadata, + observation.observation_id, + "raw", ), annotated_snapshot_url=_available_artifact_url( - metadata, observation.observation_id, "annotated" + request, + metadata, + observation.observation_id, + "annotated", ), yolo_labels_url=_available_artifact_url( - metadata, observation.observation_id, "labels" + request, + metadata, + observation.observation_id, + "labels", ), ) @@ -1540,9 +1572,13 @@ def get_detector_health( # GET /admin/analytics/cameras/{camera_id}/detections # --------------------------------------------------------------------------- -@router.get("/cameras/{camera_id}/detections", response_model=DetectionRunListResponse) +@router.get( + "/cameras/{camera_id}/detections", + response_model=DetectionRunListResponse, +) def list_camera_detections( camera_id: int, + request: Request, current_user: Annotated[User, require("analytics.view")], db: Annotated[Session, Depends(get_db)], from_: datetime | None = Query(None, alias="from"), @@ -1588,7 +1624,13 @@ def list_camera_detections( return DetectionRunListResponse( items=[ - _serialize_detection_run(db, obs, feedback_ids, zone_camera_ids) + _serialize_detection_run( + request, + db, + obs, + feedback_ids, + zone_camera_ids, + ) for obs in observations ] ) @@ -1601,14 +1643,29 @@ def list_camera_detections( @router.get("/detections/{detection_run_id}", response_model=DetectionRun) def get_detection_run( detection_run_id: int, + request: Request, current_user: Annotated[User, require("analytics.view")], db: Annotated[Session, Depends(get_db)], ): detection = _get_detection_or_404(db, detection_run_id) _ensure_detection_visible(db, detection, current_user) - feedback_ids = _feedback_run_ids(db, [detection.observation_id]) - zone_camera_ids = _zone_camera_map(db, {detection.zone_id}) - return _serialize_detection_run(db, detection, feedback_ids, zone_camera_ids) + + feedback_ids = _feedback_run_ids( + db, + [detection.observation_id], + ) + zone_camera_ids = _zone_camera_map( + db, + {detection.zone_id}, + ) + + return _serialize_detection_run( + request, + db, + detection, + feedback_ids, + zone_camera_ids, + ) # --------------------------------------------------------------------------- diff --git a/src/routers/routing.py b/src/routers/routing.py index 2d0f367..ca35310 100644 --- a/src/routers/routing.py +++ b/src/routers/routing.py @@ -67,6 +67,9 @@ # заставлять человека идти несколько кварталов ради избыточного запаса мест. SUFFICIENT_FREE_SPACES = 2 DESTINATION_WALK_COST_MULTIPLIER = 2.5 +AVAILABILITY_BASE_COST_DISCOUNT = 0.20 +CLUSTER_BASE_COST_DISCOUNT = 0.10 +PROTECTED_DESTINATION_WALK_SECONDS = 2 * 60 FORECAST_LOOKAROUND = timedelta(hours=2) @@ -1341,25 +1344,28 @@ def _confidence_penalty_seconds(confidence: float) -> float: def _availability_bonus_seconds( - effective_free_count: int, + base_cost_seconds: float, availability_strength: float, - probability_free_space: float, - prioritize_destination: bool, ) -> float: - free_count_for_bonus = effective_free_count - - if prioritize_destination: - free_count_for_bonus = min(free_count_for_bonus, SUFFICIENT_FREE_SPACES) - - free_bonus = min(max(free_count_for_bonus - 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 + # Доступность может уменьшить стоимость пути, но не должна полностью + # перекрывать время поездки и пешего участка. + return ( + max(base_cost_seconds, 0.0) + * AVAILABILITY_BASE_COST_DISCOUNT + * _clamp_float(availability_strength, 0.0, 1.0) + ) -def _cluster_bonus_seconds(cluster_strength: float) -> float: - return cluster_strength * 750.0 +def _cluster_bonus_seconds( + base_cost_seconds: float, + cluster_strength: float, +) -> float: + # Наличие альтернатив рядом — страховка, а не замена основного маршрута. + return ( + max(base_cost_seconds, 0.0) + * CLUSTER_BASE_COST_DISCOUNT + * _clamp_float(cluster_strength, 0.0, 1.0) + ) def _candidate_reasons( @@ -1387,9 +1393,9 @@ def _candidate_reasons( reasons.append("long_drive_time") if duration_to_destination_seconds is not None: - if duration_to_destination_seconds <= 5 * 60: + if duration_to_destination_seconds <= 2 * 60: reasons.append("very_close_to_destination_after_parking") - elif duration_to_destination_seconds <= 15 * 60: + elif duration_to_destination_seconds <= 8 * 60: reasons.append("acceptable_distance_to_destination_after_parking") else: reasons.append("far_from_destination_after_parking") @@ -1488,15 +1494,6 @@ def _build_ranking_context( price_penalty = _price_penalty_seconds(pay) confidence_penalty = _confidence_penalty_seconds(effective_confidence) - availability_bonus = _availability_bonus_seconds( - effective_free_count=effective_free, - availability_strength=availability_strength, - probability_free_space=probability, - prioritize_destination=routed.duration_to_destination_seconds is not None, - ) - - cluster_bonus = _cluster_bonus_seconds(cluster.cluster_strength) - walk_seconds = routed.duration_to_destination_seconds or 0 # В режиме маршрута к точке назначения пешая часть имеет повышенную цену: @@ -1507,6 +1504,15 @@ def _build_ranking_context( + DESTINATION_WALK_COST_MULTIPLIER * float(walk_seconds) ) + availability_bonus = _availability_bonus_seconds( + base_cost_seconds=base_cost, + availability_strength=availability_strength, + ) + cluster_bonus = _cluster_bonus_seconds( + base_cost_seconds=base_cost, + cluster_strength=cluster.cluster_strength, + ) + generalized_cost = ( base_cost + scarcity_penalty @@ -1601,6 +1607,8 @@ def _apply_peer_availability_penalties(contexts: list[_CandidateContext]) -> Non # в нескольких кварталах есть гораздо более крупная парковка. has_sufficient_destination_availability = ( candidate.duration_to_destination_seconds is not None + and candidate.duration_to_destination_seconds + <= PROTECTED_DESTINATION_WALK_SECONDS and candidate.current_free_count >= SUFFICIENT_FREE_SPACES and context.effective_free_count >= SUFFICIENT_FREE_SPACES and context.availability_probability >= 0.40 @@ -1692,6 +1700,7 @@ def _ranking_sort_key(context: _CandidateContext) -> tuple[Any, ...]: return ( poor_group, context.generalized_cost_seconds, + context.base_cost_seconds, -context.availability_strength, -context.cluster_metrics.cluster_strength, -context.effective_free_count, diff --git a/tests/test_routing.py b/tests/test_routing.py index a727a33..376e6ca 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -404,6 +404,74 @@ def test_single_space_at_destination_keeps_scarcity_risk(self) -> None: self.assertEqual([candidate.zone_id for candidate in ranked], [2, 1]) + + def test_dominated_candidate_cannot_win_after_bonus_saturation(self) -> None: + arrival = datetime(2026, 7, 22, 17, 21, tzinfo=timezone.utc) + worse = _target(75, free=10, capacity=11) + better = _target(66, free=20, capacity=24) + + forecasts = { + 75: Forecast( + forecast_id=75, + zone_id=75, + predicted_for=arrival, + generated_at=arrival - timedelta(minutes=5), + capacity=11, + predicted_occupied=3, + probability_free_space=0.9888, + confidence=0.7776, + model_type="test", + ), + 66: Forecast( + forecast_id=66, + zone_id=66, + predicted_for=arrival, + generated_at=arrival - timedelta(minutes=5), + capacity=24, + predicted_occupied=8, + probability_free_space=0.9965, + confidence=0.5452, + model_type="test", + ), + } + request_context = routing._RankingRequestContext( + forecasts_by_zone={ + zone_id: routing._ForecastSeries( + forecasts=[forecast], + predicted_timestamps=[routing._datetime_timestamp(arrival)], + ) + for zone_id, forecast in forecasts.items() + }, + cluster_neighbors=routing._build_cluster_neighbors( + [worse, better], [worse, better] + ), + effective_state_cache={}, + ) + + ranked = routing._finalize_contexts([ + self._context( + worse, + drive_seconds=92, + walk_seconds=276, + request_context=request_context, + use_forecast=True, + ), + self._context( + better, + drive_seconds=59, + walk_seconds=86, + request_context=request_context, + use_forecast=True, + ), + ]) + + self.assertEqual([candidate.zone_id for candidate in ranked], [66, 75]) + self.assertGreater( + ranked[1].ranking_explanation.generalized_cost_seconds, + ranked[0].ranking_explanation.generalized_cost_seconds, + ) + self.assertNotEqual(ranked[0].score, ranked[1].score) + def test_destination_priority_uses_availability_at_arrival(self) -> None: arrival = datetime(2026, 1, 1, 12, tzinfo=timezone.utc) near = _target(1, free=4, capacity=10)