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 }; + }, + }; +}