Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions src/routers/analytics.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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",
),
)

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
]
)
Expand All @@ -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,
)


# ---------------------------------------------------------------------------
Expand Down
61 changes: 35 additions & 26 deletions src/routers/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

# В режиме маршрута к точке назначения пешая часть имеет повышенную цену:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading