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
+181
View File
@@ -0,0 +1,181 @@
diff --git a/aerodatabox.py b/aerodatabox.py
index e60f4ce..5764b82 100644
--- a/aerodatabox.py
+++ b/aerodatabox.py
@@ -71,6 +71,36 @@ def _sanitize(flight_number: str) -> str:
return re.sub(r"[\s-]", "", flight_number).upper()
+def _utc_offset(local: str | None) -> str | None:
+ """Pull the UTC offset out of an AeroDataBox 'local' string
+ ('2026-06-22 13:45+10:00' -> '+10:00').
+
+ This is the airport's *actual* offset on the day of the flight (so DST
+ is already accounted for), and it's the only timezone information this
+ pipeline has - the bundled airports.csv carries IATA/ICAO/name only.
+ scheduler.py needs it to convert the FlightAware track's UTC timestamps
+ into the airport-local wall clock AirTrail expects."""
+ if not local:
+ return None
+ try:
+ dt = datetime.strptime(local, "%Y-%m-%d %H:%M%z")
+ except ValueError:
+ return None
+ offset = dt.strftime("%z") # '+1000'
+ return f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else None
+
+
+def _first_offset(leg: dict) -> str | None:
+ """The UTC offset for one leg, from whichever of its timestamps exists.
+ All of a leg's times share the same airport and day, so any of them
+ gives the same offset."""
+ for key in ("scheduledTime", "revisedTime", "actualTime", "runwayTime"):
+ offset = _utc_offset((leg.get(key) or {}).get("local"))
+ if offset:
+ return offset
+ return None
+
+
def _parse_local(local: str | None) -> str | None:
"""AeroDataBox 'local' strings look like '2026-06-22 13:45+10:00' -
already carrying their own UTC offset, no separate airport-timezone
@@ -155,4 +185,10 @@ def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
),
"takeoffActual": _parse_local((dep.get("runwayTime") or {}).get("local")),
"landingActual": _parse_local((arr.get("runwayTime") or {}).get("local")),
+ # Each leg's real UTC offset on the day, taken from whichever local
+ # timestamp that leg actually has. Not sent to AirTrail directly -
+ # scheduler.py uses these to put the FlightAware track's UTC times
+ # into the same airport-local terms as everything else here.
+ "departureUtcOffset": _first_offset(dep),
+ "arrivalUtcOffset": _first_offset(arr),
}
diff --git a/db.py b/db.py
index 5e4a2cc..7a5e551 100644
--- a/db.py
+++ b/db.py
@@ -39,6 +39,8 @@ CREATE TABLE IF NOT EXISTS pending_flights (
gate_departure_actual TEXT,
gate_arrival_scheduled TEXT,
gate_arrival_actual TEXT,
+ departure_utc_offset TEXT,
+ arrival_utc_offset TEXT,
schedule_error TEXT,
airtrail_flight_id INTEGER,
parse_warnings TEXT,
@@ -86,6 +88,8 @@ MIGRATIONS = [
("gate_departure_actual", "TEXT"),
("gate_arrival_scheduled", "TEXT"),
("gate_arrival_actual", "TEXT"),
+ ("departure_utc_offset", "TEXT"),
+ ("arrival_utc_offset", "TEXT"),
("schedule_error", "TEXT"),
]
diff --git a/scheduler.py b/scheduler.py
index cb77644..4f3cb08 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -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))
diff --git a/templates/review_detail.html b/templates/review_detail.html
index b21e042..b2d7202 100644
--- a/templates/review_detail.html
+++ b/templates/review_detail.html
@@ -73,6 +73,9 @@
{% if flight.landing_actual %}
<dt>Landing (actual)</dt><dd>{{ flight.landing_actual }}</dd>
{% endif %}
+ {% if flight.departure_utc_offset or flight.arrival_utc_offset %}
+ <dt>Airport UTC offsets</dt><dd>{{ flight.departure_utc_offset or '—' }} &rarr; {{ flight.arrival_utc_offset or '—' }}</dd>
+ {% endif %}
{% if flight.schedule_error %}
<dt>Schedule lookup</dt><dd class="muted">{{ flight.schedule_error }}</dd>
{% endif %}