Refactor bus/coach loading and rendering to share code.

Extract `load_road_transport()` as a shared helper for bus and coach,
combining the near-identical route rendering blocks in `get_trip_routes`
and `Trip.elements()` into single `in ("coach", "bus")` branches.
Document transport type patterns in AGENTS.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-02-18 12:52:28 +00:00
parent c87320415f
commit 53f4252b92
3 changed files with 59 additions and 92 deletions

View file

@ -37,3 +37,20 @@ This is a personal agenda web application built with Flask that tracks various e
- `travel_legs()` extracts airlines, airports, and stations from individual trip travel legs - `travel_legs()` extracts airlines, airports, and stations from individual trip travel legs
- `calculate_yearly_stats()` aggregates stats per year including flight/train counts, airlines, airports, stations - `calculate_yearly_stats()` aggregates stats per year including flight/train counts, airlines, airports, stations
- `calculate_overall_stats()` aggregates yearly stats into overall totals for the summary section - `calculate_overall_stats()` aggregates yearly stats into overall totals for the summary section
## Travel type patterns
Transport types: `flight`, `train`, `ferry`, `coach`, `bus`.
**Road transport (bus and coach)** share a common loader `load_road_transport()` in `trip.py`. `load_coaches` and `load_buses` are thin wrappers that pass type name, YAML filenames, and CO2 factor (coach: 0.027 kg/km, bus: 0.1 kg/km). Both use `from_station`/`to_station` fields and support scalar or dict-keyed GeoJSON route filenames in the stop/station data.
**Ferry** is loaded separately: uses `from_terminal`/`to_terminal` fields and GeoJSON routes come from the terminal's `routes` dict (always a dict, not scalar).
**Route rendering** (`get_trip_routes`): bus and coach are handled in a single combined block (they use the same pattern — `from_station`/`to_station`, `{type}_routes/` folder). Ferry always has a geojson file and renders as type `"train"` for the map renderer.
**Trip elements** (`Trip.elements()` in `types.py`): bus and coach are handled in a single combined block using `item["type"] in ("coach", "bus")`. Ferry is separate because it uses `from_terminal`/`to_terminal` and always requires `arrive` (not optional).
**Location collection** (`get_locations`): bus → `bus_stop`, coach → `coach_station`, ferry → `ferry_terminal` (all separate map pin types).
**CO2 factors** (kg CO2e per passenger per km): train 0.037, coach 0.027, ferry 0.02254, bus 0.1.
**Schengen tracking**: ferry journeys are tracked for Schengen compliance; bus and coach are not.

View file

