Next alpha stage commit

This commit is contained in:
2026-07-06 20:54:46 +02:00
parent e23387738b
commit 1d7ec956d2
42 changed files with 11603 additions and 2837 deletions

View File

@@ -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 "")