From 28cfd39d1a80cb0e5fa743c31a8987ca2fda353a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:38:38 -0700 Subject: [PATCH] feat: add a datacenter and dam tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 6 +- src/sources/infrastructureData.js | 60 +++++++++ src/tools/index.js | 2 + src/tools/queries/infrastructure.js | 70 +++++++++++ src/tools/queries/infrastructure.test.mjs | 143 ++++++++++++++++++++++ src/tools/services.js | 2 + 9 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/infrastructure.js create mode 100644 src/tools/queries/infrastructure.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3a330eb6c..df6793080 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, regional briefs, tropical cyclones, fire +area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, 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 403b37513..811a47022 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -132,6 +132,7 @@ public feeds the sources already use. | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | | `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_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/scripts/format-scope.json b/scripts/format-scope.json index 2352e3df4..75bb30a7c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -883,5 +883,6 @@ "src/tools/queries/atmosphere.test.mjs", "src/tools/queries/imagery.test.mjs", "src/tools/queries/cables.test.mjs", - "src/tools/queries/surveillance.test.mjs" + "src/tools/queries/surveillance.test.mjs", + "src/tools/queries/infrastructure.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1b7e90f56..f33058197 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3116,7 +3116,8 @@ "src/layers/wind/fields.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", - "src/tools/queries/surveillance.js" + "src/tools/queries/surveillance.js", + "src/tools/queries/infrastructure.js" ], "external": ["satellite.js"] }, @@ -3191,7 +3192,8 @@ "src/layers/alpr/source.js", "src/layers/alpr/policy.js", "src/layers/alpr/records.js", - "src/layers/alpr/tileRecords.js" + "src/layers/alpr/tileRecords.js", + "src/sources/infrastructureData.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/sources/infrastructureData.js b/src/sources/infrastructureData.js index 9c19fdc87..6936b9aa5 100644 --- a/src/sources/infrastructureData.js +++ b/src/sources/infrastructureData.js @@ -101,3 +101,63 @@ export function mapAnalystRecord(raw, layerId = '') { output, }; } + +/** A representative point for a feature: its point, or its outline's average vertex. */ +export function featurePoint(feature) { + const geometry = feature?.geometry; + const outline = + geometry?.type === 'Point' + ? [geometry.coordinates] + : geometry?.type === 'LineString' + ? geometry.coordinates + : geometry?.type === 'Polygon' + ? geometry.coordinates?.[0] + : geometry?.type === 'MultiPolygon' + ? geometry.coordinates?.[0]?.[0] + : null; + const points = (outline || []).filter( + (point) => Number.isFinite(point?.[0]) && Number.isFinite(point?.[1]), + ); + if (!points.length) return null; + return { + lon: points.reduce((sum, point) => sum + point[0], 0) / points.length, + lat: points.reduce((sum, point) => sum + point[1], 0) / points.length, + }; +} + +/** Read the bundled layers' analyst records through a supplied transport. */ +export function createInfrastructureSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const cache = new Map(); + return { + async getRecords(layerId, { signal } = {}) { + const url = INFRASTRUCTURE_DATA_URLS[layerId]; + if (!url) throw new TypeError(`Unknown infrastructure layer: ${layerId}`); + if (!cache.has(layerId)) { + signal?.throwIfAborted(); + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const records = parseGeojsonLines(await response.text()).flatMap( + (feature) => { + const point = featurePoint(feature); + return point + ? [ + mapAnalystRecord( + { + id: feature.properties?.osm_id ?? feature.id, + ...point, + properties: feature.properties, + }, + layerId, + ), + ] + : []; + }, + ); + cache.set(layerId, records); + } + return cache.get(layerId); + }, + }; +} diff --git a/src/tools/index.js b/src/tools/index.js index d1fff6f35..1c35bdcc6 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -23,6 +23,7 @@ import { } from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; import { getRecentImagery } from './queries/imagery.js'; +import { findInfrastructure } from './queries/infrastructure.js'; import { findVessel, getVesselTrack, @@ -97,6 +98,7 @@ export const coreTools = Object.freeze([ getWind, getRecentImagery, findSubmarineCables, + findInfrastructure, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/infrastructure.js b/src/tools/queries/infrastructure.js new file mode 100644 index 000000000..fd64f5fa5 --- /dev/null +++ b/src/tools/queries/infrastructure.js @@ -0,0 +1,70 @@ +/** Bundled OpenStreetMap infrastructure: datacenters and dams. */ + +import { defineTool } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const KINDS = { + datacenters: { layerId: 'local-datacenters', noun: 'datacenter' }, + dams: { layerId: 'local-dams', noun: 'dam' }, +}; +const ATTRIBUTION = { + datacenters: '© OpenStreetMap contributors (ODbL 1.0)', + dams: '© OpenStreetMap contributors (ODbL 1.0), via Open Infrastructure Map', +}; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +export const findInfrastructure = defineTool({ + name: 'find_infrastructure', + title: 'Datacenters and dams', + description: + 'Datacenters (with operator and capacity) or dams (with river and power ' + + 'output) mapped in OpenStreetMap within an area, nearest the center first.', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string', enum: Object.keys(KINDS) }, + area: AREA_SCHEMA, + limit: LIMIT_SCHEMA, + }, + required: ['kind', 'area'], + additionalProperties: false, + }, + requires: ['infrastructure'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const { layerId, noun } = KINDS[args.kind]; + const records = await services.infrastructure.getRecords(layerId, { + signal, + }); + const rows = records + .filter((record) => areaContains(area, record)) + .map((record) => ({ + name: record.name, + operator: record.operator, + ...(args.kind === 'datacenters' + ? { capacity: record.capacity } + : { river: record.river, output: record.output }), + lat: round(record.lat, 5), + lon: round(record.lon, 5), + distance_km: round(distanceKm(center, record), 1), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: `${countNoun(rows.length, `mapped ${noun}`)} in ${area.label}.`, + data: { + ...capRows(rows, args.limit), + attribution: ATTRIBUTION[args.kind], + }, + }; + }, +}); diff --git a/src/tools/queries/infrastructure.test.mjs b/src/tools/queries/infrastructure.test.mjs new file mode 100644 index 000000000..d2f209793 --- /dev/null +++ b/src/tools/queries/infrastructure.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createApiFetch } from '../../../server/mcp/services.js'; +import { + createInfrastructureSource, + featurePoint, +} from '../../sources/infrastructureData.js'; + +test('features are placed at their point or average outline vertex', () => { + assert.deepEqual( + featurePoint({ geometry: { type: 'Point', coordinates: [10, 20] } }), + { lon: 10, lat: 20 }, + ); + assert.deepEqual( + featurePoint({ + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + ], + }, + }), + { lon: 1, lat: 1 }, + ); + assert.deepEqual( + featurePoint({ + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [4, 4], + [6, 6], + ], + ], + ], + }, + }), + { lon: 5, lat: 5 }, + ); + assert.equal(featurePoint({ geometry: null }), null); +}); + +test('datacenters and dams are listed nearest first with their own fields', async () => { + const records = { + 'local-datacenters': [ + { + id: 'a', + name: 'Equinix DC2', + lat: 38.9, + lon: -77.45, + operator: 'Equinix', + capacity: '20 MW', + river: null, + output: null, + }, + { + id: 'b', + name: 'Far DC', + lat: 48.8, + lon: 2.35, + operator: null, + capacity: null, + river: null, + output: null, + }, + ], + 'local-dams': [ + { + id: 'c', + name: 'Hoover Dam', + lat: 36.016, + lon: -114.737, + operator: 'USBR', + capacity: null, + river: 'Colorado River', + output: '2080 MW', + }, + ], + }; + const infrastructure = { getRecords: async (layerId) => records[layerId] }; + const catalog = composeCatalog({ + tools: coreTools, + services: { infrastructure }, + }); + const dc = await catalog.call('find_infrastructure', { + kind: 'datacenters', + area: { lat: 38.95, lon: -77.45, radius_km: 20 }, + }); + assert.equal( + dc.summary, + '1 mapped datacenter in 20 km around 38.950, -77.450.', + ); + assert.deepEqual(dc.data.rows[0], { + name: 'Equinix DC2', + operator: 'Equinix', + capacity: '20 MW', + lat: 38.9, + lon: -77.45, + distance_km: 5.6, + }); + assert.match(dc.data.attribution, /OpenStreetMap contributors \(ODbL 1\.0\)/); + const dams = await catalog.call('find_infrastructure', { + kind: 'dams', + area: { lat: 36, lon: -114.7, radius_km: 20 }, + }); + assert.deepEqual(dams.data.rows[0], { + name: 'Hoover Dam', + operator: 'USBR', + river: 'Colorado River', + output: '2080 MW', + lat: 36.016, + lon: -114.737, + distance_km: 3.8, + }); +}); + +test('the bundled datasets load through the local fetch', async () => { + const fetchImpl = createApiFetch({ + apiBase: 'http://127.0.0.1:1', + fetchImpl: async () => assert.fail('no network'), + }); + const source = createInfrastructureSource({ fetchImpl }); + const dams = await source.getRecords('local-dams'); + const datacenters = await source.getRecords('local-datacenters'); + assert.ok(dams.length > 500 && datacenters.length > 3000); + assert.ok( + dams.every( + (record) => Number.isFinite(record.lat) && Number.isFinite(record.lon), + ), + ); + assert.equal(await source.getRecords('local-dams'), dams); + await assert.rejects( + source.getRecords('local-other'), + /Unknown infrastructure layer/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index a77494fc7..b18baca77 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 { createInfrastructureSource } from '../sources/infrastructureData.js'; import { createAdsbLolSource, createAisStreamSource, @@ -70,6 +71,7 @@ export function createToolServices({ fetchImpl, appUrl }) { imagery: createImageryService({ fetchImpl }), cables: createBundledCableSource({ fetchImpl }), alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }), + infrastructure: createInfrastructureSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary,