Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/board-where-the-scan-boarded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"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 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. 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.
11 changes: 6 additions & 5 deletions src/csa/ConnectionScanAlgorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -71,8 +72,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;

Expand Down
80 changes: 62 additions & 18 deletions src/csa/ScanResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,26 +30,32 @@ export class ScanResults {
private readonly interchange: Int32Array;
private readonly earliestArrivals: Int32Array;
private readonly connectionIndex: ConnectionIndex;
private readonly legs: Int32Array;
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.
* 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.
*/
constructor(
gtfs: GtfsData,
origins: OriginDepartureTimes,
destinations: StopID[],
private readonly tripArrivals: Int32Array
private readonly tripArrivals: 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)) {
Expand Down Expand Up @@ -75,17 +84,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];

this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]);
if (this.tripArrivals[trip] === NOT_CARRIED || rank < this.tripBoardingRanks[trip]) {
this.tripBoardings[trip] = c;
this.tripBoardingRanks[trip] = rank;
}
}
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 {
Expand All @@ -99,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];

return arrivalTime > this.connections.arrivalTime[c]
|| (arrivalTime === this.connections.arrivalTime[c] && this.staysAboard(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));
}

/**
* 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]];
Expand All @@ -120,26 +153,37 @@ 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 previousLegs = this.legs[destination];

this.connectionIndex[destination] = c;
this.connectionIndex[destination] = this.tripBoardings[this.connections.trip[c]];
this.legs[destination] = this.legsTo(c);

return this.arrive(destination, this.connections.arrivalTime[c]) < previous;
return this.arrive(destination, this.connections.arrivalTime[c]) < previous || this.legs[destination] < previousLegs;
}

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 {
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));
}

Expand Down
6 changes: 5 additions & 1 deletion src/csa/ScanResultsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@ 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);
}

/**
* The trip arrivals are shared between scans rather than allocated for each, so only the results
* of the scan in progress can still be asked whether a connection is reachable.
*/
public create(origins: OriginDepartureTimes, destinations: StopID[]): ScanResults {
return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED));
return new ScanResults(this.gtfs, origins, destinations, this.tripArrivals.fill(NOT_CARRIED), this.tripBoardings, this.tripBoardingRanks);
}

}
91 changes: 65 additions & 26 deletions src/journey/JourneyFactory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { isCall, type StopID, type StopTime, type Time, type Trip } from "@gb-transit/gtfs-loader";
import type { ConnectionIndex } from "../csa/ConnectionScanAlgorithm.js";
import type { GtfsData } from "../gtfs/GtfsLoader.js";
import { UNKNOWN_STOP } from "../gtfs/StopTable.js";
import { type Connection, isChangeRequired, isTransferConnection, NO_CONNECTION, transferOf } from "./Connection.js";
import { type AnyLeg, isTransfer, type Journey } from "./Journey.js";
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";

/**
* Creates journeys from the connection index created by the connection scan algorithm.
Expand All @@ -26,53 +26,44 @@ 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));
}

/**
* 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 || [] };
}
Expand Down Expand Up @@ -112,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
Expand Down
Loading