@pipeworx/mcp-bts-ontime 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts DELETED
@@ -1,360 +0,0 @@
1
- /**
2
- * Reads for bts-ontime (fleet #2953).
3
- *
4
- * Data: US DOT Bureau of Transportation Statistics (BTS) TranStats,
5
- * "Reporting Carrier On-Time Performance (1987-present)" monthly bulk CSVs
6
- * (https://transtats.bts.gov/PREZIP/). US federal government work -- public
7
- * domain, 17 U.S.C. §105. Loaded into a D1 database the gateway hands this
8
- * pack as `_d1` (pack-manifest "d1Binding": "BTS_ONTIME_DB") by
9
- * scripts/bts-ontime/load-data.mjs -- never by a deploy.
10
- *
11
- * Two tables, both derived from the SAME monthly raw file:
12
- * - route_month: carrier x origin x dest x month.
13
- * - delay_cause: carrier x (arrival) airport x month -- delay-minute sums
14
- * by cause, rolled up by arrival airport instead of by route. This is
15
- * NOT the separately-published BTS "Airline Delay Cause" product (which
16
- * apportions delay into fractional per-cause counts); it is the same
17
- * underlying CarrierDelay/WeatherDelay/NASDelay/SecurityDelay/
18
- * LateAircraftDelay minute fields, summed. See README.md.
19
- *
20
- * SCOPE, found 2026-10-13: loaded the most recent N months as of the run
21
- * (see bts_ontime_meta for the exact range) rather than back to 2003 -- BTS's
22
- * per-connection throttling (measured: one download ~170s regardless of file
23
- * size vs wall time, 3 concurrent downloads of the same upstream in the same
24
- * wall time as 1 serial one) makes a multi-decade backfill a multi-hour job
25
- * better run as a scheduled extension than inline in one pack-build session.
26
- * Re-run the loader with a larger --months to extend further back -- see
27
- * README.md "Refresh".
28
- */
29
-
30
- export interface D1Like {
31
- prepare(sql: string): D1StmtLike;
32
- }
33
- export interface D1StmtLike {
34
- bind(...values: unknown[]): D1StmtLike;
35
- all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
36
- first<T = Record<string, unknown>>(): Promise<T | null>;
37
- }
38
-
39
- export class StoreUnavailableError extends Error {
40
- constructor() {
41
- super(
42
- 'bts-ontime: the on-time performance store is not bound in this runtime (no _d1). These tools only answer through the Pipeworx gateway; a standalone install has no data to read.',
43
- );
44
- this.name = 'StoreUnavailableError';
45
- }
46
- }
47
- export function requireDb(d1: unknown): D1Like {
48
- if (!d1 || typeof (d1 as D1Like).prepare !== 'function') throw new StoreUnavailableError();
49
- return d1 as D1Like;
50
- }
51
-
52
- export const SOURCE = {
53
- source: 'US DOT Bureau of Transportation Statistics (BTS), TranStats "Reporting Carrier On-Time Performance (1987-present)"',
54
- source_url: 'https://transtats.bts.gov/PREZIP/',
55
- license: 'US federal government work -- public domain (17 U.S.C. §105)',
56
- };
57
-
58
- // ── carrier code resolution ─────────────────────────────────────────────
59
- // Reporting_Airline in the raw file is already a short code (e.g. "DL",
60
- // "AA", "WN"); this just lets a caller say a common name instead. Codes NOT
61
- // in this alias table still work fine as long as they're typed as the code
62
- // itself -- validity is checked against what the DB actually holds
63
- // (validCarrier below), not against this list.
64
- const CARRIER_ALIASES: Record<string, string> = {
65
- AA: 'AA', AMERICAN: 'AA', 'AMERICAN AIRLINES': 'AA',
66
- DL: 'DL', DELTA: 'DL', 'DELTA AIR LINES': 'DL',
67
- UA: 'UA', UNITED: 'UA', 'UNITED AIRLINES': 'UA',
68
- WN: 'WN', SOUTHWEST: 'WN', 'SOUTHWEST AIRLINES': 'WN',
69
- B6: 'B6', JETBLUE: 'B6', 'JETBLUE AIRWAYS': 'B6',
70
- AS: 'AS', ALASKA: 'AS', 'ALASKA AIRLINES': 'AS',
71
- NK: 'NK', SPIRIT: 'NK', 'SPIRIT AIRLINES': 'NK',
72
- F9: 'F9', FRONTIER: 'F9', 'FRONTIER AIRLINES': 'F9',
73
- G4: 'G4', ALLEGIANT: 'G4', 'ALLEGIANT AIR': 'G4',
74
- HA: 'HA', HAWAIIAN: 'HA', 'HAWAIIAN AIRLINES': 'HA',
75
- OO: 'OO', SKYWEST: 'OO', 'SKYWEST AIRLINES': 'OO',
76
- MQ: 'MQ', ENVOY: 'MQ', 'ENVOY AIR': 'MQ',
77
- YX: 'YX', REPUBLIC: 'YX', 'REPUBLIC AIRWAYS': 'YX',
78
- OH: 'OH', PSA: 'OH', 'PSA AIRLINES': 'OH',
79
- '9E': '9E', ENDEAVOR: '9E', 'ENDEAVOR AIR': '9E',
80
- YV: 'YV', MESA: 'YV', 'MESA AIRLINES': 'YV',
81
- QX: 'QX', HORIZON: 'QX', 'HORIZON AIR': 'QX',
82
- ZW: 'ZW', 'AIR WISCONSIN': 'ZW',
83
- };
84
-
85
- /** Normalize caller input to a candidate carrier code -- does NOT guarantee
86
- * the code actually exists in this pack's data; call validCarrier() for that. */
87
- export function resolveCarrierCode(input: unknown): string | null {
88
- if (input == null) return null;
89
- const s = String(input).trim().toUpperCase();
90
- if (!s) return null;
91
- return CARRIER_ALIASES[s] ?? (/^[0-9A-Z]{2}$/.test(s) ? s : null);
92
- }
93
-
94
- export async function validCarrier(db: D1Like, code: string): Promise<boolean> {
95
- const r = await db.prepare('SELECT 1 AS x FROM route_month WHERE carrier = ? LIMIT 1').bind(code).first<{ x: number }>();
96
- return r != null;
97
- }
98
-
99
- export async function validAirport(db: D1Like, code: string): Promise<boolean> {
100
- const r = await db
101
- .prepare('SELECT 1 AS x FROM route_month WHERE origin = ? OR dest = ? LIMIT 1')
102
- .bind(code, code)
103
- .first<{ x: number }>();
104
- return r != null;
105
- }
106
-
107
- export function normAirport(input: unknown): string | null {
108
- if (input == null) return null;
109
- const s = String(input).trim().toUpperCase();
110
- return /^[A-Z]{3,4}$/.test(s) ? s : null;
111
- }
112
-
113
- export interface Meta {
114
- loaded_at: string;
115
- earliest_month: string; // "YYYY-MM"
116
- latest_month: string; // "YYYY-MM"
117
- months_loaded: number;
118
- }
119
- let cachedMeta: Meta | null = null;
120
- export async function getMeta(db: D1Like): Promise<Meta> {
121
- if (cachedMeta) return cachedMeta;
122
- const r = await db.prepare('SELECT key, value FROM bts_ontime_meta').all<{ key: string; value: string }>();
123
- const m = Object.fromEntries(r.results.map((row) => [row.key, row.value]));
124
- cachedMeta = {
125
- loaded_at: m.loaded_at ?? '',
126
- earliest_month: m.earliest_month ?? '',
127
- latest_month: m.latest_month ?? '',
128
- months_loaded: Number(m.months_loaded ?? 0),
129
- };
130
- return cachedMeta;
131
- }
132
-
133
- /** Row-count limit for the delay-cause listing tools: default 50, max 200,
134
- * so a default call stays well under the ~40KB response-size guidance. */
135
- export function clampLimit(requested: unknown, dflt = 50, max = 200): number {
136
- const n = Math.floor(Number(requested));
137
- if (!Number.isFinite(n) || n <= 0) return dflt;
138
- return Math.min(n, max);
139
- }
140
-
141
- /** How many of the most recent loaded months to look back, clamped to what
142
- * is actually loaded. */
143
- export function clampMonths(requested: unknown, monthsLoaded: number, dflt = 12): number {
144
- const n = Math.floor(Number(requested));
145
- if (!Number.isFinite(n) || n <= 0) return Math.min(dflt, monthsLoaded);
146
- return Math.min(n, monthsLoaded);
147
- }
148
-
149
- export interface RouteMonthRow {
150
- year: number;
151
- month: number;
152
- carrier: string;
153
- origin: string;
154
- dest: string;
155
- flights: number;
156
- completed_flights: number;
157
- ontime_arrivals: number;
158
- sum_arr_delay: number;
159
- cancelled: number;
160
- diverted: number;
161
- carrier_delay_min: number;
162
- weather_delay_min: number;
163
- nas_delay_min: number;
164
- security_delay_min: number;
165
- late_aircraft_delay_min: number;
166
- }
167
-
168
- function shapeRoute(r: RouteMonthRow) {
169
- return {
170
- year: r.year,
171
- month: r.month,
172
- carrier: r.carrier,
173
- origin: r.origin,
174
- dest: r.dest,
175
- flights: r.flights,
176
- completed_flights: r.completed_flights,
177
- cancelled: r.cancelled,
178
- diverted: r.diverted,
179
- ontime_arrivals: r.ontime_arrivals,
180
- ontime_pct: r.completed_flights > 0 ? round2((r.ontime_arrivals / r.completed_flights) * 100) : null,
181
- avg_arr_delay_min: r.completed_flights > 0 ? round2(r.sum_arr_delay / r.completed_flights) : null,
182
- delay_minutes_by_cause: {
183
- carrier: round2(r.carrier_delay_min),
184
- weather: round2(r.weather_delay_min),
185
- nas: round2(r.nas_delay_min),
186
- security: round2(r.security_delay_min),
187
- late_aircraft: round2(r.late_aircraft_delay_min),
188
- },
189
- };
190
- }
191
- function round2(n: number): number {
192
- return Math.round(n * 100) / 100;
193
- }
194
-
195
- /** Same shape as shapeRoute, but for a carrier-level aggregate that has no
196
- * single origin/dest (it is summed across every route that matched). */
197
- function shapeCarrierMonth(carrier: string, r: Omit<RouteMonthRow, 'carrier' | 'origin' | 'dest'>) {
198
- return {
199
- year: r.year,
200
- month: r.month,
201
- carrier,
202
- flights: r.flights,
203
- completed_flights: r.completed_flights,
204
- cancelled: r.cancelled,
205
- diverted: r.diverted,
206
- ontime_arrivals: r.ontime_arrivals,
207
- ontime_pct: r.completed_flights > 0 ? round2((r.ontime_arrivals / r.completed_flights) * 100) : null,
208
- avg_arr_delay_min: r.completed_flights > 0 ? round2(r.sum_arr_delay / r.completed_flights) : null,
209
- delay_minutes_by_cause: {
210
- carrier: round2(r.carrier_delay_min),
211
- weather: round2(r.weather_delay_min),
212
- nas: round2(r.nas_delay_min),
213
- security: round2(r.security_delay_min),
214
- late_aircraft: round2(r.late_aircraft_delay_min),
215
- },
216
- };
217
- }
218
-
219
- export async function routeMonths(db: D1Like, origin: string, dest: string, carrier: string | null, limitMonths: number) {
220
- const where = ['origin = ?', 'dest = ?'];
221
- const params: unknown[] = [origin, dest];
222
- if (carrier) {
223
- where.push('carrier = ?');
224
- params.push(carrier);
225
- }
226
- const sql = `SELECT year, month, carrier, origin, dest, flights, completed_flights, ontime_arrivals, sum_arr_delay, cancelled, diverted, carrier_delay_min, weather_delay_min, nas_delay_min, security_delay_min, late_aircraft_delay_min
227
- FROM route_month WHERE ${where.join(' AND ')}
228
- ORDER BY year DESC, month DESC, carrier
229
- LIMIT ?`;
230
- params.push(limitMonths * 20); // generous cap: several carriers per route per month
231
- const r = await db.prepare(sql).bind(...params).all<RouteMonthRow>();
232
- return r.results.map(shapeRoute);
233
- }
234
-
235
- export async function carrierMonths(db: D1Like, carrier: string, airport: string | null, limitMonths: number) {
236
- const where = ['carrier = ?'];
237
- const params: unknown[] = [carrier];
238
- if (airport) {
239
- where.push('(origin = ? OR dest = ?)');
240
- params.push(airport, airport);
241
- }
242
- const sql = `SELECT year, month,
243
- SUM(flights) AS flights, SUM(completed_flights) AS completed_flights, SUM(ontime_arrivals) AS ontime_arrivals,
244
- SUM(sum_arr_delay) AS sum_arr_delay, SUM(cancelled) AS cancelled, SUM(diverted) AS diverted,
245
- SUM(carrier_delay_min) AS carrier_delay_min, SUM(weather_delay_min) AS weather_delay_min,
246
- SUM(nas_delay_min) AS nas_delay_min, SUM(security_delay_min) AS security_delay_min, SUM(late_aircraft_delay_min) AS late_aircraft_delay_min
247
- FROM route_month WHERE ${where.join(' AND ')}
248
- GROUP BY year, month
249
- ORDER BY year DESC, month DESC
250
- LIMIT ?`;
251
- params.push(limitMonths);
252
- const r = await db.prepare(sql).bind(...params).all<Omit<RouteMonthRow, 'carrier' | 'origin' | 'dest'>>();
253
- return r.results.map((row) => shapeCarrierMonth(carrier, row));
254
- }
255
-
256
- export interface DelayCauseRow {
257
- year: number;
258
- month: number;
259
- carrier: string;
260
- airport: string;
261
- arr_flights: number;
262
- arr_del15: number;
263
- arr_cancelled: number;
264
- arr_diverted: number;
265
- carrier_delay_min: number;
266
- weather_delay_min: number;
267
- nas_delay_min: number;
268
- security_delay_min: number;
269
- late_aircraft_delay_min: number;
270
- }
271
- function shapeCause(r: DelayCauseRow) {
272
- return {
273
- year: r.year,
274
- month: r.month,
275
- carrier: r.carrier,
276
- airport: r.airport,
277
- arr_flights: r.arr_flights,
278
- arr_del15: r.arr_del15,
279
- arr_del15_pct: r.arr_flights > 0 ? round2((r.arr_del15 / r.arr_flights) * 100) : null,
280
- arr_cancelled: r.arr_cancelled,
281
- arr_diverted: r.arr_diverted,
282
- delay_minutes_by_cause: {
283
- carrier: round2(r.carrier_delay_min),
284
- weather: round2(r.weather_delay_min),
285
- nas: round2(r.nas_delay_min),
286
- security: round2(r.security_delay_min),
287
- late_aircraft: round2(r.late_aircraft_delay_min),
288
- },
289
- };
290
- }
291
-
292
- export interface PagedResult<T> {
293
- rows: T[];
294
- total_matching: number;
295
- truncated: boolean;
296
- }
297
-
298
- export async function delayCausesAtAirport(
299
- db: D1Like,
300
- airport: string,
301
- year: number | null,
302
- month: number | null,
303
- limit: number,
304
- offset: number,
305
- ): Promise<PagedResult<ReturnType<typeof shapeCause>>> {
306
- const where = ['airport = ?'];
307
- const params: unknown[] = [airport];
308
- if (year != null) {
309
- where.push('year = ?');
310
- params.push(year);
311
- }
312
- if (month != null) {
313
- where.push('month = ?');
314
- params.push(month);
315
- }
316
- const whereSql = where.join(' AND ');
317
- const sql = `SELECT year, month, carrier, airport, arr_flights, arr_del15, arr_cancelled, arr_diverted, carrier_delay_min, weather_delay_min, nas_delay_min, security_delay_min, late_aircraft_delay_min
318
- FROM delay_cause WHERE ${whereSql}
319
- ORDER BY year DESC, month DESC, arr_flights DESC
320
- LIMIT ? OFFSET ?`;
321
- const countSql = `SELECT COUNT(*) AS n FROM delay_cause WHERE ${whereSql}`;
322
- const [r, c] = await Promise.all([
323
- db.prepare(sql).bind(...params, limit, offset).all<DelayCauseRow>(),
324
- db.prepare(countSql).bind(...params).first<{ n: number }>(),
325
- ]);
326
- const total = c?.n ?? r.results.length;
327
- return { rows: r.results.map(shapeCause), total_matching: total, truncated: offset + r.results.length < total };
328
- }
329
-
330
- export async function delayCauses(
331
- db: D1Like,
332
- carrier: string | null,
333
- airport: string | null,
334
- year: number,
335
- limit: number,
336
- offset: number,
337
- ): Promise<PagedResult<ReturnType<typeof shapeCause>>> {
338
- const where = ['year = ?'];
339
- const params: unknown[] = [year];
340
- if (carrier) {
341
- where.push('carrier = ?');
342
- params.push(carrier);
343
- }
344
- if (airport) {
345
- where.push('airport = ?');
346
- params.push(airport);
347
- }
348
- const whereSql = where.join(' AND ');
349
- const sql = `SELECT year, month, carrier, airport, arr_flights, arr_del15, arr_cancelled, arr_diverted, carrier_delay_min, weather_delay_min, nas_delay_min, security_delay_min, late_aircraft_delay_min
350
- FROM delay_cause WHERE ${whereSql}
351
- ORDER BY month DESC, arr_flights DESC
352
- LIMIT ? OFFSET ?`;
353
- const countSql = `SELECT COUNT(*) AS n FROM delay_cause WHERE ${whereSql}`;
354
- const [r, c] = await Promise.all([
355
- db.prepare(sql).bind(...params, limit, offset).all<DelayCauseRow>(),
356
- db.prepare(countSql).bind(...params).first<{ n: number }>(),
357
- ]);
358
- const total = c?.n ?? r.results.length;
359
- return { rows: r.results.map(shapeCause), total_matching: total, truncated: offset + r.results.length < total };
360
- }
package/tsconfig.json DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "lib": ["ES2022"],
7
- "types": ["@cloudflare/workers-types"],
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "resolveJsonModule": true,
12
- "outDir": "dist",
13
- "rootDir": "src",
14
- "declaration": true
15
- },
16
- "include": ["src"],
17
- "exclude": ["src/server.ts"]
18
- }