mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
a3aa058569
commit
9be90fff44
8 files changed
+251
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user