From 0ed59ad2959a7c1249cc274cb78a898332191088 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 11:33:36 +0100 Subject: [PATCH 1/5] Take every footpath out of a station before walking on from any Walking on from each footpath as it was taken reached stations through a neighbour, walked on from them, then reached them sooner directly and walked on from them all over again. Over the GB rail benchmark queries each footpath was set about four and a half times a scan. Taking them all first gives the same journeys and scans 12-25% faster. Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS --- src/csa/ConnectionScanAlgorithm.ts | 14 +++++++++++++- src/csa/ScanResults.ts | 7 +++++++ test/unit/csa/ScanResults.spec.ts | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 2052bbb..14194b6 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -59,11 +59,23 @@ export class ConnectionScanAlgorithm { * 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. + * + * 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++) { + const start = this.transfers.offsets[origin]; + const end = this.transfers.offsets[origin + 1]; + + for (let t = start; t < end; t++) { if (results.isTransferBetter(t)) { results.setTransfer(t); + } + } + + for (let t = start; t < end; t++) { + if (results.isReachedByTransfer(t)) { this.scanTransfers(results, this.transfers.destination[t]); } } diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index ad0887b..659d9f8 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -187,6 +187,13 @@ export class ScanResults { this.arrive(destination, this.getTransferArrivalTime(t)); } + /** + * The footpath is still how its destination was reached + */ + public isReachedByTransfer(t: number): boolean { + return this.connectionIndex[this.transfers.destination[t]] === transferConnection(t); + } + private getTransferArrivalTime(t: number): Time { const origin = this.transfers.origin[t]; diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index 50be054..99b1608 100644 --- a/test/unit/csa/ScanResults.spec.ts +++ b/test/unit/csa/ScanResults.spec.ts @@ -169,6 +169,20 @@ describe("ScanResults", () => { expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(false); }); + it("knows whether a station is still reached by a transfer", () => { + const results = resultsFor(gtfs, { A: 900 }); + + results.setTransfer(transfer(gtfs, "A", "B")); + results.setTransfer(transfer(gtfs, "B", "C")); + + expect(results.isReachedByTransfer(transfer(gtfs, "B", "C"))).toBe(true); + expect(results.isReachedByTransfer(transfer(gtfs, "A", "C"))).toBe(false); + + results.setTransfer(transfer(gtfs, "A", "C")); + + expect(results.isReachedByTransfer(transfer(gtfs, "B", "C"))).toBe(false); + }); + 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)])], From 8de3a5841a65a7c2acb00e8a89e4fd2f64142211 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 11:39:59 +0100 Subject: [PATCH 2/5] Label each station for each number of legs A station kept only its earliest arrival, with legs breaking ties, so an arrival a few minutes later in fewer legs was thrown away even when it made the same onward train. Each station now keeps the soonest it is reached in at most each number of legs, up to maxLegs with the last holding that many or more, and a trip is boarded from the fewest legs in time for it. A journey is walked back through the labels, and each destination's is its earliest arrival in the fewest legs that arrive then. The labels are the factory's and reused by every scan, as the trip arrays already were: allocating them for each scan cost about 15%. Their times are when a passenger can board, arrival plus interchange, so rejecting a connection reads one number. Over the benchmark queries the arrival at every destination is unchanged, 172 of 5,643 journeys take fewer legs and none more, and getCompactedLegs and getStraightenedLegs change none of them. Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS --- .changeset/label-each-number-of-legs.md | 20 ++ README.md | 12 +- src/csa/ConnectionScanAlgorithm.ts | 47 +++-- src/csa/ScanResults.ts | 196 +++++++++++------- src/csa/ScanResultsFactory.ts | 36 +++- src/journey/JourneyFactory.ts | 60 +++++- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 84 +++++++- test/unit/csa/ScanResults.spec.ts | 126 ++++++++--- test/unit/journey/JourneyFactory.spec.ts | 6 +- test/unit/util.ts | 9 +- 10 files changed, 452 insertions(+), 144 deletions(-) create mode 100644 .changeset/label-each-number-of-legs.md diff --git a/.changeset/label-each-number-of-legs.md b/.changeset/label-each-number-of-legs.md new file mode 100644 index 0000000..14f4e01 --- /dev/null +++ b/.changeset/label-each-number-of-legs.md @@ -0,0 +1,20 @@ +--- +"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 a trip is boarded from the fewest legs that are in time for it. +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..8620430 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,20 @@ 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, and the last holds that many legs or more, +so a longer journey is still found. + ### 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 14194b6..4e9e3bd 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,28 +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 { + 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)) { - results.setTransfer(t); + if (results.isTransferBetter(t, legs)) { + results.setTransfer(t, legs); } } for (let t = start; t < end; t++) { - if (results.isReachedByTransfer(t)) { - this.scanTransfers(results, this.transfers.destination[t]); + if (results.isReachedByTransfer(t, legs)) { + this.scanTransfers(results, this.transfers.destination[t], results.getLegsAfterWalking(legs)); } } } @@ -84,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 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 659d9f8..580e4fc 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,22 @@ 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. * * 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,24 +38,26 @@ 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. */ 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 @@ -53,9 +65,7 @@ export class ScanResults { 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 +73,7 @@ export class ScanResults { if (origin !== UNKNOWN_STOP) { this.origins.push(origin); - this.earliestArrivals[origin] = origins[code]; + this.boardingTimes.fill(origins[code], origin * this.levels, (origin + 1) * this.levels); } } @@ -93,9 +103,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,106 +126,149 @@ 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. + * The last label holds that many legs or more, so boarding from it takes no more. + */ + private getBoardingLegs(c: Connection): number { + const row = this.connections.departureStation[c] * this.levels; + const departureTime = this.connections.departureTime[c]; + + if (this.boardingTimes[row + this.maxLegs] > departureTime) { + return NOT_BOARDABLE; + } + + let legs = 0; + + while (this.boardingTimes[row + legs] > departureTime) { + legs++; + } - return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c]; + return Math.min(legs + 1, this.maxLegs); } /** - * 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 on a + * trip the passenger stays aboard. */ public isBetter(c: Connection): boolean { - const destination = this.connections.arrivalStation[c]; - const arrivalTime = this.earliestArrivals[destination]; + const label = this.getLabel(c); + const boardingTime = this.getBoardingTime(c); - if (arrivalTime !== this.connections.arrivalTime[c]) { - return arrivalTime > this.connections.arrivalTime[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.staysAboard(c, label)); } /** * 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): boolean { + const current = this.connectionIndex[label]; - return current !== NO_CONNECTION + return this.boardingTimes[label - 1] !== this.boardingTimes[label] + && 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 + * Returns the legs the connection reaches its station in if that is sooner than before, or 0 if it + * reaches it at the same time on a trip the passenger stays aboard */ - public setConnection(c: Connection): boolean { - const destination = this.connections.arrivalStation[c]; - const previous = this.earliestArrivals[destination]; - const previousLegs = this.legs[destination]; + public setConnection(c: Connection): number { + const legs = this.getLegs(c); + const station = this.connections.arrivalStation[c]; + const boardingTime = this.getBoardingTime(c); + const isSooner = boardingTime < this.boardingTimes[station * this.levels + legs]; - this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]]; - this.legs[destination] = this.legsTo(c); + this.reach(station, legs, boardingTime, this.tripBoardings[this.connections.trip[c]]); - return this.arrive(destination, this.connections.arrivalTime[c]) < previous || this.legs[destination] < previousLegs; + return isSooner ? legs : 0; } - private legsTo(c: Connection): number { - return this.legs[this.connections.departureStation[this.tripBoardings[this.connections.trip[c]]]] + 1; + /** + * 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; } - public isTransferBetter(t: number): boolean { - const destination = this.transfers.destination[t]; - const arrivalTime = this.getTransferArrivalTime(t); + private getLabel(c: Connection): number { + return this.connections.arrivalStation[c] * this.levels + this.getLegs(c); + } - return this.earliestArrivals[destination] > arrivalTime - || (this.earliestArrivals[destination] === arrivalTime && this.legs[this.transfers.origin[t]] + 1 < this.legs[destination]); + private getBoardingTime(c: Connection): Time { + return this.connections.arrivalTime[c] + this.interchange[this.connections.arrivalStation[c]]; } - public setTransfer(t: number): void { - const destination = this.transfers.destination[t]; + /** + * 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; + + for (let label = station * this.levels + legs; label < end && this.boardingTimes[label] >= boardingTime; label++) { + this.boardingTimes[label] = boardingTime; + this.connectionIndex[label] = connection; + } - this.connectionIndex[destination] = transferConnection(t); - this.legs[destination] = this.legs[this.transfers.origin[t]] + 1; - this.arrive(destination, this.getTransferArrivalTime(t)); + if (this.isDestination[station] === 1) { + this.latestDestinationArrival = this.getLatestDestinationArrival(); + } } /** - * The footpath is still how its destination was reached + * The footpath, walked from the station reached in the legs, reaches its destination sooner than in + * as many legs before */ - public isReachedByTransfer(t: number): boolean { - return this.connectionIndex[this.transfers.destination[t]] === transferConnection(t); + public isTransferBetter(t: number, legs: number): boolean { + return this.getTransferBoardingTime(t, legs) < this.boardingTimes[this.getTransferLabel(t, legs)]; } - private getTransferArrivalTime(t: number): Time { - const origin = this.transfers.origin[t]; + public setTransfer(t: number, legs: number): void { + this.reach(this.transfers.destination[t], this.getLegsAfterWalking(legs), this.getTransferBoardingTime(t, legs), transferConnection(t)); + } - return this.earliestArrivals[origin] + this.transfers.duration[t] + this.interchange[origin]; + /** + * The footpath, walked from the station reached in the legs, is still how its destination was reached + */ + public isReachedByTransfer(t: number, legs: number): boolean { + return this.connectionIndex[this.getTransferLabel(t, legs)] === transferConnection(t); } - private arrive(station: StopIdx, time: Time): Time { - this.earliestArrivals[station] = time; + /** + * A footpath is a leg of its own + */ + public getLegsAfterWalking(legs: number): number { + return Math.min(legs + 1, this.maxLegs); + } - if (this.isDestination[station] === 1) { - this.latestDestinationArrival = this.getLatestDestinationArrival(); - } + private getTransferLabel(t: number, legs: number): number { + return this.transfers.destination[t] * this.levels + this.getLegsAfterWalking(legs); + } - return time; + /** + * 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[origin * this.levels + 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 boardingTime = this.boardingTimes[destination * this.levels + this.maxLegs]; + + latest = Math.max(latest, boardingTime === NOT_REACHED ? NOT_REACHED : boardingTime - this.interchange[destination]); } return latest; @@ -225,12 +279,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..7d0f2a9 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -1,30 +1,56 @@ 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; + /** + * 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, but past it a journey in fewer legs is no longer + * told apart from a sooner one in more. + */ 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); } /** - * 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 + ); } } diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index b798cbe..defc23a 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -19,11 +19,12 @@ 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)) @@ -31,24 +32,61 @@ export class JourneyFactory { } /** - * 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]; + if (station === UNKNOWN_STOP) { + return null; + } + + let label = this.getLabel(index, station, boardingTimes[(station + 1) * levels - 1]); + + while (index.connections[label] !== NO_CONNECTION) { + const connection = index.connections[label]; legs.push(this.toLeg(connection, station)); - station = isTransferConnection(connection) - ? this.gtfs.transfers.origin[transferOf(connection)] - : this.gtfs.connections.departureStation[connection]; + + if (isTransferConnection(connection)) { + const t = transferOf(connection); + const setOff = boardingTimes[label] - interchange[station] - transfers.duration[t]; + + station = transfers.origin[t]; + label = this.getLabel(index, station, setOff); + + // 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 { + station = connections.departureStation[connection]; + label = this.getLabel(index, station, connections.departureTime[connection]); + } } return legs.length === 0 ? null : legs.reverse(); } + /** + * The label of the fewest legs the station can be boarded at by the time in + */ + private getLabel(index: ConnectionIndex, station: StopIdx, time: Time, fewestLegs = 0): number { + let label = station * index.levels + fewestLegs; + + while (index.boardingTimes[label] > time) { + label++; + } + + return label; + } + /** * Convert the connection a trip was boarded from into a TimetableLeg to the station, or a footpath * into a Transfer diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 2db7f5f..1377634 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -1,9 +1,12 @@ 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 { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; +import { allDays, byOrigin, connection, gtfsOf, labelOf, legsOf, pickUpOnly, plan, st, TUESDAY, trip, walk } from "../util.js"; describe("ConnectionScanAlgorithm", () => { @@ -217,17 +220,84 @@ 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 query = new DepartAfterQuery(new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs, 1)), new JourneyFactory(gtfs)); + const [journey] = query.plan(["A"], ["D"], TUESDAY, 900); + + expect(legsOf(journey)).toEqual(["1:A-B", "2:B-C", "3:C-D"]); + expect(journey.arrivalTime).toBe(1050); }); }); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index 99b1608..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,35 +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"), 0)).toBe(false); + + results.setTransfer(transfer(gtfs, "A", "B"), 0); - expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(true); + expect(results.isTransferBetter(transfer(gtfs, "B", "C"), 1)).toBe(true); - results.setTransfer(transfer(gtfs, "B", "C")); + results.setTransfer(transfer(gtfs, "B", "C"), 1); - expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(false); + expect(results.isTransferBetter(transfer(gtfs, "B", "C"), 1)).toBe(false); }); - it("knows whether a station is still reached by a transfer", () => { + 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")); - results.setTransfer(transfer(gtfs, "B", "C")); + 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"))); + }); - expect(results.isReachedByTransfer(transfer(gtfs, "B", "C"))).toBe(true); - expect(results.isReachedByTransfer(transfer(gtfs, "A", "C"))).toBe(false); + 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(gtfs, "A", "C")); + results.setTransfer(transfer(walks, "A", "B"), 0); + results.setTransfer(transfer(walks, "B", "C"), 1); - expect(results.isReachedByTransfer(transfer(gtfs, "B", "C"))).toBe(false); + 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", () => { @@ -191,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); @@ -200,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", () => { @@ -216,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", () => { @@ -224,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); @@ -240,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..54a8473 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"]); diff --git a/test/unit/util.ts b/test/unit/util.ts index 3a2b0e1..a07671c 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); } From a6b7e9c25ef911369719eeeb766f04c69b5d478b Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 11:40:37 +0100 Subject: [PATCH 3/5] Remove the legs JourneyFactory rewrote after the scan getCompactedLegs and getStraightenedLegs made up for the scan only knowing the legs of each station's earliest arrival. With a label per number of legs a journey is already on the fewest legs and changes at the latest call, and over the benchmark queries neither changed any journey. Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS --- src/journey/JourneyFactory.ts | 86 +------------------ test/unit/csa/ConnectionScanAlgorithm.spec.ts | 8 +- test/unit/journey/JourneyFactory.spec.ts | 19 ---- 3 files changed, 4 insertions(+), 109 deletions(-) diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index defc23a..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. @@ -26,8 +26,6 @@ export class JourneyFactory { return destinations .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)); } @@ -107,88 +105,6 @@ export class JourneyFactory { } } - /** - * Check for any redundant legs and replace them with new legs from the trip. - */ - private getCompactedLegs(legs: AnyLeg[]): AnyLeg[] { - const newLegs: AnyLeg[] = []; - - for (let i = legs.length - 1; i >= 0; i--) { - const legI = legs[i]; - - if (isTransfer(legI)) { - newLegs.push(legI); - } - 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); - } - } - - return newLegs.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. - */ - private getStraightenedLegs(legs: AnyLeg[]): AnyLeg[] { - for (let i = 1; i < legs.length; i++) { - const previous = legs[i - 1]; - const next = legs[i]; - - if (!isTransfer(previous) && !isTransfer(next)) { - this.changeAtFirstSharedCall(previous, next); - } - } - - return legs; - } - - /** - * 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. - */ - 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 - ); - - if (board !== -1) { - previous.stopTimes = previous.stopTimes.slice(0, i + 1); - previous.destination = station; - next.stopTimes = next.stopTimes.slice(board); - next.origin = station; - - return; - } - } - } - /** * Try to create a new leg from the trip, ensuring the new leg departs the origin no earlier than * the given departure time. The stop times are the feed's own, so a leg between two stations still diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 1377634..a420e16 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -80,11 +80,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({ diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts index 54a8473..ffe8b3e 100644 --- a/test/unit/journey/JourneyFactory.spec.ts +++ b/test/unit/journey/JourneyFactory.spec.ts @@ -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)])] }); From a181721385b61d6c52bc33107b59d9155e0a6988 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 12:50:17 +0100 Subject: [PATCH 4/5] Keep an origin's departure time in its own label Every label of an origin held its departure time, but walking out of an origin is charged its interchange time where boarding a train there is not. Reaching the origin some other way within that time was discarded as no sooner, even when walking on from it would be. Only the origin's label of no legs now holds its departure time. With its other labels open, a footpath from an origin and back could leave each station reached by the other's footpath, and walking on from them went back and forth for ever. A station is only walked on from by a footpath in the legs its label was reached in. Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS --- src/csa/ConnectionScanAlgorithm.ts | 4 +-- src/csa/ScanResults.ts | 30 ++++++++++++++---- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 31 ++++++++++++++++--- test/unit/util.ts | 4 +-- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 4e9e3bd..404c67c 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -88,8 +88,8 @@ export class ConnectionScanAlgorithm { /** * 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 last holds that many - * legs or more. + * 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 interface ConnectionIndex { levels: number; diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index 580e4fc..acc347a 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -31,6 +31,10 @@ const NOT_BOARDABLE = 0; * 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. * + * 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. */ @@ -73,7 +77,7 @@ export class ScanResults { if (origin !== UNKNOWN_STOP) { this.origins.push(origin); - this.boardingTimes.fill(origins[code], origin * this.levels, (origin + 1) * this.levels); + this.boardingTimes[origin * this.levels] = origins[code]; } } @@ -134,7 +138,7 @@ export class ScanResults { const row = this.connections.departureStation[c] * this.levels; const departureTime = this.connections.departureTime[c]; - if (this.boardingTimes[row + this.maxLegs] > departureTime) { + if (this.boardingTimes[row + this.maxLegs] > departureTime && this.boardingTimes[row] > departureTime) { return NOT_BOARDABLE; } @@ -168,12 +172,20 @@ export class ScanResults { private staysAboard(c: Connection, label: number): boolean { const current = this.connectionIndex[label]; - return this.boardingTimes[label - 1] !== this.boardingTimes[label] + return this.isExactLegs(label) && current !== NO_CONNECTION && isChangeRequired(this.connections, current, c) && this.isReachableFromSameService(c); } + /** + * The label was reached in as many legs as it is for, rather than as soon in fewer. A label of one + * leg always was: the only label of fewer is an origin's departure. + */ + private isExactLegs(label: number): boolean { + return label % this.levels === 1 || this.boardingTimes[label - 1] !== this.boardingTimes[label]; + } + /** * Returns the legs the connection reaches its station in if that is sooner than before, or 0 if it * reaches it at the same time on a trip the passenger stays aboard @@ -235,9 +247,13 @@ export class ScanResults { /** * 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 { - return this.connectionIndex[this.getTransferLabel(t, legs)] === transferConnection(t); + const label = this.getTransferLabel(t, legs); + + return this.connectionIndex[label] === transferConnection(t) && this.isExactLegs(label); } /** @@ -266,9 +282,11 @@ export class ScanResults { let latest = this.destinations.length === 0 ? -1 : 0; for (const destination of this.destinations) { - const boardingTime = this.boardingTimes[destination * this.levels + this.maxLegs]; + 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, boardingTime === NOT_REACHED ? NOT_REACHED : boardingTime - this.interchange[destination]); + latest = Math.max(latest, Math.min(arrival, this.boardingTimes[row])); } return latest; diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index a420e16..59ca8d9 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -5,8 +5,7 @@ import { NOT_REACHED } from "../../../src/csa/ScanResults.js"; import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; import { NO_CONNECTION } from "../../../src/journey/Connection.js"; import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; -import { allDays, byOrigin, connection, gtfsOf, labelOf, legsOf, pickUpOnly, plan, st, TUESDAY, trip, walk } from "../util.js"; +import { allDays, byOrigin, connection, gtfsOf, labelOf, legsOf, pickUpOnly, plan, queryOver, st, TUESDAY, trip, walk } from "../util.js"; describe("ConnectionScanAlgorithm", () => { @@ -291,11 +290,35 @@ describe("ConnectionScanAlgorithm", () => { trip("3", [st("C", 1040), st("D", 1050)]) ] }); - const query = new DepartAfterQuery(new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs, 1)), new JourneyFactory(gtfs)); - const [journey] = query.plan(["A"], ["D"], TUESDAY, 900); + 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); }); + 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/util.ts b/test/unit/util.ts index a07671c..e85f47c 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -171,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()] ); From 4604422e74e4dbdbb18794e5935059c6e1a78ad2 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 12:52:02 +0100 Subject: [PATCH 5/5] Keep the real number of legs of the last label The last label held that many legs or more without saying how many, so boarding a trip again from a call it had carried the passenger to cost no extra leg. The tie went to the later call, and one ride became two legs, which getCompactedLegs used to merge back. Past the last label an equal arrival also could not be told apart on legs. The last label now keeps its real number of legs, trips and footpaths count legs past maxLegs, and an equal arrival in fewer legs replaces the last label, as the one label per station of 3.0.1 did. Over the benchmark queries maxLegs of 8 gives the same journeys as before, 2 no longer splits 40 rides or adds legs to 376 journeys, and 1 gives exactly 3.0.1's journeys. Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS --- .changeset/label-each-number-of-legs.md | 3 +- README.md | 5 +- src/csa/ScanResults.ts | 91 ++++++++++++------- src/csa/ScanResultsFactory.ts | 9 +- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 12 +++ 5 files changed, 82 insertions(+), 38 deletions(-) diff --git a/.changeset/label-each-number-of-legs.md b/.changeset/label-each-number-of-legs.md index 14f4e01..e644a15 100644 --- a/.changeset/label-each-number-of-legs.md +++ b/.changeset/label-each-number-of-legs.md @@ -6,7 +6,8 @@ Return each destination's earliest arrival in the fewest legs. The scan kept one 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 a trip is boarded from the fewest legs that are in time for it. +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. diff --git a/README.md b/README.md index 8620430..b30a1b7 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ A label per number of legs is what finds the journey with the fewest changes. An 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, and the last holds that many legs or more, -so a longer journey is still found. +`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 diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index acc347a..75f88eb 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -31,6 +31,10 @@ const NOT_BOARDABLE = 0; * 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. @@ -53,7 +57,8 @@ export class ScanResults { * 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. + * 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, @@ -64,7 +69,8 @@ export class ScanResults { 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; @@ -131,36 +137,37 @@ export class ScanResults { } /** - * One leg more than the fewest the passenger can reach the connection's station in, in time for it. - * The last label holds that many legs or more, so boarding from it takes no more. + * 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 row = this.connections.departureStation[c] * this.levels; + 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 legs = 0; + let level = 0; - while (this.boardingTimes[row + legs] > departureTime) { - legs++; + while (this.boardingTimes[row + level] > departureTime) { + level++; } - return Math.min(legs + 1, this.maxLegs); + return (level === this.maxLegs ? this.lastLabelLegs[station] : level) + 1; } /** - * The connection reaches its station sooner than in as many legs before, or at the same time on a - * trip the passenger stays aboard. + * 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 label = this.getLabel(c); + const legs = this.getLegs(c); + const label = this.getLabel(this.connections.arrivalStation[c], legs); const boardingTime = this.getBoardingTime(c); return boardingTime < this.boardingTimes[label] - || (boardingTime === this.boardingTimes[label] && this.staysAboard(c, label)); + || (boardingTime === this.boardingTimes[label] && (this.hasFewerLegs(label, legs) || this.staysAboard(c, label, legs))); } /** @@ -169,36 +176,47 @@ export class ScanResults { * 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, label: number): boolean { + private staysAboard(c: Connection, label: number, legs: number): boolean { const current = this.connectionIndex[label]; - return this.isExactLegs(label) + return this.isExactLegs(label, legs) && current !== NO_CONNECTION && isChangeRequired(this.connections, current, c) && this.isReachableFromSameService(c); } /** - * The label was reached in as many legs as it is for, rather than as soon in fewer. A label of one - * leg always was: the only label of fewer is an origin's departure. + * 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. */ - private isExactLegs(label: number): boolean { - return label % this.levels === 1 || this.boardingTimes[label - 1] !== this.boardingTimes[label]; + private isExactLegs(label: number, legs: number): boolean { + const level = label % this.levels; + + return level === this.maxLegs + ? this.lastLabelLegs[(label - level) / this.levels] === legs + : level === 1 || this.boardingTimes[label - 1] !== this.boardingTimes[label]; + } + + private hasFewerLegs(label: number, legs: number): boolean { + const level = label % this.levels; + + return level === this.maxLegs && legs < this.lastLabelLegs[(label - level) / this.levels]; } /** - * Returns the legs the connection reaches its station in if that is sooner than before, or 0 if it - * reaches it at the same time on a trip the passenger stays aboard + * 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 isSooner = boardingTime < this.boardingTimes[station * this.levels + legs]; + const isImproved = boardingTime < this.boardingTimes[label] || this.hasFewerLegs(label, legs); this.reach(station, legs, boardingTime, this.tripBoardings[this.connections.trip[c]]); - return isSooner ? legs : 0; + return isImproved ? legs : 0; } /** @@ -208,8 +226,8 @@ export class ScanResults { return (this.tripBoardingRanks[this.connections.trip[c]] + CALLS_PER_LEG - 1) >> LEG_BITS; } - private getLabel(c: Connection): number { - return this.connections.arrivalStation[c] * this.levels + this.getLegs(c); + private getLabel(station: StopIdx, legs: number): number { + return station * this.levels + Math.min(legs, this.maxLegs); } private getBoardingTime(c: Connection): Time { @@ -222,12 +240,17 @@ export class ScanResults { */ private reach(station: StopIdx, legs: number, boardingTime: Time, connection: Connection): void { const end = (station + 1) * this.levels; + let label = this.getLabel(station, legs); - for (let label = station * this.levels + legs; label < end && this.boardingTimes[label] >= boardingTime; label++) { + 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(); } @@ -235,10 +258,14 @@ export class ScanResults { /** * The footpath, walked from the station reached in the legs, reaches its destination sooner than in - * as many legs before + * as many legs before, or at the same time in fewer legs than the last label */ public isTransferBetter(t: number, legs: number): boolean { - return this.getTransferBoardingTime(t, legs) < this.boardingTimes[this.getTransferLabel(t, legs)]; + 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 { @@ -253,18 +280,18 @@ export class ScanResults { public isReachedByTransfer(t: number, legs: number): boolean { const label = this.getTransferLabel(t, legs); - return this.connectionIndex[label] === transferConnection(t) && this.isExactLegs(label); + 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 Math.min(legs + 1, this.maxLegs); + return legs + 1; } private getTransferLabel(t: number, legs: number): number { - return this.transfers.destination[t] * this.levels + this.getLegsAfterWalking(legs); + return this.getLabel(this.transfers.destination[t], legs + 1); } /** @@ -273,7 +300,7 @@ export class ScanResults { */ private getTransferBoardingTime(t: number, legs: number): Time { const origin = this.transfers.origin[t]; - const setOff = this.boardingTimes[origin * this.levels + legs] + (legs === 0 ? this.interchange[origin] : 0); + 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]]; } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts index 7d0f2a9..427d9f0 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -13,11 +13,12 @@ export class ScanResultsFactory { 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, but past it a journey in fewer legs is no longer - * told apart from a sooner one in more. + * 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, @@ -32,6 +33,7 @@ export class ScanResultsFactory { 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); } /** @@ -49,7 +51,8 @@ export class ScanResultsFactory { this.connectionIndex.fill(NO_CONNECTION), this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings, - this.tripBoardingRanks + this.tripBoardingRanks, + this.lastLabelLegs ); } diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 59ca8d9..55cd71c 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -296,6 +296,18 @@ describe("ConnectionScanAlgorithm", () => { 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)])],