From 9be90fff448fb9990e9270f9f0bc37c195b14d76 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:25:00 -0700 Subject: [PATCH] feat: add a recent satellite imagery tool Add get_recent_imagery. It searches the recent Harmonized Landsat and Sentinel-2 catalog for an area, picks the image the layer would show with the layer's own ranking, and returns it with its date, sensor and cloud cover, falling back to the VIIRS daily overview. --- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 7 +- src/tools/index.js | 2 + src/tools/queries/imagery.js | 122 +++++++++++++++++++++++++++++ src/tools/queries/imagery.test.mjs | 91 +++++++++++++++++++++ src/tools/services.js | 26 ++++++ 8 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/imagery.js create mode 100644 src/tools/queries/imagery.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 483b5919d..a7a0cb788 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -14,7 +14,8 @@ by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images -(radar, satellite, lightning), wind, regional briefs, tropical cyclones, fire +(radar, satellite, lightning), wind, the most recent satellite image of an +area, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, 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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0e195db2a..e29195ca2 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -129,6 +129,7 @@ public feeds the sources already use. | `get_weather` | `weather` | Current conditions at a place or point | | `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area | | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | +| `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3b32173a3..91e86a958 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -880,5 +880,6 @@ "src/tools/queries/maritime.test.mjs", "src/tools/queries/share.test.mjs", "src/tools/localRoute.test.mjs", - "src/tools/queries/atmosphere.test.mjs" + "src/tools/queries/atmosphere.test.mjs", + "src/tools/queries/imagery.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ffa6795ba..f37849011 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3110,7 +3110,8 @@ "src/tools/queries/atmosphere.js", "src/layers/wind/inspection.js", "src/layers/wind/model.js", - "src/layers/wind/fields.js" + "src/layers/wind/fields.js", + "src/tools/queries/imagery.js" ], "external": ["satellite.js"] }, @@ -3178,7 +3179,9 @@ "src/tools/schema.js", "src/layers/weather/source.js", "src/layers/wind/source.js", - "src/sources/windBody.js" + "src/sources/windBody.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/model.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index 5c0d328ed..cf4e5d2a2 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -21,6 +21,7 @@ import { getWeather, } from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { getRecentImagery } from './queries/imagery.js'; import { findVessel, getVesselTrack, @@ -91,6 +92,7 @@ export const coreTools = Object.freeze([ getWeather, getWeatherMap, getWind, + getRecentImagery, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/imagery.js b/src/tools/queries/imagery.js new file mode 100644 index 000000000..5f412c5e2 --- /dev/null +++ b/src/tools/queries/imagery.js @@ -0,0 +1,122 @@ +/** The most recent satellite image of an area. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaRadiusKm, resolveArea } from '../area.js'; +import { toBase64 } from '../results.js'; + +const MAX_SIDE_KM = 1000; +const MAX_LATITUDE = 85; +const IMAGE_WIDTH = 1024; +const MAX_IMAGE_BYTES = 6 * 1024 * 1024; +const SENSORS = { + S30: 'Sentinel-2 (30 m)', + L30: 'Landsat 8/9 (30 m)', + VIIRS: 'VIIRS daily overview (250 m)', +}; + +export const getRecentImagery = defineTool({ + name: 'get_recent_imagery', + title: 'Recent satellite image', + description: + 'The most recent clear satellite image of an area from NASA Harmonized ' + + 'Landsat and Sentinel-2 (30 m), falling back to the VIIRS daily ' + + `overview. Areas up to ${MAX_SIDE_KM} km across. Returns the image itself.`, + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['imagery'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if (area.west > area.east) + throw new ToolError( + 'invalid_arguments', + 'The area must not cross the antimeridian', + ); + if ( + Math.abs(area.south) > MAX_LATITUDE || + Math.abs(area.north) > MAX_LATITUDE + ) + throw new ToolError( + 'invalid_arguments', + `Imagery stops at ±${MAX_LATITUDE}° latitude`, + ); + if (areaRadiusKm(area) * 2 > MAX_SIDE_KM * Math.SQRT2) + throw new ToolError( + 'invalid_arguments', + `The area is too large for imagery; use one up to ${MAX_SIDE_KM} km across`, + ); + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + let latest; + try { + latest = await services.imagery.latest({ box, signal }); + } catch (error) { + if (error instanceof TypeError) + throw new ToolError('invalid_arguments', error.message); + throw error; + } + const { candidate, reason } = latest; + if (!candidate) + return { + summary: `No recent satellite image covers ${area.label}.`, + data: { area: area.label, image: null }, + }; + const height = Math.max( + 64, + Math.min( + 1024, + Math.round( + (IMAGE_WIDTH * (box.north - box.south)) / (box.east - box.west), + ), + ), + ); + const image = await services.imagery.getSnapshot({ + product: candidate.product, + day: candidate.day, + box, + width: IMAGE_WIDTH, + height, + signal, + }); + if (!/^image\/(png|jpeg)$/.test(image.contentType)) + throw new ToolError( + 'unavailable', + 'The imagery service returned no image', + ); + if (image.bytes.byteLength > MAX_IMAGE_BYTES) + throw new ToolError('unavailable', 'The image is too large to return'); + const sensor = SENSORS[candidate.product] || candidate.product; + const cloud = candidate.cloud + ? `${Math.round(candidate.cloud.min)}–${Math.round(candidate.cloud.max)}% cloud` + : 'cloud cover unknown'; + const quality = { + clear: 'clear', + cloudy: 'cloudy', + partial: 'partly covering the area', + overview: 'coarse overview', + }[reason]; + return { + summary: `Most recent image of ${area.label}: ${sensor}, ${candidate.day}, ${quality}${reason === 'overview' ? '' : `, ${cloud}`}.`, + data: { + area: area.label, + image: { + product: candidate.product, + sensor, + day: candidate.day, + quality: reason, + cloud_pct: candidate.cloud ?? null, + coverage: candidate.coverage ?? null, + }, + search_errors: latest.errors?.map((error) => error.product) ?? [], + }, + images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }], + }; + }, +}); diff --git a/src/tools/queries/imagery.test.mjs b/src/tools/queries/imagery.test.mjs new file mode 100644 index 000000000..75ef94312 --- /dev/null +++ b/src/tools/queries/imagery.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); +const imagery = (latest) => ({ + snapshots: [], + boxes: [], + async latest({ box }) { + this.boxes.push(box); + return latest; + }, + async getSnapshot(request) { + this.snapshots.push(request); + return { contentType: 'image/png', bytes: PNG }; + }, +}); +const austin = { bbox: [-97.9, 30.1, -97.5, 30.5] }; + +test('the most recent clear image is returned with its date and sensor', async () => { + const service = imagery({ + candidate: { + product: 'S30', + day: '2026-09-28', + cloud: { min: 2, max: 6 }, + coverage: 'full', + }, + reason: 'clear', + certain: true, + errors: [], + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { imagery: service }, + }); + const result = await catalog.call('get_recent_imagery', { area: austin }); + assert.equal( + result.summary, + 'Most recent image of the requested box: Sentinel-2 (30 m), 2026-09-28, clear, 2–6% cloud.', + ); + assert.deepEqual(result.images, [ + { mimeType: 'image/png', data: 'iVBORw==' }, + ]); + assert.deepEqual(service.snapshots[0], { + product: 'S30', + day: '2026-09-28', + box: { west: -97.9, south: 30.1, east: -97.5, north: 30.5 }, + width: 1024, + height: 1024, + signal: undefined, + }); +}); + +test('overview fallback, empty results and invalid areas are reported', async () => { + const overview = composeCatalog({ + tools: coreTools, + services: { + imagery: imagery({ + candidate: { product: 'VIIRS', day: '2026-10-01', cloud: null }, + reason: 'overview', + errors: [{ product: 'S30' }], + }), + }, + }); + const coarse = await overview.call('get_recent_imagery', { area: austin }); + assert.equal( + coarse.summary, + 'Most recent image of the requested box: VIIRS daily overview (250 m), 2026-10-01, coarse overview.', + ); + assert.deepEqual(coarse.data.search_errors, ['S30']); + const none = composeCatalog({ + tools: coreTools, + services: { imagery: imagery({ candidate: null, reason: null }) }, + }); + assert.equal( + (await none.call('get_recent_imagery', { area: austin })).summary, + 'No recent satellite image covers the requested box.', + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [-120, 20, -60, 50] } }), + /too large for imagery/, + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [170, 0, -170, 5] } }), + /antimeridian/, + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [0, 85.5, 1, 86] } }), + /±85° latitude/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 7798d8d88..18f90c4b3 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -16,6 +16,8 @@ import { createInstallationSource } from '../layers/installations/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createRadioSource } from '../layers/radio/source.js'; +import { searchHls } from '../layers/recentImagery/catalog.js'; +import { rankLatest, wvsSnapshotUrl } from '../layers/recentImagery/model.js'; import { createSatelliteSource } from '../layers/satellites/source.js'; import { createTrafficSource } from '../layers/traffic/source.js'; import { createTransitSource } from '../layers/transit/source.js'; @@ -63,6 +65,7 @@ export function createToolServices({ fetchImpl, appUrl }) { weather: requests.weather, weatherMaps: createWeatherSource({ fetchImpl }), wind: createWindSource({ fetchImpl }), + imagery: createImageryService({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, @@ -76,3 +79,26 @@ export function createToolServices({ fetchImpl, appUrl }) { places: createGeocodePlaceService({ fetchImpl }), }; } + +/** Recent keyless satellite imagery: the clearest recent day and its image. */ +function createImageryService({ fetchImpl }) { + return { + async latest({ box, signal }) { + const result = await searchHls({ box, fetchImpl, signal }); + return { + ...rankLatest(result.candidates, { truncated: result.truncated }), + errors: result.errors, + }; + }, + async getSnapshot({ product, day, box, width, height, signal }) { + const response = await fetchImpl( + wvsSnapshotUrl({ product, day, box, width, height }), + { signal }, + ); + if (!response.ok) throw new Error(`Imagery HTTP ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + const type = response.headers.get('content-type') || ''; + return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + }, + }; +}