UNPKG

@siva-sub/mcp-public-transport

Version:

A Model Context Protocol server for Singapore transport data with real-time information and routing

329 lines (328 loc) 14 kB
import axios from 'axios'; import { logger } from '../utils/logger.js'; import { APIError } from '../utils/errors.js'; export class EnhancedRoutingService { oneMapService; ltaService; weatherService; cache; timeout; oneMapClient; constructor(oneMapService, ltaService, weatherService, cache, timeout = 30000) { this.oneMapService = oneMapService; this.ltaService = ltaService; this.weatherService = weatherService; this.cache = cache; this.timeout = timeout; this.oneMapClient = axios.create({ baseURL: 'https://developers.onemap.sg/privateapi', timeout: this.timeout, }); } async planOptimalJourney(origin, destination, preferences = { minimizeTransfers: true, minimizeWalkingTime: false, minimizeTotalTime: true, allowDriving: false, accessibilityRequired: false, maxWalkingDistance: 1000, }) { logger.info('Planning optimal journey', { origin: `${origin.latitude},${origin.longitude}`, destination: `${destination.latitude},${destination.longitude}`, preferences, }); try { // Get weather conditions for the route const weatherConditions = await this.weatherService.getWeatherConditionsForLocation((origin.latitude + destination.latitude) / 2, (origin.longitude + destination.longitude) / 2); const weatherAdvisories = this.weatherService.generateWeatherAdvisory(weatherConditions); // Get current disruptions const disruptions = await this.getCurrentDisruptions(); // Generate multiple route options const routeOptions = []; // Option 1: Public Transport (Primary) if (!preferences.allowDriving) { const ptRoute = await this.generatePublicTransportRoute(origin, destination, preferences, weatherConditions, disruptions); if (ptRoute) routeOptions.push(ptRoute); } // Option 2: Driving (if allowed) if (preferences.allowDriving) { const drivingRoute = await this.generateDrivingRoute(origin, destination, weatherConditions); if (drivingRoute) routeOptions.push(drivingRoute); } // Option 3: Walking + Public Transport Hybrid const hybridRoute = await this.generateHybridRoute(origin, destination, preferences, weatherConditions, disruptions); if (hybridRoute) routeOptions.push(hybridRoute); // Sort routes by preference criteria const sortedRoutes = this.sortRoutesByPreferences(routeOptions, preferences); return sortedRoutes.slice(0, 3); // Return top 3 options } catch (error) { logger.error('Journey planning failed', error); throw new APIError(`Journey planning failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'ROUTING_ERROR'); } } async generatePublicTransportRoute(origin, destination, preferences, weather, disruptions) { try { // Use OneMap public transport routing const token = await this.oneMapService.ensureValidToken(); const response = await this.oneMapClient.get('/routingsvc/route', { params: { start: `${origin.latitude},${origin.longitude}`, end: `${destination.latitude},${destination.longitude}`, routeType: 'pt', maxWalkDistance: preferences.maxWalkingDistance, mode: 'TRANSIT', token, }, }); if (response.data.status !== 0) { logger.warn('OneMap PT routing failed', response.data); return null; } // Parse OneMap response and enhance with real-time data const segments = await this.parseOneMapRoute(response.data, weather); // Calculate totals const summary = this.calculateRouteSummary(segments); // Apply weather adjustments const weatherImpact = this.calculateWeatherImpact(segments, weather); return { summary: { ...summary, walkingTime: weatherImpact.adjustedWalkingTime, }, segments, weatherImpact: { conditions: weather, advisories: this.weatherService.generateWeatherAdvisory(weather), adjustedWalkingTime: weatherImpact.adjustedWalkingTime, }, disruptions: { current: disruptions, warnings: this.generateDisruptionWarnings(disruptions), }, confidence: this.calculateRouteConfidence(segments, disruptions), timestamp: new Date().toISOString(), }; } catch (error) { logger.error('Public transport routing failed', error); return null; } } async generateDrivingRoute(origin, destination, weather) { try { const token = await this.oneMapService.ensureValidToken(); const response = await this.oneMapClient.get('/routingsvc/route', { params: { start: `${origin.latitude},${origin.longitude}`, end: `${destination.latitude},${destination.longitude}`, routeType: 'drive', token, }, }); if (response.data.status !== 0) { logger.warn('OneMap driving routing failed', response.data); return null; } // Parse driving instructions const instructions = this.parseRouteInstructions(response.data.route_instructions, 'driving'); const segment = { mode: 'DRIVE', duration: response.data.route_summary.total_time, distance: response.data.route_summary.total_distance, instructions, startLocation: { latitude: origin.latitude, longitude: origin.longitude, name: origin.name || response.data.route_summary.start_point, }, endLocation: { latitude: destination.latitude, longitude: destination.longitude, name: destination.name || response.data.route_summary.end_point, }, }; const summary = { totalTime: segment.duration, totalDistance: segment.distance, totalCost: this.estimateDrivingCost(segment.distance), walkingTime: 0, walkingDistance: 0, transfers: 0, modes: ['DRIVE'], }; return { summary, segments: [segment], weatherImpact: { conditions: weather, advisories: this.weatherService.generateWeatherAdvisory(weather), adjustedWalkingTime: 0, }, disruptions: { current: [], warnings: [], }, confidence: 0.9, timestamp: new Date().toISOString(), }; } catch (error) { logger.error('Driving routing failed', error); return null; } } async generateHybridRoute(origin, destination, preferences, weather, disruptions) { // This would implement a more complex hybrid routing strategy // For now, return null to focus on the main PT and driving routes return null; } parseRouteInstructions(instructions, mode) { return instructions.map((instruction) => ({ direction: instruction[0], streetName: instruction[1], distance: instruction[2], coordinates: instruction[3], duration: instruction[4], distanceText: instruction[5], bearing: instruction[6], fromBearing: instruction[7], mode, instruction: instruction[9], })); } async parseOneMapRoute(oneMapResponse, weather) { // This is a simplified parser - in reality, OneMap PT responses are more complex // and would need detailed parsing to extract walking segments, transit segments, etc. const segments = []; // For now, create a basic walking segment as an example if (oneMapResponse.route_instructions) { const walkingInstructions = this.parseRouteInstructions(oneMapResponse.route_instructions, 'walking'); segments.push({ mode: 'WALK', duration: oneMapResponse.route_summary.total_time, distance: oneMapResponse.route_summary.total_distance, instructions: walkingInstructions, startLocation: { latitude: 0, // Would be parsed from actual response longitude: 0, name: oneMapResponse.route_summary.start_point, }, endLocation: { latitude: 0, // Would be parsed from actual response longitude: 0, name: oneMapResponse.route_summary.end_point, }, }); } return segments; } calculateRouteSummary(segments) { const totalTime = segments.reduce((sum, seg) => sum + seg.duration, 0); const totalDistance = segments.reduce((sum, seg) => sum + seg.distance, 0); const walkingSegments = segments.filter(seg => seg.mode === 'WALK'); const walkingTime = walkingSegments.reduce((sum, seg) => sum + seg.duration, 0); const walkingDistance = walkingSegments.reduce((sum, seg) => sum + seg.distance, 0); const transfers = Math.max(0, segments.filter(seg => seg.mode !== 'WALK').length - 1); const modes = [...new Set(segments.map(seg => seg.mode))]; return { totalTime, totalDistance, totalCost: this.estimateTransportCost(segments), walkingTime, walkingDistance, transfers, modes, }; } calculateWeatherImpact(segments, weather) { const walkingSegments = segments.filter(seg => seg.mode === 'WALK'); const baseWalkingTime = walkingSegments.reduce((sum, seg) => sum + seg.duration, 0); let multiplier = 1.0; // Apply weather multipliers if (weather.rainfall > 10) multiplier *= 1.5; else if (weather.rainfall > 2.5) multiplier *= 1.2; if (weather.temperature > 32) multiplier *= 1.3; else if (weather.temperature > 30) multiplier *= 1.1; if (weather.humidity > 85) multiplier *= 1.1; if (weather.windSpeed > 20) multiplier *= 1.1; return { adjustedWalkingTime: Math.round(baseWalkingTime * multiplier), }; } async getCurrentDisruptions() { try { const trainAlerts = await this.ltaService.getTrainServiceAlerts(); const trafficIncidents = await this.ltaService.getTrafficIncidents(); return [...trainAlerts, ...trafficIncidents]; } catch (error) { logger.warn('Failed to get current disruptions', error); return []; } } generateDisruptionWarnings(disruptions) { return disruptions.map(disruption => { if (disruption.line) { return `${disruption.lineName || disruption.line} service disruption`; } if (disruption.message) { return disruption.message; } return 'Service disruption detected'; }); } calculateRouteConfidence(segments, disruptions) { let confidence = 0.9; // Reduce confidence for each disruption confidence -= disruptions.length * 0.1; // Reduce confidence for complex routes with many transfers const transfers = Math.max(0, segments.filter(seg => seg.mode !== 'WALK').length - 1); confidence -= transfers * 0.05; return Math.max(0.3, Math.min(1.0, confidence)); } sortRoutesByPreferences(routes, preferences) { return routes.sort((a, b) => { if (preferences.minimizeTransfers) { const transferDiff = a.summary.transfers - b.summary.transfers; if (transferDiff !== 0) return transferDiff; } if (preferences.minimizeTotalTime) { return a.summary.totalTime - b.summary.totalTime; } if (preferences.minimizeWalkingTime) { return a.summary.walkingTime - b.summary.walkingTime; } return a.summary.totalTime - b.summary.totalTime; }); } estimateTransportCost(segments) { // Simplified cost estimation let cost = 0; for (const segment of segments) { if (segment.mode === 'BUS') cost += 1.0; if (segment.mode === 'MRT' || segment.mode === 'LRT') cost += 1.5; } return Math.max(0.8, cost); // Minimum fare } estimateDrivingCost(distance) { // Rough estimate: fuel + parking + ERP const fuelCost = (distance / 1000) * 0.15; // $0.15 per km const parkingCost = 5.0; // Average parking const erpCost = 2.0; // Average ERP return fuelCost + parkingCost + erpCost; } }