convert track timestamps to airport-local; capture per-leg UTC offsets

This commit is contained in:
2026-09-15 22:48:34 +10:00
parent 8a2fb25897
commit 480b896d39
5 changed files with 278 additions and 12 deletions
+54 -12
View File
@@ -35,8 +35,40 @@ def next_run_datetime(hhmm: str, now: datetime | None = None) -> datetime:
return candidate
def _iso_from_unix(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def _tz_from_offset(offset: str | None):
"""'+08:00' -> a datetime.timezone. None if missing/unparseable."""
if not offset:
return None
try:
return datetime.strptime(
f"2000-01-01T00:00:00{offset}", "%Y-%m-%dT%H:%M:%S%z"
).tzinfo
except ValueError:
return None
def _iso_local_from_unix(ts: int, offset: str | None) -> str | None:
"""A FlightAware track timestamp (UTC epoch seconds) as an ISO string in
the *airport's* local wall clock.
This conversion is not cosmetic. AirTrail reads only the literal Y-M-D
out of a datetime field and the HH:MM out of its companion *Time field,
then interprets that pair in the airport's own timezone
(mergeTimeWithDate - confirmed in AirTrail's source). A UTC timestamp
sent as-is is therefore silently stored wrong by the whole offset, and
when takeoff comes from AeroDataBox (already airport-local) while
landing falls back to this track (UTC), the mismatch can invert their
order and get the save rejected outright with "Actual landing must be
after actual takeoff" - which is exactly what happened on VA556.
With no offset known (AeroDataBox unavailable, and airports.csv carries
no timezone data) this returns None so the caller omits the field
entirely - a missing takeoff time is recoverable, a wrong one is not.
"""
tzinfo = _tz_from_offset(offset)
if tzinfo is None:
return None
return datetime.fromtimestamp(ts, tz=tzinfo).isoformat(timespec="seconds")
def _fetch_gate_times(flight_number: str, flight_date: date, updates: dict) -> None:
@@ -60,14 +92,18 @@ def _fetch_gate_times(flight_number: str, flight_date: date, updates: dict) -> N
updates["gate_departure_actual"] = gate_times.get("departure")
updates["gate_arrival_scheduled"] = gate_times.get("arrivalScheduled")
updates["gate_arrival_actual"] = gate_times.get("arrival")
# Each leg's UTC offset on the day - what lets the FlightAware track's
# UTC timestamps be expressed in the same airport-local terms as
# everything else (see _iso_local_from_unix).
updates["departure_utc_offset"] = gate_times.get("departureUtcOffset")
updates["arrival_utc_offset"] = gate_times.get("arrivalUtcOffset")
# AeroDataBox's runwayTime is an authoritative actual takeoff/landing
# time - prefer it. Only set here if present; the FlightAware-track
# fallback below fills in behind it (checks "not in updates") rather
# than overwriting it unconditionally.
if gate_times.get("takeoffActual"):
updates["takeoff_actual"] = gate_times["takeoffActual"]
if gate_times.get("landingActual"):
updates["landing_actual"] = gate_times["landingActual"]
# time - prefer it. Assigned unconditionally (None included) so a
# re-query always rewrites these rather than leaving a stale value
# behind; the FlightAware-track fallback below fills in only where
# AeroDataBox came up empty.
updates["takeoff_actual"] = gate_times.get("takeoffActual")
updates["landing_actual"] = gate_times.get("landingActual")
def process_one_flight(row: dict) -> None:
@@ -151,10 +187,16 @@ def process_one_flight(row: dict) -> None:
# already give us its authoritative runwayTime above (no key
# configured, lookup failed, or that particular flight had no
# runwayTime in its response). Never overwrite a value AeroDataBox
# already set - that one's more precise.
# already set - that one's more precise - and convert into the
# departure/arrival airport's local wall clock, since that's the
# only form AirTrail interprets correctly.
if times:
updates.setdefault("takeoff_actual", _iso_from_unix(times[0]))
updates.setdefault("landing_actual", _iso_from_unix(times[-1]))
dep_offset = updates.get("departure_utc_offset") or row.get("departure_utc_offset")
arr_offset = updates.get("arrival_utc_offset") or row.get("arrival_utc_offset")
if not updates.get("takeoff_actual"):
updates["takeoff_actual"] = _iso_local_from_unix(times[0], dep_offset)
if not updates.get("landing_actual"):
updates["landing_actual"] = _iso_local_from_unix(times[-1], arr_offset)
models.update_flight(flight_id, **updates)
notifier.notify_flight_staged(models.get_flight(flight_id))