435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""Trips."""
|
|
|
|
import decimal
|
|
import os
|
|
import typing
|
|
from collections import defaultdict
|
|
from datetime import date, datetime, time
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import flask
|
|
import yaml
|
|
|
|
from agenda import travel
|
|
from agenda.types import StrDict, Trip
|
|
|
|
|
|
class UnknownStation(Exception):
|
|
"""Unknown station."""
|
|
|
|
pass
|
|
|
|
|
|
def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]:
|
|
"""Read flight and train journeys."""
|
|
items = travel.parse_yaml(plural, data_dir)
|
|
for item in items:
|
|
item["type"] = travel_type
|
|
return items
|
|
|
|
|
|
def process_train_leg(
|
|
leg: StrDict,
|
|
by_name: StrDict,
|
|
route_distances: travel.RouteDistances | None,
|
|
) -> None:
|
|
"""Process train leg."""
|
|
assert leg["from"] in by_name and leg["to"] in by_name
|
|
leg["from_station"], leg["to_station"] = by_name[leg["from"]], by_name[leg["to"]]
|
|
|
|
if route_distances:
|
|
travel.add_leg_route_distance(leg, route_distances)
|
|
|
|
|
|
def load_trains(
|
|
data_dir: str, route_distances: travel.RouteDistances | None = None
|
|
) -> list[StrDict]:
|
|
"""Load trains."""
|
|
trains = load_travel("train", "trains", data_dir)
|
|
stations = travel.parse_yaml("stations", data_dir)
|
|
by_name = {station["name"]: station for station in stations}
|
|
|
|
for train in trains:
|
|
if train["from"] not in by_name:
|
|
raise UnknownStation(train["from"])
|
|
if train["to"] not in by_name:
|
|
raise UnknownStation(train["to"])
|
|
train["from_station"] = by_name[train["from"]]
|
|
train["to_station"] = by_name[train["to"]]
|
|
|
|
for leg in train["legs"]:
|
|
process_train_leg(leg, by_name=by_name, route_distances=route_distances)
|
|
|
|
if all("distance" in leg for leg in train["legs"]):
|
|
train["distance"] = sum(leg["distance"] for leg in train["legs"])
|
|
|
|
return trains
|
|
|
|
|
|
def load_ferries(
|
|
data_dir: str, route_distances: travel.RouteDistances | None = None
|
|
) -> list[StrDict]:
|
|
"""Load ferries."""
|
|
ferries = load_travel("ferry", "ferries", data_dir)
|
|
terminals = travel.parse_yaml("ferry_terminals", data_dir)
|
|
by_name = {terminal["name"]: terminal for terminal in terminals}
|
|
|
|
for item in ferries:
|
|
assert item["from"] in by_name and item["to"] in by_name
|
|
from_terminal, to_terminal = by_name[item["from"]], by_name[item["to"]]
|
|
item["from_terminal"] = from_terminal
|
|
item["to_terminal"] = to_terminal
|
|
|
|
if route_distances:
|
|
travel.add_leg_route_distance(item, route_distances)
|
|
|
|
geojson = from_terminal["routes"].get(item["to"])
|
|
if geojson:
|
|
item["geojson_filename"] = geojson
|
|
|
|
return ferries
|
|
|
|
|
|
def depart_datetime(item: StrDict) -> datetime:
|
|
depart = item["depart"]
|
|
if isinstance(depart, datetime):
|
|
return depart
|
|
return datetime.combine(depart, time.min).replace(tzinfo=ZoneInfo("UTC"))
|
|
|
|
|
|
def process_flight(
|
|
flight: StrDict, iata: dict[str, str], airports: list[StrDict]
|
|
) -> None:
|
|
"""Add airport detail, airline name and distance to flight."""
|
|
if flight["from"] in airports:
|
|
flight["from_airport"] = airports[flight["from"]]
|
|
if flight["to"] in airports:
|
|
flight["to_airport"] = airports[flight["to"]]
|
|
if "airline" in flight:
|
|
flight["airline_name"] = iata.get(flight["airline"], "[unknown]")
|
|
|
|
flight["distance"] = travel.flight_distance(flight)
|
|
|
|
|
|
def load_flight_bookings(data_dir: str) -> list[StrDict]:
|
|
"""Load flight bookings."""
|
|
bookings = load_travel("flight", "flights", data_dir)
|
|
airlines = yaml.safe_load(open(os.path.join(data_dir, "airlines.yaml")))
|
|
iata = {a["iata"]: a["name"] for a in airlines}
|
|
airports = travel.parse_yaml("airports", data_dir)
|
|
for booking in bookings:
|
|
for flight in booking["flights"]:
|
|
process_flight(flight, iata, airports)
|
|
return bookings
|
|
|
|
|
|
def load_flights(flight_bookings: list[StrDict]) -> list[StrDict]:
|
|
"""Load flights."""
|
|
flights = []
|
|
for booking in flight_bookings:
|
|
for flight in booking["flights"]:
|
|
for f in "type", "trip", "booking_reference", "price", "currency":
|
|
if f in booking:
|
|
flight[f] = booking[f]
|
|
flights.append(flight)
|
|
return flights
|
|
|
|
|
|
def build_trip_list(
|
|
data_dir: str | None = None,
|
|
route_distances: travel.RouteDistances | None = None,
|
|
) -> list[Trip]:
|
|
"""Generate list of trips."""
|
|
trips: dict[date, Trip] = {}
|
|
|
|
if data_dir is None:
|
|
data_dir = flask.current_app.config["PERSONAL_DATA"]
|
|
|
|
yaml_trip_list = travel.parse_yaml("trips", data_dir)
|
|
|
|
yaml_trip_lookup = {item["trip"]: item for item in yaml_trip_list}
|
|
|
|
flight_bookings = load_flight_bookings(data_dir)
|
|
|
|
travel_items = sorted(
|
|
load_flights(flight_bookings)
|
|
+ load_trains(data_dir, route_distances=route_distances)
|
|
+ load_ferries(data_dir, route_distances=route_distances),
|
|
key=depart_datetime,
|
|
)
|
|
|
|
data = {
|
|
"flight_bookings": flight_bookings,
|
|
"travel": travel_items,
|
|
"accommodation": travel.parse_yaml("accommodation", data_dir),
|
|
"conferences": travel.parse_yaml("conferences", data_dir),
|
|
"events": travel.parse_yaml("events", data_dir),
|
|
}
|
|
|
|
for item in data["accommodation"]:
|
|
price = item.get("price")
|
|
if price:
|
|
item["price"] = decimal.Decimal(price)
|
|
|
|
for key, item_list in data.items():
|
|
assert isinstance(item_list, list)
|
|
for item in item_list:
|
|
if not (start := item.get("trip")):
|
|
continue
|
|
if start not in trips:
|
|
from_yaml = yaml_trip_lookup.get(start, {})
|
|
trips[start] = Trip(
|
|
start=start, **{k: v for k, v in from_yaml.items() if k != "trip"}
|
|
)
|
|
getattr(trips[start], key).append(item)
|
|
|
|
return [trip for _, trip in sorted(trips.items())]
|
|
|
|
|
|
def add_coordinates_for_unbooked_flights(
|
|
routes: list[StrDict], coordinates: list[StrDict]
|
|
) -> None:
|
|
"""Add coordinates for flights that haven't been booked yet."""
|
|
if not (
|
|
any(route["type"] == "unbooked_flight" for route in routes)
|
|
and not any(pin["type"] == "airport" for pin in coordinates)
|
|
):
|
|
return
|
|
|
|
data_dir = flask.current_app.config["PERSONAL_DATA"]
|
|
airports = typing.cast(dict[str, StrDict], travel.parse_yaml("airports", data_dir))
|
|
lhr = airports["LHR"]
|
|
coordinates.append(
|
|
{
|
|
"name": lhr["name"],
|
|
"type": "airport",
|
|
"latitude": lhr["latitude"],
|
|
"longitude": lhr["longitude"],
|
|
}
|
|
)
|
|
|
|
|
|
def collect_trip_coordinates(trip: Trip) -> list[StrDict]:
|
|
"""Extract and deduplicate airport and station coordinates from trip."""
|
|
stations = {}
|
|
station_list = []
|
|
airports = {}
|
|
ferry_terminals = {}
|
|
for t in trip.travel:
|
|
if t["type"] == "train":
|
|
station_list += [t["from_station"], t["to_station"]]
|
|
for leg in t["legs"]:
|
|
station_list.append(leg["from_station"])
|
|
station_list.append(leg["to_station"])
|
|
elif t["type"] == "flight":
|
|
for field in "from_airport", "to_airport":
|
|
if field in t:
|
|
airports[t[field]["iata"]] = t[field]
|
|
else:
|
|
assert t["type"] == "ferry"
|
|
for field in "from_terminal", "to_terminal":
|
|
terminal = t[field]
|
|
ferry_terminals[terminal["name"]] = terminal
|
|
|
|
for s in station_list:
|
|
if s["uic"] in stations:
|
|
continue
|
|
stations[s["uic"]] = s
|
|
|
|
coords = []
|
|
|
|
src = [
|
|
("accommodation", trip.accommodation),
|
|
("conference", trip.conferences),
|
|
("event", trip.events),
|
|
]
|
|
for coord_type, item_list in src:
|
|
coords += [
|
|
{
|
|
"name": item["name"],
|
|
"type": coord_type,
|
|
"latitude": item["latitude"],
|
|
"longitude": item["longitude"],
|
|
}
|
|
for item in item_list
|
|
if "latitude" in item and "longitude" in item
|
|
]
|
|
|
|
locations = [
|
|
("station", stations),
|
|
("airport", airports),
|
|
("ferry_terminal", ferry_terminals),
|
|
]
|
|
for coord_type, coord_dict in locations:
|
|
coords += [
|
|
{
|
|
"name": s["name"],
|
|
"type": coord_type,
|
|
"latitude": s["latitude"],
|
|
"longitude": s["longitude"],
|
|
}
|
|
for s in coord_dict.values()
|
|
]
|
|
|
|
return coords
|
|
|
|
|
|
def latlon_tuple(stop: StrDict) -> tuple[float, float]:
|
|
"""Given a transport stop return the lat/lon as a tuple."""
|
|
return (stop["latitude"], stop["longitude"])
|
|
|
|
|
|
def read_geojson(data_dir: str, filename: str) -> str:
|
|
"""Read GeoJSON from file."""
|
|
return open(os.path.join(data_dir, filename + ".geojson")).read()
|
|
|
|
|
|
def get_trip_routes(trip: Trip) -> list[StrDict]:
|
|
"""Get routes for given trip to show on map."""
|
|
routes: list[StrDict] = []
|
|
seen_geojson = set()
|
|
for t in trip.travel:
|
|
if t["type"] == "ferry":
|
|
ferry_from, ferry_to = t["from_terminal"], t["to_terminal"]
|
|
|
|
key = "_".join(["ferry"] + sorted([ferry_from["name"], ferry_to["name"]]))
|
|
filename = os.path.join("ferry_routes", t["geojson_filename"])
|
|
|
|
routes.append(
|
|
{
|
|
"type": "train",
|
|
"key": key,
|
|
"geojson_filename": filename,
|
|
}
|
|
)
|
|
continue
|
|
if t["type"] == "flight":
|
|
if "from_airport" not in t or "to_airport" not in t:
|
|
continue
|
|
fly_from, fly_to = t["from_airport"], t["to_airport"]
|
|
key = "_".join(["flight"] + sorted([fly_from["iata"], fly_to["iata"]]))
|
|
routes.append(
|
|
{
|
|
"type": "flight",
|
|
"key": key,
|
|
"from": latlon_tuple(fly_from),
|
|
"to": latlon_tuple(fly_to),
|
|
}
|
|
)
|
|
continue
|
|
assert t["type"] == "train"
|
|
for leg in t["legs"]:
|
|
train_from, train_to = leg["from_station"], leg["to_station"]
|
|
geojson_filename = train_from.get("routes", {}).get(train_to["uic"])
|
|
key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]]))
|
|
if not geojson_filename:
|
|
routes.append(
|
|
{
|
|
"type": "train",
|
|
"key": key,
|
|
"from": latlon_tuple(train_from),
|
|
"to": latlon_tuple(train_to),
|
|
}
|
|
)
|
|
continue
|
|
|
|
if geojson_filename in seen_geojson:
|
|
continue
|
|
seen_geojson.add(geojson_filename)
|
|
|
|
routes.append(
|
|
{
|
|
"type": "train",
|
|
"key": key,
|
|
"geojson_filename": os.path.join("train_routes", geojson_filename),
|
|
}
|
|
)
|
|
|
|
if routes:
|
|
return routes
|
|
|
|
lhr = (51.4775, -0.461389)
|
|
|
|
return [
|
|
{
|
|
"type": "unbooked_flight",
|
|
"key": f'LHR_{item["location"]}_{item["country"]}',
|
|
"from": lhr,
|
|
"to": latlon_tuple(item),
|
|
}
|
|
for item in trip.conferences
|
|
if "latitude" in item
|
|
and "longitude" in item
|
|
and item["country"] not in ("gb", "be") # not flying to Belgium
|
|
]
|
|
|
|
|
|
def get_coordinates_and_routes(
|
|
trip_list: list[Trip], data_dir: str | None = None
|
|
) -> tuple[list[StrDict], list[StrDict]]:
|
|
if data_dir is None:
|
|
data_dir = flask.current_app.config["PERSONAL_DATA"]
|
|
coordinates = []
|
|
seen_coordinates: set[tuple[str, str]] = set()
|
|
routes = []
|
|
seen_routes: set[str] = set()
|
|
for trip in trip_list:
|
|
for stop in collect_trip_coordinates(trip):
|
|
key = (stop["type"], stop["name"])
|
|
if key in seen_coordinates:
|
|
continue
|
|
coordinates.append(stop)
|
|
seen_coordinates.add(key)
|
|
|
|
for route in get_trip_routes(trip):
|
|
if route["key"] in seen_routes:
|
|
continue
|
|
routes.append(route)
|
|
seen_routes.add(route["key"])
|
|
|
|
for route in routes:
|
|
if "geojson_filename" in route:
|
|
route["geojson"] = read_geojson(data_dir, route.pop("geojson_filename"))
|
|
|
|
return (coordinates, routes)
|
|
|
|
|
|
def calculate_yearly_stats(trips: list[Trip]) -> dict[int, StrDict]:
|
|
"""Calculate total distance and distance by transport type grouped by year."""
|
|
yearly_stats: defaultdict[int, StrDict] = defaultdict(dict)
|
|
for trip in trips:
|
|
year = trip.start.year
|
|
dist = trip.total_distance()
|
|
yearly_stats[year].setdefault("count", 0)
|
|
yearly_stats[year]["count"] += 1
|
|
|
|
for c in trip.conferences:
|
|
yearly_stats[c["start"].year].setdefault("conferences", 0)
|
|
yearly_stats[c["start"].year]["conferences"] += 1
|
|
|
|
if dist:
|
|
yearly_stats[year]["total_distance"] = (
|
|
yearly_stats[year].get("total_distance", 0) + trip.total_distance()
|
|
)
|
|
|
|
for transport_type, distance in trip.distances_by_transport_type():
|
|
yearly_stats[year].setdefault("distances_by_transport_type", {})
|
|
yearly_stats[year]["distances_by_transport_type"][transport_type] = (
|
|
yearly_stats[year]["distances_by_transport_type"].get(transport_type, 0)
|
|
+ distance
|
|
)
|
|
|
|
for country in trip.countries:
|
|
if country.alpha_2 == "GB":
|
|
continue
|
|
yearly_stats[year].setdefault("countries", set())
|
|
yearly_stats[year]["countries"].add(country)
|
|
for leg in trip.travel:
|
|
if leg["type"] == "flight":
|
|
yearly_stats[year].setdefault("flight_count", 0)
|
|
yearly_stats[year]["flight_count"] += 1
|
|
if leg["type"] == "train":
|
|
yearly_stats[year].setdefault("train_count", 0)
|
|
yearly_stats[year]["train_count"] += 1
|
|
return dict(yearly_stats)
|