feat: add a Bhote Koshi flood event tool

This commit is contained in:
Sameh Khamis committed 2026-10-01 13:44:41 -07:00
1 parent a747b8a4e0
commit 0953ebb026
10 files changed
+243 -5

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a
camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share
stations, transit vehicles, road traffic flow, weather, weather map images
(radar, satellite, lightning), wind, the most recent satellite image of an
area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire
area, submarine cables, datacenters and dams, the Bhote Koshi flood event pack, regional briefs, tropical cyclones, fire
perimeters, terrain height, military installations and map features, plus a
combined situation brief, military awareness around a point, 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
+1
View File
@@ -133,6 +133,7 @@ public feeds the sources already use.
| `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) |
| `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) |
| `find_infrastructure` | `infrastructure` | OpenStreetMap datacenters or dams in an area, nearest first (ODbL) |
| `get_bhote_koshi_flood` | `events` | The 2026 Bhote Koshi flood: evidence trail in story order, flood path and imagery dates (CC BY-NC 4.0) |
| `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 |
+2 -1
View File
@@ -18,7 +18,8 @@ For commercial use, obtain separate permission or exclude both this pack and the
derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source
and build. Deleting this directory alone does not remove the compiled river
data. Excluding the Nepal scene also requires removing its registrations and
imports before building.
imports before building, including the `get_bhote_koshi_flood` tool in
`src/tools/queries/events.js`.
Sources:
+2 -1
View File
@@ -884,5 +884,6 @@
"src/tools/queries/imagery.test.mjs",
"src/tools/queries/cables.test.mjs",
"src/tools/queries/surveillance.test.mjs",
"src/tools/queries/infrastructure.test.mjs"
"src/tools/queries/infrastructure.test.mjs",
"src/tools/queries/events.test.mjs"
]
+5 -2
View File
@@ -3117,7 +3117,9 @@
"src/tools/queries/imagery.js",
"src/tools/queries/cables.js",
"src/tools/queries/surveillance.js",
"src/tools/queries/infrastructure.js"
"src/tools/queries/infrastructure.js",
"src/tools/queries/events.js",
"src/data/bhoteKoshiFloodPath.js"
],
"external": ["satellite.js"]
},
@@ -3193,7 +3195,8 @@
"src/layers/alpr/policy.js",
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js"
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
}
+24
View File
@@ -0,0 +1,24 @@
/** Event packs the app serves under `/events/<id>/`, read once per id. */
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function createEventPackSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const cache = new Map();
return {
async getEvent(id, { signal } = {}) {
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
if (!cache.has(id)) {
signal?.throwIfAborted();
const response = await fetchImpl(`/events/${id}/event.json`, {
signal,
});
if (!response.ok)
throw new Error(`Event pack ${id} unavailable (${response.status})`);
cache.set(id, await response.json());
}
return cache.get(id);
},
};
}
+2
View File
@@ -25,6 +25,7 @@ import {
getTerrainHeight,
getWeather,
} from './queries/environment.js';
import { getBhoteKoshiFlood } from './queries/events.js';
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
import { getRecentImagery } from './queries/imagery.js';
import { findInfrastructure } from './queries/infrastructure.js';
@@ -103,6 +104,7 @@ export const coreTools = Object.freeze([
getRecentImagery,
findSubmarineCables,
findInfrastructure,
getBhoteKoshiFlood,
getRegionalBrief,
getCyclones,
getFirePerimeters,
+130
View File
@@ -0,0 +1,130 @@
/** The bundled Bhote Koshi 2026 outburst flood event pack. */
import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js';
import { defineTool, ToolError } from '../catalog.js';
import { distanceKm } from '../area.js';
const EVENT_ID = 'bhote-koshi-2026';
const ATTRIBUTION =
'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' +
'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' +
"their owners' terms.";
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
/** Length of a `[lon, lat]` line in kilometers. */
function pathLengthKm(path) {
let total = 0;
for (let index = 1; index < path.length; index += 1)
total += distanceKm(
{ lat: path[index - 1][1], lon: path[index - 1][0] },
{ lat: path[index][1], lon: path[index][0] },
);
return total;
}
const pathPoint = ([lon, lat]) => ({ lat, lon });
function evidenceRow(record) {
return {
sequence: record.sequence ?? null,
id: record.id,
title: record.title ?? null,
summary: record.summary ?? null,
phase: record.phase ?? null,
lat: round(record.lat, 5),
lon: round(record.lon, 5),
location_confidence: record.confidence?.location ?? null,
time_confidence: record.confidence?.time ?? null,
captured_at: record.timing?.capturedAt ?? null,
in_imagery: record.imageryCoverage === 'within',
source_url: record.media?.sourceUrl ?? null,
platform: record.media?.platform ?? null,
via: record.provenance?.via ?? null,
};
}
export const getBhoteKoshiFlood = defineTool({
name: 'get_bhote_koshi_flood',
title: 'Bhote Koshi flood',
description:
"The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " +
'View maps it: the evidence trail of geolocated public witness posts ' +
'from the collapse to Devighat in story order, the mapped flood path, ' +
'and the before and after satellite imagery. A visualization, not an ' +
'official hazard model; capture times are unverified.',
inputSchema: {
type: 'object',
properties: {
phase: {
type: 'string',
minLength: 1,
maxLength: 40,
description: 'Only evidence in a phase, such as "downstream passage".',
},
},
additionalProperties: false,
},
requires: ['events'],
async run(args, { services, signal }) {
const event = await services.events.getEvent(EVENT_ID, { signal });
if (!Array.isArray(event?.evidenceSpine))
throw new ToolError('malformed', 'The event pack has no evidence');
const wanted = args.phase?.toLowerCase();
const evidence = event.evidenceSpine
.filter((record) => !wanted || record.phase?.toLowerCase() === wanted)
.sort((a, b) => a.sequence - b.sequence)
.map(evidenceRow);
const phases = [
...new Set(event.evidenceSpine.map((record) => record.phase)),
];
const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1);
const { imagery = {} } = event;
return {
summary:
`${event.title}, observed ${event.observedDate}: ${evidence.length} ` +
`evidence record${evidence.length === 1 ? '' : 's'}` +
(args.phase ? ` in the ${args.phase} phase` : '') +
` along a ${lengthKm} km mapped flood path.`,
data: {
id: event.id,
title: event.title,
observed_date: event.observedDate,
caveat: event.reconstruction?.caveat ?? null,
phases,
evidence,
witnesses: (event.witnessAnchors || []).map((anchor) => ({
title: anchor.title ?? null,
lat: round(anchor.lat, 6),
lon: round(anchor.lon, 6),
source_url: anchor.sourceUrl ?? null,
provenance: anchor.provenance ?? null,
})),
flood_path: {
start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]),
end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)),
length_km: lengthKm,
description:
'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.',
},
imagery: {
box: imagery.rectangle ?? null,
before: imagery.before
? {
observed_at: imagery.before.observedAt,
source: imagery.before.source,
}
: null,
after: imagery.after
? {
observed_at: imagery.after.observedAt,
source: imagery.after.source,
}
: null,
},
attribution: ATTRIBUTION,
},
};
},
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { createEventPackSource } from '../../sources/eventPacks.js';
const EVENT_URL = new URL(
'../../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
const bundled = JSON.parse(await readFile(EVENT_URL, 'utf8'));
test('the event pack source reads each event once and checks the id', async () => {
const requested = [];
const source = createEventPackSource({
fetchImpl: async (url) => {
requested.push(url);
return Response.json(bundled);
},
});
const event = await source.getEvent('bhote-koshi-2026');
assert.deepEqual(event, bundled);
assert.equal(await source.getEvent('bhote-koshi-2026'), event);
assert.deepEqual(requested, ['/events/bhote-koshi-2026/event.json']);
await assert.rejects(source.getEvent('../secret'), /Invalid event id/);
const missing = createEventPackSource({
fetchImpl: async () => new Response(null, { status: 404 }),
});
await assert.rejects(missing.getEvent('other'), /unavailable \(404\)/);
});
test('the flood tool lists the bundled evidence in story order', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { events: { getEvent: async () => bundled } },
});
const all = await catalog.call('get_bhote_koshi_flood', {});
assert.match(
all.summary,
/^Bhote Koshi Outburst Flood, observed 2026-08-26: 16 evidence records along a \d+(\.\d)? km mapped flood path\.$/,
);
assert.deepEqual(
all.data.evidence.map((row) => row.sequence),
Array.from({ length: 16 }, (_, index) => index + 1),
);
assert.deepEqual(all.data.evidence[3], {
sequence: 4,
id: 'gyirong-border-gate',
title: 'Gyirong border gate',
summary: 'Public footage geolocated at the Nepal-China border crossing.',
phase: 'border arrival',
lat: 28.28083,
lon: 85.37802,
location_confidence: 'geolocated',
time_confidence: 'unverified',
captured_at: null,
in_imagery: true,
source_url: 'https://www.youtube.com/watch?v=qlE51Eu0thk',
platform: 'YouTube',
via: 'GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness',
});
assert.deepEqual(all.data.flood_path.start, { lat: 28.33255, lon: 85.48476 });
assert.ok(all.data.flood_path.length_km > 50);
assert.equal(all.data.imagery.after.observed_at, '2026-08-27');
assert.match(all.data.caveat, /not an official hazard model/);
assert.match(all.data.attribution, /CC BY-NC 4\.0/);
const downstream = await catalog.call('get_bhote_koshi_flood', {
phase: 'Downstream passage',
});
assert.ok(
downstream.data.evidence.every((row) => row.phase === 'downstream passage'),
);
assert.match(downstream.summary, /in the Downstream passage phase/);
});
+2
View File
@@ -26,6 +26,7 @@ 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 { createEventPackSource } from '../sources/eventPacks.js';
import { createInfrastructureSource } from '../sources/infrastructureData.js';
import {
createAdsbLolSource,
@@ -72,6 +73,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
cables: createBundledCableSource({ fetchImpl }),
alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }),
infrastructure: createInfrastructureSource({ fetchImpl }),
events: createEventPackSource({ fetchImpl }),
regional: requests.regional,
terrain: requests.terrain,
summary: requests.summary,