From ae55963a0e5638b0aa2e2a90d7a66d8f10a40597 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:10:06 -0700 Subject: [PATCH 1/5] fix(tools): wrap whole-degree headings at 360 to 0 The query tools rounded courses, headings, bearings and azimuths with Number(value.toFixed(0)) and no wrap, so a direction of 359.5 degrees or more came back as 360: find_vessel with course 359.7 and heading 359.6 returned course_deg 360 and heading_deg 360. Round them through the shared roundHeadingDegrees helper the layers and UI already use (src/data/headingDegrees.js), which wraps into 0 to 359: course_deg and heading_deg (vessels), course_deg (aircraft), bearing_deg (transit), heading_deg (cameras), and rise_azimuth_deg and azimuth_deg (satellites). Other rounding is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++++- src/tools/queries/aviation.js | 3 ++- src/tools/queries/aviation.test.mjs | 13 +++++++++++++ src/tools/queries/maritime.js | 5 +++-- src/tools/queries/maritime.test.mjs | 19 +++++++++++++++++++ src/tools/queries/media.js | 3 ++- src/tools/queries/media.test.mjs | 14 ++++++++++++++ src/tools/queries/mobility.js | 3 ++- src/tools/queries/mobility.test.mjs | 17 +++++++++++++++++ src/tools/queries/space.js | 5 +++-- src/tools/queries/space.test.mjs | 18 ++++++++++++++++++ 11 files changed, 97 insertions(+), 8 deletions(-) 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 }), From d1ed2e8e6f14d0c6e23c4b9f51949621d498dad6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:49:55 -0800 Subject: [PATCH 2/5] changelog: tool headings wrap at 360 (#1524) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1524-tools-heading-wrap.md diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md new file mode 100644 index 000000000..3aefce6c5 --- /dev/null +++ b/changelog.d/1524-tools-heading-wrap.md @@ -0,0 +1,4 @@ +- The agent and voice query tools report a direction of 359.5° or more as 0 + instead of 360: vessel course and heading, aircraft course, transit bearing, + camera heading and satellite azimuths now round through the shared heading + helper the layers use (@quanticsoul4772, #1524). From ccd83c5fa2ffdd9ad4f0564c4ec95976330d6109 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:27 -0800 Subject: [PATCH 3/5] docs(tools): say heading values wrap into 0-359, not that 359.5 and up read 0 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md index 3aefce6c5..2a630f600 100644 --- a/changelog.d/1524-tools-heading-wrap.md +++ b/changelog.d/1524-tools-heading-wrap.md @@ -1,4 +1,4 @@ -- The agent and voice query tools report a direction of 359.5° or more as 0 - instead of 360: vessel course and heading, aircraft course, transit bearing, - camera heading and satellite azimuths now round through the shared heading - helper the layers use (@quanticsoul4772, #1524). +- The agent and voice query tools report a direction that rounds to 360 as 0: + vessel course and heading, aircraft course, transit bearing, camera heading + and satellite azimuths now round through the shared heading helper the + layers use, which wraps into 0–359 (@quanticsoul4772, #1524). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35db1b1bb..ecbdf5e0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -207,7 +207,7 @@ shared `area` argument (place name, bounding box, or point and radius) and cap 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 +direction that rounds to 360 reads 0, and larger or negative values wrap. `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; From df87419e64815164062637b00769c502999a3bb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:42 -0800 Subject: [PATCH 4/5] test(tools): pin the raw near-360 satellite azimuths and list the tools with the heading helper Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 9 ++++----- src/tools/queries/environment.js | 1 + src/tools/queries/space.test.mjs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e546fd563..d03064e8f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -246,10 +246,9 @@ 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. 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 that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Their whole-degree directions round through +`roundHeadingDegrees`, listed with the heading readouts below. +`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; @@ -6091,7 +6090,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 08db732fc..0af696e36 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,6 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, + // Raw Open-Meteo value, like the fields above; it reports north as 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0e44fe208..41394e305 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; import { noradIdFromSatnum, parseTleText, @@ -301,7 +305,20 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + // These points are chosen so the ISS's raw azimuths fall just below 360°; + // pin that first, so the test cannot pass on a raw value already near 0. + const [, line1, line2] = TLE.split('\n'); + const iss = twoline2satrec(line1, line2); + const nearNorth = (deg) => deg > 359.5 && deg < 360; + const rawPass = findNextSatellitePass({ + satrec: iss, + latDeg: 10, + lonDeg: 30, + fromMs: CLOCK.now(), + }); + assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`); + const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75); + assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`); const pass = await catalog.call('next_satellite_pass', { location: { lat: 10, lon: 30 }, }); From da6b63b6fdd95df533d7fbfce2ae0a3b65b1d21a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 16:45:11 +0400 Subject: [PATCH 5/5] docs(tools): tighten the heading-wrap changelog, comments and overview --- changelog.d/1524-tools-heading-wrap.md | 7 +++---- docs/CURRENT-STATE.md | 4 +--- src/tools/queries/environment.js | 2 +- src/tools/queries/space.test.mjs | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md index 2a630f600..3647bf9a8 100644 --- a/changelog.d/1524-tools-heading-wrap.md +++ b/changelog.d/1524-tools-heading-wrap.md @@ -1,4 +1,3 @@ -- The agent and voice query tools report a direction that rounds to 360 as 0: - vessel course and heading, aircraft course, transit bearing, camera heading - and satellite azimuths now round through the shared heading helper the - layers use, which wraps into 0–359 (@quanticsoul4772, #1524). +- The agent and voice query tools wrap whole-degree directions into 0–359, so + a vessel course or heading, aircraft course, transit bearing, camera heading + or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57daf7849..0ffb49ea8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -398,9 +398,7 @@ 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. Their whole-degree directions round through -`roundHeadingDegrees`, listed with the heading readouts below. -`aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. `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/environment.js b/src/tools/queries/environment.js index 0af696e36..1f5800d53 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,7 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, - // Raw Open-Meteo value, like the fields above; it reports north as 360. + // Raw Open-Meteo value, so north reads 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 41394e305..cb12dfcbe 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -305,8 +305,8 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // These points are chosen so the ISS's raw azimuths fall just below 360°; - // pin that first, so the test cannot pass on a raw value already near 0. + // At these points the ISS's raw azimuths fall just below 360°; check that + // first so the test cannot pass on a raw value near 0. const [, line1, line2] = TLE.split('\n'); const iss = twoline2satrec(line1, line2); const nearNorth = (deg) => deg > 359.5 && deg < 360;