Files
gods-eye-view/src/tools/queries/environment.js
T
quanticsoul4772andClaude Opus 5.5 25973ade2c fix(perimeters): report a stale WFIGS snapshot as stale, and stop serving it after a day
When WFIGS was unreachable the proxy served its last snapshot, marked stale
(X-Data-Stale, body.stale), with no age limit, and the client source kept
only the rows. The layer then read healthy with a fresh update time and
get_fire_perimeters reported the perimeters as current; three days into an
outage it still said "1 mapped wildfire perimeter in the requested box."
The source now offers getSnapshotDetail ({rows, stale, fetchedAt}); the
layer reports stale with the snapshot's fetch time, the tool adds stale,
retrieved_at and "(data may be stale)", and the proxy serves a stale
snapshot for at most 24 hours, as the GNSS proxy caps its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:19 -07:00

538 lines
17 KiB
JavaScript

/**
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
* terrain height, mapped military sites and map features.
*/
import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
POINT_SCHEMA,
areaCenter,
areaContains,
distanceKm,
polygonsTouchArea,
resolveArea,
resolvePoint,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
const MAX_TERRAIN_POINTS = 20;
const MAX_SITE_BOX_DEGREES = 10;
const FEATURE_KINDS = {
administrative_areas: 'getAdministrativeAreas',
named_places: 'getEnclosingAreas',
monuments: 'getMonuments',
};
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
// WMO weather interpretation codes used by the weather provider.
const WEATHER_CODES = {
0: 'clear sky',
1: 'mainly clear',
2: 'partly cloudy',
3: 'overcast',
45: 'fog',
48: 'depositing rime fog',
51: 'light drizzle',
53: 'drizzle',
55: 'dense drizzle',
56: 'light freezing drizzle',
57: 'freezing drizzle',
61: 'light rain',
63: 'rain',
65: 'heavy rain',
66: 'light freezing rain',
67: 'freezing rain',
71: 'light snow',
73: 'snow',
75: 'heavy snow',
77: 'snow grains',
80: 'light rain showers',
81: 'rain showers',
82: 'violent rain showers',
85: 'light snow showers',
86: 'snow showers',
95: 'thunderstorm',
96: 'thunderstorm with light hail',
99: 'thunderstorm with heavy hail',
};
function weatherRow(weather) {
if (!weather) return null;
return {
observed_at: weather.observedAt ?? null,
conditions: WEATHER_CODES[weather.weatherCode] ?? null,
weather_code: weather.weatherCode ?? null,
temperature_c: weather.temperatureC ?? null,
feels_like_c: weather.apparentTemperatureC ?? null,
precipitation_mm: weather.precipitationMm ?? null,
cloud_cover_pct: weather.cloudCoverPct ?? null,
wind_kph: weather.windKph ?? null,
wind_direction_deg: weather.windDirectionDeg ?? null,
visibility_m: weather.visibilityM ?? null,
};
}
function describeWeather(row) {
if (!row) return 'no current weather';
const parts = [
row.conditions,
Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`,
Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`,
].filter(Boolean);
return parts.join(', ') || 'conditions unknown';
}
export const getWeather = defineTool({
name: 'get_weather',
title: 'Current weather',
description:
'Current weather at a location: conditions, temperature, feels-like, ' +
'precipitation, cloud cover, wind and visibility.',
inputSchema: {
type: 'object',
properties: { location: POINT_SCHEMA },
required: ['location'],
additionalProperties: false,
},
requires: ['weather'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const payload = await services.weather.getConditions(point.lat, point.lon, {
signal,
});
const row = weatherRow(payload?.weather);
// The proxy answers from its cache, marked stale, when a refresh fails.
const stale = payload?.status === 'stale';
return {
summary:
`Weather at ${point.label}: ${describeWeather(row)}` +
(stale ? ' (data may be stale).' : '.'),
data: {
location: point,
weather: row,
stale,
retrieved_at: payload?.retrievedAt ?? null,
},
};
},
});
export const getRegionalBrief = defineTool({
name: 'get_regional_brief',
title: 'Regional brief',
description:
'What and where a location is, its current weather, and recent news ' +
'headlines for the region.',
inputSchema: {
type: 'object',
properties: { location: POINT_SCHEMA },
required: ['location'],
additionalProperties: false,
},
requires: ['regional'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const brief = await services.regional.getBrief(point.lat, point.lon, {
signal,
});
const place = brief?.place ?? null;
const articles = (brief?.articles || []).slice(0, 10).map((article) => ({
title: article.title ?? null,
url: article.url ?? null,
source: article.domain ?? null,
published_at: article.publishedAt ?? null,
}));
const weather = weatherRow(brief?.weather);
const label = place?.label || point.label;
return {
summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`,
data: {
location: point,
place: place && {
label: place.label ?? null,
locality: place.locality ?? null,
region: place.region ?? null,
country: place.country ?? null,
kind: place.kind ?? null,
},
weather,
news_source: brief?.newsSource ?? null,
articles,
},
};
},
});
export const getCyclones = defineTool({
name: 'get_cyclones',
title: 'Tropical cyclones',
description:
'Active tropical cyclones from the NOAA National Hurricane Center and ' +
'Central Pacific Hurricane Center, optionally limited to an area.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA },
additionalProperties: false,
},
requires: ['cyclones'],
async run(args, { services, signal }) {
const area = args.area
? await resolveArea(args.area, { services, signal })
: null;
const snapshot = await services.cyclones.getSnapshot({ signal });
if (snapshot.unavailable)
throw new ToolError(
'unavailable',
'Cyclone advisories are unavailable right now',
);
const storms = snapshot.storms
.filter(
(storm) =>
!area ||
areaContains(area, {
lat: storm.position.latitude,
lon: storm.position.longitude,
}),
)
.map((storm) => ({
id: storm.id,
name: storm.name,
classification: storm.classification,
basin: storm.basin,
lat: storm.position.latitude,
lon: storm.position.longitude,
position_at: storm.positionAt,
wind_kt: storm.windKt,
pressure_hpa: storm.pressureHpa,
movement: storm.movement ?? null,
advisory: storm.advisoryNumber,
issued_at: storm.issuedAt,
advisory_url: storm.advisoryUrl,
}))
.sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0));
const where = area ? ` in ${area.label}` : '';
return {
summary:
`${countNoun(storms.length, 'active tropical cyclone')}${where}` +
(storms[0]
? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.`
: '.'),
data: {
view: suggestView(
services,
area
? { area, layers: ['weather-cyclones'] }
: {
point: { lat: 20, lon: 0 },
altitudeM: 15_000_000,
layers: ['weather-cyclones'],
},
),
storms,
coverage: snapshot.coverage ?? null,
source: snapshot.source ?? null,
attribution: snapshot.attribution ?? null,
stale: snapshot.stale === true,
},
};
},
});
export const getFirePerimeters = defineTool({
name: 'get_fire_perimeters',
title: 'Wildfire perimeters',
description:
'Mapped wildfire perimeters from the US WFIGS service in an area, ' +
'largest first, with size, containment, cause and personnel.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['perimeters'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const snapshot =
typeof services.perimeters.getSnapshotDetail === 'function'
? await services.perimeters.getSnapshotDetail({ signal })
: { rows: await services.perimeters.getSnapshot({ signal }) };
// The proxy answers from its last snapshot, marked stale, while WFIGS is
// unreachable.
const stale = snapshot.stale === true;
const rows = snapshot.rows
.filter((fire) => polygonsTouchArea(fire.polygons, area))
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
.filter(({ point }) => point)
.map(({ fire, point }) => ({
id: fire.stableId,
name: fire.name,
acres: round(fire.acres, 0),
contained_pct: fire.containedPct,
state: fire.state,
county: fire.county,
category: fire.category,
cause: fire.cause,
behavior: fire.behavior,
personnel: fire.personnel,
discovered: isoTime(fire.discoveredTime),
updated: isoTime(fire.updatedTime),
lat: round(point.lat, 5),
lon: round(point.lon, 5),
}))
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
return {
summary:
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
(stale ? ' (data may be stale).' : '.'),
data: {
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
stale,
retrieved_at: isoTime(snapshot.fetchedAt),
...capRows(rows, args.limit),
},
};
},
});
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
function polygonCenter(polygons) {
let lat = 0;
let sinLon = 0;
let cosLon = 0;
let count = 0;
const toRad = Math.PI / 180;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
sinLon += Math.sin(x * toRad);
cosLon += Math.cos(x * toRad);
lat += y;
count += 1;
}
}
return count
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
: null;
}
export const getTerrainHeight = defineTool({
name: 'get_terrain_height',
title: 'Terrain height',
description:
'Ground elevation above sea level at up to 20 points, with the geoid ' +
'and ellipsoid heights.',
inputSchema: {
type: 'object',
properties: {
points: {
type: 'array',
minItems: 1,
maxItems: MAX_TERRAIN_POINTS,
items: {
type: 'object',
properties: {
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
required: ['lat', 'lon'],
additionalProperties: false,
},
},
},
required: ['points'],
additionalProperties: false,
},
requires: ['terrain'],
async run(args, { services, signal }) {
const results = await services.terrain.getHeights(args.points, { signal });
if (!Array.isArray(results) || results.length !== args.points.length)
throw new ToolError('malformed', 'Terrain heights were incomplete');
const rows = args.points.map((point, index) => ({
lat: point.lat,
lon: point.lon,
elevation_m: round(results[index]?.elevation, 1),
geoid_m: round(results[index]?.geoid, 1),
ellipsoid_m: round(results[index]?.ellipsoid, 1),
}));
const first = rows[0];
return {
summary:
rows.length === 1
? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.`
: `Ground elevation at ${countNoun(rows.length, 'point')}.`,
data: { points: rows },
};
},
});
const NAMES_WAIT_MS = 5000;
/**
* The value `promise` resolves to within `ms`, or null when it takes longer
* or fails. Rejects only when `signal` aborts.
*/
function settleWithin(promise, ms, signal) {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const done = (value) => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
resolve(value);
};
const abort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => done(null), ms);
signal?.addEventListener('abort', abort, { once: true });
Promise.resolve(promise).then(
(value) => done(value ?? null),
() => done(null),
);
});
}
export const findMilitaryInstallations = defineTool({
name: 'find_military_installations',
title: 'Military installations',
description:
'Military installations mapped in OpenStreetMap within an area of at ' +
`most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`,
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['installations'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
if (
area.west > area.east ||
area.north - area.south > MAX_SITE_BOX_DEGREES ||
area.east - area.west > MAX_SITE_BOX_DEGREES
)
throw new ToolError(
'invalid_arguments',
`The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`,
);
const center = areaCenter(area);
let result = await services.installations.getMappedSites(area, {
exact: true,
thinned: false,
signal,
});
// Tile results can arrive before the name pack; wait briefly for the
// named version rather than answer with generic names.
let namesPending = false;
if (result.enrichment) {
const named = await settleWithin(
result.enrichment,
NAMES_WAIT_MS,
signal,
);
if (named) result = named;
else namesPending = true;
}
// A saturated source returned only part of the mapped sites.
const complete = !result.saturated;
const rows = (result.records || [])
.map((site) => ({
site,
point: { lat: site.latitude, lon: site.longitude },
}))
.filter(({ point }) => areaContains(area, point))
.map(({ site, point }) => ({
id: site.id,
name: site.name ?? null,
kind: site.kind ?? null,
class: site.class ?? null,
lat: round(point.lat, 5),
lon: round(point.lon, 5),
distance_km: round(distanceKm(center, point), 1),
}))
.sort((a, b) => a.distance_km - b.distance_km);
const notes = [
...(complete ? [] : ['partial: the source returned only some sites']),
...(namesPending ? ['site names are still loading'] : []),
];
return {
summary:
`${countNoun(rows.length, 'mapped military installation')} in ${area.label}` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, {
area,
layers: ['military-installations'],
}),
...capRows(rows, args.limit),
complete,
names_pending: namesPending,
source: result.source ?? null,
},
};
},
});
export const getMapFeatures = defineTool({
name: 'get_map_features',
title: 'Map features',
description:
'Named OpenStreetMap features at a location: the administrative areas ' +
'containing it, named parks, land uses and water nearby, or monuments ' +
'and memorials within 2.5 km.',
inputSchema: {
type: 'object',
properties: {
location: POINT_SCHEMA,
kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) },
limit: LIMIT_SCHEMA,
},
required: ['location', 'kind'],
additionalProperties: false,
},
requires: ['features'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const features = await services.features[FEATURE_KINDS[args.kind]](
{ lat: point.lat, lon: point.lon },
{ signal },
);
if (features?.unavailable)
throw new ToolError(
'unavailable',
'Map feature lookups need an Overpass server configured for this app',
);
if (!Array.isArray(features))
throw new ToolError(
'retry_later',
'The map feature service did not answer',
);
const rows = features
.map((feature) => ({
id: feature.id,
name:
feature.names?.english ||
feature.names?.primary ||
feature.names?.official ||
null,
category: feature.category,
admin_level: feature.level === 99 ? null : feature.level,
lat: round(feature.point?.lat, 5),
lon: round(feature.point?.lon, 5),
}))
.filter((row) => row.name)
.sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99));
const kind = args.kind.replace('_', ' ');
return {
summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`,
data: { location: point, ...capRows(rows, args.limit) },
};
},
});