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

+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/,
);
});