@ -92,62 +92,32 @@ def load_ferries(
return ferries return ferries
def load_coaches( def load_road_transport(
data_dir: str, route_distances: travel.RouteDistances | None = None travel_type: str,
plural: str,
stops_yaml: str,
data_dir: str,
co2_factor: float,
route_distances: travel.RouteDistances | None = None,
) -> list[StrDict]: ) -> list[StrDict]:
"""Load coaches.""" """Load road transport (bus or coach)."""
coaches = load_travel("coach", "coaches", data_dir) items = load_travel(travel_type, plural, data_dir)
stations = travel.parse_yaml("coach_stations", data_dir) stops = travel.parse_yaml(stops_yaml, data_dir)
by_name = {station["name"]: station for station in stations}
for item in coaches:
add_station_objects(item, by_name)
# Add route distance and CO2 calculation if available
if route_distances:
travel.add_leg_route_distance(item, route_distances)
if "distance" in item:
# Calculate CO2 emissions for coach (0.027 kg CO2e per passenger per km)
item["co2_kg"] = item["distance"] * 0.027
# Include GeoJSON route if defined in stations data
from_station = item.get("from_station")
to_station = item.get("to_station")
if from_station and to_station:
# Support scalar or mapping routes: string or dict of station name -> geojson base name
routes_val = from_station.get("routes", {})
if isinstance(routes_val, str):
geo = routes_val
else:
geo = routes_val.get(to_station.get("name"))
if geo:
item["geojson_filename"] = geo
return coaches
def load_buses(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load buses."""
items = load_travel("bus", "buses", data_dir)
stops = travel.parse_yaml("bus_stops", data_dir)
by_name = {stop["name"]: stop for stop in stops} by_name = {stop["name"]: stop for stop in stops}
for item in items: for item in items:
add_station_objects(item, by_name) add_station_objects(item, by_name)
# Add route distance and CO2 calculation if available
if route_distances: if route_distances:
travel.add_leg_route_distance(item, route_distances) travel.add_leg_route_distance(item, route_distances)
if "distance" in item: if "distance" in item:
# Calculate CO2 emissions for bus (0.027 kg CO2e per passenger per km) item["co2_kg"] = item["distance"] * co2_factor
item["co2_kg"] = item["distance"] * 0.1
# Include GeoJSON route if defined in stations data
from_station = item.get("from_station") from_station = item.get("from_station")
to_station = item.get("to_station") to_station = item.get("to_station")
if from_station and to_station: if from_station and to_station:
# Support scalar or mapping routes: string or dict of station name -> geojson base name # Support scalar or mapping routes: string or dict of stop name -> geojson filename
routes_val = from_station.get("routes", {}) routes_val = from_station.get("routes", {})
if isinstance(routes_val, str): if isinstance(routes_val, str):
geo = routes_val geo: str | None = routes_val
else: else:
geo = routes_val.get(to_station.get("name")) geo = routes_val.get(to_station.get("name"))
if geo: if geo:
@ -156,6 +126,24 @@ def load_buses(
return items return items
def load_coaches(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load coaches."""
return load_road_transport(
"coach", "coaches", "coach_stations", data_dir, 0.027, route_distances
)
def load_buses(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load buses."""
return load_road_transport(
"bus", "buses", "bus_stops", data_dir, 0.1, route_distances
)
def process_flight( def process_flight(
flight: StrDict, by_iata: dict[str, Airline], airports: list[StrDict] flight: StrDict, by_iata: dict[str, Airline], airports: list[StrDict]
) -> None: ) -> None:
@ -434,40 +422,22 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]:
} }
) )
continue continue
if t["type"] == "coach": if t["type"] in ("coach", "bus"):
coach_from, coach_to = t["from_station"], t["to_station"] route_type = t["type"]
key = "_".join(["coach"] + sorted([coach_from["name"], coach_to["name"]])) stop_from, stop_to = t["from_station"], t["to_station"]
# Use GeoJSON route when available, otherwise draw straight line key = "_".join([route_type] + sorted([stop_from["name"], stop_to["name"]]))
if t.get("geojson_filename"): if t.get("geojson_filename"):
filename = os.path.join("coach_routes", t["geojson_filename"]) filename = os.path.join(f"{route_type}_routes", t["geojson_filename"])
routes.append( routes.append(
{"type": "coach", "key": key, "geojson_filename": filename} {"type": route_type, "key": key, "geojson_filename": filename}
) )
else: else:
routes.append( routes.append(
{ {
"type": "coach", "type": route_type,
"key": key, "key": key,
"from": latlon_tuple(coach_from), "from": latlon_tuple(stop_from),
"to": latlon_tuple(coach_to), "to": latlon_tuple(stop_to),
}
)
continue
if t["type"] == "bus":
bus_from, bus_to = t["from_station"], t["to_station"]
key = "_".join(["bus"] + sorted([bus_from["name"], bus_to["name"]]))
if t.get("geojson_filename"):
filename = os.path.join("bus_routes", t["geojson_filename"])
routes.append(
{"type": "bus", "key": key, "geojson_filename": filename}
)
else:
routes.append(
{
"type": "bus",
"key": key,
"from": latlon_tuple(bus_from),
"to": latlon_tuple(bus_to),
} }
) )
continue continue

View file

@ -397,8 +397,7 @@ class Trip:
end_country=to_country, end_country=to_country,
) )
) )
if item["type"] == "coach": if item["type"] in ("coach", "bus"):
# Include coach journeys in trip elements
from_country = agenda.get_country(item["from_station"]["country"]) from_country = agenda.get_country(item["from_station"]["country"])
to_country = agenda.get_country(item["to_station"]["country"]) to_country = agenda.get_country(item["to_station"]["country"])
name = f"{item['from']}{item['to']}" name = f"{item['from']}{item['to']}"
@ -408,26 +407,7 @@ class Trip:
end_time=item.get("arrive"), end_time=item.get("arrive"),
title=name, title=name,
detail=item, detail=item,
element_type="coach", element_type=item["type"],
start_loc=item["from"],
end_loc=item["to"],
start_country=from_country,
end_country=to_country,
)
)
if item["type"] == "bus":
# Include bus journeys in trip elements
from_country = agenda.get_country(item["from_station"]["country"])
to_country = agenda.get_country(item["to_station"]["country"])
name = f"{item['from']}{item['to']}"
elements.append(
TripElement(
start_time=item["depart"],
end_time=item.get("arrive"),
title=name,
detail=item,
element_type="bus",
start_loc=item["from"], start_loc=item["from"],
end_loc=item["to"], end_loc=item["to"],
start_country=from_country, start_country=from_country,