diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..35db1b1bb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -204,7 +204,10 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft +`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite +`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a +direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 153e36e03..7c988c5ea 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -19,6 +19,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const ICAO24_SCHEMA = Object.freeze({ type: 'string', @@ -75,7 +76,7 @@ function aircraftRow(record, from) { altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), on_ground: record.onGround === true, speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), vertical_rate_mps: round(record.verticalRateMps, 1), type_code: record.typeCode || null, registration: record.registration || null, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 484719790..59a928672 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async () ); }); +test('an aircraft course that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed([ + record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }), + ]), + }, + }); + const result = await catalog.call('find_aircraft', { callsign: 'N360' }); + assert.equal(result.data.rows[0].course_deg, 0); +}); + test('aircraft are found by exactly one identifier across feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5798a0936..0e9d80373 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ @@ -57,8 +58,8 @@ function vesselRow(record, from) { lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), - heading_deg: round(record.headingDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), + heading_deg: roundHeadingDegrees(record.headingDeg), observed_at: isoTime(record.observedAtMs), ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d86bdf352..f89e6bce3 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () => ); }); +test('a vessel course or heading that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000003', 37.78, -122.4, { + courseDeg: 359.7, + headingDeg: 359.6, + }), + ], + }), + }, + }); + const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data + .rows; + assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]); +}); + test('a destination with no letters counts as unknown', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a03e88bb4..d154619db 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -10,6 +10,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_FRAME_BYTES = 3 * 1024 * 1024; @@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({ provider: text(camera.provider), lat: round(camera.lat, 6), lon: round(camera.lon, 6), - heading_deg: round(camera.headingDeg, 0), + heading_deg: roundHeadingDegrees(camera.headingDeg), feed_type: text(camera.feedType), credit: text(camera.credit), distance_km: round(distanceKm(center, camera), 2), diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index 28c037a57..f690966c5 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => { assert.equal(result.data.rows[1].credit, null); }); +test('a camera heading that rounds to 360 reads as 0', async () => { + const source = { + async getCatalog() { + return { sources: [{ ...cameras[0], headingDeg: 359.6 }] }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const result = await catalog.call('find_cctv_cameras', { area: downtown }); + assert.equal(result.data.rows[0].heading_deg, 0); +}); + test('a snapshot returns the frame as an image with its credit', async () => { const source = cctv(); const catalog = composeCatalog({ diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0a57ae3ae..c31f03863 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -19,6 +19,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const MAX_SYSTEMS = 3; @@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({ trip_id: vehicle.tripId ?? null, lat: vehicle.lat, lon: vehicle.lon, - bearing_deg: round(vehicle.bearing, 0), + bearing_deg: roundHeadingDegrees(vehicle.bearing), speed_mps: round(vehicle.speedMps, 1), status: vehicle.status ?? null, occupancy: vehicle.occupancy ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 0ea882727..aaaa97bad 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.equal(rapid.data.rows[0].id, 'v2'); }); +test('a transit bearing that rounds to 360 reads as 0', async () => { + const north = [{ ...vehicles[0], bearing: 359.6 }]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + transit: transit({ + ok: true, + status: 200, + json: async () => ({ vehicles: north }), + }), + clock, + }, + }); + const result = await catalog.call('get_transit_vehicles', { area: downtown }); + assert.equal(result.data.rows[0].bearing_deg, 0); +}); + test('transit reports uncovered areas and unavailable feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ef59c0be5..a589e801c 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({ peak: isoTime(pass.maxElevMs), set: isoTime(pass.setMs), max_elevation_deg: round(pass.maxElevDeg, 1), - rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg), rise_direction: compass(pass.riseAzDeg), visible: pass.visible, }, @@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({ name: entry.name, norad: entry.norad, elevation_deg: round(look.elevDeg, 1), - azimuth_deg: round(look.azDeg, 0), + azimuth_deg: roundHeadingDegrees(look.azDeg), direction: compass(look.azDeg), }, ] diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0eb5ce346..0e44fe208 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -296,6 +296,24 @@ test('satellites overhead are those above the elevation, highest first', async ( ); }); +test('satellite azimuths that round to 360 read as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + const pass = await catalog.call('next_satellite_pass', { + location: { lat: 10, lon: 30 }, + }); + assert.equal(pass.data.pass.rise_azimuth_deg, 0); + assert.equal(pass.data.pass.rise_direction, 'N'); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 39.25, lon: 65.75 }, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.equal(overhead.data.rows[0].azimuth_deg, 0); +}); + test('stale orbit and launch data are reported', async () => { const stale = { readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),