Next alpha stage commit
This commit is contained in:
@@ -13,8 +13,16 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.address_search import is_location_token
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.journey import find_journeys, parse_service_date, resolve_location_summary
|
||||
from app.harmonization import active_harmonized_gtfs_dataset_ids
|
||||
from app.journey import (
|
||||
find_journeys,
|
||||
journey_duplicate_preference_key,
|
||||
journey_equivalence_key,
|
||||
parse_service_date,
|
||||
resolve_location_summary,
|
||||
)
|
||||
from app.models import JourneySearchCache
|
||||
from app.routing import direct_route_between_points, route_between_points
|
||||
|
||||
@@ -24,8 +32,15 @@ TRANSIT_STAGE_CACHE_TTL_SECONDS = 5 * 60
|
||||
TRANSIT_STAGE_CACHE_MAX_ENTRIES = 256
|
||||
PROGRESSIVE_SEARCH_CACHE_TTL_SECONDS = 10 * 60
|
||||
PROGRESSIVE_SEARCH_CACHE_MAX_ENTRIES = 128
|
||||
JOURNEY_SEARCH_CACHE_VERSION = "journey-search-v7"
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="journey-search")
|
||||
JOURNEY_SEARCH_CACHE_VERSION = "journey-search-v13-route-shadow"
|
||||
LOW_WALK_MIN_SAVINGS_M = 250
|
||||
LOW_WALK_MIN_RELATIVE_SAVING = 0.35
|
||||
LOW_WALK_MAX_EXTRA_SECONDS = 45 * 60
|
||||
LOW_WALK_GOOD_ENOUGH_M = 350
|
||||
_executor = ThreadPoolExecutor(
|
||||
max_workers=max(1, min(int(settings.journey_search_worker_count), 16)),
|
||||
thread_name_prefix="journey-search",
|
||||
)
|
||||
_lock = threading.RLock()
|
||||
_searches: dict[str, "_SearchState"] = {}
|
||||
_progressive_search_inflight: dict[tuple[object, ...], str] = {}
|
||||
@@ -106,9 +121,9 @@ def _run_search(search_id: str) -> None:
|
||||
state.updated_at = time.time()
|
||||
request = dict(state.request)
|
||||
try:
|
||||
mode = str(request.get("mode") or "transit")
|
||||
if mode in {"walk", "drive", "car"}:
|
||||
_run_point_route_search(search_id, "drive" if mode == "car" else mode, request)
|
||||
mode = _canonical_search_mode(request.get("mode"))
|
||||
if mode in {"walk", "bike", "drive"}:
|
||||
_run_point_route_search(search_id, mode, request)
|
||||
else:
|
||||
_run_transit_search(search_id, request)
|
||||
except Exception as exc: # noqa: BLE001 - report progressive-search failure to client
|
||||
@@ -129,25 +144,53 @@ def _run_transit_search(search_id: str, request: dict[str, Any]) -> None:
|
||||
diagnostics: dict[str, Any] = {"stages": []}
|
||||
best_count = 0
|
||||
stale_stages = 0
|
||||
ranked: list[dict] = []
|
||||
stopped_early_reason = ""
|
||||
for transfers in stages:
|
||||
if _is_cancelled(search_id):
|
||||
return
|
||||
label = "direct" if transfers == 0 else f"up to {transfers} transfer{'s' if transfers != 1 else ''}"
|
||||
_publish_status(search_id, "running", f"Searching {label}...", f"transfers_{transfers}")
|
||||
stage_started_at = time.monotonic()
|
||||
with SessionLocal() as db:
|
||||
result = _cached_find_journeys(
|
||||
db,
|
||||
from_stop_id=str(request.get("from_stop_id") or ""),
|
||||
to_stop_id=str(request.get("to_stop_id") or ""),
|
||||
via_stop_id=request.get("via_stop_id") or None,
|
||||
source_ids=source_ids,
|
||||
departure=str(request.get("departure") or "08:00"),
|
||||
service_date=service_date,
|
||||
max_transfers=transfers,
|
||||
transfer_seconds=transfer_seconds,
|
||||
limit=stage_limit,
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
result = _cached_find_journeys(
|
||||
db,
|
||||
from_stop_id=str(request.get("from_stop_id") or ""),
|
||||
to_stop_id=str(request.get("to_stop_id") or ""),
|
||||
via_stop_id=request.get("via_stop_id") or None,
|
||||
source_ids=source_ids,
|
||||
departure=str(request.get("departure") or "08:00"),
|
||||
service_date=service_date,
|
||||
max_transfers=transfers,
|
||||
transfer_seconds=transfer_seconds,
|
||||
limit=stage_limit,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - preserve already-published progressive results on DB timeouts
|
||||
if not _is_statement_timeout_error(exc) or not ranked:
|
||||
raise
|
||||
elapsed_ms = int((time.monotonic() - stage_started_at) * 1000)
|
||||
diagnostics["stages"].append(
|
||||
{
|
||||
"transfers": transfers,
|
||||
"cache": "error",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"journeys": 0,
|
||||
"timed_out": True,
|
||||
"error": _short_error_message(exc),
|
||||
}
|
||||
)
|
||||
context["diagnostics"] = diagnostics
|
||||
stopped_early_reason = f"Stopped after {label} timed out; showing the best options found so far."
|
||||
_publish_results(
|
||||
search_id,
|
||||
journeys=ranked,
|
||||
context=context,
|
||||
status="running",
|
||||
stage=f"transfers_{transfers}",
|
||||
message=stopped_early_reason,
|
||||
)
|
||||
break
|
||||
cache_status = str(result.pop("_cache_status", "miss"))
|
||||
elapsed_ms = int((time.monotonic() - stage_started_at) * 1000)
|
||||
stage_diagnostics = {
|
||||
@@ -179,11 +222,19 @@ def _run_transit_search(search_id: str, request: dict[str, Any]) -> None:
|
||||
else:
|
||||
stale_stages = 0
|
||||
best_count = max(best_count, len(ranked))
|
||||
if _has_sufficient_progressive_options(ranked, transfers=transfers, limit=limit):
|
||||
stopped_early_reason = "Stopped after finding a balanced set of fast and low-walk options."
|
||||
break
|
||||
if ranked and stale_stages >= 2 and transfers >= 2:
|
||||
stopped_early_reason = "Stopped after deeper transfer stages stopped improving the options."
|
||||
break
|
||||
if _major_hub_address_stage_is_complete(result_diagnostics, ranked, transfers=transfers, limit=limit):
|
||||
stopped_early_reason = "Stopped after finding major-hub address access options."
|
||||
break
|
||||
complete_message = (
|
||||
f"{stopped_early_reason} Found {best_count} option{'s' if best_count != 1 else ''}."
|
||||
if stopped_early_reason and best_count
|
||||
else
|
||||
f"Search complete. Found {best_count} option{'s' if best_count != 1 else ''}."
|
||||
if best_count
|
||||
else "Search complete. No route found in the imported timetable."
|
||||
@@ -270,6 +321,7 @@ def _cached_find_journeys(
|
||||
to_stop_id,
|
||||
str(via_stop_id or ""),
|
||||
tuple(sorted(int(source_id) for source_id in source_ids or [])),
|
||||
tuple(active_harmonized_gtfs_dataset_ids(db, source_ids=source_ids)),
|
||||
departure,
|
||||
str(service_date or ""),
|
||||
int(max_transfers),
|
||||
@@ -406,12 +458,14 @@ def _as_utc(value: datetime | None) -> datetime | None:
|
||||
|
||||
def _progressive_cache_key(request: dict[str, Any]) -> tuple[object, ...]:
|
||||
source_ids = _csv_ints(request.get("source_id"))
|
||||
mode = _canonical_search_mode(request.get("mode"))
|
||||
return (
|
||||
str(request.get("mode") or "transit"),
|
||||
mode,
|
||||
str(request.get("from_stop_id") or ""),
|
||||
str(request.get("to_stop_id") or ""),
|
||||
str(request.get("via_stop_id") or ""),
|
||||
tuple(sorted(int(source_id) for source_id in source_ids or [])),
|
||||
_active_dataset_cache_fragment(source_ids) if mode == "transit" else (),
|
||||
str(request.get("departure") or "08:00"),
|
||||
str(request.get("service_date") or ""),
|
||||
bool(request.get("direct_only")),
|
||||
@@ -421,6 +475,23 @@ def _progressive_cache_key(request: dict[str, Any]) -> tuple[object, ...]:
|
||||
)
|
||||
|
||||
|
||||
def _canonical_search_mode(mode: object) -> str:
|
||||
value = str(mode or "transit").lower()
|
||||
if value in {"bike", "bicycle", "cycle", "cycling"}:
|
||||
return "bike"
|
||||
if value == "car":
|
||||
return "drive"
|
||||
return value if value in {"transit", "walk", "drive"} else "transit"
|
||||
|
||||
|
||||
def _active_dataset_cache_fragment(source_ids: list[int] | None) -> tuple[int, ...]:
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
return tuple(active_harmonized_gtfs_dataset_ids(session, source_ids=source_ids))
|
||||
except Exception: # noqa: BLE001 - cache key generation must not prevent search
|
||||
return ()
|
||||
|
||||
|
||||
def _progressive_cache_get(key: tuple[object, ...]) -> dict[str, Any] | None:
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
@@ -567,24 +638,7 @@ def _context_from_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _journey_key(journey: dict[str, Any]) -> str:
|
||||
parts = []
|
||||
for leg in journey.get("legs") or []:
|
||||
parts.append(
|
||||
"|".join(
|
||||
str(part or "")
|
||||
for part in [
|
||||
leg.get("dataset_id"),
|
||||
leg.get("mode"),
|
||||
leg.get("route_id"),
|
||||
leg.get("trip_id"),
|
||||
(leg.get("from") or {}).get("stop_id") or (leg.get("from") or {}).get("name"),
|
||||
(leg.get("to") or {}).get("stop_id") or (leg.get("to") or {}).get("name"),
|
||||
leg.get("departure_time"),
|
||||
leg.get("arrival_time"),
|
||||
]
|
||||
)
|
||||
)
|
||||
return "||".join(parts)
|
||||
return json.dumps(journey_equivalence_key(journey), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _rank_journeys(journeys, ranking: str) -> list[dict]:
|
||||
@@ -623,36 +677,47 @@ def _rank_journeys(journeys, ranking: str) -> list[dict]:
|
||||
walking,
|
||||
)
|
||||
|
||||
return sorted((dict(journey) for journey in journeys), key=key)
|
||||
return sorted((dict(journey) for journey in journeys), key=lambda journey: (key(journey), journey_duplicate_preference_key(journey)))
|
||||
|
||||
|
||||
def _select_diverse_journeys(journeys: list[dict], *, limit: int) -> list[dict]:
|
||||
ranked = list(journeys)
|
||||
selected: list[dict] = []
|
||||
selected_exact: set[str] = set()
|
||||
selected_diversity: set[tuple[object, ...]] = set()
|
||||
for journey in journeys:
|
||||
|
||||
def append(journey: dict | None, *, force: bool = False) -> bool:
|
||||
if journey is None:
|
||||
return False
|
||||
exact_key = _journey_key(journey)
|
||||
if exact_key in selected_exact:
|
||||
continue
|
||||
return False
|
||||
diversity_key = _journey_diversity_key(journey)
|
||||
if diversity_key in selected_diversity and len(selected) >= 3:
|
||||
continue
|
||||
if not force and diversity_key in selected_diversity and len(selected) >= 3:
|
||||
return False
|
||||
selected.append(journey)
|
||||
selected_exact.add(exact_key)
|
||||
selected_diversity.add(diversity_key)
|
||||
return True
|
||||
|
||||
if ranked:
|
||||
append(ranked[0], force=True)
|
||||
append(_best_public_transport_option(ranked), force=True)
|
||||
append(_least_walking_public_transport_option(ranked), force=True)
|
||||
append(_fewest_transfer_public_transport_option(ranked), force=True)
|
||||
append(_walk_only_option(ranked), force=True)
|
||||
|
||||
for journey in ranked:
|
||||
append(journey)
|
||||
if len(selected) >= limit:
|
||||
return selected
|
||||
return _ensure_walk_only_option(selected[:limit], ranked, limit=limit)
|
||||
if len(selected) >= min(3, limit):
|
||||
return selected
|
||||
for journey in journeys:
|
||||
exact_key = _journey_key(journey)
|
||||
if exact_key in selected_exact:
|
||||
continue
|
||||
selected.append(journey)
|
||||
selected_exact.add(exact_key)
|
||||
for journey in ranked:
|
||||
append(journey, force=True)
|
||||
if len(selected) >= min(3, limit):
|
||||
break
|
||||
return _ensure_walk_only_option(selected, journeys, limit=limit)
|
||||
return _ensure_walk_only_option(selected[:limit], ranked, limit=limit)
|
||||
|
||||
|
||||
def _ensure_walk_only_option(selected: list[dict], ranked: list[dict], *, limit: int) -> list[dict]:
|
||||
@@ -673,6 +738,95 @@ def _journey_is_walk_only(journey: dict) -> bool:
|
||||
return bool(legs) and all(leg.get("mode") == "walk" for leg in legs)
|
||||
|
||||
|
||||
def _walk_only_option(journeys: list[dict]) -> dict | None:
|
||||
return next((journey for journey in journeys if _journey_is_walk_only(journey)), None)
|
||||
|
||||
|
||||
def _journey_has_public_transport(journey: dict) -> bool:
|
||||
return any(leg.get("mode") != "walk" for leg in journey.get("legs") or [])
|
||||
|
||||
|
||||
def _best_public_transport_option(journeys: list[dict]) -> dict | None:
|
||||
return next((journey for journey in journeys if _journey_has_public_transport(journey)), None)
|
||||
|
||||
|
||||
def _least_walking_public_transport_option(journeys: list[dict]) -> dict | None:
|
||||
public = [journey for journey in journeys if _journey_has_public_transport(journey)]
|
||||
if not public:
|
||||
return None
|
||||
fastest = public[0]
|
||||
lowest = min(public, key=lambda journey: (_journey_walking_m(journey), _journey_arrival_seconds(journey), _journey_duration_seconds(journey)))
|
||||
if lowest is fastest:
|
||||
return lowest
|
||||
fastest_walking = _journey_walking_m(fastest)
|
||||
lowest_walking = _journey_walking_m(lowest)
|
||||
walking_saved = fastest_walking - lowest_walking
|
||||
if lowest_walking <= LOW_WALK_GOOD_ENOUGH_M:
|
||||
return lowest
|
||||
if walking_saved < LOW_WALK_MIN_SAVINGS_M:
|
||||
return None
|
||||
if fastest_walking > 0 and walking_saved / fastest_walking < LOW_WALK_MIN_RELATIVE_SAVING:
|
||||
return None
|
||||
extra_seconds = _journey_arrival_seconds(lowest) - _journey_arrival_seconds(fastest)
|
||||
if extra_seconds > LOW_WALK_MAX_EXTRA_SECONDS:
|
||||
return None
|
||||
return lowest
|
||||
|
||||
|
||||
def _fewest_transfer_public_transport_option(journeys: list[dict]) -> dict | None:
|
||||
public = [journey for journey in journeys if _journey_has_public_transport(journey)]
|
||||
if not public:
|
||||
return None
|
||||
return min(
|
||||
public,
|
||||
key=lambda journey: (
|
||||
int(journey.get("transfers") or 0),
|
||||
_journey_walking_m(journey),
|
||||
_journey_arrival_seconds(journey),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _journey_walking_m(journey: dict) -> float:
|
||||
return sum(float(leg.get("distance_m") or 0) for leg in journey.get("legs") or [] if leg.get("mode") == "walk")
|
||||
|
||||
|
||||
def _journey_arrival_seconds(journey: dict) -> float:
|
||||
value = journey.get("arrival_seconds")
|
||||
return float("inf") if value is None else float(value)
|
||||
|
||||
|
||||
def _journey_duration_seconds(journey: dict) -> float:
|
||||
value = journey.get("duration_seconds")
|
||||
if value is not None:
|
||||
return float(value)
|
||||
minutes = journey.get("duration_minutes")
|
||||
return float("inf") if minutes is None else float(minutes) * 60
|
||||
|
||||
|
||||
def _has_sufficient_progressive_options(journeys: list[dict], *, transfers: int, limit: int) -> bool:
|
||||
if transfers < 2 or len(journeys) < min(3, limit):
|
||||
return False
|
||||
public = [journey for journey in journeys if _journey_has_public_transport(journey)]
|
||||
if not public:
|
||||
return False
|
||||
low_walk = _least_walking_public_transport_option(public)
|
||||
if low_walk is None:
|
||||
return False
|
||||
return _journey_walking_m(low_walk) <= max(LOW_WALK_GOOD_ENOUGH_M * 2, _journey_walking_m(public[0]) * 0.75)
|
||||
|
||||
|
||||
def _is_statement_timeout_error(exc: Exception) -> bool:
|
||||
text = " ".join(str(part).lower() for part in [exc, getattr(exc, "orig", "")])
|
||||
return "statement timeout" in text or "canceling statement due to statement timeout" in text or "querycanceled" in text
|
||||
|
||||
|
||||
def _short_error_message(exc: Exception) -> str:
|
||||
text = " ".join(str(part) for part in [getattr(exc, "orig", ""), exc] if part)
|
||||
text = " ".join(text.split())
|
||||
return text[:240] if text else exc.__class__.__name__
|
||||
|
||||
|
||||
def _journey_diversity_key(journey: dict[str, Any]) -> tuple[object, ...]:
|
||||
route_signature = tuple(
|
||||
str(leg.get("route_ref") or leg.get("route_id") or leg.get("mode") or "")
|
||||
|
||||
Reference in New Issue
Block a user