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.
This commit is contained in:
Sameh Khamis committed 2026-10-01 13:25:00 -07:00
1 parent a3aa058569
commit 9be90fff44
8 files changed
+251 -4

No files matched your search

+2
View File
@@ -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,
+122
View File
@@ -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) }],
};
},
});
+91
View File
@@ -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/,
);
});
+26
View File
@@ -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 };
},
};
}