Add timeline car import and CO2 trip stats
This commit is contained in:
parent
38dbb2b4e7
commit
3747e83ade
14 changed files with 1038 additions and 29 deletions
152
tests/test_car_journey_timeline.py
Normal file
152
tests/test_car_journey_timeline.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for importing car journeys from a Google Timeline export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
import typing
|
||||
|
||||
from agenda import car_journey_timeline, car_journey_yaml
|
||||
|
||||
|
||||
def test_journey_key_orders_split_same_day_rows() -> None:
|
||||
"""Datetime rows should sort chronologically within a single day."""
|
||||
earlier = {
|
||||
"trip": date(2025, 7, 4),
|
||||
"depart": datetime(2025, 7, 4, 8, 0, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2025, 7, 4, 8, 30, tzinfo=timezone.utc),
|
||||
"route": "a",
|
||||
}
|
||||
later = {
|
||||
"trip": date(2025, 7, 4),
|
||||
"depart": datetime(2025, 7, 4, 9, 0, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2025, 7, 4, 9, 30, tzinfo=timezone.utc),
|
||||
"route": "b",
|
||||
}
|
||||
|
||||
assert car_journey_yaml.journey_key(earlier) < car_journey_yaml.journey_key(later)
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, typing.Any]) -> None:
|
||||
"""Write JSON test data."""
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
|
||||
def test_import_from_timeline_splits_stopovers(tmp_path: Path) -> None:
|
||||
"""Past car journeys should be split into multiple timeline segments."""
|
||||
data_dir = tmp_path
|
||||
route_dir = data_dir / "car_routes"
|
||||
route_dir.mkdir()
|
||||
(data_dir / "car_journeys.yaml").write_text("""---
|
||||
|
||||
- trip: 2025-07-04
|
||||
depart: 2025-07-04
|
||||
arrive: 2025-07-04
|
||||
route: PCH_to_Far
|
||||
|
||||
- trip: 2026-07-16
|
||||
depart: 2026-07-16
|
||||
arrive: 2026-07-16
|
||||
route: Future_to_Elsewhere
|
||||
""")
|
||||
write_json(
|
||||
route_dir / "PCH_to_Far.geojson",
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[-2.6, 51.4], [-4.1, 50.3]],
|
||||
},
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
route_dir / "Future_to_Elsewhere.geojson",
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[0.0, 0.0], [1.0, 1.0]],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
timeline_path = tmp_path / "Timeline.json"
|
||||
write_json(
|
||||
timeline_path,
|
||||
{
|
||||
"semanticSegments": [
|
||||
{
|
||||
"startTime": "2025-07-04T08:00:00+00:00",
|
||||
"endTime": "2025-07-04T10:00:00+00:00",
|
||||
"timelinePath": [
|
||||
{
|
||||
"point": "51.4000°, -2.6000°",
|
||||
"time": "2025-07-04T08:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"point": "51.0000°, -3.1000°",
|
||||
"time": "2025-07-04T08:30:00+00:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"startTime": "2025-07-04T08:00:00+00:00",
|
||||
"endTime": "2025-07-04T08:30:00+00:00",
|
||||
"activity": {
|
||||
"start": {"latLng": "51.4000°, -2.6000°"},
|
||||
"end": {"latLng": "51.0000°, -3.1000°"},
|
||||
"topCandidate": {"type": "IN_PASSENGER_VEHICLE"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"startTime": "2025-07-04T10:00:00+00:00",
|
||||
"endTime": "2025-07-04T12:00:00+00:00",
|
||||
"timelinePath": [
|
||||
{
|
||||
"point": "51.0000°, -3.1000°",
|
||||
"time": "2025-07-04T10:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"point": "50.3000°, -4.1000°",
|
||||
"time": "2025-07-04T11:00:00+00:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"startTime": "2025-07-04T10:00:00+00:00",
|
||||
"endTime": "2025-07-04T11:00:00+00:00",
|
||||
"activity": {
|
||||
"start": {"latLng": "51.0000°, -3.1000°"},
|
||||
"end": {"latLng": "50.3000°, -4.1000°"},
|
||||
"topCandidate": {"type": "IN_PASSENGER_VEHICLE"},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
routes_written, rows_replaced, rows_written = (
|
||||
car_journey_timeline.import_from_timeline(
|
||||
timeline_path, data_dir=data_dir, today=date(2026, 7, 9)
|
||||
)
|
||||
)
|
||||
|
||||
assert routes_written == 2
|
||||
assert rows_replaced == 1
|
||||
assert rows_written == 2
|
||||
|
||||
updated = car_journey_yaml.load_yaml_list(data_dir / "car_journeys.yaml")
|
||||
assert len(updated) == 3
|
||||
assert updated[0]["from"] == "PCH"
|
||||
assert updated[0]["to"] == "Drive stop 1"
|
||||
assert updated[1]["from"] == "Drive stop 1"
|
||||
assert updated[1]["to"] == "Far"
|
||||
assert updated[2]["route"] == "Future_to_Elsewhere"
|
||||
|
||||
generated_paths = sorted(route_dir.glob("timeline_*.geojson"))
|
||||
assert len(generated_paths) == 2
|
||||
generated_geojson = json.loads(generated_paths[0].read_text())
|
||||
assert generated_geojson["geometry"]["type"] == "LineString"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import json
|
||||
import pathlib
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import agenda.trip
|
||||
import pytest
|
||||
|
|
@ -203,6 +203,9 @@ def test_load_cars_infers_route_labels_and_home_marker(
|
|||
assert cars[0]["to"] == "EMF"
|
||||
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
|
||||
assert cars[0]["distance"] > 0
|
||||
assert cars[0]["co2_kg"] == pytest.approx(
|
||||
cars[0]["distance"] * agenda.trip.CAR_CO2_KG_PER_KM
|
||||
)
|
||||
assert cars[0]["from_location"] == {
|
||||
"name": "PCH",
|
||||
"type": "home",
|
||||
|
|
@ -274,3 +277,49 @@ def test_get_trip_routes_includes_car_geojson() -> None:
|
|||
"geojson_filename": "car_routes/PCH_to_EMF",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_trip_title_ignores_generated_drive_stop_labels() -> None:
|
||||
"""Fallback trip titles should ignore synthetic split-car stop labels."""
|
||||
trip = Trip(
|
||||
start=date(2025, 12, 29),
|
||||
travel=[
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(2025, 12, 29, 11, 4, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2025, 12, 29, 11, 10, tzinfo=timezone.utc),
|
||||
"from": "PCH",
|
||||
"to": "Drive stop 1",
|
||||
},
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(2025, 12, 29, 11, 27, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2025, 12, 29, 11, 40, tzinfo=timezone.utc),
|
||||
"from": "Drive stop 1",
|
||||
"to": "Drive stop 2",
|
||||
},
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(2025, 12, 29, 14, 6, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2025, 12, 29, 15, 22, tzinfo=timezone.utc),
|
||||
"from": "Drive stop 2",
|
||||
"to": "St Ives",
|
||||
},
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(2026, 1, 5, 12, 46, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2026, 1, 5, 13, 0, tzinfo=timezone.utc),
|
||||
"from": "St Ives",
|
||||
"to": "Drive stop 1",
|
||||
},
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(2026, 1, 5, 16, 58, tzinfo=timezone.utc),
|
||||
"arrive": datetime(2026, 1, 5, 17, 29, tzinfo=timezone.utc),
|
||||
"from": "Drive stop 3",
|
||||
"to": "PCH",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert trip.title == "St Ives"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Regression tests for trip page route wiring and rendering."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import typing
|
||||
from unittest import mock
|
||||
|
||||
|
|
@ -307,3 +307,57 @@ def test_trip_page_uses_bed_icon_for_overnight_train_coach() -> None:
|
|||
assert response.status_code == 200
|
||||
page = response.data.decode()
|
||||
assert "🛏️ Coach S, Seat 4" in page
|
||||
|
||||
|
||||
def test_trip_page_renders_car_times_in_local_route_timezone() -> None:
|
||||
"""Car journeys should display in the route's local timezone."""
|
||||
trip = Trip(
|
||||
start=date(2023, 8, 23),
|
||||
travel=[
|
||||
{
|
||||
"type": "car",
|
||||
"depart": datetime(
|
||||
2023, 8, 24, 5, 22, tzinfo=timezone(timedelta(hours=1))
|
||||
),
|
||||
"arrive": datetime(
|
||||
2023, 8, 24, 6, 26, tzinfo=timezone(timedelta(hours=1))
|
||||
),
|
||||
"from": "JFK",
|
||||
"to": "Rockaway",
|
||||
"distance": 102.0,
|
||||
"from_location": {
|
||||
"name": "JFK",
|
||||
"country": "us",
|
||||
"latitude": 40.64,
|
||||
"longitude": -73.78,
|
||||
},
|
||||
"to_location": {
|
||||
"name": "Rockaway",
|
||||
"country": "us",
|
||||
"latitude": 40.907,
|
||||
"longitude": -74.554,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with web_view.app.app_context():
|
||||
with (
|
||||
mock.patch.object(web_view, "get_trip_list", return_value=[trip]),
|
||||
mock.patch.object(agenda.weather, "get_trip_weather", return_value=[]),
|
||||
mock.patch.object(
|
||||
web_view,
|
||||
"_timezone_from_coordinates",
|
||||
return_value="America/New_York",
|
||||
),
|
||||
):
|
||||
web_view.app.config["TESTING"] = True
|
||||
|
||||
with web_view.app.test_client() as client:
|
||||
response = client.get("/trip/2023-08-23")
|
||||
|
||||
assert response.status_code == 200
|
||||
page = response.data.decode()
|
||||
assert "JFK → Rockaway" in page
|
||||
assert "00:22 → 01:26" in page
|
||||
assert "05:22 → 06:26" not in page
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from datetime import date
|
||||
|
||||
import agenda
|
||||
import pytest
|
||||
from agenda.stats import calculate_yearly_stats
|
||||
from agenda.types import Trip
|
||||
|
||||
|
|
@ -35,3 +36,22 @@ def test_new_country_respects_previously_visited() -> None:
|
|||
|
||||
yearly_stats = calculate_yearly_stats(trips, {"CZ"})
|
||||
assert "new_countries" not in yearly_stats[2024]
|
||||
|
||||
|
||||
def test_yearly_stats_tracks_co2_by_transport_type() -> None:
|
||||
"""CO₂ stats should include a per-transport-type breakdown."""
|
||||
trip = Trip(
|
||||
start=date(2024, 5, 1),
|
||||
travel=[
|
||||
{"type": "car", "distance": 100.0, "co2_kg": 21.8},
|
||||
{"type": "bus", "distance": 50.0, "co2_kg": 5.0},
|
||||
],
|
||||
)
|
||||
|
||||
yearly_stats = calculate_yearly_stats([trip])
|
||||
|
||||
assert yearly_stats[2024]["co2_kg"] == pytest.approx(26.8)
|
||||
assert yearly_stats[2024]["co2_by_transport_type"] == {
|
||||
"car": 21.8,
|
||||
"bus": 5.0,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue