aboutsummaryrefslogtreecommitdiff
path: root/build_vitrasa/futbol.py
diff options
context:
space:
mode:
Diffstat (limited to 'build_vitrasa/futbol.py')
-rw-r--r--build_vitrasa/futbol.py236
1 files changed, 236 insertions, 0 deletions
diff --git a/build_vitrasa/futbol.py b/build_vitrasa/futbol.py
new file mode 100644
index 0000000..68bbddd
--- /dev/null
+++ b/build_vitrasa/futbol.py
@@ -0,0 +1,236 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = []
+# ///
+"""
+Generates a GTFS feed for post-football match transit routes.
+
+Match days are defined in a JSON file with the following format:
+[
+ { "date": "20260412", "match_start": "18:00" },
+ ...
+]
+
+For each match day, two trips are generated per template route:
+- Trip A: starts 2h00 after match_start
+- Trip B: starts 2h10 after match_start
+
+The template trips in futbol/trips.txt use relative time offsets from
+00:00:00, which are added on top of the calculated departure base time.
+"""
+
+from argparse import ArgumentParser
+import csv
+import io
+import json
+import logging
+import os
+import zipfile
+from datetime import datetime, timedelta
+
+
+FUTBOL_DIR = os.path.join(os.path.dirname(__file__), "futbol")
+
+
+# Default wave offsets after match start
+DEFAULT_OFFSETS = [
+ timedelta(hours=2, minutes=0),
+ timedelta(hours=2, minutes=10),
+]
+
+
+def read_csv(path: str) -> list[dict]:
+ with open(path, "r", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ if reader.fieldnames is None:
+ return []
+ reader.fieldnames = [name.strip() for name in reader.fieldnames]
+ return [row for row in reader]
+
+
+def time_to_delta(time_str: str) -> timedelta:
+ """Parse a GTFS time string (HH:MM:SS) into a timedelta. Handles times >= 24:00:00."""
+ parts = time_str.strip().split(":")
+ h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
+ return timedelta(hours=h, minutes=m, seconds=s)
+
+
+def delta_to_time(delta: timedelta) -> str:
+ """Format a timedelta as a GTFS time string (HH:MM:SS). Supports hours >= 24."""
+ total_seconds = int(delta.total_seconds())
+ h = total_seconds // 3600
+ m = (total_seconds % 3600) // 60
+ s = total_seconds % 60
+ return f"{h:02d}:{m:02d}:{s:02d}"
+
+
+def write_csv(out: io.StringIO, rows: list[dict], fieldnames: list[str]) -> None:
+ writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n")
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def build_futbol_data(
+ match_days_path: str,
+ wave_offsets: list[timedelta] | None = None,
+) -> dict[str, list[dict]]:
+ """
+ Generate futbol trip data from a match days JSON file.
+
+ Returns a dict with keys: routes, shapes, trips, stop_times, calendar_dates.
+ Does NOT include agency or stops — those are shared with the main feed.
+ """
+ if wave_offsets is None:
+ wave_offsets = DEFAULT_OFFSETS
+
+ with open(match_days_path, "r", encoding="utf-8") as f:
+ match_days: list[dict] = json.load(f)
+ logging.info(f"[futbol] Loaded {len(match_days)} match day(s).")
+
+ template_trips = read_csv(os.path.join(FUTBOL_DIR, "trips.txt"))
+ template_stop_times = read_csv(os.path.join(FUTBOL_DIR, "stop_times.txt"))
+
+ stop_times_by_trip: dict[str, list[dict]] = {}
+ for st in template_stop_times:
+ tid = st["trip_id"].strip()
+ stop_times_by_trip.setdefault(tid, []).append(st)
+ for tid in stop_times_by_trip:
+ stop_times_by_trip[tid].sort(key=lambda x: int(x["stop_sequence"].strip()))
+
+ gen_trips: list[dict] = []
+ gen_stop_times: list[dict] = []
+ gen_calendar_dates: list[dict] = []
+
+ for match in match_days:
+ date_str: str = match["date"]
+ match_start_str: str = match["match_start"]
+
+ try:
+ match_start_dt = datetime.strptime(match_start_str, "%H:%M")
+ except ValueError:
+ match_start_dt = datetime.strptime(match_start_str, "%H:%M:%S")
+
+ match_start_delta = timedelta(
+ hours=match_start_dt.hour,
+ minutes=match_start_dt.minute,
+ seconds=match_start_dt.second,
+ )
+
+ for wave_idx, offset in enumerate(wave_offsets):
+ base_departure = match_start_delta + offset
+ wave_label = f"w{wave_idx}"
+
+ for tmpl_trip in template_trips:
+ tmpl_id = tmpl_trip["trip_id"].strip()
+ service_id = f"futbol_{date_str}_{tmpl_id}_{wave_label}"
+ trip_id = service_id
+
+ new_trip = dict(tmpl_trip)
+ new_trip["trip_id"] = trip_id
+ new_trip["service_id"] = service_id
+ gen_trips.append(new_trip)
+
+ for st in stop_times_by_trip.get(tmpl_id, []):
+ new_st = dict(st)
+ new_st["trip_id"] = trip_id
+ new_st["arrival_time"] = delta_to_time(
+ base_departure + time_to_delta(st["arrival_time"])
+ )
+ new_st["departure_time"] = delta_to_time(
+ base_departure + time_to_delta(st["departure_time"])
+ )
+ gen_stop_times.append(new_st)
+
+ gen_calendar_dates.append({
+ "service_id": service_id,
+ "date": date_str,
+ "exception_type": "1",
+ })
+
+ logging.info(
+ f"[futbol] Match {date_str} @ {match_start_str}: generated "
+ f"{len(template_trips) * len(wave_offsets)} trip(s)."
+ )
+
+ logging.info(
+ f"[futbol] Total: {len(gen_trips)} trips, {len(gen_stop_times)} stop time rows, "
+ f"{len(gen_calendar_dates)} calendar_dates entries."
+ )
+
+ return {
+ "routes": read_csv(os.path.join(FUTBOL_DIR, "routes.txt")),
+ "shapes": read_csv(os.path.join(FUTBOL_DIR, "shapes.txt")),
+ "trips": gen_trips,
+ "stop_times": gen_stop_times,
+ "calendar_dates": gen_calendar_dates,
+ }
+
+
+def generate_futbol_gtfs(
+ match_days_path: str,
+ output_path: str,
+ wave_offsets: list[timedelta] | None = None,
+) -> None:
+ """Standalone wrapper: writes a complete self-contained GTFS ZIP."""
+ data = build_futbol_data(match_days_path, wave_offsets)
+
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ # agency and stops are included here for a valid standalone feed
+ for filename in ("agency.txt", "stops.txt"):
+ src = os.path.join(FUTBOL_DIR, filename)
+ if os.path.exists(src):
+ zf.write(src, filename)
+
+ for filename, rows in data.items():
+ if not rows:
+ continue
+ buf = io.StringIO()
+ write_csv(buf, rows, list(rows[0].keys()))
+ zf.writestr(f"{filename}.txt", buf.getvalue())
+
+ logging.info(f"[futbol] Output written to {output_path}.")
+
+
+if __name__ == "__main__":
+ parser = ArgumentParser(
+ description="Generate GTFS feed for post-football match transit routes."
+ )
+ parser.add_argument(
+ "match_days",
+ type=str,
+ help="Path to JSON file defining match days and start times.",
+ )
+ parser.add_argument(
+ "--output",
+ type=str,
+ default=os.path.join(os.path.dirname(__file__), "gtfs_vitrasa_futbol.zip"),
+ help="Output GTFS ZIP file path.",
+ )
+ parser.add_argument(
+ "--offset-minutes",
+ type=int,
+ nargs="+",
+ default=None,
+ metavar="MINUTES",
+ help="Override default wave offsets in minutes after match start. Default: 120 130.",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable debug logging.",
+ )
+
+ args = parser.parse_args()
+
+ logging.basicConfig(
+ level=logging.DEBUG if args.debug else logging.INFO,
+ format="%(asctime)s - %(levelname)s - %(message)s",
+ )
+
+ wave_offsets = (
+ [timedelta(minutes=m) for m in args.offset_minutes]
+ if args.offset_minutes is not None
+ else None
+ )
+
+ generate_futbol_gtfs(args.match_days, args.output, wave_offsets)