Refine conference exploration day labels
This commit is contained in:
parent
6a601f5c14
commit
8866b78202
2 changed files with 216 additions and 1 deletions
|
|
@ -6,7 +6,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import typing
|
import typing
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import pycountry
|
import pycountry
|
||||||
|
|
@ -22,6 +22,7 @@ COACH_CO2_KG_PER_KM = 0.027
|
||||||
FERRY_CO2_KG_PER_KM = 0.02254
|
FERRY_CO2_KG_PER_KM = 0.02254
|
||||||
BUS_CO2_KG_PER_KM = 0.1
|
BUS_CO2_KG_PER_KM = 0.1
|
||||||
CAR_CO2_KG_PER_KM = 0.218
|
CAR_CO2_KG_PER_KM = 0.218
|
||||||
|
NOON = time(12)
|
||||||
|
|
||||||
|
|
||||||
class Airline(typing.TypedDict, total=False):
|
class Airline(typing.TypedDict, total=False):
|
||||||
|
|
@ -786,6 +787,58 @@ def add_coordinates_for_eurostar_home_feeder(
|
||||||
add_coordinate_if_missing(coordinates, coordinate_dict(from_station, "station"))
|
add_coordinate_if_missing(coordinates, coordinate_dict(from_station, "station"))
|
||||||
|
|
||||||
|
|
||||||
|
def timed_departures(item: StrDict) -> list[datetime]:
|
||||||
|
"""Return timed train/flight departure datetimes from a travel item."""
|
||||||
|
depart_values: list[typing.Any] = []
|
||||||
|
|
||||||
|
if item.get("type") in ("flight", "train"):
|
||||||
|
depart_values.append(item.get("depart"))
|
||||||
|
|
||||||
|
if item.get("type") == "train":
|
||||||
|
legs = item.get("legs", [])
|
||||||
|
if isinstance(legs, list):
|
||||||
|
depart_values.extend(
|
||||||
|
leg.get("depart") for leg in legs if isinstance(leg, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [value for value in depart_values if isinstance(value, datetime)]
|
||||||
|
|
||||||
|
|
||||||
|
def timed_arrivals(item: StrDict) -> list[datetime]:
|
||||||
|
"""Return timed train/flight arrival datetimes from a travel item."""
|
||||||
|
arrive_values: list[typing.Any] = []
|
||||||
|
|
||||||
|
if item.get("type") in ("flight", "train"):
|
||||||
|
arrive_values.append(item.get("arrive"))
|
||||||
|
|
||||||
|
if item.get("type") == "train":
|
||||||
|
legs = item.get("legs", [])
|
||||||
|
if isinstance(legs, list):
|
||||||
|
arrive_values.extend(
|
||||||
|
leg.get("arrive") for leg in legs if isinstance(leg, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [value for value in arrive_values if isinstance(value, datetime)]
|
||||||
|
|
||||||
|
|
||||||
|
def has_morning_train_or_flight_departure(trip: Trip, target_date: date) -> bool:
|
||||||
|
"""Return true if a train or flight leaves before noon on target_date."""
|
||||||
|
return any(
|
||||||
|
depart.date() == target_date and depart.time() < NOON
|
||||||
|
for item in trip.travel
|
||||||
|
for depart in timed_departures(item)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_afternoon_train_or_flight_arrival(trip: Trip, target_date: date) -> bool:
|
||||||
|
"""Return true if a train or flight arrives after noon on target_date."""
|
||||||
|
return any(
|
||||||
|
arrive.date() == target_date and arrive.time() > NOON
|
||||||
|
for item in trip.travel
|
||||||
|
for arrive in timed_arrivals(item)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
|
def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
|
||||||
"""Return (days_before, days_after) exploration days for each conference.
|
"""Return (days_before, days_after) exploration days for each conference.
|
||||||
|
|
||||||
|
|
@ -814,6 +867,18 @@ def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
|
||||||
)
|
)
|
||||||
days_before = (conf_attend_start(conf) - before_boundary).days
|
days_before = (conf_attend_start(conf) - before_boundary).days
|
||||||
days_after = (after_boundary - conf_attend_end(conf)).days
|
days_after = (after_boundary - conf_attend_end(conf)).days
|
||||||
|
if (
|
||||||
|
i == 0
|
||||||
|
and days_before > 0
|
||||||
|
and has_afternoon_train_or_flight_arrival(trip, before_boundary)
|
||||||
|
):
|
||||||
|
days_before -= 1
|
||||||
|
if (
|
||||||
|
i == len(sorted_confs) - 1
|
||||||
|
and days_after > 0
|
||||||
|
and has_morning_train_or_flight_departure(trip, after_boundary)
|
||||||
|
):
|
||||||
|
days_after -= 1
|
||||||
result[str(conf["start"])] = (days_before, days_after)
|
result[str(conf["start"])] = (days_before, days_after)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -467,3 +467,153 @@ def test_trip_title_ignores_generated_drive_stop_labels() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert trip.title == "St Ives"
|
assert trip.title == "St Ives"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_ignores_morning_train_departure_after() -> None:
|
||||||
|
"""A morning train on the day after a conference leaves no explore day."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc),
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_ignores_afternoon_train_arrival_before() -> None:
|
||||||
|
"""An afternoon train on the day before a conference leaves no explore day."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc),
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_keeps_morning_train_arrival_before() -> None:
|
||||||
|
"""A morning train arrival still leaves the afternoon to explore."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": datetime(2027, 1, 29, 8, 0, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 1, 29, 11, 30, tzinfo=timezone.utc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_keeps_date_only_arrival_before() -> None:
|
||||||
|
"""Date-only travel should not be treated as a known afternoon arrival."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": date(2027, 1, 29),
|
||||||
|
"arrive": date(2027, 1, 29),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_keeps_afternoon_train_departure_after() -> None:
|
||||||
|
"""An afternoon train still leaves the morning to explore."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": datetime(2027, 2, 1, 13, 4, tzinfo=timezone.utc),
|
||||||
|
"arrive": datetime(2027, 2, 1, 16, 5, tzinfo=timezone.utc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_free_days_keeps_date_only_departure_after() -> None:
|
||||||
|
"""Date-only travel should not be treated as a known morning departure."""
|
||||||
|
trip = Trip(
|
||||||
|
start=date(2027, 1, 29),
|
||||||
|
conferences=[
|
||||||
|
{
|
||||||
|
"name": "FOSDEM",
|
||||||
|
"start": date(2027, 1, 30),
|
||||||
|
"end": date(2027, 1, 31),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
travel=[
|
||||||
|
{
|
||||||
|
"type": "train",
|
||||||
|
"depart": date(2027, 2, 1),
|
||||||
|
"arrive": date(2027, 2, 1),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue