warn if hotel/accommodation coords to far apart

This commit is contained in:
Edward Betts 2025-11-03 19:29:42 +00:00
parent 4364c95f25
commit a7ec33c500

View file

@ -5,10 +5,10 @@ import os
import sys
import typing
from datetime import date, datetime, timedelta
from typing import Tuple, TypeVar
from typing import Tuple, TypeVar, cast
import yaml
from geopy.distance import distance
from geopy.distance import distance # type: ignore[import-untyped]
from rich.pretty import pprint
import agenda
@ -23,6 +23,8 @@ data_dir = config.PERSONAL_DATA
currencies = set(config.CURRENCIES + ["GBP"])
LatLon = Tuple[float, float]
def check_currency(item: agenda.types.StrDict) -> None:
"""Throw error if currency is not in config."""
@ -34,11 +36,15 @@ def check_currency(item: agenda.types.StrDict) -> None:
sys.exit(-1)
def get_coords(item):
def get_coords(item: agenda.types.StrDict) -> LatLon | None:
"""Return latitude/longitude tuple when present."""
if "latitude" in item and "longitude" in item:
return (item["latitude"], item["longitude"])
else:
return None
latitude = item["latitude"]
longitude = item["longitude"]
assert isinstance(latitude, (int, float))
assert isinstance(longitude, (int, float))
return (float(latitude), float(longitude))
return None
T = TypeVar("T")
@ -49,12 +55,33 @@ def remove_nones(items: list[T | None]) -> list[T]:
return [item for item in items if item is not None]
LatLon = Tuple[float, float]
def distance_km(a: LatLon, b: LatLon) -> float:
"""Return the great-circle distance between two (lat, lon) points in km."""
return distance(a, b).km
return cast(float, distance(a, b).km)
def parse_datetime_value(value: typing.Any) -> datetime | None:
"""Return naive datetime for supported input types."""
if value is None:
return None
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"Invalid ISO datetime string: {value}") from exc
return parsed.replace(tzinfo=None)
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
raise TypeError(f"Unsupported datetime value type: {type(value)}")
def ranges_overlap(
start_a: datetime, end_a: datetime, start_b: datetime, end_b: datetime
) -> bool:
"""Return True when two datetime ranges overlap."""
return start_a < end_b and start_b < end_a
def check_trips() -> None:
@ -67,6 +94,7 @@ def check_trips() -> None:
for trip_data in trips_data:
current_trip = normalize_datetime(trip_data["trip"])
if prev_trip and current_trip < prev_trip:
assert prev_trip_data is not None
print("Out of order trip found:")
print(
f" Previous: {prev_trip_data.get('trip')} - "
@ -86,12 +114,69 @@ def check_trips() -> None:
for trip in trip_list:
if not trip.accommodation or not trip.conferences:
continue
accommodation_coords = remove_nones([get_coords(a) for a in trip.accommodation])
conference_coords = remove_nones([get_coords(c) for c in trip.conferences])
if len(accommodation_coords) != 1 or len(conference_coords) != 1:
accommodation_entries: list[
tuple[agenda.types.StrDict, LatLon, datetime, datetime]
] = []
for accommodation in trip.accommodation:
accommodation_coords = get_coords(accommodation)
if accommodation_coords is None:
continue
start_dt = parse_datetime_value(accommodation.get("from"))
end_dt = parse_datetime_value(accommodation.get("to"))
if start_dt is None or end_dt is None:
continue
accommodation_entries.append(
(accommodation, accommodation_coords, start_dt, end_dt)
)
if not accommodation_entries:
continue
dist = distance_km(conference_coords[0], accommodation_coords[0])
assert dist < config.ACCOMODATION_MAX_DISTANCE_KM
for conference in trip.conferences:
if conference.get("online"):
continue
conference_coords = get_coords(conference)
if conference_coords is None:
continue
start_dt = parse_datetime_value(conference.get("start"))
end_value = conference.get("end") or conference.get("start")
end_dt = parse_datetime_value(end_value)
if start_dt is None or end_dt is None:
continue
conference_country = (
str(conference.get("country")).lower()
if conference.get("country")
else None
)
overlapping_distances = []
for (
accommodation_item,
accommodation_coords,
accommodation_start,
accommodation_end,
) in accommodation_entries:
accommodation_country = (
str(accommodation_item.get("country")).lower()
if accommodation_item.get("country")
else None
)
if (
conference_country
and accommodation_country
and accommodation_country != conference_country
):
continue
if not ranges_overlap(
accommodation_start, accommodation_end, start_dt, end_dt
):
continue
overlapping_distances.append(
distance_km(conference_coords, accommodation_coords)
)
if not overlapping_distances:
continue
assert min(overlapping_distances) < config.ACCOMODATION_MAX_DISTANCE_KM
coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir)
print(len(coords), "coords")
@ -132,14 +217,13 @@ def check_flights(airlines: set[str]) -> None:
)
def normalize_datetime(dt_value):
def normalize_datetime(dt_value: date | datetime) -> datetime:
"""Convert date or datetime to datetime for comparison, removing timezone info."""
if isinstance(dt_value, date) and not isinstance(dt_value, datetime):
return datetime.combine(dt_value, datetime.min.time())
elif isinstance(dt_value, datetime):
# Remove timezone info to allow comparison between naive and aware datetimes
if isinstance(dt_value, datetime):
return dt_value.replace(tzinfo=None)
return dt_value
if isinstance(dt_value, date):
return datetime.combine(dt_value, datetime.min.time())
raise TypeError(f"Unsupported datetime value type: {type(dt_value)}")
def check_trains() -> None:
@ -151,6 +235,7 @@ def check_trains() -> None:
for train in trains:
current_depart = normalize_datetime(train["depart"])
if prev_depart and current_depart < prev_depart:
assert prev_train is not None
print(f"Out of order train found:")
print(
f" Previous: {prev_train.get('depart')} {prev_train.get('from', '')} -> {prev_train.get('to', '')}"
@ -184,6 +269,7 @@ def check_conferences() -> None:
current_start = normalize_datetime(conf_data["start"])
if prev_start and current_start < prev_start:
assert prev_conf_data is not None
print(f"Out of order conference found:")
print(
f" Previous: {prev_conf_data.get('start')} - {prev_conf_data.get('name', 'No name')}"
@ -237,6 +323,7 @@ def check_accommodation() -> None:
current_from = normalize_datetime(stay["from"])
if prev_from and current_from < prev_from:
assert prev_stay is not None
print(f"Out of order accommodation found:")
print(
f" Previous: {prev_stay.get('from')} - {prev_stay.get('name', 'No name')} ({prev_stay.get('location', '')})"
@ -282,6 +369,7 @@ def check_ferries() -> None:
for ferry in ferries:
current_depart = normalize_datetime(ferry["depart"])
if prev_depart and current_depart < prev_depart:
assert prev_ferry is not None
print(f"Out of order ferry found:")
print(
f" Previous: {prev_ferry.get('depart')} {prev_ferry.get('from', '')} -> {prev_ferry.get('to', '')}"