diff --git a/.changeset/integer-timetable.md b/.changeset/integer-timetable.md new file mode 100644 index 0000000..f963714 --- /dev/null +++ b/.changeset/integer-timetable.md @@ -0,0 +1,28 @@ +--- +"connection-scan-algorithm": major +--- + +Scan connections held as arrays of numbers, 30 to 90 times faster. + +The algorithm and its classes are as they were: `toGtfsData`, `ConnectionScanAlgorithm` asking +`ScanResults` whether each connection is reachable and better, and `JourneyFactory` building legs +from the connection index. What they read changes. Stations are numbered, connections are parallel +typed arrays sorted by arrival rather than an object each, `ScanResults` holds arrays indexed by +station, and a connection or footpath is its index. A scan starts at the first connection arriving +after the departure time rather than at midnight, stops once every destination has been reached, +and whether each trip runs is worked out once per date by a `TripCalendar`. + +Over the GB rail feed, planning Tuesday 15 September, across repeated runs: 32 standard queries (the +24 of `npm run perf` and eight more) take 3.1-3.5ms each on average rather than 115-117ms, 268 +journeys through couplings 1.7ms rather than 130ms, and 400 random station pairs 4.4-5.3ms rather +than 170-174ms. Building takes 305-360ms rather than 570-630ms, and holds 83MB rather than 195MB. All +1,100 answers are the same journeys 2.0.0 returns, trip for trip. + +`DepartAfterQuery`, `loadGtfs` and `toGtfsData` are called as before. What changes: + +- `new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs))` and `new JourneyFactory(gtfs)` +- `GtfsData` holds `Connections` and `Transfers` as arrays, an `Int32Array` of interchange times, a + `TripCalendar`, the `trips` and a `stopTable` numbering the stations +- `scan` returns a `ConnectionIndex` of `Int32Array`, and a `Connection` is a number: `TimetableConnection` + and `TransfersByOrigin` are gone, and `isTransfer` checks a leg while `isTransferConnection` checks + a connection diff --git a/README.md b/README.md index 8b4ddc8..e030c49 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,31 @@ const { const gtfs = await loadGtfs(fs.createReadStream("gtfs.zip")); // or toGtfsData(feed) if you already have a feed from @gb-transit/gtfs-loader -const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); -const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]); +const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); +const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs), [new MultipleCriteriaFilter()]); const results = query.plan(["TBW"], ["NRW"], new Date(), 9 * 3600); ``` +### How a scan reads the timetable + +The algorithm is the paper's: connections sorted by arrival, read in order, each one taken if it can +be reached and gets somewhere sooner. What makes it quick is what it reads. + +`toGtfsData` numbers the stations, and holds the connections as parallel arrays of those numbers and +times rather than as an object each. `ScanResults` keeps its earliest arrivals and the connection +achieving each in arrays indexed by station, and a connection or footpath is its index, so every +question the scan asks is a few array reads. A scan starts at the first connection arriving after the +departure time and stops once every destination has been reached before the connection it is on +arrives, and whether each trip runs is worked out once per date rather than asked of every connection. + ### Stations and platforms A connection runs between stations, because that is where interchange time and footpaths are defined and the only place a change of train is possible. A stop that gives a `parent_station` is read as belonging to it, and a station is named by its `stop_code` where it has one, so that is what queries are made with and journeys are returned in. `gtfs.stations` maps every feed stop id to the -station it belongs to, which is what `JourneyFactory` needs to cut a leg out of its trip. +station it belongs to, which is what `JourneyFactory` needs to cut a leg out of its trip, and +`gtfs.stopTable` numbers the stations. The stop times of a leg are the feed's own, so a leg between two stations still says which platform it uses at each end. A call the vehicle only passes through is not somewhere a journey can start or diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 4d8e2af..9cb8cca 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -1,6 +1,8 @@ import type { DateNumber, DayOfWeek, StopID, Time } from "@gb-transit/gtfs-loader"; -import type { TransfersByOrigin } from "../gtfs/GtfsLoader.js"; -import type { Connection, TimetableConnection } from "../journey/Connection.js"; +import { type Connections, firstArrivingAt } from "../gtfs/Connections.js"; +import type { GtfsData, Transfers } from "../gtfs/GtfsLoader.js"; +import type { StopIdx } from "../gtfs/StopTable.js"; +import type { TripCalendar } from "../gtfs/TripCalendar.js"; import type { ScanResults } from "./ScanResults.js"; import type { ScanResultsFactory } from "./ScanResultsFactory.js"; @@ -8,15 +10,21 @@ import type { ScanResultsFactory } from "./ScanResultsFactory.js"; * Implementation of the connection scan algorithm. */ export class ConnectionScanAlgorithm { + private readonly connections: Connections; + private readonly transfers: Transfers; + private readonly calendar: TripCalendar; constructor( - private readonly connections: TimetableConnection[], - private readonly transfers: TransfersByOrigin, + gtfs: GtfsData, private readonly resultsFactory: ScanResultsFactory - ) {} + ) { + this.connections = gtfs.connections; + this.transfers = gtfs.transfers; + this.calendar = gtfs.calendar; + } /** - * Return an index of connections that achieve the earliest arrival time at each stop. + * Return an index of connections that achieve the earliest arrival time at each station. */ public scan( origins: OriginDepartureTimes, @@ -24,19 +32,21 @@ export class ConnectionScanAlgorithm { date: DateNumber, dow: DayOfWeek ): ConnectionIndex { - const results = this.resultsFactory.create({ ...origins }); + const results = this.resultsFactory.create(origins, destinations); + const running = this.calendar.runningOn(date, dow); + const departureTime = Math.min(...Object.values(origins)); - for (const origin in origins) { + for (const origin of results.getOrigins()) { this.scanTransfers(results, origin); } - for (const c of this.connections) { - if (c.trip.service.runsOn(date, dow) && results.isReachable(c) && results.isBetter(c)) { + for (let c = firstArrivingAt(this.connections, departureTime); c < this.connections.length; c++) { + if (results.isFinished(c)) { + break; + } + if (running[this.connections.trip[c]] && results.isReachable(c) && results.isBetter(c)) { if (results.setConnection(c)) { - this.scanTransfers(results, c.destination); - } - if (results.isFinished(destinations, c.departureTime)) { - break; + this.scanTransfers(results, this.connections.arrivalStation[c]); } } } @@ -49,11 +59,11 @@ export class ConnectionScanAlgorithm { * first time: a station first reached on foot is often then reached sooner by train, and the * footpaths onwards from it have to start from the earlier time. */ - private scanTransfers(results: ScanResults, origin: StopID): void { - for (const transfer of this.transfers[origin] ?? []) { - if (results.isTransferBetter(transfer)) { - results.setTransfer(transfer); - this.scanTransfers(results, transfer.destination); + private scanTransfers(results: ScanResults, origin: StopIdx): void { + for (let t = this.transfers.offsets[origin]; t < this.transfers.offsets[origin + 1]; t++) { + if (results.isTransferBetter(t)) { + results.setTransfer(t); + this.scanTransfers(results, this.transfers.destination[t]); } } } @@ -61,9 +71,10 @@ export class ConnectionScanAlgorithm { } /** - * Index of connections that achieve the earliest arrivalTime time at each stop. + * The connection or footpath that achieves the earliest arrival at each station, by station index, + * or NO_CONNECTION where nothing does. */ -export type ConnectionIndex = Record; +export type ConnectionIndex = Int32Array; /** * Index of departure stations and their departure time diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index 87e80c5..e7fde04 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -1,50 +1,109 @@ -import type { Interchange, StopID, Time, TripID } from "@gb-transit/gtfs-loader"; -import { isChangeRequired, type TimetableConnection } from "../journey/Connection.js"; -import type { Transfer } from "../journey/Journey.js"; +import type { StopID, Time } from "@gb-transit/gtfs-loader"; +import type { Connections } from "../gtfs/Connections.js"; +import type { GtfsData, Transfers } from "../gtfs/GtfsLoader.js"; +import { type StopIdx, UNKNOWN_STOP } from "../gtfs/StopTable.js"; +import { type Connection, isChangeRequired, NO_CONNECTION, transferConnection } from "../journey/Connection.js"; import type { ConnectionIndex, OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; +/** + * Arrival time of a station that has not been reached. Larger than any real time, so it loses every + * `<` comparison without needing a special case. + */ +export const NOT_REACHED = 0x7fffffff; + +/** The trip has carried the passenger to none of its calls */ +export const NOT_CARRIED = 0x7fffffff; + /** * Mutable object that stores the current earliest arrival and best connection indexes as the * connections are being scanned. + * + * Stations are held by index, and connections and footpaths by their index into the feed's, so + * every question the scan asks of this is a few array reads. */ export class ScanResults { - private readonly connectionIndex: ConnectionIndex = {}; - private readonly tripArrivals: Record> = {}; + private readonly connections: Connections; + private readonly transfers: Transfers; + private readonly interchange: Int32Array; + private readonly earliestArrivals: Int32Array; + private readonly connectionIndex: ConnectionIndex; + private readonly origins: StopIdx[] = []; + private readonly destinations: StopIdx[] = []; + private readonly isDestination: Uint8Array; + private latestDestinationArrival: Time; + /** + * The trip arrivals are the earliest call each trip has carried the passenger to. They are only + * needed while the scan runs, so the factory gives every scan the same array. + */ constructor( - private readonly interchange: Interchange, - private readonly earliestArrivals: OriginDepartureTimes - ) {} + gtfs: GtfsData, + origins: OriginDepartureTimes, + destinations: StopID[], + private readonly tripArrivals: Int32Array + ) { + this.connections = gtfs.connections; + this.transfers = gtfs.transfers; + this.interchange = gtfs.interchange; + this.earliestArrivals = new Int32Array(gtfs.stopTable.size).fill(NOT_REACHED); + this.connectionIndex = new Int32Array(gtfs.stopTable.size).fill(NO_CONNECTION); + this.isDestination = new Uint8Array(gtfs.stopTable.size); + + for (const code of Object.keys(origins)) { + const origin = gtfs.stopTable.indexOf(code); + + if (origin !== UNKNOWN_STOP) { + this.origins.push(origin); + this.earliestArrivals[origin] = origins[code]; + } + } + + // a destination the feed does not have is one nothing can be waited for at + for (const code of destinations) { + const destination = gtfs.stopTable.indexOf(code); - public isReachable(connection: TimetableConnection): boolean { - const reachable = this.isReachableWithChange(connection) || this.isReachableFromSameService(connection); + if (destination !== UNKNOWN_STOP) { + this.destinations.push(destination); + this.isDestination[destination] = 1; + } + } + + this.latestDestinationArrival = this.getLatestDestinationArrival(); + } + + /** + * Once a trip has carried the passenger to a call, they are still aboard for any of its + * connections from there on. Boarding it is not the same: a trip only picking up at a later call + * has carried nobody to it. + */ + public isReachable(c: Connection): boolean { + const reachable = this.isReachableWithChange(c) || this.isReachableFromSameService(c); if (reachable) { - this.tripArrivals[connection.trip.tripId] ??= {}; - this.tripArrivals[connection.trip.tripId][connection.destination] = connection.arrivalTime; + const trip = this.connections.trip[c]; + + this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]); } return reachable; } - private isReachableFromSameService(connection: TimetableConnection): boolean { - return Object.hasOwn(this.tripArrivals, connection.trip.tripId) && - this.tripArrivals[connection.trip.tripId][connection.origin] <= connection.departureTime; + private isReachableFromSameService(c: Connection): boolean { + return this.tripArrivals[this.connections.trip[c]] <= this.connections.board[c]; } - private isReachableWithChange(connection: TimetableConnection): boolean { - const interchange = this.connectionIndex[connection.origin] ? this.getInterchange(connection.origin) : 0; + private isReachableWithChange(c: Connection): boolean { + const origin = this.connections.departureStation[c]; + const interchange = this.connectionIndex[origin] === NO_CONNECTION ? 0 : this.interchange[origin]; - return Object.hasOwn(this.earliestArrivals, connection.origin) - && this.earliestArrivals[connection.origin] + interchange <= connection.departureTime; + return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c]; } - public isBetter(connection: TimetableConnection): boolean { - const arrivalTime = this.earliestArrivals[connection.destination]; + public isBetter(c: Connection): boolean { + const arrivalTime = this.earliestArrivals[this.connections.arrivalStation[c]]; - return arrivalTime === undefined - || arrivalTime > connection.arrivalTime - || (arrivalTime === connection.arrivalTime && this.staysAboard(connection)); + return arrivalTime > this.connections.arrivalTime[c] + || (arrivalTime === this.connections.arrivalTime[c] && this.staysAboard(c)); } /** @@ -52,52 +111,77 @@ export class ScanResults { * that couples onto another runs as a trip of its own alongside both portions, so without this * whichever of them was scanned first would have the passenger change at the coupling. */ - private staysAboard(connection: TimetableConnection): boolean { - const current = this.connectionIndex[connection.destination]; + private staysAboard(c: Connection): boolean { + const current = this.connectionIndex[this.connections.arrivalStation[c]]; - return current !== undefined - && isChangeRequired(current, connection) - && this.isReachableFromSameService(connection); + return current !== NO_CONNECTION + && isChangeRequired(this.connections, current, c) + && this.isReachableFromSameService(c); } /** * Returns true if the connection arrives earlier than the destination was reached before, rather * than at the same time on a trip the passenger stays aboard */ - public setConnection(connection: TimetableConnection): boolean { - const previous = this.earliestArrivals[connection.destination]; - this.earliestArrivals[connection.destination] = connection.arrivalTime; - this.connectionIndex[connection.destination] = connection; + public setConnection(c: Connection): boolean { + const destination = this.connections.arrivalStation[c]; + const previous = this.earliestArrivals[destination]; + + this.connectionIndex[destination] = c; - return previous === undefined || previous > connection.arrivalTime; + return this.arrive(destination, this.connections.arrivalTime[c]) < previous; } - public isTransferBetter(transfer: Transfer): boolean { - return !Object.hasOwn(this.earliestArrivals, transfer.destination) - || this.earliestArrivals[transfer.destination] > this.getTransferArrivalTime(transfer); + public isTransferBetter(t: number): boolean { + return this.earliestArrivals[this.transfers.destination[t]] > this.getTransferArrivalTime(t); } - public setTransfer(transfer: Transfer): void { - this.earliestArrivals[transfer.destination] = this.getTransferArrivalTime(transfer); - this.connectionIndex[transfer.destination] = transfer; + public setTransfer(t: number): void { + const destination = this.transfers.destination[t]; + + this.connectionIndex[destination] = transferConnection(t); + this.arrive(destination, this.getTransferArrivalTime(t)); } - private getTransferArrivalTime(transfer: Transfer): Time { - return this.earliestArrivals[transfer.origin] + transfer.duration + this.getInterchange(transfer.origin); + private getTransferArrivalTime(t: number): Time { + const origin = this.transfers.origin[t]; + + return this.earliestArrivals[origin] + this.transfers.duration[t] + this.interchange[origin]; } - /** - * A station the feed gave no interchange time for is one a change takes no time at - */ - private getInterchange(station: StopID): Time { - return this.interchange[station] ?? 0; + private arrive(station: StopIdx, time: Time): Time { + this.earliestArrivals[station] = time; + + if (this.isDestination[station] === 1) { + this.latestDestinationArrival = this.getLatestDestinationArrival(); + } + + return time; + } + + private getLatestDestinationArrival(): Time { + let latest = this.destinations.length === 0 ? -1 : 0; + + for (const destination of this.destinations) { + latest = Math.max(latest, this.earliestArrivals[destination]); + } + + return latest; + } + + public getOrigins(): StopIdx[] { + return this.origins; } public getConnectionIndex(): ConnectionIndex { return this.connectionIndex; } - public isFinished(destinations: StopID[], departureTime: Time): boolean { - return !destinations.some(d => !this.earliestArrivals[d] || departureTime < this.earliestArrivals[d]); + /** + * Every destination has been reached before the connection arrives, so neither it nor any after + * it can arrive sooner, or at the same time on a trip the passenger is aboard. + */ + public isFinished(c: Connection): boolean { + return this.connections.arrivalTime[c] > this.latestDestinationArrival; } } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts index cd8735c..130aa39 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -1,18 +1,26 @@ -import type { Interchange } from "@gb-transit/gtfs-loader"; +import type { StopID } from "@gb-transit/gtfs-loader"; +import type { GtfsData } from "../gtfs/GtfsLoader.js"; import type { OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; -import { ScanResults } from "./ScanResults.js"; +import { NOT_CARRIED, ScanResults } from "./ScanResults.js"; /** - * Creates a new ScanResults object for a given set of origins + * Creates a new ScanResults object for a given set of origins and destinations */ export class ScanResultsFactory { + private readonly tripArrivals: Int32Array; constructor( - private readonly interchange: Interchange - ) { } + private readonly gtfs: GtfsData + ) { + this.tripArrivals = new Int32Array(gtfs.trips.length); + } - public create(origins: OriginDepartureTimes): ScanResults { - return new ScanResults(this.interchange, origins); + /** + * The trip arrivals are shared between scans rather than allocated for each, so only the results + * of the scan in progress can still be asked whether a connection is reachable. + */ + public create(origins: OriginDepartureTimes, destinations: StopID[]): ScanResults { + return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED)); } } diff --git a/src/gtfs/Connections.ts b/src/gtfs/Connections.ts new file mode 100644 index 0000000..4950e33 --- /dev/null +++ b/src/gtfs/Connections.ts @@ -0,0 +1,141 @@ +import type { Time } from "@gb-transit/gtfs-loader"; +import type { StopIdx } from "./StopTable.js"; + +/** + * Every connection in the timetable as parallel arrays, so that connection `c` is + * `departureStation[c]`, `arrivalTime[c]` and so on. A scan reads millions of them in order, and + * reading numbers out of arrays is far quicker than reading fields out of an object each. + */ +export interface Connections { + length: number; + departureStation: Int32Array; + arrivalStation: Int32Array; + departureTime: Int32Array; + arrivalTime: Int32Array; + /** Index into the feed's trips */ + trip: Int32Array; + /** The position within its trip's calls of the call the connection is boarded at */ + board: Int32Array; + /** The position within its trip's calls of the call the connection is alighted at */ + alight: Int32Array; +} + +/** + * Connections as they are added, which become Connections once they are sorted. + */ +export class ConnectionList { + private connections = allocate(1 << 16); + private length = 0; + + public push( + departureStation: StopIdx, + arrivalStation: StopIdx, + departureTime: Time, + arrivalTime: Time, + trip: number, + board: number, + alight: number + ): void { + if (this.length === this.connections.length) { + this.connections = copy(this.connections, allocate(this.length * 2), this.length); + } + + const c = this.length++; + + this.connections.departureStation[c] = departureStation; + this.connections.arrivalStation[c] = arrivalStation; + this.connections.departureTime[c] = departureTime; + this.connections.arrivalTime[c] = arrivalTime; + this.connections.trip[c] = trip; + this.connections.board[c] = board; + this.connections.alight[c] = alight; + } + + /** + * The connections sorted by arrival time. Times are whole seconds within a day or two, so a + * counting sort does this in linear time, and it is stable: connections arriving together keep the + * order they were added in. + */ + public sortByArrival(): Connections { + const { arrivalTime } = this.connections; + let latest = 0; + + for (let c = 0; c < this.length; c++) { + latest = Math.max(latest, arrivalTime[c]); + } + + const position = new Int32Array(latest + 2); + + for (let c = 0; c < this.length; c++) { + position[arrivalTime[c] + 1]++; + } + + for (let time = 1; time < position.length; time++) { + position[time] += position[time - 1]; + } + + const sorted = allocate(this.length); + + for (let c = 0; c < this.length; c++) { + const to = position[arrivalTime[c]]++; + + sorted.departureStation[to] = this.connections.departureStation[c]; + sorted.arrivalStation[to] = this.connections.arrivalStation[c]; + sorted.departureTime[to] = this.connections.departureTime[c]; + sorted.arrivalTime[to] = arrivalTime[c]; + sorted.trip[to] = this.connections.trip[c]; + sorted.board[to] = this.connections.board[c]; + sorted.alight[to] = this.connections.alight[c]; + } + + return sorted; + } + +} + +/** + * The first connection arriving at or after the time. Nothing arriving earlier can have been + * boarded after it, so that is where a scan from the time starts. + */ +export function firstArrivingAt(connections: Connections, time: Time): number { + let low = 0; + let high = connections.length; + + while (low < high) { + const middle = (low + high) >>> 1; + + if (connections.arrivalTime[middle] < time) { + low = middle + 1; + } + else { + high = middle; + } + } + + return low; +} + +function allocate(length: number): Connections { + return { + length, + departureStation: new Int32Array(length), + arrivalStation: new Int32Array(length), + departureTime: new Int32Array(length), + arrivalTime: new Int32Array(length), + trip: new Int32Array(length), + board: new Int32Array(length), + alight: new Int32Array(length) + }; +} + +function copy(from: Connections, to: Connections, length: number): Connections { + to.departureStation.set(from.departureStation.subarray(0, length)); + to.arrivalStation.set(from.arrivalStation.subarray(0, length)); + to.departureTime.set(from.departureTime.subarray(0, length)); + to.arrivalTime.set(from.arrivalTime.subarray(0, length)); + to.trip.set(from.trip.subarray(0, length)); + to.board.set(from.board.subarray(0, length)); + to.alight.set(from.alight.subarray(0, length)); + + return to; +} diff --git a/src/gtfs/GtfsLoader.ts b/src/gtfs/GtfsLoader.ts index 4c44f14..a04c545 100644 --- a/src/gtfs/GtfsLoader.ts +++ b/src/gtfs/GtfsLoader.ts @@ -2,8 +2,10 @@ import { type GTFSFeed, type GTFSSource, type Interchange, loadGTFS, normalise, type StopID, type StopIndex, type StopTime, type Trip } from "@gb-transit/gtfs-loader"; -import type { TimetableConnection } from "../journey/Connection.js"; import type { Transfer } from "../journey/Journey.js"; +import { ConnectionList, type Connections } from "./Connections.js"; +import { type StopIdx, StopTable, UNKNOWN_STOP } from "./StopTable.js"; +import { TripCalendar } from "./TripCalendar.js"; /** * Returns connections, transfers and interchange times from a GTFS zip. @@ -18,23 +20,25 @@ export async function loadGtfs(source: GTFSSource): Promise { * `normalise` resolves stops to the station they belong to, defines footpaths and interchange times * at those stations, picks out the calls a passenger can actually use, and adds a trip for each * coupling so that staying on a vehicle that carries on as another service is one trip rather than - * a change. What is left here is turning each trip into connections between stations, sorted as the - * scan reads them. + * a change. What is left here is numbering those stations and turning each trip into connections + * between them, sorted as the scan reads them. */ export function toGtfsData(feed: GTFSFeed): GtfsData { const { trips, calls, transfers, interchange, stations } = normalise(feed); - const connections: TimetableConnection[] = []; + const stops = new StopTable(); + const connections = new ConnectionList(); for (let t = 0; t < trips.length; t++) { - addConnections(connections, trips[t], calls[t], stations); + addConnections(connections, t, calls[t], calls[t].map(c => stops.intern(stations.get(c.stop) ?? c.stop))); } - connections.sort((a, b) => a.arrivalTime - b.arrivalTime); - return { - connections, - transfers: indexTransfersByOrigin(transfers), - interchange, + connections: connections.sortByArrival(), + transfers: indexTransfersByOrigin(transfers, stops), + interchange: indexInterchange(interchange, stops), + calendar: new TripCalendar(trips), + trips, + stopTable: stops, stops: feed.stops, stations }; @@ -45,27 +49,14 @@ export function toGtfsData(feed: GTFSFeed): GtfsData { * passed. A stopping pattern A(p/d) -> B(d) -> C(p/d) would otherwise create A->B but never reach C, * so this gives A->B and A->C. */ -function addConnections( - connections: TimetableConnection[], - trip: Trip, - calls: StopTime[], - stations: Map -): void { - const station = calls.map(c => stations.get(c.stop) ?? c.stop); - +function addConnections(connections: ConnectionList, trip: number, calls: StopTime[], station: StopIdx[]): void { for (let i = 0; i < calls.length - 1; i++) { if (calls[i].pickUp) { for (let j = i + 1; j < calls.length; j++) { if (calls[j].dropOff) { // two calls at one station are a stop and a start, not a journey between places if (station[i] !== station[j]) { - connections.push({ - origin: station[i], - destination: station[j], - departureTime: calls[i].departureTime, - arrivalTime: calls[j].arrivalTime, - trip - }); + connections.push(station[i], station[j], calls[i].departureTime, calls[j].arrivalTime, trip, i, j); } if (calls[j].pickUp) { @@ -78,33 +69,93 @@ function addConnections( } /** - * `normalise` returns footpaths as a flat list, the scan asks for them by origin. + * `normalise` returns footpaths as a flat list, the scan asks for them by origin. Each station's + * footpaths keep the order the feed gave them in. */ -function indexTransfersByOrigin(transfers: Transfer[]): TransfersByOrigin { - const index: TransfersByOrigin = {}; +function indexTransfersByOrigin(transfers: Transfer[], stops: StopTable): Transfers { + for (const transfer of transfers) { + stops.intern(transfer.origin); + stops.intern(transfer.destination); + } + + const offsets = new Int32Array(stops.size + 1); + + for (const transfer of transfers) { + offsets[stops.indexOf(transfer.origin) + 1]++; + } + + for (let s = 1; s <= stops.size; s++) { + offsets[s] += offsets[s - 1]; + } + + const next = offsets.slice(0, stops.size); + const index: Transfers = { + offsets, + origin: new Int32Array(transfers.length), + destination: new Int32Array(transfers.length), + duration: new Int32Array(transfers.length), + transfer: new Array(transfers.length) + }; for (const transfer of transfers) { - index[transfer.origin] ??= []; - index[transfer.origin].push(transfer); + const t = next[stops.indexOf(transfer.origin)]++; + + index.origin[t] = stops.indexOf(transfer.origin); + index.destination[t] = stops.indexOf(transfer.destination); + index.duration[t] = transfer.duration; + index.transfer[t] = transfer; } return index; } /** - * Transfers indexed by origin station + * The interchange time at each station, by index. A station the feed gave no time for is one a + * change takes no time at. + */ +function indexInterchange(interchange: Interchange, stops: StopTable): Int32Array { + const times = new Int32Array(stops.size); + + for (const station of Object.keys(interchange)) { + const index = stops.indexOf(station); + + if (index !== UNKNOWN_STOP) { + times[index] = interchange[station]; + } + } + + return times; +} + +/** + * The footpaths out of each station. Station `s` owns `[offsets[s], offsets[s + 1])` of the rest, + * so footpath `t` leaves `origin[t]` for `destination[t]`. */ -export type TransfersByOrigin = Record; +export interface Transfers { + /** Bounds of each station's footpaths. Its length is the number of stations + 1 */ + offsets: Int32Array; + origin: Int32Array; + destination: Int32Array; + duration: Int32Array; + /** The footpath as the feed gave it, which is what a journey returns */ + transfer: Transfer[]; +} /** * Contents of the GTFS zip file */ export type GtfsData = { /** every connection between two stations, sorted by arrival time */ - connections: TimetableConnection[], - transfers: TransfersByOrigin, - /** interchange time at each station */ - interchange: Interchange, + connections: Connections, + transfers: Transfers, + /** interchange time at each station, by index */ + interchange: Int32Array, + /** which trips run on a date */ + calendar: TripCalendar, + /** the trips the connections run on, by index */ + trips: Trip[], + /** the stations the connections run between, numbered */ + stopTable: StopTable, /** the feed's stops, as it gave them, which may identify individual platforms */ stops: StopIndex, /** feed stop id to the station it belongs to, which is what journeys are planned between */ diff --git a/src/gtfs/StopTable.ts b/src/gtfs/StopTable.ts new file mode 100644 index 0000000..99ebae4 --- /dev/null +++ b/src/gtfs/StopTable.ts @@ -0,0 +1,55 @@ +import type { StopID } from "@gb-transit/gtfs-loader"; + +/** + * A station as the scan works in it: a number, so that its state can be held in arrays rather than + * objects keyed by code. A query's codes are exchanged for these on the way in, and results name + * them again on the way out. + */ +export type StopIdx = number; + +/** A station the feed has not heard of, so nothing runs to or from it */ +export const UNKNOWN_STOP = -1; + +/** + * The stations a feed names, numbered in the order they are met. + */ +export class StopTable { + + private readonly codes: StopID[] = []; + private readonly indexes = new Map(); + + /** + * The index of a station, numbering it if it has not been seen before + */ + public intern(code: StopID): StopIdx { + const index = this.indexes.get(code); + + if (index !== undefined) { + return index; + } + + this.indexes.set(code, this.codes.length); + this.codes.push(code); + + return this.codes.length - 1; + } + + /** + * The index of a station, or UNKNOWN_STOP where the feed does not name it + */ + public indexOf(code: StopID): StopIdx { + return this.indexes.get(code) ?? UNKNOWN_STOP; + } + + /** + * The code of a station, for naming it in a result + */ + public nameOf(index: StopIdx): StopID { + return this.codes[index]; + } + + public get size(): number { + return this.codes.length; + } + +} diff --git a/src/gtfs/TripCalendar.ts b/src/gtfs/TripCalendar.ts new file mode 100644 index 0000000..eaa0381 --- /dev/null +++ b/src/gtfs/TripCalendar.ts @@ -0,0 +1,53 @@ +import type { DateNumber, DayOfWeek, ServiceCalendar, Trip } from "@gb-transit/gtfs-loader"; + +/** + * Which trips run on a date. + * + * A scan asks about every connection it reads, and there are far fewer calendars than trips, so each + * distinct calendar is asked once for the date and the answer is kept until a scan asks about + * another. + */ +export class TripCalendar { + /** Index into services of each trip's calendar */ + private readonly tripService: Int32Array; + private readonly services: ServiceCalendar[] = []; + private readonly running: Uint8Array; + private date: DateNumber = -1; + + constructor(trips: Trip[]) { + const serviceIndex = new Map(); + + this.tripService = new Int32Array(trips.length); + this.running = new Uint8Array(trips.length); + + for (let t = 0; t < trips.length; t++) { + let index = serviceIndex.get(trips[t].service); + + if (index === undefined) { + index = this.services.length; + this.services.push(trips[t].service); + serviceIndex.set(trips[t].service, index); + } + + this.tripService[t] = index; + } + } + + /** + * For each trip, 1 if it runs on the date and 0 if not + */ + public runningOn(date: DateNumber, dow: DayOfWeek): Uint8Array { + if (this.date !== date) { + const services = this.services.map(service => service.runsOn(date, dow) ? 1 : 0); + + for (let t = 0; t < this.tripService.length; t++) { + this.running[t] = services[this.tripService[t]]; + } + + this.date = date; + } + + return this.running; + } + +} diff --git a/src/index.ts b/src/index.ts index aca425b..de1b062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,10 @@ export * from "./csa/ConnectionScanAlgorithm.js"; export * from "./csa/ScanResults.js"; export * from "./csa/ScanResultsFactory.js"; +export * from "./gtfs/Connections.js"; export * from "./gtfs/GtfsLoader.js"; +export * from "./gtfs/StopTable.js"; +export * from "./gtfs/TripCalendar.js"; export * from "./journey/Connection.js"; export * from "./journey/Journey.js"; diff --git a/src/journey/Connection.ts b/src/journey/Connection.ts index 9e275cd..e68c561 100644 --- a/src/journey/Connection.ts +++ b/src/journey/Connection.ts @@ -1,23 +1,27 @@ -import type { StopID, Time, Trip } from "@gb-transit/gtfs-loader"; -import type { AnyLeg, Transfer } from "./Journey.js"; - -export type Connection = TimetableConnection | Transfer; +import type { Connections } from "../gtfs/Connections.js"; /** - * A trip's journey between two stations, boarded at one call and alighted at a later one. + * How a station was reached: a connection by its index into the feed's connections, or a footpath + * by its index into the feed's transfers. They are told apart by sign, so that one array can hold + * how every station was reached. */ -export interface TimetableConnection { - origin: StopID; - destination: StopID; - departureTime: Time; - arrivalTime: Time; - trip: Trip; +export type Connection = number; + +/** A station reached by nothing: an origin, or one not reached at all */ +export const NO_CONNECTION = -1; + +export function transferConnection(transfer: number): Connection { + return -2 - transfer; +} + +export function isTransferConnection(connection: Connection): boolean { + return connection <= -2; } -export function isTransfer(connection: Connection | AnyLeg): connection is Transfer { - return "duration" in connection; +export function transferOf(connection: Connection): number { + return -2 - connection; } -export function isChangeRequired(a: Connection, b: Connection): boolean { - return isTransfer(a) || isTransfer(b) || a.trip.tripId !== b.trip.tripId; +export function isChangeRequired(connections: Connections, a: Connection, b: Connection): boolean { + return isTransferConnection(a) || isTransferConnection(b) || connections.trip[a] !== connections.trip[b]; } diff --git a/src/journey/Journey.ts b/src/journey/Journey.ts index 06c4870..8bcdc6e 100644 --- a/src/journey/Journey.ts +++ b/src/journey/Journey.ts @@ -41,6 +41,10 @@ export interface Transfer extends Leg { endTime: Time; } +export function isTransfer(leg: AnyLeg): leg is Transfer { + return "duration" in leg; +} + export function journeyToString(j: Journey): string { return `${toTime(j.departureTime)}, ${toTime(j.arrivalTime)}, ${[j.legs[0].origin, ...j.legs.map(l => l.destination)].join("-")}`; } diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index b493b97..e3856bf 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -1,7 +1,9 @@ import { isCall, type StopID, type StopTime, type Time, type Trip } from "@gb-transit/gtfs-loader"; import type { ConnectionIndex } from "../csa/ConnectionScanAlgorithm.js"; -import { type Connection, isChangeRequired, isTransfer } from "./Connection.js"; -import type { AnyLeg, Journey } from "./Journey.js"; +import type { GtfsData } from "../gtfs/GtfsLoader.js"; +import { UNKNOWN_STOP } from "../gtfs/StopTable.js"; +import { type Connection, isChangeRequired, isTransferConnection, NO_CONNECTION, transferOf } from "./Connection.js"; +import { type AnyLeg, isTransfer, type Journey } from "./Journey.js"; /** * Creates journeys from the connection index created by the connection scan algorithm. @@ -9,12 +11,11 @@ import type { AnyLeg, Journey } from "./Journey.js"; export class JourneyFactory { /** - * The stations are the feed's stop ids to the station each belongs to, as `GtfsData` gives them. * Connections run between stations while a trip's stop times name platforms, so a leg is cut from * its trip by asking which station each call is at. */ constructor( - private readonly stations: Map + private readonly gtfs: GtfsData ) {} /** @@ -34,19 +35,22 @@ export class JourneyFactory { private getLegs(connections: ConnectionIndex, destination: StopID): AnyLeg[] | null { const legs: Connection[][] = []; let legConnections: Connection[] = []; - let previousConnection: Connection | null = null; + let previousConnection: Connection = NO_CONNECTION; + let station = this.gtfs.stopTable.indexOf(destination); - while (connections[destination]) { - const connection = connections[destination]; + while (station !== UNKNOWN_STOP && connections[station] !== NO_CONNECTION) { + const connection = connections[station]; - if (previousConnection && isChangeRequired(previousConnection, connection)) { + if (previousConnection !== NO_CONNECTION && isChangeRequired(this.gtfs.connections, previousConnection, connection)) { legs.push(legConnections.reverse()); legConnections = []; } legConnections.push(connection); previousConnection = connection; - destination = connection.origin; + station = isTransferConnection(connection) + ? this.gtfs.transfers.origin[transferOf(connection)] + : this.gtfs.connections.departureStation[connection]; } legs.push(legConnections.reverse()); @@ -58,16 +62,17 @@ export class JourneyFactory { * Convert a list of connections into a Transfer or a TimetableLeg */ private toLeg(cs: Connection[]): AnyLeg { + const { connections, stopTable, transfers, trips } = this.gtfs; const firstConnection = cs[0]; - if (isTransfer(firstConnection)) { - return firstConnection; + if (isTransferConnection(firstConnection)) { + return transfers.transfer[transferOf(firstConnection)]; } else { - const origin = firstConnection.origin; - const destination = cs[cs.length - 1].destination; - const trip = firstConnection.trip; - const stopTimes = this.getStopTimes(trip, origin, firstConnection.departureTime, destination); + const origin = stopTable.nameOf(connections.departureStation[firstConnection]); + const destination = stopTable.nameOf(connections.arrivalStation[cs[cs.length - 1]]); + const trip = trips[connections.trip[firstConnection]]; + const stopTimes = this.getStopTimes(trip, origin, connections.departureTime[firstConnection], destination); return { origin, destination, trip, stopTimes: stopTimes || [] }; } @@ -123,7 +128,7 @@ export class JourneyFactory { } private stationOf(stopTime: StopTime): StopID { - return this.stations.get(stopTime.stop) ?? stopTime.stop; + return this.gtfs.stations.get(stopTime.stop) ?? stopTime.stop; } private getJourney(legs: AnyLeg[]): Journey { diff --git a/test/integration.ts b/test/integration.ts index 36f6e21..4152e06 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -18,8 +18,8 @@ async function run() { const gtfs = await loadGtfs(fs.createReadStream(filename)); console.timeEnd("initial load"); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs), [new MultipleCriteriaFilter()]); console.time("query"); const results = query.plan(origins, destinations, new Date(), time); diff --git a/test/performance.ts b/test/performance.ts index 363cf81..4940a1d 100644 --- a/test/performance.ts +++ b/test/performance.ts @@ -67,8 +67,8 @@ async function run() { const gtfs = await loadGtfs(fs.createReadStream(process.argv[2] || "gtfs.zip")); console.timeEnd("initial load"); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations)); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs)); console.time("planning"); const date = new Date(); diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index f7e6a29..143e184 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -1,145 +1,78 @@ +import { Service } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import { c, defaultInterchange, setStopTimes } from "../util.js"; +import { NO_CONNECTION } from "../../../src/journey/Connection.js"; +import { allDays, byOrigin, gtfsOf, legsOf, pickUpOnly, plan, st, trip, walk } from "../util.js"; describe("ConnectionScanAlgorithm", () => { - const scanResultsFactory = new ScanResultsFactory(defaultInterchange); - const journeyResultsFactory = new JourneyFactory(new Map()); - const noTransfers = { A: [], B: [], C: [], D: [] }; it("plan a basic journey", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1020, 1045), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045), st("D", 1115)])] + }, ["A"], ["D"], 900); expect(journey.origin).toBe("A"); expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(1); + expect(legsOf(journey)).toEqual(["1:A-D"]); expect(journey.departureTime).toBe(1000); expect(journey.arrivalTime).toBe(1115); }); it("returns no results when there is no connection", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - - expect(journeyResultsFactory.getJourneys(results, ["D"])).toEqual([]); + expect(plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("C", 1100), st("D", 1115)])] + }, ["A"], ["D"], 900)).toEqual([]); }); it("returns no results when there is a missed connection", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1000, 1030), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - - expect(journeyResultsFactory.getJourneys(results, ["D"])).toEqual([]); + expect(plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1000), st("D", 1030)])] + }, ["A"], ["D"], 900)).toEqual([]); }); it("returns no results for an origin or destination the feed does not have", () => { - const timetable = [ - c("A", "B", 1000, 1015), - ]; + const trips = [trip("1", [st("A", 1000), st("B", 1015)])]; - setStopTimes(timetable); + expect(plan({ trips }, ["Z"], ["B"], 900)).toEqual([]); + expect(plan({ trips }, ["A"], ["Y"], 900)).toEqual([]); + }); - const scanner = new ConnectionScanAlgorithm(timetable, {}, scanResultsFactory); - const results = scanner.scan({ Z: 900 }, ["Y"], 20190101, 0); + it("does not board a trip that does not run on the date", () => { + const septemberOnly = new Service(20260901, 20260907, allDays, {}); - expect(journeyResultsFactory.getJourneys(results, ["Y"])).toEqual([]); + expect(plan({ trips: [trip("1", [st("A", 1000), st("B", 1015)], septemberOnly)] }, ["A"], ["B"], 900)).toEqual([]); }); it("plan a journey that starts with a transfer", () => { - const timetable = [ - c("B", "C", 1020, 1045), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); + const [journey] = plan({ + trips: [trip("1", [st("B", 1020), st("C", 1045), st("D", 1115)])], + transfers: byOrigin(walk("A", "B", 10)) + }, ["A"], ["D"], 900); - const transfers = { - ...noTransfers, - A: [ - { origin: "A", destination: "B", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - ] - }; - - const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(2); + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-D"]); expect(journey.departureTime).toBe(1010); expect(journey.arrivalTime).toBe(1115); }); it("plan a journey that ends with a transfer", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1020, 1045), - ]; - - setStopTimes(timetable); - - const transfers = { - ...noTransfers, - C: [ - { origin: "C", destination: "D", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - ] - }; - - const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045)])], + transfers: byOrigin(walk("C", "D", 10)) + }, ["A"], ["D"], 900); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(2); + expect(legsOf(journey)).toEqual(["1:A-C", "walk:C-D"]); expect(journey.departureTime).toBe(1000); expect(journey.arrivalTime).toBe(1055); }); it("walks on from a station reached on foot again once a train reaches it sooner", () => { - const aToB = [c("A", "B", 1000, 1100, "1")]; - const cToD = [c("C", "D", 1200, 1300, "2")]; - - setStopTimes(aToB); - setStopTimes(cToD); - - const transfers = { - A: [{ origin: "A", destination: "B", duration: 1000, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }], - B: [{ origin: "B", destination: "C", duration: 60, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }] - }; - - const scanner = new ConnectionScanAlgorithm([...aToB, ...cToD], transfers, new ScanResultsFactory({})); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1100)]), trip("2", [st("C", 1200), st("D", 1300)])], + transfers: byOrigin(walk("A", "B", 1000), walk("B", "C", 60)) + }, ["A"], ["D"], 900); - expect(journey.legs.map(l => `${l.origin}-${l.destination}`)).toEqual(["A-B", "B-C", "C-D"]); + expect(legsOf(journey)).toEqual(["1:A-B", "walk:B-C", "2:C-D"]); expect(journey.arrivalTime).toBe(1300); }); @@ -151,34 +84,62 @@ describe("ConnectionScanAlgorithm", () => { * up by realising that the whole journey could be made on a single trip (trip 2). */ it("checks for connections missed because of interchange time", () => { - const trip1 = [ - c("A", "B", 1000, 1010, "1"), - c("B", "C", 1010, 1020, "1"), - c("C", "D", 1020, 1040, "1"), - ]; + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("B", 1010), st("C", 1020), st("D", 1040)]), + trip("2", [st("A", 1005), st("B", 1015), st("C", 1025), st("D", 1035)]) + ], + interchange: { A: 10, B: 10, C: 10, D: 10 } + }, ["A"], ["D"], 900); + + expect(legsOf(journey)).toEqual(["2:A-D"]); + expect(journey.departureTime).toBe(1005); + expect(journey.arrivalTime).toBe(1035); + }); - setStopTimes(trip1); + /** + * Trips 1 and 2 run the same calls, both only picking up at A and B. Trip 3 reaches B before they + * leave it, but not in time to change. Being aboard trip 2 from A does not make its connection from + * B one the passenger can take, since trip 2 never carried them to B, only through it. + */ + it("does not count a passenger aboard at a call the trip only picks up at", () => { + const calls = [pickUpOnly("A", 1000), pickUpOnly("B", 1060), st("C", 2000)]; + const [journey] = plan({ + trips: [trip("1", calls), trip("2", calls), trip("3", [st("A", 1005), st("B", 1050)])], + interchange: { B: 300 } + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["1:A-C"]); + }); - const trip2 = [ - c("A", "B", 1005, 1015, "2"), - c("B", "C", 1015, 1025, "2"), - c("C", "D", 1025, 1035, "2"), - ]; + /** + * Trip 1 carries the passenger to C and on to D. Trip 2 leaves C for D at the same time, and is + * scanned first, so it reaches D first. Trip 3 hops between two other stations in no time at the + * moment both arrive, between the two in the scan, and must not end it before trip 1 is preferred. + */ + it("keeps scanning for a trip to stay aboard past a connection that takes no time", () => { + const [journey] = plan({ + trips: [ + trip("2", [st("C", 1000), st("D", 1060)]), + trip("3", [st("E", 1060), st("F", 1060)]), + trip("1", [st("A", 900), st("C", 1000), st("D", 1060)]) + ] + }, ["A"], ["D"], 800); - setStopTimes(trip2); + expect(legsOf(journey)).toEqual(["1:A-D"]); + }); - const timetable = [...trip1, ...trip2].sort((a, b) => a.arrivalTime - b.arrivalTime); + it("gives each scan a connection index of its own", () => { + const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] }); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const [a, b] = ["A", "B"].map(code => gtfs.stopTable.indexOf(code)); - const resultsFactory = new ScanResultsFactory({ A: 10, B: 10, C: 10, D: 10 }); - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, resultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20200101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + const fromA = csa.scan({ A: 900 }, ["C"], 20260908, 2); + const fromB = csa.scan({ B: 900 }, ["C"], 20260908, 2); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(1); - expect(journey.departureTime).toBe(1005); - expect(journey.arrivalTime).toBe(1035); + expect(fromA[b]).not.toBe(NO_CONNECTION); + expect(fromB[a]).toBe(NO_CONNECTION); + expect(fromB[b]).toBe(NO_CONNECTION); }); }); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index b294e32..be6fd07 100644 --- a/test/unit/csa/ScanResults.spec.ts +++ b/test/unit/csa/ScanResults.spec.ts @@ -1,146 +1,174 @@ import { describe, expect, it } from "vitest"; import { ScanResults } from "../../../src/csa/ScanResults.js"; import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { c, defaultInterchange, t } from "../util.js"; +import { transferConnection } from "../../../src/journey/Connection.js"; +import { byOrigin, connection, gtfsOf, pickUpOnly, resultsFor, st, transfer, trip, walk } from "../util.js"; describe("ScanResults", () => { + const gtfs = gtfsOf({ + trips: [ + trip("LN1111", [st("A", 1000), st("B", 1015)]), + trip("LN1112", [st("A", 1000), st("B", 1010)]), + trip("LN1113", [st("A", 1000), st("B", 1030)]), + trip("LN1114", [st("B", 1030), st("C", 1100)]) + ], + transfers: byOrigin(walk("A", "B", 10), walk("A", "C", 1000), walk("B", "C", 10)) + }); it("knows if a connection is reachable", () => { - const connection = c("A", "B", 1000, 1015); - const results = new ScanResults(defaultInterchange, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); - expect(results.isReachable(connection)).toBe(true); + expect(results.isReachable(connection(gtfs, "LN1111", "A", "B"))).toBe(true); }); it("knows if a connection is not reachable", () => { - const connection = c("A", "B", 1000, 1015); - const results = new ScanResults(defaultInterchange, { A: 1200 }); + const results = resultsFor(gtfs, { A: 1200 }); - expect(results.isReachable(connection)).toBe(false); + expect(results.isReachable(connection(gtfs, "LN1111", "A", "B"))).toBe(false); }); it("knows if a connection is not reachable because of interchange", () => { - const connection1 = c("A", "B", 1000, 1015, "LN1111"); - const connection2 = c("B", "C", 1030, 1100, "LN1112"); - const results = new ScanResults({ B: 100 }, { A: 900 }); + const withInterchange = gtfsOf({ + trips: [trip("LN1111", [st("A", 1000), st("B", 1015)]), trip("LN1112", [st("B", 1030), st("C", 1100)])], + interchange: { B: 100 } + }); + const results = resultsFor(withInterchange, { A: 900 }); - results.setConnection(connection1); + results.setConnection(connection(withInterchange, "LN1111", "A", "B")); - expect(results.isReachable(connection2)).toBe(false); + expect(results.isReachable(connection(withInterchange, "LN1112", "B", "C"))).toBe(false); }); it("changes in no time at a station with no interchange time", () => { - const connection1 = c("A", "B", 1000, 1015, "LN1111"); - const connection2 = c("B", "C", 1015, 1100, "LN1112"); - const results = new ScanResults({}, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection1); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); - expect(results.isReachable(connection2)).toBe(true); + expect(results.isReachable(connection(gtfs, "LN1114", "B", "C"))).toBe(true); }); it("knows if a connection is better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = c("A", "B", 1000, 1010); - const results = new ScanResults(defaultInterchange, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection1); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); - expect(results.isBetter(connection2)).toBe(true); + expect(results.isBetter(connection(gtfs, "LN1112", "A", "B"))).toBe(true); }); it("knows if a connection is not better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = c("A", "B", 1000, 1030); - const results = new ScanResults(defaultInterchange, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection1); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); - expect(results.isBetter(connection2)).toBe(false); + expect(results.isBetter(connection(gtfs, "LN1113", "A", "B"))).toBe(false); }); it("prefers staying aboard to changing onto a trip arriving at the same time", () => { - const toB = c("A", "B", 1000, 1015, "front"); - const rearToC = c("B", "C", 1030, 1100, "rear"); - const throughToB = c("A", "B", 1000, 1015, "through"); - const throughToC = c("B", "C", 1030, 1100, "through"); - const results = new ScanResults({ B: 5 }, { A: 900 }); - - for (const connection of [toB, throughToB, rearToC]) { - if (results.isReachable(connection) && results.isBetter(connection)) { - results.setConnection(connection); + const coupled = gtfsOf({ + trips: [ + trip("front", [st("A", 1000), st("B", 1015)]), + trip("rear", [st("B", 1030), st("C", 1100)]), + trip("through", [st("A", 1000), st("B", 1015), st("C", 1100)]) + ], + interchange: { B: 5 } + }); + const results = resultsFor(coupled, { A: 900 }); + + for (const c of [connection(coupled, "front", "A", "B"), connection(coupled, "through", "A", "B"), connection(coupled, "rear", "B", "C")]) { + if (results.isReachable(c) && results.isBetter(c)) { + results.setConnection(c); } } + const throughToC = connection(coupled, "through", "B", "C"); + expect(results.isReachable(throughToC)).toBe(true); expect(results.isBetter(throughToC)).toBe(true); }); it("does not replace a trip with another arriving at the same time that it would have to change onto", () => { - const toB = c("A", "B", 1000, 1015, "1"); - const toC = c("B", "C", 1030, 1100, "2"); - const alsoToC = c("B", "C", 1030, 1100, "3"); - const results = new ScanResults({ B: 5 }, { A: 900 }); + const parallel = gtfsOf({ + trips: [ + trip("1", [st("A", 1000), st("B", 1015)]), + trip("2", [st("B", 1030), st("C", 1100)]), + trip("3", [st("B", 1030), st("C", 1100)]) + ], + interchange: { B: 5 } + }); + const results = resultsFor(parallel, { A: 900 }); + + results.setConnection(connection(parallel, "1", "A", "B")); + results.setConnection(connection(parallel, "2", "B", "C")); - results.setConnection(toB); - results.setConnection(toC); + const alsoToC = connection(parallel, "3", "B", "C"); expect(results.isReachable(alsoToC)).toBe(true); expect(results.isBetter(alsoToC)).toBe(false); }); + it("does not count a passenger aboard at a call the trip only picks up at", () => { + const pickUps = gtfsOf({ trips: [trip("1", [pickUpOnly("A", 1000), pickUpOnly("B", 1060), st("C", 2000)])] }); + const results = resultsFor(pickUps, { A: 900 }); + + expect(results.isReachable(connection(pickUps, "1", "A", "C"))).toBe(true); + expect(results.isReachable(connection(pickUps, "1", "B", "C"))).toBe(false); + }); + it("knows if a transfer is better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("A", "B", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection1); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); - expect(results.isTransferBetter(connection2)).toBe(true); + expect(results.isTransferBetter(transfer(gtfs, "A", "B"))).toBe(true); }); it("knows if a transfer is not better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("A", "B", 1000); - const results = new ScanResults(defaultInterchange, { A: 900 }); + const results = resultsFor(gtfs, { A: 900 }); + + results.setTransfer(transfer(gtfs, "A", "B")); + + expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(true); - results.setConnection(connection1); + results.setTransfer(transfer(gtfs, "B", "C")); - expect(results.isTransferBetter(connection2)).toBe(false); + expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(false); }); - it("charges the interchange time at both ends of a walk", () => { - const results = new ScanResults({ B: 300, C: 200 }, { A: 900 }); + it("charges the interchange time at both ends of a transfer", () => { + const walking = gtfsOf({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("C", 1574), st("D", 1700)]), trip("3", [st("C", 1575), st("D", 1700)])], + transfers: byOrigin(walk("B", "C", 60)), + interchange: { B: 300, C: 200 } + }); + const results = resultsFor(walking, { A: 900 }); - results.setConnection(c("A", "B", 1000, 1015)); - results.setTransfer(t("B", "C", 60)); + results.setConnection(connection(walking, "1", "A", "B")); + results.setTransfer(transfer(walking, "B", "C")); - expect(results.isReachable(c("C", "D", 1574, 1600, "2"))).toBe(false); - expect(results.isReachable(c("C", "D", 1575, 1600, "3"))).toBe(true); + expect(results.isReachable(connection(walking, "2", "C", "D"))).toBe(false); + expect(results.isReachable(connection(walking, "3", "C", "D"))).toBe(true); }); - it("knows if a transfer is better than a transfer", () => { - const connection1 = t("A", "B", 20); - const connection2 = t("A", "B", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); + it("returns the connection index", () => { + const results = resultsFor(gtfs, { A: 900 }); + const [b, c] = ["B", "C"].map(code => gtfs.stopTable.indexOf(code)); - results.setTransfer(connection1); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); + results.setTransfer(transfer(gtfs, "B", "C")); - expect(results.isTransferBetter(connection2)).toBe(true); + expect(results.getConnectionIndex()[b]).toBe(connection(gtfs, "LN1111", "A", "B")); + expect(results.getConnectionIndex()[c]).toBe(transferConnection(transfer(gtfs, "B", "C"))); }); - it("returns the connection index", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("B", "C", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); + it("is finished once a connection arrives after every destination was reached", () => { + const results = resultsFor(gtfs, { A: 900 }, ["B"]); - results.setConnection(connection1); - results.setTransfer(connection2); + expect(results.isFinished(connection(gtfs, "LN1112", "A", "B"))).toBe(false); - const actual = results.getConnectionIndex(); + results.setConnection(connection(gtfs, "LN1112", "A", "B")); - expect(actual.B).toEqual(connection1); - expect(actual.C).toEqual(connection2); + expect(results.isFinished(connection(gtfs, "LN1112", "A", "B"))).toBe(false); + expect(results.isFinished(connection(gtfs, "LN1111", "A", "B"))).toBe(true); }); }); @@ -148,9 +176,9 @@ describe("ScanResults", () => { describe("ScanResultsFactory", () => { it("creates a ScanResults object", () => { - const factory = new ScanResultsFactory({}); + const gtfs = gtfsOf({}); - expect(factory.create({ A: 900 })).toBeInstanceOf(ScanResults); + expect(new ScanResultsFactory(gtfs).create({ A: 900 }, [])).toBeInstanceOf(ScanResults); }); }); diff --git a/test/unit/gtfs/GtfsLoader.spec.ts b/test/unit/gtfs/GtfsLoader.spec.ts index 2d18426..ef98d46 100644 --- a/test/unit/gtfs/GtfsLoader.spec.ts +++ b/test/unit/gtfs/GtfsLoader.spec.ts @@ -1,64 +1,86 @@ -import type { Trip } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; -import { toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; -import { everyDay, feed, st } from "../util.js"; - -function trip(tripId: string, ...stopTimes: Trip["stopTimes"]): Trip { - return { tripId, serviceId: "1", service: everyDay, stopTimes }; -} - -function pairs(trips: Trip[], links = feed().links) { - return toGtfsData(feed({ trips, links })).connections.map(c => `${c.origin}-${c.destination}`); +import { type GtfsData, toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; +import { UNKNOWN_STOP } from "../../../src/gtfs/StopTable.js"; +import { allDays, byOrigin, everyDay, feed, platforms, st, trip, walk } from "../util.js"; +import { Service } from "@gb-transit/gtfs-loader"; + +/** + * The connections as the stations they run between, in the order the scan reads them. + */ +function pairs(gtfs: GtfsData): string[] { + const { connections, stopTable } = gtfs; + + return Array.from( + { length: connections.length }, + (_, c) => `${stopTable.nameOf(connections.departureStation[c])}-${stopTable.nameOf(connections.arrivalStation[c])}` + ); } describe("toGtfsData", () => { it("creates connections between the stations the platforms belong to", () => { const gtfs = toGtfsData(feed({ - trips: [trip("1", st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200))] + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200)])] })); - - expect(gtfs.connections.map(c => [c.origin, c.destination, c.departureTime, c.arrivalTime])).toEqual([ - ["NRW", "DIS", 1000, 1100], - ["DIS", "LST", 1100, 1200] - ]); + const { connections } = gtfs; + + expect(pairs(gtfs)).toEqual(["NRW-DIS", "DIS-LST"]); + expect([...connections.departureTime]).toEqual([1000, 1100]); + expect([...connections.arrivalTime]).toEqual([1100, 1200]); + expect([...connections.board]).toEqual([0, 1]); + expect([...connections.alight]).toEqual([1, 2]); + expect(gtfs.stopTable.indexOf("NRW")).toBe(0); + expect(gtfs.stopTable.indexOf("NRW1")).toBe(UNKNOWN_STOP); expect(gtfs.stations.get("NRW1")).toBe("NRW"); }); - it("sorts the connections by arrival time", () => { + it("sorts the connections by arrival, keeping the order they were created in where they arrive together", () => { const gtfs = toGtfsData(feed({ trips: [ - trip("1", st("NRW1", 1000), st("LST8", 1300)), - trip("2", st("DIS2", 1100), st("IPS1", 1200)) + trip("1", [st("A", 1000), st("D", 1300)]), + trip("2", [st("B", 1100), st("C", 1200)]), + trip("3", [st("E", 1250), st("F", 1300)]) ] })); - expect(gtfs.connections.map(c => c.arrivalTime)).toEqual([1200, 1300]); + expect(pairs(gtfs)).toEqual(["B-C", "A-D", "E-F"]); + expect([...gtfs.connections.trip].map(t => gtfs.trips[t].tripId)).toEqual(["2", "1", "3"]); }); it("carries on past a call that can only be alighted at until one that can be boarded", () => { - const setDownOnly = { ...st("DIS2", 1100), pickUp: false }; + const setDownOnly = { ...st("B", 1100), pickUp: false }; + const gtfs = toGtfsData(feed({ trips: [trip("1", [st("A", 1000), setDownOnly, st("C", 1200)])] })); - expect(pairs([trip("1", st("NRW1", 1000), setDownOnly, st("LST8", 1200))])).toEqual(["NRW-DIS", "NRW-LST"]); + expect(pairs(gtfs)).toEqual(["A-B", "A-C"]); }); it("does not create a connection to or from a passing point", () => { - const passing = { ...st("DIS2", 1100), pickUp: false, dropOff: false }; + const passing = { ...st("B", 1100), pickUp: false, dropOff: false }; + const gtfs = toGtfsData(feed({ trips: [trip("1", [st("A", 1000), passing, st("C", 1200)])] })); - expect(pairs([trip("1", st("NRW1", 1000), passing, st("LST8", 1200))])).toEqual(["NRW-LST"]); + expect(pairs(gtfs)).toEqual(["A-C"]); + expect([...gtfs.connections.alight]).toEqual([1]); }); it("does not create a connection between two platforms of one station", () => { - expect(pairs([trip("1", st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200))])).toEqual(["NRW-LST"]); + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200)])] + })); + + expect(pairs(gtfs)).toEqual(["NRW-LST"]); }); it("adds the trip a passenger stays on across a coupling", () => { - const connections = toGtfsData(feed({ - trips: [trip("front", st("NRW1", 1000), st("DIS2", 1100)), trip("rear", st("DIS1", 1130), st("LST8", 1230))], + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("front", [st("NRW1", 1000), st("DIS2", 1100)]), trip("rear", [st("DIS1", 1130), st("LST8", 1230)])], links: [{ fromTripId: "front", toTripId: "rear", fromStop: "DIS2", toStop: "DIS1" }] - })).connections; + })); + const { connections, trips } = gtfs; - expect(connections.map(c => `${c.trip.tripId}:${c.origin}-${c.destination}`)).toEqual([ + expect(pairs(gtfs).map((pair, c) => `${trips[connections.trip[c]].tripId}:${pair}`)).toEqual([ "front:NRW-DIS", "front_rear:NRW-DIS", "rear:DIS-LST", @@ -68,21 +90,50 @@ describe("toGtfsData", () => { it("indexes footpaths by the origin station and drops those within one", () => { const gtfs = toGtfsData(feed({ - transfers: { - NRW1: [ - { origin: "NRW1", destination: "NRW2", duration: 300, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - { origin: "NRW1", destination: "DIS2", duration: 600, startTime: 0, endTime: Number.MAX_SAFE_INTEGER } - ] - } + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + transfers: byOrigin(walk("NRW1", "NRW2", 300), walk("NRW1", "DIS2", 600), walk("DIS1", "IPS1", 900)) })); + const { offsets, destination, duration, transfer } = gtfs.transfers; + const from = (code: string) => { + const s = gtfs.stopTable.indexOf(code); - expect(gtfs.transfers.NRW.map(t => [t.destination, t.duration])).toEqual([["DIS", 600]]); + return Array.from({ length: offsets[s + 1] - offsets[s] }, (_, i) => offsets[s] + i); + }; + + expect(from("NRW").map(i => [gtfs.stopTable.nameOf(destination[i]), duration[i]])).toEqual([["DIS", 600]]); + expect(from("DIS").map(i => transfer[i].destination)).toEqual(["IPS"]); + expect(from("IPS")).toEqual([]); + }); + + it("reports interchange time against the station, and none where the feed gives none", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + interchange: { NRW1: 300 } + })); + + expect(gtfs.interchange[gtfs.stopTable.indexOf("NRW")]).toBe(300); + expect(gtfs.interchange[gtfs.stopTable.indexOf("DIS")]).toBe(0); }); - it("reports interchange time against the station", () => { - const gtfs = toGtfsData(feed({ interchange: { NRW1: 300 } })); + it("knows which trips run on a date, asking each calendar once", () => { + let asked = 0; + const counting = { runsOn: () => { asked++; return true; }, dayEarlier: () => counting }; + const septemberOnly = new Service(20260901, 20260907, allDays, {}); + const gtfs = toGtfsData(feed({ + trips: [ + trip("1", [st("A", 1000), st("B", 1100)], counting), + trip("2", [st("B", 1200), st("C", 1300)], counting), + trip("3", [st("C", 1400), st("D", 1500)], septemberOnly), + trip("4", [st("D", 1600), st("E", 1700)], everyDay) + ] + })); - expect(gtfs.interchange.NRW).toBe(300); + expect([...gtfs.calendar.runningOn(20260908, 2)]).toEqual([1, 1, 0, 1]); + expect([...gtfs.calendar.runningOn(20260908, 2)]).toEqual([1, 1, 0, 1]); + expect(asked).toBe(1); + expect([...gtfs.calendar.runningOn(20260905, 6)]).toEqual([1, 1, 1, 1]); }); }); diff --git a/test/unit/journey/Connection.spec.ts b/test/unit/journey/Connection.spec.ts index 99e9ea9..44b042d 100644 --- a/test/unit/journey/Connection.spec.ts +++ b/test/unit/journey/Connection.spec.ts @@ -1,37 +1,37 @@ import { describe, expect, it } from "vitest"; -import { isChangeRequired, isTransfer } from "../../../src/journey/Connection.js"; -import { c, t } from "../util.js"; +import { + isChangeRequired, isTransferConnection, NO_CONNECTION, transferConnection, transferOf +} from "../../../src/journey/Connection.js"; +import { connection, gtfsOf, st, trip } from "../util.js"; describe("Connection", () => { + const gtfs = gtfsOf({ + trips: [trip("LN1111", [st("A", 1000), st("B", 1030), st("C", 1100)]), trip("LN1112", [st("A", 1000), st("B", 1030)])] + }); it("knows if it's a transfer", () => { - expect(isTransfer(t("A", "B", 10))).toBe(true); + expect(isTransferConnection(transferConnection(0))).toBe(true); + expect(transferOf(transferConnection(3))).toBe(3); }); it("knows if it's not a transfer", () => { - expect(isTransfer(c("A", "B", 1000, 1030))).toBe(false); + expect(isTransferConnection(connection(gtfs, "LN1111", "A", "B"))).toBe(false); + expect(isTransferConnection(NO_CONNECTION)).toBe(false); }); it("knows if a change is required", () => { - const timetableConnection1 = c("A", "B", 1000, 1030); - const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); - - expect(isChangeRequired(timetableConnection1, timetableConnection2)).toBe(true); + expect(isChangeRequired(gtfs.connections, connection(gtfs, "LN1111", "A", "B"), connection(gtfs, "LN1112", "A", "B"))).toBe(true); }); it("knows if a change is not required", () => { - const timetableConnection1 = c("A", "B", 1000, 1030, "LN1112"); - const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); - - expect(isChangeRequired(timetableConnection1, timetableConnection2)).toBe(false); + expect(isChangeRequired(gtfs.connections, connection(gtfs, "LN1111", "A", "B"), connection(gtfs, "LN1111", "B", "C"))).toBe(false); }); it("knows if a change is required between a transfer", () => { - const timetableConnection = c("A", "B", 1000, 1030, "LN1112"); - const transfer = t("A", "B", 1000); + const timetableConnection = connection(gtfs, "LN1111", "A", "B"); - expect(isChangeRequired(timetableConnection, transfer)).toBe(true); - expect(isChangeRequired(transfer, timetableConnection)).toBe(true); + expect(isChangeRequired(gtfs.connections, timetableConnection, transferConnection(0))).toBe(true); + expect(isChangeRequired(gtfs.connections, transferConnection(0), timetableConnection)).toBe(true); }); }); diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts index b3806e6..1de7027 100644 --- a/test/unit/journey/JourneyFactory.spec.ts +++ b/test/unit/journey/JourneyFactory.spec.ts @@ -1,25 +1,17 @@ import { describe, expect, it } from "vitest"; -import { ScanResults } from "../../../src/csa/ScanResults.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; import type { TimetableLeg } from "../../../src/journey/Journey.js"; -import { c, setStopTimes, st, t } from "../util.js"; +import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; +import { byOrigin, connection, gtfsOf, legsOf, platforms, resultsFor, st, transfer, trip, walk } from "../util.js"; describe("JourneyFactory", () => { - const factory = new JourneyFactory(new Map()); it("creates a journey from a connection index", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("A", "B", 1000, 1030) - ]; + const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1030)])] }); + const results = resultsFor(gtfs, { A: 1000 }); - setStopTimes(connections); + results.setConnection(connection(gtfs, "1", "A", "B")); - for (const connection of connections) { - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["B"]); + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["B"]); expect(journey.origin).toBe("A"); expect(journey.destination).toBe("B"); @@ -27,83 +19,75 @@ describe("JourneyFactory", () => { expect(journey.arrivalTime).toBe(1030); }); - it("calculates the departure time", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("B", "C", 1100, 1130) - ]; + it("returns no journey to a destination that was not reached or the feed does not have", () => { + const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1030)])] }); + const results = resultsFor(gtfs, { A: 1000 }); - setStopTimes(connections); - results.setTransfer(t("A", "B", 60)); + expect(new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["B", "Z"])).toEqual([]); + }); - for (const connection of connections) { - results.setConnection(connection); - } + it("calculates the departure time", () => { + const gtfs = gtfsOf({ + trips: [trip("1", [st("B", 1100), st("C", 1130)])], + transfers: byOrigin(walk("A", "B", 60)) + }); + const results = resultsFor(gtfs, { A: 1000 }); - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["C"]); + results.setTransfer(transfer(gtfs, "A", "B")); + results.setConnection(connection(gtfs, "1", "B", "C")); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("C"); + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["C"]); + + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-C"]); expect(journey.departureTime).toBe(1040); expect(journey.arrivalTime).toBe(1130); }); it("calculates the arrival time", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("B", "C", 1100, 1130) - ]; - - setStopTimes(connections); - results.setTransfer(t("A", "B", 60)); - results.setTransfer(t("C", "D", 60)); + const gtfs = gtfsOf({ + trips: [trip("1", [st("B", 1100), st("C", 1130)])], + transfers: byOrigin(walk("A", "B", 60), walk("C", "D", 60)) + }); + const results = resultsFor(gtfs, { A: 1000 }); - for (const connection of connections) { - results.setConnection(connection); - } + results.setTransfer(transfer(gtfs, "A", "B")); + results.setConnection(connection(gtfs, "1", "B", "C")); + results.setTransfer(transfer(gtfs, "C", "D")); - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["D"]); + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["D"]); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-C", "walk:C-D"]); expect(journey.departureTime).toBe(1040); expect(journey.arrivalTime).toBe(1190); }); it("removes pointless legs", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("A", "B", 1000, 1010, "LN1111"), - c("B", "C", 1010, 1020, "LN1112"), - c("C", "D", 1020, 1030, "LN1113"), - c("D", "E", 1030, 1040, "LN1114") - ]; - const stopTimes = [st("A", 1000), st("B", 1010), st("C", 1020), st("D", 1030), st("E", 1040)]; - - for (const connection of connections) { - connection.trip.stopTimes = stopTimes; - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["E"]); + const calls = [st("A", 1000), st("B", 1010), st("C", 1020), st("D", 1030), st("E", 1040)]; + const gtfs = gtfsOf({ + trips: [trip("LN1111", calls), trip("LN1112", calls), trip("LN1113", calls), trip("LN1114", calls)] + }); + const results = resultsFor(gtfs, { A: 1000 }); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("E"); + results.setConnection(connection(gtfs, "LN1111", "A", "B")); + results.setConnection(connection(gtfs, "LN1112", "B", "C")); + results.setConnection(connection(gtfs, "LN1113", "C", "D")); + results.setConnection(connection(gtfs, "LN1114", "D", "E")); + + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["E"]); + + expect(legsOf(journey)).toEqual(["LN1114:A-E"]); expect(journey.departureTime).toBe(1000); expect(journey.arrivalTime).toBe(1040); - expect(journey.legs.length).toBe(1); }); - it("cuts a leg from the trip by the station each platform belongs to, leaving out passing points", () => { - const stations = new Map([["NRW1", "NRW"], ["DIS2", "DIS"], ["LST8", "LST"]]); - const results = new ScanResults({}, { NRW: 900 }); - const connection = c("NRW", "LST", 1000, 1200); + it("names the platforms a leg uses and leaves out the points it passes through", () => { const passing = { ...st("DIS2", 1100), pickUp: false, dropOff: false }; + const gtfs = gtfsOf({ stops: platforms, trips: [trip("1", [st("NRW1", 1000), passing, st("LST8", 1200)])] }); + const results = resultsFor(gtfs, { NRW: 900 }); - connection.trip.stopTimes = [st("NRW1", 1000), passing, st("LST8", 1200)]; - results.setConnection(connection); + results.setConnection(connection(gtfs, "1", "NRW", "LST")); - const [journey] = new JourneyFactory(stations).getJourneys(results.getConnectionIndex(), ["LST"]); + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["LST"]); const leg = journey.legs[0] as TimetableLeg; expect(leg.origin).toBe("NRW"); diff --git a/test/unit/query/DepartAfterQuery.spec.ts b/test/unit/query/DepartAfterQuery.spec.ts index 5fd83f6..5a0dad8 100644 --- a/test/unit/query/DepartAfterQuery.spec.ts +++ b/test/unit/query/DepartAfterQuery.spec.ts @@ -1,25 +1,11 @@ -import { type GTFSFeed, Service, type ServiceCalendar, type StopTime, type Trip } from "@gb-transit/gtfs-loader"; +import { type GTFSFeed, Service, type ServiceCalendar } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; -import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; -import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; import type { TimetableLeg } from "../../../src/journey/Journey.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; -import { MultipleCriteriaFilter } from "../../../src/query/MultipleCriteriaFilter.js"; -import { allDays, everyDay, feed, st } from "../util.js"; - -const TUESDAY = new Date("2026-09-08T09:00:00"); - -function trip(tripId: string, stopTimes: StopTime[], service: ServiceCalendar = everyDay): Trip { - return { tripId, serviceId: tripId, service, stopTimes }; -} +import type { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; +import { allDays, gtfsOf, platforms, queryOver, st, trip, TUESDAY } from "../util.js"; function query(overrides: Partial): DepartAfterQuery { - const gtfs = toGtfsData(feed(overrides)); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - - return new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]); + return queryOver(gtfsOf({ stops: platforms, ...overrides })); } function tripsOf(legs: unknown[]): string[] { diff --git a/test/unit/util.ts b/test/unit/util.ts index 36246a6..2775922 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -1,71 +1,68 @@ -import { type GTFSFeed, Service, type StopID, type StopTime, type Time, type TripID } from "@gb-transit/gtfs-loader"; -import type { TimetableConnection } from "../../src/journey/Connection.js"; -import type { Transfer } from "../../src/journey/Journey.js"; +import { + type GTFSFeed, Service, type ServiceCalendar, type Stop, type StopID, type StopTime, type Time, type Transfer, + type Trip +} from "@gb-transit/gtfs-loader"; +import { ConnectionScanAlgorithm } from "../../src/csa/ConnectionScanAlgorithm.js"; +import type { ScanResults } from "../../src/csa/ScanResults.js"; +import { ScanResultsFactory } from "../../src/csa/ScanResultsFactory.js"; +import { type GtfsData, toGtfsData } from "../../src/gtfs/GtfsLoader.js"; +import type { Connection } from "../../src/journey/Connection.js"; +import type { Journey, TimetableLeg } from "../../src/journey/Journey.js"; +import { JourneyFactory } from "../../src/journey/JourneyFactory.js"; +import { DepartAfterQuery } from "../../src/query/DepartAfterQuery.js"; +import { MultipleCriteriaFilter } from "../../src/query/MultipleCriteriaFilter.js"; export const allDays = { 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true }; export const everyDay = new Service(20190101, 20991231, allDays, {}); -export const defaultInterchange = { - A: 0, - B: 0 -}; +export const TUESDAY = new Date("2026-09-08T09:00:00"); /** - * A connection on the given trip, which calls nowhere until setStopTimes is given its connections. + * A call at a stop, arriving and departing at the same time. */ -export function c( - origin: StopID, - destination: StopID, - departureTime: Time, - arrivalTime: Time, - tripId: TripID = "LN1111" -): TimetableConnection { - return { - origin, - destination, - departureTime, - arrivalTime, - trip: { - tripId, - serviceId: "1", - stopTimes: [], - service: everyDay - } - }; +export function st(stop: StopID, time: Time): StopTime { + return { stop, pickUp: true, dropOff: true, departureTime: time, arrivalTime: time }; } /** - * A footpath, available all day. + * A call that can only be boarded. */ -export function t(origin: StopID, destination: StopID, duration: Time): Transfer { - return { origin, destination, duration, startTime: 0, endTime: 2359 }; +export function pickUpOnly(stop: StopID, time: Time): StopTime { + return { ...st(stop, time), dropOff: false }; } /** - * A call at a stop, arriving and departing at the same time. + * A trip with the given calls, running every day unless given a calendar. */ -export function st(stop: StopID, time: Time): StopTime { - return { stop, pickUp: true, dropOff: true, departureTime: time, arrivalTime: time }; +export function trip(tripId: string, stopTimes: StopTime[], service: ServiceCalendar = everyDay): Trip { + return { tripId, serviceId: tripId, service, stopTimes }; } /** - * Give every connection the stop times of the trip they make up together. + * A footpath, available all day. */ -export function setStopTimes(connections: TimetableConnection[]): void { - const stopTimes = connections.map(connection => st(connection.origin, connection.departureTime)); - const last = connections[connections.length - 1]; +export function walk(origin: StopID, destination: StopID, duration: Time): Transfer { + return { origin, destination, duration, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }; +} - stopTimes.push(st(last.destination, last.arrivalTime)); +/** + * The footpaths indexed by the stop they leave from, as the loader gives them. + */ +export function byOrigin(...transfers: Transfer[]): GTFSFeed["transfers"] { + const index: GTFSFeed["transfers"] = {}; - for (const connection of connections) { - connection.trip.stopTimes = stopTimes; + for (const transfer of transfers) { + index[transfer.origin] ??= []; + index[transfer.origin].push(transfer); } + + return index; } /** - * A feed whose stops are platforms, each belonging to a station named by its stop_code. That is how - * the GB rail feed identifies a station, and what `normalise` resolves a call to. + * A feed of the given trips and footpaths. A stop the feed has no entry for is a station of its own, + * so a spec that is not about platforms can name its stations directly. */ export function feed(overrides: Partial = {}): GTFSFeed { return { @@ -73,18 +70,7 @@ export function feed(overrides: Partial = {}): GTFSFeed { transfers: {}, links: [], interchange: {}, - stops: { - NRW: station("NRW"), - NRW1: platform("NRW1", "NRW"), - NRW2: platform("NRW2", "NRW"), - DIS: station("DIS"), - DIS1: platform("DIS1", "DIS"), - DIS2: platform("DIS2", "DIS"), - LST: station("LST"), - LST8: platform("LST8", "LST"), - IPS: station("IPS"), - IPS1: platform("IPS1", "IPS") - }, + stops: {}, routes: {}, agencies: {}, areas: {}, @@ -93,10 +79,94 @@ export function feed(overrides: Partial = {}): GTFSFeed { }; } -function station(id: StopID) { +/** + * Stops that are platforms, each belonging to a station named by its stop_code. That is how the GB + * rail feed identifies a station, and what the loader resolves a call to. + */ +export const platforms: Record = { + NRW: station("NRW"), + NRW1: platform("NRW1", "NRW"), + NRW2: platform("NRW2", "NRW"), + DIS: station("DIS"), + DIS1: platform("DIS1", "DIS"), + DIS2: platform("DIS2", "DIS"), + LST: station("LST"), + LST8: platform("LST8", "LST"), + IPS: station("IPS"), + IPS1: platform("IPS1", "IPS") +}; + +function station(id: StopID): Stop { return { id, code: id, latitude: 0, longitude: 0, locationType: 1 }; } -function platform(id: StopID, parentStation: StopID) { +function platform(id: StopID, parentStation: StopID): Stop { return { id, latitude: 0, longitude: 0, locationType: 0, parentStation }; } + +export function gtfsOf(overrides: Partial): GtfsData { + return toGtfsData(feed(overrides)); +} + +/** + * The connection of a trip between two stations, which a spec names rather than numbers. + */ +export function connection(gtfs: GtfsData, tripId: string, origin: StopID, destination: StopID): Connection { + const { connections, stopTable, trips } = gtfs; + + for (let c = 0; c < connections.length; c++) { + if ( + trips[connections.trip[c]].tripId === tripId && + stopTable.nameOf(connections.departureStation[c]) === origin && + stopTable.nameOf(connections.arrivalStation[c]) === destination + ) { + return c; + } + } + + throw new Error(`No connection of ${tripId} from ${origin} to ${destination}`); +} + +/** + * The footpath between two stations, as an index into the feed's transfers. + */ +export function transfer(gtfs: GtfsData, origin: StopID, destination: StopID): number { + const { transfers, stopTable } = gtfs; + + for (let t = 0; t < transfers.transfer.length; t++) { + if (stopTable.nameOf(transfers.origin[t]) === origin && stopTable.nameOf(transfers.destination[t]) === destination) { + return t; + } + } + + throw new Error(`No footpath from ${origin} to ${destination}`); +} + +export function resultsFor(gtfs: GtfsData, origins: Record, destinations: StopID[] = []): ScanResults { + return new ScanResultsFactory(gtfs).create(origins, destinations); +} + +/** + * Plan over a feed with the pieces a caller would wire together. + */ +export function plan(overrides: Partial, origins: StopID[], destinations: StopID[], time: Time): Journey[] { + return queryOver(gtfsOf(overrides)).plan(origins, destinations, TUESDAY, time); +} + +export function queryOver(gtfs: GtfsData): DepartAfterQuery { + return new DepartAfterQuery( + new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)), + new JourneyFactory(gtfs), + [new MultipleCriteriaFilter()] + ); +} + +/** + * Each leg as the trip it was taken on and the stations it ran between, or a walk. + */ +export function legsOf(journey: Journey): string[] { + return journey.legs.map(leg => "trip" in leg + ? `${(leg as TimetableLeg).trip.tripId}:${leg.origin}-${leg.destination}` + : `walk:${leg.origin}-${leg.destination}` + ); +}