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
28 changes: 28 additions & 0 deletions .changeset/integer-timetable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"connection-scan-algorithm": major
---

Scan connections held as arrays of numbers, 30 to 90 times faster.

The algorithm and its classes are as they were: `toGtfsData`, `ConnectionScanAlgorithm` asking
`ScanResults` whether each connection is reachable and better, and `JourneyFactory` building legs
from the connection index. What they read changes. Stations are numbered, connections are parallel
typed arrays sorted by arrival rather than an object each, `ScanResults` holds arrays indexed by
station, and a connection or footpath is its index. A scan starts at the first connection arriving
after the departure time rather than at midnight, stops once every destination has been reached,
and whether each trip runs is worked out once per date by a `TripCalendar`.

Over the GB rail feed, planning Tuesday 15 September, across repeated runs: 32 standard queries (the
24 of `npm run perf` and eight more) take 3.1-3.5ms each on average rather than 115-117ms, 268
journeys through couplings 1.7ms rather than 130ms, and 400 random station pairs 4.4-5.3ms rather
than 170-174ms. Building takes 305-360ms rather than 570-630ms, and holds 83MB rather than 195MB. All
1,100 answers are the same journeys 2.0.0 returns, trip for trip.

`DepartAfterQuery`, `loadGtfs` and `toGtfsData` are called as before. What changes:

