From 470c2f970053b042d646bc5f63d3c4c8896b3302 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 07:13:18 +0100 Subject: [PATCH 1/4] Board each trip where the scan boarded it A journey could change trains at a station where another trip happened to arrive first, with less than the station's interchange time, when the passenger had really boarded that trip at an earlier call. getLegs split legs wherever the last connection into a station was on a different trip, which does not say where a trip was boarded. The scan now records the connection each trip was first reachable from, and the connection index holds that connection for each station, so each entry is one leg. Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S --- .changeset/board-where-the-scan-boarded.md | 11 +++++ src/csa/ConnectionScanAlgorithm.ts | 4 +- src/csa/ScanResults.ts | 13 ++++-- src/csa/ScanResultsFactory.ts | 4 +- src/journey/JourneyFactory.ts | 40 +++++++------------ test/unit/csa/ConnectionScanAlgorithm.spec.ts | 18 +++++++++ test/unit/csa/ScanResults.spec.ts | 14 ++++++- test/unit/journey/JourneyFactory.spec.ts | 18 ++++----- test/unit/util.ts | 11 +++++ 9 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 .changeset/board-where-the-scan-boarded.md diff --git a/.changeset/board-where-the-scan-boarded.md b/.changeset/board-where-the-scan-boarded.md new file mode 100644 index 0000000..958beee --- /dev/null +++ b/.changeset/board-where-the-scan-boarded.md @@ -0,0 +1,11 @@ +--- +"connection-scan-algorithm": patch +--- + +Return the journey the scan made. A journey could change trains at a station where another trip +happened to arrive first, with less than the station's interchange time, when the passenger had +really boarded that trip at an earlier call. The arrival times were right, the legs were not. + +The scan now records the connection each trip was first boarded from, and the connection index holds +that connection for each station rather than the last connection into it, so each entry is one leg. +`ScanResults.setConnection` has to follow `isReachable` for the same connection, as the scan does. diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 9cb8cca..1314cfe 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -71,8 +71,8 @@ export class ConnectionScanAlgorithm { } /** - * The connection or footpath that achieves the earliest arrival at each station, by station index, - * or NO_CONNECTION where nothing does. + * 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. */ export type ConnectionIndex = Int32Array; diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index e7fde04..614934d 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -34,13 +34,16 @@ export class ScanResults { /** * 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. + * needed while the scan runs, so the factory gives every scan the same array. So are the trip + * boardings, the connection each trip was first reachable from, which are only read for a trip + * once it has carried the passenger and so need no clearing between scans. */ constructor( gtfs: GtfsData, origins: OriginDepartureTimes, destinations: StopID[], - private readonly tripArrivals: Int32Array + private readonly tripArrivals: Int32Array, + private readonly tripBoardings: Int32Array ) { this.connections = gtfs.connections; this.transfers = gtfs.transfers; @@ -82,6 +85,10 @@ export class ScanResults { if (reachable) { const trip = this.connections.trip[c]; + if (this.tripArrivals[trip] === NOT_CARRIED) { + this.tripBoardings[trip] = c; + } + this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]); } @@ -127,7 +134,7 @@ export class ScanResults { const destination = this.connections.arrivalStation[c]; const previous = this.earliestArrivals[destination]; - this.connectionIndex[destination] = c; + this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]]; return this.arrive(destination, this.connections.arrivalTime[c]) < previous; } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts index 130aa39..0399496 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -8,11 +8,13 @@ import { NOT_CARRIED, ScanResults } from "./ScanResults.js"; */ export class ScanResultsFactory { private readonly tripArrivals: Int32Array; + private readonly tripBoardings: Int32Array; constructor( private readonly gtfs: GtfsData ) { this.tripArrivals = new Int32Array(gtfs.trips.length); + this.tripBoardings = new Int32Array(gtfs.trips.length); } /** @@ -20,7 +22,7 @@ export class ScanResultsFactory { * of the scan in progress can still be asked whether a connection is reachable. */ public create(origins: OriginDepartureTimes, destinations: StopID[]): ScanResults { - return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED)); + return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings); } } diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index e3856bf..4e4ffc4 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -1,8 +1,8 @@ import { isCall, type StopID, type StopTime, type Time, type Trip } from "@gb-transit/gtfs-loader"; import type { ConnectionIndex } from "../csa/ConnectionScanAlgorithm.js"; import type { GtfsData } from "../gtfs/GtfsLoader.js"; -import { UNKNOWN_STOP } from "../gtfs/StopTable.js"; -import { type Connection, isChangeRequired, isTransferConnection, NO_CONNECTION, transferOf } from "./Connection.js"; +import { type StopIdx, UNKNOWN_STOP } from "../gtfs/StopTable.js"; +import { type Connection, isTransferConnection, NO_CONNECTION, transferOf } from "./Connection.js"; import { type AnyLeg, isTransfer, type Journey } from "./Journey.js"; /** @@ -30,49 +30,39 @@ export class JourneyFactory { } /** - * Iterate backwards from the destination to the origin collecting connections into legs + * Iterate backwards from the destination to the origin, each station giving the leg that reached it */ private getLegs(connections: ConnectionIndex, destination: StopID): AnyLeg[] | null { - const legs: Connection[][] = []; - let legConnections: Connection[] = []; - let previousConnection: Connection = NO_CONNECTION; + const legs: AnyLeg[] = []; let station = this.gtfs.stopTable.indexOf(destination); while (station !== UNKNOWN_STOP && connections[station] !== NO_CONNECTION) { const connection = connections[station]; - if (previousConnection !== NO_CONNECTION && isChangeRequired(this.gtfs.connections, previousConnection, connection)) { - legs.push(legConnections.reverse()); - legConnections = []; - } - - legConnections.push(connection); - previousConnection = connection; + legs.push(this.toLeg(connection, station)); station = isTransferConnection(connection) ? this.gtfs.transfers.origin[transferOf(connection)] : this.gtfs.connections.departureStation[connection]; } - legs.push(legConnections.reverse()); - - return legConnections.length === 0 ? null : legs.reverse().map(cs => this.toLeg(cs)); + return legs.length === 0 ? null : legs.reverse(); } /** - * Convert a list of connections into a Transfer or a TimetableLeg + * Convert the connection a trip was boarded from into a TimetableLeg to the station, or a footpath + * into a Transfer */ - private toLeg(cs: Connection[]): AnyLeg { + private toLeg(connection: Connection, station: StopIdx): AnyLeg { const { connections, stopTable, transfers, trips } = this.gtfs; - const firstConnection = cs[0]; - if (isTransferConnection(firstConnection)) { - return transfers.transfer[transferOf(firstConnection)]; + if (isTransferConnection(connection)) { + return transfers.transfer[transferOf(connection)]; } else { - const origin = stopTable.nameOf(connections.departureStation[firstConnection]); - const destination = stopTable.nameOf(connections.arrivalStation[cs[cs.length - 1]]); - const trip = trips[connections.trip[firstConnection]]; - const stopTimes = this.getStopTimes(trip, origin, connections.departureTime[firstConnection], destination); + 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 { origin, destination, trip, stopTimes: stopTimes || [] }; } diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 143e184..4def83f 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -129,6 +129,24 @@ describe("ConnectionScanAlgorithm", () => { expect(legsOf(journey)).toEqual(["1:A-D"]); }); + /** + * Trip 2 reaches D before trip 3 does, but too late to change onto trip 3 there. The passenger + * boards trip 3 at B instead, and the journey has to say so rather than change at D. + */ + it("boards a trip where the scan boarded it rather than where another trip arrived first", () => { + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("B", 1100)]), + trip("2", [st("A", 1050), st("D", 1125)]), + trip("3", [st("B", 1110), st("D", 1130), st("E", 1150)]) + ], + interchange: { D: 600 } + }, ["A"], ["E"], 0); + + expect(legsOf(journey)).toEqual(["1:A-B", "3:B-E"]); + expect(journey.arrivalTime).toBe(1150); + }); + it("gives each scan a connection index of its own", () => { const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] }); const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index be6fd07..494d22c 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, transfer, trip, walk } from "../util.js"; +import { byOrigin, connection, gtfsOf, pickUpOnly, resultsFor, st, take, transfer, trip, walk } from "../util.js"; describe("ScanResults", () => { const gtfs = gtfsOf({ @@ -153,13 +153,23 @@ describe("ScanResults", () => { const results = resultsFor(gtfs, { A: 900 }); const [b, c] = ["B", "C"].map(code => gtfs.stopTable.indexOf(code)); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); + take(results, connection(gtfs, "LN1111", "A", "B")); results.setTransfer(transfer(gtfs, "B", "C")); expect(results.getConnectionIndex()[b]).toBe(connection(gtfs, "LN1111", "A", "B")); expect(results.getConnectionIndex()[c]).toBe(transferConnection(transfer(gtfs, "B", "C"))); }); + it("indexes a station by the connection its trip was boarded from", () => { + const through = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1030)])] }); + const results = resultsFor(through, { A: 900 }); + + 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")); + }); + it("is finished once a connection arrives after every destination was reached", () => { const results = resultsFor(gtfs, { A: 900 }, ["B"]); diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts index 1de7027..a528350 100644 --- a/test/unit/journey/JourneyFactory.spec.ts +++ b/test/unit/journey/JourneyFactory.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TimetableLeg } from "../../../src/journey/Journey.js"; import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import { byOrigin, connection, gtfsOf, legsOf, platforms, resultsFor, st, transfer, trip, walk } from "../util.js"; +import { byOrigin, connection, gtfsOf, legsOf, platforms, resultsFor, st, take, transfer, trip, walk } from "../util.js"; describe("JourneyFactory", () => { @@ -9,7 +9,7 @@ describe("JourneyFactory", () => { const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1030)])] }); const results = resultsFor(gtfs, { A: 1000 }); - results.setConnection(connection(gtfs, "1", "A", "B")); + take(results, connection(gtfs, "1", "A", "B")); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["B"]); @@ -34,7 +34,7 @@ describe("JourneyFactory", () => { const results = resultsFor(gtfs, { A: 1000 }); results.setTransfer(transfer(gtfs, "A", "B")); - results.setConnection(connection(gtfs, "1", "B", "C")); + take(results, connection(gtfs, "1", "B", "C")); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["C"]); @@ -51,7 +51,7 @@ describe("JourneyFactory", () => { const results = resultsFor(gtfs, { A: 1000 }); results.setTransfer(transfer(gtfs, "A", "B")); - results.setConnection(connection(gtfs, "1", "B", "C")); + take(results, connection(gtfs, "1", "B", "C")); results.setTransfer(transfer(gtfs, "C", "D")); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["D"]); @@ -68,10 +68,10 @@ describe("JourneyFactory", () => { }); const results = resultsFor(gtfs, { A: 1000 }); - results.setConnection(connection(gtfs, "LN1111", "A", "B")); - results.setConnection(connection(gtfs, "LN1112", "B", "C")); - results.setConnection(connection(gtfs, "LN1113", "C", "D")); - results.setConnection(connection(gtfs, "LN1114", "D", "E")); + 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"]); @@ -85,7 +85,7 @@ describe("JourneyFactory", () => { const gtfs = gtfsOf({ stops: platforms, trips: [trip("1", [st("NRW1", 1000), passing, st("LST8", 1200)])] }); const results = resultsFor(gtfs, { NRW: 900 }); - results.setConnection(connection(gtfs, "1", "NRW", "LST")); + take(results, connection(gtfs, "1", "NRW", "LST")); const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["LST"]); const leg = journey.legs[0] as TimetableLeg; diff --git a/test/unit/util.ts b/test/unit/util.ts index 2775922..3a2b0e1 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -146,6 +146,17 @@ export function resultsFor(gtfs: GtfsData, origins: Record, destin return new ScanResultsFactory(gtfs).create(origins, destinations); } +/** + * Take a connection as the scan does, which is only once it has been found reachable. + */ +export function take(results: ScanResults, c: Connection): void { + if (!results.isReachable(c)) { + throw new Error(`Connection ${c} is not reachable`); + } + + results.setConnection(c); +} + /** * Plan over a feed with the pieces a caller would wire together. */ From 58df17e3f0939a8338ad01093042a3fcebe662fb Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 08:35:42 +0100 Subject: [PATCH 2/4] Board a trip at the call reached in fewest legs, then the latest Boarding each trip at the first call it was reachable from could take a passenger past a later call of the trip only to ride back through it, such as riding into Edinburgh through Haymarket to board a train back out through Haymarket. A trip now changes where it is boarded when a call is reached in fewer legs, or in as many legs at a later call. Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S --- .changeset/board-where-the-scan-boarded.md | 9 ++-- src/csa/ScanResults.ts | 42 ++++++++++++++----- src/csa/ScanResultsFactory.ts | 4 +- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 30 +++++++++++++ 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/.changeset/board-where-the-scan-boarded.md b/.changeset/board-where-the-scan-boarded.md index 958beee..6780c36 100644 --- a/.changeset/board-where-the-scan-boarded.md +++ b/.changeset/board-where-the-scan-boarded.md @@ -6,6 +6,9 @@ Return the journey the scan made. A journey could change trains at a station whe happened to arrive first, with less than the station's interchange time, when the passenger had really boarded that trip at an earlier call. The arrival times were right, the legs were not. -The scan now records the connection each trip was first boarded from, and the connection index holds -that connection for each station rather than the last connection into it, so each entry is one leg. -`ScanResults.setConnection` has to follow `isReachable` for the same connection, as the scan does. +The scan now records the connection each trip is boarded from, and the connection index holds that +connection for each station rather than the last connection into it, so each entry is one leg. A trip +that can be boarded at more than one call is boarded where the passenger has taken the fewest legs to +reach, then at the latest call, so a passenger is not taken past a call of the trip only to ride back +through it. `ScanResults.setConnection` has to follow `isReachable` for the same connection, as the +scan does. diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index 614934d..fe9de8d 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -14,6 +14,9 @@ export const NOT_REACHED = 0x7fffffff; /** The trip has carried the passenger to none of its calls */ export const NOT_CARRIED = 0x7fffffff; +/** More calls than any trip has, so that one fewer leg outranks any later call */ +const CALLS_PER_LEG = 0x10000; + /** * Mutable object that stores the current earliest arrival and best connection indexes as the * connections are being scanned. @@ -27,6 +30,8 @@ export class ScanResults { private readonly interchange: Int32Array; private readonly earliestArrivals: Int32Array; private readonly connectionIndex: ConnectionIndex; + /** How many legs the earliest arrival at each station takes, which decides where a trip is boarded */ + private readonly legs: Int32Array; private readonly origins: StopIdx[] = []; private readonly destinations: StopIdx[] = []; private readonly isDestination: Uint8Array; @@ -35,21 +40,23 @@ export class ScanResults { /** * 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 was first reachable from, which are only read for a trip - * once it has carried the passenger and so need no clearing between scans. + * 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 tripArrivals: Int32Array, - private readonly tripBoardings: Int32Array + private readonly tripBoardings: Int32Array, + private readonly tripBoardingRanks: 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.isDestination = new Uint8Array(gtfs.stopTable.size); for (const code of Object.keys(origins)) { @@ -78,21 +85,31 @@ export class ScanResults { * Once a trip has carried the passenger to a call, they are still aboard for any of its * connections from there on. Boarding it is not the same: a trip only picking up at a later call * has carried nobody to it. + * + * A trip that can be boarded at more than one call is boarded where the passenger has taken the + * fewest legs to reach, and after that at the latest call. Boarding at the earliest call would + * have a passenger who passed a later call of the trip on the way ride back through it. Both are + * folded into one rank, lower being better, and kept per trip so that comparing against the + * current boarding reads nothing from the connections. */ public isReachable(c: Connection): boolean { - const reachable = this.isReachableWithChange(c) || this.isReachableFromSameService(c); + const trip = this.connections.trip[c]; - if (reachable) { - const trip = this.connections.trip[c]; + if (this.isReachableWithChange(c)) { + const rank = this.legs[this.connections.departureStation[c]] * CALLS_PER_LEG - this.connections.board[c]; - if (this.tripArrivals[trip] === NOT_CARRIED) { + if (this.tripArrivals[trip] === NOT_CARRIED || rank < this.tripBoardingRanks[trip]) { this.tripBoardings[trip] = c; + this.tripBoardingRanks[trip] = rank; } - - this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]); } + else if (!this.isReachableFromSameService(c)) { + return false; + } + + this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]); - return reachable; + return true; } private isReachableFromSameService(c: Connection): boolean { @@ -133,8 +150,10 @@ export class ScanResults { public setConnection(c: Connection): boolean { const destination = this.connections.arrivalStation[c]; const previous = this.earliestArrivals[destination]; + const boarding = this.tripBoardings[this.connections.trip[c]]; - this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]]; + this.connectionIndex[destination] = boarding; + this.legs[destination] = this.legs[this.connections.departureStation[boarding]] + 1; return this.arrive(destination, this.connections.arrivalTime[c]) < previous; } @@ -147,6 +166,7 @@ export class ScanResults { const destination = this.transfers.destination[t]; this.connectionIndex[destination] = transferConnection(t); + this.legs[destination] = this.legs[this.transfers.origin[t]] + 1; this.arrive(destination, this.getTransferArrivalTime(t)); } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts index 0399496..1d0cfbb 100644 --- a/src/csa/ScanResultsFactory.ts +++ b/src/csa/ScanResultsFactory.ts @@ -9,12 +9,14 @@ import { NOT_CARRIED, ScanResults } from "./ScanResults.js"; export class ScanResultsFactory { private readonly tripArrivals: Int32Array; private readonly tripBoardings: Int32Array; + private readonly tripBoardingRanks: Int32Array; constructor( private readonly gtfs: GtfsData ) { this.tripArrivals = new Int32Array(gtfs.trips.length); this.tripBoardings = new Int32Array(gtfs.trips.length); + this.tripBoardingRanks = new Int32Array(gtfs.trips.length); } /** @@ -22,7 +24,7 @@ export class ScanResultsFactory { * of the scan in progress can still be asked whether a connection is reachable. */ public create(origins: OriginDepartureTimes, destinations: StopID[]): ScanResults { - return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings); + return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings, this.tripBoardingRanks); } } diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index 4def83f..0c879ae 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -147,6 +147,36 @@ describe("ConnectionScanAlgorithm", () => { expect(journey.arrivalTime).toBe(1150); }); + /** + * Trip 1 passes Q on its way to P, where trip 2 starts back through Q. Trip 2 could be boarded at + * either, but boarding it at P would ride from Q to P and straight back. + */ + it("boards a trip at its latest call reached in as few legs rather than doubling back", () => { + const [journey] = plan({ + trips: [ + trip("1", [st("X", 1000), st("Q", 1010), st("P", 1020)]), + trip("2", [st("P", 1030), st("Q", 1040), st("R", 1100)]) + ] + }, ["X"], ["R"], 900); + + expect(legsOf(journey)).toEqual(["1:X-Q", "2:Q-R"]); + expect(journey.arrivalTime).toBe(1100); + }); + + /** + * Trip 2 overtakes trip 1 and could be changed from at B, but that is a change trip 1 does not need. + */ + it("does not board a trip later if reaching the later call takes more legs", () => { + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)]), + trip("2", [st("A", 1010), st("B", 1050)]) + ] + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["1:A-C"]); + }); + it("gives each scan a connection index of its own", () => { const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] }); const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); From 6c6ee2afcad623dee007d25525cd516adcc6f715 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 08:40:13 +0100 Subject: [PATCH 3/4] Remove comment on legs Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S --- src/csa/ScanResults.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index fe9de8d..7f40578 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -30,7 +30,6 @@ export class ScanResults { private readonly interchange: Int32Array; private readonly earliestArrivals: Int32Array; private readonly connectionIndex: ConnectionIndex; - /** How many legs the earliest arrival at each station takes, which decides where a trip is boarded */ private readonly legs: Int32Array; private readonly origins: StopIdx[] = []; private readonly destinations: StopIdx[] = []; From f1c25e65c3af0d445b0cd1da5aaec665567f2c9e Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Mon, 14 Sep 2026 09:04:33 +0100 Subject: [PATCH 4/4] Break equal arrivals on legs and change where trips cross A connection overwriting a station at the same arrival time, through staysAboard, could replace a path with one of more legs, and the footpaths from it were not walked again, so stations reached from it kept a count from the path that was replaced. An equal arrival now wins on fewer legs, and on staying aboard only when the legs are equal, and a station reached in fewer legs has its footpaths walked again. Legs are only known for the earliest arrival at each call, so a trip may pass a later call of the next one in as few legs without the scan seeing it. Where consecutive timetable legs share a call the second picks up at in time to change, the journey changes at the first such call. Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S --- .changeset/board-where-the-scan-boarded.md | 6 ++- src/csa/ConnectionScanAlgorithm.ts | 7 +-- src/csa/ScanResults.ts | 44 +++++++++++----- src/journey/JourneyFactory.ts | 51 ++++++++++++++++++- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 40 +++++++++++++++ test/unit/csa/ScanResults.spec.ts | 39 +++++++++++++- 6 files changed, 166 insertions(+), 21 deletions(-) diff --git a/.changeset/board-where-the-scan-boarded.md b/.changeset/board-where-the-scan-boarded.md index 6780c36..00f4819 100644 --- a/.changeset/board-where-the-scan-boarded.md +++ b/.changeset/board-where-the-scan-boarded.md @@ -10,5 +10,7 @@ The scan now records the connection each trip is boarded from, and the connectio connection for each station rather than the last connection into it, so each entry is one leg. A trip that can be boarded at more than one call is boarded where the passenger has taken the fewest legs to reach, then at the latest call, so a passenger is not taken past a call of the trip only to ride back -through it. `ScanResults.setConnection` has to follow `isReachable` for the same connection, as the -scan does. +through it. Reaching a station at the same time in fewer legs now replaces how it was reached, and +the footpaths from it are walked again. Where the trip before still passes a later call of the next +one in time to change there, the journey changes there. `ScanResults.setConnection` has to follow +`isReachable` for the same connection, as the scan does. diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 1314cfe..2052bbb 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -55,9 +55,10 @@ export class ConnectionScanAlgorithm { } /** - * Walk every footpath out of a station whenever it is reached earlier 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. + * 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 + * train, and the footpaths onwards from it have to start from the earlier time and count on from + * the fewer legs. */ private scanTransfers(results: ScanResults, origin: StopIdx): void { for (let t = this.transfers.offsets[origin]; t < this.transfers.offsets[origin + 1]; t++) { diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index 7f40578..ad0887b 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -122,17 +122,27 @@ export class ScanResults { return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c]; } + /** + * 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. + */ public isBetter(c: Connection): boolean { - const arrivalTime = this.earliestArrivals[this.connections.arrivalStation[c]]; + const destination = this.connections.arrivalStation[c]; + const arrivalTime = this.earliestArrivals[destination]; + + if (arrivalTime !== this.connections.arrivalTime[c]) { + return arrivalTime > this.connections.arrivalTime[c]; + } - return arrivalTime > this.connections.arrivalTime[c] - || (arrivalTime === this.connections.arrivalTime[c] && this.staysAboard(c)); + const legs = this.legsTo(c); + + return legs < this.legs[destination] || (legs === this.legs[destination] && this.staysAboard(c)); } /** - * Arriving at the same time 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. + * 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. */ private staysAboard(c: Connection): boolean { const current = this.connectionIndex[this.connections.arrivalStation[c]]; @@ -143,22 +153,30 @@ export class ScanResults { } /** - * Returns true if the connection arrives earlier than the destination was reached before, rather - * than at the same time on a trip the passenger stays aboard + * 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 */ public setConnection(c: Connection): boolean { const destination = this.connections.arrivalStation[c]; const previous = this.earliestArrivals[destination]; - const boarding = this.tripBoardings[this.connections.trip[c]]; + const previousLegs = this.legs[destination]; + + this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]]; + this.legs[destination] = this.legsTo(c); - this.connectionIndex[destination] = boarding; - this.legs[destination] = this.legs[this.connections.departureStation[boarding]] + 1; + return this.arrive(destination, this.connections.arrivalTime[c]) < previous || this.legs[destination] < previousLegs; + } - return this.arrive(destination, this.connections.arrivalTime[c]) < previous; + private legsTo(c: Connection): number { + return this.legs[this.connections.departureStation[this.tripBoardings[this.connections.trip[c]]]] + 1; } public isTransferBetter(t: number): boolean { - return this.earliestArrivals[this.transfers.destination[t]] > this.getTransferArrivalTime(t); + const destination = this.transfers.destination[t]; + const arrivalTime = this.getTransferArrivalTime(t); + + return this.earliestArrivals[destination] > arrivalTime + || (this.earliestArrivals[destination] === arrivalTime && this.legs[this.transfers.origin[t]] + 1 < this.legs[destination]); } public setTransfer(t: number): void { diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index 4e4ffc4..b798cbe 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 } from "./Journey.js"; +import { type AnyLeg, isTransfer, type Journey, type TimetableLeg } from "./Journey.js"; /** * Creates journeys from the connection index created by the connection scan algorithm. @@ -26,6 +26,7 @@ export class JourneyFactory { .map(d => this.getLegs(connections, d)) .filter((c): c is AnyLeg[] => c !== null) .map(c => this.getCompactedLegs(c)) + .map(c => this.getStraightenedLegs(c)) .map(l => this.getJourney(l)); } @@ -102,6 +103,54 @@ export class JourneyFactory { 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 0c879ae..2db7f5f 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -177,6 +177,46 @@ describe("ConnectionScanAlgorithm", () => { expect(legsOf(journey)).toEqual(["1:A-C"]); }); + /** + * S is reached at the same time in three legs, then in one on trip c, and W is walked to from S. + * Trip d can be boarded at F, reached in three legs, or at W, which is only in two once the walk + * from S is taken again after trip c. + */ + it("walks on again from a station reached at the same time in fewer legs", () => { + const [journey] = plan({ + trips: [ + trip("a", [st("O", 1000), st("M", 1010)]), + trip("a2", [st("M", 1020), st("N", 1030)]), + trip("b", [st("N", 1040), st("S", 1100)]), + trip("e", [st("N", 1040), st("F", 1100)]), + trip("c", [st("O", 1000), st("S", 1100)]), + trip("d", [st("F", 1150), st("W", 1200), st("T", 1300)]) + ], + transfers: byOrigin(walk("S", "W", 60)) + }, ["O"], ["T"], 900); + + expect(legsOf(journey)).toEqual(["c:O-S", "walk:S-W", "d:W-T"]); + expect(journey.arrivalTime).toBe(1300); + }); + + /** + * X is reached earliest in two legs, but trip A passes it later in one on the way to Y, where trip + * B is boarded back through X. The passenger changes at X rather than riding to Y and back. + */ + it("changes where the previous trip passed the next rather than riding on and back", () => { + const [journey] = plan({ + trips: [ + trip("P", [st("O", 1000), st("Q", 1005)]), + trip("R", [st("Q", 1006), st("X", 1010)]), + trip("A", [st("O", 1000), st("X", 1020), st("Y", 1030)]), + trip("B", [st("Y", 1040), st("X", 1050), st("Z", 1100)]) + ] + }, ["O"], ["Z"], 900); + + expect(legsOf(journey)).toEqual(["A:O-X", "B:X-Z"]); + expect(journey.arrivalTime).toBe(1100); + }); + it("gives each scan a connection index of its own", () => { const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] }); const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts index 494d22c..50be054 100644 --- a/test/unit/csa/ScanResults.spec.ts +++ b/test/unit/csa/ScanResults.spec.ts @@ -97,8 +97,8 @@ describe("ScanResults", () => { }); const results = resultsFor(parallel, { A: 900 }); - results.setConnection(connection(parallel, "1", "A", "B")); - results.setConnection(connection(parallel, "2", "B", "C")); + take(results, connection(parallel, "1", "A", "B")); + take(results, connection(parallel, "2", "B", "C")); const alsoToC = connection(parallel, "3", "B", "C"); @@ -106,6 +106,41 @@ describe("ScanResults", () => { expect(results.isBetter(alsoToC)).toBe(false); }); + it("replaces a trip with another arriving at the same time in fewer legs", () => { + const shorter = gtfsOf({ + 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)]) + ] + }); + const results = resultsFor(shorter, { A: 900 }); + + 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); + + 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); + }); + + it("knows a transfer arriving at the same time in fewer legs is better", () => { + const walks = gtfsOf({ + trips: [trip("1", [st("A", 1000), st("B", 1010)]), trip("2", [st("B", 1020), st("C", 1100)])], + transfers: byOrigin(walk("A", "C", 100)) + }); + const results = resultsFor(walks, { A: 1000 }); + + take(results, connection(walks, "1", "A", "B")); + take(results, connection(walks, "2", "B", "C")); + + expect(results.isTransferBetter(transfer(walks, "A", "C"))).toBe(true); + }); + it("does not count a passenger aboard at a call the trip only picks up at", () => { const pickUps = gtfsOf({ trips: [trip("1", [pickUpOnly("A", 1000), pickUpOnly("B", 1060), st("C", 2000)])] }); const results = resultsFor(pickUps, { A: 900 });