From d92db84738a864816fe75ece833df02b04ad1fe2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 16:46:34 -0800 Subject: [PATCH] Implement travel time data point saving and loading, and tests --- .../shuttle/RedisShuttleRepository.ts | 149 +++++++++++++++- .../__tests__/RedisShuttleRepository.test.ts | 159 ++++++++++++------ 2 files changed, 249 insertions(+), 59 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 1053d30..2962975 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -15,6 +15,17 @@ export interface ShuttleStopArrival { timestamp: Date; } +export interface ShuttleTravelTimeDataIdentifier { + routeId: string; + fromStopId: string; + toStopId: string; +} + +export interface ShuttleTravelTimeDateFilterArguments { + from: Date; + to: Date; +} + export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository { protected redisClient; @@ -106,6 +117,9 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`; + private createHistoricalEtaTimeSeriesKey =(routeId: string, fromStopId: string, toStopId: string) => { + return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; + } // Helper methods for converting entities to Redis hashes private createRedisHashFromStop = (stop: IStop): Record => ({ @@ -408,36 +422,153 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette await this.redisClient.hSet(key, this.createRedisHashFromRoute(route)); } - public async addOrUpdateShuttle(shuttle: IShuttle): Promise { + public async addOrUpdateShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ): Promise { const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); - await this.updateHistoricalEtasForShuttle(shuttle); + await this.updateHistoricalEtasForShuttle(shuttle, travelTimeTimestamp); + await this.updateEtasBasedOnHistoricalData(shuttle); } - private async updateHistoricalEtasForShuttle(shuttle: IShuttle) { + public async updateEtasBasedOnHistoricalData( + shuttle: IShuttle, + ) { + // Based on historical data for the key provided by this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + // Call this.addOrUpdateEta with the correct information + // "Historical data" being averaged time taken for the current hour of the same day + // in the past week, based on the reference time + } + + private async updateHistoricalEtasForShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ) { const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle) if (lastStopTimestamp != undefined) { - const now = Date(); - const routeId = shuttle.routeId + const routeId = shuttle.routeId; const fromStopId = lastStopTimestamp.stopId; const toStopId = arrivedStop.id; - // Create an entry in Redis time series - // Key: routeId:fromStopId:toStopId: - // Value: seconds it took to get from lastStopTimestamp.stopId to arrivedStop.id + const travelTimeSeconds = (travelTimeTimestamp - lastStopTimestamp.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, travelTimeTimestamp); } await this.updateShuttleLastStopArrival(shuttle, { stopId: arrivedStop.id, - timestamp: new Date(), + timestamp: new Date(travelTimeTimestamp), }) } } + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments, + ): Promise { + const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + const intervalMs = toTimestamp - fromTimestamp; + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + fromTimestamp.toString(), + toTimestamp.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + return parseFloat(averageValue); + } + + throw new Error(`No historical data found for route ${routeId} from stop ${fromStopId} to stop ${toStopId}`); + } catch (error) { + throw new Error(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + + try { + await this.redisClient.sendCommand([ + 'TS.ADD', + historicalEtaTimeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString(), + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + } catch (error) { + await this.createHistoricalEtaTimeSeriesAndAddDataPoint( + historicalEtaTimeSeriesKey, + timestamp, + travelTimeSeconds, + routeId, + fromStopId, + toStopId + ); + } + } + + + private async createHistoricalEtaTimeSeriesAndAddDataPoint( + timeSeriesKey: string, + timestamp: number, + travelTimeSeconds: number, + routeId: string, + fromStopId: string, + toStopId: string, + ): Promise { + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'RETENTION', + '2678400000', // one month in milliseconds + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } catch (createError) { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } + } + public async getArrivedStopIfExists( shuttle: IShuttle, delta = 0.001, diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 7c0c6b2..68f321e 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -15,57 +15,116 @@ describe("RedisShuttleRepository", () => { await repository.disconnect(); }); + async function setupRouteAndOrderedStops() { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await repository.addOrUpdateRoute(route); + + const stop1 = { + id: "st1", + name: "Stop 1", + systemId: systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2 = { + id: "st2", + name: "Stop 2", + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + await repository.addOrUpdateStop(stop1); + await repository.addOrUpdateStop(stop2); + + const orderedStop1 = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId: systemId, + updatedTime: new Date(), + }; + const orderedStop2 = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId: systemId, + updatedTime: new Date(), + }; + await repository.addOrUpdateOrderedStop(orderedStop1); + await repository.addOrUpdateOrderedStop(orderedStop2); + return { + route, + systemId, + stop1, + stop2, + }; + } + + describe("addOrUpdateShuttle", () => { + it("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + // Shuttle positioned at stop2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + const lastStop = await repository.getShuttleLastStopArrival(shuttle); + expect(lastStop?.stopId).toEqual(stop2.id) + }); + + it("updates how long the shuttle took to get from one stop to another", async () => { + const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); + + // Start the shuttle at stop 1, then have it move to stop 2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + } + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + // 15-minute travel time + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + expect(travelTime).toEqual(15 * 60) + }); + }); + describe("getArrivedStopIfExists", () => { - async function setupRouteAndOrderedStops() { - const systemId = "sys1"; - const route = { - id: "r1", - name: "Route 1", - color: "red", - systemId: systemId, - polylineCoordinates: [], - updatedTime: new Date(), - }; - await repository.addOrUpdateRoute(route); - - const stop1 = { - id: "st1", - name: "Stop 1", - systemId: systemId, - coordinates: { latitude: 10.0, longitude: 20.0 }, - updatedTime: new Date(), - }; - const stop2 = { - id: "st2", - name: "Stop 2", - systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, - updatedTime: new Date(), - }; - await repository.addOrUpdateStop(stop1); - await repository.addOrUpdateStop(stop2); - - const orderedStop1 = { - routeId: route.id, - stopId: stop1.id, - position: 1, - systemId: systemId, - updatedTime: new Date(), - }; - const orderedStop2 = { - routeId: route.id, - stopId: stop2.id, - position: 2, - systemId: systemId, - updatedTime: new Date(), - }; - await repository.addOrUpdateOrderedStop(orderedStop1); - await repository.addOrUpdateOrderedStop(orderedStop2); - return { route, systemId }; - } - it("gets the stop that the shuttle is currently at, if exists", async () => { - const { route, systemId } = await setupRouteAndOrderedStops(); + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); // Create a shuttle positioned at stop2 const shuttle = { @@ -73,7 +132,7 @@ describe("RedisShuttleRepository", () => { name: "Shuttle 1", routeId: route.id, systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, // Same as stop2 + coordinates: stop2.coordinates, orientationInDegrees: 0, updatedTime: new Date(), };