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.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:36:00 -07:00
1 parent e6cbcc31ad
commit 9d12da0a79
7 files changed
+241 -3

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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 |
+2
View File
@@ -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,
+6 -1
View File
@@ -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,
+120
View File
@@ -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,
},
};
},
});
+110
View File
@@ -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/,
);
});