Compare commits
3 commits
086bc630c9
...
442ae98463
| Author | SHA1 | Date | |
|---|---|---|---|
| 442ae98463 | |||
| b7d95f26ec | |||
| 1e9169b740 |
8 changed files with 1271 additions and 8 deletions
756
agenda/car_journey_yaml.py
Normal file
756
agenda/car_journey_yaml.py
Normal file
|
|
@ -0,0 +1,756 @@
|
||||||
|
"""Generate car journey YAML and routes for trips."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import typing
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import openrouteservice # type: ignore[import-untyped]
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import trip as trip_module
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
PERSONAL_DATA_DIR = Path("~/src/personal-data").expanduser()
|
||||||
|
DEFAULT_HOME_LABEL = "PCH"
|
||||||
|
DEFAULT_PROFILE = "driving-car"
|
||||||
|
DEFAULT_SNAP_RADIUS_METRES = 5000
|
||||||
|
|
||||||
|
LatLon = tuple[float, float]
|
||||||
|
LonLat = tuple[float, float]
|
||||||
|
GeoJSON = dict[str, typing.Any]
|
||||||
|
StrDict = dict[str, typing.Any]
|
||||||
|
RouteFetcher = typing.Callable[[LonLat, LonLat], GeoJSON]
|
||||||
|
|
||||||
|
|
||||||
|
class NoAliasDumper(yaml.SafeDumper):
|
||||||
|
"""YAML dumper that avoids anchors for repeated scalar values."""
|
||||||
|
|
||||||
|
def ignore_aliases(self, data: typing.Any) -> bool:
|
||||||
|
"""Disable aliases for all values."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TripLike(typing.Protocol):
|
||||||
|
"""Trip fields needed for car journey generation."""
|
||||||
|
|
||||||
|
start: date
|
||||||
|
accommodation: list[StrDict]
|
||||||
|
travel: list[StrDict]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CarJourney:
|
||||||
|
"""A car journey ready to write to YAML and GeoJSON."""
|
||||||
|
|
||||||
|
trip: date
|
||||||
|
depart: date
|
||||||
|
arrive: date
|
||||||
|
route: str
|
||||||
|
start: LonLat
|
||||||
|
end: LonLat
|
||||||
|
|
||||||
|
def as_yaml_item(self) -> StrDict:
|
||||||
|
"""Return the YAML mapping for this journey."""
|
||||||
|
return {
|
||||||
|
"trip": self.trip,
|
||||||
|
"depart": self.depart,
|
||||||
|
"arrive": self.arrive,
|
||||||
|
"route": self.route,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def latlon_to_lonlat(coord: LatLon) -> LonLat:
|
||||||
|
"""Convert a lat/lon pair to lon/lat for routing APIs."""
|
||||||
|
return (coord[1], coord[0])
|
||||||
|
|
||||||
|
|
||||||
|
def route_label(value: str) -> str:
|
||||||
|
"""Return a filesystem-safe route label."""
|
||||||
|
ascii_value = (
|
||||||
|
unicodedata.normalize(
|
||||||
|
"NFKD",
|
||||||
|
value.strip()
|
||||||
|
.replace("æ", "ae")
|
||||||
|
.replace("Æ", "AE")
|
||||||
|
.replace("ð", "d")
|
||||||
|
.replace("Ð", "D")
|
||||||
|
.replace("þ", "th")
|
||||||
|
.replace("Þ", "Th"),
|
||||||
|
)
|
||||||
|
.encode("ascii", "ignore")
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
label = re.sub(r"[^A-Za-z0-9]+", "_", ascii_value).strip("_")
|
||||||
|
if not label:
|
||||||
|
raise ValueError(f"empty route label from {value!r}")
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml_list(path: Path) -> list[StrDict]:
|
||||||
|
"""Load a YAML list, returning an empty list when the file does not exist."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
loaded = yaml.safe_load(path.read_text())
|
||||||
|
if loaded is None:
|
||||||
|
return []
|
||||||
|
if isinstance(loaded, list) and all(isinstance(item, dict) for item in loaded):
|
||||||
|
return typing.cast(list[StrDict], loaded)
|
||||||
|
raise ValueError(f"{path} must contain a YAML list")
|
||||||
|
|
||||||
|
|
||||||
|
def dump_yaml_list_with_blank_lines(items: list[StrDict]) -> str:
|
||||||
|
"""Dump a top-level YAML list using the personal-data spacing style."""
|
||||||
|
if not items:
|
||||||
|
return "---\n"
|
||||||
|
text = yaml.dump(
|
||||||
|
items, sort_keys=False, allow_unicode=True, Dumper=NoAliasDumper
|
||||||
|
).lstrip()
|
||||||
|
return "---\n" + text.replace("\n- ", "\n\n- ")
|
||||||
|
|
||||||
|
|
||||||
|
def car_route_path(data_dir: Path, route: str) -> Path:
|
||||||
|
"""Return the GeoJSON path for a car route name."""
|
||||||
|
route_filename = trip_module.route_filename_without_extension(route)
|
||||||
|
return data_dir / "car_routes" / f"{route_filename}.geojson"
|
||||||
|
|
||||||
|
|
||||||
|
def read_geojson(path: Path) -> GeoJSON:
|
||||||
|
"""Read a GeoJSON file."""
|
||||||
|
return typing.cast(GeoJSON, json.loads(path.read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
def home_latlon_from_existing_routes(data_dir: Path, home_label: str) -> LatLon:
|
||||||
|
"""Find the home coordinate from existing car routes."""
|
||||||
|
route_dir = data_dir / "car_routes"
|
||||||
|
if not route_dir.exists():
|
||||||
|
raise ValueError(f"missing car route directory: {route_dir}")
|
||||||
|
|
||||||
|
home_route_prefix = f"{home_label}_to_"
|
||||||
|
home_route_suffix = f"_to_{home_label}"
|
||||||
|
|
||||||
|
for path in sorted(route_dir.glob("*.geojson")):
|
||||||
|
geojson_data = read_geojson(path)
|
||||||
|
endpoints = trip_module.geojson_route_endpoints(geojson_data)
|
||||||
|
if endpoints is None:
|
||||||
|
continue
|
||||||
|
route_name = path.stem
|
||||||
|
if route_name.startswith(home_route_prefix):
|
||||||
|
return endpoints[0]
|
||||||
|
if route_name.endswith(home_route_suffix):
|
||||||
|
return endpoints[1]
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"could not find home coordinate for {home_label!r} in {route_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trip_for_date(data_dir: Path, trip_date: date) -> TripLike:
|
||||||
|
"""Return the trip with the given start date."""
|
||||||
|
trips = typing.cast(
|
||||||
|
list[TripLike], trip_module.build_trip_list(data_dir=str(data_dir))
|
||||||
|
)
|
||||||
|
for item in trips:
|
||||||
|
if item.start == trip_date:
|
||||||
|
return item
|
||||||
|
raise ValueError(f"could not find trip starting {trip_date}")
|
||||||
|
|
||||||
|
|
||||||
|
def primary_accommodation(trip: TripLike) -> StrDict:
|
||||||
|
"""Return the first accommodation for a trip."""
|
||||||
|
if not trip.accommodation:
|
||||||
|
raise ValueError(f"trip {trip.start} has no accommodation")
|
||||||
|
return min(trip.accommodation, key=lambda item: utils.as_datetime(item["from"]))
|
||||||
|
|
||||||
|
|
||||||
|
def accommodation_label(accommodation: StrDict) -> str:
|
||||||
|
"""Return the destination label for an accommodation."""
|
||||||
|
label = accommodation.get("location") or accommodation.get("name")
|
||||||
|
if not isinstance(label, str):
|
||||||
|
raise ValueError("accommodation has no location or name")
|
||||||
|
return route_label(label)
|
||||||
|
|
||||||
|
|
||||||
|
def accommodation_latlon(accommodation: StrDict) -> LatLon:
|
||||||
|
"""Return accommodation coordinates."""
|
||||||
|
latitude = accommodation.get("latitude")
|
||||||
|
longitude = accommodation.get("longitude")
|
||||||
|
if not isinstance(latitude, (int, float)) or not isinstance(
|
||||||
|
longitude, (int, float)
|
||||||
|
):
|
||||||
|
raise ValueError("accommodation is missing latitude/longitude")
|
||||||
|
return (float(latitude), float(longitude))
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_accommodation(trip: TripLike) -> list[StrDict]:
|
||||||
|
"""Return trip accommodation sorted by check-in time."""
|
||||||
|
return sorted(trip.accommodation, key=lambda item: utils.as_datetime(item["from"]))
|
||||||
|
|
||||||
|
|
||||||
|
def airport_latlon(airport: StrDict) -> LatLon:
|
||||||
|
"""Return airport coordinates."""
|
||||||
|
latitude = airport.get("latitude")
|
||||||
|
longitude = airport.get("longitude")
|
||||||
|
if not isinstance(latitude, (int, float)) or not isinstance(
|
||||||
|
longitude, (int, float)
|
||||||
|
):
|
||||||
|
raise ValueError("airport is missing latitude/longitude")
|
||||||
|
return (float(latitude), float(longitude))
|
||||||
|
|
||||||
|
|
||||||
|
def flight_departure_datetime(item: StrDict) -> typing.Any:
|
||||||
|
"""Return flight departure value for sorting."""
|
||||||
|
return item["depart"]
|
||||||
|
|
||||||
|
|
||||||
|
def trip_flights(trip: TripLike) -> list[StrDict]:
|
||||||
|
"""Return trip flights sorted by departure time."""
|
||||||
|
flights = [item for item in trip.travel if item.get("type") == "flight"]
|
||||||
|
if not flights:
|
||||||
|
raise ValueError(f"trip {trip.start} has no flights")
|
||||||
|
return sorted(
|
||||||
|
flights, key=lambda item: utils.as_datetime(flight_departure_datetime(item))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trip_ferries(trip: TripLike) -> list[StrDict]:
|
||||||
|
"""Return trip ferries sorted by departure time."""
|
||||||
|
ferries = [item for item in trip.travel if item.get("type") == "ferry"]
|
||||||
|
if not ferries:
|
||||||
|
raise ValueError(f"trip {trip.start} has no ferries")
|
||||||
|
return sorted(ferries, key=lambda item: utils.as_datetime(item["depart"]))
|
||||||
|
|
||||||
|
|
||||||
|
def location_latlon(location: StrDict, label: str) -> LatLon:
|
||||||
|
"""Return coordinates from a loaded travel location."""
|
||||||
|
latitude = location.get("latitude")
|
||||||
|
longitude = location.get("longitude")
|
||||||
|
if not isinstance(latitude, (int, float)) or not isinstance(
|
||||||
|
longitude, (int, float)
|
||||||
|
):
|
||||||
|
raise ValueError(f"{label} is missing latitude/longitude")
|
||||||
|
return (float(latitude), float(longitude))
|
||||||
|
|
||||||
|
|
||||||
|
def location_name(location: StrDict, fallback: str) -> str:
|
||||||
|
"""Return a route label source for a loaded travel location."""
|
||||||
|
name = location.get("name") or fallback
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError(f"location name must be a string: {location!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def car_journeys_for_accommodation(
|
||||||
|
trip: TripLike,
|
||||||
|
data_dir: Path,
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
destination_label: str | None = None,
|
||||||
|
) -> list[CarJourney]:
|
||||||
|
"""Build outbound and return car journeys for a trip's primary accommodation."""
|
||||||
|
accommodation = primary_accommodation(trip)
|
||||||
|
home = latlon_to_lonlat(home_latlon_from_existing_routes(data_dir, home_label))
|
||||||
|
destination = latlon_to_lonlat(accommodation_latlon(accommodation))
|
||||||
|
destination_route_label = destination_label or accommodation_label(accommodation)
|
||||||
|
outbound_route = f"{route_label(home_label)}_to_{destination_route_label}"
|
||||||
|
return_route = f"{destination_route_label}_to_{route_label(home_label)}"
|
||||||
|
|
||||||
|
check_in = utils.as_date(accommodation["from"])
|
||||||
|
check_out = utils.as_date(accommodation["to"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=check_in,
|
||||||
|
arrive=check_in,
|
||||||
|
route=outbound_route,
|
||||||
|
start=home,
|
||||||
|
end=destination,
|
||||||
|
),
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=check_out,
|
||||||
|
arrive=check_out,
|
||||||
|
route=return_route,
|
||||||
|
start=destination,
|
||||||
|
end=home,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def car_journeys_for_ferry(
|
||||||
|
trip: TripLike,
|
||||||
|
data_dir: Path,
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
destination_label: str | None = None,
|
||||||
|
) -> list[CarJourney]:
|
||||||
|
"""Build car journeys around an outbound and return car ferry crossing."""
|
||||||
|
ferries = trip_ferries(trip)
|
||||||
|
outbound_ferry = ferries[0]
|
||||||
|
return_ferry = ferries[-1]
|
||||||
|
accommodation = primary_accommodation(trip)
|
||||||
|
|
||||||
|
home = latlon_to_lonlat(home_latlon_from_existing_routes(data_dir, home_label))
|
||||||
|
accommodation_coord = latlon_to_lonlat(accommodation_latlon(accommodation))
|
||||||
|
accommodation_route_label = destination_label or accommodation_label(accommodation)
|
||||||
|
|
||||||
|
outbound_from_terminal = outbound_ferry.get("from_terminal")
|
||||||
|
outbound_to_terminal = outbound_ferry.get("to_terminal")
|
||||||
|
return_from_terminal = return_ferry.get("from_terminal")
|
||||||
|
return_to_terminal = return_ferry.get("to_terminal")
|
||||||
|
terminals = (
|
||||||
|
outbound_from_terminal,
|
||||||
|
outbound_to_terminal,
|
||||||
|
return_from_terminal,
|
||||||
|
return_to_terminal,
|
||||||
|
)
|
||||||
|
if not all(isinstance(terminal, dict) for terminal in terminals):
|
||||||
|
raise ValueError("ferry is missing terminal details")
|
||||||
|
|
||||||
|
outbound_from = typing.cast(StrDict, outbound_from_terminal)
|
||||||
|
outbound_to = typing.cast(StrDict, outbound_to_terminal)
|
||||||
|
return_from = typing.cast(StrDict, return_from_terminal)
|
||||||
|
return_to = typing.cast(StrDict, return_to_terminal)
|
||||||
|
|
||||||
|
home_route_label = route_label(home_label)
|
||||||
|
outbound_from_label = route_label(
|
||||||
|
location_name(outbound_from, typing.cast(str, outbound_ferry.get("from", "")))
|
||||||
|
)
|
||||||
|
outbound_to_label = route_label(
|
||||||
|
location_name(outbound_to, typing.cast(str, outbound_ferry.get("to", "")))
|
||||||
|
)
|
||||||
|
return_from_label = route_label(
|
||||||
|
location_name(return_from, typing.cast(str, return_ferry.get("from", "")))
|
||||||
|
)
|
||||||
|
return_to_label = route_label(
|
||||||
|
location_name(return_to, typing.cast(str, return_ferry.get("to", "")))
|
||||||
|
)
|
||||||
|
|
||||||
|
outbound_from_coord = latlon_to_lonlat(
|
||||||
|
location_latlon(outbound_from, outbound_from_label)
|
||||||
|
)
|
||||||
|
outbound_to_coord = latlon_to_lonlat(
|
||||||
|
location_latlon(outbound_to, outbound_to_label)
|
||||||
|
)
|
||||||
|
return_from_coord = latlon_to_lonlat(
|
||||||
|
location_latlon(return_from, return_from_label)
|
||||||
|
)
|
||||||
|
return_to_coord = latlon_to_lonlat(location_latlon(return_to, return_to_label))
|
||||||
|
|
||||||
|
return [
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(outbound_ferry["depart"]),
|
||||||
|
arrive=utils.as_date(outbound_ferry["depart"]),
|
||||||
|
route=f"{home_route_label}_to_{outbound_from_label}",
|
||||||
|
start=home,
|
||||||
|
end=outbound_from_coord,
|
||||||
|
),
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(outbound_ferry["arrive"]),
|
||||||
|
arrive=utils.as_date(outbound_ferry["arrive"]),
|
||||||
|
route=f"{outbound_to_label}_to_{accommodation_route_label}",
|
||||||
|
start=outbound_to_coord,
|
||||||
|
end=accommodation_coord,
|
||||||
|
),
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(return_ferry["depart"]),
|
||||||
|
arrive=utils.as_date(return_ferry["depart"]),
|
||||||
|
route=f"{accommodation_route_label}_to_{return_from_label}",
|
||||||
|
start=accommodation_coord,
|
||||||
|
end=return_from_coord,
|
||||||
|
),
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(return_ferry["arrive"]),
|
||||||
|
arrive=utils.as_date(return_ferry["arrive"]),
|
||||||
|
route=f"{return_to_label}_to_{home_route_label}",
|
||||||
|
start=return_to_coord,
|
||||||
|
end=home,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def car_journeys_for_airport(
|
||||||
|
trip: TripLike,
|
||||||
|
data_dir: Path,
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
) -> list[CarJourney]:
|
||||||
|
"""Build outbound and return car journeys for the trip's UK airport."""
|
||||||
|
flights = trip_flights(trip)
|
||||||
|
outbound_flight = flights[0]
|
||||||
|
return_flight = flights[-1]
|
||||||
|
outbound_airport = outbound_flight.get("from_airport")
|
||||||
|
return_airport = return_flight.get("to_airport")
|
||||||
|
if not isinstance(outbound_airport, dict) or not isinstance(return_airport, dict):
|
||||||
|
raise ValueError("flight is missing airport details")
|
||||||
|
|
||||||
|
outbound_iata = outbound_airport.get("iata")
|
||||||
|
return_iata = return_airport.get("iata")
|
||||||
|
if not isinstance(outbound_iata, str) or not isinstance(return_iata, str):
|
||||||
|
raise ValueError("flight airport is missing IATA code")
|
||||||
|
|
||||||
|
home = latlon_to_lonlat(home_latlon_from_existing_routes(data_dir, home_label))
|
||||||
|
outbound_destination = latlon_to_lonlat(airport_latlon(outbound_airport))
|
||||||
|
return_start = latlon_to_lonlat(airport_latlon(return_airport))
|
||||||
|
home_route_label = route_label(home_label)
|
||||||
|
outbound_route_label = route_label(outbound_iata)
|
||||||
|
return_route_label = route_label(return_iata)
|
||||||
|
|
||||||
|
return [
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(outbound_flight["depart"]),
|
||||||
|
arrive=utils.as_date(outbound_flight["depart"]),
|
||||||
|
route=f"{home_route_label}_to_{outbound_route_label}",
|
||||||
|
start=home,
|
||||||
|
end=outbound_destination,
|
||||||
|
),
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(return_flight["arrive"]),
|
||||||
|
arrive=utils.as_date(return_flight["arrive"]),
|
||||||
|
route=f"{return_route_label}_to_{home_route_label}",
|
||||||
|
start=return_start,
|
||||||
|
end=home,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def country_code(item: StrDict) -> str | None:
|
||||||
|
"""Return a lower-case country code from a YAML item."""
|
||||||
|
country = item.get("country")
|
||||||
|
return country.casefold() if isinstance(country, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def accommodation_route_label(accommodation: StrDict) -> str:
|
||||||
|
"""Return the route label for an accommodation."""
|
||||||
|
return accommodation_label(accommodation)
|
||||||
|
|
||||||
|
|
||||||
|
def airport_iata(airport: StrDict) -> str:
|
||||||
|
"""Return an airport IATA code."""
|
||||||
|
iata = airport.get("iata")
|
||||||
|
if not isinstance(iata, str):
|
||||||
|
raise ValueError(f"airport missing IATA code: {airport!r}")
|
||||||
|
return iata
|
||||||
|
|
||||||
|
|
||||||
|
def flight_airport(flight: StrDict, field: str) -> StrDict:
|
||||||
|
"""Return airport detail for a flight field."""
|
||||||
|
airport = flight.get(field)
|
||||||
|
if not isinstance(airport, dict):
|
||||||
|
raise ValueError(f"flight missing {field}")
|
||||||
|
return typing.cast(StrDict, airport)
|
||||||
|
|
||||||
|
|
||||||
|
def car_journeys_for_fly_drive(
|
||||||
|
trip: TripLike,
|
||||||
|
data_dir: Path,
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
) -> list[CarJourney]:
|
||||||
|
"""Build car journeys for home-airport-hotel plus destination rental car legs."""
|
||||||
|
flights = trip_flights(trip)
|
||||||
|
accommodations = sorted_accommodation(trip)
|
||||||
|
if not accommodations:
|
||||||
|
raise ValueError(f"trip {trip.start} has no accommodation")
|
||||||
|
|
||||||
|
outbound_flight = flights[0]
|
||||||
|
return_flight = flights[-1]
|
||||||
|
outbound_destination_airport = flight_airport(outbound_flight, "to_airport")
|
||||||
|
return_origin_airport = flight_airport(return_flight, "from_airport")
|
||||||
|
return_destination_airport = flight_airport(return_flight, "to_airport")
|
||||||
|
home = latlon_to_lonlat(home_latlon_from_existing_routes(data_dir, home_label))
|
||||||
|
home_route_label = route_label(home_label)
|
||||||
|
|
||||||
|
preflight_accommodation = next(
|
||||||
|
(
|
||||||
|
accommodation
|
||||||
|
for accommodation in accommodations
|
||||||
|
if country_code(accommodation) == "gb"
|
||||||
|
and utils.as_date(accommodation["from"])
|
||||||
|
<= utils.as_date(outbound_flight["depart"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
destination_country = country_code(outbound_destination_airport)
|
||||||
|
rental_accommodations = [
|
||||||
|
accommodation
|
||||||
|
for accommodation in accommodations
|
||||||
|
if country_code(accommodation) == destination_country
|
||||||
|
]
|
||||||
|
if not rental_accommodations:
|
||||||
|
raise ValueError("trip has no destination-country accommodation")
|
||||||
|
|
||||||
|
journeys: list[CarJourney] = []
|
||||||
|
if preflight_accommodation is not None:
|
||||||
|
preflight_label = accommodation_route_label(preflight_accommodation)
|
||||||
|
preflight_coord = latlon_to_lonlat(
|
||||||
|
accommodation_latlon(preflight_accommodation)
|
||||||
|
)
|
||||||
|
journeys.append(
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(preflight_accommodation["from"]),
|
||||||
|
arrive=utils.as_date(preflight_accommodation["from"]),
|
||||||
|
route=f"{home_route_label}_to_{preflight_label}",
|
||||||
|
start=home,
|
||||||
|
end=preflight_coord,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
destination_airport_label = route_label(airport_iata(outbound_destination_airport))
|
||||||
|
destination_airport_coord = latlon_to_lonlat(
|
||||||
|
airport_latlon(outbound_destination_airport)
|
||||||
|
)
|
||||||
|
first_accommodation = rental_accommodations[0]
|
||||||
|
first_label = accommodation_route_label(first_accommodation)
|
||||||
|
first_coord = latlon_to_lonlat(accommodation_latlon(first_accommodation))
|
||||||
|
journeys.append(
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(outbound_flight["arrive"]),
|
||||||
|
arrive=utils.as_date(outbound_flight["arrive"]),
|
||||||
|
route=f"{destination_airport_label}_to_{first_label}",
|
||||||
|
start=destination_airport_coord,
|
||||||
|
end=first_coord,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for previous, current in zip(rental_accommodations, rental_accommodations[1:]):
|
||||||
|
previous_label = accommodation_route_label(previous)
|
||||||
|
current_label = accommodation_route_label(current)
|
||||||
|
journeys.append(
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(previous["to"]),
|
||||||
|
arrive=utils.as_date(previous["to"]),
|
||||||
|
route=f"{previous_label}_to_{current_label}",
|
||||||
|
start=latlon_to_lonlat(accommodation_latlon(previous)),
|
||||||
|
end=latlon_to_lonlat(accommodation_latlon(current)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_accommodation = rental_accommodations[-1]
|
||||||
|
return_origin_airport_label = route_label(airport_iata(return_origin_airport))
|
||||||
|
journeys.append(
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(return_flight["depart"]),
|
||||||
|
arrive=utils.as_date(return_flight["depart"]),
|
||||||
|
route=f"{accommodation_route_label(last_accommodation)}_to_{return_origin_airport_label}",
|
||||||
|
start=latlon_to_lonlat(accommodation_latlon(last_accommodation)),
|
||||||
|
end=latlon_to_lonlat(airport_latlon(return_origin_airport)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return_destination_airport_label = route_label(
|
||||||
|
airport_iata(return_destination_airport)
|
||||||
|
)
|
||||||
|
journeys.append(
|
||||||
|
CarJourney(
|
||||||
|
trip=trip.start,
|
||||||
|
depart=utils.as_date(return_flight["arrive"]),
|
||||||
|
arrive=utils.as_date(return_flight["arrive"]),
|
||||||
|
route=f"{return_destination_airport_label}_to_{home_route_label}",
|
||||||
|
start=latlon_to_lonlat(airport_latlon(return_destination_airport)),
|
||||||
|
end=home,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return journeys
|
||||||
|
|
||||||
|
|
||||||
|
def car_journeys_for_trip(
|
||||||
|
trip: TripLike,
|
||||||
|
data_dir: Path,
|
||||||
|
destination: str,
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
destination_label: str | None = None,
|
||||||
|
) -> list[CarJourney]:
|
||||||
|
"""Build outbound and return car journeys for a trip."""
|
||||||
|
if destination == "accommodation":
|
||||||
|
return car_journeys_for_accommodation(
|
||||||
|
trip,
|
||||||
|
data_dir,
|
||||||
|
home_label=home_label,
|
||||||
|
destination_label=destination_label,
|
||||||
|
)
|
||||||
|
if destination == "airport":
|
||||||
|
return car_journeys_for_airport(trip, data_dir, home_label=home_label)
|
||||||
|
if destination == "ferry":
|
||||||
|
return car_journeys_for_ferry(
|
||||||
|
trip,
|
||||||
|
data_dir,
|
||||||
|
home_label=home_label,
|
||||||
|
destination_label=destination_label,
|
||||||
|
)
|
||||||
|
if destination == "fly-drive":
|
||||||
|
return car_journeys_for_fly_drive(trip, data_dir, home_label=home_label)
|
||||||
|
raise ValueError(f"unknown car journey destination {destination!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def openrouteservice_fetcher(api_key: str) -> RouteFetcher:
|
||||||
|
"""Return a route fetcher backed by openrouteservice."""
|
||||||
|
client = openrouteservice.Client(key=api_key)
|
||||||
|
|
||||||
|
def fetch(start: LonLat, end: LonLat) -> GeoJSON:
|
||||||
|
return typing.cast(
|
||||||
|
GeoJSON,
|
||||||
|
client.directions(
|
||||||
|
[start, end],
|
||||||
|
profile=DEFAULT_PROFILE,
|
||||||
|
format="geojson",
|
||||||
|
instructions=False,
|
||||||
|
radiuses=[DEFAULT_SNAP_RADIUS_METRES, DEFAULT_SNAP_RADIUS_METRES],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fetch
|
||||||
|
|
||||||
|
|
||||||
|
def write_route_files(
|
||||||
|
data_dir: Path,
|
||||||
|
journeys: list[CarJourney],
|
||||||
|
fetch_route: RouteFetcher,
|
||||||
|
overwrite_routes: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Write missing route GeoJSON files and return count written."""
|
||||||
|
route_dir = data_dir / "car_routes"
|
||||||
|
route_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
written = 0
|
||||||
|
|
||||||
|
for journey in journeys:
|
||||||
|
path = car_route_path(data_dir, journey.route)
|
||||||
|
if path.exists() and not overwrite_routes:
|
||||||
|
continue
|
||||||
|
route_geojson = fetch_route(journey.start, journey.end)
|
||||||
|
path.write_text(json.dumps(route_geojson, separators=(",", ":")) + "\n")
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def journey_key(item: StrDict) -> tuple[date, date, date, str]:
|
||||||
|
"""Return a stable key for duplicate detection and sorting."""
|
||||||
|
route = item.get("route")
|
||||||
|
if not isinstance(route, str):
|
||||||
|
raise ValueError(f"car journey route must be a string: {item!r}")
|
||||||
|
return (
|
||||||
|
utils.as_date(item["trip"]),
|
||||||
|
utils.as_date(item["depart"]),
|
||||||
|
utils.as_date(item["arrive"]),
|
||||||
|
trip_module.route_filename_without_extension(route),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_car_journeys(data_dir: Path, journeys: list[CarJourney]) -> int:
|
||||||
|
"""Insert car journeys into car_journeys.yaml and return count added."""
|
||||||
|
path = data_dir / "car_journeys.yaml"
|
||||||
|
existing = load_yaml_list(path)
|
||||||
|
existing_keys = {journey_key(item) for item in existing}
|
||||||
|
new_items = [
|
||||||
|
journey.as_yaml_item()
|
||||||
|
for journey in journeys
|
||||||
|
if journey_key(journey.as_yaml_item()) not in existing_keys
|
||||||
|
]
|
||||||
|
if not new_items:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
combined = existing + new_items
|
||||||
|
combined.sort(key=journey_key)
|
||||||
|
path.write_text(dump_yaml_list_with_blank_lines(combined))
|
||||||
|
return len(new_items)
|
||||||
|
|
||||||
|
|
||||||
|
def add_car_journeys_for_trip(
|
||||||
|
trip_date: date,
|
||||||
|
data_dir: Path = PERSONAL_DATA_DIR,
|
||||||
|
destination: str = "accommodation",
|
||||||
|
home_label: str = DEFAULT_HOME_LABEL,
|
||||||
|
destination_label: str | None = None,
|
||||||
|
fetch_route: RouteFetcher | None = None,
|
||||||
|
overwrite_routes: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> tuple[list[CarJourney], int, int]:
|
||||||
|
"""Add car journeys and routes for a trip."""
|
||||||
|
trip = trip_for_date(data_dir, trip_date)
|
||||||
|
journeys = car_journeys_for_trip(
|
||||||
|
trip,
|
||||||
|
data_dir,
|
||||||
|
destination=destination,
|
||||||
|
home_label=home_label,
|
||||||
|
destination_label=destination_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return (journeys, 0, 0)
|
||||||
|
|
||||||
|
if fetch_route is None:
|
||||||
|
api_key = os.environ.get("ORS_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("ORS_API_KEY must be set")
|
||||||
|
fetch_route = openrouteservice_fetcher(api_key)
|
||||||
|
|
||||||
|
routes_written = write_route_files(
|
||||||
|
data_dir, journeys, fetch_route, overwrite_routes=overwrite_routes
|
||||||
|
)
|
||||||
|
journeys_added = import_car_journeys(data_dir, journeys)
|
||||||
|
return (journeys, routes_written, journeys_added)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: str) -> date:
|
||||||
|
"""Parse an ISO date."""
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""CLI entrypoint."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Add car journeys from home to trip accommodation and back, "
|
||||||
|
"using openrouteservice for GeoJSON routes."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("trip_date", type=parse_date)
|
||||||
|
parser.add_argument(
|
||||||
|
"--destination",
|
||||||
|
choices=("accommodation", "airport", "ferry", "fly-drive"),
|
||||||
|
default="accommodation",
|
||||||
|
help=(
|
||||||
|
"Route to accommodation check-in/out, the trip airport, "
|
||||||
|
"around an outbound/return car ferry, or a fly-drive chain."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--data-dir", default=str(PERSONAL_DATA_DIR))
|
||||||
|
parser.add_argument("--home-label", default=DEFAULT_HOME_LABEL)
|
||||||
|
parser.add_argument("--destination-label")
|
||||||
|
parser.add_argument("--overwrite-routes", action="store_true")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
journeys, routes_written, journeys_added = add_car_journeys_for_trip(
|
||||||
|
args.trip_date,
|
||||||
|
data_dir=Path(args.data_dir).expanduser(),
|
||||||
|
destination=args.destination,
|
||||||
|
home_label=args.home_label,
|
||||||
|
destination_label=args.destination_label,
|
||||||
|
overwrite_routes=args.overwrite_routes,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
for journey in journeys:
|
||||||
|
print(f"{journey.depart}: {journey.route}")
|
||||||
|
if args.dry_run:
|
||||||
|
return 0
|
||||||
|
print(f"Wrote {routes_written} route file(s)")
|
||||||
|
print(f"Added {journeys_added} car journey YAML item(s)")
|
||||||
|
return 0
|
||||||
|
|
@ -37,6 +37,8 @@ class BookingConfig:
|
||||||
booking_type: str
|
booking_type: str
|
||||||
yaml_filename: str
|
yaml_filename: str
|
||||||
spec_heading: str
|
spec_heading: str
|
||||||
|
start_field: str
|
||||||
|
excluded_top_level_keys: tuple[str, ...]
|
||||||
json_key: str = "booking"
|
json_key: str = "booking"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,11 +47,22 @@ BOOKING_CONFIGS: dict[str, BookingConfig] = {
|
||||||
booking_type="flight",
|
booking_type="flight",
|
||||||
yaml_filename="flights.yaml",
|
yaml_filename="flights.yaml",
|
||||||
spec_heading="flights.yaml",
|
spec_heading="flights.yaml",
|
||||||
|
start_field="depart",
|
||||||
|
excluded_top_level_keys=("trip",),
|
||||||
),
|
),
|
||||||
"train": BookingConfig(
|
"train": BookingConfig(
|
||||||
booking_type="train",
|
booking_type="train",
|
||||||
yaml_filename="trains.yaml",
|
yaml_filename="trains.yaml",
|
||||||
spec_heading="trains.yaml",
|
spec_heading="trains.yaml",
|
||||||
|
start_field="depart",
|
||||||
|
excluded_top_level_keys=("trip",),
|
||||||
|
),
|
||||||
|
"accommodation": BookingConfig(
|
||||||
|
booking_type="accommodation",
|
||||||
|
yaml_filename="accommodation.yaml",
|
||||||
|
spec_heading="accommodation.yaml",
|
||||||
|
start_field="from",
|
||||||
|
excluded_top_level_keys=("trip", "latitude", "longitude"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +122,7 @@ def build_prompt(
|
||||||
bookings = current_bookings
|
bookings = current_bookings
|
||||||
if bookings is None:
|
if bookings is None:
|
||||||
bookings = read_existing_bookings(config)
|
bookings = read_existing_bookings(config)
|
||||||
|
excluded_keys = ", ".join(f'"{key}"' for key in config.excluded_top_level_keys)
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
I keep a record of all my {config.booking_type} bookings in a YAML file.
|
I keep a record of all my {config.booking_type} bookings in a YAML file.
|
||||||
|
|
@ -131,7 +145,7 @@ Rules:
|
||||||
- Wrap the response in a JSON object with a single key "{config.json_key}" that
|
- Wrap the response in a JSON object with a single key "{config.json_key}" that
|
||||||
contains the booking in YAML.
|
contains the booking in YAML.
|
||||||
- The value of "{config.json_key}" must be YAML text, not JSON.
|
- The value of "{config.json_key}" must be YAML text, not JSON.
|
||||||
- Exclude the top-level "trip" key from the YAML.
|
- Exclude these top-level keys from the YAML: {excluded_keys}.
|
||||||
- Do not invent details that are not present in the booking text.
|
- Do not invent details that are not present in the booking text.
|
||||||
- Quote prices and identifiers that might otherwise be parsed as numbers.
|
- Quote prices and identifiers that might otherwise be parsed as numbers.
|
||||||
|
|
||||||
|
|
@ -218,7 +232,7 @@ def first_departure(booking: dict[str, typing.Any], config: BookingConfig) -> da
|
||||||
assert isinstance(first_flight, dict)
|
assert isinstance(first_flight, dict)
|
||||||
return datetime_from_yaml_value(first_flight["depart"])
|
return datetime_from_yaml_value(first_flight["depart"])
|
||||||
|
|
||||||
return datetime_from_yaml_value(booking["depart"])
|
return datetime_from_yaml_value(booking[config.start_field])
|
||||||
|
|
||||||
|
|
||||||
def comparable_departure(
|
def comparable_departure(
|
||||||
|
|
@ -246,6 +260,13 @@ def trip_key_position(booking: dict[str, typing.Any], config: BookingConfig) ->
|
||||||
return keys.index("booking_reference") + 1
|
return keys.index("booking_reference") + 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if config.booking_type == "accommodation":
|
||||||
|
if "country" in booking:
|
||||||
|
return keys.index("country") + 1
|
||||||
|
if "location" in booking:
|
||||||
|
return keys.index("location") + 1
|
||||||
|
return 0
|
||||||
|
|
||||||
if "to" in booking:
|
if "to" in booking:
|
||||||
return keys.index("to") + 1
|
return keys.index("to") + 1
|
||||||
if "from" in booking:
|
if "from" in booking:
|
||||||
|
|
@ -455,3 +476,8 @@ def train_main(argv: list[str] | None = None) -> int:
|
||||||
def flight_main(argv: list[str] | None = None) -> int:
|
def flight_main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entrypoint for flight booking YAML generation."""
|
"""CLI entrypoint for flight booking YAML generation."""
|
||||||
return main_for_type("flight", argv)
|
return main_for_type("flight", argv)
|
||||||
|
|
||||||
|
|
||||||
|
def accommodation_main(argv: list[str] | None = None) -> int:
|
||||||
|
"""CLI entrypoint for accommodation booking YAML generation."""
|
||||||
|
return main_for_type("accommodation", argv)
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,19 @@ def car_endpoint_marker_type(item: StrDict, direction: str, label: str) -> str:
|
||||||
return marker_type if isinstance(marker_type, str) else car_endpoint_type(label)
|
return marker_type if isinstance(marker_type, str) else car_endpoint_type(label)
|
||||||
|
|
||||||
|
|
||||||
|
def show_car_endpoint_marker(item: StrDict, direction: str, label: str) -> bool:
|
||||||
|
"""Return whether to show a car endpoint marker on the map."""
|
||||||
|
direction_marker = item.get(direction + "_show_marker")
|
||||||
|
if isinstance(direction_marker, bool):
|
||||||
|
return direction_marker
|
||||||
|
|
||||||
|
show_markers = item.get("show_markers")
|
||||||
|
if isinstance(show_markers, bool):
|
||||||
|
return show_markers
|
||||||
|
|
||||||
|
return car_endpoint_type(label) == "home"
|
||||||
|
|
||||||
|
|
||||||
def load_cars(data_dir: str) -> list[StrDict]:
|
def load_cars(data_dir: str) -> list[StrDict]:
|
||||||
"""Load car journeys."""
|
"""Load car journeys."""
|
||||||
filename = os.path.join(data_dir, "car_journeys.yaml")
|
filename = os.path.join(data_dir, "car_journeys.yaml")
|
||||||
|
|
@ -416,7 +429,7 @@ def load_cars(data_dir: str) -> list[StrDict]:
|
||||||
("from", "from_location", from_label, endpoints[0]),
|
("from", "from_location", from_label, endpoints[0]),
|
||||||
("to", "to_location", to_label, endpoints[1]),
|
("to", "to_location", to_label, endpoints[1]),
|
||||||
):
|
):
|
||||||
if not label:
|
if not label or not show_car_endpoint_marker(item, direction, label):
|
||||||
continue
|
continue
|
||||||
item[field] = {
|
item[field] = {
|
||||||
"name": label,
|
"name": label,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ This document describes the YAML files read from `../personal-data/`. It is inte
|
||||||
- Currencies must be in `config.CURRENCIES` or `GBP`.
|
- Currencies must be in `config.CURRENCIES` or `GBP`.
|
||||||
- Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries.
|
- Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries.
|
||||||
- Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation.
|
- Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation.
|
||||||
|
- Preserve the existing whitespace style. Long top-level list files such as `accommodation.yaml`, `buses.yaml`, `car_journeys.yaml`, `coaches.yaml`, `conferences.yaml`, `ferries.yaml`, `flights.yaml`, `stations.yaml`, `trains.yaml`, and `trips.yaml` use one blank line between top-level items. Mapping files such as `airports.yaml` do not use this list-item spacing.
|
||||||
- Coordinates are `latitude` then `longitude`, both numeric.
|
- Coordinates are `latitude` then `longitude`, both numeric.
|
||||||
|
|
||||||
## Cross-File References
|
## Cross-File References
|
||||||
|
|
@ -267,10 +268,12 @@ Required fields:
|
||||||
Optional fields:
|
Optional fields:
|
||||||
|
|
||||||
- `from`, `to`: endpoint labels. If omitted and the route filename uses `A_to_B`, labels are inferred from the filename.
|
- `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.
|
- `show_markers`: boolean. When true, render both car endpoint markers on maps. Defaults to false because car endpoints often duplicate airport, accommodation, ferry terminal, or conference pins. Home endpoints still render by default.
|
||||||
|
- `from_show_marker`, `to_show_marker`: booleans. Per-endpoint marker overrides.
|
||||||
|
- `from_type`, `to_type`: marker type override used when that endpoint marker is shown. Use `home` to render a house icon. Otherwise car endpoints render as car markers.
|
||||||
- `operator`, `vehicle`, `price`, `currency`, `distance`.
|
- `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.
|
The route distance is calculated from the GeoJSON when `distance` is not present. Car routes render on maps by default. Non-home endpoint pins are opt-in. If an endpoint label is `home`, `PCH`, or `Picture House Court`, the marker is rendered as a house by default.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
|
||||||
15
scripts/add-car-journeys-for-trip
Executable file
15
scripts/add-car-journeys-for-trip
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SCRIPT_PATH = os.path.realpath(__file__)
|
||||||
|
SCRIPT_DIR = os.path.dirname(SCRIPT_PATH)
|
||||||
|
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||||
|
if REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, REPO_ROOT)
|
||||||
|
|
||||||
|
from agenda.car_journey_yaml import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
344
tests/test_car_journey_yaml.py
Normal file
344
tests/test_car_journey_yaml.py
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
"""Tests for car journey YAML generation."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import agenda.car_journey_yaml
|
||||||
|
from agenda.types import Trip
|
||||||
|
|
||||||
|
|
||||||
|
def write_home_route(data_dir: Path) -> None:
|
||||||
|
"""Write a route that identifies PCH as the home coordinate."""
|
||||||
|
route_dir = data_dir / "car_routes"
|
||||||
|
route_dir.mkdir()
|
||||||
|
(route_dir / "PCH_to_EMF.geojson").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {},
|
||||||
|
"geometry": {
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": [[-2.60283, 51.44083], [-2.37789, 52.038]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_car_journeys_for_trip_uses_accommodation_dates_and_locations(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Build outbound and return journeys for the trip accommodation."""
|
||||||
|
write_home_route(tmp_path)
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2025, 4, 22),
|
||||||
|
accommodation=[
|
||||||
|
{
|
||||||
|
"location": "Callestick",
|
||||||
|
"from": datetime(2025, 4, 22, 15, 0),
|
||||||
|
"to": datetime(2025, 4, 25, 10, 0),
|
||||||
|
"latitude": 50.307093,
|
||||||
|
"longitude": -5.13466,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
journeys = agenda.car_journey_yaml.car_journeys_for_trip(
|
||||||
|
trip, tmp_path, destination="accommodation"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [journey.route for journey in journeys] == [
|
||||||
|
"PCH_to_Callestick",
|
||||||
|
"Callestick_to_PCH",
|
||||||
|
]
|
||||||
|
assert [journey.depart for journey in journeys] == [
|
||||||
|
date(2025, 4, 22),
|
||||||
|
date(2025, 4, 25),
|
||||||
|
]
|
||||||
|
assert journeys[0].start == (-2.60283, 51.44083)
|
||||||
|
assert journeys[0].end == (-5.13466, 50.307093)
|
||||||
|
assert journeys[1].start == (-5.13466, 50.307093)
|
||||||
|
assert journeys[1].end == (-2.60283, 51.44083)
|
||||||
|
|
||||||
|
|
||||||
|
def test_car_journeys_for_trip_can_route_to_airport(tmp_path: Path) -> None:
|
||||||
|
"""Build outbound and return journeys for airport access."""
|
||||||
|
write_home_route(tmp_path)
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2025, 11, 17),
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "flight",
|
||||||
|
"depart": datetime(2025, 11, 17, 17, 55),
|
||||||
|
"arrive": datetime(2025, 11, 18, 14, 50),
|
||||||
|
"from_airport": {
|
||||||
|
"iata": "LHR",
|
||||||
|
"latitude": 51.4775,
|
||||||
|
"longitude": -0.461389,
|
||||||
|
},
|
||||||
|
"to_airport": {
|
||||||
|
"iata": "HKG",
|
||||||
|
"latitude": 22.308889,
|
||||||
|
"longitude": 113.914444,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "flight",
|
||||||
|
"depart": datetime(2025, 11, 30, 22, 45),
|
||||||
|
"arrive": datetime(2025, 12, 1, 5, 35),
|
||||||
|
"from_airport": {
|
||||||
|
"iata": "HKG",
|
||||||
|
"latitude": 22.308889,
|
||||||
|
"longitude": 113.914444,
|
||||||
|
},
|
||||||
|
"to_airport": {
|
||||||
|
"iata": "LHR",
|
||||||
|
"latitude": 51.4775,
|
||||||
|
"longitude": -0.461389,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
journeys = agenda.car_journey_yaml.car_journeys_for_trip(
|
||||||
|
trip, tmp_path, destination="airport"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [journey.route for journey in journeys] == ["PCH_to_LHR", "LHR_to_PCH"]
|
||||||
|
assert [journey.depart for journey in journeys] == [
|
||||||
|
date(2025, 11, 17),
|
||||||
|
date(2025, 12, 1),
|
||||||
|
]
|
||||||
|
assert journeys[0].start == (-2.60283, 51.44083)
|
||||||
|
assert journeys[0].end == (-0.461389, 51.4775)
|
||||||
|
assert journeys[1].start == (-0.461389, 51.4775)
|
||||||
|
assert journeys[1].end == (-2.60283, 51.44083)
|
||||||
|
|
||||||
|
|
||||||
|
def test_car_journeys_for_trip_can_route_around_ferry(tmp_path: Path) -> None:
|
||||||
|
"""Build home, ferry terminal and accommodation driving legs."""
|
||||||
|
write_home_route(tmp_path)
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2025, 7, 4),
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "ferry",
|
||||||
|
"depart": datetime(2025, 7, 4, 22, 0),
|
||||||
|
"arrive": datetime(2025, 7, 5, 8, 0),
|
||||||
|
"from": "Plymouth",
|
||||||
|
"to": "Roscoff",
|
||||||
|
"from_terminal": {
|
||||||
|
"name": "Plymouth",
|
||||||
|
"latitude": 50.3651254,
|
||||||
|
"longitude": -4.1578164,
|
||||||
|
},
|
||||||
|
"to_terminal": {
|
||||||
|
"name": "Roscoff",
|
||||||
|
"latitude": 48.721672,
|
||||||
|
"longitude": -3.966925,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ferry",
|
||||||
|
"depart": datetime(2025, 7, 21, 15, 0),
|
||||||
|
"arrive": datetime(2025, 7, 21, 20, 10),
|
||||||
|
"from": "Roscoff",
|
||||||
|
"to": "Plymouth",
|
||||||
|
"from_terminal": {
|
||||||
|
"name": "Roscoff",
|
||||||
|
"latitude": 48.721672,
|
||||||
|
"longitude": -3.966925,
|
||||||
|
},
|
||||||
|
"to_terminal": {
|
||||||
|
"name": "Plymouth",
|
||||||
|
"latitude": 50.3651254,
|
||||||
|
"longitude": -4.1578164,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accommodation=[
|
||||||
|
{
|
||||||
|
"location": "Brest",
|
||||||
|
"from": datetime(2025, 7, 5, 16, 0),
|
||||||
|
"to": datetime(2025, 7, 21, 10, 0),
|
||||||
|
"latitude": 48.3645307,
|
||||||
|
"longitude": -4.5534685,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
journeys = agenda.car_journey_yaml.car_journeys_for_trip(
|
||||||
|
trip, tmp_path, destination="ferry"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [journey.route for journey in journeys] == [
|
||||||
|
"PCH_to_Plymouth",
|
||||||
|
"Roscoff_to_Brest",
|
||||||
|
"Brest_to_Roscoff",
|
||||||
|
"Plymouth_to_PCH",
|
||||||
|
]
|
||||||
|
assert [journey.depart for journey in journeys] == [
|
||||||
|
date(2025, 7, 4),
|
||||||
|
date(2025, 7, 5),
|
||||||
|
date(2025, 7, 21),
|
||||||
|
date(2025, 7, 21),
|
||||||
|
]
|
||||||
|
assert journeys[0].start == (-2.60283, 51.44083)
|
||||||
|
assert journeys[0].end == (-4.1578164, 50.3651254)
|
||||||
|
assert journeys[1].start == (-3.966925, 48.721672)
|
||||||
|
assert journeys[1].end == (-4.5534685, 48.3645307)
|
||||||
|
assert journeys[2].start == (-4.5534685, 48.3645307)
|
||||||
|
assert journeys[2].end == (-3.966925, 48.721672)
|
||||||
|
assert journeys[3].start == (-4.1578164, 50.3651254)
|
||||||
|
assert journeys[3].end == (-2.60283, 51.44083)
|
||||||
|
|
||||||
|
|
||||||
|
def test_car_journeys_for_trip_can_route_fly_drive_chain(tmp_path: Path) -> None:
|
||||||
|
"""Build home, destination rental and return airport driving legs."""
|
||||||
|
write_home_route(tmp_path)
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2025, 9, 13),
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "flight",
|
||||||
|
"depart": datetime(2025, 9, 14, 6, 50),
|
||||||
|
"arrive": datetime(2025, 9, 14, 8, 55),
|
||||||
|
"from_airport": {
|
||||||
|
"iata": "LTN",
|
||||||
|
"latitude": 51.8747,
|
||||||
|
"longitude": -0.3683,
|
||||||
|
"country": "gb",
|
||||||
|
},
|
||||||
|
"to_airport": {
|
||||||
|
"iata": "KEF",
|
||||||
|
"latitude": 63.985,
|
||||||
|
"longitude": -22.605556,
|
||||||
|
"country": "is",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "flight",
|
||||||
|
"depart": datetime(2025, 10, 1, 9, 0),
|
||||||
|
"arrive": datetime(2025, 10, 1, 13, 0),
|
||||||
|
"from_airport": {
|
||||||
|
"iata": "KEF",
|
||||||
|
"latitude": 63.985,
|
||||||
|
"longitude": -22.605556,
|
||||||
|
"country": "is",
|
||||||
|
},
|
||||||
|
"to_airport": {
|
||||||
|
"iata": "LTN",
|
||||||
|
"latitude": 51.8747,
|
||||||
|
"longitude": -0.3683,
|
||||||
|
"country": "gb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accommodation=[
|
||||||
|
{
|
||||||
|
"location": "Luton Airport",
|
||||||
|
"country": "gb",
|
||||||
|
"from": datetime(2025, 9, 13, 15, 0),
|
||||||
|
"to": datetime(2025, 9, 14, 12, 0),
|
||||||
|
"latitude": 51.8745678,
|
||||||
|
"longitude": -0.3844256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "Akranes",
|
||||||
|
"country": "is",
|
||||||
|
"from": datetime(2025, 9, 14, 16, 0),
|
||||||
|
"to": datetime(2025, 9, 16, 11, 0),
|
||||||
|
"latitude": 64.4344723,
|
||||||
|
"longitude": -21.5517961,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "Ísafjörður",
|
||||||
|
"country": "is",
|
||||||
|
"from": datetime(2025, 9, 16, 15, 0),
|
||||||
|
"to": datetime(2025, 10, 1, 11, 0),
|
||||||
|
"latitude": 66.072445,
|
||||||
|
"longitude": -23.120111,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
journeys = agenda.car_journey_yaml.car_journeys_for_trip(
|
||||||
|
trip, tmp_path, destination="fly-drive"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [journey.route for journey in journeys] == [
|
||||||
|
"PCH_to_Luton_Airport",
|
||||||
|
"KEF_to_Akranes",
|
||||||
|
"Akranes_to_Isafjordur",
|
||||||
|
"Isafjordur_to_KEF",
|
||||||
|
"LTN_to_PCH",
|
||||||
|
]
|
||||||
|
assert [journey.depart for journey in journeys] == [
|
||||||
|
date(2025, 9, 13),
|
||||||
|
date(2025, 9, 14),
|
||||||
|
date(2025, 9, 16),
|
||||||
|
date(2025, 10, 1),
|
||||||
|
date(2025, 10, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_route_files_skips_existing_routes(tmp_path: Path) -> None:
|
||||||
|
"""Existing route files should not call the route API."""
|
||||||
|
write_home_route(tmp_path)
|
||||||
|
journey = agenda.car_journey_yaml.CarJourney(
|
||||||
|
trip=date(2025, 4, 22),
|
||||||
|
depart=date(2025, 4, 22),
|
||||||
|
arrive=date(2025, 4, 22),
|
||||||
|
route="PCH_to_EMF",
|
||||||
|
start=(-2.60283, 51.44083),
|
||||||
|
end=(-2.37789, 52.038),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_fetch(
|
||||||
|
_start: agenda.car_journey_yaml.LonLat,
|
||||||
|
_end: agenda.car_journey_yaml.LonLat,
|
||||||
|
) -> agenda.car_journey_yaml.GeoJSON:
|
||||||
|
raise AssertionError("route API should not be called")
|
||||||
|
|
||||||
|
written = agenda.car_journey_yaml.write_route_files(tmp_path, [journey], fail_fetch)
|
||||||
|
|
||||||
|
assert written == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_car_journeys_inserts_chronologically_and_is_idempotent(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Generated journeys should be inserted without duplicates."""
|
||||||
|
(tmp_path / "car_journeys.yaml").write_text("""---
|
||||||
|
- trip: 2025-05-01
|
||||||
|
depart: 2025-05-01
|
||||||
|
arrive: 2025-05-01
|
||||||
|
route: PCH_to_Later
|
||||||
|
""")
|
||||||
|
journeys = [
|
||||||
|
agenda.car_journey_yaml.CarJourney(
|
||||||
|
trip=date(2025, 4, 22),
|
||||||
|
depart=date(2025, 4, 22),
|
||||||
|
arrive=date(2025, 4, 22),
|
||||||
|
route="PCH_to_Callestick",
|
||||||
|
start=(-2.60283, 51.44083),
|
||||||
|
end=(-5.13466, 50.307093),
|
||||||
|
),
|
||||||
|
agenda.car_journey_yaml.CarJourney(
|
||||||
|
trip=date(2025, 4, 22),
|
||||||
|
depart=date(2025, 4, 25),
|
||||||
|
arrive=date(2025, 4, 25),
|
||||||
|
route="Callestick_to_PCH",
|
||||||
|
start=(-5.13466, 50.307093),
|
||||||
|
end=(-2.60283, 51.44083),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
added = agenda.car_journey_yaml.import_car_journeys(tmp_path, journeys)
|
||||||
|
added_again = agenda.car_journey_yaml.import_car_journeys(tmp_path, journeys)
|
||||||
|
|
||||||
|
assert added == 2
|
||||||
|
assert added_again == 0
|
||||||
|
text = (tmp_path / "car_journeys.yaml").read_text()
|
||||||
|
assert text.index("PCH_to_Callestick") < text.index("PCH_to_Later")
|
||||||
|
|
@ -40,10 +40,25 @@ def test_build_prompt_uses_spec_and_keeps_trip_exclusion() -> None:
|
||||||
|
|
||||||
assert "Use this YAML format specification" in prompt
|
assert "Use this YAML format specification" in prompt
|
||||||
assert "## `trains.yaml`" in prompt
|
assert "## `trains.yaml`" in prompt
|
||||||
assert 'Exclude the top-level "trip" key' in prompt
|
assert 'Exclude these top-level keys from the YAML: "trip"' in prompt
|
||||||
assert "Eurostar booking details" in prompt
|
assert "Eurostar booking details" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_for_accommodation_excludes_coordinates() -> None:
|
||||||
|
"""Accommodation prompt should exclude trip and coordinate fields."""
|
||||||
|
prompt = generate_booking_yaml.build_prompt(
|
||||||
|
"Airbnb booking details",
|
||||||
|
generate_booking_yaml.BOOKING_CONFIGS["accommodation"],
|
||||||
|
current_bookings="- type: airbnb\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## `accommodation.yaml`" in prompt
|
||||||
|
assert (
|
||||||
|
'Exclude these top-level keys from the YAML: "trip", "latitude", "longitude"'
|
||||||
|
in prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""URL arguments should be fetched instead of reading stdin."""
|
"""URL arguments should be fetched instead of reading stdin."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -185,3 +200,58 @@ flights:
|
||||||
assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"]
|
assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"]
|
||||||
assert written[1]["trip"] == date(2026, 2, 6)
|
assert written[1]["trip"] == date(2026, 2, 6)
|
||||||
assert list(written[1]).index("trip") == 1
|
assert list(written[1]).index("trip") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_accommodation_booking_adds_trip_and_inserts_chronologically(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Generated accommodation should be written into accommodation.yaml in order."""
|
||||||
|
(tmp_path / "accommodation.yaml").write_text("""---
|
||||||
|
- type: hotel
|
||||||
|
name: Earlier Hotel
|
||||||
|
location: Brussels
|
||||||
|
country: be
|
||||||
|
trip: 2026-02-01
|
||||||
|
from: 2026-02-01 15:00:00+01:00
|
||||||
|
to: 2026-02-02 11:00:00+01:00
|
||||||
|
|
||||||
|
- type: hotel
|
||||||
|
name: Later Hotel
|
||||||
|
location: Paris
|
||||||
|
country: fr
|
||||||
|
trip: 2026-02-10
|
||||||
|
from: 2026-02-10 15:00:00+01:00
|
||||||
|
to: 2026-02-11 11:00:00+01:00
|
||||||
|
""")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
generate_booking_yaml,
|
||||||
|
"matching_trip_date",
|
||||||
|
lambda depart, data_dir: date(2026, 2, 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
count = generate_booking_yaml.import_booking_yaml(
|
||||||
|
"""type: airbnb
|
||||||
|
name: Paris Airbnb
|
||||||
|
location: Paris
|
||||||
|
country: fr
|
||||||
|
from: 2026-02-06 15:00:00+01:00
|
||||||
|
to: 2026-02-09 11:00:00+01:00
|
||||||
|
price: '300.00'
|
||||||
|
currency: EUR
|
||||||
|
""",
|
||||||
|
generate_booking_yaml.BOOKING_CONFIGS["accommodation"],
|
||||||
|
data_dir=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (tmp_path / "accommodation.yaml").read_text()
|
||||||
|
written = yaml.safe_load(text)
|
||||||
|
assert count == 1
|
||||||
|
assert [item["name"] for item in written] == [
|
||||||
|
"Earlier Hotel",
|
||||||
|
"Paris Airbnb",
|
||||||
|
"Later Hotel",
|
||||||
|
]
|
||||||
|
assert written[1]["trip"] == date(2026, 2, 6)
|
||||||
|
assert list(written[1]).index("trip") == 4
|
||||||
|
assert "\n\n- type: airbnb\n" in text
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,10 @@ def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_load_cars_infers_route_labels_and_home_marker(tmp_path: pathlib.Path) -> None:
|
def test_load_cars_infers_route_labels_and_home_marker(
|
||||||
"""Car journeys should load direct GeoJSON routes and endpoint markers."""
|
tmp_path: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
"""Car journeys should load direct GeoJSON routes and home endpoint markers."""
|
||||||
(tmp_path / "car_routes").mkdir()
|
(tmp_path / "car_routes").mkdir()
|
||||||
(tmp_path / "car_journeys.yaml").write_text("""---
|
(tmp_path / "car_journeys.yaml").write_text("""---
|
||||||
- trip: 2026-07-16
|
- trip: 2026-07-16
|
||||||
|
|
@ -201,6 +203,40 @@ def test_load_cars_infers_route_labels_and_home_marker(tmp_path: pathlib.Path) -
|
||||||
assert cars[0]["to"] == "EMF"
|
assert cars[0]["to"] == "EMF"
|
||||||
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
|
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
|
||||||
assert cars[0]["distance"] > 0
|
assert cars[0]["distance"] > 0
|
||||||
|
assert cars[0]["from_location"] == {
|
||||||
|
"name": "PCH",
|
||||||
|
"type": "home",
|
||||||
|
"latitude": 51.44083,
|
||||||
|
"longitude": -2.60283,
|
||||||
|
}
|
||||||
|
assert "to_location" not in cars[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_cars_can_show_endpoint_markers(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Car endpoint markers should be opt-in."""
|
||||||
|
(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
|
||||||
|
show_markers: true
|
||||||
|
""")
|
||||||
|
(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 cars[0]["from_location"] == {
|
assert cars[0]["from_location"] == {
|
||||||
"name": "PCH",
|
"name": "PCH",
|
||||||
"type": "home",
|
"type": "home",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue