Next alpha stage commit
This commit is contained in:
@@ -227,7 +227,9 @@ def discover_gtfs_sources_command(
|
||||
no_mobility_database: bool = typer.Option(False, help="Skip Mobility Database feeds_v2.csv"),
|
||||
no_acceptance_test_list: bool = typer.Option(False, help="Skip MobilityData validator acceptance-test feed list"),
|
||||
no_ptna: bool = typer.Option(False, help="Skip PTNA GTFS analysis pages"),
|
||||
no_opendata_oepnv: bool = typer.Option(False, help="Skip OpenData ÖPNV Germany dataset catalog"),
|
||||
max_ptna_details: int = typer.Option(80, help="Maximum PTNA detail pages to fetch for license/crosswalk metadata"),
|
||||
max_opendata_oepnv_details: int = typer.Option(80, help="Maximum OpenData ÖPNV detail pages to fetch"),
|
||||
test_limit: int = typer.Option(24, help="Rows to write to the focused test-run ingestable CSV"),
|
||||
check_urls: bool = typer.Option(False, help="Run HEAD/range checks for ingestable feed URLs"),
|
||||
) -> None:
|
||||
@@ -237,7 +239,9 @@ def discover_gtfs_sources_command(
|
||||
include_mobility_database=not no_mobility_database,
|
||||
include_acceptance_test_list=not no_acceptance_test_list,
|
||||
include_ptna=not no_ptna,
|
||||
include_opendata_oepnv=not no_opendata_oepnv,
|
||||
max_ptna_details=max_ptna_details,
|
||||
max_opendata_oepnv_details=max_opendata_oepnv_details,
|
||||
test_limit=test_limit,
|
||||
check_urls=check_urls,
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ class Settings(BaseSettings):
|
||||
# Keep supervised workers alive across API server restarts. Stale workers are
|
||||
# detected by PID files at the next startup; stale job leases are requeued.
|
||||
queue_worker_stop_on_shutdown: bool = False
|
||||
journey_search_worker_count: int = 4
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
|
||||
+112
-4
@@ -13,6 +13,8 @@ from app.models import (
|
||||
GtfsAgency,
|
||||
GtfsCalendar,
|
||||
GtfsCalendarDate,
|
||||
GtfsHarmonizedSnapshotDataset,
|
||||
GtfsHarmonizedSnapshotRoute,
|
||||
GtfsRoute,
|
||||
GtfsRoutePatternLink,
|
||||
GtfsShape,
|
||||
@@ -20,6 +22,8 @@ from app.models import (
|
||||
GtfsStopTime,
|
||||
GtfsTripRoutePatternLink,
|
||||
GtfsTrip,
|
||||
MapGtfsDecision,
|
||||
MapGtfsReviewItem,
|
||||
OsmDiffState,
|
||||
OsmFeature,
|
||||
RouteMatch,
|
||||
@@ -136,6 +140,8 @@ def delete_source(session: Session, source_id: int) -> dict[str, object]:
|
||||
_delete_dataset_files(dataset)
|
||||
session.delete(dataset)
|
||||
session.execute(delete(OsmDiffState).where(OsmDiffState.source_id == source.id))
|
||||
session.execute(delete(MapGtfsReviewItem).where(MapGtfsReviewItem.source_id == source.id))
|
||||
session.execute(delete(MapGtfsDecision).where(MapGtfsDecision.source_id == source.id))
|
||||
session.delete(source)
|
||||
session.flush()
|
||||
return {"deleted": True, "source_id": source_id, "datasets": dataset_results}
|
||||
@@ -217,14 +223,41 @@ def prune_inactive_datasets(session: Session, dry_run: bool = True) -> dict[str,
|
||||
|
||||
for dataset_id in dataset_ids:
|
||||
_detach_update_checks_for_dataset(session, dataset_id)
|
||||
for dataset in session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all():
|
||||
_delete_workbench_rows_for_dataset(session, dataset)
|
||||
if match_filters:
|
||||
session.execute(delete(RouteMatch).where(or_(*match_filters)))
|
||||
if gtfs_ids:
|
||||
route_ids = select(GtfsRoute.id).where(GtfsRoute.dataset_id.in_(gtfs_ids))
|
||||
pattern_ids = select(RoutePattern.id).where(RoutePattern.gtfs_route_id.in_(route_ids))
|
||||
session.execute(
|
||||
delete(GtfsHarmonizedSnapshotRoute).where(
|
||||
or_(
|
||||
GtfsHarmonizedSnapshotRoute.dataset_id.in_(gtfs_ids),
|
||||
GtfsHarmonizedSnapshotRoute.shadowed_by_dataset_id.in_(gtfs_ids),
|
||||
GtfsHarmonizedSnapshotRoute.gtfs_route_id.in_(route_ids),
|
||||
GtfsHarmonizedSnapshotRoute.shadowed_by_gtfs_route_id.in_(route_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(GtfsHarmonizedSnapshotDataset).where(GtfsHarmonizedSnapshotDataset.dataset_id.in_(gtfs_ids)))
|
||||
session.execute(delete(RoutePatternStop).where(RoutePatternStop.route_pattern_id.in_(pattern_ids)))
|
||||
session.execute(delete(GtfsTripRoutePatternLink).where(GtfsTripRoutePatternLink.dataset_id.in_(gtfs_ids)))
|
||||
session.execute(delete(GtfsRoutePatternLink).where(GtfsRoutePatternLink.dataset_id.in_(gtfs_ids)))
|
||||
session.execute(
|
||||
delete(GtfsTripRoutePatternLink).where(
|
||||
or_(
|
||||
GtfsTripRoutePatternLink.dataset_id.in_(gtfs_ids),
|
||||
GtfsTripRoutePatternLink.route_pattern_id.in_(pattern_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
delete(GtfsRoutePatternLink).where(
|
||||
or_(
|
||||
GtfsRoutePatternLink.dataset_id.in_(gtfs_ids),
|
||||
GtfsRoutePatternLink.route_pattern_id.in_(pattern_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(RoutePattern).where(RoutePattern.gtfs_route_id.in_(route_ids)))
|
||||
session.execute(delete(CanonicalStopLink).where(CanonicalStopLink.dataset_id.in_(gtfs_ids), CanonicalStopLink.object_type == "gtfs_stop"))
|
||||
for model in [GtfsStopTime, GtfsShape, GtfsTrip, GtfsCalendarDate, GtfsCalendar, GtfsRoute, GtfsStop, GtfsAgency]:
|
||||
@@ -246,13 +279,39 @@ def prune_inactive_datasets(session: Session, dry_run: bool = True) -> dict[str,
|
||||
|
||||
|
||||
def _delete_dataset_rows(session: Session, dataset: Dataset) -> None:
|
||||
_delete_workbench_rows_for_dataset(session, dataset)
|
||||
if dataset.kind == "gtfs":
|
||||
route_ids = select(GtfsRoute.id).where(GtfsRoute.dataset_id == dataset.id)
|
||||
pattern_ids = select(RoutePattern.id).where(RoutePattern.gtfs_route_id.in_(route_ids))
|
||||
session.execute(
|
||||
delete(GtfsHarmonizedSnapshotRoute).where(
|
||||
or_(
|
||||
GtfsHarmonizedSnapshotRoute.dataset_id == dataset.id,
|
||||
GtfsHarmonizedSnapshotRoute.shadowed_by_dataset_id == dataset.id,
|
||||
GtfsHarmonizedSnapshotRoute.gtfs_route_id.in_(route_ids),
|
||||
GtfsHarmonizedSnapshotRoute.shadowed_by_gtfs_route_id.in_(route_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(GtfsHarmonizedSnapshotDataset).where(GtfsHarmonizedSnapshotDataset.dataset_id == dataset.id))
|
||||
session.execute(delete(RouteMatch).where(RouteMatch.gtfs_route_id.in_(route_ids)))
|
||||
session.execute(delete(RoutePatternStop).where(RoutePatternStop.route_pattern_id.in_(pattern_ids)))
|
||||
session.execute(delete(GtfsTripRoutePatternLink).where(GtfsTripRoutePatternLink.dataset_id == dataset.id))
|
||||
session.execute(delete(GtfsRoutePatternLink).where(GtfsRoutePatternLink.dataset_id == dataset.id))
|
||||
session.execute(
|
||||
delete(GtfsTripRoutePatternLink).where(
|
||||
or_(
|
||||
GtfsTripRoutePatternLink.dataset_id == dataset.id,
|
||||
GtfsTripRoutePatternLink.route_pattern_id.in_(pattern_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
delete(GtfsRoutePatternLink).where(
|
||||
or_(
|
||||
GtfsRoutePatternLink.dataset_id == dataset.id,
|
||||
GtfsRoutePatternLink.route_pattern_id.in_(pattern_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(RoutePattern).where(RoutePattern.gtfs_route_id.in_(route_ids)))
|
||||
session.execute(delete(CanonicalStopLink).where(CanonicalStopLink.dataset_id == dataset.id, CanonicalStopLink.object_type == "gtfs_stop"))
|
||||
for model in [GtfsStopTime, GtfsShape, GtfsTrip, GtfsCalendarDate, GtfsCalendar, GtfsRoute, GtfsStop, GtfsAgency]:
|
||||
@@ -269,6 +328,55 @@ def _delete_dataset_rows(session: Session, dataset: Dataset) -> None:
|
||||
session.execute(delete(OsmFeature).where(OsmFeature.dataset_id == dataset.id))
|
||||
|
||||
|
||||
def _delete_workbench_rows_for_dataset(session: Session, dataset: Dataset) -> None:
|
||||
review_filters = [MapGtfsReviewItem.dataset_id == dataset.id]
|
||||
decision_filters = [
|
||||
MapGtfsDecision.gtfs_dataset_id == dataset.id,
|
||||
MapGtfsDecision.osm_dataset_id == dataset.id,
|
||||
]
|
||||
if dataset.kind == "gtfs":
|
||||
route_ids = select(GtfsRoute.id).where(GtfsRoute.dataset_id == dataset.id)
|
||||
stop_ids = select(GtfsStop.id).where(GtfsStop.dataset_id == dataset.id)
|
||||
match_ids = select(RouteMatch.id).where(RouteMatch.gtfs_route_id.in_(route_ids))
|
||||
link_ids = select(CanonicalStopLink.id).where(CanonicalStopLink.dataset_id == dataset.id, CanonicalStopLink.object_type == "gtfs_stop")
|
||||
review_filters.extend(
|
||||
[
|
||||
MapGtfsReviewItem.gtfs_route_id.in_(route_ids),
|
||||
MapGtfsReviewItem.gtfs_stop_id.in_(stop_ids),
|
||||
MapGtfsReviewItem.route_match_id.in_(match_ids),
|
||||
MapGtfsReviewItem.canonical_stop_link_id.in_(link_ids),
|
||||
]
|
||||
)
|
||||
decision_filters.extend(
|
||||
[
|
||||
MapGtfsDecision.gtfs_route_id.in_(route_ids),
|
||||
MapGtfsDecision.gtfs_stop_id.in_(stop_ids),
|
||||
MapGtfsDecision.route_match_id.in_(match_ids),
|
||||
MapGtfsDecision.canonical_stop_link_id.in_(link_ids),
|
||||
]
|
||||
)
|
||||
elif dataset.kind == "osm_geojson":
|
||||
osm_feature_ids = select(OsmFeature.id).where(OsmFeature.dataset_id == dataset.id)
|
||||
match_ids = select(RouteMatch.id).where(RouteMatch.osm_feature_id.in_(osm_feature_ids))
|
||||
link_ids = select(CanonicalStopLink.id).where(CanonicalStopLink.dataset_id == dataset.id, CanonicalStopLink.object_type == "osm_feature")
|
||||
review_filters.extend(
|
||||
[
|
||||
MapGtfsReviewItem.osm_feature_id.in_(osm_feature_ids),
|
||||
MapGtfsReviewItem.route_match_id.in_(match_ids),
|
||||
MapGtfsReviewItem.canonical_stop_link_id.in_(link_ids),
|
||||
]
|
||||
)
|
||||
decision_filters.extend(
|
||||
[
|
||||
MapGtfsDecision.osm_feature_id.in_(osm_feature_ids),
|
||||
MapGtfsDecision.route_match_id.in_(match_ids),
|
||||
MapGtfsDecision.canonical_stop_link_id.in_(link_ids),
|
||||
]
|
||||
)
|
||||
session.execute(delete(MapGtfsReviewItem).where(or_(*review_filters)))
|
||||
session.execute(delete(MapGtfsDecision).where(or_(*decision_filters)))
|
||||
|
||||
|
||||
def _delete_dataset_files(dataset: Dataset) -> None:
|
||||
for path in dataset_sidecar_paths(dataset):
|
||||
try:
|
||||
|
||||
@@ -184,6 +184,16 @@ def _ensure_runtime_indexes() -> None:
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_shapes_dataset_shape ON gtfs_shapes (dataset_id, shape_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_calendars_dataset_service_dates ON gtfs_calendars (dataset_id, service_id, start_date, end_date)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_calendar_dates_dataset_date ON gtfs_calendar_dates (dataset_id, date, service_id, exception_type)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshots_status_created ON gtfs_harmonized_snapshots (status, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshot_datasets_role ON gtfs_harmonized_snapshot_datasets (snapshot_id, role, dataset_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshot_datasets_source ON gtfs_harmonized_snapshot_datasets (source_id, role)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshot_routes_role ON gtfs_harmonized_snapshot_routes (snapshot_id, role, dataset_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshot_routes_overlap ON gtfs_harmonized_snapshot_routes (snapshot_id, overlap_key, role)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_harmonized_snapshot_routes_shadow ON gtfs_harmonized_snapshot_routes (snapshot_id, shadowed_by_gtfs_route_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_feed_diff_runs_source_created ON gtfs_feed_diff_runs (source_id, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_feed_diff_runs_new_dataset ON gtfs_feed_diff_runs (new_dataset_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_feed_diff_items_run_type ON gtfs_feed_diff_items (diff_run_id, item_type, change_type, severity)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_gtfs_feed_diff_items_source_status ON gtfs_feed_diff_items (source_id, status, severity)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_canonical_stop_links_object ON canonical_stop_links (object_type, dataset_id, object_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_canonical_stop_links_external ON canonical_stop_links (object_type, dataset_id, external_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_route_patterns_ref_mode ON route_patterns (route_ref, mode, source_kind)",
|
||||
@@ -210,6 +220,13 @@ def _ensure_runtime_indexes() -> None:
|
||||
"CREATE INDEX IF NOT EXISTS ix_pipeline_runs_stage_source_hash ON pipeline_runs (stage, source_id, dependency_hash, status, started_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_pipeline_runs_job ON pipeline_runs (job_id, stage, status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_match_rules_type_active ON match_rules (rule_type, active)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_decisions_type_status ON map_gtfs_decisions (decision_type, status, active)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_decisions_route ON map_gtfs_decisions (gtfs_route_id, status, active)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_decisions_stop ON map_gtfs_decisions (gtfs_stop_id, status, active)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_review_items_queue_status ON map_gtfs_review_items (queue, status, severity, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_review_items_dataset_status ON map_gtfs_review_items (dataset_id, status, queue)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_review_items_route ON map_gtfs_review_items (gtfs_route_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_map_gtfs_review_items_stop ON map_gtfs_review_items (gtfs_stop_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_journey_search_cache_type_expires ON journey_search_cache (cache_type, expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_travel_requests_created ON travel_requests (created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_itineraries_request_saved ON itineraries (request_id, saved, created_at)",
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Dataset, GtfsFeedDiffItem, GtfsRoute, GtfsStop, MapGtfsDecision, OsmFeature
|
||||
from app.workbench import _upsert_review_item
|
||||
|
||||
|
||||
def capture_gtfs_decision_replay(session: Session, previous_dataset: Dataset | None, *, diff_run_id: int | None = None) -> dict[str, Any]:
|
||||
if previous_dataset is None or previous_dataset.kind != "gtfs":
|
||||
return {"previous_dataset_id": None, "diff_run_id": diff_run_id, "decisions": []}
|
||||
route_by_id = {
|
||||
int(route.id): route
|
||||
for route in session.scalars(select(GtfsRoute).where(GtfsRoute.dataset_id == previous_dataset.id)).all()
|
||||
}
|
||||
stop_by_id = {
|
||||
int(stop.id): stop
|
||||
for stop in session.scalars(select(GtfsStop).where(GtfsStop.dataset_id == previous_dataset.id)).all()
|
||||
}
|
||||
route_ids = list(route_by_id)
|
||||
stop_ids = list(stop_by_id)
|
||||
filters = [MapGtfsDecision.gtfs_dataset_id == previous_dataset.id]
|
||||
if route_ids:
|
||||
filters.append(MapGtfsDecision.gtfs_route_id.in_(route_ids))
|
||||
if stop_ids:
|
||||
filters.append(MapGtfsDecision.gtfs_stop_id.in_(stop_ids))
|
||||
decisions = session.scalars(
|
||||
select(MapGtfsDecision)
|
||||
.where(MapGtfsDecision.active.is_(True), or_(*filters))
|
||||
.order_by(MapGtfsDecision.updated_at.desc(), MapGtfsDecision.id.desc())
|
||||
).all()
|
||||
captured: list[dict[str, Any]] = []
|
||||
for decision in decisions:
|
||||
object_type = None
|
||||
object_key = None
|
||||
route = route_by_id.get(int(decision.gtfs_route_id or 0))
|
||||
stop = stop_by_id.get(int(decision.gtfs_stop_id or 0))
|
||||
if route is not None:
|
||||
object_type = "route"
|
||||
object_key = route.route_id
|
||||
elif stop is not None:
|
||||
object_type = "stop"
|
||||
object_key = stop.stop_id
|
||||
if not object_type or not object_key:
|
||||
continue
|
||||
captured.append(
|
||||
{
|
||||
"decision_id": decision.id,
|
||||
"decision_key": decision.decision_key,
|
||||
"decision_type": decision.decision_type,
|
||||
"status": decision.status,
|
||||
"source_id": decision.source_id,
|
||||
"gtfs_dataset_id": decision.gtfs_dataset_id,
|
||||
"object_type": object_type,
|
||||
"object_key": object_key,
|
||||
"selector": _json_object(decision.selector_json),
|
||||
"action": _json_object(decision.action_json),
|
||||
"note": decision.note,
|
||||
"reviewer": decision.reviewer,
|
||||
"confidence": decision.confidence,
|
||||
"osm_dataset_id": decision.osm_dataset_id,
|
||||
"osm_feature_id": decision.osm_feature_id,
|
||||
"metadata": _json_object(decision.metadata_json),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"previous_dataset_id": previous_dataset.id,
|
||||
"source_id": previous_dataset.source_id,
|
||||
"diff_run_id": diff_run_id,
|
||||
"decisions": captured,
|
||||
}
|
||||
|
||||
|
||||
def apply_gtfs_decision_replay(
|
||||
session: Session,
|
||||
replay_plan: dict[str, Any] | None,
|
||||
*,
|
||||
new_dataset: Dataset,
|
||||
diff_run_id: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
if not replay_plan:
|
||||
return {"captured": 0, "replayed": 0, "skipped": 0, "review_items": 0}
|
||||
decisions = replay_plan.get("decisions") if isinstance(replay_plan.get("decisions"), list) else []
|
||||
if not decisions and diff_run_id is None:
|
||||
return {"captured": 0, "replayed": 0, "skipped": 0, "review_items": 0}
|
||||
|
||||
current_diff_run_id = diff_run_id or _optional_int(replay_plan.get("diff_run_id"))
|
||||
changed_keys = _changed_or_removed_keys(session, current_diff_run_id)
|
||||
routes = {
|
||||
route.route_id: route
|
||||
for route in session.scalars(select(GtfsRoute).where(GtfsRoute.dataset_id == new_dataset.id)).all()
|
||||
}
|
||||
previous_decisions_by_key: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
replayed = 0
|
||||
skipped = 0
|
||||
for decision in decisions:
|
||||
object_type = str(decision.get("object_type") or "")
|
||||
object_key = str(decision.get("object_key") or "")
|
||||
if not object_type or not object_key:
|
||||
skipped += 1
|
||||
continue
|
||||
previous_decisions_by_key.setdefault((object_type, object_key), []).append(decision)
|
||||
if (object_type, object_key) in changed_keys:
|
||||
skipped += 1
|
||||
continue
|
||||
if object_type != "route":
|
||||
# Stop-level operational replay is handled by durable canonical-stop
|
||||
# MatchRule rows during route-layer rebuild. Keep this audit layer
|
||||
# route-focused to avoid stale canonical-stop foreign keys.
|
||||
skipped += 1
|
||||
continue
|
||||
route = routes.get(object_key)
|
||||
if route is None:
|
||||
skipped += 1
|
||||
continue
|
||||
if _replay_route_decision(session, decision, new_dataset=new_dataset, route=route):
|
||||
replayed += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
review_items = create_gtfs_update_decision_review_items(
|
||||
session,
|
||||
diff_run_id=current_diff_run_id,
|
||||
new_dataset=new_dataset,
|
||||
previous_decisions_by_key=previous_decisions_by_key,
|
||||
)
|
||||
session.flush()
|
||||
return {"captured": len(decisions), "replayed": replayed, "skipped": skipped, "review_items": review_items}
|
||||
|
||||
|
||||
def create_gtfs_update_decision_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
diff_run_id: int | None,
|
||||
new_dataset: Dataset,
|
||||
previous_decisions_by_key: dict[tuple[str, str], list[dict[str, Any]]] | None = None,
|
||||
) -> int:
|
||||
if diff_run_id is None:
|
||||
return 0
|
||||
previous_decisions_by_key = previous_decisions_by_key or {}
|
||||
routes = {
|
||||
route.route_id: route
|
||||
for route in session.scalars(select(GtfsRoute).where(GtfsRoute.dataset_id == new_dataset.id)).all()
|
||||
}
|
||||
stops = {
|
||||
stop.stop_id: stop
|
||||
for stop in session.scalars(select(GtfsStop).where(GtfsStop.dataset_id == new_dataset.id)).all()
|
||||
}
|
||||
items = session.scalars(
|
||||
select(GtfsFeedDiffItem)
|
||||
.where(GtfsFeedDiffItem.diff_run_id == diff_run_id, GtfsFeedDiffItem.change_type.in_(["changed", "removed"]))
|
||||
.order_by(GtfsFeedDiffItem.item_type, GtfsFeedDiffItem.change_type, GtfsFeedDiffItem.object_key)
|
||||
).all()
|
||||
created_or_updated = 0
|
||||
for item in items:
|
||||
object_type = "route" if item.item_type == "route" else "stop" if item.item_type == "stop" else str(item.item_type)
|
||||
object_key = str(item.object_key)
|
||||
previous_decisions = previous_decisions_by_key.get((object_type, object_key), [])
|
||||
severity = "error" if previous_decisions and item.change_type == "removed" else "warn"
|
||||
route = routes.get(object_key) if object_type == "route" and item.change_type != "removed" else None
|
||||
stop = stops.get(object_key) if object_type == "stop" and item.change_type != "removed" else None
|
||||
title = _diff_review_title(item, previous_decision_count=len(previous_decisions))
|
||||
metadata = {
|
||||
"diff_run_id": diff_run_id,
|
||||
"item_id": item.id,
|
||||
"item_type": item.item_type,
|
||||
"object_key": object_key,
|
||||
"change_type": item.change_type,
|
||||
"old": _json_object(item.old_payload_json),
|
||||
"new": _json_object(item.new_payload_json),
|
||||
"diff": _json_object(item.diff_json),
|
||||
"previous_decisions": previous_decisions,
|
||||
}
|
||||
result = _upsert_review_item(
|
||||
session,
|
||||
fingerprint=f"gtfs-update-diff:{diff_run_id}:{item.item_type}:{item.object_key}:{item.change_type}",
|
||||
queue="gtfs_update_diff",
|
||||
item_type=f"gtfs_{item.item_type}_update",
|
||||
severity=severity,
|
||||
title=title,
|
||||
description=_diff_review_description(item, len(previous_decisions)),
|
||||
source_id=new_dataset.source_id,
|
||||
dataset_id=new_dataset.id,
|
||||
gtfs_route_id=None if route is None else route.id,
|
||||
gtfs_stop_id=None if stop is None else stop.id,
|
||||
evidence_payload=metadata,
|
||||
)
|
||||
if result in {"created", "updated", "reopened"}:
|
||||
created_or_updated += 1
|
||||
return created_or_updated
|
||||
|
||||
|
||||
def _replay_route_decision(session: Session, decision: dict[str, Any], *, new_dataset: Dataset, route: GtfsRoute) -> bool:
|
||||
selector = _updated_route_selector(decision.get("selector"), new_dataset=new_dataset, route=route)
|
||||
action = decision.get("action") if isinstance(decision.get("action"), dict) else {}
|
||||
evidence = {
|
||||
"selector": selector,
|
||||
"action": action,
|
||||
"confidence": decision.get("confidence"),
|
||||
"replayed_from_decision_id": decision.get("decision_id"),
|
||||
}
|
||||
decision_key = _stable_key(str(decision.get("decision_type") or "route_osm_relation"), selector, action)
|
||||
existing = session.scalar(select(MapGtfsDecision).where(MapGtfsDecision.decision_key == decision_key))
|
||||
now = datetime.now(timezone.utc)
|
||||
osm_dataset_id, osm_feature_id = _existing_osm_reference(session, decision)
|
||||
metadata = decision.get("metadata") if isinstance(decision.get("metadata"), dict) else {}
|
||||
replay_metadata = {
|
||||
**metadata,
|
||||
"replay": {
|
||||
"from_decision_id": decision.get("decision_id"),
|
||||
"from_decision_key": decision.get("decision_key"),
|
||||
"previous_dataset_id": decision.get("gtfs_dataset_id"),
|
||||
"new_dataset_id": new_dataset.id,
|
||||
"object_key": route.route_id,
|
||||
},
|
||||
}
|
||||
if existing is None:
|
||||
session.add(
|
||||
MapGtfsDecision(
|
||||
decision_key=decision_key,
|
||||
decision_type=str(decision.get("decision_type") or "route_osm_relation"),
|
||||
status=str(decision.get("status") or "accepted"),
|
||||
active=True,
|
||||
source_id=new_dataset.source_id,
|
||||
gtfs_dataset_id=new_dataset.id,
|
||||
gtfs_route_id=route.id,
|
||||
osm_dataset_id=osm_dataset_id,
|
||||
osm_feature_id=osm_feature_id,
|
||||
route_match_id=None,
|
||||
confidence=_optional_float(decision.get("confidence")),
|
||||
evidence_hash=_hash_payload(evidence),
|
||||
selector_json=_json_dumps(selector),
|
||||
action_json=_json_dumps(action),
|
||||
note=decision.get("note"),
|
||||
reviewer="decision_replay",
|
||||
metadata_json=_json_dumps(replay_metadata),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
return True
|
||||
existing.status = str(decision.get("status") or existing.status)
|
||||
existing.active = True
|
||||
existing.source_id = new_dataset.source_id
|
||||
existing.gtfs_dataset_id = new_dataset.id
|
||||
existing.gtfs_route_id = route.id
|
||||
existing.osm_dataset_id = osm_dataset_id
|
||||
existing.osm_feature_id = osm_feature_id
|
||||
existing.route_match_id = None
|
||||
existing.confidence = _optional_float(decision.get("confidence"))
|
||||
existing.evidence_hash = _hash_payload(evidence)
|
||||
existing.selector_json = _json_dumps(selector)
|
||||
existing.action_json = _json_dumps(action)
|
||||
existing.reviewer = "decision_replay"
|
||||
existing.metadata_json = _json_dumps(replay_metadata)
|
||||
existing.updated_at = now
|
||||
return True
|
||||
|
||||
|
||||
def _updated_route_selector(value: object, *, new_dataset: Dataset, route: GtfsRoute) -> dict[str, Any]:
|
||||
selector = dict(value) if isinstance(value, dict) else {}
|
||||
selector["gtfs"] = {
|
||||
"source_id": new_dataset.source_id,
|
||||
"dataset_id": new_dataset.id,
|
||||
"route_id": route.route_id,
|
||||
"route_key": route.route_key,
|
||||
"ref": route.short_name,
|
||||
"mode": route.mode,
|
||||
}
|
||||
selector.pop("route_match_id", None)
|
||||
selector.pop("gtfs_route_id", None)
|
||||
return selector
|
||||
|
||||
|
||||
def _existing_osm_reference(session: Session, decision: dict[str, Any]) -> tuple[int | None, int | None]:
|
||||
dataset_id = _optional_int(decision.get("osm_dataset_id"))
|
||||
feature_id = _optional_int(decision.get("osm_feature_id"))
|
||||
feature = session.get(OsmFeature, feature_id) if feature_id is not None else None
|
||||
if feature is not None:
|
||||
return feature.dataset_id, feature.id
|
||||
if dataset_id is not None and session.get(Dataset, dataset_id) is not None:
|
||||
return dataset_id, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _changed_or_removed_keys(session: Session, diff_run_id: int | None) -> set[tuple[str, str]]:
|
||||
if diff_run_id is None:
|
||||
return set()
|
||||
rows = session.execute(
|
||||
select(GtfsFeedDiffItem.item_type, GtfsFeedDiffItem.object_key)
|
||||
.where(GtfsFeedDiffItem.diff_run_id == diff_run_id, GtfsFeedDiffItem.change_type.in_(["changed", "removed"]))
|
||||
).all()
|
||||
return {
|
||||
("route" if item_type == "route" else "stop" if item_type == "stop" else str(item_type), str(object_key))
|
||||
for item_type, object_key in rows
|
||||
}
|
||||
|
||||
|
||||
def _diff_review_title(item: GtfsFeedDiffItem, *, previous_decision_count: int) -> str:
|
||||
prefix = "Recheck previous decision for" if previous_decision_count else "Review updated"
|
||||
return f"{prefix} {item.item_type} {item.object_key}: {item.change_type}"
|
||||
|
||||
|
||||
def _diff_review_description(item: GtfsFeedDiffItem, previous_decision_count: int) -> str:
|
||||
if previous_decision_count:
|
||||
return f"{item.item_type.title()} changed after {previous_decision_count} previous decision(s); manual review is needed before replay."
|
||||
return f"{item.item_type.title()} was {item.change_type} in the latest feed update."
|
||||
|
||||
|
||||
def _stable_key(decision_type: str, selector: dict[str, Any], action: dict[str, Any]) -> str:
|
||||
payload = {"decision_type": decision_type, "selector": selector, "action": action}
|
||||
return f"{decision_type}:{_hash_payload(payload)[:40]}"
|
||||
|
||||
|
||||
def _hash_payload(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _json_object(value: str | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _json_dumps(value: dict[str, Any]) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _optional_int(value: object) -> int | None:
|
||||
try:
|
||||
return None if value is None else int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_float(value: object) -> float | None:
|
||||
try:
|
||||
return None if value is None else float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -22,6 +22,7 @@ MOBILITY_DATABASE_ACCEPTANCE_TEST_URL = (
|
||||
)
|
||||
PTNA_GTFS_INDEX_URL = "https://ptna.openstreetmap.de/gtfs/index.html"
|
||||
PTNA_COUNTRY_URL_TEMPLATE = "https://ptna.openstreetmap.de/gtfs/{country}/index.php"
|
||||
OPENDATA_OEPNV_DATASETS_URL = "https://www.opendata-oepnv.de/ht/de/datensaetze"
|
||||
|
||||
DEFAULT_DISCOVERY_COUNTRIES = ["DE", "AT", "CH", "NL", "DK", "FR", "BE", "LU", "NO", "SE", "FI", "IE", "GB"]
|
||||
CURATED_TEST_COUNTRIES = ["DE", "CH", "AT", "NL", "DK", "FI", "NO", "SE", "IE", "GB", "FR", "BE", "LU"]
|
||||
@@ -33,6 +34,11 @@ CANONICAL_HEADERS = [
|
||||
"subdivision",
|
||||
"provider",
|
||||
"feed_name",
|
||||
"authority_level",
|
||||
"coverage_scope",
|
||||
"overlap_role",
|
||||
"import_policy",
|
||||
"overlap_group",
|
||||
"stable_id",
|
||||
"ptna_feed_id",
|
||||
"data_type",
|
||||
@@ -71,6 +77,11 @@ class FeedCandidate:
|
||||
subdivision: str = ""
|
||||
provider: str = ""
|
||||
feed_name: str = ""
|
||||
authority_level: str = ""
|
||||
coverage_scope: str = ""
|
||||
overlap_role: str = ""
|
||||
import_policy: str = ""
|
||||
overlap_group: str = ""
|
||||
stable_id: str = ""
|
||||
ptna_feed_id: str = ""
|
||||
data_type: str = "gtfs"
|
||||
@@ -142,6 +153,11 @@ class FeedCandidate:
|
||||
notes = _join_notes(notes, f"Mobility Database mirror: {self.latest_url}")
|
||||
if self.osm_license_text:
|
||||
notes = _join_notes(notes, f"OSM permission note: {_truncate(self.osm_license_text, 240)}")
|
||||
if self.import_policy or self.overlap_role:
|
||||
notes = _join_notes(
|
||||
notes,
|
||||
f"Import policy: {self.import_policy or 'review'}; overlap role: {self.overlap_role or 'review'}.",
|
||||
)
|
||||
return {
|
||||
"name": _truncate(name, 240),
|
||||
"kind": "gtfs",
|
||||
@@ -166,7 +182,9 @@ def build_gtfs_discovery_manifests(
|
||||
include_mobility_database: bool = True,
|
||||
include_acceptance_test_list: bool = True,
|
||||
include_ptna: bool = True,
|
||||
include_opendata_oepnv: bool = True,
|
||||
max_ptna_details: int = 80,
|
||||
max_opendata_oepnv_details: int = 80,
|
||||
test_limit: int = 24,
|
||||
check_urls: bool = False,
|
||||
timeout: float = 30.0,
|
||||
@@ -183,8 +201,18 @@ def build_gtfs_discovery_manifests(
|
||||
candidates.extend(fetch_mobility_acceptance_candidates(countries=selected_countries, timeout=timeout))
|
||||
if include_ptna:
|
||||
candidates.extend(fetch_ptna_candidates(countries=selected_countries, max_details=max_ptna_details, timeout=timeout))
|
||||
if include_opendata_oepnv:
|
||||
candidates.extend(
|
||||
fetch_opendata_oepnv_candidates(
|
||||
countries=selected_countries,
|
||||
max_details=max_opendata_oepnv_details,
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
|
||||
merged = merge_candidates(candidates)
|
||||
for candidate in merged:
|
||||
apply_source_hierarchy(candidate)
|
||||
ingestable = [candidate for candidate in merged if candidate.selected_url and candidate.data_type == "gtfs"]
|
||||
if check_urls:
|
||||
for candidate in ingestable:
|
||||
@@ -209,6 +237,7 @@ def build_gtfs_discovery_manifests(
|
||||
"mobility_database": MOBILITY_DATABASE_FEEDS_URL if include_mobility_database else None,
|
||||
"mobility_acceptance_test_list": MOBILITY_DATABASE_ACCEPTANCE_TEST_URL if include_acceptance_test_list else None,
|
||||
"ptna": PTNA_GTFS_INDEX_URL if include_ptna else None,
|
||||
"opendata_oepnv": OPENDATA_OEPNV_DATASETS_URL if include_opendata_oepnv else None,
|
||||
},
|
||||
"counts": {
|
||||
"candidates": len(merged),
|
||||
@@ -266,6 +295,7 @@ def fetch_mobility_database_candidates(
|
||||
)
|
||||
normalize_candidate_geography(candidate)
|
||||
apply_known_download_overrides(candidate)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidate.priority = _candidate_priority(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
@@ -303,6 +333,7 @@ def fetch_mobility_acceptance_candidates(
|
||||
)
|
||||
normalize_candidate_geography(candidate)
|
||||
apply_known_download_overrides(candidate)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
@@ -332,6 +363,7 @@ def fetch_ptna_candidates(
|
||||
detail_fetches += 1
|
||||
except requests.RequestException:
|
||||
candidate.notes = _join_notes(candidate.notes, "PTNA detail page could not be fetched during discovery.")
|
||||
apply_source_hierarchy(candidate)
|
||||
candidate.priority = _candidate_priority(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
@@ -383,6 +415,7 @@ def parse_ptna_country_page(html: str, *, country: str, page_url: str) -> list[F
|
||||
)
|
||||
normalize_candidate_geography(candidate)
|
||||
apply_known_download_overrides(candidate)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
@@ -421,6 +454,134 @@ def parse_ptna_detail_fields(html: str, page_url: str) -> dict[str, str]:
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenDataOepnvDatasetLink:
|
||||
slug: str
|
||||
url: str
|
||||
title: str = ""
|
||||
|
||||
|
||||
def fetch_opendata_oepnv_candidates(
|
||||
*,
|
||||
countries: list[str] | None = None,
|
||||
max_details: int = 80,
|
||||
timeout: float = 30.0,
|
||||
url: str = OPENDATA_OEPNV_DATASETS_URL,
|
||||
) -> list[FeedCandidate]:
|
||||
if countries and "DE" not in countries:
|
||||
return []
|
||||
try:
|
||||
html = _fetch_text(url, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
return []
|
||||
dataset_links = parse_opendata_oepnv_dataset_links(html, url)
|
||||
candidates: list[FeedCandidate] = []
|
||||
for dataset_link in dataset_links[:max_details]:
|
||||
try:
|
||||
detail_html = _fetch_text(dataset_link.url, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
candidate = FeedCandidate(
|
||||
discovery_source="opendata_oepnv",
|
||||
country="DE",
|
||||
provider=_opendata_oepnv_provider(dataset_link.url, dataset_link.slug),
|
||||
feed_name=dataset_link.title or _title_from_slug(dataset_link.slug),
|
||||
stable_id=f"opendata-oepnv:{dataset_link.slug}",
|
||||
status="detail_fetch_failed",
|
||||
details_url=dataset_link.url,
|
||||
source_basis="OpenData ÖPNV dataset catalog",
|
||||
notes="OpenData ÖPNV detail page could not be fetched during discovery.",
|
||||
priority="P4",
|
||||
)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidates.append(candidate)
|
||||
continue
|
||||
candidate = parse_opendata_oepnv_detail_page(
|
||||
detail_html,
|
||||
detail_url=dataset_link.url,
|
||||
slug=dataset_link.slug,
|
||||
title_hint=dataset_link.title,
|
||||
)
|
||||
normalize_candidate_geography(candidate)
|
||||
apply_known_download_overrides(candidate)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidate.priority = candidate.priority or _candidate_priority(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def parse_opendata_oepnv_dataset_links(html: str, page_url: str = OPENDATA_OEPNV_DATASETS_URL) -> list[OpenDataOepnvDatasetLink]:
|
||||
titles_by_slug: dict[str, str] = {}
|
||||
title_pattern = re.compile(
|
||||
r"<a\s+[^>]*href=[\"'](?P<href>[^\"']*(?:dataset_name%5D|dataset_name\])=[^\"']+)[\"'][^>]*>\s*"
|
||||
r"<span[^>]*itemprop=[\"']headline[\"'][^>]*>(?P<title>.*?)</span>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
for match in title_pattern.finditer(html):
|
||||
href = urljoin(page_url, unescape(match.group("href")))
|
||||
slug = _opendata_oepnv_dataset_slug_from_url(href)
|
||||
if slug:
|
||||
titles_by_slug[slug] = _html_text(match.group("title"))
|
||||
|
||||
by_slug: dict[str, OpenDataOepnvDatasetLink] = {}
|
||||
for link in _all_links(html, page_url):
|
||||
slug = _opendata_oepnv_dataset_slug_from_url(link)
|
||||
if not slug:
|
||||
continue
|
||||
existing = by_slug.get(slug)
|
||||
candidate = OpenDataOepnvDatasetLink(slug=slug, url=link, title=titles_by_slug.get(slug, ""))
|
||||
if existing is None or _opendata_oepnv_link_score(candidate.url) > _opendata_oepnv_link_score(existing.url):
|
||||
by_slug[slug] = candidate
|
||||
return sorted(by_slug.values(), key=lambda item: (item.title.lower(), item.slug))
|
||||
|
||||
|
||||
def parse_opendata_oepnv_detail_page(
|
||||
html: str,
|
||||
*,
|
||||
detail_url: str,
|
||||
slug: str = "",
|
||||
title_hint: str = "",
|
||||
) -> FeedCandidate:
|
||||
text = _html_text(html)
|
||||
title = _opendata_oepnv_title(html) or title_hint or _title_from_slug(slug)
|
||||
provider = _opendata_oepnv_provider(detail_url, slug)
|
||||
data_type = _opendata_oepnv_data_type(title, text)
|
||||
download_urls = _opendata_oepnv_download_urls(html, detail_url)
|
||||
selected_url = _choose_opendata_oepnv_download_url(download_urls, data_type)
|
||||
login_required = _opendata_oepnv_requires_login(text)
|
||||
license_url, license_text = _opendata_oepnv_license(html, detail_url)
|
||||
status = "active" if selected_url else "review_required"
|
||||
if login_required:
|
||||
status = "registration_required"
|
||||
notes = "Discovered from OpenData ÖPNV; review per-dataset license and authority before publication."
|
||||
if selected_url:
|
||||
notes = _join_notes(notes, "Selected the first current GTFS download URL exposed on the detail page.")
|
||||
if login_required:
|
||||
notes = _join_notes(notes, "Download requires an OpenData ÖPNV account; do not auto-import without credentials.")
|
||||
candidate = FeedCandidate(
|
||||
discovery_source="opendata_oepnv",
|
||||
country="DE",
|
||||
subdivision=_opendata_oepnv_subdivision(detail_url, slug),
|
||||
provider=provider,
|
||||
feed_name=title,
|
||||
stable_id=f"opendata-oepnv:{slug}" if slug else "",
|
||||
data_type=data_type,
|
||||
status=status,
|
||||
is_official="",
|
||||
selected_url=selected_url,
|
||||
direct_download_url=selected_url,
|
||||
original_release_url=detail_url,
|
||||
license_url=license_url,
|
||||
license_text=license_text,
|
||||
details_url=detail_url,
|
||||
features=_opendata_oepnv_features(data_type, title, text),
|
||||
priority=_opendata_oepnv_priority(provider, data_type, selected_url),
|
||||
source_basis="OpenData ÖPNV dataset catalog",
|
||||
notes=notes,
|
||||
)
|
||||
apply_source_hierarchy(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def load_curated_ingestable_seed(
|
||||
*,
|
||||
countries: list[str] | None = None,
|
||||
@@ -452,6 +613,7 @@ def load_curated_ingestable_seed(
|
||||
)
|
||||
normalize_candidate_geography(candidate)
|
||||
apply_known_download_overrides(candidate)
|
||||
apply_source_hierarchy(candidate)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
@@ -609,6 +771,275 @@ def apply_known_download_overrides(candidate: FeedCandidate) -> None:
|
||||
candidate.notes,
|
||||
"Selected Mobility Database latest.zip mirror because the catalog direct URL is known to be stale.",
|
||||
)
|
||||
if _opendata_oepnv_download_is_registration_gated(candidate):
|
||||
gated_url = candidate.selected_url or candidate.direct_download_url
|
||||
candidate.original_release_url = candidate.original_release_url or gated_url
|
||||
candidate.selected_url = ""
|
||||
candidate.direct_download_url = ""
|
||||
candidate.status = "registration_required"
|
||||
candidate.notes = _join_notes(
|
||||
candidate.notes,
|
||||
"OpenData ÖPNV marks this download as registration-required; kept as a review candidate instead of direct ingest.",
|
||||
)
|
||||
|
||||
|
||||
def apply_source_hierarchy(candidate: FeedCandidate) -> None:
|
||||
text = _hierarchy_text(candidate)
|
||||
authority_level = _infer_authority_level(candidate, text)
|
||||
coverage_scope = _infer_coverage_scope(candidate, text)
|
||||
overlap_role = _infer_overlap_role(authority_level, coverage_scope, candidate, text)
|
||||
import_policy = _infer_import_policy(authority_level, overlap_role, candidate)
|
||||
overlap_group = _infer_overlap_group(candidate, authority_level, coverage_scope, text)
|
||||
|
||||
candidate.authority_level = authority_level
|
||||
candidate.coverage_scope = coverage_scope
|
||||
candidate.overlap_role = overlap_role
|
||||
candidate.import_policy = import_policy
|
||||
candidate.overlap_group = overlap_group
|
||||
|
||||
|
||||
def _hierarchy_text(candidate: FeedCandidate) -> str:
|
||||
return " ".join(
|
||||
[
|
||||
candidate.discovery_source,
|
||||
candidate.country,
|
||||
candidate.subdivision,
|
||||
candidate.provider,
|
||||
candidate.feed_name,
|
||||
candidate.stable_id,
|
||||
candidate.ptna_feed_id,
|
||||
candidate.status,
|
||||
_url_hierarchy_text(candidate.selected_url),
|
||||
_url_hierarchy_text(candidate.direct_download_url),
|
||||
_url_hierarchy_text(candidate.latest_url),
|
||||
_url_hierarchy_text(candidate.original_release_url),
|
||||
_url_hierarchy_text(candidate.details_url),
|
||||
candidate.source_basis,
|
||||
candidate.notes,
|
||||
]
|
||||
).lower()
|
||||
|
||||
|
||||
def _infer_authority_level(candidate: FeedCandidate, text: str) -> str:
|
||||
if _is_mirror_candidate(candidate, text):
|
||||
return "mirror"
|
||||
discovery_sources = {part.strip() for part in candidate.discovery_source.split(";") if part.strip()}
|
||||
if discovery_sources and discovery_sources <= {"ptna", "mobility_validator_acceptance"}:
|
||||
return "secondary_discovery"
|
||||
if _contains_any(
|
||||
text,
|
||||
[
|
||||
"delfi",
|
||||
"gtfs.de",
|
||||
"mobilithek",
|
||||
"germany-wide",
|
||||
"deutschlandweite",
|
||||
"entur",
|
||||
"bod national",
|
||||
"national gtfs",
|
||||
"national feed",
|
||||
"national timetable",
|
||||
"opentransportdata.swiss",
|
||||
"trafiklab",
|
||||
"samtrafiken",
|
||||
"transport for ireland",
|
||||
"transportforireland",
|
||||
"rejseplanen",
|
||||
"gtfs.ovapi.nl",
|
||||
],
|
||||
):
|
||||
return "national_official"
|
||||
if _contains_any(text, ["operator", "verkehrsunternehmen", "rnv", "flixbus", "flixtrain", "sncf", "tfl"]):
|
||||
return "operator_feed"
|
||||
if _contains_any(
|
||||
text,
|
||||
[
|
||||
"verkehrsverbund",
|
||||
"transport association",
|
||||
"regional authority",
|
||||
"authority feed",
|
||||
"vbb",
|
||||
"vrr",
|
||||
"vvs",
|
||||
"vrs",
|
||||
"mvv",
|
||||
"hvv",
|
||||
"rmv",
|
||||
"nvv",
|
||||
"nwl",
|
||||
"avv",
|
||||
"nah.sh",
|
||||
"nah-sh",
|
||||
"bayern",
|
||||
"baden-württemberg",
|
||||
"baden-wuerttemberg",
|
||||
"nordrhein-westfalen",
|
||||
"nrw",
|
||||
"thüringen",
|
||||
"thueringen",
|
||||
],
|
||||
):
|
||||
return "regional_authority"
|
||||
if "mobility_database" in candidate.discovery_source:
|
||||
return "aggregator_catalog"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _infer_coverage_scope(candidate: FeedCandidate, text: str) -> str:
|
||||
if _contains_any(
|
||||
text,
|
||||
[
|
||||
"germany-wide",
|
||||
"deutschlandweite",
|
||||
"national gtfs",
|
||||
"national feed",
|
||||
"national timetable",
|
||||
"delfi",
|
||||
"gtfs.de",
|
||||
"entur",
|
||||
"transport for ireland",
|
||||
"transportforireland",
|
||||
"trafiklab",
|
||||
"samtrafiken",
|
||||
"opentransportdata.swiss",
|
||||
"gtfs.ovapi.nl",
|
||||
"rejseplanen",
|
||||
],
|
||||
):
|
||||
return "national"
|
||||
if _contains_any(text, ["europe", "eu/eea", "pan-europe", "flixbus", "flixtrain"]):
|
||||
return "multi_country"
|
||||
if _contains_any(
|
||||
text,
|
||||
[
|
||||
"verkehrsverbund",
|
||||
"regional",
|
||||
"bundesland",
|
||||
"vbb",
|
||||
"vrr",
|
||||
"vvs",
|
||||
"vrs",
|
||||
"mvv",
|
||||
"hvv",
|
||||
"rmv",
|
||||
"nvv",
|
||||
"nwl",
|
||||
"avv",
|
||||
"nah.sh",
|
||||
"nah-sh",
|
||||
],
|
||||
):
|
||||
return "regional"
|
||||
if _contains_any(text, ["operator", "verkehrsunternehmen", "rnv"]):
|
||||
return "operator"
|
||||
if candidate.country:
|
||||
return "unknown_country"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _infer_overlap_role(authority_level: str, coverage_scope: str, candidate: FeedCandidate, text: str) -> str:
|
||||
if authority_level == "mirror":
|
||||
return "bootstrap_mirror"
|
||||
if authority_level == "secondary_discovery":
|
||||
return "discovery_evidence"
|
||||
if candidate.status == "registration_required":
|
||||
return "review_baseline" if coverage_scope == "national" else "review_candidate"
|
||||
if authority_level == "national_official" and coverage_scope == "national":
|
||||
return "canonical_baseline"
|
||||
if authority_level == "regional_authority":
|
||||
return "regional_override_candidate"
|
||||
if authority_level == "operator_feed":
|
||||
return "operator_detail_candidate"
|
||||
if authority_level == "aggregator_catalog":
|
||||
return "catalog_metadata"
|
||||
if _contains_any(text, ["active", "gtfs"]):
|
||||
return "gap_fill_candidate"
|
||||
return "review_candidate"
|
||||
|
||||
|
||||
def _infer_import_policy(authority_level: str, overlap_role: str, candidate: FeedCandidate) -> str:
|
||||
if candidate.status == "registration_required" or not candidate.selected_url:
|
||||
return "review_before_import"
|
||||
if overlap_role == "canonical_baseline":
|
||||
return "canonical_candidate"
|
||||
if overlap_role in {"regional_override_candidate", "operator_detail_candidate"}:
|
||||
return "qa_or_override_not_additive"
|
||||
if overlap_role == "bootstrap_mirror":
|
||||
return "bootstrap_only"
|
||||
if authority_level in {"secondary_discovery", "aggregator_catalog"}:
|
||||
return "metadata_only"
|
||||
return "review_before_import"
|
||||
|
||||
|
||||
def _infer_overlap_group(candidate: FeedCandidate, authority_level: str, coverage_scope: str, text: str) -> str:
|
||||
country = candidate.country or "unknown"
|
||||
if coverage_scope == "national":
|
||||
return f"{country}:national"
|
||||
identity_text = _hierarchy_identity_text(candidate)
|
||||
for token in [
|
||||
"vbb",
|
||||
"vrr",
|
||||
"vvs",
|
||||
"vrs",
|
||||
"mvv",
|
||||
"hvv",
|
||||
"rmv",
|
||||
"nvv",
|
||||
"nwl",
|
||||
"avv",
|
||||
"nah-sh",
|
||||
"nah.sh",
|
||||
"rnv",
|
||||
]:
|
||||
if token in identity_text:
|
||||
normalized = token.replace(".", "-")
|
||||
return f"{country}:{coverage_scope}:{normalized}"
|
||||
key = _source_key(candidate.provider or candidate.feed_name)
|
||||
if key:
|
||||
return f"{country}:{coverage_scope}:{key}"
|
||||
return f"{country}:{coverage_scope}:{authority_level}"
|
||||
|
||||
|
||||
def _is_mirror_candidate(candidate: FeedCandidate, text: str) -> bool:
|
||||
if any(_url_is_mirror_url(url) for url in [candidate.selected_url, candidate.direct_download_url, candidate.latest_url if not candidate.selected_url else ""]):
|
||||
return True
|
||||
mirror_notes = " ".join([candidate.source_basis, candidate.notes]).lower()
|
||||
return _contains_any(mirror_notes, ["european transport feeds mirror", "bootstrap mirror"])
|
||||
|
||||
|
||||
def _source_key(value: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return cleaned[:80]
|
||||
|
||||
|
||||
def _contains_any(text: str, tokens: Iterable[str]) -> bool:
|
||||
return any(token in text for token in tokens)
|
||||
|
||||
|
||||
def _hierarchy_identity_text(candidate: FeedCandidate) -> str:
|
||||
return " ".join(
|
||||
[
|
||||
candidate.provider,
|
||||
candidate.feed_name,
|
||||
candidate.stable_id,
|
||||
candidate.ptna_feed_id,
|
||||
_url_hierarchy_text(candidate.selected_url),
|
||||
_url_hierarchy_text(candidate.direct_download_url),
|
||||
_url_hierarchy_text(candidate.original_release_url),
|
||||
_url_hierarchy_text(candidate.details_url),
|
||||
]
|
||||
).lower()
|
||||
|
||||
|
||||
def _url_hierarchy_text(url: str) -> str:
|
||||
parsed = urlparse(url or "")
|
||||
return " ".join(part for part in [parsed.netloc, parsed.path] if part)
|
||||
|
||||
|
||||
def _url_is_mirror_url(url: str) -> bool:
|
||||
parsed = urlparse(url or "")
|
||||
host_path = f"{parsed.netloc.lower()}{parsed.path.lower()}"
|
||||
return _contains_any(host_path, ["files.mobilitydatabase.org", "openmobilitydata-data.s3", "scraped.data.public-transport.earth"])
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -732,6 +1163,211 @@ def _looks_like_download_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _opendata_oepnv_dataset_slug_from_url(url: str) -> str:
|
||||
query = parse_qs(urlparse(unescape(url)).query)
|
||||
return _clean_text((query.get("tx_vrrkit_view[dataset_name]") or [""])[0])
|
||||
|
||||
|
||||
def _opendata_oepnv_link_score(url: str) -> int:
|
||||
parsed = urlparse(url)
|
||||
score = 0
|
||||
if "/organisation/" in parsed.path:
|
||||
score += 2
|
||||
if parsed.path.rstrip("/").endswith("startseite"):
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def _opendata_oepnv_title(html: str) -> str:
|
||||
matches = re.findall(r"<div[^>]*class=[\"'][^\"']*element-header[^\"']*[\"'][^>]*>(.*?)</div>", html, re.IGNORECASE | re.DOTALL)
|
||||
for raw in reversed(matches):
|
||||
title = _html_text(raw)
|
||||
if title and title.lower() not in {"hinweise", "spielregeln", "lizenzen"}:
|
||||
return title
|
||||
match = re.search(r"<title>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
return _html_text(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def _opendata_oepnv_download_urls(html: str, detail_url: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for link in _all_links(html, detail_url):
|
||||
normalized = _normalize_feed_url(link)
|
||||
if normalized and _looks_like_download_url(normalized) and normalized not in urls:
|
||||
urls.append(normalized)
|
||||
return urls
|
||||
|
||||
|
||||
def _choose_opendata_oepnv_download_url(urls: list[str], data_type: str) -> str:
|
||||
if data_type == "gtfs_rt":
|
||||
for url in urls:
|
||||
lower = url.lower()
|
||||
if "realtime" in lower or "gtfs-rt" in lower or "gtfs_rt" in lower:
|
||||
return url
|
||||
return ""
|
||||
if data_type != "gtfs":
|
||||
return ""
|
||||
if not urls:
|
||||
return ""
|
||||
static_urls = [url for url in urls if "realtime" not in url.lower() and "gtfs-rt" not in url.lower() and "gtfs_rt" not in url.lower()]
|
||||
if not static_urls:
|
||||
return ""
|
||||
for url in static_urls:
|
||||
lower = url.lower()
|
||||
if "latest" in lower or "aktueller" in lower or lower.rstrip("/").endswith("/gtfs.zip"):
|
||||
return url
|
||||
return static_urls[0]
|
||||
|
||||
|
||||
def _opendata_oepnv_license(html: str, detail_url: str) -> tuple[str, str]:
|
||||
match = re.search(
|
||||
r"id=[\"']dodp_license[\"'][^>]*>.*?<a\s+[^>]*href=[\"'](?P<href>[^\"']+)[\"'][^>]*>(?P<text>.*?)</a>",
|
||||
html,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return "", ""
|
||||
return urljoin(detail_url, unescape(match.group("href"))), _html_text(match.group("text"))
|
||||
|
||||
|
||||
def _opendata_oepnv_requires_login(text: str) -> bool:
|
||||
lower = text.lower()
|
||||
return (
|
||||
"available for registered users" in lower
|
||||
or "download is only available for registered users" in lower
|
||||
or "this download is only available for registered users" in lower
|
||||
or "anmeldung erforderlich" in lower
|
||||
or "nur für registrierte nutzer" in lower
|
||||
or "nur fuer registrierte nutzer" in lower
|
||||
)
|
||||
|
||||
|
||||
def _opendata_oepnv_data_type(title: str, text: str) -> str:
|
||||
title_lower = title.lower()
|
||||
lower = f"{title} {text}".lower()
|
||||
if "gtfs-rt" in lower or "gtfs realtime" in lower or "gtfs-realtime" in lower:
|
||||
return "gtfs_rt"
|
||||
if "gtfs" in title_lower:
|
||||
return "gtfs"
|
||||
if "haltestell" in title_lower or "zhv" in title_lower:
|
||||
return "stops"
|
||||
if "netex" in title_lower:
|
||||
return "netex"
|
||||
if "trias" in lower:
|
||||
return "api"
|
||||
if "netex" in lower:
|
||||
return "netex"
|
||||
if "gtfs" in lower:
|
||||
return "gtfs"
|
||||
if "haltestell" in lower or "zhv" in lower:
|
||||
return "stops"
|
||||
if "linien" in lower:
|
||||
return "line_metadata"
|
||||
return "dataset"
|
||||
|
||||
|
||||
def _opendata_oepnv_features(data_type: str, title: str, text: str) -> str:
|
||||
features = ["OpenData ÖPNV"]
|
||||
if data_type == "gtfs":
|
||||
features.extend(["GTFS", "Schedules"])
|
||||
elif data_type == "netex":
|
||||
features.extend(["NeTEx", "Schedules"])
|
||||
elif data_type == "gtfs_rt":
|
||||
features.extend(["GTFS-Realtime"])
|
||||
elif data_type == "stops":
|
||||
features.append("Stops")
|
||||
lower = f"{title} {text}".lower()
|
||||
if "regionalbahn" in lower or "s-bahn" in lower or "bahn" in lower:
|
||||
features.append("Rail")
|
||||
if "u-bahn" in lower or "metro" in lower:
|
||||
features.append("Metro")
|
||||
if "tram" in lower or "straßenbahn" in lower:
|
||||
features.append("Tram")
|
||||
if "bus" in lower:
|
||||
features.append("Bus")
|
||||
return "|".join(dict.fromkeys(features))
|
||||
|
||||
|
||||
def _opendata_oepnv_priority(provider: str, data_type: str, selected_url: str) -> str:
|
||||
if provider == "DELFI e.V." and data_type in {"gtfs", "netex", "stops"}:
|
||||
return "P0"
|
||||
if data_type == "gtfs" and selected_url:
|
||||
return "P1"
|
||||
if data_type in {"netex", "gtfs_rt", "stops"}:
|
||||
return "P2"
|
||||
return "P3"
|
||||
|
||||
|
||||
def _opendata_oepnv_provider(detail_url: str, slug: str) -> str:
|
||||
path = urlparse(detail_url).path.lower()
|
||||
haystack = f"{path}/{slug.lower()}/"
|
||||
providers = {
|
||||
"delfi": "DELFI e.V.",
|
||||
"nah-sh": "NAH.SH",
|
||||
"avv": "Aachener Verkehrsverbund",
|
||||
"baden-wuerttemberg": "Baden-Württemberg",
|
||||
"bawue": "Baden-Württemberg",
|
||||
"hvv": "Hamburger Verkehrsverbund",
|
||||
"mvv": "Münchner Verkehrs- und Tarifverbund",
|
||||
"nvv": "Nordhessischer VerkehrsVerbund",
|
||||
"nwl": "Nahverkehr Westfalen-Lippe",
|
||||
"nordrhein-westfalen": "Nordrhein-Westfalen",
|
||||
"nrw": "Nordrhein-Westfalen",
|
||||
"rmv": "Rhein-Main-Verkehrsverbund",
|
||||
"rnv": "Rhein-Neckar-Verkehr",
|
||||
"thueringen": "Thüringen",
|
||||
"vbb": "Verkehrsverbund Berlin-Brandenburg",
|
||||
"vrr": "Verkehrsverbund Rhein-Ruhr",
|
||||
"vrs": "Verkehrsverbund Rhein-Sieg",
|
||||
"vvs": "Verkehrs- und Tarifverbund Stuttgart",
|
||||
}
|
||||
for token, provider in providers.items():
|
||||
if f"/{token}/" in haystack or f"-{token}" in haystack or f"_{token}" in haystack:
|
||||
return provider
|
||||
return "OpenData ÖPNV"
|
||||
|
||||
|
||||
def _opendata_oepnv_subdivision(detail_url: str, slug: str) -> str:
|
||||
text = f"{urlparse(detail_url).path} {slug}".lower()
|
||||
subdivisions = {
|
||||
"baden-wuerttemberg": "Baden-Württemberg",
|
||||
"bawue": "Baden-Württemberg",
|
||||
"thueringen": "Thüringen",
|
||||
"nrw": "Nordrhein-Westfalen",
|
||||
"nordrhein-westfalen": "Nordrhein-Westfalen",
|
||||
"vbb": "Berlin-Brandenburg",
|
||||
"hvv": "Hamburg",
|
||||
"mvv": "Bayern",
|
||||
"vrr": "Nordrhein-Westfalen",
|
||||
"vrs": "Nordrhein-Westfalen",
|
||||
"nwl": "Nordrhein-Westfalen",
|
||||
"avv": "Nordrhein-Westfalen",
|
||||
"rmv": "Hessen",
|
||||
"nvv": "Hessen",
|
||||
"rnv": "Baden-Württemberg/Rheinland-Pfalz/Hessen",
|
||||
"vvs": "Baden-Württemberg",
|
||||
}
|
||||
for token, subdivision in subdivisions.items():
|
||||
if token in text:
|
||||
return subdivision
|
||||
return ""
|
||||
|
||||
|
||||
def _opendata_oepnv_download_is_registration_gated(candidate: FeedCandidate) -> bool:
|
||||
url_text = " ".join([candidate.selected_url, candidate.direct_download_url, candidate.original_release_url]).lower()
|
||||
if "opendata-oepnv.de" not in url_text:
|
||||
return False
|
||||
descriptive_text = " ".join([candidate.provider, candidate.feed_name, candidate.status, candidate.notes]).lower()
|
||||
return "registration required" in descriptive_text or "user registration required" in descriptive_text
|
||||
|
||||
|
||||
def _html_text(value: str) -> str:
|
||||
return _clean_text(re.sub(r"<[^>]+>", " ", value or ""))
|
||||
|
||||
|
||||
def _title_from_slug(slug: str) -> str:
|
||||
return _clean_text(slug.replace("_", " ").replace("-", " ").title())
|
||||
|
||||
|
||||
def _normalize_feed_url(url: str) -> str:
|
||||
cleaned = _clean_text(url)
|
||||
if not cleaned:
|
||||
@@ -800,6 +1436,10 @@ def _merge_candidate(existing: FeedCandidate, incoming: FeedCandidate) -> None:
|
||||
new_value = getattr(incoming, field_name, "")
|
||||
if new_value:
|
||||
setattr(existing, field_name, new_value)
|
||||
if existing.selected_url and incoming.selected_url and _url_is_mirror_url(existing.selected_url) and not _url_is_mirror_url(incoming.selected_url):
|
||||
existing.selected_url = incoming.selected_url
|
||||
existing.direct_download_url = incoming.direct_download_url or incoming.selected_url
|
||||
existing.notes = _join_notes(existing.notes, "Selected non-mirror publisher URL from merged source evidence.")
|
||||
existing.discovery_source = _join_unique(existing.discovery_source, incoming.discovery_source)
|
||||
for field_name in CANONICAL_HEADERS:
|
||||
if field_name == "candidate_id":
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models import Dataset, GtfsFeedDiffItem, GtfsFeedDiffRun, GtfsRoute, GtfsStop, GtfsTrip, Source
|
||||
|
||||
MAX_DIFF_ITEMS_PER_TYPE = 200
|
||||
|
||||
|
||||
def create_gtfs_update_diff_from_stage(
|
||||
session: Session,
|
||||
*,
|
||||
source: Source,
|
||||
previous_dataset: Dataset | None,
|
||||
new_dataset: Dataset,
|
||||
stage_path: Path,
|
||||
) -> dict[str, Any] | None:
|
||||
if previous_dataset is None or previous_dataset.kind != "gtfs" or not stage_path.exists():
|
||||
return None
|
||||
old_routes = _main_routes(session, previous_dataset.id)
|
||||
old_stops = _main_stops(session, previous_dataset.id)
|
||||
old_counts = {
|
||||
"routes": len(old_routes),
|
||||
"stops": len(old_stops),
|
||||
"trips": _main_count(session, GtfsTrip, previous_dataset.id),
|
||||
}
|
||||
with sqlite3.connect(stage_path) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
new_routes = _stage_routes(connection)
|
||||
new_stops = _stage_stops(connection)
|
||||
new_counts = {
|
||||
"routes": len(new_routes),
|
||||
"stops": len(new_stops),
|
||||
"trips": _stage_count(connection, "gtfs_trips"),
|
||||
}
|
||||
|
||||
route_summary, route_items = _diff_objects(
|
||||
item_type="route",
|
||||
old=old_routes,
|
||||
new=new_routes,
|
||||
identity_label="route",
|
||||
)
|
||||
stop_summary, stop_items = _diff_objects(
|
||||
item_type="stop",
|
||||
old=old_stops,
|
||||
new=new_stops,
|
||||
identity_label="stop",
|
||||
)
|
||||
summary = {
|
||||
"source_id": source.id,
|
||||
"previous_dataset_id": previous_dataset.id,
|
||||
"new_dataset_id": new_dataset.id,
|
||||
"counts": {"old": old_counts, "new": new_counts},
|
||||
"routes": route_summary,
|
||||
"stops": stop_summary,
|
||||
"items_stored": {
|
||||
"route": min(len(route_items), MAX_DIFF_ITEMS_PER_TYPE),
|
||||
"stop": min(len(stop_items), MAX_DIFF_ITEMS_PER_TYPE),
|
||||
},
|
||||
}
|
||||
diff_key = _diff_key(source.id, previous_dataset.id, new_dataset.id, summary)
|
||||
existing = session.scalar(select(GtfsFeedDiffRun).where(GtfsFeedDiffRun.diff_key == diff_key))
|
||||
if existing is not None:
|
||||
return diff_run_payload(existing)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
diff_run = GtfsFeedDiffRun(
|
||||
source_id=source.id,
|
||||
previous_dataset_id=previous_dataset.id,
|
||||
new_dataset_id=new_dataset.id,
|
||||
status="completed",
|
||||
diff_key=diff_key,
|
||||
summary_json=_json_dumps(summary),
|
||||
metadata_json=_json_dumps({"stage_path": str(stage_path), "created_from": "gtfs_activation"}),
|
||||
created_at=now,
|
||||
)
|
||||
session.add(diff_run)
|
||||
session.flush()
|
||||
for item in [*route_items[:MAX_DIFF_ITEMS_PER_TYPE], *stop_items[:MAX_DIFF_ITEMS_PER_TYPE]]:
|
||||
session.add(
|
||||
GtfsFeedDiffItem(
|
||||
diff_run_id=diff_run.id,
|
||||
source_id=source.id,
|
||||
previous_dataset_id=previous_dataset.id,
|
||||
new_dataset_id=new_dataset.id,
|
||||
item_type=item["item_type"],
|
||||
object_key=item["object_key"],
|
||||
change_type=item["change_type"],
|
||||
severity=item["severity"],
|
||||
status="open",
|
||||
title=item["title"],
|
||||
old_payload_json=_json_dumps(item["old"]) if item.get("old") else None,
|
||||
new_payload_json=_json_dumps(item["new"]) if item.get("new") else None,
|
||||
diff_json=_json_dumps(item["diff"]) if item.get("diff") else None,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return diff_run_payload(diff_run)
|
||||
|
||||
|
||||
def latest_gtfs_update_diffs(session: Session, *, source_id: int | None = None, limit: int = 20) -> list[GtfsFeedDiffRun]:
|
||||
stmt = (
|
||||
select(GtfsFeedDiffRun)
|
||||
.options(joinedload(GtfsFeedDiffRun.source))
|
||||
.order_by(GtfsFeedDiffRun.created_at.desc(), GtfsFeedDiffRun.id.desc())
|
||||
.limit(max(1, min(int(limit), 100)))
|
||||
)
|
||||
if source_id is not None:
|
||||
stmt = stmt.where(GtfsFeedDiffRun.source_id == int(source_id))
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
|
||||
def gtfs_update_diff_detail(session: Session, diff_id: int, *, item_limit: int = 200) -> dict[str, Any] | None:
|
||||
diff_run = session.get(GtfsFeedDiffRun, diff_id)
|
||||
if diff_run is None:
|
||||
return None
|
||||
items = session.scalars(
|
||||
select(GtfsFeedDiffItem)
|
||||
.where(GtfsFeedDiffItem.diff_run_id == diff_run.id)
|
||||
.order_by(_change_type_order(), GtfsFeedDiffItem.item_type, GtfsFeedDiffItem.object_key)
|
||||
.limit(max(1, min(int(item_limit), 1000)))
|
||||
).all()
|
||||
return {**diff_run_payload(diff_run), "items": [diff_item_payload(item) for item in items]}
|
||||
|
||||
|
||||
def gtfs_update_diff_summary(session: Session) -> dict[str, Any]:
|
||||
runs = session.scalar(select(func.count()).select_from(GtfsFeedDiffRun)) or 0
|
||||
rows = session.execute(
|
||||
select(GtfsFeedDiffItem.item_type, GtfsFeedDiffItem.change_type, GtfsFeedDiffItem.severity, func.count())
|
||||
.select_from(GtfsFeedDiffItem)
|
||||
.group_by(GtfsFeedDiffItem.item_type, GtfsFeedDiffItem.change_type, GtfsFeedDiffItem.severity)
|
||||
).all()
|
||||
by_type: dict[str, int] = {}
|
||||
by_change: dict[str, int] = {}
|
||||
by_severity: dict[str, int] = {}
|
||||
for item_type, change_type, severity, count in rows:
|
||||
value = int(count or 0)
|
||||
by_type[str(item_type)] = by_type.get(str(item_type), 0) + value
|
||||
by_change[str(change_type)] = by_change.get(str(change_type), 0) + value
|
||||
by_severity[str(severity)] = by_severity.get(str(severity), 0) + value
|
||||
return {"runs": int(runs), "by_type": by_type, "by_change": by_change, "by_severity": by_severity}
|
||||
|
||||
|
||||
def diff_run_payload(diff_run: GtfsFeedDiffRun) -> dict[str, Any]:
|
||||
summary = _json_object(diff_run.summary_json)
|
||||
return {
|
||||
"id": diff_run.id,
|
||||
"source_id": diff_run.source_id,
|
||||
"source_name": None if diff_run.source is None else diff_run.source.name,
|
||||
"previous_dataset_id": diff_run.previous_dataset_id,
|
||||
"new_dataset_id": diff_run.new_dataset_id,
|
||||
"status": diff_run.status,
|
||||
"diff_key": diff_run.diff_key,
|
||||
"summary": summary,
|
||||
"metadata": _json_object(diff_run.metadata_json),
|
||||
"created_at": diff_run.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def diff_item_payload(item: GtfsFeedDiffItem) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item.id,
|
||||
"diff_run_id": item.diff_run_id,
|
||||
"source_id": item.source_id,
|
||||
"previous_dataset_id": item.previous_dataset_id,
|
||||
"new_dataset_id": item.new_dataset_id,
|
||||
"item_type": item.item_type,
|
||||
"object_key": item.object_key,
|
||||
"change_type": item.change_type,
|
||||
"severity": item.severity,
|
||||
"status": item.status,
|
||||
"title": item.title,
|
||||
"old": _json_object(item.old_payload_json),
|
||||
"new": _json_object(item.new_payload_json),
|
||||
"diff": _json_object(item.diff_json),
|
||||
"created_at": item.created_at.isoformat(),
|
||||
"resolved_at": None if item.resolved_at is None else item.resolved_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _main_routes(session: Session, dataset_id: int) -> dict[str, dict[str, Any]]:
|
||||
trip_counts = {
|
||||
str(route_id): int(count or 0)
|
||||
for route_id, count in session.execute(
|
||||
select(GtfsTrip.route_id, func.count()).where(GtfsTrip.dataset_id == dataset_id).group_by(GtfsTrip.route_id)
|
||||
).all()
|
||||
}
|
||||
routes = session.scalars(select(GtfsRoute).where(GtfsRoute.dataset_id == dataset_id)).all()
|
||||
return {
|
||||
str(route.route_id): _normalized_route_payload(
|
||||
{
|
||||
"route_id": route.route_id,
|
||||
"short_name": route.short_name,
|
||||
"long_name": route.long_name,
|
||||
"mode": route.mode,
|
||||
"route_scope": route.route_scope,
|
||||
"operator_name": route.operator_name,
|
||||
"route_key": route.route_key,
|
||||
"operator_key": route.operator_key,
|
||||
"has_geometry": bool(route.geometry_geojson),
|
||||
"trip_count": trip_counts.get(str(route.route_id), 0),
|
||||
}
|
||||
)
|
||||
for route in routes
|
||||
}
|
||||
|
||||
|
||||
def _stage_routes(connection: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
trip_counts = {
|
||||
str(row["route_id"]): int(row["count"] or 0)
|
||||
for row in connection.execute("SELECT route_id, COUNT(*) AS count FROM gtfs_trips GROUP BY route_id").fetchall()
|
||||
}
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT route_id, short_name, long_name, mode, route_scope, operator_name, route_key, operator_key, geometry_geojson
|
||||
FROM gtfs_routes
|
||||
"""
|
||||
).fetchall()
|
||||
return {
|
||||
str(row["route_id"]): _normalized_route_payload(
|
||||
{
|
||||
"route_id": row["route_id"],
|
||||
"short_name": row["short_name"],
|
||||
"long_name": row["long_name"],
|
||||
"mode": row["mode"],
|
||||
"route_scope": row["route_scope"],
|
||||
"operator_name": row["operator_name"],
|
||||
"route_key": row["route_key"],
|
||||
"operator_key": row["operator_key"],
|
||||
"has_geometry": bool(row["geometry_geojson"]),
|
||||
"trip_count": trip_counts.get(str(row["route_id"]), 0),
|
||||
}
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
def _main_stops(session: Session, dataset_id: int) -> dict[str, dict[str, Any]]:
|
||||
stops = session.scalars(select(GtfsStop).where(GtfsStop.dataset_id == dataset_id)).all()
|
||||
return {
|
||||
str(stop.stop_id): _normalized_stop_payload(
|
||||
{
|
||||
"stop_id": stop.stop_id,
|
||||
"name": stop.name,
|
||||
"lat": stop.lat,
|
||||
"lon": stop.lon,
|
||||
"parent_station": stop.parent_station,
|
||||
}
|
||||
)
|
||||
for stop in stops
|
||||
}
|
||||
|
||||
|
||||
def _stage_stops(connection: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
rows = connection.execute("SELECT stop_id, name, lat, lon, parent_station FROM gtfs_stops").fetchall()
|
||||
return {
|
||||
str(row["stop_id"]): _normalized_stop_payload(
|
||||
{
|
||||
"stop_id": row["stop_id"],
|
||||
"name": row["name"],
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
"parent_station": row["parent_station"],
|
||||
}
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
def _diff_objects(
|
||||
*,
|
||||
item_type: str,
|
||||
old: dict[str, dict[str, Any]],
|
||||
new: dict[str, dict[str, Any]],
|
||||
identity_label: str,
|
||||
) -> tuple[dict[str, int], list[dict[str, Any]]]:
|
||||
old_keys = set(old)
|
||||
new_keys = set(new)
|
||||
added = sorted(new_keys - old_keys)
|
||||
removed = sorted(old_keys - new_keys)
|
||||
common = sorted(old_keys & new_keys)
|
||||
items: list[dict[str, Any]] = []
|
||||
unchanged = 0
|
||||
changed = 0
|
||||
for key in common:
|
||||
differences = _payload_differences(old[key], new[key])
|
||||
if not differences:
|
||||
unchanged += 1
|
||||
continue
|
||||
changed += 1
|
||||
items.append(
|
||||
{
|
||||
"item_type": item_type,
|
||||
"object_key": key,
|
||||
"change_type": "changed",
|
||||
"severity": "warn",
|
||||
"title": f"{identity_label.title()} changed: {key}",
|
||||
"old": old[key],
|
||||
"new": new[key],
|
||||
"diff": differences,
|
||||
}
|
||||
)
|
||||
for key in added:
|
||||
items.append(
|
||||
{
|
||||
"item_type": item_type,
|
||||
"object_key": key,
|
||||
"change_type": "added",
|
||||
"severity": "info",
|
||||
"title": f"{identity_label.title()} added: {key}",
|
||||
"new": new[key],
|
||||
"diff": {"added": True},
|
||||
}
|
||||
)
|
||||
for key in removed:
|
||||
items.append(
|
||||
{
|
||||
"item_type": item_type,
|
||||
"object_key": key,
|
||||
"change_type": "removed",
|
||||
"severity": "warn",
|
||||
"title": f"{identity_label.title()} removed: {key}",
|
||||
"old": old[key],
|
||||
"diff": {"removed": True},
|
||||
}
|
||||
)
|
||||
summary = {
|
||||
"old": len(old),
|
||||
"new": len(new),
|
||||
"added": len(added),
|
||||
"removed": len(removed),
|
||||
"changed": changed,
|
||||
"unchanged": unchanged,
|
||||
}
|
||||
return summary, sorted(items, key=lambda item: (_change_type_rank(item["change_type"]), item["item_type"], item["object_key"]))
|
||||
|
||||
|
||||
def _payload_differences(old: dict[str, Any], new: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
differences = {}
|
||||
for key in sorted(set(old) | set(new)):
|
||||
if old.get(key) != new.get(key):
|
||||
differences[key] = {"old": old.get(key), "new": new.get(key)}
|
||||
return differences
|
||||
|
||||
|
||||
def _normalized_route_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"route_id": str(payload.get("route_id") or ""),
|
||||
"short_name": _clean_text(payload.get("short_name")),
|
||||
"long_name": _clean_text(payload.get("long_name")),
|
||||
"mode": _clean_text(payload.get("mode")),
|
||||
"route_scope": _clean_text(payload.get("route_scope")),
|
||||
"operator_name": _clean_text(payload.get("operator_name")),
|
||||
"route_key": _clean_text(payload.get("route_key")),
|
||||
"operator_key": _clean_text(payload.get("operator_key")),
|
||||
"has_geometry": bool(payload.get("has_geometry")),
|
||||
"trip_count": int(payload.get("trip_count") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _normalized_stop_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"stop_id": str(payload.get("stop_id") or ""),
|
||||
"name": _clean_text(payload.get("name")),
|
||||
"lat": _rounded_coord(payload.get("lat")),
|
||||
"lon": _rounded_coord(payload.get("lon")),
|
||||
"parent_station": _clean_text(payload.get("parent_station")),
|
||||
}
|
||||
|
||||
|
||||
def _main_count(session: Session, model, dataset_id: int) -> int:
|
||||
return int(session.scalar(select(func.count()).select_from(model).where(model.dataset_id == dataset_id)) or 0)
|
||||
|
||||
|
||||
def _stage_count(connection: sqlite3.Connection, table_name: str) -> int:
|
||||
return int(connection.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0] or 0)
|
||||
|
||||
|
||||
def _diff_key(source_id: int, previous_dataset_id: int, new_dataset_id: int, summary: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"source_id": source_id,
|
||||
"previous_dataset_id": previous_dataset_id,
|
||||
"new_dataset_id": new_dataset_id,
|
||||
"routes": summary.get("routes"),
|
||||
"stops": summary.get("stops"),
|
||||
}
|
||||
digest = hashlib.sha256(_json_dumps(payload).encode("utf-8")).hexdigest()
|
||||
return f"gtfs-diff-{digest[:32]}"
|
||||
|
||||
|
||||
def _change_type_order():
|
||||
from sqlalchemy import case
|
||||
|
||||
return case(
|
||||
(GtfsFeedDiffItem.change_type == "changed", 0),
|
||||
(GtfsFeedDiffItem.change_type == "removed", 1),
|
||||
(GtfsFeedDiffItem.change_type == "added", 2),
|
||||
else_=9,
|
||||
)
|
||||
|
||||
|
||||
def _change_type_rank(value: str) -> int:
|
||||
return {"changed": 0, "removed": 1, "added": 2}.get(value, 9)
|
||||
|
||||
|
||||
def _clean_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = " ".join(str(value).strip().split())
|
||||
return text or None
|
||||
|
||||
|
||||
def _rounded_coord(value: object) -> float | None:
|
||||
try:
|
||||
return round(float(value), 7)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _json_object(value: str | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _json_dumps(value: dict[str, Any]) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
+1265
-13
File diff suppressed because it is too large
Load Diff
+127
-4
@@ -23,6 +23,7 @@ from app.data_management import (
|
||||
from app.db import SessionLocal, engine, init_db
|
||||
from app.db_lock import DatabaseWriteBusy, database_write_lock
|
||||
from app.gtfs_storage import missing_sidecar_paths as gtfs_missing_sidecar_paths
|
||||
from app.harmonization import create_gtfs_harmonized_snapshot
|
||||
from app.models import Dataset, Job, JobEvent, Source, SourceUpdateCheck
|
||||
from app.osm_storage import missing_sidecar_paths as osm_missing_sidecar_paths
|
||||
from app.pipeline.gtfs import backfill_gtfs_shapes
|
||||
@@ -34,6 +35,7 @@ from app.pipeline.route_layer import rebuild_route_layer
|
||||
from app.pipeline.run import run_source
|
||||
from app.pipeline.sample_data import clear_project_data, load_sample_project
|
||||
from app.source_catalog import import_ingestable_sources, import_source_catalog, source_catalog_summary
|
||||
from app.workbench import generate_map_gtfs_review_items
|
||||
|
||||
|
||||
ROUTE_MATCHING_JOB_KIND = "route_matching"
|
||||
@@ -44,6 +46,8 @@ SOURCE_IMPORT_JOB_KIND = "source_import"
|
||||
SOURCE_DELETE_JOB_KIND = "source_delete"
|
||||
DATASET_DELETE_JOB_KIND = "dataset_delete"
|
||||
MAINTENANCE_JOB_KIND = "maintenance"
|
||||
MAP_GTFS_REVIEW_JOB_KIND = "map_gtfs_review"
|
||||
GTFS_HARMONIZED_SNAPSHOT_JOB_KIND = "gtfs_harmonized_snapshot"
|
||||
TERMINAL_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
||||
ACTIVE_JOB_STATUSES = {"queued", "running", "paused"}
|
||||
LEASE_SECONDS = max(300, int(settings.queue_job_lease_seconds))
|
||||
@@ -124,6 +128,70 @@ def create_route_matching_job(session: Session, *, priority: int = 0) -> Job:
|
||||
return job
|
||||
|
||||
|
||||
def create_map_gtfs_review_job(
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int | None = None,
|
||||
dataset_id: int | None = None,
|
||||
limit: int = 1000,
|
||||
priority: int = 0,
|
||||
) -> Job:
|
||||
result = {
|
||||
"source_id": source_id,
|
||||
"dataset_id": dataset_id,
|
||||
"limit": max(1, min(int(limit), 50_000)),
|
||||
}
|
||||
job = Job(
|
||||
kind=MAP_GTFS_REVIEW_JOB_KIND,
|
||||
status="queued",
|
||||
description="Generate Map/GTFS workbench review queue",
|
||||
progress_current=0,
|
||||
progress_total=2,
|
||||
priority=int(priority),
|
||||
result_json=json.dumps(result, separators=(",", ":")),
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
add_job_event(
|
||||
session,
|
||||
job,
|
||||
event_type="queued",
|
||||
message="Map/GTFS review generation queued.",
|
||||
progress_current=0,
|
||||
progress_total=job.progress_total,
|
||||
metadata=result,
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
def create_gtfs_harmonized_snapshot_job(session: Session, *, activate: bool = True, priority: int = 0) -> Job:
|
||||
result = {
|
||||
"activate": bool(activate),
|
||||
"queued_pid": os.getpid(),
|
||||
}
|
||||
job = Job(
|
||||
kind=GTFS_HARMONIZED_SNAPSHOT_JOB_KIND,
|
||||
status="queued",
|
||||
description="Build harmonized GTFS snapshot",
|
||||
progress_current=0,
|
||||
progress_total=2,
|
||||
priority=int(priority),
|
||||
result_json=json.dumps(result, separators=(",", ":")),
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
add_job_event(
|
||||
session,
|
||||
job,
|
||||
event_type="queued",
|
||||
message="Harmonized GTFS snapshot build queued.",
|
||||
progress_current=0,
|
||||
progress_total=job.progress_total,
|
||||
metadata=result,
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
def create_osm_relabel_job(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -855,12 +923,14 @@ def job_queue_revision(session: Session, *, include_dismissed: bool = False) ->
|
||||
|
||||
|
||||
def job_events(session: Session, job_id: int, *, limit: int = 100) -> list[JobEvent]:
|
||||
return session.scalars(
|
||||
event_limit = max(1, min(limit, 500))
|
||||
events = session.scalars(
|
||||
select(JobEvent)
|
||||
.where(JobEvent.job_id == job_id)
|
||||
.order_by(JobEvent.created_at, JobEvent.id)
|
||||
.limit(max(1, min(limit, 500)))
|
||||
.order_by(JobEvent.created_at.desc(), JobEvent.id.desc())
|
||||
.limit(event_limit)
|
||||
).all()
|
||||
return list(reversed(events))
|
||||
|
||||
|
||||
def request_job_control(job_id: int, action: str) -> dict[str, Any]:
|
||||
@@ -990,6 +1060,10 @@ def run_claimed_job(job_id: int, worker_id: str) -> None:
|
||||
_run_dataset_delete_job(job_id, worker_id)
|
||||
elif job.kind == MAINTENANCE_JOB_KIND:
|
||||
_run_maintenance_job(job_id, worker_id)
|
||||
elif job.kind == MAP_GTFS_REVIEW_JOB_KIND:
|
||||
_run_map_gtfs_review_job(job_id, worker_id)
|
||||
elif job.kind == GTFS_HARMONIZED_SNAPSHOT_JOB_KIND:
|
||||
_run_gtfs_harmonized_snapshot_job(job_id, worker_id)
|
||||
else:
|
||||
raise ValueError(f"unsupported job kind: {job.kind}")
|
||||
except JobPaused:
|
||||
@@ -1292,6 +1366,46 @@ def _run_osm_relabel_job(job_id: int, worker_id: str) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _run_map_gtfs_review_job(job_id: int, worker_id: str) -> None:
|
||||
init_db()
|
||||
with SessionLocal() as session:
|
||||
job = _job_for_worker(session, job_id, worker_id)
|
||||
options = _json_object(job.result_json)
|
||||
_job_running(session, job, worker_id, "started", "Generating Map/GTFS workbench review queue.", 1)
|
||||
_check_job_control(session, job)
|
||||
progress_callback = _job_progress_callback(session, job, worker_id, update_job_progress=False)
|
||||
result = generate_map_gtfs_review_items(
|
||||
session,
|
||||
source_id=_optional_int(options.get("source_id")),
|
||||
dataset_id=_optional_int(options.get("dataset_id")),
|
||||
limit=int(options.get("limit") or 1000),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
job = _job_for_worker(session, job_id, worker_id)
|
||||
_complete_job(session, job, "Map/GTFS workbench review queue generated.", {**options, "review_result": result})
|
||||
session.commit()
|
||||
|
||||
|
||||
def _run_gtfs_harmonized_snapshot_job(job_id: int, worker_id: str) -> None:
|
||||
init_db()
|
||||
with SessionLocal() as session:
|
||||
job = _job_for_worker(session, job_id, worker_id)
|
||||
options = _json_object(job.result_json)
|
||||
options["worker_pid"] = os.getpid()
|
||||
options["worker_id"] = worker_id
|
||||
job.result_json = json.dumps(options, separators=(",", ":"))
|
||||
_job_running(session, job, worker_id, "started", "Building harmonized GTFS snapshot.", 1)
|
||||
_check_job_control(session, job)
|
||||
snapshot = create_gtfs_harmonized_snapshot(
|
||||
session,
|
||||
activate=bool(options.get("activate", True)),
|
||||
note=f"job #{job.id}",
|
||||
)
|
||||
job = _job_for_worker(session, job_id, worker_id)
|
||||
_complete_job(session, job, "Harmonized GTFS snapshot built.", {**options, "snapshot": snapshot})
|
||||
session.commit()
|
||||
|
||||
|
||||
def _run_source_import_job(job_id: int, worker_id: str) -> None:
|
||||
init_db()
|
||||
with SessionLocal() as session:
|
||||
@@ -1335,7 +1449,16 @@ def _run_source_import_job(job_id: int, worker_id: str) -> None:
|
||||
if options.get("run_match"):
|
||||
_job_running(session, job, worker_id, "matching", "Running route matcher after import.", 3)
|
||||
progress_callback = _job_progress_callback(session, job, worker_id)
|
||||
match_result = run_route_matching(session, progress_callback=progress_callback)
|
||||
imported_gtfs_dataset_id = (
|
||||
int(options["dataset_id"])
|
||||
if options.get("dataset_kind") == "gtfs" and options.get("dataset_id")
|
||||
else None
|
||||
)
|
||||
match_result = run_route_matching(
|
||||
session,
|
||||
progress_callback=progress_callback,
|
||||
gtfs_dataset_ids=[imported_gtfs_dataset_id] if imported_gtfs_dataset_id is not None else None,
|
||||
)
|
||||
job = _job_for_worker(session, job_id, worker_id)
|
||||
options = _json_object(job.result_json)
|
||||
options["match_result"] = match_result
|
||||
|
||||
+1068
-48
File diff suppressed because it is too large
Load Diff
+204
-50
@@ -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 "")
|
||||
|
||||
+406
-12
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
@@ -24,8 +26,17 @@ from app.dataset_search import search_datasets
|
||||
from app.db import engine, get_db, init_db
|
||||
from app.db_lock import DatabaseWriteBusy, database_write_lock, database_write_status
|
||||
from app.geofabrik import create_geofabrik_source, geofabrik_catalog
|
||||
from app.gtfs_diff import diff_run_payload, gtfs_update_diff_detail, gtfs_update_diff_summary, latest_gtfs_update_diffs
|
||||
from app.gtfs_storage import scheduled_stop_ids
|
||||
from app.harmonization import GTFS_QA_NOTE_PREFIX, gtfs_harmonization_feed_detail, gtfs_harmonization_inventory
|
||||
from app.harmonization import (
|
||||
GTFS_QA_NOTE_PREFIX,
|
||||
gtfs_harmonization_feed_detail,
|
||||
gtfs_harmonization_inventory,
|
||||
gtfs_harmonized_snapshot,
|
||||
gtfs_harmonized_snapshot_diagnostics,
|
||||
gtfs_qa_review_payload,
|
||||
list_active_harmonized_snapshot_routes,
|
||||
)
|
||||
from app.itineraries import generate_itineraries, itinerary_payload, recent_itineraries, set_itinerary_saved, set_leg_locked
|
||||
from app.journey import find_journeys, nearest_scheduled_stops, search_scheduled_stops
|
||||
from app.journey_search import cancel_journey_search, journey_search_payload, start_journey_search
|
||||
@@ -37,6 +48,8 @@ from app.jobs import (
|
||||
cancel_job,
|
||||
create_dataset_delete_job,
|
||||
create_address_index_rebuild_job,
|
||||
create_gtfs_harmonized_snapshot_job,
|
||||
create_map_gtfs_review_job,
|
||||
create_maintenance_job,
|
||||
create_osm_relabel_job,
|
||||
create_route_matching_job,
|
||||
@@ -69,6 +82,7 @@ from app.models import (
|
||||
ItineraryLeg,
|
||||
Job,
|
||||
MatchRule,
|
||||
MapGtfsReviewItem,
|
||||
OsmFeature,
|
||||
PipelineRun,
|
||||
RouteMatch,
|
||||
@@ -106,7 +120,21 @@ from app.source_catalog import (
|
||||
source_catalog_rows,
|
||||
source_catalog_summary,
|
||||
)
|
||||
from app.worker_supervisor import queue_worker_status, start_queue_workers, stop_queue_workers
|
||||
from app.worker_supervisor import (
|
||||
queue_worker_status,
|
||||
restart_queue_workers,
|
||||
start_queue_workers,
|
||||
stop_configured_queue_workers,
|
||||
stop_queue_workers,
|
||||
)
|
||||
from app.workbench import (
|
||||
generate_map_gtfs_review_items,
|
||||
list_map_gtfs_review_items,
|
||||
map_gtfs_review_summary,
|
||||
record_route_match_decision,
|
||||
review_item_payload,
|
||||
update_map_gtfs_review_item,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
@@ -125,6 +153,7 @@ async def lifespan(_app: FastAPI):
|
||||
app = FastAPI(title="Mobility Workbench", version="0.1.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
STATIC_ASSET_VERSION = "20260706-bike-mode"
|
||||
MAP_FEATURE_LIMIT = 5000
|
||||
MAP_FEATURE_LIMIT_MAX = 20000
|
||||
|
||||
@@ -146,6 +175,12 @@ class GtfsFeedReviewUpdate(BaseModel):
|
||||
license: Optional[str] = None
|
||||
review_status: Optional[str] = None
|
||||
review_note: Optional[str] = None
|
||||
can_import: Optional[str] = None
|
||||
can_derive: Optional[str] = None
|
||||
can_redistribute: Optional[str] = None
|
||||
requires_attribution: Optional[str] = None
|
||||
commercial_restrictions: Optional[str] = None
|
||||
authority_level: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@@ -221,6 +256,11 @@ class JobPriorityRequest(BaseModel):
|
||||
priority: int
|
||||
|
||||
|
||||
class WorkbenchReviewUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
def write_endpoint(operation: str):
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
@@ -249,7 +289,7 @@ async def database_operational_error_handler(_request: Request, exc: Operational
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request=request, name="index.html")
|
||||
return templates.TemplateResponse(request=request, name="index.html", context={"static_version": STATIC_ASSET_VERSION})
|
||||
|
||||
|
||||
@app.get("/api/sources")
|
||||
@@ -623,6 +663,21 @@ def queue_route_matching(priority: int = 0, db: Session = Depends(get_db)) -> di
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.post("/api/jobs/map-gtfs-review")
|
||||
@write_endpoint("queue Map/GTFS review generation")
|
||||
def queue_map_gtfs_review(
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 1000,
|
||||
priority: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = create_map_gtfs_review_job(db, source_id=source_id, dataset_id=dataset_id, limit=limit, priority=priority)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.post("/api/jobs/osm-relabel")
|
||||
@write_endpoint("queue OSM relabeling")
|
||||
def queue_osm_relabel(
|
||||
@@ -658,6 +713,28 @@ def list_jobs(
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/workers/start")
|
||||
def start_workers_endpoint() -> dict:
|
||||
started = start_queue_workers(force=True)
|
||||
return _worker_control_payload("start", started=started)
|
||||
|
||||
|
||||
@app.post("/api/workers/stop")
|
||||
def stop_workers_endpoint() -> dict:
|
||||
stopped = stop_configured_queue_workers()
|
||||
return _worker_control_payload("stop", stopped=stopped)
|
||||
|
||||
|
||||
@app.post("/api/workers/restart")
|
||||
def restart_workers_endpoint() -> dict:
|
||||
result = restart_queue_workers()
|
||||
return _worker_control_payload(
|
||||
"restart",
|
||||
stopped=result.get("stopped", []),
|
||||
started=result.get("started", []),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/jobs/revision")
|
||||
def get_jobs_revision(
|
||||
response: Response,
|
||||
@@ -870,6 +947,152 @@ def gtfs_harmonization_inventory_endpoint(db: Session = Depends(get_db)) -> dict
|
||||
return gtfs_harmonization_inventory(db)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot")
|
||||
def gtfs_harmonized_snapshot_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
return gtfs_harmonized_snapshot(db)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot/routes")
|
||||
def gtfs_harmonized_snapshot_routes_endpoint(
|
||||
role: Optional[str] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
source_id: Optional[int] = None,
|
||||
shadowed_by_dataset_id: Optional[int] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if role is not None and role not in {"included", "shadowed", "excluded"}:
|
||||
raise HTTPException(status_code=400, detail="role must be included, shadowed, or excluded")
|
||||
return list_active_harmonized_snapshot_routes(
|
||||
db,
|
||||
role=role,
|
||||
dataset_id=dataset_id,
|
||||
source_id=source_id,
|
||||
shadowed_by_dataset_id=shadowed_by_dataset_id,
|
||||
mode=mode,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot/routes.csv")
|
||||
def gtfs_harmonized_snapshot_routes_csv_endpoint(
|
||||
role: Optional[str] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
source_id: Optional[int] = None,
|
||||
shadowed_by_dataset_id: Optional[int] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 10000,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
if role is not None and role not in {"included", "shadowed", "excluded"}:
|
||||
raise HTTPException(status_code=400, detail="role must be included, shadowed, or excluded")
|
||||
payload = list_active_harmonized_snapshot_routes(
|
||||
db,
|
||||
role=role,
|
||||
dataset_id=dataset_id,
|
||||
source_id=source_id,
|
||||
shadowed_by_dataset_id=shadowed_by_dataset_id,
|
||||
mode=mode,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
max_limit=100_000,
|
||||
)
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"snapshot_id",
|
||||
"gtfs_route_id",
|
||||
"dataset_id",
|
||||
"source_id",
|
||||
"source_name",
|
||||
"route_id",
|
||||
"route_ref",
|
||||
"route_name",
|
||||
"mode",
|
||||
"operator",
|
||||
"overlap_key",
|
||||
"role",
|
||||
"reason",
|
||||
"shadowed_by_gtfs_route_id",
|
||||
"shadowed_by_dataset_id",
|
||||
"shadowed_by_source_id",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for route in payload.get("routes", []):
|
||||
writer.writerow(route)
|
||||
snapshot = payload.get("snapshot") or {}
|
||||
filename = f"gtfs_harmonized_snapshot_routes_{snapshot.get('id') or 'none'}.csv"
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/diffs")
|
||||
def list_gtfs_update_diffs(
|
||||
source_id: Optional[int] = None,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
"summary": gtfs_update_diff_summary(db),
|
||||
"diffs": [diff_run_payload(diff) for diff in latest_gtfs_update_diffs(db, source_id=source_id, limit=limit)],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/diffs/{diff_id}")
|
||||
def get_gtfs_update_diff(diff_id: int, item_limit: int = 200, db: Session = Depends(get_db)) -> dict:
|
||||
detail = gtfs_update_diff_detail(db, diff_id, item_limit=item_limit)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="GTFS update diff not found")
|
||||
return detail
|
||||
|
||||
|
||||
@app.get("/api/workbench/gtfs-workflow")
|
||||
def gtfs_workflow_status_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
inventory = gtfs_harmonization_inventory(db)
|
||||
snapshot_diagnostics = inventory.get("snapshot_diagnostics") or gtfs_harmonized_snapshot_diagnostics(db)
|
||||
snapshot = inventory["snapshot"]
|
||||
snapshot_summary = snapshot.get("summary") or {}
|
||||
snapshot_route_preview = (
|
||||
list_active_harmonized_snapshot_routes(db, role="shadowed", limit=8)
|
||||
if snapshot.get("persisted") and int(snapshot_summary.get("snapshot_route_rows") or 0) > 0
|
||||
else {"snapshot": None, "summary": {}, "routes": []}
|
||||
)
|
||||
review = map_gtfs_review_summary(db)
|
||||
diff_summary = gtfs_update_diff_summary(db)
|
||||
latest_diffs = [diff_run_payload(diff) for diff in latest_gtfs_update_diffs(db, limit=5)]
|
||||
jobs = latest_jobs(db, limit=8)
|
||||
return {
|
||||
"inventory_summary": inventory["summary"],
|
||||
"snapshot": snapshot,
|
||||
"snapshot_diagnostics": snapshot_diagnostics,
|
||||
"snapshot_route_preview": snapshot_route_preview,
|
||||
"review_summary": review,
|
||||
"diff_summary": diff_summary,
|
||||
"latest_diffs": latest_diffs,
|
||||
"jobs": [job_payload(job) for job in jobs],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/jobs/gtfs-harmonized-snapshot")
|
||||
@write_endpoint("queue harmonized GTFS snapshot build")
|
||||
def queue_gtfs_harmonized_snapshot(activate: bool = True, priority: int = 0, db: Session = Depends(get_db)) -> dict:
|
||||
job = create_gtfs_harmonized_snapshot_job(db, activate=activate, priority=priority)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/sources/{source_id}")
|
||||
def gtfs_harmonization_feed_detail_endpoint(source_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
detail = gtfs_harmonization_feed_detail(db, source_id)
|
||||
@@ -886,15 +1109,48 @@ def update_gtfs_harmonization_feed_review(source_id: int, payload: GtfsFeedRevie
|
||||
raise HTTPException(status_code=404, detail="GTFS source not found")
|
||||
if payload.review_status is not None and payload.review_status not in {"unreviewed", "approved", "needs_review", "blocked", "rejected"}:
|
||||
raise HTTPException(status_code=400, detail="review_status must be unreviewed, approved, needs_review, blocked, or rejected")
|
||||
license_flags = {
|
||||
"can_import": payload.can_import,
|
||||
"can_derive": payload.can_derive,
|
||||
"can_redistribute": payload.can_redistribute,
|
||||
"requires_attribution": payload.requires_attribution,
|
||||
"commercial_restrictions": payload.commercial_restrictions,
|
||||
}
|
||||
for field, value in license_flags.items():
|
||||
if value is not None and value not in {"unknown", "yes", "no"}:
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be unknown, yes, or no")
|
||||
if payload.authority_level is not None and payload.authority_level not in {
|
||||
"unknown",
|
||||
"national_official",
|
||||
"regional_authority",
|
||||
"operator",
|
||||
"aggregator",
|
||||
"mirror",
|
||||
"secondary_discovery",
|
||||
}:
|
||||
raise HTTPException(status_code=400, detail="authority_level is not valid")
|
||||
if payload.license is not None:
|
||||
source.license = _truncate(payload.license, 255)
|
||||
if payload.enabled is not None:
|
||||
source.enabled = bool(payload.enabled)
|
||||
if payload.review_status is not None or payload.review_note is not None:
|
||||
existing_review = gtfs_qa_review_payload(source.notes)
|
||||
review_fields_present = (
|
||||
payload.review_status is not None
|
||||
or payload.review_note is not None
|
||||
or any(value is not None for value in license_flags.values())
|
||||
or payload.authority_level is not None
|
||||
)
|
||||
if review_fields_present:
|
||||
source.notes = _upsert_gtfs_qa_note(
|
||||
source.notes,
|
||||
status=payload.review_status or "unreviewed",
|
||||
note=payload.review_note or "",
|
||||
status=payload.review_status or existing_review["status"],
|
||||
note=payload.review_note if payload.review_note is not None else existing_review["note"],
|
||||
can_import=payload.can_import or existing_review["can_import"],
|
||||
can_derive=payload.can_derive or existing_review["can_derive"],
|
||||
can_redistribute=payload.can_redistribute or existing_review["can_redistribute"],
|
||||
requires_attribution=payload.requires_attribution or existing_review["requires_attribution"],
|
||||
commercial_restrictions=payload.commercial_restrictions or existing_review["commercial_restrictions"],
|
||||
authority_level=payload.authority_level or existing_review["authority_level"],
|
||||
)
|
||||
db.commit()
|
||||
detail = gtfs_harmonization_feed_detail(db, source_id)
|
||||
@@ -903,6 +1159,84 @@ def update_gtfs_harmonization_feed_review(source_id: int, payload: GtfsFeedRevie
|
||||
return detail
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/summary")
|
||||
def map_gtfs_workbench_summary_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
return map_gtfs_review_summary(db)
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/review-items")
|
||||
def list_map_gtfs_workbench_review_items(
|
||||
queue: Optional[str] = None,
|
||||
status: Optional[str] = "open",
|
||||
severity: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
items = list_map_gtfs_review_items(
|
||||
db,
|
||||
queue=queue,
|
||||
status=status,
|
||||
severity=severity,
|
||||
mode=mode,
|
||||
q=q,
|
||||
source_id=source_id,
|
||||
dataset_id=dataset_id,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"summary": map_gtfs_review_summary(db),
|
||||
"filters": {
|
||||
"queue": queue,
|
||||
"status": status,
|
||||
"severity": severity,
|
||||
"mode": mode,
|
||||
"q": q,
|
||||
"source_id": source_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
"items": [review_item_payload(item) for item in items],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/review-items/{item_id}")
|
||||
def get_map_gtfs_workbench_review_item(item_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
item = db.get(MapGtfsReviewItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="review item not found")
|
||||
return review_item_payload(item)
|
||||
|
||||
|
||||
@app.post("/api/workbench/map-gtfs/review-items/generate")
|
||||
@write_endpoint("generate Map/GTFS review queue")
|
||||
def generate_map_gtfs_workbench_review_items(
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 1000,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
result = generate_map_gtfs_review_items(db, source_id=source_id, dataset_id=dataset_id, limit=limit)
|
||||
db.commit()
|
||||
return {"summary": map_gtfs_review_summary(db), "result": result}
|
||||
|
||||
|
||||
@app.patch("/api/workbench/map-gtfs/review-items/{item_id}")
|
||||
@write_endpoint("update Map/GTFS review item")
|
||||
def update_map_gtfs_workbench_review_item(item_id: int, payload: WorkbenchReviewUpdate, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
item = update_map_gtfs_review_item(db, item_id, status=payload.status, note=payload.note)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = 404 if detail == "review item not found" else 400
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return review_item_payload(item)
|
||||
|
||||
|
||||
@app.get("/api/matches")
|
||||
def list_matches(
|
||||
status: Optional[str] = None,
|
||||
@@ -933,6 +1267,7 @@ def accept_match(match_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
match.rule_source = "manual"
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "accept_match")
|
||||
record_route_match_decision(db, match, note="Accepted from route match review.")
|
||||
db.commit()
|
||||
return {"id": match.id, "status": match.status}
|
||||
|
||||
@@ -947,6 +1282,7 @@ def reject_match(match_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
match.rule_source = "manual"
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "reject_match")
|
||||
record_route_match_decision(db, match, note="Rejected from route match review.")
|
||||
db.commit()
|
||||
return {"id": match.id, "status": match.status}
|
||||
|
||||
@@ -1030,6 +1366,7 @@ def accept_match_candidate(match_id: int, osm_feature_id: str, db: Session = Dep
|
||||
match.reasons_json = json.dumps(reasons, separators=(",", ":"))
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "accept_match")
|
||||
record_route_match_decision(db, match, note="Accepted from route candidate review.")
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return {"id": match.id, "status": match.status, "confidence": match.confidence, "match": match_row(match)}
|
||||
@@ -1423,6 +1760,7 @@ def journey_nearest_location(
|
||||
source_ids=_csv_ints(source_id, "source_id"),
|
||||
limit=1,
|
||||
radius_m=max(5, min(float(stop_radius_m), 120)),
|
||||
grouped=False,
|
||||
)
|
||||
location = stops[0] if stops else None
|
||||
if location is not None:
|
||||
@@ -1509,7 +1847,8 @@ def journey_search(
|
||||
|
||||
@app.post("/api/journey/searches")
|
||||
def start_progressive_journey_search(payload: JourneySearchRequest) -> dict:
|
||||
mode = payload.mode if payload.mode in {"transit", "walk", "drive", "car"} else "transit"
|
||||
requested_mode = str(payload.mode or "transit").lower()
|
||||
mode = "bike" if requested_mode in {"bike", "bicycle", "cycle", "cycling"} else requested_mode if requested_mode in {"transit", "walk", "drive", "car"} else "transit"
|
||||
ranking = payload.ranking if payload.ranking in {"recommended", "earliest_arrival", "duration", "fewest_transfers"} else "recommended"
|
||||
try:
|
||||
return start_journey_search(
|
||||
@@ -1978,6 +2317,29 @@ def _json_object_value(value: object, key: str) -> object:
|
||||
return value.get(key) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _worker_control_payload(action: str, **groups: list[object]) -> dict:
|
||||
return {
|
||||
"action": action,
|
||||
"operations": {
|
||||
name: [_worker_handle_payload(handle) for handle in handles]
|
||||
for name, handles in groups.items()
|
||||
},
|
||||
"workers": queue_worker_status(),
|
||||
}
|
||||
|
||||
|
||||
def _worker_handle_payload(handle: object) -> dict:
|
||||
return {
|
||||
"index": getattr(handle, "index", None),
|
||||
"worker_id": getattr(handle, "worker_id", None),
|
||||
"pid": getattr(handle, "pid", None),
|
||||
"status": getattr(handle, "status", None),
|
||||
"pid_file": str(getattr(handle, "pid_file", "")),
|
||||
"log_file": str(getattr(handle, "log_file", "")),
|
||||
"started_by_server": bool(getattr(handle, "started_by_server", False)),
|
||||
}
|
||||
|
||||
|
||||
def _job_queue_revision_payload(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -2018,19 +2380,51 @@ def _set_etag(response: Response, revision: str) -> None:
|
||||
response.headers["ETag"] = f'W/"{revision}"'
|
||||
|
||||
|
||||
def _upsert_gtfs_qa_note(notes: str | None, *, status: str, note: str) -> str | None:
|
||||
def _upsert_gtfs_qa_note(
|
||||
notes: str | None,
|
||||
*,
|
||||
status: str,
|
||||
note: str,
|
||||
can_import: str | None = None,
|
||||
can_derive: str | None = None,
|
||||
can_redistribute: str | None = None,
|
||||
requires_attribution: str | None = None,
|
||||
commercial_restrictions: str | None = None,
|
||||
authority_level: str | None = None,
|
||||
) -> str | None:
|
||||
status_text = (status or "unreviewed").strip() or "unreviewed"
|
||||
note_text = " ".join((note or "").strip().split())
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
marker = f"{GTFS_QA_NOTE_PREFIX} status={status_text}; updated_at={updated_at}"
|
||||
if note_text:
|
||||
marker = f"{marker}; note={note_text}"
|
||||
payload = {
|
||||
"status": status_text,
|
||||
"updated_at": updated_at,
|
||||
"note": note_text,
|
||||
"can_import": can_import or "unknown",
|
||||
"can_derive": can_derive or "unknown",
|
||||
"can_redistribute": can_redistribute or "unknown",
|
||||
"requires_attribution": requires_attribution or "unknown",
|
||||
"commercial_restrictions": commercial_restrictions or "unknown",
|
||||
"authority_level": authority_level or "unknown",
|
||||
}
|
||||
marker = f"{GTFS_QA_NOTE_PREFIX} {json.dumps(payload, sort_keys=True, separators=(',', ':'))}"
|
||||
preserved = [
|
||||
line
|
||||
for line in str(notes or "").splitlines()
|
||||
if line.strip() and not line.startswith(GTFS_QA_NOTE_PREFIX)
|
||||
]
|
||||
if status_text != "unreviewed" or note_text:
|
||||
has_decision = (
|
||||
status_text != "unreviewed"
|
||||
or note_text
|
||||
or any(payload[key] != "unknown" for key in [
|
||||
"can_import",
|
||||
"can_derive",
|
||||
"can_redistribute",
|
||||
"requires_attribution",
|
||||
"commercial_restrictions",
|
||||
"authority_level",
|
||||
])
|
||||
)
|
||||
if has_decision:
|
||||
preserved.insert(0, marker)
|
||||
return "\n".join(preserved) or None
|
||||
|
||||
|
||||
+186
@@ -81,6 +81,115 @@ class Dataset(Base):
|
||||
source: Mapped[Source] = relationship(back_populates="datasets")
|
||||
|
||||
|
||||
class GtfsHarmonizedSnapshot(Base):
|
||||
__tablename__ = "gtfs_harmonized_snapshots"
|
||||
__table_args__ = (UniqueConstraint("snapshot_key", name="uq_gtfs_harmonized_snapshot_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
snapshot_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False, default="draft", index=True)
|
||||
source: Mapped[str] = mapped_column(String(64), nullable=False, default="computed")
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
summary_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
datasets: Mapped[list["GtfsHarmonizedSnapshotDataset"]] = relationship(back_populates="snapshot", cascade="all, delete-orphan")
|
||||
routes: Mapped[list["GtfsHarmonizedSnapshotRoute"]] = relationship(back_populates="snapshot", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GtfsHarmonizedSnapshotDataset(Base):
|
||||
__tablename__ = "gtfs_harmonized_snapshot_datasets"
|
||||
__table_args__ = (UniqueConstraint("snapshot_id", "dataset_id", name="uq_gtfs_harmonized_snapshot_dataset"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(ForeignKey("gtfs_harmonized_snapshots.id"), nullable=False, index=True)
|
||||
dataset_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
source_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
source_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
shadowed_by_dataset_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
shadowed_by_source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
authority_level: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
review_authority_level: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
route_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
route_key_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
route_key_overlap: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
route_key_overlap_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
|
||||
snapshot: Mapped[GtfsHarmonizedSnapshot] = relationship(back_populates="datasets")
|
||||
|
||||
|
||||
class GtfsHarmonizedSnapshotRoute(Base):
|
||||
__tablename__ = "gtfs_harmonized_snapshot_routes"
|
||||
__table_args__ = (UniqueConstraint("snapshot_id", "gtfs_route_id", name="uq_gtfs_harmonized_snapshot_route"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(ForeignKey("gtfs_harmonized_snapshots.id"), nullable=False, index=True)
|
||||
gtfs_route_id: Mapped[int] = mapped_column(ForeignKey("gtfs_routes.id"), nullable=False, index=True)
|
||||
dataset_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
source_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
route_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
overlap_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||||
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
shadowed_by_gtfs_route_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gtfs_routes.id"), nullable=True, index=True)
|
||||
shadowed_by_dataset_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
shadowed_by_source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
|
||||
snapshot: Mapped[GtfsHarmonizedSnapshot] = relationship(back_populates="routes")
|
||||
gtfs_route: Mapped["GtfsRoute"] = relationship(foreign_keys=[gtfs_route_id])
|
||||
shadowed_by_gtfs_route: Mapped[Optional["GtfsRoute"]] = relationship(foreign_keys=[shadowed_by_gtfs_route_id])
|
||||
|
||||
|
||||
class GtfsFeedDiffRun(Base):
|
||||
__tablename__ = "gtfs_feed_diff_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False, index=True)
|
||||
previous_dataset_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
new_dataset_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False, default="completed", index=True)
|
||||
diff_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
summary_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
|
||||
source: Mapped[Source] = relationship()
|
||||
items: Mapped[list["GtfsFeedDiffItem"]] = relationship(back_populates="diff_run", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GtfsFeedDiffItem(Base):
|
||||
__tablename__ = "gtfs_feed_diff_items"
|
||||
__table_args__ = (UniqueConstraint("diff_run_id", "item_type", "object_key", "change_type", name="uq_gtfs_feed_diff_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
diff_run_id: Mapped[int] = mapped_column(ForeignKey("gtfs_feed_diff_runs.id"), nullable=False, index=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False, index=True)
|
||||
previous_dataset_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
new_dataset_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
item_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
object_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
change_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False, default="open", index=True)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
old_payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
new_payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
diff_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
diff_run: Mapped[GtfsFeedDiffRun] = relationship(back_populates="items")
|
||||
source: Mapped[Source] = relationship()
|
||||
|
||||
|
||||
class SourceUpdateCheck(Base):
|
||||
__tablename__ = "source_update_checks"
|
||||
|
||||
@@ -543,6 +652,83 @@ class MatchRule(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False)
|
||||
|
||||
|
||||
class MapGtfsDecision(Base):
|
||||
__tablename__ = "map_gtfs_decisions"
|
||||
__table_args__ = (UniqueConstraint("decision_key", name="uq_map_gtfs_decision_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
decision_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
decision_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(ForeignKey("sources.id"), nullable=True, index=True)
|
||||
gtfs_dataset_id: Mapped[Optional[int]] = mapped_column(ForeignKey("datasets.id"), nullable=True, index=True)
|
||||
gtfs_route_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gtfs_routes.id"), nullable=True, index=True)
|
||||
gtfs_stop_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gtfs_stops.id"), nullable=True, index=True)
|
||||
osm_dataset_id: Mapped[Optional[int]] = mapped_column(ForeignKey("datasets.id"), nullable=True, index=True)
|
||||
osm_feature_id: Mapped[Optional[int]] = mapped_column(ForeignKey("osm_features.id"), nullable=True, index=True)
|
||||
route_match_id: Mapped[Optional[int]] = mapped_column(ForeignKey("route_matches.id"), nullable=True, index=True)
|
||||
canonical_stop_id: Mapped[Optional[int]] = mapped_column(ForeignKey("canonical_stops.id"), nullable=True, index=True)
|
||||
canonical_stop_link_id: Mapped[Optional[int]] = mapped_column(ForeignKey("canonical_stop_links.id"), nullable=True, index=True)
|
||||
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
evidence_hash: Mapped[Optional[str]] = mapped_column(String(128), nullable=True, index=True)
|
||||
selector_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
action_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
reviewer: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
|
||||
source: Mapped[Optional[Source]] = relationship(foreign_keys=[source_id])
|
||||
gtfs_dataset: Mapped[Optional[Dataset]] = relationship(foreign_keys=[gtfs_dataset_id])
|
||||
gtfs_route: Mapped[Optional[GtfsRoute]] = relationship()
|
||||
gtfs_stop: Mapped[Optional[GtfsStop]] = relationship()
|
||||
osm_dataset: Mapped[Optional[Dataset]] = relationship(foreign_keys=[osm_dataset_id])
|
||||
osm_feature: Mapped[Optional[OsmFeature]] = relationship()
|
||||
route_match: Mapped[Optional[RouteMatch]] = relationship()
|
||||
canonical_stop: Mapped[Optional[CanonicalStop]] = relationship()
|
||||
canonical_stop_link: Mapped[Optional[CanonicalStopLink]] = relationship()
|
||||
|
||||
|
||||
class MapGtfsReviewItem(Base):
|
||||
__tablename__ = "map_gtfs_review_items"
|
||||
__table_args__ = (UniqueConstraint("fingerprint", name="uq_map_gtfs_review_item_fingerprint"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
queue: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
item_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False, default="open", index=True)
|
||||
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", index=True)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(ForeignKey("sources.id"), nullable=True, index=True)
|
||||
dataset_id: Mapped[Optional[int]] = mapped_column(ForeignKey("datasets.id"), nullable=True, index=True)
|
||||
gtfs_route_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gtfs_routes.id"), nullable=True, index=True)
|
||||
gtfs_stop_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gtfs_stops.id"), nullable=True, index=True)
|
||||
osm_feature_id: Mapped[Optional[int]] = mapped_column(ForeignKey("osm_features.id"), nullable=True, index=True)
|
||||
route_match_id: Mapped[Optional[int]] = mapped_column(ForeignKey("route_matches.id"), nullable=True, index=True)
|
||||
canonical_stop_id: Mapped[Optional[int]] = mapped_column(ForeignKey("canonical_stops.id"), nullable=True, index=True)
|
||||
canonical_stop_link_id: Mapped[Optional[int]] = mapped_column(ForeignKey("canonical_stop_links.id"), nullable=True, index=True)
|
||||
decision_id: Mapped[Optional[int]] = mapped_column(ForeignKey("map_gtfs_decisions.id"), nullable=True, index=True)
|
||||
evidence_hash: Mapped[Optional[str]] = mapped_column(String(128), nullable=True, index=True)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, nullable=False, index=True)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
source: Mapped[Optional[Source]] = relationship(foreign_keys=[source_id])
|
||||
dataset: Mapped[Optional[Dataset]] = relationship(foreign_keys=[dataset_id])
|
||||
gtfs_route: Mapped[Optional[GtfsRoute]] = relationship()
|
||||
gtfs_stop: Mapped[Optional[GtfsStop]] = relationship()
|
||||
osm_feature: Mapped[Optional[OsmFeature]] = relationship()
|
||||
route_match: Mapped[Optional[RouteMatch]] = relationship()
|
||||
canonical_stop: Mapped[Optional[CanonicalStop]] = relationship()
|
||||
canonical_stop_link: Mapped[Optional[CanonicalStopLink]] = relationship()
|
||||
decision: Mapped[Optional[MapGtfsDecision]] = relationship()
|
||||
|
||||
|
||||
class JourneySearchCache(Base):
|
||||
__tablename__ = "journey_search_cache"
|
||||
|
||||
|
||||
+6
-1
@@ -293,7 +293,12 @@ def query_osm_features(
|
||||
dataset.id: dataset
|
||||
for dataset in session.scalars(select(Dataset).where(Dataset.id.in_([int(value) for value in dataset_ids]))).all()
|
||||
}
|
||||
materialized_ids = _materialized_ids_by_identity(session, list(datasets)) if prefer_materialized_ids else {}
|
||||
sidecar_dataset_ids = [
|
||||
dataset_id
|
||||
for dataset_id, dataset in datasets.items()
|
||||
if features_are_sidecar(dataset)
|
||||
]
|
||||
materialized_ids = _materialized_ids_by_identity(session, sidecar_dataset_ids) if prefer_materialized_ids else {}
|
||||
rows: list[OsmFeature] = []
|
||||
main_dataset_ids = [dataset_id for dataset_id, dataset in datasets.items() if not features_are_sidecar(dataset)]
|
||||
if main_dataset_ids:
|
||||
|
||||
+132
-5
@@ -15,6 +15,8 @@ from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.decision_replay import apply_gtfs_decision_replay, capture_gtfs_decision_replay
|
||||
from app.gtfs_diff import create_gtfs_update_diff_from_stage
|
||||
from app.gtfs_storage import GTFS_STORAGE_MAIN, GTFS_STORAGE_METADATA_KEY, GTFS_STORAGE_SIDECAR_STOP_TIMES, effective_gtfs_timetable_storage
|
||||
from app.models import (
|
||||
Dataset,
|
||||
@@ -64,6 +66,7 @@ GTFS_EXTENDED_MODE_RANGES = [
|
||||
GTFS_IMPORTER_VERSION = "gtfs_import_v6_sidecar_stop_times"
|
||||
|
||||
REQUIRED_FILES = {"agency.txt", "stops.txt", "routes.txt", "trips.txt", "stop_times.txt"}
|
||||
GTFS_CALENDAR_WEEKDAY_COLUMNS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||
GTFS_STAGE_BATCH_SIZE = 50_000
|
||||
ProgressCallback = Callable[[str, str, int | None, int | None, dict[str, Any] | None], None]
|
||||
|
||||
@@ -82,7 +85,7 @@ def run_gtfs_source(session: Session, source: Source, progress_callback: Progres
|
||||
)
|
||||
.order_by(Dataset.id.desc())
|
||||
)
|
||||
if existing is not None and _dataset_importer_version(existing) == GTFS_IMPORTER_VERSION:
|
||||
if existing is not None and _dataset_import_can_reuse(existing):
|
||||
return existing
|
||||
return import_gtfs_zip(session=session, source=source, zip_path=local_path, source_hash=source_hash, progress_callback=progress_callback)
|
||||
|
||||
@@ -395,15 +398,96 @@ def _activate_staged_gtfs(
|
||||
dataset = session.get(Dataset, dataset.id) or dataset
|
||||
source = session.get(Source, source.id) or source
|
||||
replaced_datasets = [existing for existing in list(source.datasets) if existing.id != dataset.id and existing.kind == "gtfs"]
|
||||
previous_dataset = next(
|
||||
(
|
||||
existing
|
||||
for existing in sorted(replaced_datasets, key=lambda item: (not item.is_active, item.created_at, item.id))
|
||||
if existing.status == "imported"
|
||||
),
|
||||
None,
|
||||
)
|
||||
for existing in source.datasets:
|
||||
if existing.id != dataset.id:
|
||||
existing.is_active = False
|
||||
copy_stop_times = _copy_stop_times_to_main(summary)
|
||||
heavy_index_drop = copy_stop_times and _should_drop_indexes_for_activation(stage_path)
|
||||
diff_result: dict[str, Any] | None = None
|
||||
decision_replay_plan: dict[str, Any] | None = None
|
||||
if heavy_index_drop:
|
||||
_emit_progress(progress_callback, "gtfs_activation_indexes_dropped", "Dropping heavy GTFS lookup indexes before bulk activation.", None, None, None)
|
||||
_drop_gtfs_bulk_indexes(session.connection())
|
||||
try:
|
||||
if previous_dataset is not None:
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_update_diff_started",
|
||||
f"Comparing staged GTFS dataset #{dataset.id} with previous dataset #{previous_dataset.id}.",
|
||||
None,
|
||||
None,
|
||||
{"previous_dataset_id": previous_dataset.id, "new_dataset_id": dataset.id},
|
||||
)
|
||||
try:
|
||||
diff_result = create_gtfs_update_diff_from_stage(
|
||||
session,
|
||||
source=source,
|
||||
previous_dataset=previous_dataset,
|
||||
new_dataset=dataset,
|
||||
stage_path=stage_path,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - update diff must not block activation
|
||||
diff_result = None
|
||||
summary["gtfs_update_diff_error"] = str(exc)
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_update_diff_failed",
|
||||
"GTFS update diff failed; continuing dataset activation.",
|
||||
None,
|
||||
None,
|
||||
{"error": str(exc), "previous_dataset_id": previous_dataset.id, "new_dataset_id": dataset.id},
|
||||
)
|
||||
if diff_result is not None:
|
||||
summary["gtfs_update_diff"] = {
|
||||
"id": diff_result["id"],
|
||||
"summary": diff_result["summary"],
|
||||
}
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_update_diff_completed",
|
||||
"Stored GTFS update diff before replacing the previous dataset.",
|
||||
None,
|
||||
None,
|
||||
diff_result,
|
||||
)
|
||||
try:
|
||||
decision_replay_plan = capture_gtfs_decision_replay(
|
||||
session,
|
||||
previous_dataset,
|
||||
diff_run_id=None if diff_result is None else int(diff_result["id"]),
|
||||
)
|
||||
summary["gtfs_decision_replay"] = {
|
||||
"captured": len(decision_replay_plan.get("decisions", [])),
|
||||
"diff_run_id": decision_replay_plan.get("diff_run_id"),
|
||||
"previous_dataset_id": previous_dataset.id,
|
||||
}
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_decision_replay_captured",
|
||||
"Captured existing GTFS-to-map decisions for update replay.",
|
||||
None,
|
||||
None,
|
||||
summary["gtfs_decision_replay"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - replay capture must not block activation
|
||||
decision_replay_plan = None
|
||||
summary["gtfs_decision_replay_error"] = str(exc)
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_decision_replay_capture_failed",
|
||||
"GTFS decision replay capture failed; continuing dataset activation.",
|
||||
None,
|
||||
None,
|
||||
{"error": str(exc), "previous_dataset_id": previous_dataset.id, "new_dataset_id": dataset.id},
|
||||
)
|
||||
if replaced_datasets:
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
@@ -511,6 +595,33 @@ def _activate_staged_gtfs(
|
||||
],
|
||||
progress_callback,
|
||||
)
|
||||
if decision_replay_plan is not None:
|
||||
try:
|
||||
replay_result = apply_gtfs_decision_replay(
|
||||
session,
|
||||
decision_replay_plan,
|
||||
new_dataset=dataset,
|
||||
diff_run_id=None if diff_result is None else int(diff_result["id"]),
|
||||
)
|
||||
summary["gtfs_decision_replay"] = replay_result
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_decision_replay_completed",
|
||||
"Replayed unchanged GTFS-to-map decisions and queued changed decisions for review.",
|
||||
None,
|
||||
None,
|
||||
replay_result,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - replay must not block activation
|
||||
summary["gtfs_decision_replay_error"] = str(exc)
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"gtfs_decision_replay_failed",
|
||||
"GTFS decision replay failed; continuing dataset activation.",
|
||||
None,
|
||||
None,
|
||||
{"error": str(exc), "new_dataset_id": dataset.id},
|
||||
)
|
||||
finally:
|
||||
if heavy_index_drop:
|
||||
_emit_progress(progress_callback, "gtfs_activation_indexes_rebuilding", "Rebuilding GTFS lookup indexes after bulk activation.", None, None, None)
|
||||
@@ -557,10 +668,7 @@ def _copy_stage_table(
|
||||
rows = cursor.fetchmany(GTFS_STAGE_BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
payload = [
|
||||
{"dataset_id": dataset_id, **{column: row[index] for index, column in enumerate(columns)}}
|
||||
for row in rows
|
||||
]
|
||||
payload = [_copy_stage_row_payload(table, dataset_id, columns, row) for row in rows]
|
||||
session.execute(text(insert_sql), payload)
|
||||
copied += len(rows)
|
||||
_emit_progress(
|
||||
@@ -573,6 +681,15 @@ def _copy_stage_table(
|
||||
)
|
||||
|
||||
|
||||
def _copy_stage_row_payload(table: str, dataset_id: int, columns: list[str], row: sqlite3.Row | tuple[Any, ...]) -> dict[str, Any]:
|
||||
payload = {"dataset_id": dataset_id, **{column: row[index] for index, column in enumerate(columns)}}
|
||||
if table == "gtfs_calendars":
|
||||
for column in GTFS_CALENDAR_WEEKDAY_COLUMNS:
|
||||
if column in payload:
|
||||
payload[column] = _bool_flag(payload[column])
|
||||
return payload
|
||||
|
||||
|
||||
def _should_drop_indexes_for_activation(stage_path: Path) -> bool:
|
||||
if settings.is_postgresql_database:
|
||||
return False
|
||||
@@ -1325,3 +1442,13 @@ def _dataset_importer_version(dataset: Dataset) -> str:
|
||||
return str(json.loads(dataset.metadata_json or "{}").get("importer") or "")
|
||||
except json.JSONDecodeError:
|
||||
return ""
|
||||
|
||||
|
||||
def _dataset_import_can_reuse(dataset: Dataset) -> bool:
|
||||
try:
|
||||
metadata = json.loads(dataset.metadata_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
if str(metadata.get("importer") or "") != GTFS_IMPORTER_VERSION:
|
||||
return False
|
||||
return int(metadata.get("stop_times_import_limit") or 0) == int(settings.gtfs_stop_times_import_limit or 0)
|
||||
|
||||
+143
-29
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
from shapely.geometry import LineString, MultiLineString, Point, shape
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.harmonization import active_harmonized_gtfs_shadowed_route_ids
|
||||
from app.config import settings
|
||||
from app.models import Dataset, GtfsRoute, MatchRule, OsmFeature, RouteMatch
|
||||
from app.osm_storage import ensure_main_osm_feature, osm_feature_bbox, query_osm_features
|
||||
@@ -34,7 +36,7 @@ MAX_EXACT_REF_CANDIDATES = 120
|
||||
OSM_SCOPE_NEAR_DISTANCE_DEG = 0.15
|
||||
GEOMETRY_PROXIMITY_DEG = 0.0035
|
||||
GEOMETRY_SAMPLE_POINTS = 24
|
||||
MATCHER_VERSION = "matcher_v4_scope_spatial_manual_rules"
|
||||
MATCHER_VERSION = "matcher_v5_spatial_candidate_gate"
|
||||
ProgressCallback = Callable[[str, str, int | None, int | None, dict[str, object] | None], None]
|
||||
|
||||
|
||||
@@ -78,6 +80,7 @@ def run_route_matching(
|
||||
*,
|
||||
progress_callback: ProgressCallback | None = None,
|
||||
batch_size: int | None = None,
|
||||
gtfs_dataset_ids: Sequence[int] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Match active GTFS routes against active OSM route features."""
|
||||
active_datasets = session.execute(
|
||||
@@ -86,22 +89,37 @@ def run_route_matching(
|
||||
if not active_datasets:
|
||||
return {"routes": 0, "matches": 0, "missing": 0}
|
||||
dataset_source_ids = {int(dataset_id): int(source_id) for dataset_id, _, source_id in active_datasets}
|
||||
gtfs_dataset_ids = [int(dataset_id) for dataset_id, kind, _ in active_datasets if kind == "gtfs"]
|
||||
active_gtfs_dataset_ids = [int(dataset_id) for dataset_id, kind, _ in active_datasets if kind == "gtfs"]
|
||||
osm_dataset_ids = [int(dataset_id) for dataset_id, kind, _ in active_datasets if kind == "osm_geojson"]
|
||||
if not gtfs_dataset_ids:
|
||||
if gtfs_dataset_ids is None:
|
||||
selected_gtfs_dataset_ids = active_gtfs_dataset_ids
|
||||
else:
|
||||
requested_gtfs_dataset_ids = {int(dataset_id) for dataset_id in gtfs_dataset_ids}
|
||||
selected_gtfs_dataset_ids = [
|
||||
dataset_id
|
||||
for dataset_id in active_gtfs_dataset_ids
|
||||
if dataset_id in requested_gtfs_dataset_ids
|
||||
]
|
||||
if not selected_gtfs_dataset_ids:
|
||||
return {"routes": 0, "matches": 0, "missing": 0}
|
||||
|
||||
route_row_ids = session.scalars(
|
||||
select(GtfsRoute.id)
|
||||
.where(GtfsRoute.dataset_id.in_(gtfs_dataset_ids))
|
||||
.order_by(GtfsRoute.dataset_id, GtfsRoute.route_id, GtfsRoute.id)
|
||||
).all()
|
||||
all_route_row_ids = [
|
||||
int(route_id)
|
||||
for route_id in session.scalars(
|
||||
select(GtfsRoute.id)
|
||||
.where(GtfsRoute.dataset_id.in_(selected_gtfs_dataset_ids))
|
||||
.order_by(GtfsRoute.dataset_id, GtfsRoute.route_id, GtfsRoute.id)
|
||||
).all()
|
||||
]
|
||||
shadowed_route_ids = active_harmonized_gtfs_shadowed_route_ids(session)
|
||||
route_row_ids = [route_id for route_id in all_route_row_ids if route_id not in shadowed_route_ids]
|
||||
shadowed_skipped = len(all_route_row_ids) - len(route_row_ids)
|
||||
# Reconcile current match rows from auto scoring plus durable manual rules.
|
||||
total_routes = len(route_row_ids)
|
||||
if total_routes == 0:
|
||||
return {"routes": 0, "matches": 0, "missing": 0}
|
||||
return {"routes": 0, "matches": 0, "missing": 0, "shadowed_skipped": shadowed_skipped}
|
||||
|
||||
dependency = _route_matching_dependency(session, active_datasets)
|
||||
dependency = _route_matching_dependency(session, active_datasets, shadowed_route_ids=shadowed_route_ids)
|
||||
run = start_pipeline_run(
|
||||
session,
|
||||
stage=STAGE_MATCH_ROUTES,
|
||||
@@ -117,11 +135,49 @@ def run_route_matching(
|
||||
f"Matching {total_routes} GTFS routes in batches of {effective_batch_size}.",
|
||||
0,
|
||||
total_routes,
|
||||
{"gtfs_datasets": gtfs_dataset_ids, "osm_datasets": osm_dataset_ids, "batch_size": effective_batch_size},
|
||||
{
|
||||
"gtfs_datasets": selected_gtfs_dataset_ids,
|
||||
"osm_datasets": osm_dataset_ids,
|
||||
"batch_size": effective_batch_size,
|
||||
"shadowed_skipped": shadowed_skipped,
|
||||
},
|
||||
)
|
||||
manual_rules = _manual_match_rules(session)
|
||||
osm_scope_bbox = osm_feature_bbox(session, osm_dataset_ids, kinds=["route"])
|
||||
counts = {"routes": total_routes, "matches": 0, "missing": 0, "manual": 0, "created": 0, "updated": 0, "unchanged": 0}
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"route_matching_osm_index_started",
|
||||
"Loading OSM route candidates for matcher.",
|
||||
0,
|
||||
None,
|
||||
{"osm_datasets": osm_dataset_ids},
|
||||
)
|
||||
osm_routes = query_osm_features(
|
||||
session,
|
||||
osm_dataset_ids,
|
||||
kinds=["route"],
|
||||
prefer_materialized_ids=False,
|
||||
)
|
||||
osm_route_index = _build_osm_route_index(osm_routes)
|
||||
feature_profile_cache: dict[tuple[int, str, str], _GeometryProfile | None] = {}
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"route_matching_osm_index_completed",
|
||||
f"Loaded {len(osm_routes)} OSM route candidates.",
|
||||
len(osm_routes),
|
||||
None,
|
||||
{"osm_routes": len(osm_routes)},
|
||||
)
|
||||
counts = {
|
||||
"routes": total_routes,
|
||||
"matches": 0,
|
||||
"missing": 0,
|
||||
"manual": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"shadowed_skipped": shadowed_skipped,
|
||||
}
|
||||
scoped_counts = {"in_osm_scope": 0, "near_osm_scope": 0, "outside_osm_scope": 0, "unknown_scope": 0}
|
||||
processed = 0
|
||||
for chunk in _chunks_int(route_row_ids, effective_batch_size):
|
||||
@@ -137,6 +193,8 @@ def run_route_matching(
|
||||
dataset_source_ids=dataset_source_ids,
|
||||
manual_rules=manual_rules,
|
||||
osm_scope_bbox=osm_scope_bbox,
|
||||
osm_route_index=osm_route_index,
|
||||
feature_profile_cache=feature_profile_cache,
|
||||
scoped_counts=scoped_counts,
|
||||
)
|
||||
counts["matches"] += batch_counts["matches"]
|
||||
@@ -178,7 +236,12 @@ def run_route_matching(
|
||||
return result
|
||||
|
||||
|
||||
def _route_matching_dependency(session: Session, active_datasets) -> dict[str, object]:
|
||||
def _route_matching_dependency(
|
||||
session: Session,
|
||||
active_datasets,
|
||||
*,
|
||||
shadowed_route_ids: set[int] | None = None,
|
||||
) -> dict[str, object]:
|
||||
datasets = [
|
||||
{"id": int(dataset_id), "kind": str(kind), "source_id": int(source_id), "sha256": _dataset_sha(session, int(dataset_id))}
|
||||
for dataset_id, kind, source_id in active_datasets
|
||||
@@ -193,7 +256,15 @@ def _route_matching_dependency(session: Session, active_datasets) -> dict[str, o
|
||||
}
|
||||
for rule in session.scalars(select(MatchRule).order_by(MatchRule.id)).all()
|
||||
]
|
||||
return {"version": MATCHER_VERSION, "active_datasets": datasets, "manual_rules": rules}
|
||||
return {
|
||||
"version": MATCHER_VERSION,
|
||||
"active_datasets": datasets,
|
||||
"manual_rules": rules,
|
||||
"shadowed_routes": {
|
||||
"count": len(shadowed_route_ids or set()),
|
||||
"digest": _int_set_digest(shadowed_route_ids or set()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _dataset_sha(session: Session, dataset_id: int) -> str | None:
|
||||
@@ -201,6 +272,13 @@ def _dataset_sha(session: Session, dataset_id: int) -> str | None:
|
||||
return None if dataset is None else dataset.sha256
|
||||
|
||||
|
||||
def _int_set_digest(values: set[int]) -> str:
|
||||
if not values:
|
||||
return ""
|
||||
payload = ",".join(str(value) for value in sorted(values))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def _match_route_batch(
|
||||
*,
|
||||
session: Session,
|
||||
@@ -209,6 +287,8 @@ def _match_route_batch(
|
||||
dataset_source_ids: dict[int, int],
|
||||
manual_rules: list[_ManualMatchRule],
|
||||
osm_scope_bbox: tuple[float | None, float | None, float | None, float | None],
|
||||
osm_route_index: _OsmRouteIndex,
|
||||
feature_profile_cache: dict[tuple[int, str, str], _GeometryProfile | None],
|
||||
scoped_counts: dict[str, int],
|
||||
) -> dict[str, int]:
|
||||
matches = 0
|
||||
@@ -265,10 +345,13 @@ def _match_route_batch(
|
||||
best_score = 0.0
|
||||
best_reasons: dict[str, object] = {}
|
||||
route_geometry_profile = _geometry_profile(route.geometry_geojson)
|
||||
for feature in candidate_osm_routes_for_route(session, route, osm_dataset_ids):
|
||||
for feature in _candidate_osm_routes(route, osm_route_index):
|
||||
if _is_rejected_pair(manual_rules, route, route_source_id, feature, dataset_source_ids.get(feature.dataset_id)):
|
||||
continue
|
||||
feature_geometry_profile = _geometry_profile(feature.geometry_geojson)
|
||||
feature_key = _feature_identity_key(feature)
|
||||
if feature_key not in feature_profile_cache:
|
||||
feature_profile_cache[feature_key] = _geometry_profile(feature.geometry_geojson)
|
||||
feature_geometry_profile = feature_profile_cache[feature_key]
|
||||
score, reasons = score_route_pair(
|
||||
route,
|
||||
feature,
|
||||
@@ -393,22 +476,26 @@ def _build_osm_route_index(osm_routes: list[OsmFeature]) -> _OsmRouteIndex:
|
||||
|
||||
def _candidate_osm_routes(route: GtfsRoute, index: _OsmRouteIndex) -> list[OsmFeature]:
|
||||
selected: list[OsmFeature] = []
|
||||
seen: set[int] = set()
|
||||
seen: set[tuple[int, str, str]] = set()
|
||||
|
||||
def add(features: list[OsmFeature], *, require_compatible_mode: bool = True) -> None:
|
||||
def add(features: list[OsmFeature], *, require_compatible_mode: bool = True, require_spatial: bool = False) -> None:
|
||||
for feature in features:
|
||||
if feature.id in seen:
|
||||
key = (feature.dataset_id, feature.osm_type, feature.osm_id)
|
||||
if key in seen:
|
||||
continue
|
||||
if require_compatible_mode and not _mode_compatible(route.mode or "", feature.mode or ""):
|
||||
continue
|
||||
seen.add(feature.id)
|
||||
if require_spatial and not _candidate_is_spatially_plausible(route, feature):
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(feature)
|
||||
|
||||
route_ref = norm_ref(route.short_name or route.route_id)
|
||||
route_has_bbox = _has_known_bbox((route.min_lon, route.min_lat, route.max_lon, route.max_lat))
|
||||
if route_ref:
|
||||
add(index.by_ref.get(route_ref, []))
|
||||
add(index.by_ref.get(route_ref, []), require_spatial=route_has_bbox)
|
||||
if route.route_key:
|
||||
add(index.by_route_key.get(route.route_key, []))
|
||||
add(index.by_route_key.get(route.route_key, []), require_spatial=route_has_bbox)
|
||||
if selected:
|
||||
return _spatially_ranked_candidates(route, selected, MAX_EXACT_REF_CANDIDATES)
|
||||
|
||||
@@ -431,7 +518,7 @@ def _candidate_osm_routes(route: GtfsRoute, index: _OsmRouteIndex) -> list[OsmFe
|
||||
near_candidates.append((distance, feature))
|
||||
fallback_limit = MAX_FALLBACK_CANDIDATES_WITH_REF if route_ref else MAX_FALLBACK_CANDIDATES_WITHOUT_REF
|
||||
fallback = [feature for _, feature in sorted(near_candidates, key=lambda item: item[0])[:fallback_limit]]
|
||||
if not fallback:
|
||||
if not fallback and not route_has_bbox:
|
||||
fallback = mode_candidates[:fallback_limit]
|
||||
add(fallback)
|
||||
return _spatially_ranked_candidates(route, selected, fallback_limit)
|
||||
@@ -443,17 +530,22 @@ def candidate_osm_routes_for_route(session: Session, route: GtfsRoute, osm_datas
|
||||
selected: list[OsmFeature] = []
|
||||
seen: set[tuple[int, str, str]] = set()
|
||||
|
||||
def add(features: list[OsmFeature], *, require_compatible_mode: bool = True) -> None:
|
||||
def add(features: list[OsmFeature], *, require_compatible_mode: bool = True, require_spatial: bool = False) -> None:
|
||||
for feature in features:
|
||||
key = (feature.dataset_id, feature.osm_type, feature.osm_id)
|
||||
if key in seen:
|
||||
continue
|
||||
if require_compatible_mode and not _mode_compatible(route.mode or "", feature.mode or ""):
|
||||
continue
|
||||
if require_spatial and not _candidate_is_spatially_plausible(route, feature):
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(feature)
|
||||
|
||||
route_ref = norm_ref(route.short_name or route.route_id)
|
||||
gtfs_bbox = (route.min_lon, route.min_lat, route.max_lon, route.max_lat)
|
||||
route_has_bbox = _has_known_bbox(gtfs_bbox)
|
||||
exact_ref_bbox = _expanded_bbox(gtfs_bbox, OSM_SCOPE_NEAR_DISTANCE_DEG) if route_has_bbox else None
|
||||
route_keys = [key for key in [route.route_key, route_ref] if key]
|
||||
for route_key in dict.fromkeys(route_keys):
|
||||
add(
|
||||
@@ -462,14 +554,15 @@ def candidate_osm_routes_for_route(session: Session, route: GtfsRoute, osm_datas
|
||||
osm_dataset_ids,
|
||||
kinds=["route"],
|
||||
route_key=route_key,
|
||||
)
|
||||
bbox=exact_ref_bbox,
|
||||
),
|
||||
require_spatial=route_has_bbox,
|
||||
)
|
||||
if selected:
|
||||
return _spatially_ranked_candidates(route, selected, MAX_EXACT_REF_CANDIDATES)
|
||||
|
||||
gtfs_bbox = (route.min_lon, route.min_lat, route.max_lon, route.max_lat)
|
||||
compatible_modes = sorted(MODE_GROUPS.get(route.mode or "", {route.mode or ""}) - {""})
|
||||
if not any(value is None for value in gtfs_bbox):
|
||||
if route_has_bbox:
|
||||
bbox = _expanded_bbox(gtfs_bbox, 0.10)
|
||||
add(
|
||||
query_osm_features(
|
||||
@@ -482,7 +575,7 @@ def candidate_osm_routes_for_route(session: Session, route: GtfsRoute, osm_datas
|
||||
),
|
||||
require_compatible_mode=False,
|
||||
)
|
||||
if not selected:
|
||||
if not selected and not route_has_bbox:
|
||||
add(
|
||||
query_osm_features(
|
||||
session,
|
||||
@@ -634,6 +727,27 @@ def route_match_scope(route: GtfsRoute, osm_scope_bbox: tuple[float | None, floa
|
||||
return "outside_osm_scope"
|
||||
|
||||
|
||||
def _candidate_is_spatially_plausible(route: GtfsRoute, feature: OsmFeature) -> bool:
|
||||
route_bbox = (route.min_lon, route.min_lat, route.max_lon, route.max_lat)
|
||||
feature_bbox = (feature.min_lon, feature.min_lat, feature.max_lon, feature.max_lat)
|
||||
if not _has_known_bbox(route_bbox):
|
||||
return True
|
||||
if not _has_known_bbox(feature_bbox):
|
||||
return False
|
||||
if bbox_overlap(route_bbox, feature_bbox):
|
||||
return True
|
||||
distance = approx_bbox_center_distance_deg(route_bbox, feature_bbox)
|
||||
return distance is not None and distance < OSM_SCOPE_NEAR_DISTANCE_DEG
|
||||
|
||||
|
||||
def _has_known_bbox(bbox: tuple[float | None, float | None, float | None, float | None]) -> bool:
|
||||
return not any(value is None for value in bbox)
|
||||
|
||||
|
||||
def _feature_identity_key(feature: OsmFeature) -> tuple[int, str, str]:
|
||||
return (int(feature.dataset_id), str(feature.osm_type), str(feature.osm_id))
|
||||
|
||||
|
||||
def _combined_bbox(features: list[OsmFeature]) -> tuple[float | None, float | None, float | None, float | None]:
|
||||
boxes = [
|
||||
(feature.min_lon, feature.min_lat, feature.max_lon, feature.max_lat)
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.gtfs_storage import all_scheduled_stop_ids, stop_times_by_trip as storage_stop_times_by_trip
|
||||
from app.harmonization import active_harmonized_gtfs_shadowed_route_ids
|
||||
from app.models import (
|
||||
CanonicalStop,
|
||||
CanonicalStopLink,
|
||||
@@ -36,7 +37,7 @@ from app.pipeline.utils import bbox_overlap, geometry_json_and_bbox, norm_ref, n
|
||||
from app.spatial import analyze_postgresql_tables, refresh_postgis_geometries, using_postgresql
|
||||
|
||||
|
||||
ROUTE_LAYER_VERSION = "route_layer_v3_stop_alias_matching"
|
||||
ROUTE_LAYER_VERSION = "route_layer_v4_harmonized_route_ownership"
|
||||
GTFS_ROUTE_PATTERN_NULL_SHAPE = "__route__"
|
||||
OSM_STOP_LINK_RADIUS_DEG = 0.0018
|
||||
OSM_STOP_NAME_LINK_RADIUS_DEG = 0.0032
|
||||
@@ -144,6 +145,7 @@ def rebuild_route_layer(
|
||||
"route_patterns_updated": pattern_result.get("route_patterns_updated", 0),
|
||||
"route_patterns_reused": pattern_result.get("route_patterns_reused", 0),
|
||||
"route_patterns_removed": pattern_result.get("route_patterns_removed", 0),
|
||||
"shadowed_routes_skipped": pattern_result.get("shadowed_routes_skipped", 0),
|
||||
"route_pattern_links": pattern_result["route_pattern_links"],
|
||||
"trip_pattern_links": pattern_result["trip_pattern_links"],
|
||||
"route_pattern_stops": pattern_result["route_pattern_stops"],
|
||||
@@ -161,6 +163,7 @@ def _route_layer_dependency(session: Session) -> dict[str, object]:
|
||||
for dataset in session.scalars(select(Dataset).where(Dataset.is_active.is_(True)).order_by(Dataset.kind, Dataset.id)).all()
|
||||
]
|
||||
match_rows = session.execute(select(RouteMatch.id, RouteMatch.gtfs_route_id, RouteMatch.osm_feature_id, RouteMatch.status, RouteMatch.updated_at).order_by(RouteMatch.id)).all()
|
||||
shadowed_route_ids = active_harmonized_gtfs_shadowed_route_ids(session)
|
||||
match_signature = dependency_hash(
|
||||
[
|
||||
[
|
||||
@@ -177,6 +180,10 @@ def _route_layer_dependency(session: Session) -> dict[str, object]:
|
||||
"version": ROUTE_LAYER_VERSION,
|
||||
"active_datasets": active_datasets,
|
||||
"route_matches": {"count": len(match_rows), "signature": match_signature},
|
||||
"shadowed_routes": {
|
||||
"count": len(shadowed_route_ids),
|
||||
"signature": dependency_hash(sorted(shadowed_route_ids)) if shadowed_route_ids else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -945,14 +952,15 @@ def _build_route_patterns(
|
||||
) -> dict[str, int]:
|
||||
osm_candidates = _osm_route_candidates(session, progress_callback=progress_callback)
|
||||
overrides = _route_layer_overrides(session)
|
||||
seeds = _gtfs_pattern_seeds(session)
|
||||
shadowed_route_ids = active_harmonized_gtfs_shadowed_route_ids(session)
|
||||
seeds, shadowed_routes_skipped = _gtfs_pattern_seeds(session, shadowed_route_ids=shadowed_route_ids)
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
"route_layer_pattern_seeds",
|
||||
f"Loaded {len(seeds)} GTFS route-pattern seeds.",
|
||||
0,
|
||||
len(seeds),
|
||||
{"seeds": len(seeds)},
|
||||
{"seeds": len(seeds), "shadowed_routes_skipped": shadowed_routes_skipped},
|
||||
)
|
||||
link_count = 0
|
||||
stop_count = 0
|
||||
@@ -1256,6 +1264,7 @@ def _build_route_patterns(
|
||||
"trip_pattern_links": trip_link_count,
|
||||
"route_pattern_stops": stop_count,
|
||||
"gtfs_proposed_patterns": proposed_count,
|
||||
"shadowed_routes_skipped": shadowed_routes_skipped,
|
||||
}
|
||||
_emit_progress(
|
||||
progress_callback,
|
||||
@@ -1494,10 +1503,11 @@ def _route_layer_overrides(session: Session) -> _RouteLayerOverrides:
|
||||
return _RouteLayerOverrides(accepted_by_gtfs_route_id=accepted, rejected_by_gtfs_route_id=rejected)
|
||||
|
||||
|
||||
def _gtfs_pattern_seeds(session: Session) -> list[_GtfsPatternSeed]:
|
||||
def _gtfs_pattern_seeds(session: Session, *, shadowed_route_ids: set[int] | None = None) -> tuple[list[_GtfsPatternSeed], int]:
|
||||
active_gtfs_dataset_ids = _active_dataset_ids(session, "gtfs")
|
||||
if not active_gtfs_dataset_ids:
|
||||
return []
|
||||
return [], 0
|
||||
shadowed_route_ids = shadowed_route_ids or set()
|
||||
rows = session.execute(
|
||||
select(GtfsRoute, GtfsTrip.shape_id, func.min(GtfsTrip.trip_id))
|
||||
.join(GtfsTrip, and_(GtfsTrip.dataset_id == GtfsRoute.dataset_id, GtfsTrip.route_id == GtfsRoute.route_id))
|
||||
@@ -1525,7 +1535,11 @@ def _gtfs_pattern_seeds(session: Session) -> list[_GtfsPatternSeed]:
|
||||
for dataset_id, shape_id, geometry, min_lon, min_lat, max_lon, max_lat in shape_rows
|
||||
}
|
||||
seeds = []
|
||||
skipped = 0
|
||||
for route, shape_id, trip_id in rows:
|
||||
if int(route.id) in shadowed_route_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
geometry_text = None
|
||||
geometry_source = "none"
|
||||
bbox = (route.min_lon, route.min_lat, route.max_lon, route.max_lat)
|
||||
@@ -1556,7 +1570,7 @@ def _gtfs_pattern_seeds(session: Session) -> list[_GtfsPatternSeed]:
|
||||
center_point=center_point,
|
||||
)
|
||||
)
|
||||
return seeds
|
||||
return seeds, skipped
|
||||
|
||||
|
||||
def _choose_osm_candidate(
|
||||
|
||||
@@ -19,6 +19,11 @@ from app.models import (
|
||||
GtfsAgency,
|
||||
GtfsCalendar,
|
||||
GtfsCalendarDate,
|
||||
GtfsFeedDiffItem,
|
||||
GtfsFeedDiffRun,
|
||||
GtfsHarmonizedSnapshot,
|
||||
GtfsHarmonizedSnapshotDataset,
|
||||
GtfsHarmonizedSnapshotRoute,
|
||||
GtfsRoute,
|
||||
GtfsRoutePatternLink,
|
||||
GtfsShape,
|
||||
@@ -30,6 +35,8 @@ from app.models import (
|
||||
ItineraryLeg,
|
||||
Job,
|
||||
JobEvent,
|
||||
MapGtfsDecision,
|
||||
MapGtfsReviewItem,
|
||||
MatchRule,
|
||||
OsmDiffState,
|
||||
OsmFeature,
|
||||
@@ -96,8 +103,15 @@ def clear_project_data(
|
||||
ItineraryLeg,
|
||||
Itinerary,
|
||||
TravelRequest,
|
||||
GtfsFeedDiffItem,
|
||||
GtfsFeedDiffRun,
|
||||
GtfsHarmonizedSnapshotRoute,
|
||||
GtfsHarmonizedSnapshotDataset,
|
||||
GtfsHarmonizedSnapshot,
|
||||
SourceUpdateCheck,
|
||||
OsmDiffState,
|
||||
MapGtfsReviewItem,
|
||||
MapGtfsDecision,
|
||||
MatchRule,
|
||||
RouteMatch,
|
||||
GtfsTripRoutePatternLink,
|
||||
|
||||
+266
-43
@@ -20,12 +20,18 @@ from app.serializers import feature_collection
|
||||
|
||||
|
||||
WALK_HEURISTIC_MPS = 1.6
|
||||
BIKE_HEURISTIC_MPS = 6.0
|
||||
DRIVE_HEURISTIC_MPS = 36.0
|
||||
BIKE_SPEED_MPS = 4.8
|
||||
DEFAULT_MAX_VISITED = 160_000
|
||||
PGR_WALK_BBOX_PADDING_KM = [0.5, 1.5, 4, 10, 25]
|
||||
PGR_BIKE_BBOX_PADDING_KM = [1, 3, 8, 20, 60]
|
||||
PGR_DRIVE_BBOX_PADDING_KM = [2, 8, 25, 75, 200]
|
||||
PGR_WALK_STATEMENT_TIMEOUT_MS = 2_500
|
||||
PGR_BIKE_STATEMENT_TIMEOUT_MS = 4_000
|
||||
PGR_DRIVE_STATEMENT_TIMEOUT_MS = 7_500
|
||||
ROUTING_MODES = {"walk", "bike", "drive"}
|
||||
ROUTING_MODE_ALIASES = {"bicycle": "bike", "cycle": "bike", "cycling": "bike", "car": "drive"}
|
||||
ROUTE_CACHE_TTL_SECONDS = 15 * 60
|
||||
ROUTE_CACHE_MAX_ENTRIES = 512
|
||||
_route_cache_lock = threading.RLock()
|
||||
@@ -123,6 +129,43 @@ def _metadata(dataset: Dataset) -> dict[str, object]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _normalize_routing_mode(mode: str) -> str:
|
||||
normalized = ROUTING_MODE_ALIASES.get(str(mode or "").lower(), str(mode or "").lower())
|
||||
if normalized not in ROUTING_MODES:
|
||||
raise ValueError("mode must be walk, bike, or drive")
|
||||
return normalized
|
||||
|
||||
|
||||
def _cost_columns(mode: str) -> tuple[str, str]:
|
||||
if mode == "drive":
|
||||
return "drive_cost_s", "reverse_drive_cost_s"
|
||||
return "walk_cost_s", "reverse_walk_cost_s"
|
||||
|
||||
|
||||
def _heuristic_mps(mode: str) -> float:
|
||||
if mode == "walk":
|
||||
return WALK_HEURISTIC_MPS
|
||||
if mode == "bike":
|
||||
return BIKE_HEURISTIC_MPS
|
||||
return DRIVE_HEURISTIC_MPS
|
||||
|
||||
|
||||
def _pgrouting_padding_km(mode: str) -> list[float]:
|
||||
if mode == "walk":
|
||||
return PGR_WALK_BBOX_PADDING_KM
|
||||
if mode == "bike":
|
||||
return PGR_BIKE_BBOX_PADDING_KM
|
||||
return PGR_DRIVE_BBOX_PADDING_KM
|
||||
|
||||
|
||||
def _pgrouting_timeout_ms(mode: str) -> int:
|
||||
if mode == "walk":
|
||||
return PGR_WALK_STATEMENT_TIMEOUT_MS
|
||||
if mode == "bike":
|
||||
return PGR_BIKE_STATEMENT_TIMEOUT_MS
|
||||
return PGR_DRIVE_STATEMENT_TIMEOUT_MS
|
||||
|
||||
|
||||
def route_between_points(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -133,9 +176,11 @@ def route_between_points(
|
||||
mode: str = "walk",
|
||||
dataset_id: int | None = None,
|
||||
max_visited: int = DEFAULT_MAX_VISITED,
|
||||
allow_python_fallback: bool = True,
|
||||
pgrouting_timeout_ms: int | None = None,
|
||||
pgrouting_padding_km: tuple[float, ...] | list[float] | None = None,
|
||||
) -> dict[str, object]:
|
||||
if mode not in {"walk", "drive"}:
|
||||
raise ValueError("mode must be walk or drive")
|
||||
mode = _normalize_routing_mode(mode)
|
||||
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
|
||||
if dataset is None:
|
||||
raise ValueError("No routing dataset is available.")
|
||||
@@ -152,7 +197,8 @@ def route_between_points(
|
||||
payload = _single_point_route(start, from_lon, from_lat, to_lon, to_lat, mode, dataset_id)
|
||||
_route_cache_put(cache_key, payload)
|
||||
return payload
|
||||
if settings.is_postgresql_database and _pgrouting_installed(db):
|
||||
pgrouting_available = settings.is_postgresql_database and _pgrouting_installed(db)
|
||||
if pgrouting_available:
|
||||
try:
|
||||
payload = _route_with_pgrouting(
|
||||
db,
|
||||
@@ -164,15 +210,23 @@ def route_between_points(
|
||||
from_lat=from_lat,
|
||||
to_lon=to_lon,
|
||||
to_lat=to_lat,
|
||||
timeout_ms=pgrouting_timeout_ms,
|
||||
padding_km=pgrouting_padding_km,
|
||||
)
|
||||
_route_cache_put(cache_key, payload)
|
||||
return payload
|
||||
except ValueError:
|
||||
pass
|
||||
if not allow_python_fallback:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
if not allow_python_fallback:
|
||||
raise
|
||||
|
||||
heuristic_mps = WALK_HEURISTIC_MPS if mode == "walk" else DRIVE_HEURISTIC_MPS
|
||||
if not allow_python_fallback:
|
||||
raise ValueError("pgRouting did not find a bounded route and Python fallback is disabled.")
|
||||
|
||||
heuristic_mps = _heuristic_mps(mode)
|
||||
queue: list[tuple[float, float, int]] = []
|
||||
heapq.heappush(queue, (0.0, 0.0, start.osm_node_id))
|
||||
costs: dict[int, float] = {start.osm_node_id: 0.0}
|
||||
@@ -226,8 +280,7 @@ def direct_route_between_points(
|
||||
dataset_id: int | None = None,
|
||||
reason: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if mode not in {"walk", "drive"}:
|
||||
raise ValueError("mode must be walk or drive")
|
||||
mode = _normalize_routing_mode(mode)
|
||||
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
|
||||
payload = _direct_route_payload(
|
||||
dataset_id=0 if dataset is None else int(dataset.id),
|
||||
@@ -251,8 +304,7 @@ def snap_point_to_routing_graph(
|
||||
dataset_id: int | None = None,
|
||||
max_distance_m: float = 250,
|
||||
) -> dict[str, object] | None:
|
||||
if mode not in {"walk", "drive"}:
|
||||
raise ValueError("mode must be walk or drive")
|
||||
mode = _normalize_routing_mode(mode)
|
||||
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
|
||||
if dataset is None:
|
||||
return None
|
||||
@@ -288,8 +340,8 @@ def _snap_point_to_routing_edge_postgresql(
|
||||
mode: str,
|
||||
max_distance_m: float,
|
||||
) -> dict[str, object] | None:
|
||||
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
|
||||
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
|
||||
cost_column, reverse_cost_column = _cost_columns(mode)
|
||||
access_filter = f"AND {_bike_access_sql('edge')}" if mode == "bike" else ""
|
||||
radius_deg = max_distance_m / 111_320
|
||||
row = db.execute(
|
||||
text(
|
||||
@@ -319,6 +371,7 @@ def _snap_point_to_routing_edge_postgresql(
|
||||
CROSS JOIN point
|
||||
WHERE edge.dataset_id = :dataset_id
|
||||
AND (edge.{cost_column} IS NOT NULL OR edge.{reverse_cost_column} IS NOT NULL)
|
||||
{access_filter}
|
||||
AND box(point(edge.max_lon, edge.max_lat), point(edge.min_lon, edge.min_lat))
|
||||
&& box(
|
||||
point(:lon + :radius_deg, :lat + :radius_deg),
|
||||
@@ -337,13 +390,16 @@ def _snap_point_to_routing_edge_postgresql(
|
||||
ST_DistanceSphere(edges.edge_geom, point.geom) AS distance_m,
|
||||
CASE
|
||||
WHEN edges.highway IN ('footway', 'pedestrian', 'steps') THEN 0
|
||||
WHEN edges.highway IN ('path', 'cycleway', 'bridleway') THEN 1
|
||||
WHEN edges.highway = 'cycleway' THEN 0
|
||||
WHEN edges.highway IN ('path', 'bridleway') THEN 1
|
||||
WHEN edges.highway IN ('living_street', 'residential') THEN 2
|
||||
WHEN edges.highway = 'service' THEN 3
|
||||
ELSE 4
|
||||
END AS highway_rank,
|
||||
CASE
|
||||
WHEN :mode != 'walk' THEN 0
|
||||
WHEN :mode NOT IN ('walk', 'bike') THEN 0
|
||||
WHEN :mode = 'bike' AND edges.highway = 'cycleway' THEN -12
|
||||
WHEN :mode = 'bike' AND edges.highway IN ('path', 'living_street', 'residential') THEN -4
|
||||
WHEN edges.highway = 'service' THEN 20
|
||||
WHEN edges.highway IN ('primary', 'primary_link', 'secondary', 'secondary_link') THEN 10
|
||||
WHEN edges.highway IN ('tertiary', 'tertiary_link', 'unclassified', 'road') THEN 5
|
||||
@@ -359,7 +415,9 @@ def _snap_point_to_routing_edge_postgresql(
|
||||
)
|
||||
ORDER BY
|
||||
ST_DistanceSphere(edges.edge_geom, point.geom) + CASE
|
||||
WHEN :mode != 'walk' THEN 0
|
||||
WHEN :mode NOT IN ('walk', 'bike') THEN 0
|
||||
WHEN :mode = 'bike' AND edges.highway = 'cycleway' THEN -12
|
||||
WHEN :mode = 'bike' AND edges.highway IN ('path', 'living_street', 'residential') THEN -4
|
||||
WHEN edges.highway = 'service' THEN 20
|
||||
WHEN edges.highway IN ('primary', 'primary_link', 'secondary', 'secondary_link') THEN 10
|
||||
WHEN edges.highway IN ('tertiary', 'tertiary_link', 'unclassified', 'road') THEN 5
|
||||
@@ -455,15 +513,26 @@ def _route_with_pgrouting(
|
||||
from_lat: float,
|
||||
to_lon: float,
|
||||
to_lat: float,
|
||||
timeout_ms: int | None = None,
|
||||
padding_km: tuple[float, ...] | list[float] | None = None,
|
||||
) -> dict[str, object]:
|
||||
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
|
||||
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
|
||||
cost_column, reverse_cost_column = _cost_columns(mode)
|
||||
routing_cost = _routing_cost_expression(cost_column, mode)
|
||||
reverse_routing_cost = _routing_cost_expression(reverse_cost_column, mode)
|
||||
for padding_km in PGR_WALK_BBOX_PADDING_KM if mode == "walk" else PGR_DRIVE_BBOX_PADDING_KM:
|
||||
reverse_routing_cost = _routing_cost_expression(reverse_cost_column, mode, reverse=True)
|
||||
actual_cost_sql = "steps.cost" if mode == "bike" else f"""
|
||||
CASE
|
||||
WHEN steps.from_node = edge.source_osm_node_id THEN edge.{cost_column}
|
||||
ELSE edge.{reverse_cost_column}
|
||||
END
|
||||
"""
|
||||
paddings = list(padding_km or _pgrouting_padding_km(mode))
|
||||
statement_timeout_ms = int(
|
||||
timeout_ms if timeout_ms is not None else _pgrouting_timeout_ms(mode)
|
||||
)
|
||||
for padding_km in paddings:
|
||||
_set_local_statement_timeout(
|
||||
db,
|
||||
PGR_WALK_STATEMENT_TIMEOUT_MS if mode == "walk" else PGR_DRIVE_STATEMENT_TIMEOUT_MS,
|
||||
statement_timeout_ms,
|
||||
)
|
||||
bbox = _expanded_bbox(
|
||||
min(from_lon, to_lon, start.lon, target.lon),
|
||||
@@ -517,10 +586,7 @@ def _route_with_pgrouting(
|
||||
edge.highway,
|
||||
edge.name,
|
||||
edge.geometry_geojson,
|
||||
CASE
|
||||
WHEN steps.from_node = edge.source_osm_node_id THEN edge.{cost_column}
|
||||
ELSE edge.{reverse_cost_column}
|
||||
END AS actual_cost_s
|
||||
{actual_cost_sql} AS actual_cost_s
|
||||
FROM steps
|
||||
JOIN routing_edges AS edge ON edge.id = steps.edge
|
||||
WHERE steps.edge <> -1
|
||||
@@ -616,33 +682,109 @@ def _pgrouting_payload(
|
||||
return payload
|
||||
|
||||
|
||||
def _routing_cost_expression(column: str, mode: str) -> str:
|
||||
if mode != "walk":
|
||||
def _routing_cost_expression(column: str, mode: str, *, reverse: bool = False) -> str:
|
||||
if mode == "drive":
|
||||
return column
|
||||
if mode == "bike":
|
||||
direction_filter = f"WHEN {_bike_direction_forbidden_sql(reverse=reverse)} THEN NULL"
|
||||
return f"""
|
||||
CASE
|
||||
WHEN {column} IS NULL THEN NULL
|
||||
WHEN NOT ({_bike_access_sql()}) THEN NULL
|
||||
{direction_filter}
|
||||
ELSE (length_m / {BIKE_SPEED_MPS:.4f}) * CASE
|
||||
WHEN highway = 'cycleway' THEN 0.82
|
||||
WHEN highway = 'path' THEN 0.95
|
||||
WHEN highway IN ('living_street', 'residential') THEN 1.00
|
||||
WHEN highway = 'service' THEN 1.08
|
||||
WHEN highway = 'track' THEN 1.10
|
||||
WHEN highway IN ('unclassified', 'road', 'tertiary', 'tertiary_link') THEN 1.12
|
||||
WHEN highway IN ('secondary', 'secondary_link') THEN 1.22
|
||||
WHEN highway IN ('primary', 'primary_link') THEN 1.35
|
||||
WHEN highway IN ('footway', 'pedestrian', 'bridleway') THEN 1.40
|
||||
ELSE 1.05
|
||||
END
|
||||
END
|
||||
"""
|
||||
return f"""
|
||||
CASE
|
||||
WHEN {column} IS NULL THEN NULL
|
||||
ELSE {column} * CASE
|
||||
WHEN highway IN ('footway', 'pedestrian') THEN 0.70
|
||||
WHEN highway = 'path' THEN 0.78
|
||||
WHEN highway = 'steps' THEN 0.95
|
||||
WHEN highway = 'cycleway' THEN 1.05
|
||||
WHEN highway = 'bridleway' THEN 1.10
|
||||
WHEN highway IN ('living_street', 'track') THEN 1.15
|
||||
WHEN highway IN ('residential', 'service') THEN 1.35
|
||||
WHEN highway IN ('unclassified', 'road') THEN 1.55
|
||||
WHEN highway IN ('tertiary', 'tertiary_link') THEN 1.80
|
||||
WHEN highway IN ('secondary', 'secondary_link') THEN 2.15
|
||||
WHEN highway IN ('primary', 'primary_link') THEN 2.50
|
||||
ELSE 1.30
|
||||
WHEN highway IN ('footway', 'pedestrian') THEN 0.92
|
||||
WHEN highway = 'path' THEN 0.94
|
||||
WHEN highway = 'steps' THEN 1.05
|
||||
WHEN highway = 'cycleway' THEN 1.02
|
||||
WHEN highway = 'bridleway' THEN 1.05
|
||||
WHEN highway = 'living_street' THEN 1.00
|
||||
WHEN highway IN ('residential', 'service') THEN 1.05
|
||||
WHEN highway = 'track' THEN 1.10
|
||||
WHEN highway IN ('unclassified', 'road') THEN 1.12
|
||||
WHEN highway IN ('tertiary', 'tertiary_link') THEN 1.18
|
||||
WHEN highway IN ('secondary', 'secondary_link') THEN 1.28
|
||||
WHEN highway IN ('primary', 'primary_link') THEN 1.40
|
||||
ELSE 1.08
|
||||
END
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def _routing_tags_sql(alias: str | None = None) -> str:
|
||||
prefix = f"{alias}." if alias else ""
|
||||
return f"COALESCE(NULLIF({prefix}tags_json, '')::jsonb, '{{}}'::jsonb)"
|
||||
|
||||
|
||||
def _bike_access_sql(alias: str | None = None) -> str:
|
||||
tags = _routing_tags_sql(alias)
|
||||
highway = f"{alias}.highway" if alias else "highway"
|
||||
return f"""
|
||||
(
|
||||
{highway} NOT IN ('motorway', 'motorway_link', 'steps', 'platform')
|
||||
AND NOT (
|
||||
COALESCE({tags} ->> 'bicycle', '') IN ('no', 'private')
|
||||
OR (
|
||||
COALESCE({tags} ->> 'access', '') IN ('no', 'private')
|
||||
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
|
||||
AND COALESCE({tags} ->> 'vehicle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
|
||||
)
|
||||
)
|
||||
AND NOT (
|
||||
{highway} IN ('footway', 'pedestrian', 'bridleway')
|
||||
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
|
||||
)
|
||||
AND NOT (
|
||||
{highway} IN ('trunk', 'trunk_link')
|
||||
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _bike_direction_forbidden_sql(alias: str | None = None, *, reverse: bool) -> str:
|
||||
tags = _routing_tags_sql(alias)
|
||||
highway = f"{alias}.highway" if alias else "highway"
|
||||
bicycle_direction_key = "bicycle:backward" if reverse else "bicycle:forward"
|
||||
one_way_condition = (
|
||||
f"COALESCE({tags} ->> 'oneway', '') IN ('yes', 'true', '1') "
|
||||
f"OR COALESCE({tags} ->> 'junction', '') = 'roundabout' "
|
||||
f"OR {highway} = 'motorway'"
|
||||
if reverse
|
||||
else f"COALESCE({tags} ->> 'oneway', '') = '-1'"
|
||||
)
|
||||
return f"""
|
||||
(
|
||||
(
|
||||
{one_way_condition}
|
||||
)
|
||||
AND COALESCE({tags} ->> 'oneway:bicycle', '') != 'no'
|
||||
AND COALESCE({tags} ->> 'bicycle:oneway', '') != 'no'
|
||||
AND COALESCE({tags} ->> '{bicycle_direction_key}', '') NOT IN ('yes', 'designated', 'permissive')
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: str) -> _GraphNode | None:
|
||||
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
|
||||
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
|
||||
cost_column, reverse_cost_column = _cost_columns(mode)
|
||||
access_filter = f"AND {_bike_access_sql('edge')}" if mode == "bike" else ""
|
||||
row = None
|
||||
for candidate_limit in (64, 512, 4096):
|
||||
row = db.execute(
|
||||
@@ -663,6 +805,7 @@ def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: st
|
||||
SELECT 1
|
||||
FROM routing_edges AS edge
|
||||
WHERE edge.dataset_id = :dataset_id
|
||||
{access_filter}
|
||||
AND (
|
||||
(edge.source_osm_node_id = nearest.osm_node_id AND edge.{cost_column} IS NOT NULL)
|
||||
OR (edge.target_osm_node_id = nearest.osm_node_id AND edge.{reverse_cost_column} IS NOT NULL)
|
||||
@@ -686,8 +829,7 @@ def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: st
|
||||
|
||||
|
||||
def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> list[_Traversal]:
|
||||
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
|
||||
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
|
||||
cost_column, reverse_cost_column = _cost_columns(mode)
|
||||
rows = db.execute(
|
||||
text(
|
||||
f"""
|
||||
@@ -695,6 +837,7 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
|
||||
id, source_osm_node_id, target_osm_node_id,
|
||||
source_lon, source_lat, target_lon, target_lat,
|
||||
length_m, highway, name, geometry_geojson,
|
||||
tags_json,
|
||||
CASE
|
||||
WHEN source_osm_node_id = :node_id THEN {cost_column}
|
||||
ELSE {reverse_cost_column}
|
||||
@@ -713,6 +856,14 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
|
||||
edges = []
|
||||
for row in rows:
|
||||
forward = bool(row.forward)
|
||||
cost_s = float(row.cost_s)
|
||||
if mode == "bike":
|
||||
tags = _json_object(str(row.tags_json or ""))
|
||||
if not _bike_access_python(row.highway, tags):
|
||||
continue
|
||||
if _bike_direction_forbidden_python(row.highway, tags, reverse=not forward):
|
||||
continue
|
||||
cost_s = _bike_edge_seconds(float(row.length_m), row.highway, tags)
|
||||
if forward:
|
||||
to_node = int(row.target_osm_node_id)
|
||||
from_lon, from_lat = float(row.source_lon), float(row.source_lat)
|
||||
@@ -730,7 +881,7 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
|
||||
from_lat=from_lat,
|
||||
to_lon=to_lon,
|
||||
to_lat=to_lat,
|
||||
cost_s=float(row.cost_s),
|
||||
cost_s=cost_s,
|
||||
length_m=float(row.length_m),
|
||||
highway=row.highway,
|
||||
name=row.name,
|
||||
@@ -869,10 +1020,82 @@ def _connector_feature(kind: str, mode: str, coordinates: list[list[float]], dis
|
||||
|
||||
|
||||
def _connector_seconds(distance_m: float, mode: str) -> float:
|
||||
speed = 1.35 if mode == "walk" else 8.0
|
||||
speed = 1.35 if mode == "walk" else BIKE_SPEED_MPS if mode == "bike" else 8.0
|
||||
return float(distance_m) / speed
|
||||
|
||||
|
||||
def _bike_edge_seconds(length_m: float, highway: str | None, tags: dict[str, object]) -> float:
|
||||
factor = {
|
||||
"cycleway": 0.82,
|
||||
"path": 0.95,
|
||||
"living_street": 1.0,
|
||||
"residential": 1.0,
|
||||
"service": 1.08,
|
||||
"track": 1.10,
|
||||
"unclassified": 1.12,
|
||||
"road": 1.12,
|
||||
"tertiary": 1.12,
|
||||
"tertiary_link": 1.12,
|
||||
"secondary": 1.22,
|
||||
"secondary_link": 1.22,
|
||||
"primary": 1.35,
|
||||
"primary_link": 1.35,
|
||||
"footway": 1.40,
|
||||
"pedestrian": 1.40,
|
||||
"bridleway": 1.40,
|
||||
}.get(str(highway or ""), 1.05)
|
||||
return float(length_m) / BIKE_SPEED_MPS * factor
|
||||
|
||||
|
||||
def _bike_access_python(highway: str | None, tags: dict[str, object]) -> bool:
|
||||
highway_text = str(highway or "")
|
||||
bicycle = _tag_text(tags, "bicycle")
|
||||
access = _tag_text(tags, "access")
|
||||
vehicle = _tag_text(tags, "vehicle")
|
||||
if highway_text in {"motorway", "motorway_link", "steps", "platform"}:
|
||||
return False
|
||||
if bicycle in {"no", "private"}:
|
||||
return False
|
||||
if access in {"no", "private"} and bicycle not in {"yes", "designated", "permissive", "destination"} and vehicle not in {"yes", "designated", "permissive", "destination"}:
|
||||
return False
|
||||
if highway_text in {"footway", "pedestrian", "bridleway"} and bicycle not in {"yes", "designated", "permissive", "destination"}:
|
||||
return False
|
||||
if highway_text in {"trunk", "trunk_link"} and bicycle not in {"yes", "designated", "permissive", "destination"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _bike_direction_forbidden_python(highway: str | None, tags: dict[str, object], *, reverse: bool) -> bool:
|
||||
oneway = _tag_text(tags, "oneway")
|
||||
junction = _tag_text(tags, "junction")
|
||||
if reverse:
|
||||
one_way = oneway in {"yes", "true", "1"} or junction == "roundabout" or str(highway or "") == "motorway"
|
||||
else:
|
||||
one_way = oneway == "-1"
|
||||
if not one_way:
|
||||
return False
|
||||
bicycle_direction_key = "bicycle:backward" if reverse else "bicycle:forward"
|
||||
return (
|
||||
_tag_text(tags, "oneway:bicycle") != "no"
|
||||
and _tag_text(tags, "bicycle:oneway") != "no"
|
||||
and _tag_text(tags, bicycle_direction_key) not in {"yes", "designated", "permissive"}
|
||||
)
|
||||
|
||||
|
||||
def _tag_text(tags: dict[str, object], key: str) -> str:
|
||||
return str(tags.get(key) or "").strip().lower()
|
||||
|
||||
|
||||
def _json_object(value: str | None) -> dict[str, object]:
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _duration_minutes_ceil(seconds: int | float | None) -> int | None:
|
||||
if seconds is None:
|
||||
return None
|
||||
|
||||
+938
-50
File diff suppressed because it is too large
Load Diff
@@ -383,10 +383,16 @@ input.journey-selected-stop {
|
||||
gap: 8px;
|
||||
}
|
||||
.harmonization-review-form label:has(textarea),
|
||||
.harmonization-review-form .harmonization-license-grid,
|
||||
.harmonization-review-form .source-actions,
|
||||
.harmonization-review-form > .muted {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.harmonization-license-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.harmonization-review-item {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -416,6 +422,155 @@ input.journey-selected-stop {
|
||||
.harmonization-review-item span {
|
||||
color: #52606d;
|
||||
}
|
||||
.map-gtfs-review-queue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.map-gtfs-review-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.map-gtfs-review-summary div {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.map-gtfs-review-summary strong,
|
||||
.map-gtfs-review-summary span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.map-gtfs-review-summary strong {
|
||||
font-size: 13px;
|
||||
color: #17212b;
|
||||
}
|
||||
.map-gtfs-review-summary span {
|
||||
font-size: 10px;
|
||||
color: #5a6875;
|
||||
}
|
||||
.map-gtfs-review-item {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border-left: 4px solid #64748b;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.map-gtfs-review-item.error {
|
||||
border-left-color: #c24141;
|
||||
background: #fff7f7;
|
||||
}
|
||||
.map-gtfs-review-item.warn {
|
||||
border-left-color: #c47a12;
|
||||
background: #fffaf0;
|
||||
}
|
||||
.map-gtfs-review-item.info {
|
||||
border-left-color: #2563eb;
|
||||
background: #f6f9ff;
|
||||
}
|
||||
.map-gtfs-review-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.map-gtfs-review-title > * {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.map-gtfs-review-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.gtfs-workflow,
|
||||
.gtfs-workflow-steps,
|
||||
.gtfs-workflow-diffs,
|
||||
.gtfs-diff-item-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.gtfs-workflow-step {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
.gtfs-workflow-step.pending {
|
||||
opacity: 0.82;
|
||||
}
|
||||
.gtfs-workflow-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gtfs-workflow-diffs {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.gtfs-workflow-audit {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.gtfs-workflow-audit h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
.snapshot-route-preview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.snapshot-route-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-left: 3px solid #2563eb;
|
||||
background: #f8fafc;
|
||||
padding: 6px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.snapshot-route-row strong,
|
||||
.snapshot-route-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gtfs-workflow-diffs h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
.gtfs-workflow-diff {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #17212b;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.gtfs-workflow-diff span {
|
||||
color: #52606d;
|
||||
font-size: 11px;
|
||||
}
|
||||
.source, .match, .catalog-entry {
|
||||
border-top: 1px solid #e1e7ee;
|
||||
padding: 8px 0;
|
||||
@@ -814,6 +969,14 @@ input.journey-selected-stop {
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.jobs-worker-status {
|
||||
min-width: 0;
|
||||
}
|
||||
.jobs-queue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-height: 280px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
@@ -831,6 +994,12 @@ input.journey-selected-stop {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.worker-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.worker-row {
|
||||
border: 1px solid #e1e7ee;
|
||||
border-radius: 6px;
|
||||
@@ -1202,6 +1371,8 @@ input.journey-selected-stop {
|
||||
.mode-coach { background: #fef3c7; color: #a16207; }
|
||||
.mode-ferry { background: #dbeafe; color: #0284c7; }
|
||||
.mode-walk { background: #dcfce7; color: #16a34a; }
|
||||
.mode-bike,
|
||||
.mode-bicycle { background: #ccfbf1; color: #0d9488; }
|
||||
.mode-drive,
|
||||
.mode-car { background: #ffedd5; color: #f97316; }
|
||||
.mode-monorail,
|
||||
@@ -1416,6 +1587,79 @@ input.journey-selected-stop {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.review-detail section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.review-detail h3,
|
||||
.review-entity-card h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.review-detail-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.review-detail-grid,
|
||||
.review-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.review-entity-card {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 1px solid #e1e7ee;
|
||||
border-radius: 6px;
|
||||
background: #fbfdff;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.review-entity-card div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 0.42fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.review-entity-card span {
|
||||
color: #64748b;
|
||||
}
|
||||
.review-entity-card strong,
|
||||
.review-diff-table span,
|
||||
.review-diff-table strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-diff-table {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.review-diff-table > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.7fr) repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 7px;
|
||||
}
|
||||
.review-detail pre {
|
||||
margin: 5px 0 0;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #f7fafc;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.canonical-stop-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
+69
-12
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mobility Workbench</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260701-harmonizer-module" />
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ static_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -49,7 +49,7 @@
|
||||
<summary><h2>GTFS Harmonization</h2></summary>
|
||||
<div class="sidebar-section-body">
|
||||
<details class="nested-section" data-sidebar-section="add-gtfs-source">
|
||||
<summary><h3>Add GTFS source</h3></summary>
|
||||
<summary><h3>Add GTFS feed</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<form id="sourceForm">
|
||||
<input name="catalog_entry_id" type="hidden" />
|
||||
@@ -58,13 +58,13 @@
|
||||
<label>URL or path <input name="url" required placeholder="https://.../feed.zip or ./data/feed.zip" /></label>
|
||||
<label>Country <input name="country" placeholder="DE" maxlength="8" /></label>
|
||||
<label>License <input name="license" placeholder="ODbL / CC-BY / unknown" /></label>
|
||||
<button type="submit">Add GTFS source</button>
|
||||
<button type="submit">Add GTFS feed</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="nested-section source-catalog-card" data-sidebar-section="source-catalog">
|
||||
<summary><h3>Transit source catalog</h3></summary>
|
||||
<summary><h3>Discovery catalog</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<div id="sourceCatalogSummary" class="muted"></div>
|
||||
<div class="filter-row source-catalog-filter">
|
||||
@@ -83,14 +83,24 @@
|
||||
</div>
|
||||
<div class="source-catalog-actions">
|
||||
<button type="button" id="importSourceCatalogBtn">Import catalog</button>
|
||||
<button type="button" id="importIngestableSourcesBtn">Import ingestable seeds</button>
|
||||
<button type="button" id="importIngestableSourcesBtn">Import feed seeds</button>
|
||||
</div>
|
||||
<div id="sourceCatalog"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="nested-section" data-sidebar-section="gtfs-workflow" open>
|
||||
<summary><h3>GTFS workflow</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<div class="qa-toolbar">
|
||||
<button type="button" id="refreshGtfsWorkflowBtn">Refresh workflow</button>
|
||||
</div>
|
||||
<div id="gtfsWorkflow" class="gtfs-workflow muted">Workflow status loading...</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="nested-section" data-sidebar-section="gtfs-feed-qa" open>
|
||||
<summary><h3>Feed QA</h3></summary>
|
||||
<summary><h3>GTFS feed QA</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<div class="qa-toolbar">
|
||||
<button type="button" id="refreshGtfsHarmonizationBtn">Refresh feeds</button>
|
||||
@@ -99,11 +109,57 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="nested-section" data-sidebar-section="map-gtfs-workbench" open>
|
||||
<summary><h3>Map/GTFS workbench</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<p class="muted">Review and persist GTFS-to-map decisions here. Run the ordered workflow from the GTFS workflow panel above.</p>
|
||||
<div class="qa-toolbar">
|
||||
<button type="button" id="refreshMapGtfsWorkbenchBtn">Refresh queue</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="mapGtfsReviewQueueFilter">
|
||||
<option value="">all queues</option>
|
||||
<option value="gtfs_deduplication">GTFS deduplication</option>
|
||||
<option value="gtfs_update_diff">GTFS update diff</option>
|
||||
<option value="route_matching">route matching</option>
|
||||
<option value="stop_matching">stop matching</option>
|
||||
</select>
|
||||
<select id="mapGtfsReviewStatus">
|
||||
<option value="open">open</option>
|
||||
<option value="in_review">in review</option>
|
||||
<option value="resolved">resolved</option>
|
||||
<option value="dismissed">dismissed</option>
|
||||
<option value="">all statuses</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="mapGtfsReviewSeverityFilter">
|
||||
<option value="">all severities</option>
|
||||
<option value="error">error</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
<select id="mapGtfsReviewModeFilter">
|
||||
<option value="">all modes</option>
|
||||
<option value="train">train</option>
|
||||
<option value="tram">tram</option>
|
||||
<option value="subway">subway</option>
|
||||
<option value="bus">bus</option>
|
||||
<option value="ferry">ferry</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input id="mapGtfsReviewSearch" placeholder="Search review queue" />
|
||||
</div>
|
||||
<div id="mapGtfsReviewQueue" class="map-gtfs-review-queue muted">No workbench review queue loaded.</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="nested-section" data-sidebar-section="gtfs-source-management" open>
|
||||
<summary><h3>GTFS source library</h3></summary>
|
||||
<summary><h3>Registered GTFS feeds</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<div class="filter-row">
|
||||
<input id="sourceSearch" placeholder="Filter GTFS sources" />
|
||||
<input id="sourceSearch" placeholder="Filter registered feeds" />
|
||||
</div>
|
||||
<div id="sources"></div>
|
||||
</div>
|
||||
@@ -172,10 +228,9 @@
|
||||
<summary><h3>Derivation pipeline</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<div class="workflow-actions">
|
||||
<button id="runMatchBtn" type="button">Run matcher</button>
|
||||
<button id="buildRouteLayerBtn" type="button">Build route layer</button>
|
||||
<button id="loadSampleBtn" type="button">Reset sample</button>
|
||||
</div>
|
||||
<p class="muted">Matcher and route-layer controls live in the Map/GTFS workbench so review actions stay in one workflow.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -194,8 +249,9 @@
|
||||
</details>
|
||||
|
||||
<details class="nested-section matches-card" data-sidebar-section="route-matches">
|
||||
<summary><h3>Route matches</h3></summary>
|
||||
<summary><h3>Raw route matches</h3></summary>
|
||||
<div class="nested-section-body">
|
||||
<p class="muted">Inspection/debug view of raw matcher rows. Use the Map/GTFS workbench for review decisions.</p>
|
||||
<div class="filter-row">
|
||||
<select id="matchStatusFilter">
|
||||
<option value="">all</option>
|
||||
@@ -272,6 +328,7 @@
|
||||
<div class="journey-mode" role="radiogroup" aria-label="Route mode">
|
||||
<label><input type="radio" name="journeyMode" value="transit" checked /> Public transport</label>
|
||||
<label><input type="radio" name="journeyMode" value="walk" /> Walk</label>
|
||||
<label><input type="radio" name="journeyMode" value="bike" /> Bike</label>
|
||||
<label><input type="radio" name="journeyMode" value="drive" /> Car</label>
|
||||
</div>
|
||||
<div class="journey-options">
|
||||
@@ -324,6 +381,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<script src="/static/app.js?v=20260701-harmonizer-module"></script>
|
||||
<script src="/static/app.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import and_, case, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased, joinedload
|
||||
|
||||
from app.harmonization import active_harmonized_gtfs_route_shadow_map, gtfs_harmonized_snapshot, gtfs_route_overlap_key
|
||||
from app.models import (
|
||||
CanonicalStopLink,
|
||||
Dataset,
|
||||
GtfsRoute,
|
||||
GtfsStop,
|
||||
GtfsTrip,
|
||||
MapGtfsDecision,
|
||||
MapGtfsReviewItem,
|
||||
OsmFeature,
|
||||
RouteMatch,
|
||||
Source,
|
||||
)
|
||||
|
||||
ProgressCallback = Callable[[str, str, int | None, int | None, dict[str, Any] | None], None]
|
||||
|
||||
OPEN_REVIEW_STATUSES = {"open", "in_review"}
|
||||
TERMINAL_REVIEW_STATUSES = {"resolved", "dismissed"}
|
||||
ROUTE_REVIEW_STATUSES = {"matched", "probable", "weak", "missing"}
|
||||
|
||||
|
||||
def generate_map_gtfs_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int | None = None,
|
||||
dataset_id: int | None = None,
|
||||
limit: int = 1000,
|
||||
progress_callback: ProgressCallback | None = None,
|
||||
) -> dict[str, Any]:
|
||||
selected_limit = max(1, min(int(limit), 50_000))
|
||||
result = {
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"reopened": 0,
|
||||
"unchanged": 0,
|
||||
"resolved": 0,
|
||||
"route_items": 0,
|
||||
"dedup_items": 0,
|
||||
"stop_items": 0,
|
||||
"limit": selected_limit,
|
||||
}
|
||||
route_limit = max(1, selected_limit // 2)
|
||||
dedup_limit = max(1, selected_limit // 4)
|
||||
_emit(progress_callback, "map_gtfs_review_routes_started", "Scanning GTFS/OSM route matches.", 0, route_limit, result)
|
||||
route_result = _generate_route_review_items(session, source_id=source_id, dataset_id=dataset_id, limit=route_limit)
|
||||
_merge_counts(result, route_result)
|
||||
result["route_items"] = route_result["items"]
|
||||
remaining = max(0, selected_limit - int(route_result["items"]))
|
||||
_emit(
|
||||
progress_callback,
|
||||
"map_gtfs_review_routes_completed",
|
||||
"Scanned GTFS/OSM route matches.",
|
||||
int(route_result["items"]),
|
||||
route_limit,
|
||||
route_result,
|
||||
)
|
||||
if remaining and dedup_limit:
|
||||
current_dedup_limit = min(dedup_limit, remaining)
|
||||
_emit(progress_callback, "map_gtfs_review_dedup_started", "Scanning shadowed GTFS routes for duplicate/conflict review.", 0, current_dedup_limit, result)
|
||||
dedup_result = _generate_gtfs_route_dedup_review_items(session, source_id=source_id, dataset_id=dataset_id, limit=current_dedup_limit)
|
||||
_merge_counts(result, dedup_result)
|
||||
result["dedup_items"] = dedup_result["items"]
|
||||
remaining = max(0, remaining - int(dedup_result["items"]))
|
||||
_emit(
|
||||
progress_callback,
|
||||
"map_gtfs_review_dedup_completed",
|
||||
"Scanned GTFS route duplicates.",
|
||||
int(dedup_result["items"]),
|
||||
current_dedup_limit,
|
||||
dedup_result,
|
||||
)
|
||||
if remaining:
|
||||
_emit(progress_callback, "map_gtfs_review_stops_started", "Scanning GTFS stops needing concrete map links.", 0, remaining, result)
|
||||
stop_result = _generate_stop_review_items(session, source_id=source_id, dataset_id=dataset_id, limit=remaining)
|
||||
_merge_counts(result, stop_result)
|
||||
result["stop_items"] = stop_result["items"]
|
||||
_emit(
|
||||
progress_callback,
|
||||
"map_gtfs_review_stops_completed",
|
||||
"Scanned GTFS stops needing concrete map links.",
|
||||
int(stop_result["items"]),
|
||||
remaining,
|
||||
stop_result,
|
||||
)
|
||||
session.flush()
|
||||
_emit(progress_callback, "map_gtfs_review_completed", "Map/GTFS review queue generated.", None, None, result)
|
||||
return result
|
||||
|
||||
|
||||
def list_map_gtfs_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
queue: str | None = None,
|
||||
status: str | None = "open",
|
||||
severity: str | None = None,
|
||||
mode: str | None = None,
|
||||
q: str | None = None,
|
||||
source_id: int | None = None,
|
||||
dataset_id: int | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[MapGtfsReviewItem]:
|
||||
stmt = (
|
||||
select(MapGtfsReviewItem)
|
||||
.outerjoin(GtfsRoute, GtfsRoute.id == MapGtfsReviewItem.gtfs_route_id)
|
||||
.outerjoin(GtfsStop, GtfsStop.id == MapGtfsReviewItem.gtfs_stop_id)
|
||||
.outerjoin(OsmFeature, OsmFeature.id == MapGtfsReviewItem.osm_feature_id)
|
||||
.outerjoin(Source, Source.id == MapGtfsReviewItem.source_id)
|
||||
.options(
|
||||
joinedload(MapGtfsReviewItem.source),
|
||||
joinedload(MapGtfsReviewItem.dataset),
|
||||
joinedload(MapGtfsReviewItem.gtfs_route),
|
||||
joinedload(MapGtfsReviewItem.gtfs_stop),
|
||||
joinedload(MapGtfsReviewItem.osm_feature),
|
||||
joinedload(MapGtfsReviewItem.route_match),
|
||||
joinedload(MapGtfsReviewItem.decision),
|
||||
)
|
||||
.order_by(
|
||||
_severity_order_expression(),
|
||||
MapGtfsReviewItem.updated_at.desc(),
|
||||
MapGtfsReviewItem.id.desc(),
|
||||
)
|
||||
)
|
||||
if queue:
|
||||
stmt = stmt.where(MapGtfsReviewItem.queue == queue)
|
||||
if status:
|
||||
stmt = stmt.where(MapGtfsReviewItem.status == status)
|
||||
if severity:
|
||||
stmt = stmt.where(MapGtfsReviewItem.severity == severity)
|
||||
mode_text = " ".join(str(mode or "").strip().split())
|
||||
if mode_text:
|
||||
stmt = stmt.where(or_(GtfsRoute.mode == mode_text, OsmFeature.mode == mode_text))
|
||||
query_text = " ".join(str(q or "").strip().split())
|
||||
if query_text:
|
||||
pattern = f"%{query_text}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
MapGtfsReviewItem.fingerprint.ilike(pattern),
|
||||
MapGtfsReviewItem.title.ilike(pattern),
|
||||
MapGtfsReviewItem.description.ilike(pattern),
|
||||
GtfsRoute.route_id.ilike(pattern),
|
||||
GtfsRoute.short_name.ilike(pattern),
|
||||
GtfsRoute.long_name.ilike(pattern),
|
||||
GtfsRoute.operator_name.ilike(pattern),
|
||||
GtfsStop.stop_id.ilike(pattern),
|
||||
GtfsStop.name.ilike(pattern),
|
||||
OsmFeature.ref.ilike(pattern),
|
||||
OsmFeature.name.ilike(pattern),
|
||||
Source.name.ilike(pattern),
|
||||
)
|
||||
)
|
||||
if source_id is not None:
|
||||
stmt = stmt.where(MapGtfsReviewItem.source_id == source_id)
|
||||
if dataset_id is not None:
|
||||
stmt = stmt.where(MapGtfsReviewItem.dataset_id == dataset_id)
|
||||
return session.scalars(stmt.limit(max(1, min(int(limit), 1000)))).all()
|
||||
|
||||
|
||||
def map_gtfs_review_summary(session: Session) -> dict[str, Any]:
|
||||
rows = session.execute(
|
||||
select(MapGtfsReviewItem.queue, MapGtfsReviewItem.status, MapGtfsReviewItem.severity, func.count())
|
||||
.select_from(MapGtfsReviewItem)
|
||||
.group_by(MapGtfsReviewItem.queue, MapGtfsReviewItem.status, MapGtfsReviewItem.severity)
|
||||
).all()
|
||||
by_queue: dict[str, dict[str, int]] = {}
|
||||
by_status: dict[str, int] = {}
|
||||
by_severity: dict[str, int] = {}
|
||||
total = 0
|
||||
for queue, status, severity, count in rows:
|
||||
count = int(count or 0)
|
||||
total += count
|
||||
queue_counts = by_queue.setdefault(str(queue), {})
|
||||
queue_counts[str(status)] = queue_counts.get(str(status), 0) + count
|
||||
by_status[str(status)] = by_status.get(str(status), 0) + count
|
||||
by_severity[str(severity)] = by_severity.get(str(severity), 0) + count
|
||||
decisions = session.scalar(select(func.count()).select_from(MapGtfsDecision)) or 0
|
||||
active_decisions = session.scalar(select(func.count()).select_from(MapGtfsDecision).where(MapGtfsDecision.active.is_(True))) or 0
|
||||
return {
|
||||
"total": total,
|
||||
"by_queue": by_queue,
|
||||
"by_status": by_status,
|
||||
"by_severity": by_severity,
|
||||
"decisions": int(decisions),
|
||||
"active_decisions": int(active_decisions),
|
||||
}
|
||||
|
||||
|
||||
def update_map_gtfs_review_item(
|
||||
session: Session,
|
||||
item_id: int,
|
||||
*,
|
||||
status: str | None = None,
|
||||
note: str | None = None,
|
||||
) -> MapGtfsReviewItem:
|
||||
item = session.get(MapGtfsReviewItem, item_id)
|
||||
if item is None:
|
||||
raise ValueError("review item not found")
|
||||
now = datetime.now(timezone.utc)
|
||||
if status is not None:
|
||||
if status not in {"open", "in_review", "resolved", "dismissed"}:
|
||||
raise ValueError("review status must be open, in_review, resolved, or dismissed")
|
||||
item.status = status
|
||||
item.resolved_at = now if status in TERMINAL_REVIEW_STATUSES else None
|
||||
if note is not None:
|
||||
metadata = _json_object(item.metadata_json)
|
||||
metadata["review_note"] = note
|
||||
item.metadata_json = _json_dumps(metadata)
|
||||
item.updated_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def record_route_match_decision(
|
||||
session: Session,
|
||||
match: RouteMatch,
|
||||
*,
|
||||
decision_type: str = "route_osm_relation",
|
||||
reviewer: str | None = "manual",
|
||||
note: str | None = None,
|
||||
) -> MapGtfsDecision | None:
|
||||
route = session.get(GtfsRoute, match.gtfs_route_id)
|
||||
if route is None:
|
||||
return None
|
||||
route_dataset = session.get(Dataset, route.dataset_id)
|
||||
feature = session.get(OsmFeature, match.osm_feature_id) if match.osm_feature_id else None
|
||||
feature_dataset = session.get(Dataset, feature.dataset_id) if feature is not None else None
|
||||
selector = {
|
||||
"gtfs": {
|
||||
"source_id": None if route_dataset is None else route_dataset.source_id,
|
||||
"dataset_id": route.dataset_id,
|
||||
"route_id": route.route_id,
|
||||
"route_key": route.route_key,
|
||||
"ref": route.short_name,
|
||||
"mode": route.mode,
|
||||
},
|
||||
"route_match_id": match.id,
|
||||
}
|
||||
action: dict[str, Any] = {"status": match.status}
|
||||
if feature is not None:
|
||||
action["osm"] = {
|
||||
"source_id": None if feature_dataset is None else feature_dataset.source_id,
|
||||
"dataset_id": feature.dataset_id,
|
||||
"osm_type": feature.osm_type,
|
||||
"osm_id": feature.osm_id,
|
||||
"route_key": feature.route_key,
|
||||
"ref": feature.ref,
|
||||
"mode": feature.mode,
|
||||
}
|
||||
evidence = {
|
||||
"selector": selector,
|
||||
"action": action,
|
||||
"confidence": match.confidence,
|
||||
"reasons": _json_object(match.reasons_json),
|
||||
}
|
||||
decision_key = _stable_key(decision_type, selector, action)
|
||||
evidence_hash = _hash_payload(evidence)
|
||||
decision = session.scalar(select(MapGtfsDecision).where(MapGtfsDecision.decision_key == decision_key))
|
||||
now = datetime.now(timezone.utc)
|
||||
if decision is None:
|
||||
decision = MapGtfsDecision(
|
||||
decision_key=decision_key,
|
||||
decision_type=decision_type,
|
||||
status=match.status,
|
||||
active=True,
|
||||
source_id=None if route_dataset is None else route_dataset.source_id,
|
||||
gtfs_dataset_id=route.dataset_id,
|
||||
gtfs_route_id=route.id,
|
||||
osm_dataset_id=None if feature is None else feature.dataset_id,
|
||||
osm_feature_id=None if feature is None else feature.id,
|
||||
route_match_id=match.id,
|
||||
confidence=match.confidence,
|
||||
evidence_hash=evidence_hash,
|
||||
selector_json=_json_dumps(selector),
|
||||
action_json=_json_dumps(action),
|
||||
note=note,
|
||||
reviewer=reviewer,
|
||||
metadata_json=_json_dumps({"reasons": _json_object(match.reasons_json)}),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(decision)
|
||||
else:
|
||||
decision.status = match.status
|
||||
decision.active = True
|
||||
decision.osm_dataset_id = None if feature is None else feature.dataset_id
|
||||
decision.osm_feature_id = None if feature is None else feature.id
|
||||
decision.route_match_id = match.id
|
||||
decision.confidence = match.confidence
|
||||
decision.evidence_hash = evidence_hash
|
||||
decision.selector_json = _json_dumps(selector)
|
||||
decision.action_json = _json_dumps(action)
|
||||
decision.note = note if note is not None else decision.note
|
||||
decision.reviewer = reviewer if reviewer is not None else decision.reviewer
|
||||
decision.metadata_json = _json_dumps({"reasons": _json_object(match.reasons_json)})
|
||||
decision.updated_at = now
|
||||
_resolve_review_item_by_fingerprint(session, _route_fingerprint(route), decision=decision)
|
||||
session.flush()
|
||||
return decision
|
||||
|
||||
|
||||
def review_item_payload(item: MapGtfsReviewItem) -> dict[str, Any]:
|
||||
route = item.gtfs_route
|
||||
stop = item.gtfs_stop
|
||||
osm = item.osm_feature
|
||||
return {
|
||||
"id": item.id,
|
||||
"fingerprint": item.fingerprint,
|
||||
"queue": item.queue,
|
||||
"item_type": item.item_type,
|
||||
"status": item.status,
|
||||
"severity": item.severity,
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"source": None if item.source is None else {"id": item.source.id, "name": item.source.name, "kind": item.source.kind},
|
||||
"dataset": None if item.dataset is None else {"id": item.dataset.id, "kind": item.dataset.kind, "sha256": item.dataset.sha256},
|
||||
"gtfs_route": None
|
||||
if route is None
|
||||
else {
|
||||
"id": route.id,
|
||||
"dataset_id": route.dataset_id,
|
||||
"route_id": route.route_id,
|
||||
"ref": route.short_name,
|
||||
"name": route.long_name,
|
||||
"mode": route.mode,
|
||||
"operator": route.operator_name,
|
||||
},
|
||||
"gtfs_stop": None
|
||||
if stop is None
|
||||
else {
|
||||
"id": stop.id,
|
||||
"dataset_id": stop.dataset_id,
|
||||
"stop_id": stop.stop_id,
|
||||
"name": stop.name,
|
||||
"lat": stop.lat,
|
||||
"lon": stop.lon,
|
||||
},
|
||||
"osm_feature": None
|
||||
if osm is None
|
||||
else {
|
||||
"id": osm.id,
|
||||
"dataset_id": osm.dataset_id,
|
||||
"osm_type": osm.osm_type,
|
||||
"osm_id": osm.osm_id,
|
||||
"kind": osm.kind,
|
||||
"mode": osm.mode,
|
||||
"ref": osm.ref,
|
||||
"name": osm.name,
|
||||
},
|
||||
"route_match_id": item.route_match_id,
|
||||
"canonical_stop_id": item.canonical_stop_id,
|
||||
"canonical_stop_link_id": item.canonical_stop_link_id,
|
||||
"decision_id": item.decision_id,
|
||||
"evidence_hash": item.evidence_hash,
|
||||
"metadata": _json_object(item.metadata_json),
|
||||
"created_at": item.created_at.isoformat(),
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
"resolved_at": None if item.resolved_at is None else item.resolved_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def decision_payload(decision: MapGtfsDecision) -> dict[str, Any]:
|
||||
return {
|
||||
"id": decision.id,
|
||||
"decision_key": decision.decision_key,
|
||||
"decision_type": decision.decision_type,
|
||||
"status": decision.status,
|
||||
"active": decision.active,
|
||||
"source_id": decision.source_id,
|
||||
"gtfs_dataset_id": decision.gtfs_dataset_id,
|
||||
"gtfs_route_id": decision.gtfs_route_id,
|
||||
"gtfs_stop_id": decision.gtfs_stop_id,
|
||||
"osm_dataset_id": decision.osm_dataset_id,
|
||||
"osm_feature_id": decision.osm_feature_id,
|
||||
"route_match_id": decision.route_match_id,
|
||||
"canonical_stop_id": decision.canonical_stop_id,
|
||||
"canonical_stop_link_id": decision.canonical_stop_link_id,
|
||||
"confidence": decision.confidence,
|
||||
"evidence_hash": decision.evidence_hash,
|
||||
"selector": _json_object(decision.selector_json),
|
||||
"action": _json_object(decision.action_json),
|
||||
"note": decision.note,
|
||||
"reviewer": decision.reviewer,
|
||||
"metadata": _json_object(decision.metadata_json),
|
||||
"created_at": decision.created_at.isoformat(),
|
||||
"updated_at": decision.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _generate_route_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int | None,
|
||||
dataset_id: int | None,
|
||||
limit: int,
|
||||
) -> dict[str, int]:
|
||||
stmt = (
|
||||
select(RouteMatch)
|
||||
.options(joinedload(RouteMatch.gtfs_route), joinedload(RouteMatch.osm_feature))
|
||||
.join(RouteMatch.gtfs_route)
|
||||
.join(Dataset, Dataset.id == GtfsRoute.dataset_id)
|
||||
.where(Dataset.is_active.is_(True), Dataset.kind == "gtfs", RouteMatch.status.in_(ROUTE_REVIEW_STATUSES))
|
||||
.order_by(RouteMatch.status.desc(), RouteMatch.confidence.asc(), RouteMatch.id)
|
||||
)
|
||||
if source_id is not None:
|
||||
stmt = stmt.where(Dataset.source_id == source_id)
|
||||
if dataset_id is not None:
|
||||
stmt = stmt.where(GtfsRoute.dataset_id == dataset_id)
|
||||
matches = session.scalars(stmt.limit(limit)).all()
|
||||
counts = _empty_counts()
|
||||
counts["items"] = len(matches)
|
||||
for match in matches:
|
||||
route = match.gtfs_route
|
||||
if route is None:
|
||||
continue
|
||||
dataset = session.get(Dataset, route.dataset_id)
|
||||
metadata = {
|
||||
"route_match_status": match.status,
|
||||
"confidence": match.confidence,
|
||||
"reasons": _json_object(match.reasons_json),
|
||||
"gtfs": {
|
||||
"route_id": route.route_id,
|
||||
"route_key": route.route_key,
|
||||
"ref": route.short_name,
|
||||
"mode": route.mode,
|
||||
},
|
||||
"osm_feature_id": match.osm_feature_id,
|
||||
}
|
||||
status_label = match.status.replace("_", " ")
|
||||
item = _upsert_review_item(
|
||||
session,
|
||||
fingerprint=_route_fingerprint(route),
|
||||
queue="route_matching",
|
||||
item_type="gtfs_route_osm_route",
|
||||
severity=_route_review_severity(match.status),
|
||||
title=f"Review route {route.short_name or route.route_id}: {status_label}",
|
||||
description=_route_review_description(route, match),
|
||||
source_id=None if dataset is None else dataset.source_id,
|
||||
dataset_id=route.dataset_id,
|
||||
gtfs_route_id=route.id,
|
||||
osm_feature_id=match.osm_feature_id,
|
||||
route_match_id=match.id,
|
||||
evidence_payload=metadata,
|
||||
)
|
||||
counts[item] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _generate_gtfs_route_dedup_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int | None,
|
||||
dataset_id: int | None,
|
||||
limit: int,
|
||||
) -> dict[str, int]:
|
||||
snapshot = gtfs_harmonized_snapshot(session)
|
||||
snapshot_items_by_dataset = {
|
||||
int(item["dataset_id"]): item
|
||||
for item in snapshot.get("datasets", [])
|
||||
if item.get("dataset_id") is not None
|
||||
}
|
||||
included_dataset_ids = [int(item["dataset_id"]) for item in snapshot.get("included", []) if item.get("dataset_active", True)]
|
||||
shadowed_items = {
|
||||
int(item["dataset_id"]): item
|
||||
for item in snapshot.get("shadowed", [])
|
||||
if item.get("dataset_id") is not None
|
||||
}
|
||||
if source_id is not None:
|
||||
shadowed_items = {dataset_id: item for dataset_id, item in shadowed_items.items() if int(item.get("source_id") or 0) == int(source_id)}
|
||||
if dataset_id is not None:
|
||||
shadowed_items = {int(dataset_id): item} if int(dataset_id) in shadowed_items else {}
|
||||
route_shadow_map = active_harmonized_gtfs_route_shadow_map(session)
|
||||
if not included_dataset_ids and not route_shadow_map:
|
||||
counts = _empty_counts()
|
||||
counts["items"] = 0
|
||||
return counts
|
||||
|
||||
pairs: list[tuple[GtfsRoute, GtfsRoute, dict[str, Any]]] = []
|
||||
if included_dataset_ids and shadowed_items:
|
||||
included_by_key = _routes_by_overlap_key(session, included_dataset_ids)
|
||||
shadowed_routes = session.scalars(
|
||||
select(GtfsRoute)
|
||||
.where(GtfsRoute.dataset_id.in_(list(shadowed_items)))
|
||||
.order_by(GtfsRoute.dataset_id, GtfsRoute.mode, GtfsRoute.short_name, GtfsRoute.route_id)
|
||||
.limit(max(1, int(limit)) * 3)
|
||||
).all()
|
||||
for route in shadowed_routes:
|
||||
key = gtfs_route_overlap_key(route)
|
||||
if not key:
|
||||
continue
|
||||
candidates = included_by_key.get(key, [])
|
||||
if not candidates:
|
||||
continue
|
||||
shadowed_item = shadowed_items[int(route.dataset_id)]
|
||||
preferred_dataset_id = _optional_int(shadowed_item.get("shadowed_by_dataset_id"))
|
||||
candidate = _best_included_duplicate_candidate(candidates, preferred_dataset_id=preferred_dataset_id)
|
||||
pairs.append((route, candidate, shadowed_item))
|
||||
if len(pairs) >= max(1, int(limit)):
|
||||
break
|
||||
if len(pairs) < max(1, int(limit)) and route_shadow_map:
|
||||
pairs.extend(
|
||||
_partial_route_shadow_pairs(
|
||||
session,
|
||||
route_shadow_map=route_shadow_map,
|
||||
snapshot_items_by_dataset=snapshot_items_by_dataset,
|
||||
source_id=source_id,
|
||||
dataset_id=dataset_id,
|
||||
limit=max(0, int(limit) - len(pairs)),
|
||||
)
|
||||
)
|
||||
|
||||
trip_counts = _trip_counts_for_routes(session, [route for pair in pairs for route in pair[:2]])
|
||||
counts = _empty_counts()
|
||||
counts["items"] = len(pairs)
|
||||
for shadowed_route, included_route, shadowed_item in pairs:
|
||||
dataset = session.get(Dataset, shadowed_route.dataset_id)
|
||||
evidence = _route_dedup_evidence(
|
||||
shadowed_route,
|
||||
included_route,
|
||||
shadowed_item=shadowed_item,
|
||||
trip_counts=trip_counts,
|
||||
)
|
||||
severity = "warn" if evidence["data_differs"] else "info"
|
||||
item = _upsert_review_item(
|
||||
session,
|
||||
fingerprint=_route_dedup_fingerprint(shadowed_route, included_route),
|
||||
queue="gtfs_deduplication",
|
||||
item_type="gtfs_route_duplicate",
|
||||
severity=severity,
|
||||
title=_route_dedup_title(shadowed_route, included_route, shadowed_item),
|
||||
description=_route_dedup_description(evidence),
|
||||
source_id=None if dataset is None else dataset.source_id,
|
||||
dataset_id=shadowed_route.dataset_id,
|
||||
gtfs_route_id=shadowed_route.id,
|
||||
evidence_payload=evidence,
|
||||
)
|
||||
counts[item] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _partial_route_shadow_pairs(
|
||||
session: Session,
|
||||
*,
|
||||
route_shadow_map: dict[int, int],
|
||||
snapshot_items_by_dataset: dict[int, dict[str, Any]],
|
||||
source_id: int | None,
|
||||
dataset_id: int | None,
|
||||
limit: int,
|
||||
) -> list[tuple[GtfsRoute, GtfsRoute, dict[str, Any]]]:
|
||||
if limit <= 0 or not route_shadow_map:
|
||||
return []
|
||||
route_ids = sorted({*route_shadow_map.keys(), *route_shadow_map.values()})
|
||||
routes = {int(route.id): route for route in session.scalars(select(GtfsRoute).where(GtfsRoute.id.in_(route_ids))).all()}
|
||||
dataset_ids = sorted({int(route.dataset_id) for route in routes.values()})
|
||||
datasets = {int(dataset.id): dataset for dataset in session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all()}
|
||||
pairs: list[tuple[GtfsRoute, GtfsRoute, dict[str, Any]]] = []
|
||||
for shadowed_route_id, included_route_id in sorted(route_shadow_map.items()):
|
||||
shadowed_route = routes.get(int(shadowed_route_id))
|
||||
included_route = routes.get(int(included_route_id))
|
||||
if shadowed_route is None or included_route is None:
|
||||
continue
|
||||
shadowed_dataset = datasets.get(int(shadowed_route.dataset_id))
|
||||
included_dataset = datasets.get(int(included_route.dataset_id))
|
||||
if source_id is not None and (shadowed_dataset is None or int(shadowed_dataset.source_id) != int(source_id)):
|
||||
continue
|
||||
if dataset_id is not None and int(shadowed_route.dataset_id) != int(dataset_id):
|
||||
continue
|
||||
shadowed_item = {
|
||||
**snapshot_items_by_dataset.get(int(shadowed_route.dataset_id), {}),
|
||||
"reason": "route_covered_by_higher_precedence_dataset",
|
||||
"shadowed_by_dataset_id": included_route.dataset_id,
|
||||
"shadowed_by_source_id": None if included_dataset is None else included_dataset.source_id,
|
||||
"route_key_overlap": 1,
|
||||
"route_key_overlap_ratio": 1.0,
|
||||
}
|
||||
pairs.append((shadowed_route, included_route, shadowed_item))
|
||||
if len(pairs) >= limit:
|
||||
break
|
||||
return pairs
|
||||
|
||||
|
||||
def _generate_stop_review_items(
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int | None,
|
||||
dataset_id: int | None,
|
||||
limit: int,
|
||||
) -> dict[str, int]:
|
||||
gtfs_link = aliased(CanonicalStopLink)
|
||||
visual_link = aliased(CanonicalStopLink)
|
||||
visual_link_exists = (
|
||||
select(visual_link.id)
|
||||
.where(
|
||||
visual_link.canonical_stop_id == gtfs_link.canonical_stop_id,
|
||||
visual_link.object_type == "osm_feature",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
stmt = (
|
||||
select(GtfsStop, gtfs_link.id, gtfs_link.canonical_stop_id)
|
||||
.join(Dataset, Dataset.id == GtfsStop.dataset_id)
|
||||
.outerjoin(
|
||||
gtfs_link,
|
||||
and_(
|
||||
gtfs_link.object_type == "gtfs_stop",
|
||||
gtfs_link.dataset_id == GtfsStop.dataset_id,
|
||||
gtfs_link.object_id == GtfsStop.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Dataset.is_active.is_(True),
|
||||
Dataset.kind == "gtfs",
|
||||
or_(gtfs_link.id.is_(None), ~visual_link_exists),
|
||||
)
|
||||
.order_by(GtfsStop.dataset_id, GtfsStop.name, GtfsStop.stop_id)
|
||||
)
|
||||
if source_id is not None:
|
||||
stmt = stmt.where(Dataset.source_id == source_id)
|
||||
if dataset_id is not None:
|
||||
stmt = stmt.where(GtfsStop.dataset_id == dataset_id)
|
||||
rows = session.execute(stmt.limit(limit)).all()
|
||||
counts = _empty_counts()
|
||||
counts["items"] = len(rows)
|
||||
for stop, link_id, canonical_stop_id in rows:
|
||||
dataset = session.get(Dataset, stop.dataset_id)
|
||||
missing_canonical = link_id is None
|
||||
metadata = {
|
||||
"missing_canonical_stop": missing_canonical,
|
||||
"missing_visual_stop": not missing_canonical,
|
||||
"gtfs": {"stop_id": stop.stop_id, "name": stop.name, "lat": stop.lat, "lon": stop.lon},
|
||||
"canonical_stop_id": canonical_stop_id,
|
||||
}
|
||||
item = _upsert_review_item(
|
||||
session,
|
||||
fingerprint=_stop_fingerprint(stop),
|
||||
queue="stop_matching",
|
||||
item_type="gtfs_stop_osm_stop",
|
||||
severity="warn" if missing_canonical else "info",
|
||||
title=f"Review stop {stop.name or stop.stop_id}",
|
||||
description="GTFS stop has no canonical stop link." if missing_canonical else "GTFS stop has no concrete OSM stop/platform link.",
|
||||
source_id=None if dataset is None else dataset.source_id,
|
||||
dataset_id=stop.dataset_id,
|
||||
gtfs_stop_id=stop.id,
|
||||
canonical_stop_id=canonical_stop_id,
|
||||
canonical_stop_link_id=link_id,
|
||||
evidence_payload=metadata,
|
||||
)
|
||||
counts[item] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _routes_by_overlap_key(session: Session, dataset_ids: list[int]) -> dict[str, list[GtfsRoute]]:
|
||||
if not dataset_ids:
|
||||
return {}
|
||||
rows = session.scalars(
|
||||
select(GtfsRoute)
|
||||
.where(GtfsRoute.dataset_id.in_(dataset_ids))
|
||||
.order_by(GtfsRoute.dataset_id, GtfsRoute.mode, GtfsRoute.short_name, GtfsRoute.route_id)
|
||||
).all()
|
||||
routes: dict[str, list[GtfsRoute]] = {}
|
||||
for route in rows:
|
||||
key = gtfs_route_overlap_key(route)
|
||||
if key:
|
||||
routes.setdefault(key, []).append(route)
|
||||
return routes
|
||||
|
||||
|
||||
def _best_included_duplicate_candidate(routes: list[GtfsRoute], *, preferred_dataset_id: int | None) -> GtfsRoute:
|
||||
return sorted(
|
||||
routes,
|
||||
key=lambda route: (
|
||||
0 if preferred_dataset_id is not None and int(route.dataset_id) == preferred_dataset_id else 1,
|
||||
route.dataset_id,
|
||||
route.route_id,
|
||||
),
|
||||
)[0]
|
||||
|
||||
|
||||
def _trip_counts_for_routes(session: Session, routes: list[GtfsRoute]) -> dict[tuple[int, str], int]:
|
||||
keys = {(int(route.dataset_id), str(route.route_id)) for route in routes}
|
||||
if not keys:
|
||||
return {}
|
||||
dataset_ids = sorted({dataset_id for dataset_id, _route_id in keys})
|
||||
route_ids = sorted({route_id for _dataset_id, route_id in keys})
|
||||
rows = session.execute(
|
||||
select(GtfsTrip.dataset_id, GtfsTrip.route_id, func.count())
|
||||
.where(GtfsTrip.dataset_id.in_(dataset_ids), GtfsTrip.route_id.in_(route_ids))
|
||||
.group_by(GtfsTrip.dataset_id, GtfsTrip.route_id)
|
||||
).all()
|
||||
return {
|
||||
(int(dataset_id), str(route_id)): int(count or 0)
|
||||
for dataset_id, route_id, count in rows
|
||||
if (int(dataset_id), str(route_id)) in keys
|
||||
}
|
||||
|
||||
|
||||
def _route_dedup_evidence(
|
||||
shadowed_route: GtfsRoute,
|
||||
included_route: GtfsRoute,
|
||||
*,
|
||||
shadowed_item: dict[str, Any],
|
||||
trip_counts: dict[tuple[int, str], int],
|
||||
) -> dict[str, Any]:
|
||||
shadowed_trip_count = trip_counts.get((int(shadowed_route.dataset_id), str(shadowed_route.route_id)), 0)
|
||||
included_trip_count = trip_counts.get((int(included_route.dataset_id), str(included_route.route_id)), 0)
|
||||
differences = []
|
||||
for field, left, right in [
|
||||
("name", shadowed_route.long_name, included_route.long_name),
|
||||
("operator", shadowed_route.operator_name, included_route.operator_name),
|
||||
("route_scope", shadowed_route.route_scope, included_route.route_scope),
|
||||
]:
|
||||
if _dedup_text(left) != _dedup_text(right):
|
||||
differences.append(field)
|
||||
if shadowed_trip_count != included_trip_count:
|
||||
differences.append("trip_count")
|
||||
if _geometry_digest(shadowed_route.geometry_geojson) != _geometry_digest(included_route.geometry_geojson):
|
||||
differences.append("geometry")
|
||||
return {
|
||||
"data_differs": bool(differences),
|
||||
"differences": differences,
|
||||
"overlap_key": gtfs_route_overlap_key(shadowed_route),
|
||||
"shadowed_dataset": {
|
||||
"dataset_id": shadowed_route.dataset_id,
|
||||
"source_id": shadowed_item.get("source_id"),
|
||||
"source_name": shadowed_item.get("source_name"),
|
||||
"route_id": shadowed_route.route_id,
|
||||
"ref": shadowed_route.short_name,
|
||||
"name": shadowed_route.long_name,
|
||||
"operator": shadowed_route.operator_name,
|
||||
"mode": shadowed_route.mode,
|
||||
"trip_count": shadowed_trip_count,
|
||||
},
|
||||
"included_dataset": {
|
||||
"dataset_id": included_route.dataset_id,
|
||||
"source_id": shadowed_item.get("shadowed_by_source_id"),
|
||||
"route_id": included_route.route_id,
|
||||
"ref": included_route.short_name,
|
||||
"name": included_route.long_name,
|
||||
"operator": included_route.operator_name,
|
||||
"mode": included_route.mode,
|
||||
"trip_count": included_trip_count,
|
||||
},
|
||||
"snapshot": {
|
||||
"reason": shadowed_item.get("reason"),
|
||||
"route_key_overlap": shadowed_item.get("route_key_overlap"),
|
||||
"route_key_overlap_ratio": shadowed_item.get("route_key_overlap_ratio"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _route_dedup_title(shadowed_route: GtfsRoute, included_route: GtfsRoute, shadowed_item: dict[str, Any]) -> str:
|
||||
ref = shadowed_route.short_name or shadowed_route.route_id
|
||||
source = shadowed_item.get("source_name") or f"dataset #{shadowed_route.dataset_id}"
|
||||
return f"Review duplicate GTFS route {ref}: {source} -> dataset #{included_route.dataset_id}"
|
||||
|
||||
|
||||
def _route_dedup_description(evidence: dict[str, Any]) -> str:
|
||||
differences = evidence.get("differences") or []
|
||||
if differences:
|
||||
return f"Route is shadowed by the active snapshot but differs in {', '.join(differences)}."
|
||||
return "Route is shadowed by the active snapshot and looks equivalent on first-pass route evidence."
|
||||
|
||||
|
||||
def _route_dedup_fingerprint(shadowed_route: GtfsRoute, included_route: GtfsRoute) -> str:
|
||||
return f"gtfs-dedup:{shadowed_route.dataset_id}:{shadowed_route.route_id}:{included_route.dataset_id}:{included_route.route_id}"
|
||||
|
||||
|
||||
def _geometry_digest(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _dedup_text(value: str | None) -> str:
|
||||
return " ".join(str(value or "").casefold().replace("ß", "ss").split())
|
||||
|
||||
|
||||
def _upsert_review_item(
|
||||
session: Session,
|
||||
*,
|
||||
fingerprint: str,
|
||||
queue: str,
|
||||
item_type: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
description: str,
|
||||
source_id: int | None = None,
|
||||
dataset_id: int | None = None,
|
||||
gtfs_route_id: int | None = None,
|
||||
gtfs_stop_id: int | None = None,
|
||||
osm_feature_id: int | None = None,
|
||||
route_match_id: int | None = None,
|
||||
canonical_stop_id: int | None = None,
|
||||
canonical_stop_link_id: int | None = None,
|
||||
decision_id: int | None = None,
|
||||
evidence_payload: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
evidence_hash = _hash_payload(evidence_payload or {})
|
||||
metadata_json = _json_dumps(evidence_payload or {})
|
||||
item = session.scalar(select(MapGtfsReviewItem).where(MapGtfsReviewItem.fingerprint == fingerprint))
|
||||
now = datetime.now(timezone.utc)
|
||||
if item is None:
|
||||
session.add(
|
||||
MapGtfsReviewItem(
|
||||
fingerprint=fingerprint,
|
||||
queue=queue,
|
||||
item_type=item_type,
|
||||
status="open",
|
||||
severity=severity,
|
||||
title=title,
|
||||
description=description,
|
||||
source_id=source_id,
|
||||
dataset_id=dataset_id,
|
||||
gtfs_route_id=gtfs_route_id,
|
||||
gtfs_stop_id=gtfs_stop_id,
|
||||
osm_feature_id=osm_feature_id,
|
||||
route_match_id=route_match_id,
|
||||
canonical_stop_id=canonical_stop_id,
|
||||
canonical_stop_link_id=canonical_stop_link_id,
|
||||
decision_id=decision_id,
|
||||
evidence_hash=evidence_hash,
|
||||
metadata_json=metadata_json,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
return "created"
|
||||
if item.evidence_hash == evidence_hash and item.status in TERMINAL_REVIEW_STATUSES:
|
||||
return "unchanged"
|
||||
changed = (
|
||||
item.queue != queue
|
||||
or item.item_type != item_type
|
||||
or item.severity != severity
|
||||
or item.title != title
|
||||
or item.description != description
|
||||
or item.source_id != source_id
|
||||
or item.dataset_id != dataset_id
|
||||
or item.gtfs_route_id != gtfs_route_id
|
||||
or item.gtfs_stop_id != gtfs_stop_id
|
||||
or item.osm_feature_id != osm_feature_id
|
||||
or item.route_match_id != route_match_id
|
||||
or item.canonical_stop_id != canonical_stop_id
|
||||
or item.canonical_stop_link_id != canonical_stop_link_id
|
||||
or item.decision_id != decision_id
|
||||
or item.evidence_hash != evidence_hash
|
||||
or item.metadata_json != metadata_json
|
||||
)
|
||||
was_terminal = item.status in TERMINAL_REVIEW_STATUSES
|
||||
item.queue = queue
|
||||
item.item_type = item_type
|
||||
item.severity = severity
|
||||
item.title = title
|
||||
item.description = description
|
||||
item.source_id = source_id
|
||||
item.dataset_id = dataset_id
|
||||
item.gtfs_route_id = gtfs_route_id
|
||||
item.gtfs_stop_id = gtfs_stop_id
|
||||
item.osm_feature_id = osm_feature_id
|
||||
item.route_match_id = route_match_id
|
||||
item.canonical_stop_id = canonical_stop_id
|
||||
item.canonical_stop_link_id = canonical_stop_link_id
|
||||
item.decision_id = decision_id
|
||||
item.evidence_hash = evidence_hash
|
||||
item.metadata_json = metadata_json
|
||||
if was_terminal:
|
||||
item.status = "open"
|
||||
item.resolved_at = None
|
||||
item.updated_at = now
|
||||
return "reopened"
|
||||
if changed:
|
||||
item.updated_at = now
|
||||
return "updated"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def _resolve_review_item_by_fingerprint(
|
||||
session: Session,
|
||||
fingerprint: str,
|
||||
*,
|
||||
decision: MapGtfsDecision | None = None,
|
||||
) -> None:
|
||||
item = session.scalar(select(MapGtfsReviewItem).where(MapGtfsReviewItem.fingerprint == fingerprint))
|
||||
if item is None:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
item.status = "resolved"
|
||||
item.resolved_at = now
|
||||
item.updated_at = now
|
||||
if decision is not None:
|
||||
item.decision = decision
|
||||
|
||||
|
||||
def _route_review_severity(status: str) -> str:
|
||||
if status == "missing":
|
||||
return "error"
|
||||
if status in {"weak", "probable"}:
|
||||
return "warn"
|
||||
return "info"
|
||||
|
||||
|
||||
def _route_review_description(route: GtfsRoute, match: RouteMatch) -> str:
|
||||
if match.status == "missing":
|
||||
return "No plausible OSM route relation is linked yet."
|
||||
if match.status == "weak":
|
||||
return "The best OSM route candidate has weak evidence."
|
||||
if match.status == "probable":
|
||||
return "The OSM route candidate is plausible but should be reviewed."
|
||||
return "Automatic route match is available for review."
|
||||
|
||||
|
||||
def _route_fingerprint(route: GtfsRoute) -> str:
|
||||
return f"route:{route.dataset_id}:{route.route_id}"
|
||||
|
||||
|
||||
def _stop_fingerprint(stop: GtfsStop) -> str:
|
||||
return f"stop:{stop.dataset_id}:{stop.stop_id}"
|
||||
|
||||
|
||||
def _stable_key(decision_type: str, selector: dict[str, Any], action: dict[str, Any]) -> str:
|
||||
payload = {"decision_type": decision_type, "selector": selector, "action": action}
|
||||
return f"{decision_type}:{_hash_payload(payload)[:40]}"
|
||||
|
||||
|
||||
def _hash_payload(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _json_object(value: str | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _json_dumps(value: dict[str, Any]) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _empty_counts() -> dict[str, int]:
|
||||
return {"created": 0, "updated": 0, "reopened": 0, "unchanged": 0, "resolved": 0, "items": 0}
|
||||
|
||||
|
||||
def _merge_counts(target: dict[str, Any], source: dict[str, int]) -> None:
|
||||
for key in ["created", "updated", "reopened", "unchanged", "resolved"]:
|
||||
target[key] = int(target.get(key, 0)) + int(source.get(key, 0))
|
||||
|
||||
|
||||
def _optional_int(value: object) -> int | None:
|
||||
try:
|
||||
return None if value is None else int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _emit(
|
||||
progress_callback: ProgressCallback | None,
|
||||
event_type: str,
|
||||
message: str,
|
||||
progress_current: int | None,
|
||||
progress_total: int | None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if progress_callback is not None:
|
||||
progress_callback(event_type, message, progress_current, progress_total, metadata)
|
||||
|
||||
|
||||
def _severity_order_expression():
|
||||
return case(
|
||||
(MapGtfsReviewItem.severity == "error", 0),
|
||||
(MapGtfsReviewItem.severity == "warn", 1),
|
||||
(MapGtfsReviewItem.severity == "info", 2),
|
||||
else_=9,
|
||||
)
|
||||
+146
-13
@@ -4,6 +4,7 @@ import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,8 +25,8 @@ class WorkerHandle:
|
||||
_handles: list[WorkerHandle] = []
|
||||
|
||||
|
||||
def start_queue_workers() -> list[WorkerHandle]:
|
||||
if not settings.queue_worker_autostart:
|
||||
def start_queue_workers(*, force: bool = False) -> list[WorkerHandle]:
|
||||
if not force and not settings.queue_worker_autostart:
|
||||
return []
|
||||
worker_count = max(0, int(settings.queue_worker_count))
|
||||
handles: list[WorkerHandle] = []
|
||||
@@ -36,7 +37,7 @@ def start_queue_workers() -> list[WorkerHandle]:
|
||||
pid_file = worker_dir / f"{worker_id}.pid"
|
||||
log_file = worker_dir / f"{worker_id}.log"
|
||||
existing_pid = _read_pid(pid_file)
|
||||
if existing_pid is not None and _pid_running(existing_pid):
|
||||
if existing_pid is not None and _worker_pid_running(existing_pid, worker_id):
|
||||
handles.append(
|
||||
WorkerHandle(
|
||||
index=index,
|
||||
@@ -69,16 +70,77 @@ def start_queue_workers() -> list[WorkerHandle]:
|
||||
def stop_queue_workers() -> None:
|
||||
if not settings.queue_worker_stop_on_shutdown:
|
||||
return
|
||||
for handle in list(_handles):
|
||||
if not handle.started_by_server or handle.pid is None:
|
||||
stop_configured_queue_workers()
|
||||
|
||||
|
||||
def stop_configured_queue_workers() -> list[WorkerHandle]:
|
||||
handles: list[WorkerHandle] = []
|
||||
worker_dir = settings.data_dir / "workers"
|
||||
worker_dir.mkdir(parents=True, exist_ok=True)
|
||||
worker_count = max(0, int(settings.queue_worker_count))
|
||||
for index in range(worker_count):
|
||||
worker_id = f"server-worker-{index + 1}"
|
||||
pid_file = worker_dir / f"{worker_id}.pid"
|
||||
log_file = worker_dir / f"{worker_id}.log"
|
||||
pid = _read_pid(pid_file)
|
||||
if pid is None:
|
||||
handles.append(
|
||||
WorkerHandle(
|
||||
index=index,
|
||||
worker_id=worker_id,
|
||||
pid=None,
|
||||
status="already_stopped",
|
||||
pid_file=pid_file,
|
||||
log_file=log_file,
|
||||
)
|
||||
)
|
||||
continue
|
||||
_terminate_pid(handle.pid)
|
||||
handle.pid_file.unlink(missing_ok=True)
|
||||
if not _worker_pid_running(pid, worker_id):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
handles.append(
|
||||
WorkerHandle(
|
||||
index=index,
|
||||
worker_id=worker_id,
|
||||
pid=pid,
|
||||
status="stale_pid_removed",
|
||||
pid_file=pid_file,
|
||||
log_file=log_file,
|
||||
)
|
||||
)
|
||||
continue
|
||||
stopped = _terminate_pid(pid)
|
||||
if stopped:
|
||||
pid_file.unlink(missing_ok=True)
|
||||
status = "stopped"
|
||||
stored_pid = None
|
||||
else:
|
||||
status = "stop_requested"
|
||||
stored_pid = pid
|
||||
handles.append(
|
||||
WorkerHandle(
|
||||
index=index,
|
||||
worker_id=worker_id,
|
||||
pid=stored_pid,
|
||||
status=status,
|
||||
pid_file=pid_file,
|
||||
log_file=log_file,
|
||||
)
|
||||
)
|
||||
_handles[:] = [
|
||||
handle
|
||||
for handle in _handles
|
||||
if handle.pid is not None and _worker_pid_running(handle.pid, handle.worker_id)
|
||||
]
|
||||
return handles
|
||||
|
||||
|
||||
def restart_queue_workers() -> dict[str, list[WorkerHandle]]:
|
||||
stopped = stop_configured_queue_workers()
|
||||
started = start_queue_workers(force=True)
|
||||
return {"stopped": stopped, "started": started}
|
||||
|
||||
|
||||
def queue_worker_status() -> list[dict[str, object]]:
|
||||
if not settings.queue_worker_autostart:
|
||||
return []
|
||||
worker_dir = settings.data_dir / "workers"
|
||||
statuses: list[dict[str, object]] = []
|
||||
configured_count = max(0, int(settings.queue_worker_count))
|
||||
@@ -87,13 +149,20 @@ def queue_worker_status() -> list[dict[str, object]]:
|
||||
pid_file = worker_dir / f"{worker_id}.pid"
|
||||
log_file = worker_dir / f"{worker_id}.log"
|
||||
pid = _read_pid(pid_file)
|
||||
running = pid is not None and _pid_running(pid)
|
||||
pid_alive = pid is not None and _pid_running(pid)
|
||||
running = pid is not None and _worker_pid_running(pid, worker_id)
|
||||
stale = pid is not None and not running
|
||||
status = "running" if running else "stale" if stale else "stopped"
|
||||
if pid_alive and stale:
|
||||
status = "stale_pid_reused"
|
||||
statuses.append(
|
||||
{
|
||||
"index": index,
|
||||
"worker_id": worker_id,
|
||||
"pid": pid,
|
||||
"running": running,
|
||||
"stale": stale,
|
||||
"status": status,
|
||||
"pid_file": str(pid_file),
|
||||
"log_file": str(log_file),
|
||||
}
|
||||
@@ -145,11 +214,75 @@ def _pid_running(pid: int) -> bool:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
return not _pid_is_zombie(pid)
|
||||
|
||||
|
||||
def _terminate_pid(pid: int) -> None:
|
||||
def _pid_is_zombie(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for line in Path(f"/proc/{pid}/status").read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if line.startswith("State:"):
|
||||
return "\tZ" in line or "zombie" in line.lower()
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _worker_pid_running(pid: int, worker_id: str) -> bool:
|
||||
return _pid_running(pid) and _pid_matches_worker(pid, worker_id)
|
||||
|
||||
|
||||
def _pid_matches_worker(pid: int, worker_id: str) -> bool:
|
||||
try:
|
||||
raw_parts = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
|
||||
return False
|
||||
parts = [part.decode("utf-8", errors="replace") for part in raw_parts if part]
|
||||
if "app.cli" not in parts or "worker" not in parts:
|
||||
return False
|
||||
try:
|
||||
worker_id_index = parts.index("--worker-id") + 1
|
||||
except ValueError:
|
||||
return False
|
||||
return worker_id_index < len(parts) and parts[worker_id_index] == worker_id
|
||||
|
||||
|
||||
def _terminate_pid(pid: int, *, timeout_seconds: float = 5.0) -> bool:
|
||||
_signal_pid(pid, signal.SIGTERM)
|
||||
if _wait_for_exit(pid, timeout_seconds):
|
||||
return True
|
||||
_signal_pid(pid, signal.SIGKILL)
|
||||
return _wait_for_exit(pid, 1.0)
|
||||
|
||||
|
||||
def _signal_pid(pid: int, sig: signal.Signals) -> None:
|
||||
try:
|
||||
os.killpg(pid, sig)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError:
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def _wait_for_exit(pid: int, timeout_seconds: float) -> bool:
|
||||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_running(pid):
|
||||
_reap_pid(pid)
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
if not _pid_running(pid):
|
||||
_reap_pid(pid)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reap_pid(pid: int) -> None:
|
||||
try:
|
||||
os.waitpid(pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user