diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md new file mode 100644 index 000000000..a6b69472d --- /dev/null +++ b/changelog.d/1115-aircraft-altitude-band-unknown.md @@ -0,0 +1,2 @@ +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df92c0d50..1bdca7670 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Aircraft altitude-band queries require a reported barometric or geometric altitude +for each match, so a grounded contact that reports none is left out of a band too. +Queries without an altitude band retain unknown-altitude contacts. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..235f542e1 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({ (record) => areaContains(area, { lat: record.latitude, lon: record.longitude }) && !(args.airborne_only && record.onGround) && + ((args.min_altitude_m == null && args.max_altitude_m == null) || + Number.isFinite(altitude(record))) && (args.min_altitude_m == null || altitude(record) >= args.min_altitude_m) && (args.max_altitude_m == null || diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..d0e979ed6 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('altitude bands require a reported altitude from normalized OpenSky records', async () => { + const { normalizeOpenSkyAircraft } = + await import('../../sources/live/aircraft.js'); + const state = (id, baro, geometric) => + normalizeOpenSkyAircraft([ + id, + 'TEST', + 'Test', + 1767225600, + 1767225600, + 0, + 0, + baro, + false, + 10, + 90, + 0, + [], + geometric, + ]); + const records = [ + state('abc001', null, null), + state('abc002', 0, null), + state('abc003', null, 50), + state('abc004', 500, null), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: { getSnapshot: async () => ({ records }) }, + }, + }); + const area = { bbox: [-1, -1, 1, 1] }; + assert.equal( + (await catalog.call('aircraft_in_area', { area })).data.total, + 4, + 'unfiltered answers retain unknown altitude', + ); + for (const band of [ + { max_altitude_m: 100 }, + { min_altitude_m: -500, max_altitude_m: 100 }, + ]) { + const result = await catalog.call('aircraft_in_area', { area, ...band }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['abc002', 'abc003'], + ); + assert.deepEqual( + result.data.rows.map((row) => row.altitude_m), + [0, 50], + ); + } + const minimum = await catalog.call('aircraft_in_area', { + area, + min_altitude_m: -500, + }); + assert.equal(minimum.data.total, 3); +});