- `new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs))` and `new JourneyFactory(gtfs)`
- `GtfsData` holds `Connections` and `Transfers` as arrays, an `Int32Array` of interchange times, a
`TripCalendar`, the `trips` and a `stopTable` numbering the stations
- `scan` returns a `ConnectionIndex` of `Int32Array`, and a `Connection` is a number: `TimetableConnection`
and `TransfersByOrigin` are gone, and `isTransfer` checks a leg while `isTransferConnection` checks
a connection
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,31 @@ const {
const gtfs = await loadGtfs(fs.createReadStream("gtfs.zip"));
// or toGtfsData(feed) if you already have a feed from @gb-transit/gtfs-loader

const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange));
const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]);
const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs));
const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs), [new MultipleCriteriaFilter()]);
const results = query.plan(["TBW"], ["NRW"], new Date(), 9 * 3600);
```

### How a scan reads the timetable

The algorithm is the paper's: connections sorted by arrival, read in order, each one taken if it can
be reached and gets somewhere sooner. What makes it quick is what it reads.

`toGtfsData` numbers the stations, and holds the connections as parallel arrays of those numbers and
times rather than as an object each. `ScanResults` keeps its earliest arrivals and the connection
achieving each in arrays indexed by station, and a connection or footpath is its index, so every
question the scan asks is a few array reads. A scan starts at the first connection arriving after the
departure time and stops once every destination has been reached before the connection it is on
arrives, and whether each trip runs is worked out once per date rather than asked of every connection.

### Stations and platforms

A connection runs between stations, because that is where interchange time and footpaths are
defined and the only place a change of train is possible. A stop that gives a `parent_station` is
read as belonging to it, and a station is named by its `stop_code` where it has one, so that is what
queries are made with and journeys are returned in. `gtfs.stations` maps every feed stop id to the
station it belongs to, which is what `JourneyFactory` needs to cut a leg out of its trip.
station it belongs to, which is what `JourneyFactory` needs to cut a leg out of its trip, and
`gtfs.stopTable` numbers the stations.

The stop times of a leg are the feed's own, so a leg between two stations still says which platform
it uses at each end. A call the vehicle only passes through is not somewhere a journey can start or
Expand Down
53 changes: 32 additions & 21 deletions src/csa/ConnectionScanAlgorithm.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,52 @@
import type { DateNumber, DayOfWeek, StopID, Time } from "@gb-transit/gtfs-loader";
import type { TransfersByOrigin } from "../gtfs/GtfsLoader.js";
import type { Connection, TimetableConnection } from "../journey/Connection.js";
import { type Connections, firstArrivingAt } from "../gtfs/Connections.js";
import type { GtfsData, Transfers } from "../gtfs/GtfsLoader.js";
import type { StopIdx } from "../gtfs/StopTable.js";
import type { TripCalendar } from "../gtfs/TripCalendar.js";
import type { ScanResults } from "./ScanResults.js";
import type { ScanResultsFactory } from "./ScanResultsFactory.js";

/**
* Implementation of the connection scan algorithm.
*/
export class ConnectionScanAlgorithm {
private readonly connections: Connections;
private readonly transfers: Transfers;
private readonly calendar: TripCalendar;

constructor(
private readonly connections: TimetableConnection[],
private readonly transfers: TransfersByOrigin,
gtfs: GtfsData,
private readonly resultsFactory: ScanResultsFactory
) {}
) {
this.connections = gtfs.connections;
this.transfers = gtfs.transfers;
this.calendar = gtfs.calendar;
}

/**
* Return an index of connections that achieve the earliest arrival time at each stop.
* Return an index of connections that achieve the earliest arrival time at each station.
*/
public scan(
origins: OriginDepartureTimes,
destinations: StopID[],
date: DateNumber,
dow: DayOfWeek
): ConnectionIndex {
const results = this.resultsFactory.create({ ...origins });
const results = this.resultsFactory.create(origins, destinations);
const running = this.calendar.runningOn(date, dow);
const departureTime = Math.min(...Object.values(origins));

for (const origin in origins) {
for (const origin of results.getOrigins()) {
this.scanTransfers(results, origin);
}

for (const c of this.connections) {
if (c.trip.service.runsOn(date, dow) && results.isReachable(c) && results.isBetter(c)) {
for (let c = firstArrivingAt(this.connections, departureTime); c < this.connections.length; c++) {
if (results.isFinished(c)) {
break;
}
if (running[this.connections.trip[c]] && results.isReachable(c) && results.isBetter(c)) {
if (results.setConnection(c)) {
this.scanTransfers(results, c.destination);
}
if (results.isFinished(destinations, c.departureTime)) {
break;
this.scanTransfers(results, this.connections.arrivalStation[c]);
}
}
}
Expand All @@ -49,21 +59,22 @@ export class ConnectionScanAlgorithm {
* first time: a station first reached on foot is often then reached sooner by train, and the
* footpaths onwards from it have to start from the earlier time.
*/
private scanTransfers(results: ScanResults, origin: StopID): void {
for (const transfer of this.transfers[origin] ?? []) {
if (results.isTransferBetter(transfer)) {
results.setTransfer(transfer);
this.scanTransfers(results, transfer.destination);
private scanTransfers(results: ScanResults, origin: StopIdx): void {
for (let t = this.transfers.offsets[origin]; t < this.transfers.offsets[origin + 1]; t++) {
if (results.isTransferBetter(t)) {
results.setTransfer(t);
this.scanTransfers(results, this.transfers.destination[t]);
}
}
}

}

/**
* Index of connections that achieve the earliest arrivalTime time at each stop.
* The connection or footpath that achieves the earliest arrival at each station, by station index,
* or NO_CONNECTION where nothing does.
*/
export type ConnectionIndex = Record<StopID, Connection>;
export type ConnectionIndex = Int32Array;

/**
* Index of departure stations and their departure time
Expand Down
182 changes: 133 additions & 49 deletions src/csa/ScanResults.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,187 @@
import type { Interchange, StopID, Time, TripID } from "@gb-transit/gtfs-loader";
import { isChangeRequired, type TimetableConnection } from "../journey/Connection.js";
import type { Transfer } from "../journey/Journey.js";
import type { StopID, Time } from "@gb-transit/gtfs-loader";
import type { Connections } from "../gtfs/Connections.js";
import type { GtfsData, Transfers } from "../gtfs/GtfsLoader.js";
import { type StopIdx, UNKNOWN_STOP } from "../gtfs/StopTable.js";
import { type Connection, isChangeRequired, NO_CONNECTION, transferConnection } from "../journey/Connection.js";
import type { ConnectionIndex, OriginDepartureTimes } from "./ConnectionScanAlgorithm.js";

/**
* Arrival time of a station that has not been reached. Larger than any real time, so it loses every
* `<` comparison without needing a special case.
*/
export const NOT_REACHED = 0x7fffffff;

/** The trip has carried the passenger to none of its calls */
export const NOT_CARRIED = 0x7fffffff;

/**
* Mutable object that stores the current earliest arrival and best connection indexes as the
* connections are being scanned.
*
* Stations are held by index, and connections and footpaths by their index into the feed's, so
* every question the scan asks of this is a few array reads.
*/
export class ScanResults {
private readonly connectionIndex: ConnectionIndex = {};
private readonly tripArrivals: Record<TripID, Record<StopID, Time>> = {};
private readonly connections: Connections;
private readonly transfers: Transfers;
private readonly interchange: Int32Array;
private readonly earliestArrivals: Int32Array;
private readonly connectionIndex: ConnectionIndex;
private readonly origins: StopIdx[] = [];
private readonly destinations: StopIdx[] = [];
private readonly isDestination: Uint8Array;
private latestDestinationArrival: Time;

/**
* The trip arrivals are the earliest call each trip has carried the passenger to. They are only
* needed while the scan runs, so the factory gives every scan the same array.
*/
constructor(
private readonly interchange: Interchange,
private readonly earliestArrivals: OriginDepartureTimes
) {}
gtfs: GtfsData,
origins: OriginDepartureTimes,
destinations: StopID[],
private readonly tripArrivals: Int32Array
) {
this.connections = gtfs.connections;
this.transfers = gtfs.transfers;
this.interchange = gtfs.interchange;
this.earliestArrivals = new Int32Array(gtfs.stopTable.size).fill(NOT_REACHED);
this.connectionIndex = new Int32Array(gtfs.stopTable.size).fill(NO_CONNECTION);
this.isDestination = new Uint8Array(gtfs.stopTable.size);

for (const code of Object.keys(origins)) {
const origin = gtfs.stopTable.indexOf(code);

if (origin !== UNKNOWN_STOP) {
this.origins.push(origin);
this.earliestArrivals[origin] = origins[code];
}
}

// a destination the feed does not have is one nothing can be waited for at
for (const code of destinations) {
const destination = gtfs.stopTable.indexOf(code);

public isReachable(connection: TimetableConnection): boolean {
const reachable = this.isReachableWithChange(connection) || this.isReachableFromSameService(connection);
if (destination !== UNKNOWN_STOP) {
this.destinations.push(destination);
this.isDestination[destination] = 1;
}
}

this.latestDestinationArrival = this.getLatestDestinationArrival();
}

/**
* Once a trip has carried the passenger to a call, they are still aboard for any of its
* connections from there on. Boarding it is not the same: a trip only picking up at a later call
* has carried nobody to it.
*/
public isReachable(c: Connection): boolean {
const reachable = this.isReachableWithChange(c) || this.isReachableFromSameService(c);

if (reachable) {
this.tripArrivals[connection.trip.tripId] ??= {};
this.tripArrivals[connection.trip.tripId][connection.destination] = connection.arrivalTime;
const trip = this.connections.trip[c];

this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]);
}

return reachable;
}

private isReachableFromSameService(connection: TimetableConnection): boolean {
return Object.hasOwn(this.tripArrivals, connection.trip.tripId) &&
this.tripArrivals[connection.trip.tripId][connection.origin] <= connection.departureTime;
private isReachableFromSameService(c: Connection): boolean {
return this.tripArrivals[this.connections.trip[c]] <= this.connections.board[c];
}

private isReachableWithChange(connection: TimetableConnection): boolean {
const interchange = this.connectionIndex[connection.origin] ? this.getInterchange(connection.origin) : 0;
private isReachableWithChange(c: Connection): boolean {
const origin = this.connections.departureStation[c];
const interchange = this.connectionIndex[origin] === NO_CONNECTION ? 0 : this.interchange[origin];

return Object.hasOwn(this.earliestArrivals, connection.origin)
&& this.earliestArrivals[connection.origin] + interchange <= connection.departureTime;
return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c];
}

public isBetter(connection: TimetableConnection): boolean {
const arrivalTime = this.earliestArrivals[connection.destination];
public isBetter(c: Connection): boolean {
const arrivalTime = this.earliestArrivals[this.connections.arrivalStation[c]];

return arrivalTime === undefined
|| arrivalTime > connection.arrivalTime
|| (arrivalTime === connection.arrivalTime && this.staysAboard(connection));
return arrivalTime > this.connections.arrivalTime[c]
|| (arrivalTime === this.connections.arrivalTime[c] && this.staysAboard(c));
}

/**
* 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.
*/
private staysAboard(connection: TimetableConnection): boolean {
const current = this.connectionIndex[connection.destination];
private staysAboard(c: Connection): boolean {
const current = this.connectionIndex[this.connections.arrivalStation[c]];

return current !== undefined
&& isChangeRequired(current, connection)
&& this.isReachableFromSameService(connection);
return current !== NO_CONNECTION
&& isChangeRequired(this.connections, current, c)
&& this.isReachableFromSameService(c);
}

/**
* Returns true if the connection arrives earlier than the destination was reached before, rather
* than at the same time on a trip the passenger stays aboard
*/
public setConnection(connection: TimetableConnection): boolean {
const previous = this.earliestArrivals[connection.destination];
this.earliestArrivals[connection.destination] = connection.arrivalTime;
this.connectionIndex[connection.destination] = connection;
public setConnection(c: Connection): boolean {
const destination = this.connections.arrivalStation[c];
const previous = this.earliestArrivals[destination];

this.connectionIndex[destination] = c;

return previous === undefined || previous > connection.arrivalTime;
return this.arrive(destination, this.connections.arrivalTime[c]) < previous;
}

public isTransferBetter(transfer: Transfer): boolean {
return !Object.hasOwn(this.earliestArrivals, transfer.destination)
|| this.earliestArrivals[transfer.destination] > this.getTransferArrivalTime(transfer);
public isTransferBetter(t: number): boolean {
return this.earliestArrivals[this.transfers.destination[t]] > this.getTransferArrivalTime(t);
}

public setTransfer(transfer: Transfer): void {
this.earliestArrivals[transfer.destination] = this.getTransferArrivalTime(transfer);
this.connectionIndex[transfer.destination] = transfer;
public setTransfer(t: number): void {
const destination = this.transfers.destination[t];

this.connectionIndex[destination] = transferConnection(t);
this.arrive(destination, this.getTransferArrivalTime(t));
}

private getTransferArrivalTime(transfer: Transfer): Time {
return this.earliestArrivals[transfer.origin] + transfer.duration + this.getInterchange(transfer.origin);
private getTransferArrivalTime(t: number): Time {
const origin = this.transfers.origin[t];

return this.earliestArrivals[origin] + this.transfers.duration[t] + this.interchange[origin];
}

/**
* A station the feed gave no interchange time for is one a change takes no time at
*/
private getInterchange(station: StopID): Time {
return this.interchange[station] ?? 0;
private arrive(station: StopIdx, time: Time): Time {
this.earliestArrivals[station] = time;

if (this.isDestination[station] === 1) {
this.latestDestinationArrival = this.getLatestDestinationArrival();
}

return time;
}

private getLatestDestinationArrival(): Time {
let latest = this.destinations.length === 0 ? -1 : 0;

for (const destination of this.destinations) {
latest = Math.max(latest, this.earliestArrivals[destination]);
}

return latest;
}

public getOrigins(): StopIdx[] {
return this.origins;
}

public getConnectionIndex(): ConnectionIndex {
return this.connectionIndex;
}

public isFinished(destinations: StopID[], departureTime: Time): boolean {
return !destinations.some(d => !this.earliestArrivals[d] || departureTime < this.earliestArrivals[d]);
/**
* Every destination has been reached before the connection arrives, so neither it nor any after
* it can arrive sooner, or at the same time on a trip the passenger is aboard.
*/
public isFinished(c: Connection): boolean {
return this.connections.arrivalTime[c] > this.latestDestinationArrival;
}
}
Loading