mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add weather map and wind tools
Add get_weather_map, which returns the latest NOAA radar, satellite or lightning map over an area as an image, choosing a window the image route accepts and reporting areas outside the product's coverage, and get_wind, which samples GFS or IFS wind at a location with the wind layer's own grid sampling.
This commit is contained in:
1 parent
c43c94ba5f
commit
a3aa058569
10 files changed
+385
-14
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
getAircraftInfo,
|
||||
getAircraftTrack,
|
||||
} from './queries/aviation.js';
|
||||
import { getWeatherMap, getWind } from './queries/atmosphere.js';
|
||||
import { getHudCaption, situationBrief } from './queries/brief.js';
|
||||
import {
|
||||
findMilitaryInstallations,
|
||||
@@ -88,6 +89,8 @@ export const coreTools = Object.freeze([
|
||||
getTransitVehicles,
|
||||
getTrafficFlow,
|
||||
getWeather,
|
||||
getWeatherMap,
|
||||
getWind,
|
||||
getRegionalBrief,
|
||||
getCyclones,
|
||||
getFirePerimeters,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/** Weather map imagery (radar, satellite, lightning) and wind at a point. */
|
||||
|
||||
import { windFrom } from '../../layers/wind/inspection.js';
|
||||
import { sampleWind, windSpeed } from '../../layers/wind/model.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaRadiusKm,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { toBase64 } from '../results.js';
|
||||
|
||||
/** Map names offered to callers, and the weather products behind them. */
|
||||
const MAPS = {
|
||||
radar: 'radar',
|
||||
satellite: 'clouds-regional',
|
||||
global_satellite: 'clouds',
|
||||
lightning: 'lightning',
|
||||
};
|
||||
const IMAGE_SIZE = { width: 1024, height: 512 };
|
||||
const SNAP_DEGREES = 0.25;
|
||||
const MIN_HALF_HEIGHT_DEGREES = 0.5;
|
||||
const KM_PER_DEGREE = 111.2;
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
const snapDown = (value) => Math.floor(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const snapUp = (value) => Math.ceil(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/**
|
||||
* A 2:1 box snapped to 0.25° around the area, moved inside the product's
|
||||
* bounds. Returns null when the box would not fit, meaning the whole
|
||||
* extent should be shown instead.
|
||||
*/
|
||||
export function weatherWindow(area, bounds) {
|
||||
const center = areaCenter(area);
|
||||
const halfHeight = Math.max(
|
||||
MIN_HALF_HEIGHT_DEGREES,
|
||||
areaRadiusKm(area) / KM_PER_DEGREE,
|
||||
);
|
||||
const height = snapUp(halfHeight * 2);
|
||||
const width = height * 2;
|
||||
if (width > bounds.east - bounds.west || height > bounds.north - bounds.south)
|
||||
return null;
|
||||
let west = snapDown(center.lon - width / 2);
|
||||
let south = snapDown(center.lat - height / 2);
|
||||
west = Math.min(
|
||||
Math.max(west, snapUp(bounds.west)),
|
||||
snapDown(bounds.east - width),
|
||||
);
|
||||
south = Math.min(
|
||||
Math.max(south, snapUp(bounds.south)),
|
||||
snapDown(bounds.north - height),
|
||||
);
|
||||
const mapBox = { west, south, east: west + width, north: south + height };
|
||||
return mapBox.west >= bounds.west &&
|
||||
mapBox.east <= bounds.east &&
|
||||
mapBox.south >= bounds.south &&
|
||||
mapBox.north <= bounds.north
|
||||
? mapBox
|
||||
: null;
|
||||
}
|
||||
|
||||
export const getWeatherMap = defineTool({
|
||||
name: 'get_weather_map',
|
||||
title: 'Weather map',
|
||||
description:
|
||||
'The latest NOAA weather map image over an area: precipitation radar ' +
|
||||
'(contiguous US), regional or global infrared satellite clouds, or ' +
|
||||
'lightning density (the Americas). Returns the image itself.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
map: { type: 'string', enum: Object.keys(MAPS) },
|
||||
area: AREA_SCHEMA,
|
||||
},
|
||||
required: ['map'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weatherMaps'],
|
||||
async run(args, { services, signal }) {
|
||||
const product = MAPS[args.map];
|
||||
const snapshot = await services.weatherMaps.getSnapshot({
|
||||
product,
|
||||
signal,
|
||||
});
|
||||
if (snapshot.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`The ${args.map} map is unavailable right now`,
|
||||
);
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
let mapBox = null;
|
||||
if (area) {
|
||||
const { bounds } = snapshot;
|
||||
const center = areaCenter(area);
|
||||
if (
|
||||
center.lon < bounds.west ||
|
||||
center.lon > bounds.east ||
|
||||
center.lat < bounds.south ||
|
||||
center.lat > bounds.north
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`${area.label} is outside the ${args.map} map's coverage: ${snapshot.coverage || 'see its bounds'}`,
|
||||
);
|
||||
mapBox = weatherWindow(area, bounds);
|
||||
}
|
||||
const image = await services.weatherMaps.getImage({
|
||||
product,
|
||||
time: snapshot.latest,
|
||||
size: IMAGE_SIZE,
|
||||
bbox: mapBox,
|
||||
signal,
|
||||
});
|
||||
if (!/^image\/(png|jpeg)$/.test(image.contentType))
|
||||
throw new ToolError('unavailable', 'The weather map returned no image');
|
||||
if (image.bytes.byteLength > MAX_IMAGE_BYTES)
|
||||
throw new ToolError('unavailable', 'The weather map image is too large');
|
||||
const where = area ? ` over ${area.label}` : '';
|
||||
return {
|
||||
summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`,
|
||||
data: {
|
||||
map: args.map,
|
||||
title: snapshot.title ?? null,
|
||||
description: snapshot.description ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
time: snapshot.latest,
|
||||
box: mapBox ?? snapshot.bounds,
|
||||
source: snapshot.source ?? null,
|
||||
attribution: snapshot.attribution ?? null,
|
||||
},
|
||||
images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getWind = defineTool({
|
||||
name: 'get_wind',
|
||||
title: 'Wind',
|
||||
description:
|
||||
'Forecast wind 10 m above ground at a location from the GFS or IFS ' +
|
||||
'global model: speed and the direction it blows from.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
model: { type: 'string', enum: ['gfs', 'ifs'] },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['wind'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const model = args.model ?? 'gfs';
|
||||
const snapshot = await services.wind.getSnapshot({ model, signal });
|
||||
if (snapshot.unavailable || !snapshot.u || !snapshot.v)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Wind forecasts are unavailable right now',
|
||||
);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
point.lon,
|
||||
point.lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const kph = speed * 3.6;
|
||||
return {
|
||||
summary:
|
||||
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
|
||||
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
|
||||
data: {
|
||||
location: point,
|
||||
speed_mps: round(speed, 1),
|
||||
speed_kph: round(kph, 1),
|
||||
from: from === 'Calm' ? null : from,
|
||||
calm: from === 'Calm',
|
||||
u_mps: round(vector.u, 2),
|
||||
v_mps: round(vector.v, 2),
|
||||
level: snapshot.level ?? '10 m above ground',
|
||||
model,
|
||||
cycle: snapshot.cycle ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { weatherWindow } from './atmosphere.js';
|
||||
|
||||
const CONUS = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const weatherMaps = (snapshot = {}) => ({
|
||||
requests: [],
|
||||
async getSnapshot({ product }) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
product,
|
||||
title: 'MRMS radar',
|
||||
coverage: 'Contiguous United States',
|
||||
source: 'NOAA nowCOAST',
|
||||
attribution: 'NOAA',
|
||||
bounds: CONUS,
|
||||
times: ['2026-10-01T19:00:00.000Z'],
|
||||
latest: '2026-10-01T19:00:00.000Z',
|
||||
...snapshot,
|
||||
};
|
||||
},
|
||||
async getImage(request) {
|
||||
this.requests.push(request);
|
||||
return { contentType: 'image/png', bytes: PNG };
|
||||
},
|
||||
});
|
||||
|
||||
test('weather windows are 2:1, snapped to 0.25° and kept inside the bounds', () => {
|
||||
const austin = weatherWindow(
|
||||
{
|
||||
center: { lat: 30.27, lon: -97.74, radiusKm: 50 },
|
||||
west: -98.3,
|
||||
east: -97.2,
|
||||
south: 29.8,
|
||||
north: 30.7,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.deepEqual(austin, {
|
||||
west: -98.75,
|
||||
south: 29.75,
|
||||
east: -96.75,
|
||||
north: 30.75,
|
||||
});
|
||||
const edge = weatherWindow(
|
||||
{
|
||||
center: { lat: 21, lon: -129, radiusKm: 200 },
|
||||
west: -131,
|
||||
east: -127,
|
||||
south: 19,
|
||||
north: 23,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.equal(edge.west, -130);
|
||||
assert.equal(edge.south, 20);
|
||||
assert.equal((edge.east - edge.west) / (edge.north - edge.south), 2);
|
||||
assert.equal(
|
||||
weatherWindow(
|
||||
{ bbox: true, west: -180, east: 180, south: -80, north: 80 },
|
||||
CONUS,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('weather maps return the latest frame as an image over the area', async () => {
|
||||
const maps = weatherMaps();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: maps },
|
||||
});
|
||||
const result = await catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 50 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'MRMS radar at 2026-10-01T19:00:00.000Z over 50 km around 30.270, -97.740.',
|
||||
);
|
||||
assert.deepEqual(result.images, [
|
||||
{ mimeType: 'image/png', data: 'iVBORw==' },
|
||||
]);
|
||||
assert.deepEqual(maps.requests[0], {
|
||||
product: 'radar',
|
||||
time: '2026-10-01T19:00:00.000Z',
|
||||
size: { width: 1024, height: 512 },
|
||||
bbox: { west: -98.75, south: 29.75, east: -96.75, north: 30.75 },
|
||||
signal: undefined,
|
||||
});
|
||||
const whole = await catalog.call('get_weather_map', { map: 'satellite' });
|
||||
assert.equal(maps.requests[1].product, 'clouds-regional');
|
||||
assert.equal(maps.requests[1].bbox, null);
|
||||
assert.deepEqual(whole.data.box, CONUS);
|
||||
await assert.rejects(
|
||||
catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 51.5, lon: -0.1, radius_km: 50 },
|
||||
}),
|
||||
/outside the radar map's coverage: Contiguous United States/,
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: weatherMaps({ unavailable: true }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_weather_map', { map: 'lightning' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind is sampled from the model grid at the location', async () => {
|
||||
// A 1° global grid where every cell blows 10 m/s toward the east (from the west).
|
||||
const nx = 360;
|
||||
const ny = 181;
|
||||
const u = new Float32Array(nx * ny).fill(10);
|
||||
const v = new Float32Array(nx * ny).fill(0);
|
||||
const wind = {
|
||||
requested: [],
|
||||
async getSnapshot({ model }) {
|
||||
this.requested.push(model);
|
||||
return {
|
||||
model,
|
||||
cycle: '2026-10-01T12:00:00Z',
|
||||
level: '10 m above ground',
|
||||
grid: { nx, ny, lo1: 0, la1: 90, dx: 1, dy: 1 },
|
||||
u,
|
||||
v,
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { wind } });
|
||||
const result = await catalog.call('get_wind', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
model: 'ifs',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Wind at 30.2700, -97.7400: 36 km/h from the W.',
|
||||
);
|
||||
assert.equal(result.data.speed_mps, 10);
|
||||
assert.equal(result.data.from, 'W');
|
||||
assert.deepEqual(wind.requested, ['ifs']);
|
||||
u.fill(0);
|
||||
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
|
||||
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
|
||||
assert.equal(calm.data.calm, true);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_wind', { location: { lat: 0, lon: 0 } }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
|
||||
|
||||
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
|
||||
@@ -107,7 +107,7 @@ export const getCctvSnapshot = defineTool({
|
||||
mime_type: frame.contentType,
|
||||
bytes: frame.bytes.byteLength,
|
||||
},
|
||||
images: [{ mimeType: frame.contentType, data: base64(frame.bytes) }],
|
||||
images: [{ mimeType: frame.contentType, data: toBase64(frame.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -192,11 +192,3 @@ export const findRadioStations = defineTool({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** Base64-encode bytes with the platform's btoa, in chunks. */
|
||||
function base64(bytes) {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -41,3 +41,11 @@ export function thinEvenly(items, max) {
|
||||
(_, index) => items[Math.round(index * step)],
|
||||
);
|
||||
}
|
||||
|
||||
/** Base64-encode bytes with the platform's btoa, in chunks. */
|
||||
export function toBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { createRadioSource } from '../layers/radio/source.js';
|
||||
import { createSatelliteSource } from '../layers/satellites/source.js';
|
||||
import { createTrafficSource } from '../layers/traffic/source.js';
|
||||
import { createTransitSource } from '../layers/transit/source.js';
|
||||
import { createWeatherSource } from '../layers/weather/source.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
@@ -59,6 +61,8 @@ export function createToolServices({ fetchImpl, appUrl }) {
|
||||
transit: createTransitSource({ fetchImpl }),
|
||||
traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }),
|
||||
weather: requests.weather,
|
||||
weatherMaps: createWeatherSource({ fetchImpl }),
|
||||
wind: createWindSource({ fetchImpl }),
|
||||
regional: requests.regional,
|
||||
terrain: requests.terrain,
|
||||
summary: requests.summary,
|
||||
|
||||
Reference in new issue
Block a user