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 -1
View File
@@ -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
+1
View File
@@ -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 |
+2 -1
View File
@@ -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"
]
+5 -2
View File
@@ -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"]
}
+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 };
},
};
}