mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
When the model upstream fails, the wind proxy keeps serving its last run, however old, with manifest.stale set. The wind layer shows that, but get_wind ignored it and gave the old run's wind as current. get_wind now returns stale and adds "(forecast may be stale)" to its summary, the way get_weather and the space tools flag stale data. Co-Authored-By: Claude Opus 5.5 <[email protected]>
236 lines
7.7 KiB
JavaScript
236 lines
7.7 KiB
JavaScript
/** Weather map imagery (radar, satellite, lightning) and wind at a point. */
|
|
|
|
import { suggestView } from '../views.js';
|
|
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',
|
|
};
|
|
/** The app layer that shows each map. */
|
|
const MAP_LAYERS = {
|
|
radar: 'weather-radar',
|
|
satellite: 'weather-satellite',
|
|
global_satellite: 'weather-satellite',
|
|
lightning: 'weather-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 no such box covers the whole area (it would not
|
|
* fit, or the area crosses the antimeridian), meaning the whole extent
|
|
* should be shown instead.
|
|
*/
|
|
export function weatherWindow(area, bounds) {
|
|
if (area.west > area.east) return null;
|
|
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 };
|
|
const insideProduct =
|
|
mapBox.west >= bounds.west &&
|
|
mapBox.east <= bounds.east &&
|
|
mapBox.south >= bounds.south &&
|
|
mapBox.north <= bounds.north;
|
|
// Moving the box inside the product can uncover part of the area; only a
|
|
// box that still holds the whole area (clipped to the product) will do.
|
|
const coversArea =
|
|
mapBox.west <= Math.max(area.west, bounds.west) &&
|
|
mapBox.east >= Math.min(area.east, bounds.east) &&
|
|
mapBox.south <= Math.max(area.south, bounds.south) &&
|
|
mapBox.north >= Math.min(area.north, bounds.north);
|
|
return insideProduct && coversArea ? 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: {
|
|
view: suggestView(
|
|
services,
|
|
area
|
|
? { area, layers: [MAP_LAYERS[args.map]] }
|
|
: {
|
|
point: {
|
|
lat: (snapshot.bounds.south + snapshot.bounds.north) / 2,
|
|
lon: (snapshot.bounds.west + snapshot.bounds.east) / 2,
|
|
},
|
|
altitudeM: 8_000_000,
|
|
layers: [MAP_LAYERS[args.map]],
|
|
},
|
|
),
|
|
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;
|
|
// When the model upstream fails, the proxy keeps serving the last run it
|
|
// has, however old, marked stale.
|
|
const stale = snapshot.stale === true;
|
|
return {
|
|
summary:
|
|
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
|
|
(from === 'Calm' ? ', calm' : ` from the ${from}`) +
|
|
(stale ? ' (forecast may be stale).' : '.'),
|
|
data: {
|
|
view: suggestView(services, {
|
|
point,
|
|
altitudeM: 2_000_000,
|
|
layers: ['wind'],
|
|
}),
|
|
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,
|
|
stale,
|
|
},
|
|
};
|
|
},
|
|
});
|