From 9d12da0a79e27c7f3f855d614358210aa85d7bbd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:36:00 -0700 Subject: [PATCH] feat: add a traffic flow tool Add get_traffic_flow over the traffic source's TomTom flow tiles. It reports average speed as a share of free-flow speed, congested and closed road length, and a breakdown by road category, stepping down a zoom level when an area needs more tiles than the source reads at once. --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + server/mcp/services.js | 2 + src/tools/index.js | 7 +- src/tools/queries/mobility.js | 120 ++++++++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 110 +++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daf8505e5..acb9205d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add tools for language-model clients and a local MCP server. `npm run mcp` serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio, - place, routing, bike-share, transit, weather, cyclone, fire-perimeter, + place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter, terrain, installation and map-feature queries, plus a situation brief, over stdio to clients such as Claude Code, reading from a running app. Tools are defined diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index afacd89dc..b53207b8d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -11,7 +11,7 @@ area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share -stations, transit vehicles, weather, regional briefs, tropical cyclones, fire +stations, transit vehicles, road traffic flow, weather, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 66c836cd7..0457944bd 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -112,6 +112,7 @@ public feeds the sources already use. | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | +| `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road | | `get_weather` | `weather` | Current conditions at a place or point | | `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 | diff --git a/server/mcp/services.js b/server/mcp/services.js index 1aa20af4e..b9df46bd4 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -14,6 +14,7 @@ import { createLaunchSource } from '../../src/layers/launches/source.js'; import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js'; import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { createTrafficSource } from '../../src/layers/traffic/source.js'; import { createTransitSource } from '../../src/layers/transit/source.js'; import { createApplicationRequestServices } from '../../src/services/requests.js'; import { @@ -70,6 +71,7 @@ export function createLocalToolServices(options = {}) { getStations: createBikeshareSource({ fetchImpl }).getStations, }, transit: createTransitSource({ fetchImpl }), + traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }), weather: requests.weather, regional: requests.regional, terrain: requests.terrain, diff --git a/src/tools/index.js b/src/tools/index.js index 5a3af690d..a8d6f9eca 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -25,7 +25,11 @@ import { getVesselTrack, vesselsInArea, } from './queries/maritime.js'; -import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; +import { + getBikeShare, + getTrafficFlow, + getTransitVehicles, +} from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { findCctvCameras, @@ -81,6 +85,7 @@ export const coreTools = Object.freeze([ planRoute, getBikeShare, getTransitVehicles, + getTrafficFlow, getWeather, getRegionalBrief, getCyclones, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 8d975648c..0c9e3a7fe 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -222,3 +222,123 @@ export const getTransitVehicles = defineTool({ }; }, }); + +const FLOW_ZOOMS = [12, 11, 10, 9]; +const CONGESTED_LEVEL = 0.5; + +/** Length in kilometers of a `[lon, lat]` line. */ +function lineKm(coords) { + let total = 0; + for (let index = 1; index < coords.length; index += 1) + total += distanceKm( + { lat: coords[index - 1][1], lon: coords[index - 1][0] }, + { lat: coords[index][1], lon: coords[index][0] }, + ); + return total; +} + +async function readFlow(traffic, area, signal) { + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + for (const zoom of FLOW_ZOOMS) { + try { + return await traffic.fetchFlowForBounds(box, { zoom, signal }); + } catch (error) { + if (error?.code !== 'TILE_VIEW_TOO_WIDE') throw error; + } + } + throw new ToolError( + 'invalid_arguments', + 'The area is too large for traffic detail; use a city-sized area', + ); +} + +export const getTrafficFlow = defineTool({ + name: 'get_traffic_flow', + title: 'Traffic flow', + description: + 'Live road traffic in a city-sized area from TomTom: average speed as a ' + + 'share of free-flow speed, congested and closed road length, and a ' + + 'breakdown by road category.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['traffic'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if (area.west > area.east) + throw new ToolError( + 'invalid_arguments', + 'The area must not cross the antimeridian', + ); + const status = await services.traffic.getStatus({ signal }); + if (!status.hasKey) + throw new ToolError( + 'unavailable', + 'Live traffic needs a TomTom key configured for this server', + ); + const segments = (await readFlow(services.traffic, area, signal)).filter( + (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, + ); + const categories = new Map(); + let measuredKm = 0; + let weighted = 0; + let congestedKm = 0; + let closedKm = 0; + for (const segment of segments) { + const km = lineKm(segment.coords); + const key = segment.roadCategory || 'other'; + const entry = categories.get(key) || { + km: 0, + weighted: 0, + congestedKm: 0, + }; + entry.km += km; + if (segment.closure) closedKm += km; + if (Number.isFinite(segment.trafficLevel)) { + measuredKm += km; + weighted += segment.trafficLevel * km; + entry.weighted += segment.trafficLevel * km; + if (segment.trafficLevel < CONGESTED_LEVEL) { + congestedKm += km; + entry.congestedKm += km; + } + } + categories.set(key, entry); + } + const percent = (part, whole) => + whole > 0 ? Math.round((part / whole) * 100) : null; + const speedPct = percent(weighted, measuredKm); + const byCategory = [...categories.entries()] + .map(([category, entry]) => ({ + category, + road_km: round(entry.km, 1), + speed_pct_of_free_flow: percent(entry.weighted, entry.km), + congested_km: round(entry.congestedKm, 1), + })) + .sort((a, b) => b.road_km - a.road_km); + return { + summary: segments.length + ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + + `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + + (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') + : `No live traffic is reported in ${area.label}.`, + data: { + area: area.label, + speed_pct_of_free_flow: speedPct, + measured_km: round(measuredKm, 1), + congested_km: round(congestedKm, 1), + closed_km: round(closedKm, 1), + congested_below_pct: CONGESTED_LEVEL * 100, + by_category: byCategory, + }, + }; + }, +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 72b7d4a42..cbf48b99d 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -260,3 +260,113 @@ test('transit reports uncovered areas and unavailable feeds', async () => { (error) => error.code === 'unavailable', ); }); + +test('traffic flow aggregates speed, congestion and closures by road length', async () => { + // Two 0.01° segments along the equator (about 1.11 km each) and one closure. + const segments = [ + { + coords: [ + [0, 0], + [0.01, 0], + ], + trafficLevel: 1, + roadCategory: 'motorway', + closure: false, + }, + { + coords: [ + [0, 0.001], + [0.01, 0.001], + ], + trafficLevel: 0.25, + roadCategory: 'street', + closure: false, + }, + { + coords: [ + [0, 0.002], + [0.005, 0.002], + ], + trafficLevel: 0, + roadCategory: 'street', + closure: true, + }, + { coords: [[0, 0]], trafficLevel: 1 }, + ]; + const zooms = []; + const traffic = { + getStatus: async () => ({ hasKey: true }), + async fetchFlowForBounds(box, { zoom }) { + zooms.push(zoom); + if (zoom > 10) + throw Object.assign(new Error('Zoom in'), { + code: 'TILE_VIEW_TOO_WIDE', + }); + return segments; + }, + }; + const catalog = composeCatalog({ tools: coreTools, services: { traffic } }); + const result = await catalog.call('get_traffic_flow', { + area: { bbox: [-0.1, -0.1, 0.1, 0.1] }, + }); + assert.deepEqual(zooms, [12, 11, 10]); + assert.equal( + result.summary, + 'Traffic in the requested box: 50% of free-flow speed on average; 1.7 km congested of 2.8 km measured; 0.6 km closed.', + ); + assert.equal(result.data.speed_pct_of_free_flow, 50); + assert.deepEqual(result.data.by_category, [ + { + category: 'street', + road_km: 1.7, + speed_pct_of_free_flow: 17, + congested_km: 1.7, + }, + { + category: 'motorway', + road_km: 1.1, + speed_pct_of_free_flow: 100, + congested_km: 0, + }, + ]); +}); + +test('traffic reports a missing key, empty areas and areas that are too wide', async () => { + const keyless = { + getStatus: async () => ({ hasKey: false }), + fetchFlowForBounds: async () => [], + }; + await assert.rejects( + composeCatalog({ tools: coreTools, services: { traffic: keyless } }).call( + 'get_traffic_flow', + { area: downtown }, + ), + (error) => error.code === 'unavailable' && /TomTom key/.test(error.message), + ); + const quiet = { + getStatus: async () => ({ hasKey: true }), + fetchFlowForBounds: async () => [], + }; + assert.equal( + ( + await composeCatalog({ + tools: coreTools, + services: { traffic: quiet }, + }).call('get_traffic_flow', { area: downtown }) + ).summary, + 'No live traffic is reported in 2 km around 30.267, -97.743.', + ); + const wide = { + getStatus: async () => ({ hasKey: true }), + fetchFlowForBounds: async () => { + throw Object.assign(new Error('Zoom in'), { code: 'TILE_VIEW_TOO_WIDE' }); + }, + }; + await assert.rejects( + composeCatalog({ tools: coreTools, services: { traffic: wide } }).call( + 'get_traffic_flow', + { area: { bbox: [-100, 30, -95, 35] } }, + ), + /too large for traffic detail/, + ); +});