Add timeline car import and CO2 trip stats

This commit is contained in:
Edward Betts 2026-08-03 13:52:45 +01:00
parent 38dbb2b4e7
commit 3747e83ade
14 changed files with 1038 additions and 29 deletions

View file

@ -3,6 +3,7 @@
"""Web page to show upcoming events."""
import decimal
import functools
import hashlib
import importlib
import inspect
@ -841,6 +842,16 @@ def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float]
return list(distances_by_transport_type.items())
def sum_co2_by_transport_type(trips: list[Trip]) -> list[tuple[str, float]]:
"""Sum CO₂ by transport type."""
co2_by_transport_type: defaultdict[str, float] = defaultdict(float)
for trip in trips:
for transport_type, co2_kg in trip.co2_by_transport_type():
co2_by_transport_type[transport_type] += co2_kg
return list(co2_by_transport_type.items())
def get_home_weather() -> list[StrDict]:
"""Get Bristol home weather forecast, with date objects added for templates."""
from datetime import date as date_type
@ -911,6 +922,7 @@ def trip_past_list() -> str:
total_distance=calc_total_distance(past),
total_co2_kg=calc_total_co2_kg(past),
distances_by_transport_type=sum_distances_by_transport_type(past),
co2_by_transport_type=sum_co2_by_transport_type(past),
)
@ -973,6 +985,7 @@ def trip_future_list() -> str:
total_distance=calc_total_distance(current + future),
total_co2_kg=calc_total_co2_kg(current + future),
distances_by_transport_type=sum_distances_by_transport_type(current + future),
co2_by_transport_type=sum_co2_by_transport_type(current + future),
)
@ -1084,6 +1097,7 @@ def _timezone_from_coordinates(latitude: float, longitude: float) -> str | None:
return None
@functools.lru_cache(maxsize=1)
def _get_timezone_finder() -> typing.Any:
"""Get timezone finder instance if dependency is available."""
try:
@ -1098,6 +1112,94 @@ def _get_timezone_finder() -> typing.Any:
return timezone_finder_cls()
def _coordinates_from_location(location: typing.Any) -> tuple[float, float] | None:
"""Extract latitude/longitude from a location mapping."""
if not isinstance(location, dict):
return None
latitude = location.get("latitude")
longitude = location.get("longitude")
if not isinstance(latitude, (int, float)) or not isinstance(
longitude, (int, float)
):
return None
return (float(latitude), float(longitude))
def _route_endpoints_for_car_item(
item: StrDict,
data_dir: str,
route_cache: dict[str, tuple[tuple[float, float], tuple[float, float]] | None],
) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""Return route endpoints for a car journey when available."""
route_filename = item.get("geojson_filename")
if not isinstance(route_filename, str):
return None
if route_filename not in route_cache:
geojson_text = agenda.trip.read_geojson(
data_dir, os.path.join("car_routes", route_filename)
)
endpoints = agenda.trip.geojson_route_endpoints(json.loads(geojson_text))
route_cache[route_filename] = endpoints
return route_cache[route_filename]
def _timezone_name_for_car_item(
item: StrDict,
data_dir: str,
route_cache: dict[str, tuple[tuple[float, float], tuple[float, float]] | None],
timezone_cache: dict[tuple[float, float], str | None],
) -> str | None:
"""Resolve the local timezone for a car journey from endpoints."""
candidate_coords: list[tuple[float, float]] = []
for field in ("from_location", "to_location"):
coord = _coordinates_from_location(item.get(field))
if coord is not None:
candidate_coords.append(coord)
if not candidate_coords:
endpoints = _route_endpoints_for_car_item(item, data_dir, route_cache)
if endpoints is not None:
candidate_coords.extend(endpoints)
for coord in candidate_coords:
if coord not in timezone_cache:
timezone_cache[coord] = _timezone_from_coordinates(coord[0], coord[1])
timezone_name = timezone_cache[coord]
if timezone_name:
return timezone_name
return None
def localize_trip_car_journey_display_times(trip: Trip, data_dir: str) -> None:
"""Add route-local display timestamps for car journeys on the trip page."""
route_cache: dict[str, tuple[tuple[float, float], tuple[float, float]] | None] = {}
timezone_cache: dict[tuple[float, float], str | None] = {}
for item in trip.travel:
if item.get("type") != "car":
continue
depart = item.get("depart")
arrive = item.get("arrive")
if not isinstance(depart, datetime) or not isinstance(arrive, datetime):
continue
timezone_name = _timezone_name_for_car_item(
item, data_dir, route_cache, timezone_cache
)
if not timezone_name:
continue
item["display_depart"] = depart.astimezone(ZoneInfo(timezone_name))
item["display_arrive"] = arrive.astimezone(ZoneInfo(timezone_name))
def get_destination_timezones(trip: Trip) -> list[StrDict]:
"""Build destination timezone metadata for the trip page."""
per_location: dict[tuple[str, str], list[str]] = defaultdict(list)
@ -1276,6 +1378,7 @@ def trip_page(start: str) -> str:
trip,
cache_only=True,
)
localize_trip_car_journey_display_times(trip, app.config["PERSONAL_DATA"])
return flask.render_template(
"trip_page.html",
@ -1424,7 +1527,9 @@ def trip_stats() -> str:
"trip/stats.html",
count=len(trip_list),
total_distance=calc_total_distance(trip_list),
total_co2_kg=calc_total_co2_kg(trip_list),
distances_by_transport_type=sum_distances_by_transport_type(trip_list),
co2_by_transport_type=sum_co2_by_transport_type(trip_list),
yearly_stats=yearly_stats,
overall_stats=overall_stats,
conferences=conferences,