mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user