Add car journeys to trips

This commit is contained in:
Edward Betts 2026-07-08 09:09:48 +01:00
parent bc7a2e89d0
commit c6cb06b71e
9 changed files with 430 additions and 10 deletions

View file

@ -94,7 +94,7 @@ def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime:
def _require_date_only(value: typing.Any, field_name: str) -> date:
"""Return field value as a date or raise ValueError."""
return typing.cast(date, utils.as_date(_require_date_value(value, field_name)))
return utils.as_date(_require_date_value(value, field_name))
def conference_date_fields(item: StrDict) -> StrDict:

View file

@ -2,6 +2,7 @@
import decimal
import hashlib
import json
import os
import typing
import unicodedata
@ -10,6 +11,7 @@ from datetime import date, datetime, timedelta, timezone
import flask
import pycountry
import yaml
from geopy.distance import geodesic # type: ignore
from agenda import conference, ical, travel, trip_schengen
from agenda.types import StrDict, Trip, TripElement
@ -281,6 +283,156 @@ def load_buses(
)
def route_filename_without_extension(route: str) -> str:
"""Return route filename without a GeoJSON extension."""
return route.removesuffix(".geojson")
def line_strings_from_geojson(geojson_data: StrDict) -> list[list[list[float]]]:
"""Extract LineString coordinate arrays from GeoJSON."""
if geojson_data["type"] == "FeatureCollection":
features = typing.cast(list[StrDict], geojson_data["features"])
return [
line for feature in features for line in line_strings_from_geojson(feature)
]
if geojson_data["type"] == "Feature":
return line_strings_from_geojson(typing.cast(StrDict, geojson_data["geometry"]))
if geojson_data["type"] == "LineString":
return [typing.cast(list[list[float]], geojson_data["coordinates"])]
if geojson_data["type"] == "MultiLineString":
return typing.cast(list[list[list[float]]], geojson_data["coordinates"])
return []
def geojson_lonlat_to_latlon(coord: list[float]) -> tuple[float, float]:
"""Convert a GeoJSON lon/lat coordinate to a Leaflet lat/lon tuple."""
return (coord[1], coord[0])
def geojson_route_endpoints(
geojson_data: StrDict,
) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""Return the first and last points of a GeoJSON route."""
lines = line_strings_from_geojson(geojson_data)
populated_lines = [line for line in lines if line]
if not populated_lines:
return None
return (
geojson_lonlat_to_latlon(populated_lines[0][0]),
geojson_lonlat_to_latlon(populated_lines[-1][-1]),
)
def geojson_route_distance_km(geojson_data: StrDict) -> float:
"""Calculate the length of a GeoJSON route in kilometres."""
total = 0.0
for line in line_strings_from_geojson(geojson_data):
for i in range(len(line) - 1):
total += float(
geodesic(
geojson_lonlat_to_latlon(line[i]),
geojson_lonlat_to_latlon(line[i + 1]),
).km
)
return total
def car_endpoint_type(name: str) -> str:
"""Choose a marker type for a car route endpoint."""
return (
"home"
if name.casefold() in {"home", "pch", "picture house court"}
else "car_stop"
)
def car_route_labels(item: StrDict) -> tuple[str | None, str | None]:
"""Return car route from/to labels from YAML or the route filename."""
from_label = item.get("from")
to_label = item.get("to")
if isinstance(from_label, str) and isinstance(to_label, str):
return (from_label, to_label)
route = item.get("route")
if not isinstance(route, str):
return (
from_label if isinstance(from_label, str) else None,
to_label if isinstance(to_label, str) else None,
)
route_stem = os.path.basename(route_filename_without_extension(route))
if "_to_" not in route_stem:
return (
from_label if isinstance(from_label, str) else None,
to_label if isinstance(to_label, str) else None,
)
start, end = route_stem.split("_to_", 1)
return (
from_label if isinstance(from_label, str) else start.replace("_", " "),
to_label if isinstance(to_label, str) else end.replace("_", " "),
)
def car_endpoint_marker_type(item: StrDict, direction: str, label: str) -> str:
"""Return the marker type for a car route endpoint."""
marker_type = item.get(direction + "_type")
return marker_type if isinstance(marker_type, str) else car_endpoint_type(label)
def load_cars(data_dir: str) -> list[StrDict]:
"""Load car journeys."""
filename = os.path.join(data_dir, "car_journeys.yaml")
if not os.path.exists(filename):
return []
items = load_travel("car", "car_journeys", data_dir)
for item in items:
route = item.get("route")
if not isinstance(route, str):
continue
route_filename = route_filename_without_extension(route)
item["geojson_filename"] = route_filename
with open(
os.path.join(data_dir, "car_routes", route_filename + ".geojson")
) as f:
geojson_data = typing.cast(StrDict, json.load(f))
if "distance" not in item:
item["distance"] = geojson_route_distance_km(geojson_data)
endpoints = geojson_route_endpoints(geojson_data)
from_label, to_label = car_route_labels(item)
if endpoints is None:
continue
for direction, field, label, endpoint in (
("from", "from_location", from_label, endpoints[0]),
("to", "to_location", to_label, endpoints[1]),
):
if not label:
continue
item[field] = {
"name": label,
"type": car_endpoint_marker_type(item, direction, label),
"latitude": endpoint[0],
"longitude": endpoint[1],
}
if from_label:
item["from"] = from_label
if to_label:
item["to"] = to_label
return items
def process_flight(
flight: StrDict, by_iata: dict[str, Airline], airports: list[StrDict]
) -> None:
@ -337,7 +489,8 @@ def collect_travel_items(
+ load_trains(data_dir, route_distances=route_distances)
+ load_ferries(data_dir, route_distances=route_distances)
+ load_coaches(data_dir, route_distances=route_distances)
+ load_buses(data_dir, route_distances=route_distances),
+ load_buses(data_dir, route_distances=route_distances)
+ load_cars(data_dir),
key=depart_datetime,
)
@ -465,6 +618,8 @@ def get_locations(trip: Trip) -> dict[str, StrDict]:
"ferry_terminal": {},
"coach_station": {},
"bus_stop": {},
"home": {},
"car_stop": {},
}
station_list = []
@ -488,6 +643,13 @@ def get_locations(trip: Trip) -> dict[str, StrDict]:
for field in "from_terminal", "to_terminal":
terminal = t[field]
locations["ferry_terminal"][terminal["name"]] = terminal
case "car":
for field in ("from_location", "to_location"):
location = t.get(field)
if not location:
continue
location_type = location.get("type", "car_stop")
locations[location_type][location["name"]] = location
locations["station"] = process_station_list(station_list)
return locations
@ -635,6 +797,27 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]:
}
)
continue
if t["type"] == "car":
route = t.get("geojson_filename")
if not isinstance(route, str):
continue
route_filename = route_filename_without_extension(route)
key = "_".join(
[
"car",
typing.cast(str, t.get("from", "")),
typing.cast(str, t.get("to", "")),
route_filename,
]
)
routes.append(
{
"type": "car",
"key": key,
"geojson_filename": os.path.join("car_routes", route_filename),
}
)
continue
if t["type"] == "train":
for leg in t["legs"]:
train_from, train_to = leg["from_station"], leg["to_station"]

View file

@ -16,7 +16,6 @@ from agenda import format_list_with_ampersand
from . import utils
StrDict = dict[str, typing.Any]
DateOrDateTime = datetime.datetime | datetime.date
@ -46,6 +45,7 @@ class TripElement:
"ferry": ":ferry:",
"coach": ":bus:",
"bus": ":bus:",
"car": ":automobile:",
}
alias = emoji_map.get(self.element_type)
@ -414,6 +414,25 @@ class Trip:
end_country=to_country,
)
)
if item["type"] == "car":
start_location = item.get("from_location", {})
end_location = item.get("to_location", {})
from_country = agenda.get_country(start_location.get("country"))
to_country = agenda.get_country(end_location.get("country"))
name = f"{item.get('from', 'Car journey')}{item.get('to', 'destination')}"
elements.append(
TripElement(
start_time=item["depart"],
end_time=item.get("arrive"),
title=name,
detail=item,
element_type="car",
start_loc=item.get("from"),
end_loc=item.get("to"),
start_country=from_country,
end_country=to_country,
)
)
return sorted(elements, key=lambda e: utils.as_datetime(e.start_time))

View file

@ -23,6 +23,7 @@ This document describes the YAML files read from `../personal-data/`. It is inte
- `ferries.yaml` `from` and `to` values reference `ferry_terminals.yaml` `name`.
- `buses.yaml` `from` and `to` values reference `bus_stops.yaml` `name`.
- `coaches.yaml` `from` and `to` values reference `coach_stations.yaml` `name`.
- `car_journeys.yaml` `route` values name files in `car_routes/`. The `.geojson` extension is optional.
- Station, stop, and terminal `routes` values name GeoJSON files without the `.geojson` extension.
## `accommodation.yaml`
@ -250,6 +251,41 @@ Example:
base_fare: '55.00'
```
## `car_journeys.yaml`
Top-level shape: list of car journeys.
Used by: trip loading, maps, trip timeline. Car journeys are for driving your own car or a rental car.
Required fields:
- `trip`: trip start date.
- `depart`: departure date or datetime.
- `arrive`: arrival date or datetime.
- `route`: GeoJSON filename in `car_routes/`, with or without the `.geojson` extension.
Optional fields:
- `from`, `to`: endpoint labels. If omitted and the route filename uses `A_to_B`, labels are inferred from the filename.
- `from_type`, `to_type`: marker type override. Use `home` to render a house icon. Otherwise car endpoints render as car markers.
- `operator`, `vehicle`, `price`, `currency`, `distance`.
The route distance is calculated from the GeoJSON when `distance` is not present. If an inferred or explicit endpoint label is `home`, `PCH`, or `Picture House Court`, the marker is rendered as a house.
Example:
```yaml
- trip: 2026-07-16
depart: 2026-07-16
arrive: 2026-07-16
route: PCH_to_EMF.geojson
- trip: 2026-07-16
depart: 2026-07-19
arrive: 2026-07-19
route: EMF_to_PCH.geojson
```
## `conferences.yaml`
Top-level shape: list of conferences and conference-like events.

View file

@ -8,6 +8,8 @@ var emojiByType = {
"ferry_terminal": "🚢",
"coach_station": "🚌",
"bus_stop": "🚏",
"home": "🏠",
"car_stop": "🚗",
"accommodation": "🏨",
"conference": "🖥️",
"event": "🍷"
@ -197,7 +199,7 @@ function build_map(map_id, coordinates, routes) {
// Draw routes
routes.forEach(function(route) {
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange", "coach": "green", "bus": "purple"}[route.type];
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange", "coach": "green", "bus": "purple", "car": "#555"}[route.type];
var style = { weight: 3, opacity: 0.5, color: color };
if (route.geojson) {
L.geoJSON(JSON.parse(route.geojson), {

View file

@ -409,7 +409,7 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{% if item.operator and item.operator != item.name %}<span class="text-muted small">{{ item.operator }}</span>{% endif %}
</div>
{% elif e.element_type != "conference" %}
{# Transport: flight, train, ferry, coach, bus #}
{# Transport: flight, train, ferry, coach, bus, car #}
{% set has_arrive = item.arrive is defined and item.arrive %}
{# item.depart may be a date (no .date() method) or a datetime (has .date()) #}
{% set has_time = item.depart is defined and item.depart and item.depart.hour is defined %}

View file

@ -367,20 +367,25 @@
</div>
</div>
{% elif e.element_type in ("coach", "bus") %}
{% elif e.element_type in ("coach", "bus", "car") %}
{% set item = e.detail %}
<div class="trip-transport-card my-1">
<div class="card-body">
<h5 class="card-title">
🚌 {{ item.from }} → {{ item.to }}
{% if e.element_type == "car" %}🚗{% else %}🚌{% endif %}
{{ item.from }} → {{ item.to }}
{% if item.operator %}<small class="text-muted fw-normal">{{ item.operator }}</small>{% endif %}
</h5>
<p class="card-text">
{{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }}
{% if item.depart.hour is defined and item.arrive.hour is defined %}
{{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }}
{% endif %}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% if item.depart.hour is defined and item.arrive.hour is defined %}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% endif %}
{% if item.distance %}
<span class="text-muted">🛤️ {{ "{:,.0f} km".format(item.distance) }}</span>
{% endif %}

View file

@ -1,5 +1,7 @@
"""Tests for trip map coordinate assembly."""
import json
import pathlib
from datetime import date
import agenda.trip
@ -167,3 +169,72 @@ def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train(
"geojson_filename": "train_routes/London_St_Pancras_to_Paris_Gare_du_Nord",
}
]
def test_load_cars_infers_route_labels_and_home_marker(tmp_path: pathlib.Path) -> None:
"""Car journeys should load direct GeoJSON routes and endpoint markers."""
(tmp_path / "car_routes").mkdir()
(tmp_path / "car_journeys.yaml").write_text("""---
- trip: 2026-07-16
depart: 2026-07-16
arrive: 2026-07-16
route: PCH_to_EMF.geojson
""")
(tmp_path / "car_routes" / "PCH_to_EMF.geojson").write_text(
json.dumps(
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "LineString",
"coordinates": [[-2.60283, 51.44083], [-1.2, 52.0]],
},
}
)
)
cars = agenda.trip.load_cars(str(tmp_path))
assert len(cars) == 1
assert cars[0]["type"] == "car"
assert cars[0]["from"] == "PCH"
assert cars[0]["to"] == "EMF"
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
assert cars[0]["distance"] > 0
assert cars[0]["from_location"] == {
"name": "PCH",
"type": "home",
"latitude": 51.44083,
"longitude": -2.60283,
}
assert cars[0]["to_location"] == {
"name": "EMF",
"type": "car_stop",
"latitude": 52.0,
"longitude": -1.2,
}
def test_get_trip_routes_includes_car_geojson() -> None:
"""Car routes should render from the car_routes directory."""
trip = Trip(
start=date(2026, 7, 16),
travel=[
{
"type": "car",
"from": "PCH",
"to": "EMF",
"geojson_filename": "PCH_to_EMF",
}
],
)
routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data")
assert routes == [
{
"type": "car",
"key": "car_PCH_EMF_PCH_to_EMF",
"geojson_filename": "car_routes/PCH_to_EMF",
}
]

View file

@ -38,7 +38,7 @@ def check_currency(item: agenda.types.StrDict) -> None:
def check_country_code(
item: agenda.types.StrDict, source: str, required: bool = True
item: typing.Mapping[str, typing.Any], source: str, required: bool = True
) -> None:
"""Throw error if country code is missing or invalid."""
country = item.get("country")
@ -494,6 +494,109 @@ def check_buses() -> None:
print(len(buses), "buses")
def car_route_path(route: str) -> str:
"""Return the absolute path for a car route filename."""
route_filename = agenda.trip.route_filename_without_extension(route)
if os.path.isabs(route_filename) or ".." in route_filename.split(os.path.sep):
raise ValueError(f"invalid car route filename {route!r}")
return os.path.join(data_dir, "car_routes", route_filename + ".geojson")
def validate_car_journey_routes(cars: list[agenda.types.StrDict]) -> None:
"""Check every car journey route references an existing GeoJSON file."""
for car in cars:
route = car.get("route")
if not isinstance(route, str) or not route:
pprint(car)
print("car journey missing route")
sys.exit(-1)
try:
route_path = car_route_path(route)
except ValueError as exc:
pprint(car)
print(exc)
sys.exit(-1)
if not os.path.exists(route_path):
pprint(car)
print(f"car route does not exist: {route_path}")
sys.exit(-1)
def validate_car_journey_dates(
cars: list[agenda.types.StrDict], trip_list: list[agenda.types.Trip]
) -> None:
"""Check car journey dates fit the referenced trip."""
trip_lookup = {trip.start: trip for trip in trip_list}
prev_depart = None
prev_car = None
for car in cars:
for field in ("trip", "depart", "arrive"):
if field not in car:
pprint(car)
print(f"car journey missing {field}")
sys.exit(-1)
current_depart = normalize_datetime(car["depart"])
arrive = normalize_datetime(car["arrive"])
if prev_depart and current_depart < prev_depart:
assert prev_car is not None
print("Out of order car journey found:")
print(
f" Previous: {prev_car.get('depart')} {prev_car.get('from', '')} -> {prev_car.get('to', '')}"
)
print(
f" Current: {car.get('depart')} {car.get('from', '')} -> {car.get('to', '')}"
)
assert (
False
), "Car journeys are not in chronological order by departure time."
prev_depart = current_depart
prev_car = car
if arrive < current_depart:
print("Invalid car journey found (arrive before depart):")
print(f" {car.get('depart')} {car.get('from', '')} -> {car.get('to', '')}")
print(f" arrive: {car.get('arrive')}")
assert False, "Car journey arrival time is earlier than departure time."
trip_start = agenda.utils.as_date(car["trip"])
trip = trip_lookup.get(trip_start)
if trip is None:
pprint(car)
print(f"car journey references unknown trip {trip_start}")
sys.exit(-1)
trip_end = trip.end or trip.start
depart_date = agenda.utils.as_date(car["depart"])
arrive_date = agenda.utils.as_date(car["arrive"])
if depart_date < trip.start or arrive_date > trip_end:
pprint(car)
print(
"car journey dates outside trip range: "
f"{depart_date} to {arrive_date}; trip {trip.start} to {trip_end}"
)
sys.exit(-1)
check_currency(car)
def check_car_journeys() -> None:
"""Check car journeys, route files and trip date consistency."""
filepath = os.path.join(data_dir, "car_journeys.yaml")
if not os.path.exists(filepath):
print("0 car journeys")
return
cars = agenda.travel.parse_yaml("car_journeys", data_dir)
validate_car_journey_routes(cars)
trip_list = agenda.trip.build_trip_list(data_dir)
validate_car_journey_dates(cars, trip_list)
print(len(cars), "car journeys")
def check_airlines() -> list[agenda.types.StrDict]:
"""Check airlines."""
airlines: list[agenda.types.StrDict] = agenda.travel.parse_yaml(
@ -521,6 +624,7 @@ def check_airlines() -> list[agenda.types.StrDict]:
def check() -> None:
"""Validate personal data YAML files."""
airlines = check_airlines()
check_car_journeys()
check_trips()
check_flights({airline["iata"] for airline in airlines})
check_trains()