// Serving observed days: an airport's real movements, recorded from ADS-B on a recent day, laid onto the date asked // for as a schedule in RadarWorks's flight-record contract (the same records as shapeAeroDataBox in ../traffic.mjs). // // For airport X on its local date D, the movements come from the most recent recorded local day with D's weekday (D // itself when it has been recorded, else D-7, D-14, ...), or, while history is shorter than a week, the most recent // recorded day of any weekday (see observedDayFor). A local day is fully recorded when every UTC day it overlaps has // been ingested; until then a day's length of recordings beside it is used, and the note says which hours they are. // Each movement keeps its local clock time (so a day across a daylight-saving change stays right); observed times // become schedule-like: a departure 15 minutes before takeoff (rounded down to 5), an arrival 5 minutes after landing // (rounded up to 5). import { addDays, adsbSettings, openForReading } from "./store.mjs"; export const SOURCE = "adsb.lol"; export const ATTRIBUTION = "Flight data: adsb.lol contributors (ODbL 1.0)"; export const LICENCE = "ODbL-1.0"; export const LICENCE_URL = "https://opendatacommons.org/licenses/odbl/1-0/"; // Operators that fly only freight. const CARGO = new Set( ("FDX UPS GTI CLX BCS ABW CKS DHK DHX DAE ABX ATN BOX GEC NCA PAC CAO CKK CSS YZR TAY SWN KYE AJT MPH ICV LCO WGN NPT " + "SOO KFA CMB CTJ QAC ASJ TPA LTG MSX AHK CYZ").split(" "), ); // ── Clocks ──────────────────────────────────────────────────────────────────────────────────────────────────────── const formatters = new Map(); function formatter(tz) { let f = formatters.get(tz); if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); formatters.set(tz, f); } return f; } // The airport's clock at an instant: { date: "YYYY-MM-DD", minutes: minutes after local midnight }. UTC without a zone. export function wallClock(ms, tz) { if (!tz) { const d = new Date(ms); return { date: d.toISOString().slice(0, 10), minutes: d.getUTCHours() * 60 + d.getUTCMinutes() }; } const p = Object.fromEntries(formatter(tz).formatToParts(new Date(ms)).map((x) => [x.type, x.value])); return { date: `${p.year}-${p.month}-${p.day}`, minutes: Number(p.hour) * 60 + Number(p.minute) }; } function offsetMs(tz, ms) { if (!tz) return 0; const p = Object.fromEntries(formatter(tz).formatToParts(new Date(ms)).map((x) => [x.type, x.value])); const asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)); return asUtc - Math.floor(ms / 1000) * 1000; } // The instant the airport's clock shows `minutes` after midnight on `date`. In a spring-forward gap the clock time // does not exist and the later reading is used. export function wallToUtc(date, minutes, tz) { const guess = Date.parse(`${date}T00:00:00Z`) + minutes * 60000; if (!tz) return guess; let t = guess - offsetMs(tz, guess); const second = offsetMs(tz, t); if (second !== guess - t) t = guess - second; return t; } const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"); const pad = (n) => String(n).padStart(2, "0"); const weekday = (date) => new Date(`${date}T12:00:00Z`).getUTCDay(); export function longDate(date) { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }) .formatToParts(new Date(`${date}T12:00:00Z`)) .map((p) => [p.type, p.value]), ); return `${parts.weekday} ${parts.day} ${parts.month} ${parts.year}`; } const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; // ── Which recorded day ──────────────────────────────────────────────────────────────────────────────────────────── function localDayWindow(date, tz) { const start = wallToUtc(date, 0, tz); const end = wallToUtc(addDays(date, 1), 0, tz); return { start, end }; } function utcDaysBetween(startMs, endMs) { const days = []; for (let d = new Date(startMs).toISOString().slice(0, 10); Date.parse(`${d}T00:00:00Z`) < endMs; d = addDays(d, 1)) days.push(d); return days; } const HOUR = 3600000; // How much of a window the ingested UTC days cover, and the stretches they do not. function recorded(window, ingested) { let ms = 0; const gaps = []; for (const d of utcDaysBetween(window.start, window.end)) { const d0 = Date.parse(`${d}T00:00:00Z`); const s = Math.max(window.start, d0); const e = Math.min(window.end, d0 + 24 * HOUR); if (e <= s) continue; if (ingested.has(d)) ms += e - s; else if (gaps.length && gaps[gaps.length - 1][1] === s) gaps[gaps.length - 1][1] = e; else gaps.push([s, e]); } return { hours: ms / HOUR, gaps, complete: gaps.length === 0 }; } function countIn(db, icao, window) { const days = utcDaysBetween(window.start, window.end); const row = db .prepare( `SELECT COUNT(*) AS n, SUM(kind = 'A') AS arrivals FROM movements WHERE airport = ? AND day IN (${days.map(() => "?").join(",")}) AND utc >= ? AND utc < ?`, ) .get(icao, ...days, Math.floor(window.start / 1000), Math.ceil(window.end / 1000)); return { flights: row.n, arrivals: row.arrivals || 0, departures: row.n - (row.arrivals || 0) }; } export function minRecordedHours() { const v = Number(process.env.RW_ADSB_MIN_HOURS); return Number.isFinite(v) && v > 0 && v <= 24 ? v : 18; } // Runs of consecutive ingested UTC days, as [start, end) in ms. function recordedSpans(ingested) { const spans = []; for (const d of [...ingested].sort()) { const d0 = Date.parse(`${d}T00:00:00Z`); const last = spans[spans.length - 1]; if (last && last[1] === d0) last[1] = d0 + 24 * HOUR; else spans.push([d0, d0 + 24 * HOUR]); } return spans; } // How local day `o` can be served from what has been recorded: // "local-day" the whole local day is recorded; // "shifted-day" it is not, but a day's length of recordings next to it is: the window slides into them, so every // clock time is still there once and the hours outside the date come from the neighbouring day; // "partial-day" too little is recorded to slide (a 25-hour day at a clock change with one day ingested): the // recorded part, when it is at least RW_ADSB_MIN_HOURS (18). // ownHours is how much of the date itself is recorded. function servable(o, tz, ingested, spans) { const day = localDayWindow(o, tz); const r = recorded(day, ingested); if (r.complete) return { basis: "local-day", window: day, ownHours: r.hours, gaps: [] }; if (!r.hours) return null; const length = day.end - day.start; const overlap = (sp) => Math.max(0, Math.min(sp[1], day.end) - Math.max(sp[0], day.start)); const span = spans.reduce((best, sp) => (overlap(sp) >= overlap(best) ? sp : best), spans[0]); if (span[1] - span[0] >= length) { const start = Math.min(Math.max(day.start, span[0]), span[1] - length); return { basis: "shifted-day", window: { start, end: start + length }, ownHours: r.hours, gaps: [] }; } if (r.hours < minRecordedHours()) return null; return { basis: "partial-day", window: day, ownHours: r.hours, gaps: r.gaps }; } // The recorded day to serve for local date `date` at an airport, or null when the recordings have nothing there. // In order: the most recent fully recorded local day with the same weekday (the date itself, else 7, 14, ... days // earlier); a same-weekday day mostly recorded (12 hours or more of it), slid or partial as above; then the same for // any weekday, latest first. Once a day and its neighbours are ingested, every airport has full local days. export function observedDayFor(db, airport, date) { const ingested = new Set(db.prepare("SELECT day FROM ingested").all().map((r) => r.day)); if (!ingested.size) return null; const spans = recordedSpans(ingested); const days = [...ingested].sort(); const first = addDays(days[0], -1); const last = addDays(days[days.length - 1], 1); const tz = airport.tz || ""; const evaluate = (o) => { const v = servable(o, tz, ingested, spans); if (!v || (v.basis !== "local-day" && v.ownHours < 12)) return null; if (!countIn(db, airport.icao, v.window).flights) return null; return { ...v, basedOn: o, recordedHours: Math.round(((v.basis === "partial-day" ? v.ownHours : (v.window.end - v.window.start) / HOUR)) * 10) / 10, complete: v.basis === "local-day" }; }; const pick = (list) => { let fallback = null; for (const o of list) { const e = evaluate(o); if (e?.complete) return e; if (e && (!fallback || e.ownHours > fallback.ownHours)) fallback = e; } return fallback; }; const sameWeekday = []; for (let o = date; o >= first; o = addDays(o, -7)) if (o <= last) sameWeekday.push(o); const found = pick(sameWeekday); if (found) return { ...found, sameWeekday: true }; const any = []; for (let o = last; o >= first; o = addDays(o, -1)) any.push(o); const other = pick(any); return other ? { ...other, sameWeekday: weekday(other.basedOn) === weekday(date) } : null; } // ── Records ─────────────────────────────────────────────────────────────────────────────────────────────────────── const FIVE = 5 * 60000; export function scheduledFrom(kind, utcSeconds) { const ms = utcSeconds * 1000; return kind === "D" ? Math.floor((ms - 15 * 60000) / FIVE) * FIVE : Math.ceil((ms + 5 * 60000) / FIVE) * FIVE; } function lookups(db) { const airportRow = db.prepare("SELECT icao, iata, name, city, country, lat, lon, tz FROM airports WHERE icao = ?"); const airlineRow = db.prepare("SELECT iata, name FROM airlines WHERE icao = ?"); const airports = new Map(); const airlines = new Map(); return { airport(icao) { if (!icao) return null; if (!airports.has(icao)) airports.set(icao, airportRow.get(icao) || null); return airports.get(icao); }, airline(icao) { if (!airlines.has(icao)) airlines.set(icao, airlineRow.get(icao) || null); return airlines.get(icao); }, }; } // One stored movement as RadarWorks's flight record, at `scheduled` (ms) shown on the airport's clock. export function shapeObserved(m, { scheduledMs, scheduledLocal, airline, other }) { const suffix = m.callsign.slice(3); const iata = airline?.iata || ""; return { kind: m.kind, airline: m.airline, airlineIata: iata, airlineName: airline?.name || "", number: iata && /^\d+$/.test(suffix) ? `${iata} ${Number(suffix)}` : "", callsign: m.callsign, scheduledLocal, scheduledUtc: iso(scheduledMs), estimatedUtc: "", status: "", other: m.other || "", otherIata: other?.iata || "", otherName: other ? other.city || other.name : "", otherLat: other ? other.lat : null, otherLon: other ? other.lon : null, gate: "", terminal: "", gateSource: "", aircraft: m.type || "", registration: m.registration || "", cargo: CARGO.has(m.airline), codeshare: false, live: false, }; } const clock = (minutes) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; // "00:00-20:00 Sunday 25 October 2026 and 20:00-24:00 Saturday 24 October 2026" function describeWindow(window, tz, basedOn) { const parts = []; let s = window.start; while (s < window.end) { const at = wallClock(s, tz); const midnight = wallToUtc(addDays(at.date, 1), 0, tz); const e = Math.min(window.end, midnight); const to = e === midnight ? "24:00" : clock(wallClock(e, tz).minutes); parts.push({ date: at.date, text: `${clock(at.minutes)}-${to} on ${longDate(at.date)}` }); s = e; } parts.sort((x, y) => (x.date === basedOn ? -1 : y.date === basedOn ? 1 : 0)); return parts.map((p) => p.text).join(" and "); } function coverageNoteFor(observed, date, tz, c) { const pct = (n) => `${c.flights ? Math.round((n * 100) / c.flights) : 0}%`; const count = `${c.flights.toLocaleString("en-US")} ${c.flights === 1 ? "flight" : "flights"}`; const quality = `types for ${pct(c.withType)}, registrations for ${pct(c.withRegistration)}, origins and destinations for ${pct(c.withOther)}.`; const { basedOn, basis, gaps, window } = observed; const where = tz ? "local time" : "UTC"; let note; if (basis === "shifted-day") { note = `Recorded from ADS-B ${describeWindow(window, tz, basedOn)} (${where}), as the whole of ${longDate(basedOn)} has not been recorded yet: ${count}; ${quality}`; } else { note = `Recorded from ADS-B on ${longDate(basedOn)}: ${count}; ${quality}`; if (gaps.length) { const parts = gaps.map(([s, e]) => `${clock(wallClock(s, tz).minutes)}-${e >= window.end ? "24:00" : clock(wallClock(e, tz).minutes)}`); note += ` ${parts.join(" and ")} ${where} ${gaps.length === 1 ? "was" : "were"} not recorded.`; } } if (!observed.sameWeekday) note += ` No ${WEEKDAYS[weekday(date)]} has been recorded here yet, so this is the latest recorded day.`; return note; } // The day for the service, or { missing: "no-source" | "unknown-airport" } when there is nothing to serve. export function adsbDay(icao, date, { now = Date.now(), file } = {}) { const settings = adsbSettings(); const db = openForReading(file || settings.db); if (!db || !db.prepare("SELECT 1 FROM ingested LIMIT 1").get()) return { missing: "no-source" }; const look = lookups(db); const airport = look.airport(icao); if (!airport) return { missing: "unknown-airport" }; const observed = observedDayFor(db, airport, date); if (!observed) return { missing: "unknown-airport" }; const tz = airport.tz || ""; const { window, basedOn, sameWeekday, basis } = observed; // A departure seen just after this local day may be scheduled on it (15 minutes earlier), an arrival just before it // may be scheduled on it (5 minutes later). const days = utcDaysBetween(window.start - 30 * 60000, window.end + 30 * 60000); const rows = db .prepare( `SELECT utc, kind, hex, callsign, airline, registration, type, other FROM movements WHERE airport = ? AND day IN (${days.map(() => "?").join(",")}) AND utc >= ? AND utc < ? ORDER BY utc`, ) .all(icao, ...days, Math.floor((window.start - 30 * 60000) / 1000), Math.ceil((window.end + 30 * 60000) / 1000)); const shift = Date.parse(`${date}T00:00:00Z`) - Date.parse(`${basedOn}T00:00:00Z`); const flights = []; for (const m of rows) { const observedSched = scheduledFrom(m.kind, m.utc); const wall = wallClock(observedSched, tz); // The movements scheduled within the window, each at its own clock time on the date. if (observedSched < window.start || observedSched >= window.end) continue; const scheduledMs = tz ? wallToUtc(date, wall.minutes, tz) : observedSched + shift; const scheduledLocal = tz ? `${date}T${pad(Math.floor(wall.minutes / 60))}:${pad(wall.minutes % 60)}` : ""; flights.push(shapeObserved(m, { scheduledMs, scheduledLocal, airline: look.airline(m.airline), other: look.airport(m.other) })); } flights.sort((a, b) => (a.scheduledUtc < b.scheduledUtc ? -1 : a.scheduledUtc > b.scheduledUtc ? 1 : 0)); const coverage = { flights: flights.length, withGate: 0, withRegistration: flights.filter((f) => f.registration).length, withType: flights.filter((f) => f.aircraft).length, withOther: flights.filter((f) => f.other).length, live: 0, codeshareListings: 0, cancelled: 0, }; const finished = utcDaysBetween(window.start, window.end) .map((d) => db.prepare("SELECT finished_at FROM ingested WHERE day = ?").get(d)?.finished_at) .filter(Boolean) .sort(); // A newer recording of this weekday can arrive with the next nightly ingest (~05:00 UTC). const next = new Date(now); next.setUTCHours(6, 0, 0, 0); if (next.getTime() <= now) next.setUTCDate(next.getUTCDate() + 1); return { body: { status: "fresh", message: "", icao, date, source: SOURCE, attribution: ATTRIBUTION, licence: LICENCE, licenceUrl: LICENCE_URL, basedOn, basis, sameWeekday, recordedHours: observed.recordedHours, timeZone: tz, retrievedAt: finished[finished.length - 1] || iso(now), freshUntil: iso(next.getTime()), expiresAt: iso(Date.parse(`${addDays(date, settings.retentionDays + 1)}T00:00:00Z`)), coverage, coverageNote: coverageNoteFor(observed, date, tz, coverage), flights, }, }; } // ── Coverage ────────────────────────────────────────────────────────────────────────────────────────────────────── // Which airports the recordings cover, from the summary written with each day: cheap to ask for. Every airport with // a recorded movement can be served (see observedDayFor), so these are the airports getDay answers for. Counts are // movements on the latest recorded UTC day. export function coverageSummary({ minFlights = adsbSettings().coverageMin, file } = {}) { const db = openForReading(file || adsbSettings().db); if (!db) return null; const days = db.prepare("SELECT day, finished_at FROM ingested ORDER BY day DESC").all(); if (!days.length) return null; const latestDay = days[0].day; const count = db.prepare( "SELECT COUNT(*) AS n FROM airport_days d JOIN airports a ON a.icao = d.airport WHERE d.day = ? AND d.arrivals + d.departures >= ?", ); const top = db .prepare( `SELECT d.airport AS icao, a.iata, a.name, a.city, a.country, d.arrivals + d.departures AS flights FROM airport_days d JOIN airports a ON a.icao = d.airport WHERE d.day = ? ORDER BY flights DESC, d.airport LIMIT 50`, ) .all(latestDay); return { updatedAt: days.map((d) => d.finished_at).sort().pop(), latestDay, countedOver: "UTC day", daysKept: days.length, days: days.map((d) => d.day), minFlights, airports: count.get(latestDay, minFlights).n, airportsWithAny: count.get(latestDay, 1).n, airportsServable: db.prepare("SELECT COUNT(DISTINCT d.airport) AS n FROM airport_days d JOIN airports a ON a.icao = d.airport").get().n, source: SOURCE, attribution: ATTRIBUTION, licence: LICENCE, top, }; } // One airport's recorded days, counted over its own local days (UTC days when its zone is unknown), with how much of // each was recorded, and the day the schedule for `date` (default: today at the airport) is based on. export function airportCoverage(icao, { now = Date.now(), date = null, file } = {}) { const db = openForReading(file || adsbSettings().db); if (!db) return null; const airport = db.prepare("SELECT icao, iata, name, city, country, tz FROM airports WHERE icao = ?").get(icao); if (!airport) return null; const tz = airport.tz || ""; const ingested = new Set(db.prepare("SELECT day FROM ingested").all().map((r) => r.day)); const sorted = [...ingested].sort(); const days = []; if (sorted.length) { for (let o = addDays(sorted[sorted.length - 1], 1); o >= addDays(sorted[0], -1); o = addDays(o, -1)) { const window = localDayWindow(o, tz); const r = recorded(window, ingested); if (!r.hours) continue; const c = countIn(db, icao, window); if (!c.flights) continue; days.push({ date: o, ...c, recordedHours: Math.round(r.hours * 10) / 10, complete: r.complete }); } } const utcDays = db .prepare("SELECT day, arrivals + departures AS flights FROM airport_days WHERE airport = ? ORDER BY day DESC") .all(icao) .map((r) => ({ date: r.day, flights: r.flights })); const today = date || wallClock(now, tz).date; const observed = observedDayFor(db, airport, today); return { icao, iata: airport.iata, name: airport.name, city: airport.city, country: airport.country, timeZone: tz, countedOver: tz ? "local day" : "UTC day", days, utcDays, date: today, basedOnForToday: observed ? observed.basedOn : null, basisForToday: observed ? observed.basis : null, source: SOURCE, attribution: ATTRIBUTION, licence: LICENCE, }; }