diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bf65cedf1..483b5919d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,7 +13,8 @@ area, by identifier, tracks, and type and route lookups), ships (in an area, 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, regional briefs, tropical cyclones, fire +stations, transit vehicles, road traffic flow, weather, weather map images +(radar, satellite, lightning), wind, 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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2c102da85..0e195db2a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -127,6 +127,8 @@ public feeds the sources already use. | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | | `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road | | `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_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 | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f46193be..3b32173a3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -879,5 +879,6 @@ "src/tools/queries/environment.test.mjs", "src/tools/queries/maritime.test.mjs", "src/tools/queries/share.test.mjs", - "src/tools/localRoute.test.mjs" + "src/tools/localRoute.test.mjs", + "src/tools/queries/atmosphere.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ecde8cac4..ffa6795ba 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3106,7 +3106,11 @@ "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", "src/sources/tle.js", - "src/data/satellitePass.js" + "src/data/satellitePass.js", + "src/tools/queries/atmosphere.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/fields.js" ], "external": ["satellite.js"] }, @@ -3171,7 +3175,10 @@ "src/sources/live/vessels.js", "src/tools/places.js", "src/tools/catalog.js", - "src/tools/schema.js" + "src/tools/schema.js", + "src/layers/weather/source.js", + "src/layers/wind/source.js", + "src/sources/windBody.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index 0efcd84f5..5c0d328ed 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js new file mode 100644 index 000000000..838e4849f --- /dev/null +++ b/src/tools/queries/atmosphere.js @@ -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, + }, + }; + }, +}); diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs new file mode 100644 index 000000000..8623904ab --- /dev/null +++ b/src/tools/queries/atmosphere.test.mjs @@ -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', + ); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 2ad0f78e3..a0a551196 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -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); -} diff --git a/src/tools/results.js b/src/tools/results.js index 33213da0b..51ed82bbb 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -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); +} diff --git a/src/tools/services.js b/src/tools/services.js index 8071425ed..7798d8d88 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -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,