mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add weather, hazard, terrain and situation tools
Add get_weather, get_regional_brief, get_cyclones, get_fire_perimeters, get_terrain_height, find_military_installations and get_map_features over the application request services and the layers' sources. situation_brief combines weather, earthquakes, fires, aircraft and cyclones for an area by reusing those tools, marking sections that are unavailable instead of failing. get_hud_caption returns the app's heads-up display caption for the same overview.
This commit is contained in:
1 parent
8c97fed92d
commit
0a74e36ce8
11 files changed
+1093
-23
No files matched your search
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
|
||||
* terrain height, mapped military sites and map features.
|
||||
*/
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
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);
|
||||
return {
|
||||
summary: `Weather at ${point.label}: ${describeWeather(row)}.`,
|
||||
data: { location: point, weather: row },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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: {
|
||||
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 rows = (await services.perimeters.getSnapshot({ signal }))
|
||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||
.filter(({ point }) => point && areaContains(area, 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}.`,
|
||||
data: capRows(rows, args.limit),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
||||
function polygonCenter(polygons) {
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
let count = 0;
|
||||
for (const polygon of polygons || []) {
|
||||
for (const [x, y] of polygon?.[0] || []) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
lon += x;
|
||||
lat += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count ? { lat: lat / count, lon: lon / count } : 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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
const result = await services.installations.getMappedSites(area, {
|
||||
signal,
|
||||
});
|
||||
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);
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}.`,
|
||||
data: { ...capRows(rows, args.limit), 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) },
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user