mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
e6cbcc31ad
commit
9d12da0a79
7 files changed
+241
-3
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
||||||
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
|
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
|
terrain, installation and map-feature queries, plus a situation brief, over
|
||||||
stdio to clients such as Claude Code, reading
|
stdio to clients such as Claude Code, reading
|
||||||
from a running app. Tools are defined
|
from a running app. Tools are defined
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ area, by identifier, tracks, and type and route lookups), ships (in an area,
|
|||||||
by identifier, and tracks), satellites (next
|
by identifier, and tracks), satellites (next
|
||||||
pass over a point, and those overhead now), public cameras (including a
|
pass over a point, and those overhead now), public cameras (including a
|
||||||
camera's current image), radio stations, place search, routing, bike-share
|
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
|
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
|
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
|
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ public feeds the sources already use.
|
|||||||
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
|
| `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_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_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_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_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_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createLaunchSource } from '../../src/layers/launches/source.js';
|
|||||||
import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js';
|
import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js';
|
||||||
import { createRadioSource } from '../../src/layers/radio/source.js';
|
import { createRadioSource } from '../../src/layers/radio/source.js';
|
||||||
import { createSatelliteSource } from '../../src/layers/satellites/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 { createTransitSource } from '../../src/layers/transit/source.js';
|
||||||
import { createApplicationRequestServices } from '../../src/services/requests.js';
|
import { createApplicationRequestServices } from '../../src/services/requests.js';
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +71,7 @@ export function createLocalToolServices(options = {}) {
|
|||||||
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
||||||
},
|
},
|
||||||
transit: createTransitSource({ fetchImpl }),
|
transit: createTransitSource({ fetchImpl }),
|
||||||
|
traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }),
|
||||||
weather: requests.weather,
|
weather: requests.weather,
|
||||||
regional: requests.regional,
|
regional: requests.regional,
|
||||||
terrain: requests.terrain,
|
terrain: requests.terrain,
|
||||||
|
|||||||
+6
-1
@@ -25,7 +25,11 @@ import {
|
|||||||
getVesselTrack,
|
getVesselTrack,
|
||||||
vesselsInArea,
|
vesselsInArea,
|
||||||
} from './queries/maritime.js';
|
} 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 { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||||
import {
|
import {
|
||||||
findCctvCameras,
|
findCctvCameras,
|
||||||
@@ -81,6 +85,7 @@ export const coreTools = Object.freeze([
|
|||||||
planRoute,
|
planRoute,
|
||||||
getBikeShare,
|
getBikeShare,
|
||||||
getTransitVehicles,
|
getTransitVehicles,
|
||||||
|
getTrafficFlow,
|
||||||
getWeather,
|
getWeather,
|
||||||
getRegionalBrief,
|
getRegionalBrief,
|
||||||
getCyclones,
|
getCyclones,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -260,3 +260,113 @@ test('transit reports uncovered areas and unavailable feeds', async () => {
|
|||||||
(error) => error.code === 'unavailable',
|
(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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user