convert track timestamps to airport-local; capture per-leg UTC offsets
This commit is contained in:
+54
-12
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user