mirror of
https://github.com/brendan-ch/project-inter-server.git
synced 2026-04-19 08:50:29 +00:00
Implement travel time data point saving and loading, and tests
This commit is contained in:
@@ -15,6 +15,17 @@ export interface ShuttleStopArrival {
|
|||||||
timestamp: Date;
|
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 {
|
export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository {
|
||||||
protected redisClient;
|
protected redisClient;
|
||||||
|
|
||||||
@@ -106,6 +117,9 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette
|
|||||||
private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`;
|
private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`;
|
||||||
private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`;
|
private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`;
|
||||||
private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`;
|
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
|
// Helper methods for converting entities to Redis hashes
|
||||||
private createRedisHashFromStop = (stop: IStop): Record<string, string> => ({
|
private createRedisHashFromStop = (stop: IStop): Record<string, string> => ({
|
||||||
@@ -408,36 +422,153 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette
|
|||||||
await this.redisClient.hSet(key, this.createRedisHashFromRoute(route));
|
await this.redisClient.hSet(key, this.createRedisHashFromRoute(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addOrUpdateShuttle(shuttle: IShuttle): Promise<void> {
|
public async addOrUpdateShuttle(
|
||||||
|
shuttle: IShuttle,
|
||||||
|
travelTimeTimestamp = Date.now(),
|
||||||
|
): Promise<void> {
|
||||||
const key = this.createShuttleKey(shuttle.id);
|
const key = this.createShuttleKey(shuttle.id);
|
||||||
await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle));
|
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);
|
const arrivedStop = await this.getArrivedStopIfExists(shuttle);
|
||||||
|
|
||||||
if (arrivedStop != undefined) {
|
if (arrivedStop != undefined) {
|
||||||
const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle)
|
const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle)
|
||||||
if (lastStopTimestamp != undefined) {
|
if (lastStopTimestamp != undefined) {
|
||||||
const now = Date();
|
const routeId = shuttle.routeId;
|
||||||
const routeId = shuttle.routeId
|
|
||||||
const fromStopId = lastStopTimestamp.stopId;
|
const fromStopId = lastStopTimestamp.stopId;
|
||||||
const toStopId = arrivedStop.id;
|
const toStopId = arrivedStop.id;
|
||||||
|
|
||||||
// Create an entry in Redis time series
|
const travelTimeSeconds = (travelTimeTimestamp - lastStopTimestamp.timestamp.getTime()) / 1000;
|
||||||
// Key: routeId:fromStopId:toStopId:
|
await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, travelTimeTimestamp);
|
||||||
// Value: seconds it took to get from lastStopTimestamp.stopId to arrivedStop.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateShuttleLastStopArrival(shuttle, {
|
await this.updateShuttleLastStopArrival(shuttle, {
|
||||||
stopId: arrivedStop.id,
|
stopId: arrivedStop.id,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(travelTimeTimestamp),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAverageTravelTimeSeconds(
|
||||||
|
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||||
|
{ from, to }: ShuttleTravelTimeDateFilterArguments,
|
||||||
|
): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
public async getArrivedStopIfExists(
|
||||||
shuttle: IShuttle,
|
shuttle: IShuttle,
|
||||||
delta = 0.001,
|
delta = 0.001,
|
||||||
|
|||||||
@@ -15,57 +15,116 @@ describe("RedisShuttleRepository", () => {
|
|||||||
await repository.disconnect();
|
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", () => {
|
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 () => {
|
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
|
// Create a shuttle positioned at stop2
|
||||||
const shuttle = {
|
const shuttle = {
|
||||||
@@ -73,7 +132,7 @@ describe("RedisShuttleRepository", () => {
|
|||||||
name: "Shuttle 1",
|
name: "Shuttle 1",
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
systemId: systemId,
|
systemId: systemId,
|
||||||
coordinates: { latitude: 15.0, longitude: 25.0 }, // Same as stop2
|
coordinates: stop2.coordinates,
|
||||||
orientationInDegrees: 0,
|
orientationInDegrees: 0,
|
||||||
updatedTime: new Date(),
|
updatedTime: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user