From 0953ebb0269d87852e37f7cdc238a9c632ed527e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:44:41 -0700 Subject: [PATCH] feat: add a Bhote Koshi flood event tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + public/events/bhote-koshi-2026/README.md | 3 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 7 +- src/sources/eventPacks.js | 24 +++++ src/tools/index.js | 2 + src/tools/queries/events.js | 130 +++++++++++++++++++++++ src/tools/queries/events.test.mjs | 74 +++++++++++++ src/tools/services.js | 2 + 10 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/sources/eventPacks.js create mode 100644 src/tools/queries/events.js create mode 100644 src/tools/queries/events.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6673919e..54e410f44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8186f11ed..d8da75bb0 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/public/events/bhote-koshi-2026/README.md b/public/events/bhote-koshi-2026/README.md index f8d3657e7..a0160eab9 100644 --- a/public/events/bhote-koshi-2026/README.md +++ b/public/events/bhote-koshi-2026/README.md @@ -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: diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 75bb30a7c..e02a2e4f9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f33058197..fa7885c2c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] } diff --git a/src/sources/eventPacks.js b/src/sources/eventPacks.js new file mode 100644 index 000000000..53a2c3776 --- /dev/null +++ b/src/sources/eventPacks.js @@ -0,0 +1,24 @@ +/** Event packs the app serves under `/events//`, 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); + }, + }; +} diff --git a/src/tools/index.js b/src/tools/index.js index bedf234de..ebe5b0b7e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, diff --git a/src/tools/queries/events.js b/src/tools/queries/events.js new file mode 100644 index 000000000..9b530b40b --- /dev/null +++ b/src/tools/queries/events.js @@ -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, + }, + }; + }, +}); diff --git a/src/tools/queries/events.test.mjs b/src/tools/queries/events.test.mjs new file mode 100644 index 000000000..1daaff486 --- /dev/null +++ b/src/tools/queries/events.test.mjs @@ -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/); +}); diff --git a/src/tools/services.js b/src/tools/services.js index b18baca77..422207629 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -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,