182 lines
8.4 KiB
Diff
182 lines
8.4 KiB
Diff
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 '—' }} → {{ flight.arrival_utc_offset or '—' }}</dd>
|
|
+ {% endif %}
|
|
{% if flight.schedule_error %}
|
|
<dt>Schedule lookup</dt><dd class="muted">{{ flight.schedule_error }}</dd>
|
|
{% endif %}
|