diff --git a/src/raptor/types.ts b/src/raptor/types.ts index 49c59c5..ebb7341 100644 --- a/src/raptor/types.ts +++ b/src/raptor/types.ts @@ -85,4 +85,69 @@ export type TransfersByOrigin = Record; /** * Map defining the minimum time required to switch vehicles at each stop. */ -export type Interchange = Record; \ No newline at end of file +export type Interchange = Record; + +/** + * Classification of a user's relationship to nearby buses. + */ +export type OnBusStatus = 'on_bus' | 'near_bus' | 'waiting_at_stop' | 'not_near_bus'; + +/** + * A single user location sample provided by the frontend for motion validation. + */ +export interface LocationSample { + lat: number; + lon: number; + /** Epoch milliseconds. */ + timestamp: number; + /** Speed in m/s from the device, if available. */ + speed?: number; + /** Heading in degrees from the device, if available. */ + heading?: number; +} + +/** + * Result of classifying whether a user is on a bus, near one, or waiting at a stop. + */ +export interface OnBusClassification { + status: OnBusStatus; + /** Vehicle ID of the matched bus, when applicable. */ + vid?: string; + /** Motion-correlation confidence score from 0 to 1. */ + confidence: number; + /** Human-readable explanation of the classification. */ + reason: string; +} + +/** + * Context describing a stop the user is physically standing at. + */ +export interface AtStopContext { + stopId: StopID; + stopName: string; + distanceMeters: number; + /** 0 when the user is considered to be at the stop. */ + walkTimeSeconds: number; +} + +/** + * Routing context for a user confirmed to be aboard a specific bus. + */ +export interface OnBusContext { + vid: string; + tripId: TripID; + rt: string; + /** Virtual stop ID injected into the graph, e.g. "ON_BUS_341". */ + virtualStopId: StopID; + /** Index in the original trip's stopTimes where the user can next alight. */ + boardStopIndex: number; + busLat: number; + busLon: number; + /** Trip copy starting from the virtual on-bus stop. */ + trimmedTrip: Trip; + classification: OnBusClassification; + /** Physical stop the bus is currently at/servicing, if stopped at one. */ + currentStopId?: StopID; + currentStopName?: string; + isStoppedAtStop: boolean; +} \ No newline at end of file diff --git a/src/routes/api.ts b/src/routes/api.ts index 8f24271..ceab449 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -8,6 +8,9 @@ import * as meta from '../services/metadata'; import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; +import * as onBusService from '../services/onBus'; +import { haversine } from '../walking/loadMap'; +import { LocationSample } from '../raptor/types'; import { startBackgroundJobs } from '../jobs'; /** @@ -364,15 +367,35 @@ export function getNearestStops(req: express.Request, res: express.Response) { } router.get('/nearest-stops', getNearestStops); +/** Parses a locationTrail provided as a JSON query string or a request body array. */ +function parseLocationTrail(raw: unknown): LocationSample[] { + let parsed: unknown = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((s: any) => + s && typeof s.lat === 'number' && typeof s.lon === 'number' && typeof s.timestamp === 'number' + ); +} + /** * Plans a journey between origin and destination coordinates. + * Optionally starts the journey from a bus the user is riding: pass + * `originVid` (vehicle ID), `onBus=true`, and `locationTrail` (URL-encoded + * JSON array of recent location samples) for motion validation. * @param req - Express request * @param res - Express response - * @returns JSON object with `journeys` array containing possible routes. + * @returns JSON object with `journeys` array and, when on-bus routing was + * requested, an `originContext` describing how the origin was resolved. */ export async function planJourney(req: express.Request, res: express.Response) { try { - const { originLat, originLon, destLat, destLon, walkingPenalty, range } = req.query; + const { originLat, originLon, destLat, destLon, walkingPenalty, range, originVid, onBus, locationTrail } = req.query; if (!originLat || !originLon || !destLat || !destLon) { res.status(400).json({ error: 'coordinates are required' }); @@ -382,17 +405,35 @@ export async function planJourney(req: express.Request, res: express.Response) { const now = new Date(); const secondsSinceMidnight = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); - const results = await journeyService.planJourney( + // On-bus mode: explicit onBus=true, or implied by originVid unless onBus=false + const onBusFlag = typeof onBus === 'string' ? onBus.toLowerCase() : undefined; + const onBusEnabled = onBusFlag === 'true' || onBusFlag === '1' + || (!!originVid && onBusFlag !== 'false' && onBusFlag !== '0'); + + let onBusOptions: journeyService.OnBusOptions | undefined; + if (onBusEnabled) { + const trailRaw = locationTrail !== undefined ? locationTrail : (req.body ? req.body.locationTrail : undefined); + onBusOptions = { + vid: originVid ? String(originVid) : undefined, + locationTrail: parseLocationTrail(trailRaw) + }; + } + + const result = await journeyService.planJourney( parseFloat(originLat as string), parseFloat(originLon as string), parseFloat(destLat as string), parseFloat(destLon as string), secondsSinceMidnight, { walkingPenalty: walkingPenalty ? parseFloat(walkingPenalty as string) : undefined, - range: range ? parseInt(range as string) : undefined + range: range ? parseInt(range as string) : undefined, + onBus: onBusOptions } ); - res.json({ journeys: results }); + res.json({ + journeys: result.journeys, + ...(result.originContext ? { originContext: result.originContext } : {}) + }); } catch (error) { console.error("Journey plan error:", error); res.status(500).json({ error: 'Journey planning failed' }); @@ -400,6 +441,73 @@ export async function planJourney(req: express.Request, res: express.Response) { } router.get('/plan-journey', planJourney); +const LocationSampleSchema = z.object({ + lat: z.number(), + lon: z.number(), + timestamp: z.number(), + speed: z.number().optional(), + heading: z.number().optional(), +}); +const DetectOnBusBody = z.object({ + lat: z.number(), + lon: z.number(), + locationTrail: z.array(LocationSampleSchema).default([]), + candidateVid: z.string().optional(), +}); + +/** + * Classifies whether the user is on a bus, near one, or waiting at a stop, + * based on their current position and recent location trail. + * @param req - Express request, expects `DetectOnBusBody` in the body + * @param res - Express response + * @returns JSON object with `status`, `onBus`, and matched bus details. + */ +export function detectOnBus(req: express.Request, res: express.Response) { + const result = DetectOnBusBody.safeParse(req.body); + if (!result.success) { + res.status(400).json({ error: result.error.message }); + return; + } + try { + const { lat, lon, locationTrail, candidateVid } = result.data; + const classification = onBusService.classifyOnBusStatus(lat, lon, locationTrail, candidateVid); + + const response: any = { + status: classification.status, + onBus: classification.status === 'on_bus', + confidence: classification.confidence, + reason: classification.reason, + }; + + if (classification.vid) { + response.vid = classification.vid; + const bus = state.curBusPositions.buses.find(b => (b.vid || b.id) === classification.vid); + if (bus) { + const busLat = parseFloat(bus.lat); + const busLon = parseFloat(bus.lon); + if (!isNaN(busLat) && !isNaN(busLon)) { + response.busLat = busLat; + response.busLon = busLon; + response.distanceMeters = Math.round(haversine(lat, lon, busLat, busLon)); + } + if (bus.rt) response.rt = bus.rt; + if (bus.des) response.des = bus.des; + } + const trip = graphBuilder.findTripByVid(classification.vid); + if (trip) { + response.tripId = trip.tripId; + if (!response.rt) response.rt = state.tatripidToRt[trip.tripId]; + } + } + + res.json(response); + } catch (error) { + console.error('detect-on-bus error:', error); + res.status(500).json({ error: 'Detection failed' }); + } +} +router.post('/detect-on-bus', detectOnBus); + /** * Saves the current graph state to a file (DEV mode only). * @param req - Express request diff --git a/src/services/graphBuilder.ts b/src/services/graphBuilder.ts index 68ac957..22efe45 100644 --- a/src/services/graphBuilder.ts +++ b/src/services/graphBuilder.ts @@ -15,10 +15,18 @@ const DEFAULT_RIDE_ROUTES = ["3", "4", "5", "6", "22", "23", "25", "26", "27", " export async function updateBusPositions() { const buses = await mbus.fetchVehicles(DEFAULT_ROUTES); state.curBusPositions.buses = buses; + state.recordBusPositionHistory(buses); const ridesBusses = await rideBus.fetchVehicles(DEFAULT_RIDE_ROUTES); state.curRidePositions.buses = ridesBusses; } +/** Finds the active (non-virtual) trip in the cached graph for a vehicle ID. */ +export function findTripByVid(vid: string): Trip | null { + return state.cachedGraph.trips.find( + t => t.vid === vid && t.tripId !== 'VIRTUAL_ORIGIN_TRIP' && t.tripId !== 'VIRTUAL_DESTINATION_TRIP' + ) || null; +} + /** Initializes route data, caching patterns and stop locations. */ export async function initializeRoutes() { if (process.env.DEV_CACHE === 'true') return; diff --git a/src/services/journey.ts b/src/services/journey.ts index f5a0776..27e1986 100644 --- a/src/services/journey.ts +++ b/src/services/journey.ts @@ -1,6 +1,40 @@ import * as state from '../state/transitState'; import * as walking from '../walking/walkingMap'; +import * as onBusService from './onBus'; import { McRaptorAlgorithm, Journey, JourneyLeg } from "../raptor/McRaptorAlgorithm"; +import { AtStopContext, LocationSample, OnBusContext, Trip } from "../raptor/types"; + +/** Optional on-bus routing input passed from the API layer. */ +export interface OnBusOptions { + /** Vehicle ID the frontend believes the user is on. */ + vid?: string; + /** Recent user location samples for motion validation. */ + locationTrail?: LocationSample[]; +} + +/** + * Adjusts DUE (predicted within ~1 minute) departures at a stop the user is + * physically at, so they are boardable immediately instead of in 60 seconds. + * Returns adjusted copies; never mutates the cached graph trips. + * @param trips List of trips to adjust + * @param stopId The stop the user is standing at + * @param currentTime Current time in seconds since midnight (UTC) + */ +export function adjustDueTripsAtStop(trips: Trip[], stopId: string, currentTime: number): Trip[] { + return trips.map(trip => { + if (trip.tripId === 'VIRTUAL_ORIGIN_TRIP' || trip.tripId === 'VIRTUAL_DESTINATION_TRIP') return trip; + let changed = false; + const stopTimes = trip.stopTimes.map(st => { + const delta = st.arrivalTime - currentTime; + if (st.stop === stopId && delta >= -60 && delta <= 60) { + changed = true; + return { ...st, arrivalTime: currentTime, departureTime: currentTime }; + } + return st; + }); + return changed ? { ...trip, stopTimes } : trip; + }); +} /** * Plans a journey between two coordinates using the McRaptor algorithm. @@ -9,31 +43,130 @@ import { McRaptorAlgorithm, Journey, JourneyLeg } from "../raptor/McRaptorAlgori * @param dLat Destination latitude * @param dLon Destination longitude * @param time Start time in seconds since midnight - * @param options Optional parameters for walking penalty and search range + * @param options Optional parameters for walking penalty, search range, and on-bus routing + * @returns Object with `journeys` and, when on-bus routing was requested, `originContext` */ export async function planJourney( oLat: number, oLon: number, dLat: number, dLon: number, time: number, - options: { walkingPenalty?: number, range?: number } + options: { walkingPenalty?: number, range?: number, onBus?: OnBusOptions } ) { const V_ORIGIN = 'VIRTUAL_ORIGIN'; const V_DEST = 'VIRTUAL_DESTINATION'; - const walksFromOrigin = walking.getWalkingDistancesFrom(oLat, oLon, dLat, dLon); + let originContext: any = null; + let onBusContext: OnBusContext | null = null; + let atStopContext: AtStopContext | null = null; + + if (options.onBus) { + const trail = options.onBus.locationTrail || []; + if (trail.length === 0) { + originContext = { + mode: 'walking', status: 'not_near_bus', + validated: false, fallbackReason: 'trail_required' + }; + } else { + const classification = onBusService.classifyOnBusStatus(oLat, oLon, trail, options.onBus.vid); + if (classification.status === 'on_bus' && classification.vid) { + onBusContext = onBusService.resolveOnBusContext( + classification.vid, oLat, oLon, time, classification + ); + if (onBusContext) { + originContext = { + mode: 'on_bus', + status: 'on_bus', + vid: onBusContext.vid, + tripId: onBusContext.tripId, + rt: onBusContext.rt, + validated: true, + confidence: classification.confidence, + isStoppedAtStop: onBusContext.isStoppedAtStop, + ...(onBusContext.isStoppedAtStop ? { + currentStopId: onBusContext.currentStopId, + currentStopName: onBusContext.currentStopName, + showsTransferOptions: true, + } : {}) + }; + } else { + originContext = { + mode: 'walking', status: classification.status, + vid: classification.vid, validated: false, + confidence: classification.confidence, fallbackReason: 'bus_not_found' + }; + } + } else { + const fallbackReason = + classification.status === 'waiting_at_stop' ? 'waiting_at_stop' : + classification.status === 'near_bus' ? 'co_movement_failed' : 'not_near_bus'; + originContext = { + mode: 'walking', status: classification.status, + vid: classification.vid, validated: false, + confidence: classification.confidence, fallbackReason + }; + } + } + + // At-stop origin: applies whenever on-bus wasn't confirmed but the user + // is physically standing at a stop (e.g. waiting for a bus there). + if (!onBusContext) { + atStopContext = onBusService.detectAtStopContext(oLat, oLon); + if (atStopContext) { + originContext = { + ...originContext, + mode: 'at_stop', + stopId: atStopContext.stopId, + stopName: atStopContext.stopName, + }; + } + } + } + const walksToDest = walking.getWalkingDistancesFrom(dLat, dLon); const transferData = { ...state.cachedGraph.transfers }; transferData[V_ORIGIN] = []; transferData[V_DEST] = []; - walksFromOrigin.forEach(walk => { - const dest = walk.stopId === "DIRECT_WALK" ? V_DEST : walk.stopId; + if (onBusContext) { + // The user starts aboard the bus: a zero-cost hop onto the trimmed trip. transferData[V_ORIGIN].push({ - origin: V_ORIGIN, destination: dest, - duration: walk.duration, startTime: time, endTime: Number.MAX_SAFE_INTEGER + origin: V_ORIGIN, destination: onBusContext.virtualStopId, + duration: 0, startTime: time, endTime: Number.MAX_SAFE_INTEGER }); - }); + // If the bus is stopped at a physical stop, the user can also step off + // right now and transfer there — inject a second zero-cost origin path. + if (onBusContext.isStoppedAtStop && onBusContext.currentStopId) { + transferData[V_ORIGIN].push({ + origin: V_ORIGIN, destination: onBusContext.currentStopId, + duration: 0, startTime: time, endTime: Number.MAX_SAFE_INTEGER + }); + } + } else if (atStopContext) { + // User is standing at the stop: route directly from it with no walk penalty. + transferData[V_ORIGIN].push({ + origin: V_ORIGIN, destination: atStopContext.stopId, + duration: 0, startTime: time, endTime: Number.MAX_SAFE_INTEGER + }); + // Keep the direct origin-to-destination walk available. + const walksFromOrigin = walking.getWalkingDistancesFrom(oLat, oLon, dLat, dLon); + const direct = walksFromOrigin.find(w => w.stopId === 'DIRECT_WALK'); + if (direct) { + transferData[V_ORIGIN].push({ + origin: V_ORIGIN, destination: V_DEST, + duration: direct.duration, startTime: time, endTime: Number.MAX_SAFE_INTEGER + }); + } + } else { + const walksFromOrigin = walking.getWalkingDistancesFrom(oLat, oLon, dLat, dLon); + walksFromOrigin.forEach(walk => { + const dest = walk.stopId === "DIRECT_WALK" ? V_DEST : walk.stopId; + transferData[V_ORIGIN].push({ + origin: V_ORIGIN, destination: dest, + duration: walk.duration, startTime: time, endTime: Number.MAX_SAFE_INTEGER + }); + }); + } walksToDest.forEach(walk => { transferData[walk.stopId] = [...(transferData[walk.stopId] || [])]; @@ -43,7 +176,7 @@ export async function planJourney( }); }); - const requestTrips = state.cachedGraph.trips.map(trip => { + let requestTrips = state.cachedGraph.trips.map(trip => { if (trip.tripId === 'VIRTUAL_ORIGIN_TRIP') { return { ...trip, @@ -59,7 +192,20 @@ export async function planJourney( return trip; }); - const mcRaptor = new McRaptorAlgorithm(requestTrips, transferData, state.cachedGraph.interchange); + if (onBusContext) { + requestTrips.push(onBusContext.trimmedTrip); + if (onBusContext.isStoppedAtStop && onBusContext.currentStopId) { + requestTrips = adjustDueTripsAtStop(requestTrips, onBusContext.currentStopId, time); + } + } else if (atStopContext) { + requestTrips = adjustDueTripsAtStop(requestTrips, atStopContext.stopId, time); + } + + const interchangeData = onBusContext + ? { ...state.cachedGraph.interchange, [onBusContext.virtualStopId]: 0 } + : state.cachedGraph.interchange; + + const mcRaptor = new McRaptorAlgorithm(requestTrips, transferData, interchangeData); mcRaptor.setWalkingPenalty(options.walkingPenalty || 1); const range = options.range; @@ -72,17 +218,28 @@ export async function planJourney( range ?? 45 * 60 ); - return processJourneys(journeys, oLat, oLon, dLat, dLon); + const processed = await processJourneys(journeys, oLat, oLon, dLat, dLon, { onBusContext, atStopContext }); + return { journeys: processed, originContext }; } -async function processJourneys(journeys: Journey[], oLat: number, oLon: number, dLat: number, dLon: number) { +async function processJourneys( + journeys: Journey[], + oLat: number, oLon: number, + dLat: number, dLon: number, + context: { onBusContext?: OnBusContext | null, atStopContext?: AtStopContext | null } = {} +) { + const onBusCtx = context.onBusContext || null; + const atStopCtx = context.atStopContext || null; const processLeg = async (leg: JourneyLeg) => { const isWalk = !leg.trip; + const isOnBusOrigin = onBusCtx !== null && leg.origin === onBusCtx.virtualStopId; const formattedLeg: any = { origin_id: leg.origin, - origin: leg.origin === 'VIRTUAL_ORIGIN' ? 'Start' : (leg.origin === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.origin] || leg.origin)), + origin: leg.origin === 'VIRTUAL_ORIGIN' ? 'Start' + : isOnBusOrigin ? 'On Bus' + : (leg.origin === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.origin] || leg.origin)), destination_id: leg.destination, destination: leg.destination === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.destination] || leg.destination), destinationName: leg.destination === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.destination] || leg.destination), @@ -133,13 +290,36 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, const processedList = await Promise.all(journeys.map(async (journey: Journey) => { if (!journey) return null; - const legs = await Promise.all(journey.legs.map(processLeg)); + let rawLegs = journey.legs; + // Drop the synthetic zero-duration hop from VIRTUAL_ORIGIN in + // on-bus / at-stop modes (the user is already there). + if ((onBusCtx || atStopCtx) && rawLegs.length > 1 + && rawLegs[0].type === 'Transfer' + && rawLegs[0].origin === 'VIRTUAL_ORIGIN' + && rawLegs[0].duration === 0) { + rawLegs = rawLegs.slice(1); + } + + const legs = await Promise.all(rawLegs.map(processLeg)); + + const criteria = { ...journey.criteria }; + if (onBusCtx && legs.length > 0) { + const firstBusLeg = legs.find((l: any) => l.mode === 'bus'); + if (firstBusLeg && firstBusLeg.vid === onBusCtx.vid) { + legs[0].originChoice = 'continue_on_bus'; + firstBusLeg.busPosition = { lat: onBusCtx.busLat, lon: onBusCtx.busLon }; + // The ride the user is already on isn't a new boarding + criteria.transferCount = Math.max(0, criteria.transferCount - 1); + } else if (firstBusLeg) { + legs[0].originChoice = 'alight_and_transfer'; + } + } return { legs, - departureTime: journey.criteria.arrivalTime - (legs.reduce((acc, leg) => acc + leg.duration, 0)), - arrivalTime: journey.criteria.arrivalTime, - criteria: journey.criteria + departureTime: criteria.arrivalTime - (legs.reduce((acc: number, leg: any) => acc + leg.duration, 0)), + arrivalTime: criteria.arrivalTime, + criteria }; })); @@ -149,4 +329,4 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, a.arrivalTime - b.arrivalTime || a.criteria.walkingDistance - b.criteria.walkingDistance ); -} \ No newline at end of file +} diff --git a/src/services/onBus.ts b/src/services/onBus.ts new file mode 100644 index 0000000..ede9690 --- /dev/null +++ b/src/services/onBus.ts @@ -0,0 +1,446 @@ +import * as state from '../state/transitState'; +import * as graphBuilder from './graphBuilder'; +import { haversine } from '../walking/loadMap'; +import { + AtStopContext, + LocationSample, + OnBusClassification, + OnBusContext, + OnBusStatus, + Trip, +} from '../raptor/types'; + +/** Max user-to-bus distance to consider the bus a candidate. */ +export const PROXIMITY_THRESHOLD_M = 50; +/** Distance to a known stop for the user/bus to count as "at" that stop. */ +export const STOP_PROXIMITY_M = 40; +/** Distance to the bus center to count as aboard while both are stationary. */ +export const TIGHT_BUS_PROXIMITY_M = 15; +/** Speeds below this are treated as stationary. */ +export const STOPPED_SPEED_MS = 0.8; +/** A bus is considered moving above this speed. */ +export const MOVING_BUS_SPEED_MS = 1.5; +/** Max heading difference for co-movement. */ +export const HEADING_TOLERANCE_DEG = 50; +/** User speed vs bus speed ratio bounds for co-movement. */ +export const SPEED_RATIO_MIN = 0.4; +export const SPEED_RATIO_MAX = 1.6; +/** Minimum location samples for a usable trail. */ +export const MIN_TRAIL_SAMPLES = 3; +/** Minimum time span (ms) for a usable trail. */ +export const MIN_TRAIL_DURATION_MS = 10_000; + +type Velocity = { speed: number, heading: number | null }; + +/** Initial bearing in degrees (0-360) from point A to point B. */ +function bearing(aLat: number, aLon: number, bLat: number, bLon: number): number { + const toRad = (d: number) => d * Math.PI / 180; + const dLon = toRad(bLon - aLon); + const y = Math.sin(dLon) * Math.cos(toRad(bLat)); + const x = Math.cos(toRad(aLat)) * Math.sin(toRad(bLat)) + - Math.sin(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.cos(dLon); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +} + +/** Absolute angular difference between two headings, in [0, 180]. */ +function headingDiff(a: number, b: number): number { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} + +/** + * Derives speed (m/s) and heading from a series of timestamped positions. + * Uses the most recent window of samples to reflect current motion. + */ +export function velocityFromSamples( + samples: { lat: number, lon: number, timestamp: number }[], + windowMs: number = 15_000 +): Velocity | null { + if (samples.length < 2) return null; + const sorted = [...samples].sort((a, b) => a.timestamp - b.timestamp); + const last = sorted[sorted.length - 1]; + let first = sorted[0]; + for (const s of sorted) { + if (last.timestamp - s.timestamp <= windowMs) { first = s; break; } + } + if (first === last) first = sorted[sorted.length - 2]; + const dt = (last.timestamp - first.timestamp) / 1000; + if (dt <= 0) return null; + const dist = haversine(first.lat, first.lon, last.lat, last.lon); + const speed = dist / dt; + // Heading is unreliable for tiny displacements + const heading = dist > 5 ? bearing(first.lat, first.lon, last.lat, last.lon) : null; + return { speed, heading }; +} + +/** User velocity, preferring device-reported speed/heading over GPS-derived values. */ +function userVelocity(trail: LocationSample[]): Velocity | null { + const last = trail[trail.length - 1]; + const derived = velocityFromSamples(trail); + const speed = last?.speed !== undefined ? last.speed : derived?.speed; + if (speed === undefined) return null; + const heading = last?.heading !== undefined ? last.heading : (derived?.heading ?? null); + return { speed, heading }; +} + +/** Current bus velocity from its most recent pair of history samples. */ +function busVelocity(vid: string, bus?: any): Velocity { + const history = state.busPositionHistory[vid] || []; + if (history.length >= 2) { + const a = history[history.length - 2]; + const b = history[history.length - 1]; + const dt = (b.timestamp - a.timestamp) / 1000; + if (dt > 0) { + const dist = haversine(a.lat, a.lon, b.lat, b.lon); + const speed = dist / dt; + const heading = dist > 5 + ? bearing(a.lat, a.lon, b.lat, b.lon) + : (b.heading ?? null); + return { speed, heading }; + } + } + // No usable history yet: conservatively treat as stationary + const hdg = bus ? parseFloat(bus.hdg) : NaN; + return { speed: 0, heading: isNaN(hdg) ? null : hdg }; +} + +function isTrailValid(trail: LocationSample[]): boolean { + if (trail.length < MIN_TRAIL_SAMPLES) return false; + return trail[trail.length - 1].timestamp - trail[0].timestamp >= MIN_TRAIL_DURATION_MS; +} + +/** Finds the bus history sample closest in time to `timestamp`, within `maxDeltaMs`. */ +function nearestInTime( + history: state.BusPositionSample[], + timestamp: number, + maxDeltaMs: number +): state.BusPositionSample | null { + let best: state.BusPositionSample | null = null; + let bestDelta = Infinity; + for (const h of history) { + const d = Math.abs(h.timestamp - timestamp); + if (d < bestDelta) { bestDelta = d; best = h; } + } + return bestDelta <= maxDeltaMs ? best : null; +} + +/** + * Checks whether the trail contains an earlier window where the user moved + * with this bus while the bus was in motion (the "was riding" signal used + * when the bus is currently stopped at a light or stop). + */ +function hadPriorCoMovement(trail: LocationSample[], history: state.BusPositionSample[]): boolean { + const sortedHist = [...history].sort((a, b) => a.timestamp - b.timestamp); + for (let i = 0; i + 1 < sortedHist.length; i++) { + const b1 = sortedHist[i]; + const b2 = sortedHist[i + 1]; + const dt = (b2.timestamp - b1.timestamp) / 1000; + if (dt <= 0) continue; + const busSpeed = haversine(b1.lat, b1.lon, b2.lat, b2.lon) / dt; + if (busSpeed < MOVING_BUS_SPEED_MS) continue; + + const userWindow = trail.filter( + s => s.timestamp >= b1.timestamp - 5000 && s.timestamp <= b2.timestamp + 5000 + ); + if (userWindow.length < 2) continue; + const uVel = velocityFromSamples(userWindow, Number.MAX_SAFE_INTEGER); + if (!uVel || uVel.speed < MOVING_BUS_SPEED_MS) continue; + + const busHeading = bearing(b1.lat, b1.lon, b2.lat, b2.lon); + if (uVel.heading !== null && headingDiff(uVel.heading, busHeading) > HEADING_TOLERANCE_DEG) continue; + + const ratio = uVel.speed / busSpeed; + if (ratio < SPEED_RATIO_MIN || ratio > SPEED_RATIO_MAX) continue; + + const mid = userWindow[Math.floor(userWindow.length / 2)]; + const distToSegment = Math.min( + haversine(mid.lat, mid.lon, b1.lat, b1.lon), + haversine(mid.lat, mid.lon, b2.lat, b2.lon) + ); + if (distToSegment > PROXIMITY_THRESHOLD_M) continue; + + return true; + } + return false; +} + +/** Whether the user stayed within tight proximity of the bus across the trail. */ +function sustainedTightProximity(trail: LocationSample[], history: state.BusPositionSample[]): boolean { + let matched = 0; + let within = 0; + for (const s of trail) { + const b = nearestInTime(history, s.timestamp, 10_000); + if (!b) continue; + matched++; + if (haversine(s.lat, s.lon, b.lat, b.lon) <= TIGHT_BUS_PROXIMITY_M) within++; + } + return matched >= 2 && within / matched >= 0.6; +} + +/** Whether the user-to-bus distance stayed stable (not drifting apart) over the trail. */ +function distanceStable(trail: LocationSample[], history: state.BusPositionSample[]): boolean { + const distances: number[] = []; + for (const s of trail) { + const b = nearestInTime(history, s.timestamp, 10_000); + if (b) distances.push(haversine(s.lat, s.lon, b.lat, b.lon)); + } + if (distances.length < 2) return true; // not enough overlap to judge; don't reject + return distances[distances.length - 1] <= distances[0] + 20; +} + +const STATUS_RANK: Record = { + on_bus: 3, + waiting_at_stop: 2, + near_bus: 1, + not_near_bus: 0, +}; + +/** + * Detects whether the user is physically standing at a known stop. + * @returns Context for the nearest stop within STOP_PROXIMITY_M, or null. + */ +export function detectAtStopContext(userLat: number, userLon: number): AtStopContext | null { + try { + const nearest = graphBuilder.findNearestStops(userLat, userLon, 1); + if (!nearest.length || nearest[0].distance > STOP_PROXIMITY_M) return null; + return { + stopId: nearest[0].stpid, + stopName: nearest[0].name, + distanceMeters: nearest[0].distance, + walkTimeSeconds: 0, + }; + } catch { + return null; + } +} + +/** + * Classifies the user's relationship to nearby buses using motion correlation. + * Proximity alone never yields `on_bus`; the trail must show co-movement + * (or prior co-movement when the bus is currently stopped). + * + * @param userLat - Current user latitude. + * @param userLon - Current user longitude. + * @param locationTrail - Recent user location samples (oldest to newest). + * @param candidateVid - Optional vehicle ID to restrict evaluation to. + */ +export function classifyOnBusStatus( + userLat: number, + userLon: number, + locationTrail: LocationSample[], + candidateVid?: string +): OnBusClassification { + const trail = [...(locationTrail || [])].sort((a, b) => a.timestamp - b.timestamp); + + const candidates: { vid: string, bus: any, distance: number }[] = []; + for (const bus of state.curBusPositions.buses) { + const vid = bus.vid || bus.id; + if (!vid) continue; + if (candidateVid && vid !== candidateVid) continue; + const lat = parseFloat(bus.lat); + const lon = parseFloat(bus.lon); + if (isNaN(lat) || isNaN(lon)) continue; + const distance = haversine(userLat, userLon, lat, lon); + if (distance <= PROXIMITY_THRESHOLD_M) candidates.push({ vid, bus, distance }); + } + + if (candidates.length === 0) { + return { + status: 'not_near_bus', + confidence: 0, + reason: candidateVid ? 'candidate_bus_not_nearby' : 'no_bus_within_proximity', + }; + } + + const trailValid = isTrailValid(trail); + const uVel = trailValid ? userVelocity(trail) : null; + const userStopped = !uVel || uVel.speed < STOPPED_SPEED_MS; + const atStop = detectAtStopContext(userLat, userLon) !== null; + + let best: OnBusClassification | null = null; + for (const cand of candidates) { + const result = classifyCandidate(cand.vid, cand.bus, trail, trailValid, uVel, userStopped, atStop); + if (!best + || STATUS_RANK[result.status] > STATUS_RANK[best.status] + || (STATUS_RANK[result.status] === STATUS_RANK[best.status] && result.confidence > best.confidence)) { + best = result; + } + } + return best!; +} + +function classifyCandidate( + vid: string, + bus: any, + trail: LocationSample[], + trailValid: boolean, + uVel: Velocity | null, + userStopped: boolean, + atStop: boolean +): OnBusClassification { + const history = state.busPositionHistory[vid] || []; + const bVel = busVelocity(vid, bus); + const busMoving = bVel.speed >= MOVING_BUS_SPEED_MS; + const priorCoMove = trailValid && hadPriorCoMovement(trail, history); + + if (busMoving) { + if (!trailValid) { + return { status: 'near_bus', vid, confidence: 0.2, reason: 'trail_insufficient' }; + } + if (!uVel || userStopped || uVel.speed < MOVING_BUS_SPEED_MS) { + return { status: 'near_bus', vid, confidence: 0.2, reason: 'user_not_moving_with_bus' }; + } + + const hd = (uVel.heading !== null && bVel.heading !== null) + ? headingDiff(uVel.heading, bVel.heading) + : null; + const ratio = bVel.speed > 0 ? uVel.speed / bVel.speed : 0; + const headingOk = hd === null || hd < HEADING_TOLERANCE_DEG; + const ratioOk = ratio >= SPEED_RATIO_MIN && ratio <= SPEED_RATIO_MAX; + const stable = distanceStable(trail, history); + + if (headingOk && ratioOk && stable) { + const headingScore = hd === null ? 0.5 : 1 - hd / HEADING_TOLERANCE_DEG; + const ratioScore = Math.max(0, 1 - Math.abs(1 - ratio)); + const confidence = Math.min(1, 0.5 + 0.25 * headingScore + 0.25 * ratioScore); + return { status: 'on_bus', vid, confidence, reason: 'co_movement_matched' }; + } + const reason = !headingOk ? 'heading_mismatch' : !ratioOk ? 'speed_mismatch' : 'drifting_from_bus'; + return { status: 'near_bus', vid, confidence: 0.3, reason }; + } + + // Bus is stationary (or too slow to confirm motion) + if (priorCoMove) { + if (atStop) { + if (sustainedTightProximity(trail, history)) { + // User tracked this bus while it moved and is still right on top of it + return { status: 'on_bus', vid, confidence: 0.7, reason: 'prior_co_movement_bus_at_stop' }; + } + // Was near the bus but not tightly: ambiguous, default to waiting + return { status: 'waiting_at_stop', vid, confidence: 0.5, reason: 'prior_co_movement_not_tight' }; + } + // e.g. bus paused at a red light mid-route while user remains aboard + return { status: 'on_bus', vid, confidence: 0.6, reason: 'prior_co_movement_bus_paused' }; + } + + if (atStop && userStopped) { + return { status: 'waiting_at_stop', vid, confidence: 0.6, reason: 'user_stationary_near_stop' }; + } + + return { + status: 'near_bus', + vid, + confidence: 0.2, + reason: trailValid ? 'no_co_movement_detected' : 'trail_insufficient', + }; +} + +/** Picks the trip stop index the user can ride from, using predictions then time then distance. */ +function determineBoardIndex( + trip: Trip, + vid: string, + busLat: number, + busLon: number, + currentTime: number +): number { + // Primary: the vehicle's next predicted stop + const preds = state.cachedPredsByVid[vid]; + if (preds && preds.length > 0) { + const idx = trip.stopTimes.findIndex(st => st.stop === preds[0].stpid); + if (idx >= 0) return idx; + } + // Secondary: first stop the trip has not yet reached + const timeIdx = trip.stopTimes.findIndex(st => st.arrivalTime >= currentTime); + if (timeIdx >= 0) return timeIdx; + // Tertiary: stop nearest to the bus's GPS position + let bestIdx = 0; + let bestDist = Infinity; + trip.stopTimes.forEach((st, i) => { + const loc = state.cachedStopLocations[st.stop]; + if (!loc) return; + const d = haversine(busLat, busLon, loc.lat, loc.lon); + if (d < bestDist) { bestDist = d; bestIdx = i; } + }); + return bestIdx; +} + +/** + * Builds the routing context for a user confirmed to be on a bus: + * locates the vehicle and its active trip, picks the boarding index, resolves + * the physical stop if the bus is stopped at one, and builds the trimmed trip + * starting from a virtual on-bus stop. + * + * @param vid - Vehicle ID the user is on. + * @param userLat - Current user latitude (unused for resolution, kept for parity). + * @param userLon - Current user longitude. + * @param currentTime - Journey time in seconds since midnight (UTC). + * @param classification - The classification that confirmed on-bus status. + * @returns Context for graph injection, or null when the bus has no active trip. + */ +export function resolveOnBusContext( + vid: string, + userLat: number, + userLon: number, + currentTime: number, + classification: OnBusClassification +): OnBusContext | null { + const bus = state.curBusPositions.buses.find(b => (b.vid || b.id) === vid); + if (!bus) return null; + const busLat = parseFloat(bus.lat); + const busLon = parseFloat(bus.lon); + if (isNaN(busLat) || isNaN(busLon)) return null; + + const trip = graphBuilder.findTripByVid(vid); + if (!trip || trip.stopTimes.length === 0) return null; + + const boardStopIndex = determineBoardIndex(trip, vid, busLat, busLon, currentTime); + const remaining = trip.stopTimes.slice(boardStopIndex); + if (remaining.length === 0) return null; + + // Physical stop the bus is currently servicing, if stationary at one + let currentStopId: string | undefined; + let currentStopName: string | undefined; + let isStoppedAtStop = false; + if (busVelocity(vid, bus).speed < STOPPED_SPEED_MS) { + try { + const nearest = graphBuilder.findNearestStops(busLat, busLon, 1); + if (nearest.length && nearest[0].distance <= STOP_PROXIMITY_M) { + currentStopId = nearest[0].stpid; + currentStopName = nearest[0].name; + isStoppedAtStop = true; + } + } catch { + // No stop locations cached; treat as not at a stop + } + } + + const virtualStopId = `ON_BUS_${vid}`; + const rt = remaining[0].rt || state.tatripidToRt[trip.tripId] || bus.rt || 'UNKNOWN'; + + const trimmedTrip: Trip = { + tripId: trip.tripId, + vid: trip.vid, + stopTimes: [ + { stop: virtualStopId, arrivalTime: currentTime, departureTime: currentTime, pickUp: true, dropOff: false }, + // Clamp slightly-stale predictions so times stay monotonic from "now" + ...remaining.map(st => st.arrivalTime < currentTime + ? { ...st, arrivalTime: currentTime, departureTime: Math.max(st.departureTime, currentTime) } + : st), + ], + }; + + return { + vid, + tripId: trip.tripId, + rt, + virtualStopId, + boardStopIndex, + busLat, + busLon, + trimmedTrip, + classification, + currentStopId, + currentStopName, + isStoppedAtStop, + }; +} diff --git a/src/state/transitState.ts b/src/state/transitState.ts index 6686a27..60b675a 100644 --- a/src/state/transitState.ts +++ b/src/state/transitState.ts @@ -4,6 +4,53 @@ import { Trip, TransfersByOrigin, Interchange } from "../raptor/types"; export const curBusPositions = { buses: [] as any[] }; /** Current positions of all ride buses. */ export const curRidePositions = { buses: [] as any[] }; + +/** A single historical GPS sample for a bus. */ +export type BusPositionSample = { + lat: number, + lon: number, + /** Epoch milliseconds (poll time). */ + timestamp: number, + /** Heading in degrees from the vehicle's hdg field, if available. */ + heading?: number +}; + +/** Maximum age of bus position history samples, in milliseconds. */ +export const BUS_HISTORY_MAX_AGE_MS = 60_000; + +/** Recent position samples per vehicle ID (ring buffer, last ~60s). */ +export const busPositionHistory: Record = {}; + +/** Appends current bus positions to the per-vid history and prunes old samples. */ +export function recordBusPositionHistory(buses: any[], now: number = Date.now()) { + const seen = new Set(); + for (const bus of buses) { + const vid = bus.vid || bus.id; + const lat = parseFloat(bus.lat); + const lon = parseFloat(bus.lon); + if (!vid || isNaN(lat) || isNaN(lon)) continue; + seen.add(vid); + + const sample: BusPositionSample = { lat, lon, timestamp: now }; + const hdg = parseFloat(bus.hdg); + if (!isNaN(hdg)) sample.heading = hdg; + + if (!busPositionHistory[vid]) busPositionHistory[vid] = []; + busPositionHistory[vid].push(sample); + busPositionHistory[vid] = busPositionHistory[vid].filter( + s => now - s.timestamp <= BUS_HISTORY_MAX_AGE_MS + ); + } + // Drop history for vehicles no longer reporting + for (const vid of Object.keys(busPositionHistory)) { + if (!seen.has(vid)) { + const samples = busPositionHistory[vid]; + if (!samples.length || now - samples[samples.length - 1].timestamp > BUS_HISTORY_MAX_AGE_MS) { + delete busPositionHistory[vid]; + } + } + } +} /** Cache of route patterns and static data. */ export const cachedRoutes: Record = {}; /** Cache of route patterns and static data for the ride. */ diff --git a/src/walking/walkingMap.ts b/src/walking/walkingMap.ts index b105667..6a7a83c 100644 --- a/src/walking/walkingMap.ts +++ b/src/walking/walkingMap.ts @@ -537,9 +537,14 @@ export function getCachedWalk(originId: string, destId: string): WalkingResponse initializeGraph(); if (existsSync(WALKING_CACHE_PATH)) { - const file = readFileSync(WALKING_CACHE_PATH, "utf8"); - Object.assign(walkingCache, JSON.parse(file)); - console.log("Loaded walkingCache.json"); + try { + const file = readFileSync(WALKING_CACHE_PATH, "utf8"); + Object.assign(walkingCache, JSON.parse(file)); + console.log("Loaded walkingCache.json"); + } catch (err) { + // A truncated cache (e.g. from a failed write on a full disk) must not crash startup + console.warn("walkingCache.json is corrupt — ignoring it and rebuilding the cache", err); + } } else { console.log("walkingCache.json does not exist — using empty cache"); } \ No newline at end of file diff --git a/test/onBus.test.ts b/test/onBus.test.ts new file mode 100644 index 0000000..3b7b6ca --- /dev/null +++ b/test/onBus.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import * as onBus from '@/services/onBus'; +import * as state from '@/state/transitState'; +import { adjustDueTripsAtStop } from '@/services/journey'; +import { LocationSample, Trip } from '@/raptor/types'; + +const BASE_LAT = 42.27; +const BASE_LON = -83.73; +const M_PER_DEG_LAT = 111320; + +/** Converts a northward offset in meters to degrees of latitude. */ +function offsetLat(meters: number): number { + return meters / M_PER_DEG_LAT; +} + +function resetState() { + state.curBusPositions.buses = []; + for (const key of Object.keys(state.busPositionHistory)) delete state.busPositionHistory[key]; + for (const key of Object.keys(state.cachedPredsByVid)) delete state.cachedPredsByVid[key]; + state.setCachedStopLocations({}); + state.setCachedGraph({ trips: [], transfers: {}, interchange: {} }); +} + +/** Seeds a bus's history and live position. The last sample is the live position. */ +function seedBus(vid: string, samples: { lat: number, lon: number, timestamp: number }[], extra: any = {}) { + state.busPositionHistory[vid] = samples.map(s => ({ ...s })); + const last = samples[samples.length - 1]; + state.curBusPositions.buses.push({ + vid, + lat: String(last.lat), + lon: String(last.lon), + ...extra, + }); +} + +describe('classifyOnBusStatus', () => { + beforeEach(resetState); + + it('confirms on_bus when user moves with a moving bus', () => { + const t0 = Date.now(); + // Bus heading north at 8 m/s (60m per 7.5s poll) + seedBus('341', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 15000 }, + { lat: BASE_LAT + offsetLat(60), lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT + offsetLat(120), lon: BASE_LON, timestamp: t0 }, + ], { rt: 'CN' }); + + // User co-located (5m east offset), same northward speed + const lonOffset = 5 / (M_PER_DEG_LAT * Math.cos(BASE_LAT * Math.PI / 180)); + const trail: LocationSample[] = [0, 5000, 10000, 15000].map(dt => ({ + lat: BASE_LAT + offsetLat(8 * dt / 1000), + lon: BASE_LON + lonOffset, + timestamp: t0 - 15000 + dt, + })); + + const result = onBus.classifyOnBusStatus( + BASE_LAT + offsetLat(120), BASE_LON + lonOffset, trail + ); + expect(result.status).toBe('on_bus'); + expect(result.vid).toBe('341'); + expect(result.confidence).toBeGreaterThan(0.5); + }); + + it('classifies waiting_at_stop when user and bus are both stationary at a stop', () => { + const t0 = Date.now(); + state.setCachedStopLocations({ + 'C250': { name: 'Central Campus Transit Center', lat: BASE_LAT, lon: BASE_LON }, + }); + // Bus parked 20m north of the stop + const busLat = BASE_LAT + offsetLat(20); + seedBus('341', [ + { lat: busLat, lon: BASE_LON, timestamp: t0 - 15000 }, + { lat: busLat, lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: busLat, lon: BASE_LON, timestamp: t0 }, + ]); + + // User stationary at the stop + const trail: LocationSample[] = [0, 5000, 10000, 15000].map(dt => ({ + lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 15000 + dt, + })); + + const result = onBus.classifyOnBusStatus(BASE_LAT, BASE_LON, trail); + expect(result.status).toBe('waiting_at_stop'); + }); + + it('classifies near_bus when a moving bus passes a stationary user', () => { + const t0 = Date.now(); + seedBus('341', [ + { lat: BASE_LAT - offsetLat(110), lon: BASE_LON, timestamp: t0 - 15000 }, + { lat: BASE_LAT - offsetLat(50), lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT + offsetLat(10), lon: BASE_LON, timestamp: t0 }, + ]); + + const trail: LocationSample[] = [0, 5000, 10000, 15000].map(dt => ({ + lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 15000 + dt, + })); + + const result = onBus.classifyOnBusStatus(BASE_LAT, BASE_LON, trail); + expect(result.status).toBe('near_bus'); + }); + + it('classifies not_near_bus when no bus is within proximity', () => { + const t0 = Date.now(); + seedBus('341', [ + { lat: BASE_LAT + offsetLat(500), lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT + offsetLat(500), lon: BASE_LON, timestamp: t0 }, + ]); + + const trail: LocationSample[] = [0, 5000, 10000, 15000].map(dt => ({ + lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 15000 + dt, + })); + + const result = onBus.classifyOnBusStatus(BASE_LAT, BASE_LON, trail); + expect(result.status).toBe('not_near_bus'); + }); + + it('keeps on_bus when the bus stops at a stop after co-movement (rider aboard)', () => { + const t0 = Date.now(); + const stopLat = BASE_LAT + offsetLat(120); + state.setCachedStopLocations({ + 'C250': { name: 'Central Campus Transit Center', lat: stopLat, lon: BASE_LON }, + }); + // Bus drives north then dwells at the stop + seedBus('341', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 30000 }, + { lat: BASE_LAT + offsetLat(60), lon: BASE_LON, timestamp: t0 - 22500 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 - 15000 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 }, + ]); + + // User rode along, then stayed aboard while the bus dwells + const trail: LocationSample[] = [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 30000 }, + { lat: BASE_LAT + offsetLat(40), lon: BASE_LON, timestamp: t0 - 25000 }, + { lat: BASE_LAT + offsetLat(80), lon: BASE_LON, timestamp: t0 - 20000 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 - 15000 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 - 10000 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 - 5000 }, + { lat: stopLat, lon: BASE_LON, timestamp: t0 }, + ]; + + const result = onBus.classifyOnBusStatus(stopLat, BASE_LON, trail); + expect(result.status).toBe('on_bus'); + expect(result.vid).toBe('341'); + }); + + it('never confirms on_bus from a trail that is too short', () => { + const t0 = Date.now(); + seedBus('341', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT + offsetLat(60), lon: BASE_LON, timestamp: t0 }, + ]); + + const trail: LocationSample[] = [ + { lat: BASE_LAT + offsetLat(60), lon: BASE_LON, timestamp: t0 - 2000 }, + { lat: BASE_LAT + offsetLat(60), lon: BASE_LON, timestamp: t0 }, + ]; + + const result = onBus.classifyOnBusStatus(BASE_LAT + offsetLat(60), BASE_LON, trail); + expect(result.status).not.toBe('on_bus'); + }); + + it('returns not_near_bus when the candidate vid is not nearby', () => { + const t0 = Date.now(); + seedBus('341', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 }, + ]); + + const trail: LocationSample[] = [0, 5000, 10000, 15000].map(dt => ({ + lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 15000 + dt, + })); + + const result = onBus.classifyOnBusStatus(BASE_LAT, BASE_LON, trail, '999'); + expect(result.status).toBe('not_near_bus'); + expect(result.reason).toBe('candidate_bus_not_nearby'); + }); +}); + +describe('detectAtStopContext', () => { + beforeEach(resetState); + + it('detects the stop when the user is within the proximity threshold', () => { + state.setCachedStopLocations({ + 'C250': { name: 'Central Campus Transit Center', lat: BASE_LAT, lon: BASE_LON }, + }); + const ctx = onBus.detectAtStopContext(BASE_LAT + offsetLat(10), BASE_LON); + expect(ctx).not.toBeNull(); + expect(ctx!.stopId).toBe('C250'); + expect(ctx!.walkTimeSeconds).toBe(0); + }); + + it('returns null when no stop is close enough', () => { + state.setCachedStopLocations({ + 'C250': { name: 'Central Campus Transit Center', lat: BASE_LAT, lon: BASE_LON }, + }); + const ctx = onBus.detectAtStopContext(BASE_LAT + offsetLat(100), BASE_LON); + expect(ctx).toBeNull(); + }); +}); + +describe('resolveOnBusContext', () => { + beforeEach(resetState); + + const classification = { status: 'on_bus' as const, vid: '341', confidence: 0.8, reason: 'co_movement_matched' }; + + it('builds a trimmed trip from the next predicted stop with a virtual on-bus stop', () => { + const t0 = Date.now(); + const now = 50000; // seconds since midnight + state.setCachedStopLocations({ + 'S1': { name: 'Stop 1', lat: BASE_LAT - offsetLat(500), lon: BASE_LON }, + 'S2': { name: 'Stop 2', lat: BASE_LAT, lon: BASE_LON }, + 'S3': { name: 'Stop 3', lat: BASE_LAT + offsetLat(500), lon: BASE_LON }, + }); + state.setCachedGraph({ + trips: [{ + tripId: 'T1', vid: '341', + stopTimes: [ + { stop: 'S1', arrivalTime: now + 60, departureTime: now + 60, pickUp: true, dropOff: true, rt: 'CN' }, + { stop: 'S2', arrivalTime: now + 120, departureTime: now + 120, pickUp: true, dropOff: true, rt: 'CN' }, + { stop: 'S3', arrivalTime: now + 240, departureTime: now + 240, pickUp: true, dropOff: true, rt: 'CN' }, + ], + }], + transfers: {}, interchange: {}, + }); + state.cachedPredsByVid['341'] = [ + { rt: 'CN', vid: '341', stpid: 'S2', prdtm: t0 + 120_000, prdctdn: '2' }, + ]; + // Bus stationary right at S2 + seedBus('341', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 }, + ], { rt: 'CN' }); + + const ctx = onBus.resolveOnBusContext('341', BASE_LAT, BASE_LON, now, classification); + expect(ctx).not.toBeNull(); + expect(ctx!.virtualStopId).toBe('ON_BUS_341'); + expect(ctx!.boardStopIndex).toBe(1); + expect(ctx!.rt).toBe('CN'); + + const stops = ctx!.trimmedTrip.stopTimes; + expect(stops[0].stop).toBe('ON_BUS_341'); + expect(stops[0].pickUp).toBe(true); + expect(stops[0].dropOff).toBe(false); + expect(stops[0].departureTime).toBe(now); + expect(stops.slice(1).map(s => s.stop)).toEqual(['S2', 'S3']); + + // Bus is stationary at S2, so the physical stop is resolved for transfers + expect(ctx!.isStoppedAtStop).toBe(true); + expect(ctx!.currentStopId).toBe('S2'); + }); + + it('does not resolve a physical stop when the bus is moving', () => { + const t0 = Date.now(); + const now = 50000; + state.setCachedStopLocations({ + 'S2': { name: 'Stop 2', lat: BASE_LAT, lon: BASE_LON }, + }); + state.setCachedGraph({ + trips: [{ + tripId: 'T1', vid: '341', + stopTimes: [ + { stop: 'S2', arrivalTime: now + 120, departureTime: now + 120, pickUp: true, dropOff: true, rt: 'CN' }, + ], + }], + transfers: {}, interchange: {}, + }); + // Bus moving north at 8 m/s near S2 + seedBus('341', [ + { lat: BASE_LAT - offsetLat(60), lon: BASE_LON, timestamp: t0 - 7500 }, + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 }, + ]); + + const ctx = onBus.resolveOnBusContext('341', BASE_LAT, BASE_LON, now, classification); + expect(ctx).not.toBeNull(); + expect(ctx!.isStoppedAtStop).toBe(false); + expect(ctx!.currentStopId).toBeUndefined(); + }); + + it('returns null when the vehicle has no active trip in the graph', () => { + const t0 = Date.now(); + seedBus('999', [ + { lat: BASE_LAT, lon: BASE_LON, timestamp: t0 }, + ]); + const ctx = onBus.resolveOnBusContext('999', BASE_LAT, BASE_LON, 50000, classification); + expect(ctx).toBeNull(); + }); +}); + +describe('adjustDueTripsAtStop', () => { + it('makes DUE departures at the stop boardable now and leaves others unchanged', () => { + const now = 50000; + const trips: Trip[] = [ + { + tripId: 'T1', vid: '341', + stopTimes: [ + { stop: 'S1', arrivalTime: now + 60, departureTime: now + 60, pickUp: true, dropOff: true }, + { stop: 'S2', arrivalTime: now + 120, departureTime: now + 120, pickUp: true, dropOff: true }, + ], + }, + { + tripId: 'T2', vid: '342', + stopTimes: [ + { stop: 'S1', arrivalTime: now + 300, departureTime: now + 300, pickUp: true, dropOff: true }, + ], + }, + { + tripId: 'VIRTUAL_ORIGIN_TRIP', vid: null, + stopTimes: [ + { stop: 'VIRTUAL_ORIGIN', arrivalTime: 0, departureTime: 0, pickUp: true, dropOff: true }, + ], + }, + ]; + + const adjusted = adjustDueTripsAtStop(trips, 'S1', now); + + // DUE bus (60s out) at S1 is boardable immediately + expect(adjusted[0].stopTimes[0].arrivalTime).toBe(now); + expect(adjusted[0].stopTimes[0].departureTime).toBe(now); + // Later stop on the same trip is untouched + expect(adjusted[0].stopTimes[1].arrivalTime).toBe(now + 120); + // A bus 5 minutes out is not pulled forward + expect(adjusted[1].stopTimes[0].arrivalTime).toBe(now + 300); + // Virtual trips are untouched + expect(adjusted[2].stopTimes[0].arrivalTime).toBe(0); + + // Original trips are not mutated + expect(trips[0].stopTimes[0].arrivalTime).toBe(now + 60); + }); +}); + +describe('recordBusPositionHistory', () => { + beforeEach(resetState); + + it('appends samples per vid and prunes entries older than 60 seconds', () => { + const t0 = Date.now(); + state.recordBusPositionHistory( + [{ vid: '7', lat: String(BASE_LAT), lon: String(BASE_LON), hdg: '90' }], t0 + ); + state.recordBusPositionHistory( + [{ vid: '7', lat: String(BASE_LAT + offsetLat(60)), lon: String(BASE_LON), hdg: '0' }], t0 + 7500 + ); + + expect(state.busPositionHistory['7']).toHaveLength(2); + expect(state.busPositionHistory['7'][0].heading).toBe(90); + + // A poll 70s later prunes the old samples + state.recordBusPositionHistory( + [{ vid: '7', lat: String(BASE_LAT + offsetLat(120)), lon: String(BASE_LON) }], t0 + 70000 + ); + expect(state.busPositionHistory['7']).toHaveLength(1); + expect(state.busPositionHistory['7'][0].timestamp).toBe(t0 + 70000); + }); + + it('drops history for vehicles that stop reporting', () => { + const t0 = Date.now(); + state.recordBusPositionHistory( + [{ vid: '7', lat: String(BASE_LAT), lon: String(BASE_LON) }], t0 + ); + // 70s later the vehicle is gone from the feed + state.recordBusPositionHistory([], t0 + 70000); + expect(state.busPositionHistory['7']).toBeUndefined(); + }); +}); diff --git a/test/onBusLive.test.ts b/test/onBusLive.test.ts new file mode 100644 index 0000000..c03f820 --- /dev/null +++ b/test/onBusLive.test.ts @@ -0,0 +1,648 @@ +/** + * Live integration tests for on-bus detection and routing. + * + * These tests run against a RUNNING server (`npm start`) and use whatever + * buses are actually in service right now. User location data is synthesized + * from the real-time bus positions the server itself reports, so the suite + * adapts to live conditions: + * + * - If no buses are running, bus-dependent tests are skipped with a warning. + * - If no bus is currently moving / dwelling at a stop, the tests that need + * that condition are skipped with a warning. + * - Transient live-data races (a bus stopping or turning mid-test) downgrade + * to warnings instead of failures; hard invariants (e.g. a stationary user + * is NEVER classified on_bus) are always asserted strictly. + * + * Run with: npx vitest run test/onBusLive.test.ts + * (Optionally MBUS_TEST_PORT=#### to target a non-default port.) + */ +import axios from 'axios'; +import { describe, it, expect, beforeAll } from 'vitest'; + +const SERVER_PORT = process.env.MBUS_TEST_PORT || 3000; +const BASE_URL = `http://localhost:${SERVER_PORT}/mbus/api/v3`; + +const M_PER_DEG_LAT = 111320; +const mPerDegLon = (lat: number) => M_PER_DEG_LAT * Math.cos(lat * Math.PI / 180); + +/** Number of position polls in the shared observation window. */ +const OBSERVE_POLLS = 4; +/** Interval between polls (server refreshes its cache every ~7.5s). */ +const OBSERVE_INTERVAL_MS = 6000; + +/** Displacement over the window above which a bus counts as moving. */ +const MOVING_DISPLACEMENT_M = 50; +/** Displacement below which a bus counts as stationary. */ +const STATIONARY_DISPLACEMENT_M = 10; + +type UserSample = { lat: number, lon: number, timestamp: number }; +type BusTrack = { + vid: string, + rt?: string, + samples: { lat: number, lon: number, timestamp: number }[], +}; +type Stop = { stpid: string, name: string, lat: number, lon: number }; + +function haversineM(aLat: number, aLon: number, bLat: number, bLon: number): number { + const R = 6371000; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(bLat - aLat); + const dLon = toRad(bLon - aLon); + const sinDlat = Math.sin(dLat / 2); + const sinDlon = Math.sin(dLon / 2); + const a = sinDlat * sinDlat + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * sinDlon * sinDlon; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function warnSkip(test: string, why: string) { + console.warn(`\n⚠️ SKIPPED [${test}]: ${why}\n`); +} + +function warnLive(test: string, why: string) { + console.warn(`\n⚠️ LIVE-CONDITION [${test}]: ${why}\n`); +} + +async function fetchBuses(): Promise { + const res = await axios.get(`${BASE_URL}/getBusPositions`); + return res.data.buses || []; +} + +/** Total displacement of a track from first to last sample, in meters. */ +function displacement(track: BusTrack): number { + const s = track.samples; + if (s.length < 2) return 0; + return haversineM(s[0].lat, s[0].lon, s[s.length - 1].lat, s[s.length - 1].lon); +} + +/** Displacement between the last two samples of a track, in meters. */ +function recentMovement(track: BusTrack): number { + const s = track.samples; + if (s.length < 2) return 0; + return haversineM(s[s.length - 2].lat, s[s.length - 2].lon, s[s.length - 1].lat, s[s.length - 1].lon); +} + +/** Fakes a rider's GPS sample a few meters off the bus's GPS center. */ +function toUserSample(lat: number, lon: number, timestamp: number, offsetM = 3): UserSample { + return { lat, lon: lon + offsetM / mPerDegLon(lat), timestamp }; +} + +/** + * Builds a fake user trail that rides along an observed bus track, + * densified with midpoints to resemble a phone's 2-5s GPS cadence. + */ +function buildRidingTrail(track: BusTrack): UserSample[] { + const trail: UserSample[] = []; + const s = track.samples; + for (let i = 0; i < s.length; i++) { + if (i > 0) { + const a = s[i - 1]; + const b = s[i]; + trail.push(toUserSample((a.lat + b.lat) / 2, (a.lon + b.lon) / 2, Math.round((a.timestamp + b.timestamp) / 2))); + } + trail.push(toUserSample(s[i].lat, s[i].lon, s[i].timestamp)); + } + return trail; +} + +/** Builds a fake stationary user trail at a fixed point, ending now. */ +function buildStationaryTrail(lat: number, lon: number, spanMs = 24000, samples = 5): UserSample[] { + const now = Date.now(); + const trail: UserSample[] = []; + for (let i = 0; i < samples; i++) { + trail.push({ lat, lon, timestamp: now - spanMs + (spanMs / (samples - 1)) * i }); + } + return trail; +} + +async function detect(lat: number, lon: number, locationTrail: UserSample[], candidateVid?: string) { + const res = await axios.post(`${BASE_URL}/detect-on-bus`, { lat, lon, locationTrail, candidateVid }); + return res.data; +} + +async function planJourney(params: Record) { + const qs = new URLSearchParams(params).toString(); + const res = await axios.get(`${BASE_URL}/plan-journey?${qs}`); + return res.data; +} + +/** Appends the bus's current cached position to its track. Null if it vanished. */ +async function refreshTrack(track: BusTrack): Promise<{ lat: number, lon: number, timestamp: number } | null> { + const buses = await fetchBuses(); + const bus = buses.find(b => (b.vid || b.id) === track.vid); + if (!bus) return null; + const sample = { lat: parseFloat(bus.lat), lon: parseFloat(bus.lon), timestamp: Date.now() }; + if (isNaN(sample.lat) || isNaN(sample.lon)) return null; + track.samples.push(sample); + return sample; +} + +/** Extends a track with additional live polls. */ +async function observeMore(track: BusTrack, polls: number, intervalMs = OBSERVE_INTERVAL_MS) { + for (let i = 0; i < polls; i++) { + await sleep(intervalMs); + await refreshTrack(track); + } +} + +/** Polls bus positions and accumulates per-vehicle tracks. */ +async function observeAllBuses(polls: number, intervalMs: number): Promise> { + const result = new Map(); + for (let i = 0; i < polls; i++) { + const buses = await fetchBuses(); + const now = Date.now(); + for (const bus of buses) { + const vid = bus.vid || bus.id; + const lat = parseFloat(bus.lat); + const lon = parseFloat(bus.lon); + if (!vid || isNaN(lat) || isNaN(lon)) continue; + if (!result.has(vid)) result.set(vid, { vid, rt: bus.rt, samples: [] }); + result.get(vid)!.samples.push({ lat, lon, timestamp: now }); + } + if (i < polls - 1) await sleep(intervalMs); + } + return result; +} + +/** Whether the vehicle has predictions (i.e. an active trip in the routing graph). */ +async function hasActiveTrip(vid: string): Promise { + const res = await axios.get(`${BASE_URL}/getBusPredictions/${vid}`); + return (res.data['bustime-response']?.prd || []).length > 0; +} + +/** Picks the fastest-moving bus that has an active trip; falls back to fastest overall. */ +async function pickMovingBusWithTrip(): Promise<{ track: BusTrack, inGraph: boolean } | null> { + for (const track of movingBuses) { + if (await hasActiveTrip(track.vid)) return { track, inGraph: true }; + } + return movingBuses.length ? { track: movingBuses[0], inGraph: false } : null; +} + +function nearestStop(stops: Stop[], lat: number, lon: number): { stop: Stop, distance: number } | null { + let best: Stop | null = null; + let bestDist = Infinity; + for (const stop of stops) { + const d = haversineM(lat, lon, stop.lat, stop.lon); + if (d < bestDist) { bestDist = d; best = stop; } + } + return best ? { stop: best, distance: bestDist } : null; +} + +/** + * Runs riding detection against a moving bus with one retry, and decides + * whether a non-on_bus result is a genuine failure or explainable by live + * conditions (bus stopped/slowed mid-test). + */ +async function attemptRidingDetection(testName: string, track: BusTrack, useCandidate: boolean) { + let result: any = null; + for (let attempt = 0; attempt < 2; attempt++) { + const current = await refreshTrack(track); + if (!current) { + warnLive(testName, `bus ${track.vid} disappeared from the live feed mid-test`); + return null; + } + const trail = buildRidingTrail(track); + const userPos = toUserSample(current.lat, current.lon, current.timestamp); + result = await detect(userPos.lat, userPos.lon, trail, useCandidate ? track.vid : undefined); + if (result.status === 'on_bus') return result; + // Give the bus another chance to show clean motion, then retry once + if (attempt === 0) await observeMore(track, 2); + } + return result; +} + +// Shared live state collected in beforeAll +let stops: Stop[] = []; +let tracks: Map = new Map(); +let movingBuses: BusTrack[] = []; +let stationaryBuses: BusTrack[] = []; +let stationaryBusesAtStops: { track: BusTrack, stop: Stop, stopDistance: number }[] = []; +let busesRunning = false; + +describe('On-Bus Detection & Routing (live data)', () => { + beforeAll(async () => { + try { + await axios.get(`${BASE_URL}/getBusPositions`); + } catch { + throw new Error( + `Server is not running at ${BASE_URL}. Start it with \`npm start\` before running the live on-bus tests.` + ); + } + + const stopsRes = await axios.get(`${BASE_URL}/getAllStops`); + stops = (Array.isArray(stopsRes.data) ? stopsRes.data : []) + .filter((s: any) => typeof s.lat === 'number' && typeof s.lon === 'number'); + + // Skip the long observation window entirely when nothing is reporting + let shouldObserve = (await fetchBuses()).length > 0; + if (!shouldObserve) { + await sleep(OBSERVE_INTERVAL_MS); + shouldObserve = (await fetchBuses()).length > 0; + } + if (shouldObserve) { + console.log(`Observing live bus positions (${OBSERVE_POLLS} polls over ~${(OBSERVE_POLLS - 1) * OBSERVE_INTERVAL_MS / 1000}s)...`); + tracks = await observeAllBuses(OBSERVE_POLLS, OBSERVE_INTERVAL_MS); + } + busesRunning = tracks.size > 0; + + for (const track of tracks.values()) { + const moved = displacement(track); + if (moved >= MOVING_DISPLACEMENT_M && recentMovement(track) >= 15) { + movingBuses.push(track); + } else if (moved <= STATIONARY_DISPLACEMENT_M) { + stationaryBuses.push(track); + const last = track.samples[track.samples.length - 1]; + const near = nearestStop(stops, last.lat, last.lon); + if (near && near.distance <= 40) { + stationaryBusesAtStops.push({ track, stop: near.stop, stopDistance: near.distance }); + } + } + } + // Prefer the fastest-moving bus for co-movement tests + movingBuses.sort((a, b) => displacement(b) - displacement(a)); + + if (!busesRunning) { + console.warn( + '\n' + + '⚠️ ============================================================\n' + + '⚠️ NO BUSES ARE CURRENTLY RUNNING.\n' + + '⚠️ All bus-dependent live tests will be skipped with warnings.\n' + + '⚠️ Re-run this suite while MBus vehicles are in service to get\n' + + '⚠️ full live coverage of on-bus detection.\n' + + '⚠️ ============================================================\n' + ); + } else { + console.log( + `Live snapshot: ${tracks.size} buses total — ` + + `${movingBuses.length} moving, ${stationaryBuses.length} stationary ` + + `(${stationaryBusesAtStops.length} dwelling at a stop), ${stops.length} stops cached.` + ); + } + }, 90_000); + + it('reports current live bus availability', () => { + if (!busesRunning) { + console.warn('⚠️ No MBus vehicles reporting positions right now — bus-dependent tests below are skipped.'); + } + // Always passes; exists to surface the availability summary in test output. + expect(tracks).toBeDefined(); + }); + + // ------------------------------------------------------------------ + // Hard invariants — these hold no matter what buses are running + // ------------------------------------------------------------------ + + it('classifies not_near_bus when the user is far from every bus', async () => { + // Middle of Lake Erie — guaranteed far from any MBus vehicle + const trail = buildStationaryTrail(41.7, -82.5); + const result = await detect(41.7, -82.5, trail); + expect(result.status).toBe('not_near_bus'); + expect(result.onBus).toBe(false); + }); + + it('returns candidate_bus_not_nearby for an unknown candidateVid', async () => { + const trail = buildStationaryTrail(42.278, -83.738); + const result = await detect(42.278, -83.738, trail, 'TEST_NO_SUCH_VID'); + expect(result.status).toBe('not_near_bus'); + expect(result.reason).toBe('candidate_bus_not_nearby'); + }); + + it('rejects a malformed detect-on-bus body with 400', async () => { + try { + await axios.post(`${BASE_URL}/detect-on-bus`, { lat: 'not-a-number' }); + expect.fail('Expected 400 for malformed body'); + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + expect(error.response.status).toBe(400); + } else { + throw error; + } + } + }); + + it('falls back with trail_required when onBus=true has no locationTrail', async () => { + const data = await planJourney({ + originLat: '42.278', originLon: '-83.738', + destLat: '42.2912', destLon: '-83.7175', + onBus: 'true', + }); + expect(Array.isArray(data.journeys)).toBe(true); + expect(data.originContext).toBeDefined(); + expect(data.originContext.fallbackReason).toBe('trail_required'); + expect(data.originContext.validated).toBe(false); + }); + + it('keeps the legacy plan-journey response shape when no on-bus params are sent', async () => { + const data = await planJourney({ + originLat: '42.278', originLon: '-83.738', + destLat: '42.2912', destLon: '-83.7175', + }); + expect(Array.isArray(data.journeys)).toBe(true); + expect(data).not.toHaveProperty('originContext'); + }); + + // ------------------------------------------------------------------ + // Riding detection with real moving buses + // ------------------------------------------------------------------ + + it('confirms on_bus for a fake user riding along a real moving bus', async () => { + const name = 'riding → on_bus'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + if (!movingBuses.length) return warnSkip(name, 'no bus is currently moving — re-run while buses are driving'); + + const track = movingBuses[0]; + const result = await attemptRidingDetection(name, track, true); + if (!result) return; // vehicle disappeared mid-test (already warned) + + if (result.status !== 'on_bus' && recentMovement(track) < 15) { + warnLive(name, `bus ${track.vid} stopped/slowed mid-test; got '${result.status}' (${result.reason}) — acceptable under live conditions`); + expect(['on_bus', 'near_bus', 'waiting_at_stop']).toContain(result.status); + return; + } + + expect(result.status, `bus ${track.vid} (rt ${track.rt}) still moving but not detected: ${result.reason}`).toBe('on_bus'); + expect(result.onBus).toBe(true); + expect(result.vid).toBe(track.vid); + expect(result.confidence).toBeGreaterThan(0.5); + expect(typeof result.busLat).toBe('number'); + expect(typeof result.busLon).toBe('number'); + console.log(`✓ on_bus confirmed riding bus ${track.vid} (rt ${track.rt}), confidence ${result.confidence.toFixed(2)}, reason ${result.reason}`); + }, 60_000); + + it('auto-picks the co-moving bus when no candidateVid is given', async () => { + const name = 'auto-pick vid'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + if (!movingBuses.length) return warnSkip(name, 'no bus is currently moving'); + + const track = movingBuses[0]; + const result = await attemptRidingDetection(name, track, false); + if (!result) return; + + if (result.status !== 'on_bus' && recentMovement(track) < 15) { + warnLive(name, `bus ${track.vid} stopped/slowed mid-test; got '${result.status}' (${result.reason})`); + expect(result.status).not.toBe('not_near_bus'); + return; + } + + expect(result.status).toBe('on_bus'); + expect(result.vid).toBeDefined(); + if (result.vid !== track.vid) { + warnLive(name, `auto-pick matched co-located bus ${result.vid} instead of ${track.vid} (both plausible)`); + } + }, 60_000); + + it('plans a journey starting from the bus when riding (continue/alight tagging)', async () => { + const name = 'plan-journey on-bus'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + if (!movingBuses.length) return warnSkip(name, 'no bus is currently moving'); + + // Prefer a moving bus that actually has an active trip in the graph, + // so the primary on-bus path is exercised (not just the fallback). + const picked = await pickMovingBusWithTrip(); + if (!picked) return warnSkip(name, 'no bus is currently moving'); + if (!picked.inGraph) { + warnLive(name, `no moving bus has an active trip right now — bus ${picked.track.vid} will exercise the bus_not_found fallback`); + } + const track = picked.track; + const current = await refreshTrack(track); + if (!current) return warnSkip(name, `bus ${track.vid} disappeared from the live feed`); + const trail = buildRidingTrail(track); + const userPos = toUserSample(current.lat, current.lon, current.timestamp); + + // Destination: the cached stop farthest from the bus, to maximize transit options + let dest = { lat: 42.2912, lon: -83.7175 }; // CCTC fallback + if (stops.length) { + const farthest = stops.reduce((a, b) => + haversineM(userPos.lat, userPos.lon, a.lat, a.lon) >= haversineM(userPos.lat, userPos.lon, b.lat, b.lon) ? a : b); + dest = { lat: farthest.lat, lon: farthest.lon }; + } + + const data = await planJourney({ + originLat: String(userPos.lat), originLon: String(userPos.lon), + destLat: String(dest.lat), destLon: String(dest.lon), + onBus: 'true', originVid: track.vid, + locationTrail: JSON.stringify(trail), + }); + + expect(Array.isArray(data.journeys)).toBe(true); + expect(data.originContext).toBeDefined(); + const ctx = data.originContext; + + if (ctx.mode !== 'on_bus') { + if (ctx.fallbackReason === 'bus_not_found') { + warnLive(name, `bus ${track.vid} has no active trip in the routing graph — fell back to '${ctx.mode}'`); + return; + } + warnLive(name, `live classification raced to '${ctx.status}' (${ctx.fallbackReason}); mode '${ctx.mode}'`); + expect(['walking', 'at_stop']).toContain(ctx.mode); + return; + } + + expect(ctx.status).toBe('on_bus'); + expect(ctx.vid).toBe(track.vid); + expect(ctx.validated).toBe(true); + + const choices = data.journeys + .map((j: any) => j.legs?.[0]?.originChoice) + .filter(Boolean); + for (const choice of choices) { + expect(['continue_on_bus', 'alight_and_transfer']).toContain(choice); + } + if (ctx.isStoppedAtStop) { + expect(ctx.showsTransferOptions).toBe(true); + expect(ctx.currentStopId).toBeDefined(); + } else { + // Moving bus: every journey must start by continuing on this bus + for (const journey of data.journeys) { + const firstBusLeg = journey.legs.find((l: any) => l.mode === 'bus'); + if (firstBusLeg) expect(firstBusLeg.vid).toBe(track.vid); + } + } + const continueLegs = data.journeys.filter((j: any) => j.legs?.[0]?.originChoice === 'continue_on_bus'); + for (const journey of continueLegs) { + const firstBusLeg = journey.legs.find((l: any) => l.mode === 'bus'); + expect(firstBusLeg.busPosition).toBeDefined(); + } + console.log( + `✓ on-bus journey plan for bus ${track.vid}: ${data.journeys.length} journeys ` + + `(${choices.filter((c: string) => c === 'continue_on_bus').length} continue, ` + + `${choices.filter((c: string) => c === 'alight_and_transfer').length} alight-and-transfer), ` + + `stoppedAtStop=${ctx.isStoppedAtStop}` + ); + }, 60_000); + + // ------------------------------------------------------------------ + // Negative cases with real buses: proximity alone must never be enough + // ------------------------------------------------------------------ + + it('never classifies a stationary user next to a real bus as on_bus', async () => { + const name = 'stationary user near bus'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + + // Test against every bus currently reporting — none may produce on_bus + const buses = await fetchBuses(); + expect(buses.length).toBeGreaterThan(0); + for (const bus of buses.slice(0, 5)) { + const vid = bus.vid || bus.id; + const lat = parseFloat(bus.lat); + const lon = parseFloat(bus.lon); + if (!vid || isNaN(lat) || isNaN(lon)) continue; + const trail = buildStationaryTrail(lat, lon); + const result = await detect(lat, lon, trail, vid); + expect(result.status, `stationary user misclassified as riding bus ${vid}`).not.toBe('on_bus'); + } + }, 30_000); + + it('never classifies a too-short trail as on_bus, even right on a real bus', async () => { + const name = 'short trail near bus'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + + const track = (movingBuses[0] || [...tracks.values()][0]); + const current = await refreshTrack(track); + if (!current) return warnSkip(name, `bus ${track.vid} disappeared from the live feed`); + + const now = Date.now(); + const shortTrail: UserSample[] = [ + toUserSample(current.lat, current.lon, now - 2000), + toUserSample(current.lat, current.lon, now), + ]; + const result = await detect(current.lat, current.lon, shortTrail, track.vid); + expect(result.status).not.toBe('on_bus'); + }, 30_000); + + // ------------------------------------------------------------------ + // Waiting at a stop vs riding + // ------------------------------------------------------------------ + + it('classifies a stationary user at a stop with a dwelling bus as waiting_at_stop', async () => { + const name = 'waiting_at_stop'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + if (!stops.length) return warnSkip(name, 'no stops cached on the server yet'); + if (!stationaryBusesAtStops.length) { + return warnSkip(name, 'no bus is currently dwelling at a stop — re-run when buses are stopped at stops'); + } + + const { track, stop } = stationaryBusesAtStops[0]; + // Confirm the bus is still there before asserting + const current = await refreshTrack(track); + if (!current) return warnSkip(name, `bus ${track.vid} disappeared from the live feed`); + const stillThere = haversineM(current.lat, current.lon, stop.lat, stop.lon) <= 50 + && recentMovement(track) <= 10; + if (!stillThere) return warnSkip(name, `bus ${track.vid} departed stop ${stop.stpid} mid-test`); + + const trail = buildStationaryTrail(stop.lat, stop.lon); + const result = await detect(stop.lat, stop.lon, trail, track.vid); + + expect(result.status, 'a waiting user must never be classified as riding').not.toBe('on_bus'); + if (result.status !== 'waiting_at_stop') { + const after = await refreshTrack(track); + if (!after || recentMovement(track) > 10) { + warnLive(name, `bus ${track.vid} departed during detection; got '${result.status}' (${result.reason})`); + return; + } + } + expect(result.status).toBe('waiting_at_stop'); + console.log(`✓ waiting_at_stop at ${stop.stpid} (${stop.name}) next to dwelling bus ${track.vid}`); + }, 30_000); + + it('plans from the stop itself (at_stop origin) for a user standing at a real stop', async () => { + const name = 'plan-journey at-stop'; + if (!stops.length) return warnSkip(name, 'no stops cached on the server yet (graph may still be building)'); + + const stop = stops[0]; + let dest = stops.reduce((a, b) => + haversineM(stop.lat, stop.lon, a.lat, a.lon) >= haversineM(stop.lat, stop.lon, b.lat, b.lon) ? a : b); + + const trail = buildStationaryTrail(stop.lat, stop.lon); + const data = await planJourney({ + originLat: String(stop.lat), originLon: String(stop.lon), + destLat: String(dest.lat), destLon: String(dest.lon), + onBus: 'true', + locationTrail: JSON.stringify(trail), + }); + + expect(Array.isArray(data.journeys)).toBe(true); + expect(data.originContext).toBeDefined(); + expect(data.originContext.mode).toBe('at_stop'); + expect(data.originContext.stopId).toBe(stop.stpid); + + // DUE-boarding check: if a bus is DUE at this stop, an immediate boarding should exist + const predsRes = await axios.get(`${BASE_URL}/getStopPredictions/${stop.stpid}`); + const preds = predsRes.data['bustime-response']?.prd || []; + const dueHere = preds.filter((p: any) => p.prdctdn === 'DUE' || parseInt(p.prdctdn) <= 1); + if (dueHere.length) { + const now = new Date(); + const nowSec = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); + const immediate = data.journeys.some((j: any) => { + const firstBus = j.legs.find((l: any) => l.mode === 'bus'); + return firstBus && firstBus.origin_id === stop.stpid && firstBus.startTime <= nowSec + 90; + }); + if (immediate) { + console.log(`✓ DUE bus at ${stop.stpid} is boardable immediately in the journey plan`); + } else { + warnLive(name, `bus DUE at ${stop.stpid} but no immediate boarding in plan (may serve the wrong direction for this destination)`); + } + } + console.log(`✓ at_stop origin from ${stop.stpid} (${stop.name}): ${data.journeys.length} journeys`); + }, 30_000); + + it('keeps on_bus (with transfer options) for a rider whose bus dwells at a stop after moving', async () => { + const name = 'on-bus-at-stop'; + if (!busesRunning) return warnSkip(name, 'no buses running'); + if (!stops.length) return warnSkip(name, 'no stops cached on the server yet'); + + // Fresh observation so the movement is inside the server's 60s history + const fresh = await observeAllBuses(3, OBSERVE_INTERVAL_MS); + const candidates = [...fresh.values()].filter(t => { + if (displacement(t) < 40 || recentMovement(t) > 5) return false; + const last = t.samples[t.samples.length - 1]; + const near = nearestStop(stops, last.lat, last.lon); + return near !== null && near.distance <= 40; + }); + if (!candidates.length) { + return warnSkip(name, 'no bus moved and then dwelled at a stop during the observation window — re-run when buses are servicing stops'); + } + + const track = candidates[0]; + const trail = buildRidingTrail(track); + const last = track.samples[track.samples.length - 1]; + const userPos = toUserSample(last.lat, last.lon, last.timestamp); + + const result = await detect(userPos.lat, userPos.lon, trail, track.vid); + expect(['on_bus', 'waiting_at_stop']).toContain(result.status); + if (result.status !== 'on_bus') { + warnLive(name, `prior co-movement aged out of server history for bus ${track.vid}; got '${result.status}' (${result.reason})`); + return; + } + console.log(`✓ rider kept on_bus while bus ${track.vid} dwells at a stop (${result.reason})`); + + // The journey plan should expose both continue and alight-and-transfer paths + const dest = stops.reduce((a, b) => + haversineM(userPos.lat, userPos.lon, a.lat, a.lon) >= haversineM(userPos.lat, userPos.lon, b.lat, b.lon) ? a : b); + const data = await planJourney({ + originLat: String(userPos.lat), originLon: String(userPos.lon), + destLat: String(dest.lat), destLon: String(dest.lon), + onBus: 'true', originVid: track.vid, + locationTrail: JSON.stringify(trail), + }); + + expect(data.originContext).toBeDefined(); + if (data.originContext.mode !== 'on_bus') { + warnLive(name, `plan fell back to '${data.originContext.mode}' (${data.originContext.fallbackReason ?? data.originContext.status})`); + return; + } + if (data.originContext.isStoppedAtStop) { + expect(data.originContext.showsTransferOptions).toBe(true); + expect(data.originContext.currentStopId).toBeDefined(); + const choices = data.journeys.map((j: any) => j.legs?.[0]?.originChoice).filter(Boolean); + console.log( + `✓ dual-origin plan at ${data.originContext.currentStopId}: ` + + `${choices.filter((c: string) => c === 'continue_on_bus').length} continue, ` + + `${choices.filter((c: string) => c === 'alight_and_transfer').length} alight-and-transfer` + ); + } else { + warnLive(name, `bus ${track.vid} no longer registered as stopped at a stop when planning`); + } + }, 90_000); +});