diff --git a/.changeset/label-each-number-of-legs.md b/.changeset/label-each-number-of-legs.md new file mode 100644 index 0000000..e644a15 --- /dev/null +++ b/.changeset/label-each-number-of-legs.md @@ -0,0 +1,21 @@ +--- +"connection-scan-algorithm": minor +--- + +Return each destination's earliest arrival in the fewest legs. The scan kept one label per station, +its earliest arrival, so an arrival a few minutes later in fewer legs was thrown away even when it +made the same onward train, and every journey on from it took the extra legs. A station now keeps a +label for each number of legs, up to `maxLegs` (8 unless `ScanResultsFactory` is given another, the +last holding that many or more and keeping how many), and a trip is boarded from the fewest legs that +are in time for it. With `maxLegs` of 1 the journeys are 3.0.1's. +Over the GB rail benchmark queries the arrivals are unchanged and 172 of 5,643 journeys take fewer +legs. `JourneyFactory` no longer rewrites the legs of a journey after the scan. + +Footpaths out of a station are all taken before any is walked on from, and the labels are allocated +once per `ScanResultsFactory` rather than per scan, so point to point scans are about 25% faster. + +`ConnectionIndex` is now the labels: `levels` per station, with `boardingTimes` and `connections` +at `station * levels + legs`. It is reused by the next scan from the same `ScanResultsFactory`. +`ScanResults.setConnection` returns the legs a station was reached sooner in, or 0. +`isTransferBetter`, `setTransfer` and `isReachedByTransfer` take the legs of the station the footpath +is walked from. diff --git a/README.md b/README.md index e030c49..b30a1b7 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,21 @@ The algorithm is the paper's: connections sorted by arrival, read in order, each 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 +times rather than as an object each. `ScanResults` keeps a label for each station and number of legs: +the soonest the station is reached in at most that many legs, and the connection achieving it. They +are held in arrays indexed by station and legs, 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. +A label per number of legs is what finds the journey with the fewest changes. An arrival a few minutes +later in fewer legs can still make the same onward train, so a station keeps it alongside the sooner +arrival, and a train is boarded from the fewest legs that are in time for it. `JourneyFactory` +returns each destination's earliest arrival, in the fewest legs that arrive then. The labels go up to +`maxLegs`, 8 unless `ScanResultsFactory` is given another. The last holds that many legs or more and +keeps how many, so a longer journey is still found in the fewest legs that arrive as soon, but past +`maxLegs` a later arrival in fewer legs is not kept. + ### Stations and platforms A connection runs between stations, because that is where interchange time and footpaths are diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 2052bbb..404c67c 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -24,7 +24,7 @@ export class ConnectionScanAlgorithm { } /** - * Return an index of connections that achieve the earliest arrival time at each station. + * Return an index of how each station was reached soonest in each number of legs. */ public scan( origins: OriginDepartureTimes, @@ -37,7 +37,7 @@ export class ConnectionScanAlgorithm { const departureTime = Math.min(...Object.values(origins)); for (const origin of results.getOrigins()) { - this.scanTransfers(results, origin); + this.scanTransfers(results, origin, 0); } for (let c = firstArrivingAt(this.connections, departureTime); c < this.connections.length; c++) { @@ -45,8 +45,10 @@ export class ConnectionScanAlgorithm { break; } if (running[this.connections.trip[c]] && results.isReachable(c) && results.isBetter(c)) { - if (results.setConnection(c)) { - this.scanTransfers(results, this.connections.arrivalStation[c]); + const legs = results.setConnection(c); + + if (legs !== 0) { + this.scanTransfers(results, this.connections.arrivalStation[c], legs); } } } @@ -55,16 +57,28 @@ export class ConnectionScanAlgorithm { } /** - * Walk every footpath out of a station whenever it is reached earlier or in fewer legs than it - * was, not only the first time: a station first reached on foot is often then reached sooner by + * Walk every footpath out of a station whenever it is reached sooner in some number of legs than + * it was, not only the 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 and count on from - * the fewer legs. + * the legs it was reached in. + * + * Every footpath out of the station is taken before any is walked on from. Walking on from each as + * it is taken would walk on from a station reached through a neighbour, only to reach it sooner + * directly and walk on from it all over again. */ - 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]); + private scanTransfers(results: ScanResults, origin: StopIdx, legs: number): void { + const start = this.transfers.offsets[origin]; + const end = this.transfers.offsets[origin + 1]; + + for (let t = start; t < end; t++) { + if (results.isTransferBetter(t, legs)) { + results.setTransfer(t, legs); + } + } + + for (let t = start; t < end; t++) { + if (results.isReachedByTransfer(t, legs)) { + this.scanTransfers(results, this.transfers.destination[t], results.getLegsAfterWalking(legs)); } } } @@ -72,10 +86,25 @@ export class ConnectionScanAlgorithm { } /** - * How the earliest arrival at each station was made, by station index: the connection its trip was - * boarded from, or the footpath, or NO_CONNECTION where nothing reaches it. Each is one leg. + * How each station was reached soonest in each number of legs. A station has a row of `levels` labels, + * so the label of a station in some legs is at `station * levels + legs`: the soonest it was reached + * in at most that many. The first label is the origins, reached in none, and the only label an origin's + * departure time is in. The last holds that many legs or more. */ -export type ConnectionIndex = Int32Array; +export interface ConnectionIndex { + levels: number; + /** + * When a passenger reaching the station by the label can board a trip there: the arrival plus the + * station's interchange time, or the departure time at an origin. NOT_REACHED where nothing reaches + * it in so few legs. + */ + boardingTimes: Int32Array; + /** + * The connection the last leg's trip was boarded from, or the footpath, or NO_CONNECTION at an origin + * or where nothing reaches the station. Each is one leg. + */ + connections: Int32Array; +} /** * Index of departure stations and their departure time diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index ad0887b..75f88eb 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -6,7 +6,7 @@ import { type Connection, isChangeRequired, NO_CONNECTION, transferConnection } 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 + * Boarding 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; @@ -14,12 +14,30 @@ export const NOT_REACHED = 0x7fffffff; /** The trip has carried the passenger to none of its calls */ export const NOT_CARRIED = 0x7fffffff; +const LEG_BITS = 16; + /** More calls than any trip has, so that one fewer leg outranks any later call */ -const CALLS_PER_LEG = 0x10000; +const CALLS_PER_LEG = 1 << LEG_BITS; + +/** The connection is not in time to be boarded from any label of its station */ +const NOT_BOARDABLE = 0; /** - * Mutable object that stores the current earliest arrival and best connection indexes as the - * connections are being scanned. + * Mutable object that stores how each station has been reached as the connections are being scanned. + * + * A station has a label for each number of legs rather than one for its earliest arrival: an arrival + * a few minutes later in fewer legs can still make the same onward trip, and the journey on from it + * then takes fewer legs. Each label is the soonest the passenger can board at the station having taken + * at most that many legs, so a station's labels never get later as the legs go up, and a connection is + * boarded from the fewest legs that are in time for it. + * + * The last label holds `maxLegs` legs or more, and keeps how many. Past `maxLegs` a later arrival in + * fewer legs is not kept, but the earliest arrival still takes the fewest legs, and boarding a trip + * again from a call it carried the passenger to still takes a leg more than staying aboard. + * + * An origin's departure time is only its label of no legs. A train can be boarded there at that time, + * but walking out of it is charged the interchange time, so the origin may still be reached some other + * way in time to walk on sooner, and its other labels are left for that. * * 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. @@ -28,34 +46,36 @@ export class ScanResults { private readonly connections: Connections; private readonly transfers: Transfers; private readonly interchange: Int32Array; - private readonly earliestArrivals: Int32Array; - private readonly connectionIndex: ConnectionIndex; - private readonly legs: Int32Array; + private readonly levels: number; 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. So are the trip - * boardings, the connection each trip is boarded from, and their ranks. Those are only read for a - * trip once it has carried the passenger, so need no clearing between scans. + * The labels' boarding times and connections are only needed while the scan runs and until its + * journeys are read, so the factory gives every scan the same arrays, filled afresh. So are the trip + * arrivals, the earliest call each trip has carried the passenger to, and the trip boardings, the + * connection each trip is boarded from, and their ranks. Those are only read for a trip once it has + * carried the passenger, so need no clearing between scans, and nor do the legs of each station's + * last label, only read once the label is set. */ constructor( gtfs: GtfsData, origins: OriginDepartureTimes, destinations: StopID[], + private readonly maxLegs: number, + private readonly boardingTimes: Int32Array, + private readonly connectionIndex: Int32Array, private readonly tripArrivals: Int32Array, private readonly tripBoardings: Int32Array, - private readonly tripBoardingRanks: Int32Array + private readonly tripBoardingRanks: Int32Array, + private readonly lastLabelLegs: 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.legs = new Int32Array(gtfs.stopTable.size); + this.levels = maxLegs + 1; this.isDestination = new Uint8Array(gtfs.stopTable.size); for (const code of Object.keys(origins)) { @@ -63,7 +83,7 @@ export class ScanResults { if (origin !== UNKNOWN_STOP) { this.origins.push(origin); - this.earliestArrivals[origin] = origins[code]; + this.boardingTimes[origin * this.levels] = origins[code]; } } @@ -93,9 +113,10 @@ export class ScanResults { */ public isReachable(c: Connection): boolean { const trip = this.connections.trip[c]; + const legs = this.getBoardingLegs(c); - if (this.isReachableWithChange(c)) { - const rank = this.legs[this.connections.departureStation[c]] * CALLS_PER_LEG - this.connections.board[c]; + if (legs !== NOT_BOARDABLE) { + const rank = legs * CALLS_PER_LEG - this.connections.board[c]; if (this.tripArrivals[trip] === NOT_CARRIED || rank < this.tripBoardingRanks[trip]) { this.tripBoardings[trip] = c; @@ -115,99 +136,184 @@ export class ScanResults { return this.tripArrivals[this.connections.trip[c]] <= this.connections.board[c]; } - private isReachableWithChange(c: Connection): boolean { - const origin = this.connections.departureStation[c]; - const interchange = this.connectionIndex[origin] === NO_CONNECTION ? 0 : this.interchange[origin]; + /** + * One leg more than the fewest the passenger can reach the connection's station in, in time for it + */ + private getBoardingLegs(c: Connection): number { + const station = this.connections.departureStation[c]; + const row = station * this.levels; + const departureTime = this.connections.departureTime[c]; + + if (this.boardingTimes[row + this.maxLegs] > departureTime && this.boardingTimes[row] > departureTime) { + return NOT_BOARDABLE; + } + + let level = 0; - return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c]; + while (this.boardingTimes[row + level] > departureTime) { + level++; + } + + return (level === this.maxLegs ? this.lastLabelLegs[station] : level) + 1; } /** - * Arriving at the same time is better in fewer legs, as the stations reached from here and the - * trips boarded here count their legs from it. + * The connection reaches its station sooner than in as many legs before, or at the same time in + * fewer legs than the last label or on a trip the passenger stays aboard. */ public isBetter(c: Connection): boolean { - const destination = this.connections.arrivalStation[c]; - const arrivalTime = this.earliestArrivals[destination]; - - if (arrivalTime !== this.connections.arrivalTime[c]) { - return arrivalTime > this.connections.arrivalTime[c]; - } + const legs = this.getLegs(c); + const label = this.getLabel(this.connections.arrivalStation[c], legs); + const boardingTime = this.getBoardingTime(c); - const legs = this.legsTo(c); - - return legs < this.legs[destination] || (legs === this.legs[destination] && this.staysAboard(c)); + return boardingTime < this.boardingTimes[label] + || (boardingTime === this.boardingTimes[label] && (this.hasFewerLegs(label, legs) || this.staysAboard(c, label, legs))); } /** * Arriving at the same time in as many legs without changing is better than arriving on another * trip. A vehicle 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. + * A label reached as soon in fewer legs is not one this trip got to in as many. */ - private staysAboard(c: Connection): boolean { - const current = this.connectionIndex[this.connections.arrivalStation[c]]; + private staysAboard(c: Connection, label: number, legs: number): boolean { + const current = this.connectionIndex[label]; - return current !== NO_CONNECTION + return this.isExactLegs(label, legs) + && current !== NO_CONNECTION && isChangeRequired(this.connections, current, c) && this.isReachableFromSameService(c); } /** - * Returns true if the connection reaches the destination earlier or in fewer legs than before, - * rather than at the same time in as many on a trip the passenger stays aboard + * The label was reached in the legs, rather than as soon in fewer. A label of one leg always was: the + * only label of fewer is an origin's departure. */ - public setConnection(c: Connection): boolean { - const destination = this.connections.arrivalStation[c]; - const previous = this.earliestArrivals[destination]; - const previousLegs = this.legs[destination]; - - this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]]; - this.legs[destination] = this.legsTo(c); + private isExactLegs(label: number, legs: number): boolean { + const level = label % this.levels; - return this.arrive(destination, this.connections.arrivalTime[c]) < previous || this.legs[destination] < previousLegs; + return level === this.maxLegs + ? this.lastLabelLegs[(label - level) / this.levels] === legs + : level === 1 || this.boardingTimes[label - 1] !== this.boardingTimes[label]; } - private legsTo(c: Connection): number { - return this.legs[this.connections.departureStation[this.tripBoardings[this.connections.trip[c]]]] + 1; + private hasFewerLegs(label: number, legs: number): boolean { + const level = label % this.levels; + + return level === this.maxLegs && legs < this.lastLabelLegs[(label - level) / this.levels]; } - public isTransferBetter(t: number): boolean { - const destination = this.transfers.destination[t]; - const arrivalTime = this.getTransferArrivalTime(t); + /** + * Returns the legs the connection reaches its station in if that is sooner or in fewer legs than + * before, or 0 if it reaches it at the same time on a trip the passenger stays aboard + */ + public setConnection(c: Connection): number { + const legs = this.getLegs(c); + const station = this.connections.arrivalStation[c]; + const label = this.getLabel(station, legs); + const boardingTime = this.getBoardingTime(c); + const isImproved = boardingTime < this.boardingTimes[label] || this.hasFewerLegs(label, legs); - return this.earliestArrivals[destination] > arrivalTime - || (this.earliestArrivals[destination] === arrivalTime && this.legs[this.transfers.origin[t]] + 1 < this.legs[destination]); - } + this.reach(station, legs, boardingTime, this.tripBoardings[this.connections.trip[c]]); - public setTransfer(t: number): void { - const destination = this.transfers.destination[t]; + return isImproved ? legs : 0; + } - this.connectionIndex[destination] = transferConnection(t); - this.legs[destination] = this.legs[this.transfers.origin[t]] + 1; - this.arrive(destination, this.getTransferArrivalTime(t)); + /** + * The legs of the trip's boarding: its rank rounded up to whole legs + */ + private getLegs(c: Connection): number { + return (this.tripBoardingRanks[this.connections.trip[c]] + CALLS_PER_LEG - 1) >> LEG_BITS; } - private getTransferArrivalTime(t: number): Time { - const origin = this.transfers.origin[t]; + private getLabel(station: StopIdx, legs: number): number { + return station * this.levels + Math.min(legs, this.maxLegs); + } - return this.earliestArrivals[origin] + this.transfers.duration[t] + this.interchange[origin]; + private getBoardingTime(c: Connection): Time { + return this.connections.arrivalTime[c] + this.interchange[this.connections.arrivalStation[c]]; } - private arrive(station: StopIdx, time: Time): Time { - this.earliestArrivals[station] = time; + /** + * Label the station as reached in the legs, and in every number of legs more that it was not + * reached sooner in + */ + private reach(station: StopIdx, legs: number, boardingTime: Time, connection: Connection): void { + const end = (station + 1) * this.levels; + let label = this.getLabel(station, legs); + + for (; label < end && this.boardingTimes[label] >= boardingTime; label++) { + this.boardingTimes[label] = boardingTime; + this.connectionIndex[label] = connection; + } + + if (label === end) { + this.lastLabelLegs[station] = legs; + } if (this.isDestination[station] === 1) { this.latestDestinationArrival = this.getLatestDestinationArrival(); } + } + + /** + * The footpath, walked from the station reached in the legs, reaches its destination sooner than in + * as many legs before, or at the same time in fewer legs than the last label + */ + public isTransferBetter(t: number, legs: number): boolean { + const boardingTime = this.getTransferBoardingTime(t, legs); + const label = this.getTransferLabel(t, legs); + + return boardingTime < this.boardingTimes[label] + || (boardingTime === this.boardingTimes[label] && this.hasFewerLegs(label, legs + 1)); + } + + public setTransfer(t: number, legs: number): void { + this.reach(this.transfers.destination[t], this.getLegsAfterWalking(legs), this.getTransferBoardingTime(t, legs), transferConnection(t)); + } + + /** + * The footpath, walked from the station reached in the legs, is still how its destination was reached + * in the legs after it. Where the label was reached as soon in fewer legs, it was walked on from in + * those, and walking on from it again in more could go back and forth between two stations. + */ + public isReachedByTransfer(t: number, legs: number): boolean { + const label = this.getTransferLabel(t, legs); - return time; + return this.connectionIndex[label] === transferConnection(t) && this.isExactLegs(label, legs + 1); + } + + /** + * A footpath is a leg of its own + */ + public getLegsAfterWalking(legs: number): number { + return legs + 1; + } + + private getTransferLabel(t: number, legs: number): number { + return this.getLabel(this.transfers.destination[t], legs + 1); + } + + /** + * A footpath is charged the interchange time at both ends. An origin's label has none in it, as a + * train can be boarded there without it, so walking from an origin adds it. + */ + private getTransferBoardingTime(t: number, legs: number): Time { + const origin = this.transfers.origin[t]; + const setOff = this.boardingTimes[this.getLabel(origin, legs)] + (legs === 0 ? this.interchange[origin] : 0); + + return setOff + this.transfers.duration[t] + this.interchange[this.transfers.destination[t]]; } private getLatestDestinationArrival(): Time { let latest = this.destinations.length === 0 ? -1 : 0; for (const destination of this.destinations) { - latest = Math.max(latest, this.earliestArrivals[destination]); + const row = destination * this.levels; + const boardingTime = this.boardingTimes[row + this.maxLegs]; + const arrival = boardingTime === NOT_REACHED ? NOT_REACHED : boardingTime - this.interchange[destination]; + + latest = Math.max(latest, Math.min(arrival, this.boardingTimes[row])); } return latest; @@ -218,12 +324,12 @@ export class ScanResults { } public getConnectionIndex(): ConnectionIndex { - return this.connectionIndex; + return { levels: this.levels, boardingTimes: this.boardingTimes, connections: this.connectionIndex }; } /** * 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. + * it can arrive sooner, or at the same time on a trip the passenger is aboard or in fewer legs. */ 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 1d0cfbb..427d9f0 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -1,30 +1,59 @@ import type { StopID } from "@gb-transit/gtfs-loader"; import type { GtfsData } from "../gtfs/GtfsLoader.js"; +import { NO_CONNECTION } from "../journey/Connection.js"; import type { OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; -import { NOT_CARRIED, ScanResults } from "./ScanResults.js"; +import { NOT_CARRIED, NOT_REACHED, ScanResults } from "./ScanResults.js"; /** * Creates a new ScanResults object for a given set of origins and destinations */ export class ScanResultsFactory { + private readonly boardingTimes: Int32Array; + private readonly connectionIndex: Int32Array; private readonly tripArrivals: Int32Array; private readonly tripBoardings: Int32Array; private readonly tripBoardingRanks: Int32Array; + private readonly lastLabelLegs: Int32Array; + /** + * Stations are labelled for each number of legs up to `maxLegs`, the last label holding that many + * or more. A journey of more legs is still found, and is still the earliest arrival in the fewest + * legs, but past `maxLegs` a later journey in fewer legs is not kept. + */ constructor( - private readonly gtfs: GtfsData + private readonly gtfs: GtfsData, + private readonly maxLegs = 8 ) { + if (maxLegs < 1) { + throw new Error(`A journey has at least one leg, not ${maxLegs}`); + } + + this.boardingTimes = new Int32Array(gtfs.stopTable.size * (maxLegs + 1)); + this.connectionIndex = new Int32Array(gtfs.stopTable.size * (maxLegs + 1)); this.tripArrivals = new Int32Array(gtfs.trips.length); this.tripBoardings = new Int32Array(gtfs.trips.length); this.tripBoardingRanks = new Int32Array(gtfs.trips.length); + this.lastLabelLegs = new Int32Array(gtfs.stopTable.size); } /** - * 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. + * The labels and 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, and the + * connection index a scan returns is only good until the next scan starts. */ public create(origins: OriginDepartureTimes, destinations: StopID[]): ScanResults { - return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings, this.tripBoardingRanks); + return new ScanResults( + this.gtfs, + origins, + destinations, + this.maxLegs, + this.boardingTimes.fill(NOT_REACHED), + this.connectionIndex.fill(NO_CONNECTION), + this.tripArrivals.fill(NOT_CARRIED), + this.tripBoardings, + this.tripBoardingRanks, + this.lastLabelLegs + ); } } diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index b798cbe..8e99d5a 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -3,7 +3,7 @@ import type { ConnectionIndex } from "../csa/ConnectionScanAlgorithm.js"; import type { GtfsData } from "../gtfs/GtfsLoader.js"; import { type StopIdx, UNKNOWN_STOP } from "../gtfs/StopTable.js"; import { type Connection, isTransferConnection, NO_CONNECTION, transferOf } from "./Connection.js"; -import { type AnyLeg, isTransfer, type Journey, type TimetableLeg } from "./Journey.js"; +import { type AnyLeg, isTransfer, type Journey } from "./Journey.js"; /** * Creates journeys from the connection index created by the connection scan algorithm. @@ -19,135 +19,89 @@ export class JourneyFactory { ) {} /** - * Extract a result for each destination in the list. + * Extract a result for each destination in the list: its earliest arrival, in the fewest legs that + * arrive then. */ - public getJourneys(connections: ConnectionIndex, destinations: StopID[]): Journey[] { + public getJourneys(index: ConnectionIndex, destinations: StopID[]): Journey[] { return destinations - .map(d => this.getLegs(connections, d)) + .map(d => this.getLegs(index, d)) .filter((c): c is AnyLeg[] => c !== null) - .map(c => this.getCompactedLegs(c)) - .map(c => this.getStraightenedLegs(c)) .map(l => this.getJourney(l)); } /** - * Iterate backwards from the destination to the origin, each station giving the leg that reached it + * Iterate backwards from the destination to the origin, each label giving the leg that reached it. + * The label before a leg is the fewest legs its station was reached in, in time for the leg: the + * label it was taken from, or one reached as soon in fewer legs since. */ - private getLegs(connections: ConnectionIndex, destination: StopID): AnyLeg[] | null { + private getLegs(index: ConnectionIndex, destination: StopID): AnyLeg[] | null { + const { connections, transfers, interchange, stopTable } = this.gtfs; + const { levels, boardingTimes } = index; const legs: AnyLeg[] = []; - let station = this.gtfs.stopTable.indexOf(destination); + let station = stopTable.indexOf(destination); - while (station !== UNKNOWN_STOP && connections[station] !== NO_CONNECTION) { - const connection = connections[station]; - - legs.push(this.toLeg(connection, station)); - station = isTransferConnection(connection) - ? this.gtfs.transfers.origin[transferOf(connection)] - : this.gtfs.connections.departureStation[connection]; + if (station === UNKNOWN_STOP) { + return null; } - return legs.length === 0 ? null : legs.reverse(); - } - - /** - * Convert the connection a trip was boarded from into a TimetableLeg to the station, or a footpath - * into a Transfer - */ - private toLeg(connection: Connection, station: StopIdx): AnyLeg { - const { connections, stopTable, transfers, trips } = this.gtfs; + let label = this.getLabel(index, station, boardingTimes[(station + 1) * levels - 1]); - if (isTransferConnection(connection)) { - return transfers.transfer[transferOf(connection)]; - } - else { - const origin = stopTable.nameOf(connections.departureStation[connection]); - const destination = stopTable.nameOf(station); - const trip = trips[connections.trip[connection]]; - const stopTimes = this.getStopTimes(trip, origin, connections.departureTime[connection], destination); + while (index.connections[label] !== NO_CONNECTION) { + const connection = index.connections[label]; - return { origin, destination, trip, stopTimes: stopTimes || [] }; - } - } + legs.push(this.toLeg(connection, station)); - /** - * Check for any redundant legs and replace them with new legs from the trip. - */ - private getCompactedLegs(legs: AnyLeg[]): AnyLeg[] { - const newLegs: AnyLeg[] = []; + if (isTransferConnection(connection)) { + const t = transferOf(connection); + const setOff = boardingTimes[label] - interchange[station] - transfers.duration[t]; - for (let i = legs.length - 1; i >= 0; i--) { - const legI = legs[i]; + station = transfers.origin[t]; + label = this.getLabel(index, station, setOff); - if (isTransfer(legI)) { - newLegs.push(legI); + // walking from an origin is charged the interchange time its label does not have in it + if (label % levels === 0 && boardingTimes[label] + interchange[station] > setOff) { + label = this.getLabel(index, station, setOff, 1); + } } else { - let lastDepartureTime = legI.stopTimes[0].departureTime; - - for (let j = i - 1; j >= 0; j--) { - const legJ = legs[j]; - lastDepartureTime = isTransfer(legJ) ? lastDepartureTime - legJ.duration : legJ.stopTimes[0].departureTime; - const stopTimes = this.getStopTimes(legI.trip, legJ.origin, lastDepartureTime, legI.destination); - - if (stopTimes) { - legI.origin = legJ.origin; - legI.stopTimes = stopTimes; - i = j; - } - } - - newLegs.push(legI); + station = connections.departureStation[connection]; + label = this.getLabel(index, station, connections.departureTime[connection]); } } - return newLegs.reverse(); + return legs.length === 0 ? null : legs.reverse(); } /** - * The scan boards a trip at the call reached in the fewest legs, but only knows the legs of the - * earliest arrival at each call. The trip before may pass a later call of the next one after that - * earliest arrival, leaving the passenger riding on to where they board and back through it. Where - * there is time to change at such a call, the passenger changes there instead. + * The label of the fewest legs the station can be boarded at by the time in */ - private getStraightenedLegs(legs: AnyLeg[]): AnyLeg[] { - for (let i = 1; i < legs.length; i++) { - const previous = legs[i - 1]; - const next = legs[i]; + private getLabel(index: ConnectionIndex, station: StopIdx, time: Time, fewestLegs = 0): number { + let label = station * index.levels + fewestLegs; - if (!isTransfer(previous) && !isTransfer(next)) { - this.changeAtFirstSharedCall(previous, next); - } + while (index.boardingTimes[label] > time) { + label++; } - return legs; + return label; } /** - * Cut the previous leg at the first call it sets down at that the next leg goes on to pick up at in - * time, and board the next leg there, so the whole of the ride on and back is left out. + * Convert the connection a trip was boarded from into a TimetableLeg to the station, or a footpath + * into a Transfer */ - private changeAtFirstSharedCall(previous: TimetableLeg, next: TimetableLeg): void { - for (let i = 1; i < previous.stopTimes.length - 1; i++) { - const alight = previous.stopTimes[i]; - - if (!alight.dropOff) { - continue; - } - - const station = this.stationOf(alight); - const departureTime = alight.arrivalTime + (this.gtfs.interchange[this.gtfs.stopTable.indexOf(station)] ?? 0); - const board = next.stopTimes.findIndex((c, j) => - j > 0 && j < next.stopTimes.length - 1 && c.pickUp && c.departureTime >= departureTime && this.stationOf(c) === station - ); + private toLeg(connection: Connection, station: StopIdx): AnyLeg { + const { connections, stopTable, transfers, trips } = this.gtfs; - if (board !== -1) { - previous.stopTimes = previous.stopTimes.slice(0, i + 1); - previous.destination = station; - next.stopTimes = next.stopTimes.slice(board); - next.origin = station; + if (isTransferConnection(connection)) { + return transfers.transfer[transferOf(connection)]; + } + else { + const origin = stopTable.nameOf(connections.departureStation[connection]); + const destination = stopTable.nameOf(station); + const trip = trips[connections.trip[connection]]; + const stopTimes = this.getStopTimes(trip, origin, connections.departureTime[connection], destination); - return; - } + return { origin, destination, trip, stopTimes: stopTimes || [] }; } } diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 2db7f5f..55cd71c 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -1,9 +1,11 @@ import { Service } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; +import { NOT_REACHED } from "../../../src/csa/ScanResults.js"; import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; import { NO_CONNECTION } from "../../../src/journey/Connection.js"; -import { allDays, byOrigin, gtfsOf, legsOf, pickUpOnly, plan, st, trip, walk } from "../util.js"; +import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; +import { allDays, byOrigin, connection, gtfsOf, labelOf, legsOf, pickUpOnly, plan, queryOver, st, TUESDAY, trip, walk } from "../util.js"; describe("ConnectionScanAlgorithm", () => { @@ -77,11 +79,9 @@ describe("ConnectionScanAlgorithm", () => { }); /** - * In this scenario there are two trips running in parallel. Trip 1 arrives earliest at A, B and C and Trip 2 arrives - * earliest at D. It is not possible to change onto the second trip at C because of the interchange change, however - * the algorithm should detect that it was possible to board at A and add the connection. The list of connections - * will be incorrect as it will use trip 1 for A->B, B->C and then trip 2 for C->D. The results factory tidies this - * up by realising that the whole journey could be made on a single trip (trip 2). + * Two trips run in parallel. Trip 1 arrives earliest at B and C and trip 2 earliest at D. Trip 2 + * cannot be changed onto at C in the interchange time, but it can be boarded at A, so the whole + * journey is made on it. */ it("checks for connections missed because of interchange time", () => { const [journey] = plan({ @@ -217,17 +217,120 @@ describe("ConnectionScanAlgorithm", () => { expect(journey.arrivalTime).toBe(1100); }); - it("gives each scan a connection index of its own", () => { + it("starts each scan with nothing reached", () => { 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 fromA = csa.scan({ A: 900 }, ["C"], 20260908, 2); + csa.scan({ A: 900 }, ["C"], 20260908, 2); + const fromB = csa.scan({ B: 900 }, ["C"], 20260908, 2); - expect(fromA[b]).not.toBe(NO_CONNECTION); - expect(fromB[a]).toBe(NO_CONNECTION); - expect(fromB[b]).toBe(NO_CONNECTION); + expect(labelOf(fromB, gtfs, "A", 8)).toBe(NO_CONNECTION); + expect(fromB.boardingTimes[gtfs.stopTable.indexOf("A") * fromB.levels + 8]).toBe(NOT_REACHED); + expect(labelOf(fromB, gtfs, "C", 1)).toBe(connection(gtfs, "1", "B", "C")); + }); + + /** + * Y is reached soonest in three legs, changing onto trip 3 at H, but trip 2 reaches it four minutes + * later in two, still in time for trip 4. S is reached at the same time either way. + */ + it("changes where a later arrival in fewer legs still makes the next trip", () => { + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("M", 1050)]), + trip("2", [st("M", 1100), st("H", 1141), st("Y", 1241)]), + trip("3", [st("H", 1146), st("Y", 1237), st("E", 1322)]), + trip("4", [st("Y", 1252), st("E", 1334), st("S", 1410)]) + ], + interchange: { M: 5, H: 5, Y: 5, E: 5, S: 5 } + }, ["A"], ["S"], 900); + + expect(legsOf(journey)).toEqual(["1:A-M", "2:M-Y", "4:Y-S"]); + expect(journey.arrivalTime).toBe(1410); + }); + + /** + * S is reached soonest in two legs and a minute later in one, and both are in time to walk to W for + * trip d. + */ + it("walks on from a station in each number of legs it is reached in", () => { + const [journey] = plan({ + trips: [ + trip("a", [st("O", 900), st("M", 950)]), + trip("b", [st("M", 955), st("S", 1000)]), + trip("c", [st("O", 900), st("S", 1010)]), + trip("d", [st("W", 1030), st("T", 1100)]) + ], + transfers: byOrigin(walk("S", "W", 10)) + }, ["O"], ["T"], 800); + + expect(legsOf(journey)).toEqual(["c:O-S", "walk:S-W", "d:W-T"]); + }); + + /** + * Trip 2 reaches C first, scanned before trip 3 reaches it at the same time directly. + */ + it("keeps scanning for a journey arriving as soon in fewer legs once a destination is reached", () => { + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("B", 1010)]), + trip("2", [st("B", 1020), st("C", 1100)]), + trip("3", [st("A", 1000), st("C", 1100)]) + ] + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["3:A-C"]); + }); + + it("finds a journey of more legs than it labels", () => { + const gtfs = gtfsOf({ + trips: [ + trip("1", [st("A", 1000), st("B", 1010)]), + trip("2", [st("B", 1020), st("C", 1030)]), + trip("3", [st("C", 1040), st("D", 1050)]) + ] + }); + const [journey] = queryOver(gtfs, 1).plan(["A"], ["D"], TUESDAY, 900); + + expect(legsOf(journey)).toEqual(["1:A-B", "2:B-C", "3:C-D"]); + expect(journey.arrivalTime).toBe(1050); + }); + + /** + * Eight trips of a stop each reach S8, where trip "long" is boarded through P to Q. Boarding it again + * at P takes a leg more than staying aboard, however many legs the labels go up to. + */ + it.each([1, 2, 8])("stays aboard a trip rather than boarding it again with labels up to %i legs", maxLegs => { + const hops = Array.from({ length: 8 }, (_, i) => trip(`hop${i}`, [st(`S${i}`, 1000 + i * 20), st(`S${i + 1}`, 1010 + i * 20)])); + const gtfs = gtfsOf({ trips: [...hops, trip("long", [st("S8", 1200), st("P", 1210), st("Q", 1220)])] }); + const [journey] = queryOver(gtfs, maxLegs).plan(["S0"], ["Q"], TUESDAY, 900); + + expect(legsOf(journey).slice(-2)).toEqual(["hop7:S7-S8", "long:S8-Q"]); + }); + + it("does not walk back and forth between an origin and a station no time away", () => { + const [journey] = plan({ + trips: [trip("1", [st("B", 1000), st("C", 1100)])], + transfers: byOrigin(walk("A", "B", 0), walk("B", "A", 0)) + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-C"]); + }); + + /** + * B sets off at 1001 and A at 1009, with ten minutes to change at A. Walking from B reaches A at + * 1006, so walking on from A can start at 1016 rather than 1019, in time for the train from E. + */ + it("walks on through an origin reached from another before its interchange time is up", () => { + const gtfs = gtfsOf({ + trips: [trip("1", [st("E", 1020), st("F", 1100)])], + transfers: byOrigin(walk("B", "A", 5), walk("A", "E", 3)), + interchange: { A: 10 } + }); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const [journey] = new JourneyFactory(gtfs).getJourneys(csa.scan({ A: 1009, B: 1001 }, ["F"], 20260908, 2), ["F"]); + + expect(legsOf(journey)).toEqual(["walk:B-A", "walk:A-E", "1:E-F"]); }); }); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index 50be054..d63fa21 100644 --- a/test/unit/csa/ScanResults.spec.ts +++ b/test/unit/csa/ScanResults.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { ScanResults } from "../../../src/csa/ScanResults.js"; import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; import { transferConnection } from "../../../src/journey/Connection.js"; -import { byOrigin, connection, gtfsOf, pickUpOnly, resultsFor, st, take, transfer, trip, walk } from "../util.js"; +import { byOrigin, connection, gtfsOf, labelOf, pickUpOnly, resultsFor, st, take, transfer, trip, walk } from "../util.js"; describe("ScanResults", () => { const gtfs = gtfsOf({ @@ -34,7 +34,7 @@ describe("ScanResults", () => { }); const results = resultsFor(withInterchange, { A: 900 }); - results.setConnection(connection(withInterchange, "LN1111", "A", "B")); + take(results, connection(withInterchange, "LN1111", "A", "B")); expect(results.isReachable(connection(withInterchange, "LN1112", "B", "C"))).toBe(false); }); @@ -42,7 +42,7 @@ describe("ScanResults", () => { it("changes in no time at a station with no interchange time", () => { const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); + take(results, connection(gtfs, "LN1111", "A", "B")); expect(results.isReachable(connection(gtfs, "LN1114", "B", "C"))).toBe(true); }); @@ -50,19 +50,63 @@ describe("ScanResults", () => { it("knows if a connection is better", () => { const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); + take(results, connection(gtfs, "LN1111", "A", "B")); + expect(results.isReachable(connection(gtfs, "LN1112", "A", "B"))).toBe(true); expect(results.isBetter(connection(gtfs, "LN1112", "A", "B"))).toBe(true); }); it("knows if a connection is not better", () => { const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); + take(results, connection(gtfs, "LN1111", "A", "B")); + expect(results.isReachable(connection(gtfs, "LN1113", "A", "B"))).toBe(true); expect(results.isBetter(connection(gtfs, "LN1113", "A", "B"))).toBe(false); }); + it("does not keep a station reached no sooner in more legs", () => { + const longer = gtfsOf({ + trips: [ + trip("1", [st("A", 1000), st("B", 1010)]), + trip("2", [st("A", 1000), st("X", 1002)]), + trip("3", [st("X", 1003), st("B", 1010)]), + trip("4", [st("X", 1003), st("B", 1020)]) + ] + }); + const results = resultsFor(longer, { A: 900 }); + + take(results, connection(longer, "1", "A", "B")); + take(results, connection(longer, "2", "A", "X")); + + for (const c of [connection(longer, "3", "X", "B"), connection(longer, "4", "X", "B")]) { + expect(results.isReachable(c)).toBe(true); + expect(results.isBetter(c)).toBe(false); + } + }); + + it("keeps a station reached later in fewer legs alongside the sooner arrival", () => { + const direct = gtfsOf({ + trips: [ + trip("1", [st("A", 1000), st("X", 1005)]), + trip("2", [st("X", 1006), st("B", 1010)]), + trip("3", [st("A", 1000), st("B", 1030)]) + ] + }); + const results = resultsFor(direct, { A: 900 }); + + take(results, connection(direct, "1", "A", "X")); + take(results, connection(direct, "2", "X", "B")); + + const slower = connection(direct, "3", "A", "B"); + + expect(results.isReachable(slower)).toBe(true); + expect(results.isBetter(slower)).toBe(true); + expect(results.setConnection(slower)).toBe(1); + expect(labelOf(results.getConnectionIndex(), direct, "B", 1)).toBe(slower); + expect(labelOf(results.getConnectionIndex(), direct, "B", 2)).toBe(connection(direct, "2", "X", "B")); + }); + it("prefers staying aboard to changing onto a trip arriving at the same time", () => { const coupled = gtfsOf({ trips: [ @@ -119,13 +163,13 @@ describe("ScanResults", () => { take(results, connection(shorter, "1", "A", "B")); expect(results.isReachable(connection(shorter, "2", "B", "C"))).toBe(true); - expect(results.setConnection(connection(shorter, "2", "B", "C"))).toBe(true); + expect(results.setConnection(connection(shorter, "2", "B", "C"))).toBe(2); const direct = connection(shorter, "3", "A", "C"); expect(results.isReachable(direct)).toBe(true); expect(results.isBetter(direct)).toBe(true); - expect(results.setConnection(direct)).toBe(true); + expect(results.setConnection(direct)).toBe(1); }); it("knows a transfer arriving at the same time in fewer legs is better", () => { @@ -138,7 +182,7 @@ describe("ScanResults", () => { take(results, connection(walks, "1", "A", "B")); take(results, connection(walks, "2", "B", "C")); - expect(results.isTransferBetter(transfer(walks, "A", "C"))).toBe(true); + expect(results.isTransferBetter(transfer(walks, "A", "C"), 0)).toBe(true); }); it("does not count a passenger aboard at a call the trip only picks up at", () => { @@ -152,21 +196,56 @@ describe("ScanResults", () => { it("knows if a transfer is better", () => { const results = resultsFor(gtfs, { A: 900 }); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); + take(results, connection(gtfs, "LN1111", "A", "B")); - expect(results.isTransferBetter(transfer(gtfs, "A", "B"))).toBe(true); + expect(results.isTransferBetter(transfer(gtfs, "A", "B"), 0)).toBe(true); }); it("knows if a transfer is not better", () => { const results = resultsFor(gtfs, { A: 900 }); - results.setTransfer(transfer(gtfs, "A", "B")); + results.setTransfer(transfer(gtfs, "A", "C"), 0); - expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(true); + expect(results.isTransferBetter(transfer(gtfs, "A", "C"), 0)).toBe(false); - results.setTransfer(transfer(gtfs, "B", "C")); + results.setTransfer(transfer(gtfs, "A", "B"), 0); - expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(false); + expect(results.isTransferBetter(transfer(gtfs, "B", "C"), 1)).toBe(true); + + results.setTransfer(transfer(gtfs, "B", "C"), 1); + + expect(results.isTransferBetter(transfer(gtfs, "B", "C"), 1)).toBe(false); + }); + + it("walks a transfer from each number of legs a station is reached in", () => { + const results = resultsFor(gtfs, { A: 900 }); + + results.setTransfer(transfer(gtfs, "A", "B"), 0); + results.setTransfer(transfer(gtfs, "B", "C"), 1); + + expect(results.isTransferBetter(transfer(gtfs, "A", "C"), 0)).toBe(true); + + results.setTransfer(transfer(gtfs, "A", "C"), 0); + + expect(labelOf(results.getConnectionIndex(), gtfs, "C", 1)).toBe(transferConnection(transfer(gtfs, "A", "C"))); + expect(labelOf(results.getConnectionIndex(), gtfs, "C", 2)).toBe(transferConnection(transfer(gtfs, "B", "C"))); + }); + + it("knows whether a station is still reached by a transfer", () => { + const walks = gtfsOf({ + trips: [trip("1", [st("A", 1000), st("C", 1015)])], + transfers: byOrigin(walk("A", "B", 10), walk("B", "C", 10)) + }); + const results = resultsFor(walks, { A: 1000 }); + + results.setTransfer(transfer(walks, "A", "B"), 0); + results.setTransfer(transfer(walks, "B", "C"), 1); + + expect(results.isReachedByTransfer(transfer(walks, "B", "C"), 1)).toBe(true); + + take(results, connection(walks, "1", "A", "C")); + + expect(results.isReachedByTransfer(transfer(walks, "B", "C"), 1)).toBe(false); }); it("charges the interchange time at both ends of a transfer", () => { @@ -177,8 +256,8 @@ describe("ScanResults", () => { }); const results = resultsFor(walking, { A: 900 }); - results.setConnection(connection(walking, "1", "A", "B")); - results.setTransfer(transfer(walking, "B", "C")); + take(results, connection(walking, "1", "A", "B")); + results.setTransfer(transfer(walking, "B", "C"), 1); expect(results.isReachable(connection(walking, "2", "C", "D"))).toBe(false); expect(results.isReachable(connection(walking, "3", "C", "D"))).toBe(true); @@ -186,13 +265,12 @@ describe("ScanResults", () => { it("returns the connection index", () => { const results = resultsFor(gtfs, { A: 900 }); - const [b, c] = ["B", "C"].map(code => gtfs.stopTable.indexOf(code)); take(results, connection(gtfs, "LN1111", "A", "B")); - results.setTransfer(transfer(gtfs, "B", "C")); + results.setTransfer(transfer(gtfs, "B", "C"), 1); - expect(results.getConnectionIndex()[b]).toBe(connection(gtfs, "LN1111", "A", "B")); - expect(results.getConnectionIndex()[c]).toBe(transferConnection(transfer(gtfs, "B", "C"))); + expect(labelOf(results.getConnectionIndex(), gtfs, "B", 1)).toBe(connection(gtfs, "LN1111", "A", "B")); + expect(labelOf(results.getConnectionIndex(), gtfs, "C", 2)).toBe(transferConnection(transfer(gtfs, "B", "C"))); }); it("indexes a station by the connection its trip was boarded from", () => { @@ -202,7 +280,7 @@ describe("ScanResults", () => { take(results, connection(through, "1", "A", "B")); take(results, connection(through, "1", "B", "C")); - expect(results.getConnectionIndex()[through.stopTable.indexOf("C")]).toBe(connection(through, "1", "A", "B")); + expect(labelOf(results.getConnectionIndex(), through, "C", 1)).toBe(connection(through, "1", "A", "B")); }); it("is finished once a connection arrives after every destination was reached", () => { @@ -210,7 +288,7 @@ describe("ScanResults", () => { expect(results.isFinished(connection(gtfs, "LN1112", "A", "B"))).toBe(false); - results.setConnection(connection(gtfs, "LN1112", "A", "B")); + take(results, connection(gtfs, "LN1112", "A", "B")); expect(results.isFinished(connection(gtfs, "LN1112", "A", "B"))).toBe(false); expect(results.isFinished(connection(gtfs, "LN1111", "A", "B"))).toBe(true); @@ -226,4 +304,8 @@ describe("ScanResultsFactory", () => { expect(new ScanResultsFactory(gtfs).create({ A: 900 }, [])).toBeInstanceOf(ScanResults); }); + it("needs room for at least one leg", () => { + expect(() => new ScanResultsFactory(gtfsOf({}), 0)).toThrow(); + }); + }); diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts index a528350..ffe8b3e 100644 --- a/test/unit/journey/JourneyFactory.spec.ts +++ b/test/unit/journey/JourneyFactory.spec.ts @@ -33,7 +33,7 @@ describe("JourneyFactory", () => { }); const results = resultsFor(gtfs, { A: 1000 }); - results.setTransfer(transfer(gtfs, "A", "B")); + results.setTransfer(transfer(gtfs, "A", "B"), 0); take(results, connection(gtfs, "1", "B", "C")); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["C"]); @@ -50,9 +50,9 @@ describe("JourneyFactory", () => { }); const results = resultsFor(gtfs, { A: 1000 }); - results.setTransfer(transfer(gtfs, "A", "B")); + results.setTransfer(transfer(gtfs, "A", "B"), 0); take(results, connection(gtfs, "1", "B", "C")); - results.setTransfer(transfer(gtfs, "C", "D")); + results.setTransfer(transfer(gtfs, "C", "D"), 2); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["D"]); @@ -61,25 +61,6 @@ describe("JourneyFactory", () => { expect(journey.arrivalTime).toBe(1190); }); - it("removes pointless legs", () => { - 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 }); - - take(results, connection(gtfs, "LN1111", "A", "B")); - take(results, connection(gtfs, "LN1112", "B", "C")); - take(results, connection(gtfs, "LN1113", "C", "D")); - take(results, 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); - }); - 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)])] }); diff --git a/test/unit/util.ts b/test/unit/util.ts index 3a2b0e1..e85f47c 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -2,7 +2,7 @@ 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 ConnectionIndex, 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"; @@ -142,6 +142,13 @@ export function transfer(gtfs: GtfsData, origin: StopID, destination: StopID): n throw new Error(`No footpath from ${origin} to ${destination}`); } +/** + * How the station was reached in at most the legs + */ +export function labelOf(index: ConnectionIndex, gtfs: GtfsData, station: StopID, legs: number): Connection { + return index.connections[gtfs.stopTable.indexOf(station) * index.levels + legs]; +} + export function resultsFor(gtfs: GtfsData, origins: Record, destinations: StopID[] = []): ScanResults { return new ScanResultsFactory(gtfs).create(origins, destinations); } @@ -164,9 +171,9 @@ export function plan(overrides: Partial, origins: StopID[], destinatio return queryOver(gtfsOf(overrides)).plan(origins, destinations, TUESDAY, time); } -export function queryOver(gtfs: GtfsData): DepartAfterQuery { +export function queryOver(gtfs: GtfsData, maxLegs?: number): DepartAfterQuery { return new DepartAfterQuery( - new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)), + new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs, maxLegs)), new JourneyFactory(gtfs), [new MultipleCriteriaFilter()] );