mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
@@ -14,7 +14,8 @@ by identifier, and tracks), satellites (next
|
|||||||
pass over a point, and those overhead now), public cameras (including a
|
pass over a point, and those overhead now), public cameras (including a
|
||||||
camera's current image), radio stations, place search, routing, bike-share
|
camera's current image), radio stations, place search, routing, bike-share
|
||||||
stations, transit vehicles, road traffic flow, weather, weather map images
|
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
|
perimeters, terrain height, military installations and map features, plus a
|
||||||
combined situation brief, the app's heads-up display caption, and a link that
|
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
|
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ public feeds the sources already use.
|
|||||||
| `get_weather` | `weather` | Current conditions at a place or point |
|
| `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_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_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_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_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area |
|
||||||
| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first |
|
| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first |
|
||||||
|
|||||||
@@ -880,5 +880,6 @@
|
|||||||
"src/tools/queries/maritime.test.mjs",
|
"src/tools/queries/maritime.test.mjs",
|
||||||
"src/tools/queries/share.test.mjs",
|
"src/tools/queries/share.test.mjs",
|
||||||
"src/tools/localRoute.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"
|
||||||
]
|
]
|
||||||
@@ -3110,7 +3110,8 @@
|
|||||||
"src/tools/queries/atmosphere.js",
|
"src/tools/queries/atmosphere.js",
|
||||||
"src/layers/wind/inspection.js",
|
"src/layers/wind/inspection.js",
|
||||||
"src/layers/wind/model.js",
|
"src/layers/wind/model.js",
|
||||||
"src/layers/wind/fields.js"
|
"src/layers/wind/fields.js",
|
||||||
|
"src/tools/queries/imagery.js"
|
||||||
],
|
],
|
||||||
"external": ["satellite.js"]
|
"external": ["satellite.js"]
|
||||||
},
|
},
|
||||||
@@ -3178,7 +3179,9 @@
|
|||||||
"src/tools/schema.js",
|
"src/tools/schema.js",
|
||||||
"src/layers/weather/source.js",
|
"src/layers/weather/source.js",
|
||||||
"src/layers/wind/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"]
|
"external": ["@mapbox/vector-tile", "pbf"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getWeather,
|
getWeather,
|
||||||
} from './queries/environment.js';
|
} from './queries/environment.js';
|
||||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||||
|
import { getRecentImagery } from './queries/imagery.js';
|
||||||
import {
|
import {
|
||||||
findVessel,
|
findVessel,
|
||||||
getVesselTrack,
|
getVesselTrack,
|
||||||
@@ -91,6 +92,7 @@ export const coreTools = Object.freeze([
|
|||||||
getWeather,
|
getWeather,
|
||||||
getWeatherMap,
|
getWeatherMap,
|
||||||
getWind,
|
getWind,
|
||||||
|
getRecentImagery,
|
||||||
getRegionalBrief,
|
getRegionalBrief,
|
||||||
getCyclones,
|
getCyclones,
|
||||||
getFirePerimeters,
|
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 { createLaunchSource } from '../layers/launches/source.js';
|
||||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||||
import { createRadioSource } from '../layers/radio/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 { createSatelliteSource } from '../layers/satellites/source.js';
|
||||||
import { createTrafficSource } from '../layers/traffic/source.js';
|
import { createTrafficSource } from '../layers/traffic/source.js';
|
||||||
import { createTransitSource } from '../layers/transit/source.js';
|
import { createTransitSource } from '../layers/transit/source.js';
|
||||||
@@ -63,6 +65,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
|
|||||||
weather: requests.weather,
|
weather: requests.weather,
|
||||||
weatherMaps: createWeatherSource({ fetchImpl }),
|
weatherMaps: createWeatherSource({ fetchImpl }),
|
||||||
wind: createWindSource({ fetchImpl }),
|
wind: createWindSource({ fetchImpl }),
|
||||||
|
imagery: createImageryService({ fetchImpl }),
|
||||||
regional: requests.regional,
|
regional: requests.regional,
|
||||||
terrain: requests.terrain,
|
terrain: requests.terrain,
|
||||||
summary: requests.summary,
|
summary: requests.summary,
|
||||||
@@ -76,3 +79,26 @@ export function createToolServices({ fetchImpl, appUrl }) {
|
|||||||
places: createGeocodePlaceService({ fetchImpl }),
|
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