From 84e25235e096146bd9bef090ff280b2f5e9aac78 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Sun, 13 Sep 2026 20:02:38 +0100 Subject: [PATCH 1/2] Scan an integer timetable The scan read an object per connection and kept its state in objects keyed by station code and trip id, so every step was a string-keyed lookup, every query started from midnight, and every connection asked its trip's calendar whether it ran. On the GB rail feed that is 115-175ms a query. createTimetable numbers the stations as it is built and holds everything the scan reads as parallel typed arrays: connections sorted by arrival with a counting sort, which is linear and stable so connections arriving together keep the order 2.0.0 scanned them in; footpaths grouped by origin station; each trip's usable calls as one flat run. A scan's state is an array per station and per trip, and journeys are built from the station and call numbers, looking codes, trips and stop times up only for the legs returned. A scan now: - starts at the first connection arriving at or after the departure time, found by binary search, since nothing arriving earlier could be boarded - stops once a connection arrives after every destination has been reached. Stopping on departure instead would end it at a connection that departs and arrives in the same minute, before a trip arriving in that minute that the passenger is aboard could take the tie - asks each distinct calendar once per date rather than each trip per connection - counts a passenger aboard a trip from the earliest call one of its connections has arrived at, not the call they boarded at, as 2.0.0 did by station. A trip only picking up at a later call has carried nobody to it, and treating it as boardable there let a tie build a change onto a train that had already left Benchmarked against 2.0.0 over the gb-transit feed published 11 September, planning Tuesday 15 September, each in its own process and repeated: 2.0.0 this build timetable 570-630ms 400-470ms planner memory 195MB 129MB 32 standard queries, mean 115-117ms 2.8-3.5ms 268 coupling through trips, mean 130ms 1.4-1.7ms 400 random station pairs, mean 171-174ms 3.9-4.0ms the same, a new date each query 177ms 5.2ms All 1,100 answers are the same journeys 2.0.0 returns, trip for trip. The API changes with it: loadTimetable and createTimetable replace loadGtfs and toGtfsData, DepartAfterQuery takes the timetable and its filters, ConnectionScanAlgorithm and JourneyFactory take the timetable and work in station numbers, and ScanResultsFactory and the connection object types go. Claude-Session: https://claude.ai/code/session_013JmX1aXaZ9tXrxkLxRhop4 --- .changeset/integer-timetable.md | 27 ++ README.md | 42 ++- src/csa/ConnectionScanAlgorithm.ts | 214 ++++++++++-- src/csa/ScanResults.ts | 106 +----- src/csa/ScanResultsFactory.ts | 18 - src/gtfs/GtfsLoader.ts | 112 ------ src/index.ts | 4 +- src/journey/Connection.ts | 23 -- src/journey/Journey.ts | 4 + src/journey/JourneyFactory.ts | 218 +++++++----- src/query/DepartAfterQuery.ts | 49 ++- src/timetable/Timetable.ts | 329 ++++++++++++++++++ test/integration.ts | 10 +- test/performance.ts | 13 +- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 246 ++++++------- test/unit/csa/ScanResults.spec.ts | 156 --------- test/unit/gtfs/GtfsLoader.spec.ts | 88 ----- test/unit/journey/Connection.spec.ts | 37 -- test/unit/journey/JourneyFactory.spec.ts | 114 ------ test/unit/query/DepartAfterQuery.spec.ts | 38 +- test/unit/timetable/Timetable.spec.ts | 125 +++++++ test/unit/util.ts | 134 +++---- 22 files changed, 1108 insertions(+), 999 deletions(-) create mode 100644 .changeset/integer-timetable.md delete mode 100644 src/csa/ScanResultsFactory.ts delete mode 100644 src/gtfs/GtfsLoader.ts delete mode 100644 src/journey/Connection.ts create mode 100644 src/timetable/Timetable.ts delete mode 100644 test/unit/csa/ScanResults.spec.ts delete mode 100644 test/unit/gtfs/GtfsLoader.spec.ts delete mode 100644 test/unit/journey/Connection.spec.ts delete mode 100644 test/unit/journey/JourneyFactory.spec.ts create mode 100644 test/unit/timetable/Timetable.spec.ts diff --git a/.changeset/integer-timetable.md b/.changeset/integer-timetable.md new file mode 100644 index 0000000..57d8566 --- /dev/null +++ b/.changeset/integer-timetable.md @@ -0,0 +1,27 @@ +--- +"connection-scan-algorithm": major +--- + +Scan an integer timetable, 30 to 100 times faster. + +The timetable numbers its stations as it is built, and a scan reads parallel typed arrays sorted by +arrival rather than an object per connection: its state is an array per station and per trip, a +scan starts at the first connection arriving after the departure time and stops once every +destination has been reached, and whether each trip runs is worked out once per date. Station codes, +trips and stop times are only looked up again for the journeys returned. + +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 2.8-3.5ms each on average rather than 115-117ms, 268 journeys through couplings +1.3-1.7ms rather than 130ms, and 400 random station pairs 3.8-4.0ms rather than 170-174ms, or 5.2ms +rather than 177ms when every query is for a different date. Building the timetable takes 390-470ms +rather than 570-630ms, and it holds 129MB rather than 195MB. All 1,100 answers are the same journeys 2.0.0 +returns, trip for trip. + +The API changes with it: + +- `loadGtfs` and `toGtfsData` become `loadTimetable` and `createTimetable`, returning a `Timetable` +- `DepartAfterQuery` takes the timetable and the filters: `new DepartAfterQuery(timetable, filters)` +- `ConnectionScanAlgorithm` and `JourneyFactory` take the timetable, a scan is given station numbers + and returns a `ScanResults` of typed arrays, and `ScanResultsFactory` is gone +- `TimetableConnection`, `Connection`, `isChangeRequired`, `TransfersByOrigin` and `GtfsData` are + gone with the objects they described; `isTransfer` is exported from the journey types diff --git a/README.md b/README.md index 8b4ddc8..3fd8234 100644 --- a/README.md +++ b/README.md @@ -37,25 +37,49 @@ Find the first results that depart after a specific time ```javascript const fs = require("fs"); -const { - loadGtfs, JourneyFactory, ConnectionScanAlgorithm, ScanResultsFactory, MultipleCriteriaFilter, DepartAfterQuery -} = require("connection-scan-algorithm"); +const { loadTimetable, MultipleCriteriaFilter, DepartAfterQuery } = require("connection-scan-algorithm"); -const gtfs = await loadGtfs(fs.createReadStream("gtfs.zip")); -// or toGtfsData(feed) if you already have a feed from @gb-transit/gtfs-loader +const timetable = await loadTimetable(fs.createReadStream("gtfs.zip")); +// or createTimetable(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 query = new DepartAfterQuery(timetable, [new MultipleCriteriaFilter()]); const results = query.plan(["TBW"], ["NRW"], new Date(), 9 * 3600); ``` +A timetable is built once and planned over as many times as you like, for any date the feed covers. + +### The timetable + +Stations are numbered as the timetable is built, and a scan works in those numbers throughout: every +connection is a row of parallel integer arrays, sorted by arrival, and what a scan finds is an +earliest arrival and the connection or footpath achieving it for each station, again by number. The +codes, trips and stop times are only looked up again for the journeys returned. + +`ConnectionScanAlgorithm` and `JourneyFactory` are exported for a caller that wants the scan's +results rather than journeys, or its own query: + +```javascript +const { ConnectionScanAlgorithm, JourneyFactory } = require("connection-scan-algorithm"); + +const csa = new ConnectionScanAlgorithm(timetable); +const [origin, destination] = ["TBW", "NRW"].map(code => timetable.stationIndex.get(code)); +const results = csa.scan(new Map([[origin, 9 * 3600]]), [destination], 20260915, 2); + +results.earliestArrivals[destination]; // seconds past midnight, or NOT_REACHED +new JourneyFactory(timetable).getJourneys(results, [destination]); +``` + +A scan starts at the first connection arriving after the departure time, and stops once every +destination has been reached earlier than the connection it is on arrives. Whether each trip runs is +worked out once per date, asking each distinct calendar rather than each trip. + ### 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. +queries are made with and journeys are returned in. `timetable.stations` names each station by its +number, and `timetable.stationIndex` numbers each code. 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 diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 4d8e2af..72836e0 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -1,71 +1,219 @@ -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 { ScanResults } from "./ScanResults.js"; -import type { ScanResultsFactory } from "./ScanResultsFactory.js"; +import type { DateNumber, DayOfWeek, Time } from "@gb-transit/gtfs-loader"; +import { NOT_REACHED, type StopIdx, type Timetable } from "../timetable/Timetable.js"; +import { NOT_ARRIVED, type ScanResults } from "./ScanResults.js"; + +/** The trip has carried the passenger to none of its calls */ +const NOT_CARRIED = 0x7fffffff; /** * Implementation of the connection scan algorithm. */ export class ConnectionScanAlgorithm { + /** Per trip, the earliest of its calls it has carried the passenger to. Reused across scans */ + private readonly carriedTo: Int32Array; + private readonly isDestination: Uint8Array; + private calendarDate: DateNumber = -1; + private readonly runs: Uint8Array; constructor( - private readonly connections: TimetableConnection[], - private readonly transfers: TransfersByOrigin, - private readonly resultsFactory: ScanResultsFactory - ) {} + private readonly timetable: Timetable + ) { + this.carriedTo = new Int32Array(timetable.trips.length); + this.isDestination = new Uint8Array(timetable.stations.length); + this.runs = new Uint8Array(timetable.trips.length); + } /** - * Return an index of connections that achieve the earliest arrival time at each stop. + * Scan for the earliest arrival at every station, and the connection or footpath that achieves it. + * + * The results are the scan's own, so a later scan does not change them. */ public scan( origins: OriginDepartureTimes, - destinations: StopID[], + destinations: StopIdx[], date: DateNumber, dow: DayOfWeek - ): ConnectionIndex { - const results = this.resultsFactory.create({ ...origins }); + ): ScanResults { + const { stations, connections, interchange } = this.timetable; + const { departureStation, arrivalStation, departureTime, arrivalTime, trip, board, alight } = connections; + const results: ScanResults = { + earliestArrivals: new Int32Array(stations.length).fill(NOT_REACHED), + arrivedBy: new Int32Array(stations.length).fill(NOT_ARRIVED) + }; + const { earliestArrivals, arrivedBy } = results; + const runs = this.calendar(date, dow); + const carriedTo = this.carriedTo.fill(NOT_CARRIED); + let departure = NOT_REACHED; + + for (const [origin, time] of origins) { + earliestArrivals[origin] = time; + departure = Math.min(departure, time); + } + + for (const destination of destinations) { + this.isDestination[destination] = 1; + } + + let target = this.latestArrival(earliestArrivals, destinations); - for (const origin in origins) { - this.scanTransfers(results, origin); + for (const origin of origins.keys()) { + target = this.scanTransfers(results, origin, destinations, target); } - for (const c of this.connections) { - if (c.trip.service.runsOn(date, dow) && results.isReachable(c) && results.isBetter(c)) { - if (results.setConnection(c)) { - this.scanTransfers(results, c.destination); - } - if (results.isFinished(destinations, c.departureTime)) { - break; - } + // nothing arriving before the earliest departure can have been boarded after it + for (let c = firstArrivingAt(arrivalTime, departure); c < connections.length; c++) { + // every destination has been reached before this connection arrives, so neither it nor any + // after it can arrive sooner, or at the same time on the trip the passenger is aboard + if (arrivalTime[c] > target) { + break; + } + + const t = trip[c]; + + if (runs[t] === 0) { + continue; + } + + const origin = departureStation[c]; + const change = arrivedBy[origin] === NOT_ARRIVED ? 0 : interchange[origin]; + // once the trip has carried the passenger to a call, they are still aboard for any of its + // connections from there on. Being boarded at a call is not the same: a trip only picking up + // at a later call has carried nobody to it + const aboard = carriedTo[t] <= board[c]; + + if (!aboard && earliestArrivals[origin] + change > departureTime[c]) { + continue; + } + + if (alight[c] < carriedTo[t]) { + carriedTo[t] = alight[c]; + } + + const destination = arrivalStation[c]; + const previous = earliestArrivals[destination]; + + if (arrivalTime[c] < previous) { + earliestArrivals[destination] = arrivalTime[c]; + arrivedBy[destination] = c; + target = this.scanTransfers(results, destination, destinations, target); } + // 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 + else if (arrivalTime[c] === previous && aboard && arrivedOnAnotherTrip(results, destination, t, trip)) { + arrivedBy[destination] = c; + } + } + + for (const destination of destinations) { + this.isDestination[destination] = 0; } - return results.getConnectionIndex(); + return results; } /** * 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. + * + * A footpath is charged the interchange time of the station it leaves, and boarding at the + * station it reaches charges that one's. + * + * Returns the latest arrival at any destination, which a footpath reaching one may have moved. */ - 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, destinations: StopIdx[], target: Time): Time { + const { offsets, destination, duration } = this.timetable.transfers; + const { earliestArrivals, arrivedBy } = results; + const leave = earliestArrivals[origin] + this.timetable.interchange[origin]; + + if (this.isDestination[origin] === 1) { + target = this.latestArrival(earliestArrivals, destinations); + } + + for (let i = offsets[origin]; i < offsets[origin + 1]; i++) { + const to = destination[i]; + const arrival = leave + duration[i]; + + if (arrival < earliestArrivals[to]) { + earliestArrivals[to] = arrival; + arrivedBy[to] = transferArrival(i); + target = this.scanTransfers(results, to, destinations, target); } } + + return target; + } + + private latestArrival(earliestArrivals: Int32Array, destinations: StopIdx[]): Time { + let latest = destinations.length === 0 ? -1 : 0; + + for (const destination of destinations) { + latest = Math.max(latest, earliestArrivals[destination]); + } + + return latest; + } + + /** + * Whether each trip runs on the date. Every distinct calendar is asked once, and the answer kept + * until a scan is made for another date. + */ + private calendar(date: DateNumber, dow: DayOfWeek): Uint8Array { + if (this.calendarDate !== date) { + const { services, tripService } = this.timetable; + const serviceRuns = services.map(service => service.runsOn(date, dow) ? 1 : 0); + + for (let t = 0; t < tripService.length; t++) { + this.runs[t] = serviceRuns[tripService[t]]; + } + + this.calendarDate = date; + } + + return this.runs; } } /** - * Index of connections that achieve the earliest arrivalTime time at each stop. + * The first connection arriving at or after the time, by binary search of the sorted arrivals. */ -export type ConnectionIndex = Record; +function firstArrivingAt(arrivalTime: Int32Array, time: Time): number { + let low = 0; + let high = arrivalTime.length; + + while (low < high) { + const middle = (low + high) >>> 1; + + if (arrivalTime[middle] < time) { + low = middle + 1; + } + else { + high = middle; + } + } + + return low; +} + +function arrivedOnAnotherTrip(results: ScanResults, station: StopIdx, t: number, trip: Int32Array): boolean { + const current = results.arrivedBy[station]; + + return current !== NOT_ARRIVED && (isTransferArrival(current) || trip[current] !== t); +} + +/** + * A footpath is recorded as a negative number, so that one array can say how every station was + * reached: a connection by its index, a footpath by its index below -1. + */ +export const transferArrival = (transfer: number): number => -2 - transfer; + +export const isTransferArrival = (arrivedBy: number): boolean => arrivedBy <= -2; + +export const transferIndex = (arrivedBy: number): number => -2 - arrivedBy; /** - * Index of departure stations and their departure time + * The departure time from each origin station */ -export type OriginDepartureTimes = Record; +export type OriginDepartureTimes = Map; diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index 87e80c5..fc47f0a 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -1,103 +1,15 @@ -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 { ConnectionIndex, OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; +/** A station reached by nothing: an origin, or one not reached at all */ +export const NOT_ARRIVED = -1; /** - * Mutable object that stores the current earliest arrival and best connection indexes as the - * connections are being scanned. + * What a scan found, by station index. */ -export class ScanResults { - private readonly connectionIndex: ConnectionIndex = {}; - private readonly tripArrivals: Record> = {}; - - constructor( - private readonly interchange: Interchange, - private readonly earliestArrivals: OriginDepartureTimes - ) {} - - public isReachable(connection: TimetableConnection): boolean { - const reachable = this.isReachableWithChange(connection) || this.isReachableFromSameService(connection); - - if (reachable) { - this.tripArrivals[connection.trip.tripId] ??= {}; - this.tripArrivals[connection.trip.tripId][connection.destination] = connection.arrivalTime; - } - - 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 isReachableWithChange(connection: TimetableConnection): boolean { - const interchange = this.connectionIndex[connection.origin] ? this.getInterchange(connection.origin) : 0; - - return Object.hasOwn(this.earliestArrivals, connection.origin) - && this.earliestArrivals[connection.origin] + interchange <= connection.departureTime; - } - - public isBetter(connection: TimetableConnection): boolean { - const arrivalTime = this.earliestArrivals[connection.destination]; - - return arrivalTime === undefined - || arrivalTime > connection.arrivalTime - || (arrivalTime === connection.arrivalTime && this.staysAboard(connection)); - } - +export interface ScanResults { + /** The earliest arrival at each station, NOT_REACHED where it was not reached */ + earliestArrivals: Int32Array; /** - * 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. + * How each station was reached: the index of the connection arriving there, a footpath as + * `transferArrival` records it, or NOT_ARRIVED */ - private staysAboard(connection: TimetableConnection): boolean { - const current = this.connectionIndex[connection.destination]; - - return current !== undefined - && isChangeRequired(current, connection) - && this.isReachableFromSameService(connection); - } - - /** - * 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; - - return previous === undefined || previous > connection.arrivalTime; - } - - public isTransferBetter(transfer: Transfer): boolean { - return !Object.hasOwn(this.earliestArrivals, transfer.destination) - || this.earliestArrivals[transfer.destination] > this.getTransferArrivalTime(transfer); - } - - public setTransfer(transfer: Transfer): void { - this.earliestArrivals[transfer.destination] = this.getTransferArrivalTime(transfer); - this.connectionIndex[transfer.destination] = transfer; - } - - private getTransferArrivalTime(transfer: Transfer): Time { - return this.earliestArrivals[transfer.origin] + transfer.duration + this.getInterchange(transfer.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; - } - - public getConnectionIndex(): ConnectionIndex { - return this.connectionIndex; - } - - public isFinished(destinations: StopID[], departureTime: Time): boolean { - return !destinations.some(d => !this.earliestArrivals[d] || departureTime < this.earliestArrivals[d]); - } + arrivedBy: Int32Array; } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts deleted file mode 100644 index cd8735c..0000000 --- a/src/csa/ScanResultsFactory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Interchange } from "@gb-transit/gtfs-loader"; -import type { OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; -import { ScanResults } from "./ScanResults.js"; - -/** - * Creates a new ScanResults object for a given set of origins - */ -export class ScanResultsFactory { - - constructor( - private readonly interchange: Interchange - ) { } - - public create(origins: OriginDepartureTimes): ScanResults { - return new ScanResults(this.interchange, origins); - } - -} diff --git a/src/gtfs/GtfsLoader.ts b/src/gtfs/GtfsLoader.ts deleted file mode 100644 index 4c44f14..0000000 --- a/src/gtfs/GtfsLoader.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - type GTFSFeed, type GTFSSource, type Interchange, loadGTFS, normalise, type StopID, type StopIndex, - type StopTime, type Trip -} from "@gb-transit/gtfs-loader"; -import type { TimetableConnection } from "../journey/Connection.js"; -import type { Transfer } from "../journey/Journey.js"; - -/** - * Returns connections, transfers and interchange times from a GTFS zip. - */ -export async function loadGtfs(source: GTFSSource): Promise { - return toGtfsData(await loadGTFS(source)); -} - -/** - * Puts a feed into the terms the connection scan works in. - * - * `normalise` resolves stops to the station they belong to, defines footpaths and interchange times - * at those stations, picks out the calls a passenger can actually use, and adds a trip for each - * coupling so that staying on a vehicle that carries on as another service is one trip rather than - * a change. What is left here is turning each trip into connections between stations, sorted as the - * scan reads them. - */ -export function toGtfsData(feed: GTFSFeed): GtfsData { - const { trips, calls, transfers, interchange, stations } = normalise(feed); - const connections: TimetableConnection[] = []; - - for (let t = 0; t < trips.length; t++) { - addConnections(connections, trips[t], calls[t], stations); - } - - connections.sort((a, b) => a.arrivalTime - b.arrivalTime); - - return { - connections, - transfers: indexTransfersByOrigin(transfers), - interchange, - stops: feed.stops, - stations - }; -} - -/** - * Go through the calls adding connections until at least one pick up and set down point has been - * passed. A stopping pattern A(p/d) -> B(d) -> C(p/d) would otherwise create A->B but never reach C, - * so this gives A->B and A->C. - */ -function addConnections( - connections: TimetableConnection[], - trip: Trip, - calls: StopTime[], - stations: Map -): void { - const station = calls.map(c => stations.get(c.stop) ?? c.stop); - - for (let i = 0; i < calls.length - 1; i++) { - if (calls[i].pickUp) { - for (let j = i + 1; j < calls.length; j++) { - if (calls[j].dropOff) { - // two calls at one station are a stop and a start, not a journey between places - if (station[i] !== station[j]) { - connections.push({ - origin: station[i], - destination: station[j], - departureTime: calls[i].departureTime, - arrivalTime: calls[j].arrivalTime, - trip - }); - } - - if (calls[j].pickUp) { - break; - } - } - } - } - } -} - -/** - * `normalise` returns footpaths as a flat list, the scan asks for them by origin. - */ -function indexTransfersByOrigin(transfers: Transfer[]): TransfersByOrigin { - const index: TransfersByOrigin = {}; - - for (const transfer of transfers) { - index[transfer.origin] ??= []; - index[transfer.origin].push(transfer); - } - - return index; -} - -/** - * Transfers indexed by origin station - */ -export type TransfersByOrigin = Record; - -/** - * Contents of the GTFS zip file - */ -export type GtfsData = { - /** every connection between two stations, sorted by arrival time */ - connections: TimetableConnection[], - transfers: TransfersByOrigin, - /** interchange time at each station */ - interchange: Interchange, - /** the feed's stops, as it gave them, which may identify individual platforms */ - stops: StopIndex, - /** feed stop id to the station it belongs to, which is what journeys are planned between */ - stations: Map -}; diff --git a/src/index.ts b/src/index.ts index aca425b..0d558ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,9 @@ export type { export * from "./csa/ConnectionScanAlgorithm.js"; export * from "./csa/ScanResults.js"; -export * from "./csa/ScanResultsFactory.js"; -export * from "./gtfs/GtfsLoader.js"; +export * from "./timetable/Timetable.js"; -export * from "./journey/Connection.js"; export * from "./journey/Journey.js"; export * from "./journey/JourneyFactory.js"; diff --git a/src/journey/Connection.ts b/src/journey/Connection.ts deleted file mode 100644 index 9e275cd..0000000 --- a/src/journey/Connection.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { StopID, Time, Trip } from "@gb-transit/gtfs-loader"; -import type { AnyLeg, Transfer } from "./Journey.js"; - -export type Connection = TimetableConnection | Transfer; - -/** - * A trip's journey between two stations, boarded at one call and alighted at a later one. - */ -export interface TimetableConnection { - origin: StopID; - destination: StopID; - departureTime: Time; - arrivalTime: Time; - trip: Trip; -} - -export function isTransfer(connection: Connection | AnyLeg): connection is Transfer { - return "duration" in connection; -} - -export function isChangeRequired(a: Connection, b: Connection): boolean { - return isTransfer(a) || isTransfer(b) || a.trip.tripId !== b.trip.tripId; -} diff --git a/src/journey/Journey.ts b/src/journey/Journey.ts index 06c4870..8bcdc6e 100644 --- a/src/journey/Journey.ts +++ b/src/journey/Journey.ts @@ -41,6 +41,10 @@ export interface Transfer extends Leg { endTime: Time; } +export function isTransfer(leg: AnyLeg): leg is Transfer { + return "duration" in leg; +} + export function journeyToString(j: Journey): string { return `${toTime(j.departureTime)}, ${toTime(j.arrivalTime)}, ${[j.legs[0].origin, ...j.legs.map(l => l.destination)].join("-")}`; } diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index b493b97..0f14ca1 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -1,129 +1,189 @@ -import { isCall, type StopID, type StopTime, type Time, type Trip } from "@gb-transit/gtfs-loader"; -import type { ConnectionIndex } from "../csa/ConnectionScanAlgorithm.js"; -import { type Connection, isChangeRequired, isTransfer } from "./Connection.js"; -import type { AnyLeg, Journey } from "./Journey.js"; +import type { Time } from "@gb-transit/gtfs-loader"; +import { isTransferArrival, transferIndex } from "../csa/ConnectionScanAlgorithm.js"; +import { NOT_ARRIVED, type ScanResults } from "../csa/ScanResults.js"; +import type { StopIdx, Timetable } from "../timetable/Timetable.js"; +import { type AnyLeg, isTransfer, type Journey } from "./Journey.js"; /** - * Creates journeys from the connection index created by the connection scan algorithm. + * A leg while it is still in the timetable's terms: a footpath by its index, or a trip between two of + * its calls. + */ +type Part = FootpathPart | TripPart; + +interface FootpathPart { + transfer: number; +} + +interface TripPart { + trip: number; + /** Index into the timetable's calls of the call boarded at */ + start: number; + /** Index into the timetable's calls of the call alighted at */ + end: number; + origin: StopIdx; + destination: StopIdx; +} + +const isFootpath = (part: Part): part is FootpathPart => "transfer" in part; + +/** + * Creates journeys from the results of a connection scan. */ export class JourneyFactory { - /** - * The stations are the feed's stop ids to the station each belongs to, as `GtfsData` gives them. - * Connections run between stations while a trip's stop times name platforms, so a leg is cut from - * its trip by asking which station each call is at. - */ constructor( - private readonly stations: Map + private readonly timetable: Timetable ) {} /** * Extract a result for each destination in the list. */ - public getJourneys(connections: ConnectionIndex, destinations: StopID[]): Journey[] { - return destinations - .map(d => this.getLegs(connections, d)) - .filter((c): c is AnyLeg[] => c !== null) - .map(c => this.getCompactedLegs(c)) - .map(l => this.getJourney(l)); - } - - /** - * Iterate backwards from the destination to the origin collecting connections into legs - */ - private getLegs(connections: ConnectionIndex, destination: StopID): AnyLeg[] | null { - const legs: Connection[][] = []; - let legConnections: Connection[] = []; - let previousConnection: Connection | null = null; + public getJourneys(results: ScanResults, destinations: StopIdx[]): Journey[] { + const journeys: Journey[] = []; - while (connections[destination]) { - const connection = connections[destination]; + for (const destination of destinations) { + const parts = this.getParts(results, destination); - if (previousConnection && isChangeRequired(previousConnection, connection)) { - legs.push(legConnections.reverse()); - legConnections = []; + if (parts !== null) { + journeys.push(this.getJourney(this.getCompactedParts(parts).map(part => this.toLeg(part)))); } - - legConnections.push(connection); - previousConnection = connection; - destination = connection.origin; } - legs.push(legConnections.reverse()); - - return legConnections.length === 0 ? null : legs.reverse().map(cs => this.toLeg(cs)); + return journeys; } /** - * Convert a list of connections into a Transfer or a TimetableLeg + * Iterate backwards from the destination to the origin, collecting consecutive connections of one + * trip into a single part */ - private toLeg(cs: Connection[]): AnyLeg { - const firstConnection = cs[0]; + private getParts(results: ScanResults, destination: StopIdx): Part[] | null { + const { connections, transfers } = this.timetable; + const parts: Part[] = []; + let station = destination; - if (isTransfer(firstConnection)) { - return firstConnection; - } - else { - const origin = firstConnection.origin; - const destination = cs[cs.length - 1].destination; - const trip = firstConnection.trip; - const stopTimes = this.getStopTimes(trip, origin, firstConnection.departureTime, destination); + while (results.arrivedBy[station] !== NOT_ARRIVED) { + const arrivedBy = results.arrivedBy[station]; + + if (isTransferArrival(arrivedBy)) { + const transfer = transferIndex(arrivedBy); - return { origin, destination, trip, stopTimes: stopTimes || [] }; + parts.push({ transfer }); + station = transfers.origin[transfer]; + } + else { + const previous = parts[parts.length - 1]; + const trip = connections.trip[arrivedBy]; + + station = connections.departureStation[arrivedBy]; + + if (previous !== undefined && !isFootpath(previous) && previous.trip === trip) { + previous.start = connections.board[arrivedBy]; + previous.origin = station; + } + else { + parts.push({ + trip, + start: connections.board[arrivedBy], + end: connections.alight[arrivedBy], + origin: station, + destination: connections.arrivalStation[arrivedBy] + }); + } + } } + + return parts.length === 0 ? null : parts.reverse(); } /** - * Check for any redundant legs and replace them with new legs from the trip. + * Check for any redundant parts and replace them with the trip of a later one, boarded earlier. */ - private getCompactedLegs(legs: AnyLeg[]): AnyLeg[] { - const newLegs: AnyLeg[] = []; + private getCompactedParts(parts: Part[]): Part[] { + const { calls, transfers } = this.timetable; + const compacted: Part[] = []; - for (let i = legs.length - 1; i >= 0; i--) { - const legI = legs[i]; + for (let i = parts.length - 1; i >= 0; i--) { + const partI = parts[i]; - if (isTransfer(legI)) { - newLegs.push(legI); + if (isFootpath(partI)) { + compacted.push(partI); } else { - let lastDepartureTime = legI.stopTimes[0].departureTime; + let lastDepartureTime = calls[partI.start].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); + const partJ = parts[j]; + const origin = isFootpath(partJ) ? transfers.origin[partJ.transfer] : partJ.origin; - if (stopTimes) { - legI.origin = legJ.origin; - legI.stopTimes = stopTimes; + lastDepartureTime = isFootpath(partJ) + ? lastDepartureTime - transfers.duration[partJ.transfer] + : calls[partJ.start].departureTime; + + const start = this.findStart(partI.trip, origin, lastDepartureTime); + const end = start === -1 ? -1 : this.findEnd(partI.trip, start, partI.destination); + + if (end !== -1) { + partI.origin = origin; + partI.start = start; + partI.end = end; i = j; } } - newLegs.push(legI); + compacted.push(partI); + } + } + + return compacted.reverse(); + } + + /** + * The first call of the trip at the station that can be boarded no earlier than the time + */ + private findStart(trip: number, station: StopIdx, departureTime: Time): number { + const { callOffsets, calls, callStations } = this.timetable; + + for (let k = callOffsets[trip]; k < callOffsets[trip + 1]; k++) { + if (callStations[k] === station && calls[k].pickUp && calls[k].departureTime >= departureTime) { + return k; } } - return newLegs.reverse(); + return -1; } /** - * 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 - * says which platform it uses at each end, but the points the trip only passes are left out. + * The first call of the trip after the one boarded that can be alighted at the station */ - private getStopTimes(trip: Trip, origin: StopID, departureTime: Time, destination: StopID): StopTime[] | null { - const stopTimes = trip.stopTimes; - const start = stopTimes.findIndex( - c => c.pickUp && c.departureTime >= departureTime && this.stationOf(c) === origin - ); - const end = stopTimes.findIndex((c, i) => c.dropOff && i > start && this.stationOf(c) === destination); - - return start === -1 || end === -1 ? null : stopTimes.slice(start, end + 1).filter(isCall); + private findEnd(trip: number, start: number, station: StopIdx): number { + const { callOffsets, calls, callStations } = this.timetable; + + for (let k = start + 1; k < callOffsets[trip + 1]; k++) { + if (callStations[k] === station && calls[k].dropOff) { + return k; + } + } + + return -1; } - private stationOf(stopTime: StopTime): StopID { - return this.stations.get(stopTime.stop) ?? stopTime.stop; + /** + * A part in the terms a journey is returned in. The stop times are the feed's own, so a leg between + * two stations still says which platform it uses at each end. + */ + private toLeg(part: Part): AnyLeg { + const { stations, trips, calls, transfers } = this.timetable; + + if (isFootpath(part)) { + return transfers.transfer[part.transfer]; + } + + return { + origin: stations[part.origin], + destination: stations[part.destination], + trip: trips[part.trip], + stopTimes: calls.slice(part.start, part.end + 1) + }; } private getJourney(legs: AnyLeg[]): Journey { diff --git a/src/query/DepartAfterQuery.ts b/src/query/DepartAfterQuery.ts index 2542558..a45833e 100644 --- a/src/query/DepartAfterQuery.ts +++ b/src/query/DepartAfterQuery.ts @@ -1,37 +1,66 @@ import type { DateNumber, DayOfWeek, StopID, Time } from "@gb-transit/gtfs-loader"; -import type { ConnectionScanAlgorithm, OriginDepartureTimes } from "../csa/ConnectionScanAlgorithm.js"; +import { ConnectionScanAlgorithm, type OriginDepartureTimes } from "../csa/ConnectionScanAlgorithm.js"; import type { Journey } from "../journey/Journey.js"; -import type { JourneyFactory } from "../journey/JourneyFactory.js"; +import { JourneyFactory } from "../journey/JourneyFactory.js"; +import type { StopIdx, Timetable } from "../timetable/Timetable.js"; import type { JourneyFilter } from "./JourneyFilter.js"; /** * Implementation of CSA that searches for journeys between a set of origin and destinations. */ export class DepartAfterQuery { + private readonly csa: ConnectionScanAlgorithm; + private readonly resultsFactory: JourneyFactory; constructor( - private readonly csa: ConnectionScanAlgorithm, - private readonly resultsFactory: JourneyFactory, + private readonly timetable: Timetable, private readonly filters: JourneyFilter[] = [] - ) { } + ) { + this.csa = new ConnectionScanAlgorithm(timetable); + this.resultsFactory = new JourneyFactory(timetable); + } /** * Plan a journey between the origin and destination set of stations on the given date and time */ public plan(origins: StopID[], destinations: StopID[], date: Date, time: Time): Journey[] { - const originTimes: OriginDepartureTimes = {}; + const originTimes: OriginDepartureTimes = new Map(); + + for (const origin of this.toStationIndexes(origins)) { + originTimes.set(origin, time); + } - for (const origin of origins) { - originTimes[origin] = time; + const to = this.toStationIndexes(destinations); + + if (originTimes.size === 0 || to.length === 0) { + return []; } - const results = this.csa.scan(originTimes, destinations, this.getDateNumber(date), date.getDay() as DayOfWeek); - const journeys = this.resultsFactory.getJourneys(results, destinations); + const results = this.csa.scan(originTimes, to, this.getDateNumber(date), date.getDay() as DayOfWeek); + const journeys = this.resultsFactory.getJourneys(results, to); // apply each filter to the results return this.filters.reduce((rs, filter) => filter.apply(rs), journeys); } + /** + * A station the timetable does not have is one nothing runs to or from, so it is left out rather + * than planned for + */ + private toStationIndexes(stations: StopID[]): StopIdx[] { + const indexes: StopIdx[] = []; + + for (const station of stations) { + const index = this.timetable.stationIndex.get(station); + + if (index !== undefined) { + indexes.push(index); + } + } + + return indexes; + } + // read in local time to agree with getDay(), otherwise the date and the day of week can describe different days private getDateNumber(date: Date): DateNumber { return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate(); diff --git a/src/timetable/Timetable.ts b/src/timetable/Timetable.ts new file mode 100644 index 0000000..99d3e69 --- /dev/null +++ b/src/timetable/Timetable.ts @@ -0,0 +1,329 @@ +import { + type GTFSFeed, type GTFSSource, loadGTFS, normalise, type ServiceCalendar, type StopID, type StopIndex, + type StopTime, type Transfer, type Trip +} from "@gb-transit/gtfs-loader"; + +/** + * 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; + +/** + * Dense index of a station, in the range [0, number of stations). + */ +export type StopIdx = number; + +/** + * Every connection in the timetable, sorted by arrival time, as parallel arrays so that a scan + * reads integers in order rather than chasing an object per connection. + */ +export interface Connections { + length: number; + departureStation: Int32Array; + arrivalStation: Int32Array; + departureTime: Int32Array; + arrivalTime: Int32Array; + /** Index into the timetable's trips */ + trip: Int32Array; + /** The call the connection is boarded at, as an index into the timetable's calls */ + board: Int32Array; + /** The call the connection is alighted at, as an index into the timetable's calls */ + alight: Int32Array; +} + +/** + * The footpaths out of each station. Station `s` owns `[offsets[s], offsets[s + 1])` of the rest. + */ +export interface Transfers { + /** Bounds of each station's slice. Its length is the number of stations + 1 */ + offsets: Int32Array; + origin: Int32Array; + destination: Int32Array; + duration: Int32Array; + /** Parallel to the rest, the footpath as the feed gave it, which is what a journey returns */ + transfer: Transfer[]; +} + +/** + * The feed as the connection scan reads it. + * + * Stations are numbered as they are met, and everything the scan touches is indexed by those + * numbers. The codes, the feed's trips and its stop times are kept alongside for the journeys that + * are built from a scan's results. + */ +export interface Timetable { + /** Station code of each station index */ + stations: StopID[]; + stationIndex: Map; + /** Interchange time at each station, in seconds */ + interchange: Int32Array; + transfers: Transfers; + connections: Connections; + trips: Trip[]; + /** Index into services of each trip's calendar */ + tripService: Int32Array; + /** Each distinct calendar once, since far fewer of them than trips have to be asked about a date */ + services: ServiceCalendar[]; + /** + * The calls a passenger can use, of every trip in turn. Trip `t` owns `[callOffsets[t], + * callOffsets[t + 1])` of calls and callStations. + */ + callOffsets: Int32Array; + calls: StopTime[]; + callStations: Int32Array; + /** the feed's stops, as it gave them, which may identify individual platforms */ + stops: StopIndex; +} + +/** + * Returns the timetable of a GTFS zip. + */ +export async function loadTimetable(source: GTFSSource): Promise { + return createTimetable(await loadGTFS(source)); +} + +/** + * Puts a feed into the terms the connection scan works in. + * + * `normalise` resolves stops to the station they belong to, defines footpaths and interchange times + * at those stations, picks out the calls a passenger can actually use, and adds a trip for each + * coupling so that staying on a vehicle that carries on as another service is one trip rather than + * a change. What is left here is numbering the stations and turning each trip into connections + * between them, sorted as the scan reads them. + */ +export function createTimetable(feed: GTFSFeed): Timetable { + const { trips, calls: tripCalls, transfers, interchange, stations: stationOf } = normalise(feed); + const stations: StopID[] = []; + const stationIndex = new Map(); + + const intern = (code: StopID): StopIdx => { + let index = stationIndex.get(code); + + if (index === undefined) { + index = stations.length; + stations.push(code); + stationIndex.set(code, index); + } + + return index; + }; + + const callOffsets = new Int32Array(trips.length + 1); + const calls: StopTime[] = []; + const callStationList: StopIdx[] = []; + + for (let t = 0; t < trips.length; t++) { + callOffsets[t] = calls.length; + + for (const call of tripCalls[t]) { + calls.push(call); + callStationList.push(intern(stationOf.get(call.stop) ?? call.stop)); + } + } + + callOffsets[trips.length] = calls.length; + + const callStations = Int32Array.from(callStationList); + + for (const transfer of transfers) { + intern(transfer.origin); + intern(transfer.destination); + } + + const interchangeTimes = new Int32Array(stations.length); + + for (const station of Object.keys(interchange)) { + const index = stationIndex.get(station); + + if (index !== undefined) { + interchangeTimes[index] = interchange[station]; + } + } + + const { tripService, services } = indexServices(trips); + + return { + stations, + stationIndex, + interchange: interchangeTimes, + transfers: indexTransfers(transfers, stationIndex, stations.length), + connections: sortByArrival(createConnections(trips.length, callOffsets, calls, callStations)), + trips, + tripService, + services, + callOffsets, + calls, + callStations, + stops: feed.stops + }; +} + +/** + * Go through each trip's calls adding connections until at least one pick up and set down point has + * been passed. A stopping pattern A(p/d) -> B(d) -> C(p/d) would otherwise create A->B but never + * reach C, so this gives A->B and A->C. + * + * The connections are counted before they are written, so that each array is allocated once. + */ +function createConnections( + numTrips: number, + callOffsets: Int32Array, + calls: StopTime[], + callStations: Int32Array +): Connections { + const visit = (add: (trip: number, i: number, j: number) => void): void => { + for (let t = 0; t < numTrips; t++) { + const end = callOffsets[t + 1]; + + for (let i = callOffsets[t]; i < end - 1; i++) { + if (calls[i].pickUp) { + for (let j = i + 1; j < end; j++) { + if (calls[j].dropOff) { + // two calls at one station are a stop and a start, not a journey between places + if (callStations[i] !== callStations[j]) { + add(t, i, j); + } + + if (calls[j].pickUp) { + break; + } + } + } + } + } + } + }; + + let length = 0; + visit(() => length++); + + const connections = allocateConnections(length); + let c = 0; + + visit((t, i, j) => { + connections.departureStation[c] = callStations[i]; + connections.arrivalStation[c] = callStations[j]; + connections.departureTime[c] = calls[i].departureTime; + connections.arrivalTime[c] = calls[j].arrivalTime; + connections.trip[c] = t; + connections.board[c] = i; + connections.alight[c] = j; + c++; + }); + + return connections; +} + +/** + * A counting sort on arrival time. Times are whole seconds within a day or two, so this is linear, + * and it is stable: connections arriving together keep the order they were created in. + */ +function sortByArrival(unsorted: Connections): Connections { + const { length, arrivalTime } = unsorted; + let latest = 0; + + for (let c = 0; c < length; c++) { + latest = Math.max(latest, arrivalTime[c]); + } + + const position = new Int32Array(latest + 2); + + for (let c = 0; c < length; c++) { + position[arrivalTime[c] + 1]++; + } + + for (let time = 1; time < position.length; time++) { + position[time] += position[time - 1]; + } + + const sorted = allocateConnections(length); + + for (let c = 0; c < length; c++) { + const to = position[arrivalTime[c]]++; + + sorted.departureStation[to] = unsorted.departureStation[c]; + sorted.arrivalStation[to] = unsorted.arrivalStation[c]; + sorted.departureTime[to] = unsorted.departureTime[c]; + sorted.arrivalTime[to] = arrivalTime[c]; + sorted.trip[to] = unsorted.trip[c]; + sorted.board[to] = unsorted.board[c]; + sorted.alight[to] = unsorted.alight[c]; + } + + return sorted; +} + +function allocateConnections(length: number): Connections { + return { + length, + departureStation: new Int32Array(length), + arrivalStation: new Int32Array(length), + departureTime: new Int32Array(length), + arrivalTime: new Int32Array(length), + trip: new Int32Array(length), + board: new Int32Array(length), + alight: new Int32Array(length) + }; +} + +/** + * `normalise` returns footpaths as a flat list, the scan asks for them by origin. Each station's + * footpaths keep the order the feed gave them in. + */ +function indexTransfers( + transfers: Transfer[], + stationIndex: Map, + numStations: number +): Transfers { + const offsets = new Int32Array(numStations + 1); + + for (const transfer of transfers) { + offsets[stationIndex.get(transfer.origin)! + 1]++; + } + + for (let s = 1; s <= numStations; s++) { + offsets[s] += offsets[s - 1]; + } + + const next = offsets.slice(0, numStations); + const index: Transfers = { + offsets, + origin: new Int32Array(transfers.length), + destination: new Int32Array(transfers.length), + duration: new Int32Array(transfers.length), + transfer: new Array(transfers.length) + }; + + for (const transfer of transfers) { + const origin = stationIndex.get(transfer.origin)!; + const i = next[origin]++; + + index.origin[i] = origin; + index.destination[i] = stationIndex.get(transfer.destination)!; + index.duration[i] = transfer.duration; + index.transfer[i] = transfer; + } + + return index; +} + +function indexServices(trips: Trip[]): { tripService: Int32Array, services: ServiceCalendar[] } { + const tripService = new Int32Array(trips.length); + const services: ServiceCalendar[] = []; + const serviceIndex = new Map(); + + for (let t = 0; t < trips.length; t++) { + let index = serviceIndex.get(trips[t].service); + + if (index === undefined) { + index = services.length; + services.push(trips[t].service); + serviceIndex.set(trips[t].service, index); + } + + tripService[t] = index; + } + + return { tripService, services }; +} diff --git a/test/integration.ts b/test/integration.ts index 36f6e21..1e372fe 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -1,11 +1,8 @@ import * as fs from "node:fs"; -import { ConnectionScanAlgorithm } from "../src/csa/ConnectionScanAlgorithm.js"; -import { ScanResultsFactory } from "../src/csa/ScanResultsFactory.js"; -import { loadGtfs } from "../src/gtfs/GtfsLoader.js"; import { journeyToString } from "../src/journey/Journey.js"; -import { JourneyFactory } from "../src/journey/JourneyFactory.js"; import { DepartAfterQuery } from "../src/query/DepartAfterQuery.js"; import { MultipleCriteriaFilter } from "../src/query/MultipleCriteriaFilter.js"; +import { loadTimetable } from "../src/timetable/Timetable.js"; async function run() { const filename = process.argv[2] || "gtfs.zip"; @@ -15,11 +12,10 @@ async function run() { console.log(`Loading ${filename}`); console.time("initial load"); - const gtfs = await loadGtfs(fs.createReadStream(filename)); + const timetable = await loadTimetable(fs.createReadStream(filename)); console.timeEnd("initial load"); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]); + const query = new DepartAfterQuery(timetable, [new MultipleCriteriaFilter()]); console.time("query"); const results = query.plan(origins, destinations, new Date(), time); diff --git a/test/performance.ts b/test/performance.ts index 363cf81..d54be19 100644 --- a/test/performance.ts +++ b/test/performance.ts @@ -1,9 +1,7 @@ import * as fs from "node:fs"; -import { ConnectionScanAlgorithm } from "../src/csa/ConnectionScanAlgorithm.js"; -import { ScanResultsFactory } from "../src/csa/ScanResultsFactory.js"; -import { loadGtfs } from "../src/gtfs/GtfsLoader.js"; -import { JourneyFactory } from "../src/journey/JourneyFactory.js"; +import { loadGTFS } from "@gb-transit/gtfs-loader"; import { DepartAfterQuery } from "../src/query/DepartAfterQuery.js"; +import { createTimetable } from "../src/timetable/Timetable.js"; const queries = [ [["MRF", "LVC", "LVJ", "LIV"], ["NRW"]], @@ -64,11 +62,12 @@ const queries = [ async function run() { console.time("initial load"); - const gtfs = await loadGtfs(fs.createReadStream(process.argv[2] || "gtfs.zip")); + const feed = await loadGTFS(fs.createReadStream(process.argv[2] || "gtfs.zip")); console.timeEnd("initial load"); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations)); + console.time("timetable"); + const query = new DepartAfterQuery(createTimetable(feed)); + console.timeEnd("timetable"); console.time("planning"); const date = new Date(); diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index f7e6a29..cc51788 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -1,184 +1,168 @@ +import { Service } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; -import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import { c, defaultInterchange, setStopTimes } from "../util.js"; +import { createTimetable, NOT_REACHED } from "../../../src/timetable/Timetable.js"; +import { allDays, byOrigin, feed, legsOf, pickUpOnly, plan, st, trip, walk } from "../util.js"; describe("ConnectionScanAlgorithm", () => { - const scanResultsFactory = new ScanResultsFactory(defaultInterchange); - const journeyResultsFactory = new JourneyFactory(new Map()); - const noTransfers = { A: [], B: [], C: [], D: [] }; - - it("plan a basic journey", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1020, 1045), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + it("plans a basic journey", () => { + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045), st("D", 1115)])] + }, ["A"], ["D"], 900); expect(journey.origin).toBe("A"); expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(1); + expect(legsOf(journey)).toEqual(["1:A-D"]); expect(journey.departureTime).toBe(1000); expect(journey.arrivalTime).toBe(1115); }); it("returns no results when there is no connection", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - - expect(journeyResultsFactory.getJourneys(results, ["D"])).toEqual([]); + expect(plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("C", 1100), st("D", 1115)])] + }, ["A"], ["D"], 900)).toEqual([]); }); it("returns no results when there is a missed connection", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1000, 1030), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - - expect(journeyResultsFactory.getJourneys(results, ["D"])).toEqual([]); + expect(plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1000), st("D", 1030)])] + }, ["A"], ["D"], 900)).toEqual([]); }); it("returns no results for an origin or destination the feed does not have", () => { - const timetable = [ - c("A", "B", 1000, 1015), - ]; - - setStopTimes(timetable); - - const scanner = new ConnectionScanAlgorithm(timetable, {}, scanResultsFactory); - const results = scanner.scan({ Z: 900 }, ["Y"], 20190101, 0); + const trips = [trip("1", [st("A", 1000), st("B", 1015)])]; - expect(journeyResultsFactory.getJourneys(results, ["Y"])).toEqual([]); + expect(plan({ trips }, ["Z"], ["B"], 900)).toEqual([]); + expect(plan({ trips }, ["A"], ["Y"], 900)).toEqual([]); }); - it("plan a journey that starts with a transfer", () => { - const timetable = [ - c("B", "C", 1020, 1045), - c("C", "D", 1100, 1115), - ]; - - setStopTimes(timetable); + it("does not board a trip that does not run on the date", () => { + const septemberOnly = new Service(20260901, 20260907, allDays, {}); - const transfers = { - ...noTransfers, - A: [ - { origin: "A", destination: "B", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - ] - }; + expect(plan({ trips: [trip("1", [st("A", 1000), st("B", 1015)], septemberOnly)] }, ["A"], ["B"], 900)).toEqual([]); + }); - const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + it("plans a journey that starts with a footpath", () => { + const [journey] = plan({ + trips: [trip("1", [st("B", 1020), st("C", 1045), st("D", 1115)])], + transfers: byOrigin(walk("A", "B", 10)) + }, ["A"], ["D"], 900); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(2); + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-D"]); expect(journey.departureTime).toBe(1010); expect(journey.arrivalTime).toBe(1115); }); - it("plan a journey that ends with a transfer", () => { - const timetable = [ - c("A", "B", 1000, 1015), - c("B", "C", 1020, 1045), - ]; + it("plans a journey that ends with a footpath", () => { + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045)])], + transfers: byOrigin(walk("C", "D", 10)) + }, ["A"], ["D"], 900); - setStopTimes(timetable); - - const transfers = { - ...noTransfers, - C: [ - { origin: "C", destination: "D", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - ] - }; - - const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(2); + expect(legsOf(journey)).toEqual(["1:A-C", "walk:C-D"]); expect(journey.departureTime).toBe(1000); expect(journey.arrivalTime).toBe(1055); }); it("walks on from a station reached on foot again once a train reaches it sooner", () => { - const aToB = [c("A", "B", 1000, 1100, "1")]; - const cToD = [c("C", "D", 1200, 1300, "2")]; + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1100)]), trip("2", [st("C", 1200), st("D", 1300)])], + transfers: byOrigin(walk("A", "B", 1000), walk("B", "C", 60)) + }, ["A"], ["D"], 900); - setStopTimes(aToB); - setStopTimes(cToD); + expect(legsOf(journey)).toEqual(["1:A-B", "walk:B-C", "2:C-D"]); + expect(journey.arrivalTime).toBe(1300); + }); - const transfers = { - A: [{ origin: "A", destination: "B", duration: 1000, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }], - B: [{ origin: "B", destination: "C", duration: 60, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }] - }; + it("does not change where there is less than the interchange time", () => { + const trips = [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1030), st("C", 1100)])]; - const scanner = new ConnectionScanAlgorithm([...aToB, ...cToD], transfers, new ScanResultsFactory({})); - const results = scanner.scan({ A: 900 }, ["D"], 20190101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + expect(plan({ trips, interchange: { B: 100 } }, ["A"], ["C"], 900)).toEqual([]); + }); - expect(journey.legs.map(l => `${l.origin}-${l.destination}`)).toEqual(["A-B", "B-C", "C-D"]); - expect(journey.arrivalTime).toBe(1300); + it("changes in no time at a station with no interchange time", () => { + const [journey] = plan({ + trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1015), st("C", 1100)])] + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["1:A-B", "2:B-C"]); + }); + + it("charges the interchange time at both ends of a footpath", () => { + const trips = (departs: number) => [ + trip("1", [st("A", 1000), st("B", 1015)]), + trip("2", [st("C", departs), st("D", 1700)]) + ]; + const overrides = { interchange: { B: 300, C: 200 }, transfers: byOrigin(walk("B", "C", 60)) }; + + expect(plan({ ...overrides, trips: trips(1574) }, ["A"], ["D"], 900)).toEqual([]); + expect(plan({ ...overrides, trips: trips(1575) }, ["A"], ["D"], 900).length).toBe(1); }); /** - * 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. Changing + * onto trip 2 at C is ruled out by the interchange time, but it could have been boarded at A, so the + * scan reaches D on it. The connections found are trip 1 to C and trip 2 from C, and the journey is + * tidied up by realising the whole of it can be made on trip 2. */ it("checks for connections missed because of interchange time", () => { - const trip1 = [ - c("A", "B", 1000, 1010, "1"), - c("B", "C", 1010, 1020, "1"), - c("C", "D", 1020, 1040, "1"), - ]; + const [journey] = plan({ + trips: [ + trip("1", [st("A", 1000), st("B", 1010), st("C", 1020), st("D", 1040)]), + trip("2", [st("A", 1005), st("B", 1015), st("C", 1025), st("D", 1035)]) + ], + interchange: { A: 10, B: 10, C: 10, D: 10 } + }, ["A"], ["D"], 900); + + expect(legsOf(journey)).toEqual(["2:A-D"]); + expect(journey.departureTime).toBe(1005); + expect(journey.arrivalTime).toBe(1035); + }); - setStopTimes(trip1); + /** + * Trips 1 and 2 run the same calls, both only picking up at A and B. Trip 3 reaches B before they + * leave it, but not in time to change. Being aboard trip 2 from A does not make its connection from + * B one the passenger can take, since trip 2 never carried them to B, only through it. + */ + it("does not count a passenger aboard at a call the trip only picks up at", () => { + const calls = [pickUpOnly("A", 1000), pickUpOnly("B", 1060), st("C", 2000)]; + const [journey] = plan({ + trips: [trip("1", calls), trip("2", calls), trip("3", [st("A", 1005), st("B", 1050)])], + interchange: { B: 300 } + }, ["A"], ["C"], 900); + + expect(legsOf(journey)).toEqual(["1:A-C"]); + }); - const trip2 = [ - c("A", "B", 1005, 1015, "2"), - c("B", "C", 1015, 1025, "2"), - c("C", "D", 1025, 1035, "2"), - ]; + /** + * Trip 1 carries the passenger to C and on to D. Trip 2 leaves C for D at the same time, and is + * scanned first, so it reaches D first. Trip 3 hops between two other stations in no time at the + * moment both arrive, between the two in the scan, and must not end it before trip 1 is preferred. + */ + it("keeps scanning for a trip to stay aboard past a connection that takes no time", () => { + const [journey] = plan({ + trips: [ + trip("2", [st("C", 1000), st("D", 1060)]), + trip("3", [st("E", 1060), st("F", 1060)]), + trip("1", [st("A", 900), st("C", 1000), st("D", 1060)]) + ] + }, ["A"], ["D"], 800); - setStopTimes(trip2); + expect(legsOf(journey)).toEqual(["1:A-D"]); + }); - const timetable = [...trip1, ...trip2].sort((a, b) => a.arrivalTime - b.arrivalTime); + it("gives each scan results of its own", () => { + const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] })); + const [a, b, c] = ["A", "B", "C"].map(code => timetable.stationIndex.get(code)!); + const csa = new ConnectionScanAlgorithm(timetable); - const resultsFactory = new ScanResultsFactory({ A: 10, B: 10, C: 10, D: 10 }); - const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, resultsFactory); - const results = scanner.scan({ A: 900 }, ["D"], 20200101, 0); - const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); + const fromA = csa.scan(new Map([[a, 900]]), [c], 20260908, 2); + const fromB = csa.scan(new Map([[b, 900]]), [c], 20260908, 2); - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.legs.length).toBe(1); - expect(journey.departureTime).toBe(1005); - expect(journey.arrivalTime).toBe(1035); + expect(fromA.earliestArrivals[b]).toBe(1100); + expect(fromB.earliestArrivals[a]).toBe(NOT_REACHED); + expect(fromB.earliestArrivals[c]).toBe(1200); }); }); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts deleted file mode 100644 index b294e32..0000000 --- a/test/unit/csa/ScanResults.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ScanResults } from "../../../src/csa/ScanResults.js"; -import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { c, defaultInterchange, t } from "../util.js"; - -describe("ScanResults", () => { - - it("knows if a connection is reachable", () => { - const connection = c("A", "B", 1000, 1015); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - expect(results.isReachable(connection)).toBe(true); - }); - - it("knows if a connection is not reachable", () => { - const connection = c("A", "B", 1000, 1015); - const results = new ScanResults(defaultInterchange, { A: 1200 }); - - expect(results.isReachable(connection)).toBe(false); - }); - - it("knows if a connection is not reachable because of interchange", () => { - const connection1 = c("A", "B", 1000, 1015, "LN1111"); - const connection2 = c("B", "C", 1030, 1100, "LN1112"); - const results = new ScanResults({ B: 100 }, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isReachable(connection2)).toBe(false); - }); - - it("changes in no time at a station with no interchange time", () => { - const connection1 = c("A", "B", 1000, 1015, "LN1111"); - const connection2 = c("B", "C", 1015, 1100, "LN1112"); - const results = new ScanResults({}, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isReachable(connection2)).toBe(true); - }); - - it("knows if a connection is better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = c("A", "B", 1000, 1010); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isBetter(connection2)).toBe(true); - }); - - it("knows if a connection is not better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = c("A", "B", 1000, 1030); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isBetter(connection2)).toBe(false); - }); - - it("prefers staying aboard to changing onto a trip arriving at the same time", () => { - const toB = c("A", "B", 1000, 1015, "front"); - const rearToC = c("B", "C", 1030, 1100, "rear"); - const throughToB = c("A", "B", 1000, 1015, "through"); - const throughToC = c("B", "C", 1030, 1100, "through"); - const results = new ScanResults({ B: 5 }, { A: 900 }); - - for (const connection of [toB, throughToB, rearToC]) { - if (results.isReachable(connection) && results.isBetter(connection)) { - results.setConnection(connection); - } - } - - expect(results.isReachable(throughToC)).toBe(true); - expect(results.isBetter(throughToC)).toBe(true); - }); - - it("does not replace a trip with another arriving at the same time that it would have to change onto", () => { - const toB = c("A", "B", 1000, 1015, "1"); - const toC = c("B", "C", 1030, 1100, "2"); - const alsoToC = c("B", "C", 1030, 1100, "3"); - const results = new ScanResults({ B: 5 }, { A: 900 }); - - results.setConnection(toB); - results.setConnection(toC); - - expect(results.isReachable(alsoToC)).toBe(true); - expect(results.isBetter(alsoToC)).toBe(false); - }); - - it("knows if a transfer is better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("A", "B", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isTransferBetter(connection2)).toBe(true); - }); - - it("knows if a transfer is not better", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("A", "B", 1000); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setConnection(connection1); - - expect(results.isTransferBetter(connection2)).toBe(false); - }); - - it("charges the interchange time at both ends of a walk", () => { - const results = new ScanResults({ B: 300, C: 200 }, { A: 900 }); - - results.setConnection(c("A", "B", 1000, 1015)); - results.setTransfer(t("B", "C", 60)); - - expect(results.isReachable(c("C", "D", 1574, 1600, "2"))).toBe(false); - expect(results.isReachable(c("C", "D", 1575, 1600, "3"))).toBe(true); - }); - - it("knows if a transfer is better than a transfer", () => { - const connection1 = t("A", "B", 20); - const connection2 = t("A", "B", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setTransfer(connection1); - - expect(results.isTransferBetter(connection2)).toBe(true); - }); - - it("returns the connection index", () => { - const connection1 = c("A", "B", 1000, 1015); - const connection2 = t("B", "C", 10); - const results = new ScanResults(defaultInterchange, { A: 900 }); - - results.setConnection(connection1); - results.setTransfer(connection2); - - const actual = results.getConnectionIndex(); - - expect(actual.B).toEqual(connection1); - expect(actual.C).toEqual(connection2); - }); - -}); - -describe("ScanResultsFactory", () => { - - it("creates a ScanResults object", () => { - const factory = new ScanResultsFactory({}); - - expect(factory.create({ A: 900 })).toBeInstanceOf(ScanResults); - }); - -}); diff --git a/test/unit/gtfs/GtfsLoader.spec.ts b/test/unit/gtfs/GtfsLoader.spec.ts deleted file mode 100644 index 2d18426..0000000 --- a/test/unit/gtfs/GtfsLoader.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Trip } from "@gb-transit/gtfs-loader"; -import { describe, expect, it } from "vitest"; -import { toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; -import { everyDay, feed, st } from "../util.js"; - -function trip(tripId: string, ...stopTimes: Trip["stopTimes"]): Trip { - return { tripId, serviceId: "1", service: everyDay, stopTimes }; -} - -function pairs(trips: Trip[], links = feed().links) { - return toGtfsData(feed({ trips, links })).connections.map(c => `${c.origin}-${c.destination}`); -} - -describe("toGtfsData", () => { - - it("creates connections between the stations the platforms belong to", () => { - const gtfs = toGtfsData(feed({ - trips: [trip("1", st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200))] - })); - - expect(gtfs.connections.map(c => [c.origin, c.destination, c.departureTime, c.arrivalTime])).toEqual([ - ["NRW", "DIS", 1000, 1100], - ["DIS", "LST", 1100, 1200] - ]); - expect(gtfs.stations.get("NRW1")).toBe("NRW"); - }); - - it("sorts the connections by arrival time", () => { - const gtfs = toGtfsData(feed({ - trips: [ - trip("1", st("NRW1", 1000), st("LST8", 1300)), - trip("2", st("DIS2", 1100), st("IPS1", 1200)) - ] - })); - - expect(gtfs.connections.map(c => c.arrivalTime)).toEqual([1200, 1300]); - }); - - it("carries on past a call that can only be alighted at until one that can be boarded", () => { - const setDownOnly = { ...st("DIS2", 1100), pickUp: false }; - - expect(pairs([trip("1", st("NRW1", 1000), setDownOnly, st("LST8", 1200))])).toEqual(["NRW-DIS", "NRW-LST"]); - }); - - it("does not create a connection to or from a passing point", () => { - const passing = { ...st("DIS2", 1100), pickUp: false, dropOff: false }; - - expect(pairs([trip("1", st("NRW1", 1000), passing, st("LST8", 1200))])).toEqual(["NRW-LST"]); - }); - - it("does not create a connection between two platforms of one station", () => { - expect(pairs([trip("1", st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200))])).toEqual(["NRW-LST"]); - }); - - it("adds the trip a passenger stays on across a coupling", () => { - const connections = toGtfsData(feed({ - trips: [trip("front", st("NRW1", 1000), st("DIS2", 1100)), trip("rear", st("DIS1", 1130), st("LST8", 1230))], - links: [{ fromTripId: "front", toTripId: "rear", fromStop: "DIS2", toStop: "DIS1" }] - })).connections; - - expect(connections.map(c => `${c.trip.tripId}:${c.origin}-${c.destination}`)).toEqual([ - "front:NRW-DIS", - "front_rear:NRW-DIS", - "rear:DIS-LST", - "front_rear:DIS-LST" - ]); - }); - - it("indexes footpaths by the origin station and drops those within one", () => { - const gtfs = toGtfsData(feed({ - transfers: { - NRW1: [ - { origin: "NRW1", destination: "NRW2", duration: 300, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, - { origin: "NRW1", destination: "DIS2", duration: 600, startTime: 0, endTime: Number.MAX_SAFE_INTEGER } - ] - } - })); - - expect(gtfs.transfers.NRW.map(t => [t.destination, t.duration])).toEqual([["DIS", 600]]); - }); - - it("reports interchange time against the station", () => { - const gtfs = toGtfsData(feed({ interchange: { NRW1: 300 } })); - - expect(gtfs.interchange.NRW).toBe(300); - }); - -}); diff --git a/test/unit/journey/Connection.spec.ts b/test/unit/journey/Connection.spec.ts deleted file mode 100644 index 99e9ea9..0000000 --- a/test/unit/journey/Connection.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isChangeRequired, isTransfer } from "../../../src/journey/Connection.js"; -import { c, t } from "../util.js"; - -describe("Connection", () => { - - it("knows if it's a transfer", () => { - expect(isTransfer(t("A", "B", 10))).toBe(true); - }); - - it("knows if it's not a transfer", () => { - expect(isTransfer(c("A", "B", 1000, 1030))).toBe(false); - }); - - it("knows if a change is required", () => { - const timetableConnection1 = c("A", "B", 1000, 1030); - const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); - - expect(isChangeRequired(timetableConnection1, timetableConnection2)).toBe(true); - }); - - it("knows if a change is not required", () => { - const timetableConnection1 = c("A", "B", 1000, 1030, "LN1112"); - const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); - - expect(isChangeRequired(timetableConnection1, timetableConnection2)).toBe(false); - }); - - it("knows if a change is required between a transfer", () => { - const timetableConnection = c("A", "B", 1000, 1030, "LN1112"); - const transfer = t("A", "B", 1000); - - expect(isChangeRequired(timetableConnection, transfer)).toBe(true); - expect(isChangeRequired(transfer, timetableConnection)).toBe(true); - }); - -}); diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts deleted file mode 100644 index b3806e6..0000000 --- a/test/unit/journey/JourneyFactory.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ScanResults } from "../../../src/csa/ScanResults.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; -import type { TimetableLeg } from "../../../src/journey/Journey.js"; -import { c, setStopTimes, st, t } from "../util.js"; - -describe("JourneyFactory", () => { - const factory = new JourneyFactory(new Map()); - - it("creates a journey from a connection index", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("A", "B", 1000, 1030) - ]; - - setStopTimes(connections); - - for (const connection of connections) { - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["B"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("B"); - expect(journey.departureTime).toBe(1000); - expect(journey.arrivalTime).toBe(1030); - }); - - it("calculates the departure time", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("B", "C", 1100, 1130) - ]; - - setStopTimes(connections); - results.setTransfer(t("A", "B", 60)); - - for (const connection of connections) { - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["C"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("C"); - expect(journey.departureTime).toBe(1040); - expect(journey.arrivalTime).toBe(1130); - }); - - it("calculates the arrival time", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("B", "C", 1100, 1130) - ]; - - setStopTimes(connections); - results.setTransfer(t("A", "B", 60)); - results.setTransfer(t("C", "D", 60)); - - for (const connection of connections) { - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["D"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("D"); - expect(journey.departureTime).toBe(1040); - expect(journey.arrivalTime).toBe(1190); - }); - - it("removes pointless legs", () => { - const results = new ScanResults({ A: 1000 }, {}); - const connections = [ - c("A", "B", 1000, 1010, "LN1111"), - c("B", "C", 1010, 1020, "LN1112"), - c("C", "D", 1020, 1030, "LN1113"), - c("D", "E", 1030, 1040, "LN1114") - ]; - const stopTimes = [st("A", 1000), st("B", 1010), st("C", 1020), st("D", 1030), st("E", 1040)]; - - for (const connection of connections) { - connection.trip.stopTimes = stopTimes; - results.setConnection(connection); - } - - const [journey] = factory.getJourneys(results.getConnectionIndex(), ["E"]); - - expect(journey.origin).toBe("A"); - expect(journey.destination).toBe("E"); - expect(journey.departureTime).toBe(1000); - expect(journey.arrivalTime).toBe(1040); - expect(journey.legs.length).toBe(1); - }); - - it("cuts a leg from the trip by the station each platform belongs to, leaving out passing points", () => { - const stations = new Map([["NRW1", "NRW"], ["DIS2", "DIS"], ["LST8", "LST"]]); - const results = new ScanResults({}, { NRW: 900 }); - const connection = c("NRW", "LST", 1000, 1200); - const passing = { ...st("DIS2", 1100), pickUp: false, dropOff: false }; - - connection.trip.stopTimes = [st("NRW1", 1000), passing, st("LST8", 1200)]; - results.setConnection(connection); - - const [journey] = new JourneyFactory(stations).getJourneys(results.getConnectionIndex(), ["LST"]); - const leg = journey.legs[0] as TimetableLeg; - - expect(leg.origin).toBe("NRW"); - expect(leg.destination).toBe("LST"); - expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "LST8"]); - }); - -}); diff --git a/test/unit/query/DepartAfterQuery.spec.ts b/test/unit/query/DepartAfterQuery.spec.ts index 5fd83f6..716cbad 100644 --- a/test/unit/query/DepartAfterQuery.spec.ts +++ b/test/unit/query/DepartAfterQuery.spec.ts @@ -1,25 +1,13 @@ -import { type GTFSFeed, Service, type ServiceCalendar, type StopTime, type Trip } from "@gb-transit/gtfs-loader"; +import { type GTFSFeed, Service, type ServiceCalendar } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; -import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; -import { ScanResultsFactory } from "../../../src/csa/ScanResultsFactory.js"; -import { toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; import type { TimetableLeg } from "../../../src/journey/Journey.js"; -import { JourneyFactory } from "../../../src/journey/JourneyFactory.js"; import { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; import { MultipleCriteriaFilter } from "../../../src/query/MultipleCriteriaFilter.js"; -import { allDays, everyDay, feed, st } from "../util.js"; - -const TUESDAY = new Date("2026-09-08T09:00:00"); - -function trip(tripId: string, stopTimes: StopTime[], service: ServiceCalendar = everyDay): Trip { - return { tripId, serviceId: tripId, service, stopTimes }; -} +import { createTimetable } from "../../../src/timetable/Timetable.js"; +import { allDays, feed, platforms, st, trip, TUESDAY } from "../util.js"; function query(overrides: Partial): DepartAfterQuery { - const gtfs = toGtfsData(feed(overrides)); - const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); - - return new DepartAfterQuery(csa, new JourneyFactory(gtfs.stations), [new MultipleCriteriaFilter()]); + return new DepartAfterQuery(createTimetable(feed({ stops: platforms, ...overrides })), [new MultipleCriteriaFilter()]); } function tripsOf(legs: unknown[]): string[] { @@ -41,6 +29,24 @@ describe("DepartAfterQuery", () => { expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "DIS2"]); }); + it("leaves the points a train passes through out of its leg", () => { + const passing = { ...st("DIS2", 1050), pickUp: false, dropOff: false }; + const [journey] = query({ trips: [trip("1", [st("NRW1", 1000), passing, st("LST8", 1200)])] }) + .plan(["NRW"], ["LST"], TUESDAY, 900); + const [leg] = journey.legs as TimetableLeg[]; + + expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "LST8"]); + expect(leg.trip.stopTimes.length).toBe(3); + }); + + it("returns nothing where every origin or every destination is unknown", () => { + const planner = query({ trips: [front] }); + + expect(planner.plan(["XXX"], ["DIS"], TUESDAY, 900)).toEqual([]); + expect(planner.plan(["NRW"], ["XXX"], TUESDAY, 900)).toEqual([]); + expect(planner.plan(["XXX", "NRW"], ["DIS"], TUESDAY, 900).length).toBe(1); + }); + it("stays aboard across a coupling rather than changing where there is time to", () => { const journeys = query({ trips: [front, rear], links: [coupling], interchange: { DIS: 10 } }) .plan(["NRW"], ["LST"], TUESDAY, 900); diff --git a/test/unit/timetable/Timetable.spec.ts b/test/unit/timetable/Timetable.spec.ts new file mode 100644 index 0000000..fa1bebe --- /dev/null +++ b/test/unit/timetable/Timetable.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { createTimetable, type Timetable } from "../../../src/timetable/Timetable.js"; +import { byOrigin, everyDay, feed, platforms, st, trip, walk } from "../util.js"; + +/** + * The connections as the stations they run between, in the order the scan reads them. + */ +function pairs(timetable: Timetable): string[] { + const { connections, stations } = timetable; + + return Array.from( + { length: connections.length }, + (_, c) => `${stations[connections.departureStation[c]]}-${stations[connections.arrivalStation[c]]}` + ); +} + +describe("createTimetable", () => { + + it("creates connections between the stations the platforms belong to", () => { + const timetable = createTimetable(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200)])] + })); + const { connections } = timetable; + + expect(pairs(timetable)).toEqual(["NRW-DIS", "DIS-LST"]); + expect([...connections.departureTime]).toEqual([1000, 1100]); + expect([...connections.arrivalTime]).toEqual([1100, 1200]); + expect(timetable.stationIndex.get("NRW")).toBe(0); + expect([...connections.board].map(k => timetable.calls[k].stop)).toEqual(["NRW1", "DIS2"]); + expect([...connections.alight].map(k => timetable.calls[k].stop)).toEqual(["DIS2", "LST8"]); + }); + + it("sorts the connections by arrival, keeping the order they were created in where they arrive together", () => { + const timetable = createTimetable(feed({ + trips: [ + trip("1", [st("A", 1000), st("D", 1300)]), + trip("2", [st("B", 1100), st("C", 1200)]), + trip("3", [st("E", 1250), st("F", 1300)]) + ] + })); + + expect(pairs(timetable)).toEqual(["B-C", "A-D", "E-F"]); + expect([...timetable.connections.trip].map(t => timetable.trips[t].tripId)).toEqual(["2", "1", "3"]); + }); + + it("carries on past a call that can only be alighted at until one that can be boarded", () => { + const setDownOnly = { ...st("B", 1100), pickUp: false }; + const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), setDownOnly, st("C", 1200)])] })); + + expect(pairs(timetable)).toEqual(["A-B", "A-C"]); + }); + + it("does not create a connection to or from a passing point", () => { + const passing = { ...st("B", 1100), pickUp: false, dropOff: false }; + const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), passing, st("C", 1200)])] })); + + expect(pairs(timetable)).toEqual(["A-C"]); + expect(timetable.calls.map(call => call.stop)).toEqual(["A", "C"]); + }); + + it("does not create a connection between two platforms of one station", () => { + const timetable = createTimetable(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200)])] + })); + + expect(pairs(timetable)).toEqual(["NRW-LST"]); + }); + + it("adds the trip a passenger stays on across a coupling", () => { + const timetable = createTimetable(feed({ + stops: platforms, + trips: [trip("front", [st("NRW1", 1000), st("DIS2", 1100)]), trip("rear", [st("DIS1", 1130), st("LST8", 1230)])], + links: [{ fromTripId: "front", toTripId: "rear", fromStop: "DIS2", toStop: "DIS1" }] + })); + const { connections, trips } = timetable; + + expect(pairs(timetable).map((pair, c) => `${trips[connections.trip[c]].tripId}:${pair}`)).toEqual([ + "front:NRW-DIS", + "front_rear:NRW-DIS", + "rear:DIS-LST", + "front_rear:DIS-LST" + ]); + }); + + it("indexes footpaths by the origin station and drops those within one", () => { + const timetable = createTimetable(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + transfers: byOrigin(walk("NRW1", "NRW2", 300), walk("NRW1", "DIS2", 600), walk("DIS1", "IPS1", 900)) + })); + const { offsets, destination, duration, transfer } = timetable.transfers; + const from = (code: string) => { + const s = timetable.stationIndex.get(code)!; + + return Array.from({ length: offsets[s + 1] - offsets[s] }, (_, i) => offsets[s] + i); + }; + + expect(from("NRW").map(i => [timetable.stations[destination[i]], duration[i]])).toEqual([["DIS", 600]]); + expect(from("DIS").map(i => transfer[i].destination)).toEqual(["IPS"]); + expect(from("IPS")).toEqual([]); + }); + + it("reports interchange time against the station, and none where the feed gives none", () => { + const timetable = createTimetable(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + interchange: { NRW1: 300 } + })); + + expect(timetable.interchange[timetable.stationIndex.get("NRW")!]).toBe(300); + expect(timetable.interchange[timetable.stationIndex.get("DIS")!]).toBe(0); + }); + + it("holds each calendar once, however many trips run to it", () => { + const timetable = createTimetable(feed({ + trips: [trip("1", [st("A", 1000), st("B", 1100)]), trip("2", [st("B", 1200), st("C", 1300)], everyDay)] + })); + + expect(timetable.services.length).toBe(1); + expect([...timetable.tripService]).toEqual([0, 0]); + }); + +}); diff --git a/test/unit/util.ts b/test/unit/util.ts index 36246a6..a0055ad 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -1,71 +1,49 @@ -import { type GTFSFeed, Service, type StopID, type StopTime, type Time, type TripID } from "@gb-transit/gtfs-loader"; -import type { TimetableConnection } from "../../src/journey/Connection.js"; -import type { Transfer } from "../../src/journey/Journey.js"; +import { + type GTFSFeed, Service, type ServiceCalendar, type Stop, type StopID, type StopTime, type Time, type Transfer, + type Trip +} from "@gb-transit/gtfs-loader"; +import type { Journey, TimetableLeg } from "../../src/journey/Journey.js"; +import { DepartAfterQuery } from "../../src/query/DepartAfterQuery.js"; +import { MultipleCriteriaFilter } from "../../src/query/MultipleCriteriaFilter.js"; +import { createTimetable } from "../../src/timetable/Timetable.js"; export const allDays = { 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true }; export const everyDay = new Service(20190101, 20991231, allDays, {}); -export const defaultInterchange = { - A: 0, - B: 0 -}; +export const TUESDAY = new Date("2026-09-08T09:00:00"); /** - * A connection on the given trip, which calls nowhere until setStopTimes is given its connections. + * A call at a stop, arriving and departing at the same time. */ -export function c( - origin: StopID, - destination: StopID, - departureTime: Time, - arrivalTime: Time, - tripId: TripID = "LN1111" -): TimetableConnection { - return { - origin, - destination, - departureTime, - arrivalTime, - trip: { - tripId, - serviceId: "1", - stopTimes: [], - service: everyDay - } - }; +export function st(stop: StopID, time: Time): StopTime { + return { stop, pickUp: true, dropOff: true, departureTime: time, arrivalTime: time }; } /** - * A footpath, available all day. + * A call that can only be boarded. */ -export function t(origin: StopID, destination: StopID, duration: Time): Transfer { - return { origin, destination, duration, startTime: 0, endTime: 2359 }; +export function pickUpOnly(stop: StopID, time: Time): StopTime { + return { ...st(stop, time), dropOff: false }; } /** - * A call at a stop, arriving and departing at the same time. + * A trip with the given calls, running every day unless given a calendar. */ -export function st(stop: StopID, time: Time): StopTime { - return { stop, pickUp: true, dropOff: true, departureTime: time, arrivalTime: time }; +export function trip(tripId: string, stopTimes: StopTime[], service: ServiceCalendar = everyDay): Trip { + return { tripId, serviceId: tripId, service, stopTimes }; } /** - * Give every connection the stop times of the trip they make up together. + * A footpath, available all day. */ -export function setStopTimes(connections: TimetableConnection[]): void { - const stopTimes = connections.map(connection => st(connection.origin, connection.departureTime)); - const last = connections[connections.length - 1]; - - stopTimes.push(st(last.destination, last.arrivalTime)); - - for (const connection of connections) { - connection.trip.stopTimes = stopTimes; - } +export function walk(origin: StopID, destination: StopID, duration: Time): Transfer { + return { origin, destination, duration, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }; } /** - * A feed whose stops are platforms, each belonging to a station named by its stop_code. That is how - * the GB rail feed identifies a station, and what `normalise` resolves a call to. + * A feed of the given trips and footpaths. A stop the feed has no entry for is a station of its own, + * so a spec that is not about platforms can name its stations directly. */ export function feed(overrides: Partial = {}): GTFSFeed { return { @@ -73,18 +51,7 @@ export function feed(overrides: Partial = {}): GTFSFeed { transfers: {}, links: [], interchange: {}, - stops: { - NRW: station("NRW"), - NRW1: platform("NRW1", "NRW"), - NRW2: platform("NRW2", "NRW"), - DIS: station("DIS"), - DIS1: platform("DIS1", "DIS"), - DIS2: platform("DIS2", "DIS"), - LST: station("LST"), - LST8: platform("LST8", "LST"), - IPS: station("IPS"), - IPS1: platform("IPS1", "IPS") - }, + stops: {}, routes: {}, agencies: {}, areas: {}, @@ -93,10 +60,59 @@ export function feed(overrides: Partial = {}): GTFSFeed { }; } -function station(id: StopID) { +/** + * The same footpaths indexed by the stop they leave from, as the loader gives them. + */ +export function byOrigin(...transfers: Transfer[]): GTFSFeed["transfers"] { + const index: GTFSFeed["transfers"] = {}; + + for (const transfer of transfers) { + index[transfer.origin] ??= []; + index[transfer.origin].push(transfer); + } + + return index; +} + +/** + * Stops that are platforms, each belonging to a station named by its stop_code. That is how the GB + * rail feed identifies a station, and what the timetable resolves a call to. + */ +export const platforms: Record = { + NRW: station("NRW"), + NRW1: platform("NRW1", "NRW"), + NRW2: platform("NRW2", "NRW"), + DIS: station("DIS"), + DIS1: platform("DIS1", "DIS"), + DIS2: platform("DIS2", "DIS"), + LST: station("LST"), + LST8: platform("LST8", "LST"), + IPS: station("IPS"), + IPS1: platform("IPS1", "IPS") +}; + +function station(id: StopID): Stop { return { id, code: id, latitude: 0, longitude: 0, locationType: 1 }; } -function platform(id: StopID, parentStation: StopID) { +function platform(id: StopID, parentStation: StopID): Stop { return { id, latitude: 0, longitude: 0, locationType: 0, parentStation }; } + +/** + * Plan over a feed with the query a caller would make. + */ +export function plan(overrides: Partial, origins: StopID[], destinations: StopID[], time: Time): Journey[] { + return new DepartAfterQuery(createTimetable(feed(overrides)), [new MultipleCriteriaFilter()]) + .plan(origins, destinations, TUESDAY, time); +} + +/** + * Each leg as the trip it was taken on and the stations it ran between, or a walk. + */ +export function legsOf(journey: Journey): string[] { + return journey.legs.map(leg => "trip" in leg + ? `${(leg as TimetableLeg).trip.tripId}:${leg.origin}-${leg.destination}` + : `walk:${leg.origin}-${leg.destination}` + ); +} From 70f12d0f6c1c0420c44e8c5f41effcfa338f5c49 Mon Sep 17 00:00:00 2001 From: Linus Norton Date: Sun, 13 Sep 2026 20:22:50 +0100 Subject: [PATCH 2/2] Keep the classes and the algorithm as they were, reading arrays The first version rewrote the scan as one function over a new Timetable, which was quick but no longer read like the algorithm or the code it replaced. This puts 2.0.0's structure back and changes only what the scan reads. toGtfsData, ConnectionScanAlgorithm asking ScanResults whether each connection isReachable and isBetter, setConnection, scanTransfers, isTransferBetter and setTransfer, and JourneyFactory's getLegs, toLeg, getCompactedLegs and getStopTimes are 2.0.0's, and DepartAfterQuery is unchanged. What they are given: - GtfsData numbers the stations in a StopTable, and holds Connections and Transfers as parallel typed arrays, interchange by station, and a TripCalendar that asks each distinct calendar once per date - a Connection is a number: a connection's index, or a footpath's below -1 - ScanResults holds arrays indexed by station and a trip arrival per trip, the earliest of its calls it has carried the passenger to - a scan starts at the first connection arriving after the departure time, and isFinished once a connection arrives after every destination was reached Every ScanResults method is inlined into the scan, so the structure costs little. Against the single function it replaces, standard queries and couplings take the same time and random pairs about 15% longer, while building is quicker and the timetable smaller, since no flat copy of every call is kept: 305-360ms and 83MB, against 400-470ms and 129MB. Against 2.0.0 the answers to all 1,100 benchmark queries are still the same journeys. Claude-Session: https://claude.ai/code/session_013JmX1aXaZ9tXrxkLxRhop4 --- .changeset/integer-timetable.md | 39 ++- README.md | 49 +-- src/csa/ConnectionScanAlgorithm.ts | 217 +++--------- src/csa/ScanResults.ts | 190 +++++++++- src/csa/ScanResultsFactory.ts | 26 ++ src/gtfs/Connections.ts | 141 ++++++++ src/gtfs/GtfsLoader.ts | 163 +++++++++ src/gtfs/StopTable.ts | 55 +++ src/gtfs/TripCalendar.ts | 53 +++ src/index.ts | 7 +- src/journey/Connection.ts | 27 ++ src/journey/JourneyFactory.ts | 223 +++++------- src/query/DepartAfterQuery.ts | 49 +-- src/timetable/Timetable.ts | 329 ------------------ test/integration.ts | 10 +- test/performance.ts | 13 +- test/unit/csa/ConnectionScanAlgorithm.spec.ts | 63 ++-- test/unit/csa/ScanResults.spec.ts | 184 ++++++++++ test/unit/gtfs/GtfsLoader.spec.ts | 139 ++++++++ test/unit/journey/Connection.spec.ts | 37 ++ test/unit/journey/JourneyFactory.spec.ts | 98 ++++++ test/unit/query/DepartAfterQuery.spec.ts | 26 +- test/unit/timetable/Timetable.spec.ts | 125 ------- test/unit/util.ts | 92 ++++- 24 files changed, 1393 insertions(+), 962 deletions(-) create mode 100644 src/csa/ScanResultsFactory.ts create mode 100644 src/gtfs/Connections.ts create mode 100644 src/gtfs/GtfsLoader.ts create mode 100644 src/gtfs/StopTable.ts create mode 100644 src/gtfs/TripCalendar.ts create mode 100644 src/journey/Connection.ts delete mode 100644 src/timetable/Timetable.ts create mode 100644 test/unit/csa/ScanResults.spec.ts create mode 100644 test/unit/gtfs/GtfsLoader.spec.ts create mode 100644 test/unit/journey/Connection.spec.ts create mode 100644 test/unit/journey/JourneyFactory.spec.ts delete mode 100644 test/unit/timetable/Timetable.spec.ts diff --git a/.changeset/integer-timetable.md b/.changeset/integer-timetable.md index 57d8566..f963714 100644 --- a/.changeset/integer-timetable.md +++ b/.changeset/integer-timetable.md @@ -2,26 +2,27 @@ "connection-scan-algorithm": major --- -Scan an integer timetable, 30 to 100 times faster. +Scan connections held as arrays of numbers, 30 to 90 times faster. -The timetable numbers its stations as it is built, and a scan reads parallel typed arrays sorted by -arrival rather than an object per connection: its state is an array per station and per trip, a -scan starts at the first connection arriving after the departure time and stops once every -destination has been reached, and whether each trip runs is worked out once per date. Station codes, -trips and stop times are only looked up again for the journeys returned. +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 2.8-3.5ms each on average rather than 115-117ms, 268 journeys through couplings -1.3-1.7ms rather than 130ms, and 400 random station pairs 3.8-4.0ms rather than 170-174ms, or 5.2ms -rather than 177ms when every query is for a different date. Building the timetable takes 390-470ms -rather than 570-630ms, and it holds 129MB rather than 195MB. All 1,100 answers are the same journeys 2.0.0 -returns, trip for trip. +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. -The API changes with it: +`DepartAfterQuery`, `loadGtfs` and `toGtfsData` are called as before. What changes: -- `loadGtfs` and `toGtfsData` become `loadTimetable` and `createTimetable`, returning a `Timetable` -- `DepartAfterQuery` takes the timetable and the filters: `new DepartAfterQuery(timetable, filters)` -- `ConnectionScanAlgorithm` and `JourneyFactory` take the timetable, a scan is given station numbers - and returns a `ScanResults` of typed arrays, and `ScanResultsFactory` is gone -- `TimetableConnection`, `Connection`, `isChangeRequired`, `TransfersByOrigin` and `GtfsData` are - gone with the objects they described; `isTransfer` is exported from the journey types +- `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 diff --git a/README.md b/README.md index 3fd8234..e030c49 100644 --- a/README.md +++ b/README.md @@ -37,49 +37,38 @@ Find the first results that depart after a specific time ```javascript const fs = require("fs"); -const { loadTimetable, MultipleCriteriaFilter, DepartAfterQuery } = require("connection-scan-algorithm"); +const { + loadGtfs, JourneyFactory, ConnectionScanAlgorithm, ScanResultsFactory, MultipleCriteriaFilter, DepartAfterQuery +} = require("connection-scan-algorithm"); -const timetable = await loadTimetable(fs.createReadStream("gtfs.zip")); -// or createTimetable(feed) if you already have a feed from @gb-transit/gtfs-loader +const gtfs = await loadGtfs(fs.createReadStream("gtfs.zip")); +// or toGtfsData(feed) if you already have a feed from @gb-transit/gtfs-loader -const query = new DepartAfterQuery(timetable, [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); ``` -A timetable is built once and planned over as many times as you like, for any date the feed covers. +### How a scan reads the timetable -### 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. -Stations are numbered as the timetable is built, and a scan works in those numbers throughout: every -connection is a row of parallel integer arrays, sorted by arrival, and what a scan finds is an -earliest arrival and the connection or footpath achieving it for each station, again by number. The -codes, trips and stop times are only looked up again for the journeys returned. - -`ConnectionScanAlgorithm` and `JourneyFactory` are exported for a caller that wants the scan's -results rather than journeys, or its own query: - -```javascript -const { ConnectionScanAlgorithm, JourneyFactory } = require("connection-scan-algorithm"); - -const csa = new ConnectionScanAlgorithm(timetable); -const [origin, destination] = ["TBW", "NRW"].map(code => timetable.stationIndex.get(code)); -const results = csa.scan(new Map([[origin, 9 * 3600]]), [destination], 20260915, 2); - -results.earliestArrivals[destination]; // seconds past midnight, or NOT_REACHED -new JourneyFactory(timetable).getJourneys(results, [destination]); -``` - -A scan starts at the first connection arriving after the departure time, and stops once every -destination has been reached earlier than the connection it is on arrives. Whether each trip runs is -worked out once per date, asking each distinct calendar rather than each trip. +`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. `timetable.stations` names each station by its -number, and `timetable.stationIndex` numbers each code. +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, 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 diff --git a/src/csa/ConnectionScanAlgorithm.ts b/src/csa/ConnectionScanAlgorithm.ts index 72836e0..9cb8cca 100644 --- a/src/csa/ConnectionScanAlgorithm.ts +++ b/src/csa/ConnectionScanAlgorithm.ts @@ -1,219 +1,82 @@ -import type { DateNumber, DayOfWeek, Time } from "@gb-transit/gtfs-loader"; -import { NOT_REACHED, type StopIdx, type Timetable } from "../timetable/Timetable.js"; -import { NOT_ARRIVED, type ScanResults } from "./ScanResults.js"; - -/** The trip has carried the passenger to none of its calls */ -const NOT_CARRIED = 0x7fffffff; +import type { DateNumber, DayOfWeek, StopID, Time } from "@gb-transit/gtfs-loader"; +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 { - /** Per trip, the earliest of its calls it has carried the passenger to. Reused across scans */ - private readonly carriedTo: Int32Array; - private readonly isDestination: Uint8Array; - private calendarDate: DateNumber = -1; - private readonly runs: Uint8Array; + private readonly connections: Connections; + private readonly transfers: Transfers; + private readonly calendar: TripCalendar; constructor( - private readonly timetable: Timetable + gtfs: GtfsData, + private readonly resultsFactory: ScanResultsFactory ) { - this.carriedTo = new Int32Array(timetable.trips.length); - this.isDestination = new Uint8Array(timetable.stations.length); - this.runs = new Uint8Array(timetable.trips.length); + this.connections = gtfs.connections; + this.transfers = gtfs.transfers; + this.calendar = gtfs.calendar; } /** - * Scan for the earliest arrival at every station, and the connection or footpath that achieves it. - * - * The results are the scan's own, so a later scan does not change them. + * Return an index of connections that achieve the earliest arrival time at each station. */ public scan( origins: OriginDepartureTimes, - destinations: StopIdx[], + destinations: StopID[], date: DateNumber, dow: DayOfWeek - ): ScanResults { - const { stations, connections, interchange } = this.timetable; - const { departureStation, arrivalStation, departureTime, arrivalTime, trip, board, alight } = connections; - const results: ScanResults = { - earliestArrivals: new Int32Array(stations.length).fill(NOT_REACHED), - arrivedBy: new Int32Array(stations.length).fill(NOT_ARRIVED) - }; - const { earliestArrivals, arrivedBy } = results; - const runs = this.calendar(date, dow); - const carriedTo = this.carriedTo.fill(NOT_CARRIED); - let departure = NOT_REACHED; - - for (const [origin, time] of origins) { - earliestArrivals[origin] = time; - departure = Math.min(departure, time); - } + ): ConnectionIndex { + const results = this.resultsFactory.create(origins, destinations); + const running = this.calendar.runningOn(date, dow); + const departureTime = Math.min(...Object.values(origins)); - for (const destination of destinations) { - this.isDestination[destination] = 1; + for (const origin of results.getOrigins()) { + this.scanTransfers(results, origin); } - let target = this.latestArrival(earliestArrivals, destinations); - - for (const origin of origins.keys()) { - target = this.scanTransfers(results, origin, destinations, target); - } - - // nothing arriving before the earliest departure can have been boarded after it - for (let c = firstArrivingAt(arrivalTime, departure); c < connections.length; c++) { - // every destination has been reached before this connection arrives, so neither it nor any - // after it can arrive sooner, or at the same time on the trip the passenger is aboard - if (arrivalTime[c] > target) { + for (let c = firstArrivingAt(this.connections, departureTime); c < this.connections.length; c++) { + if (results.isFinished(c)) { break; } - - const t = trip[c]; - - if (runs[t] === 0) { - continue; - } - - const origin = departureStation[c]; - const change = arrivedBy[origin] === NOT_ARRIVED ? 0 : interchange[origin]; - // once the trip has carried the passenger to a call, they are still aboard for any of its - // connections from there on. Being boarded at a call is not the same: a trip only picking up - // at a later call has carried nobody to it - const aboard = carriedTo[t] <= board[c]; - - if (!aboard && earliestArrivals[origin] + change > departureTime[c]) { - continue; - } - - if (alight[c] < carriedTo[t]) { - carriedTo[t] = alight[c]; - } - - const destination = arrivalStation[c]; - const previous = earliestArrivals[destination]; - - if (arrivalTime[c] < previous) { - earliestArrivals[destination] = arrivalTime[c]; - arrivedBy[destination] = c; - target = this.scanTransfers(results, destination, destinations, target); - } - // 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 - else if (arrivalTime[c] === previous && aboard && arrivedOnAnotherTrip(results, destination, t, trip)) { - arrivedBy[destination] = c; + if (running[this.connections.trip[c]] && results.isReachable(c) && results.isBetter(c)) { + if (results.setConnection(c)) { + this.scanTransfers(results, this.connections.arrivalStation[c]); + } } } - for (const destination of destinations) { - this.isDestination[destination] = 0; - } - - return results; + return results.getConnectionIndex(); } /** * 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. - * - * A footpath is charged the interchange time of the station it leaves, and boarding at the - * station it reaches charges that one's. - * - * Returns the latest arrival at any destination, which a footpath reaching one may have moved. */ - private scanTransfers(results: ScanResults, origin: StopIdx, destinations: StopIdx[], target: Time): Time { - const { offsets, destination, duration } = this.timetable.transfers; - const { earliestArrivals, arrivedBy } = results; - const leave = earliestArrivals[origin] + this.timetable.interchange[origin]; - - if (this.isDestination[origin] === 1) { - target = this.latestArrival(earliestArrivals, destinations); - } - - for (let i = offsets[origin]; i < offsets[origin + 1]; i++) { - const to = destination[i]; - const arrival = leave + duration[i]; - - if (arrival < earliestArrivals[to]) { - earliestArrivals[to] = arrival; - arrivedBy[to] = transferArrival(i); - target = this.scanTransfers(results, to, destinations, target); + 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]); } } - - return target; } - private latestArrival(earliestArrivals: Int32Array, destinations: StopIdx[]): Time { - let latest = destinations.length === 0 ? -1 : 0; - - for (const destination of destinations) { - latest = Math.max(latest, earliestArrivals[destination]); - } - - return latest; - } - - /** - * Whether each trip runs on the date. Every distinct calendar is asked once, and the answer kept - * until a scan is made for another date. - */ - private calendar(date: DateNumber, dow: DayOfWeek): Uint8Array { - if (this.calendarDate !== date) { - const { services, tripService } = this.timetable; - const serviceRuns = services.map(service => service.runsOn(date, dow) ? 1 : 0); - - for (let t = 0; t < tripService.length; t++) { - this.runs[t] = serviceRuns[tripService[t]]; - } - - this.calendarDate = date; - } - - return this.runs; - } - -} - -/** - * The first connection arriving at or after the time, by binary search of the sorted arrivals. - */ -function firstArrivingAt(arrivalTime: Int32Array, time: Time): number { - let low = 0; - let high = arrivalTime.length; - - while (low < high) { - const middle = (low + high) >>> 1; - - if (arrivalTime[middle] < time) { - low = middle + 1; - } - else { - high = middle; - } - } - - return low; -} - -function arrivedOnAnotherTrip(results: ScanResults, station: StopIdx, t: number, trip: Int32Array): boolean { - const current = results.arrivedBy[station]; - - return current !== NOT_ARRIVED && (isTransferArrival(current) || trip[current] !== t); } /** - * A footpath is recorded as a negative number, so that one array can say how every station was - * reached: a connection by its index, a footpath by its index below -1. + * The connection or footpath that achieves the earliest arrival at each station, by station index, + * or NO_CONNECTION where nothing does. */ -export const transferArrival = (transfer: number): number => -2 - transfer; - -export const isTransferArrival = (arrivedBy: number): boolean => arrivedBy <= -2; - -export const transferIndex = (arrivedBy: number): number => -2 - arrivedBy; +export type ConnectionIndex = Int32Array; /** - * The departure time from each origin station + * Index of departure stations and their departure time */ -export type OriginDepartureTimes = Map; +export type OriginDepartureTimes = Record; diff --git a/src/csa/ScanResults.ts b/src/csa/ScanResults.ts index fc47f0a..e7fde04 100644 --- a/src/csa/ScanResults.ts +++ b/src/csa/ScanResults.ts @@ -1,15 +1,187 @@ -/** A station reached by nothing: an origin, or one not reached at all */ -export const NOT_ARRIVED = -1; +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"; /** - * What a scan found, by station index. + * 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 interface ScanResults { - /** The earliest arrival at each station, NOT_REACHED where it was not reached */ - earliestArrivals: Int32Array; +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 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( + 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); + + 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) { + const trip = this.connections.trip[c]; + + this.tripArrivals[trip] = Math.min(this.tripArrivals[trip], this.connections.alight[c]); + } + + return reachable; + } + + private isReachableFromSameService(c: Connection): boolean { + 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]; + + return this.earliestArrivals[origin] + interchange <= this.connections.departureTime[c]; + } + + public isBetter(c: Connection): boolean { + const arrivalTime = this.earliestArrivals[this.connections.arrivalStation[c]]; + + 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(c: Connection): boolean { + const current = this.connectionIndex[this.connections.arrivalStation[c]]; + + 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(c: Connection): boolean { + const destination = this.connections.arrivalStation[c]; + const previous = this.earliestArrivals[destination]; + + this.connectionIndex[destination] = c; + + return this.arrive(destination, this.connections.arrivalTime[c]) < previous; + } + + public isTransferBetter(t: number): boolean { + return this.earliestArrivals[this.transfers.destination[t]] > this.getTransferArrivalTime(t); + } + + public setTransfer(t: number): void { + const destination = this.transfers.destination[t]; + + this.connectionIndex[destination] = transferConnection(t); + this.arrive(destination, this.getTransferArrivalTime(t)); + } + + private getTransferArrivalTime(t: number): Time { + const origin = this.transfers.origin[t]; + + return this.earliestArrivals[origin] + this.transfers.duration[t] + this.interchange[origin]; + } + + 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; + } + /** - * How each station was reached: the index of the connection arriving there, a footpath as - * `transferArrival` records it, or NOT_ARRIVED + * 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. */ - arrivedBy: Int32Array; + public isFinished(c: Connection): boolean { + return this.connections.arrivalTime[c] > this.latestDestinationArrival; + } } diff --git a/src/csa/ScanResultsFactory.ts b/src/csa/ScanResultsFactory.ts new file mode 100644 index 0000000..130aa39 --- /dev/null +++ b/src/csa/ScanResultsFactory.ts @@ -0,0 +1,26 @@ +import type { StopID } from "@gb-transit/gtfs-loader"; +import type { GtfsData } from "../gtfs/GtfsLoader.js"; +import type { OriginDepartureTimes } from "./ConnectionScanAlgorithm.js"; +import { NOT_CARRIED, ScanResults } from "./ScanResults.js"; + +/** + * Creates a new ScanResults object for a given set of origins and destinations + */ +export class ScanResultsFactory { + private readonly tripArrivals: Int32Array; + + constructor( + private readonly gtfs: GtfsData + ) { + this.tripArrivals = 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)); + } + +} diff --git a/src/gtfs/Connections.ts b/src/gtfs/Connections.ts new file mode 100644 index 0000000..4950e33 --- /dev/null +++ b/src/gtfs/Connections.ts @@ -0,0 +1,141 @@ +import type { Time } from "@gb-transit/gtfs-loader"; +import type { StopIdx } from "./StopTable.js"; + +/** + * Every connection in the timetable as parallel arrays, so that connection `c` is + * `departureStation[c]`, `arrivalTime[c]` and so on. A scan reads millions of them in order, and + * reading numbers out of arrays is far quicker than reading fields out of an object each. + */ +export interface Connections { + length: number; + departureStation: Int32Array; + arrivalStation: Int32Array; + departureTime: Int32Array; + arrivalTime: Int32Array; + /** Index into the feed's trips */ + trip: Int32Array; + /** The position within its trip's calls of the call the connection is boarded at */ + board: Int32Array; + /** The position within its trip's calls of the call the connection is alighted at */ + alight: Int32Array; +} + +/** + * Connections as they are added, which become Connections once they are sorted. + */ +export class ConnectionList { + private connections = allocate(1 << 16); + private length = 0; + + public push( + departureStation: StopIdx, + arrivalStation: StopIdx, + departureTime: Time, + arrivalTime: Time, + trip: number, + board: number, + alight: number + ): void { + if (this.length === this.connections.length) { + this.connections = copy(this.connections, allocate(this.length * 2), this.length); + } + + const c = this.length++; + + this.connections.departureStation[c] = departureStation; + this.connections.arrivalStation[c] = arrivalStation; + this.connections.departureTime[c] = departureTime; + this.connections.arrivalTime[c] = arrivalTime; + this.connections.trip[c] = trip; + this.connections.board[c] = board; + this.connections.alight[c] = alight; + } + + /** + * The connections sorted by arrival time. Times are whole seconds within a day or two, so a + * counting sort does this in linear time, and it is stable: connections arriving together keep the + * order they were added in. + */ + public sortByArrival(): Connections { + const { arrivalTime } = this.connections; + let latest = 0; + + for (let c = 0; c < this.length; c++) { + latest = Math.max(latest, arrivalTime[c]); + } + + const position = new Int32Array(latest + 2); + + for (let c = 0; c < this.length; c++) { + position[arrivalTime[c] + 1]++; + } + + for (let time = 1; time < position.length; time++) { + position[time] += position[time - 1]; + } + + const sorted = allocate(this.length); + + for (let c = 0; c < this.length; c++) { + const to = position[arrivalTime[c]]++; + + sorted.departureStation[to] = this.connections.departureStation[c]; + sorted.arrivalStation[to] = this.connections.arrivalStation[c]; + sorted.departureTime[to] = this.connections.departureTime[c]; + sorted.arrivalTime[to] = arrivalTime[c]; + sorted.trip[to] = this.connections.trip[c]; + sorted.board[to] = this.connections.board[c]; + sorted.alight[to] = this.connections.alight[c]; + } + + return sorted; + } + +} + +/** + * The first connection arriving at or after the time. Nothing arriving earlier can have been + * boarded after it, so that is where a scan from the time starts. + */ +export function firstArrivingAt(connections: Connections, time: Time): number { + let low = 0; + let high = connections.length; + + while (low < high) { + const middle = (low + high) >>> 1; + + if (connections.arrivalTime[middle] < time) { + low = middle + 1; + } + else { + high = middle; + } + } + + return low; +} + +function allocate(length: number): Connections { + return { + length, + departureStation: new Int32Array(length), + arrivalStation: new Int32Array(length), + departureTime: new Int32Array(length), + arrivalTime: new Int32Array(length), + trip: new Int32Array(length), + board: new Int32Array(length), + alight: new Int32Array(length) + }; +} + +function copy(from: Connections, to: Connections, length: number): Connections { + to.departureStation.set(from.departureStation.subarray(0, length)); + to.arrivalStation.set(from.arrivalStation.subarray(0, length)); + to.departureTime.set(from.departureTime.subarray(0, length)); + to.arrivalTime.set(from.arrivalTime.subarray(0, length)); + to.trip.set(from.trip.subarray(0, length)); + to.board.set(from.board.subarray(0, length)); + to.alight.set(from.alight.subarray(0, length)); + + return to; +} diff --git a/src/gtfs/GtfsLoader.ts b/src/gtfs/GtfsLoader.ts new file mode 100644 index 0000000..a04c545 --- /dev/null +++ b/src/gtfs/GtfsLoader.ts @@ -0,0 +1,163 @@ +import { + type GTFSFeed, type GTFSSource, type Interchange, loadGTFS, normalise, type StopID, type StopIndex, + type StopTime, type Trip +} from "@gb-transit/gtfs-loader"; +import type { Transfer } from "../journey/Journey.js"; +import { ConnectionList, type Connections } from "./Connections.js"; +import { type StopIdx, StopTable, UNKNOWN_STOP } from "./StopTable.js"; +import { TripCalendar } from "./TripCalendar.js"; + +/** + * Returns connections, transfers and interchange times from a GTFS zip. + */ +export async function loadGtfs(source: GTFSSource): Promise { + return toGtfsData(await loadGTFS(source)); +} + +/** + * Puts a feed into the terms the connection scan works in. + * + * `normalise` resolves stops to the station they belong to, defines footpaths and interchange times + * at those stations, picks out the calls a passenger can actually use, and adds a trip for each + * coupling so that staying on a vehicle that carries on as another service is one trip rather than + * a change. What is left here is numbering those stations and turning each trip into connections + * between them, sorted as the scan reads them. + */ +export function toGtfsData(feed: GTFSFeed): GtfsData { + const { trips, calls, transfers, interchange, stations } = normalise(feed); + const stops = new StopTable(); + const connections = new ConnectionList(); + + for (let t = 0; t < trips.length; t++) { + addConnections(connections, t, calls[t], calls[t].map(c => stops.intern(stations.get(c.stop) ?? c.stop))); + } + + return { + connections: connections.sortByArrival(), + transfers: indexTransfersByOrigin(transfers, stops), + interchange: indexInterchange(interchange, stops), + calendar: new TripCalendar(trips), + trips, + stopTable: stops, + stops: feed.stops, + stations + }; +} + +/** + * Go through the calls adding connections until at least one pick up and set down point has been + * passed. A stopping pattern A(p/d) -> B(d) -> C(p/d) would otherwise create A->B but never reach C, + * so this gives A->B and A->C. + */ +function addConnections(connections: ConnectionList, trip: number, calls: StopTime[], station: StopIdx[]): void { + for (let i = 0; i < calls.length - 1; i++) { + if (calls[i].pickUp) { + for (let j = i + 1; j < calls.length; j++) { + if (calls[j].dropOff) { + // two calls at one station are a stop and a start, not a journey between places + if (station[i] !== station[j]) { + connections.push(station[i], station[j], calls[i].departureTime, calls[j].arrivalTime, trip, i, j); + } + + if (calls[j].pickUp) { + break; + } + } + } + } + } +} + +/** + * `normalise` returns footpaths as a flat list, the scan asks for them by origin. Each station's + * footpaths keep the order the feed gave them in. + */ +function indexTransfersByOrigin(transfers: Transfer[], stops: StopTable): Transfers { + for (const transfer of transfers) { + stops.intern(transfer.origin); + stops.intern(transfer.destination); + } + + const offsets = new Int32Array(stops.size + 1); + + for (const transfer of transfers) { + offsets[stops.indexOf(transfer.origin) + 1]++; + } + + for (let s = 1; s <= stops.size; s++) { + offsets[s] += offsets[s - 1]; + } + + const next = offsets.slice(0, stops.size); + const index: Transfers = { + offsets, + origin: new Int32Array(transfers.length), + destination: new Int32Array(transfers.length), + duration: new Int32Array(transfers.length), + transfer: new Array(transfers.length) + }; + + for (const transfer of transfers) { + const t = next[stops.indexOf(transfer.origin)]++; + + index.origin[t] = stops.indexOf(transfer.origin); + index.destination[t] = stops.indexOf(transfer.destination); + index.duration[t] = transfer.duration; + index.transfer[t] = transfer; + } + + return index; +} + +/** + * The interchange time at each station, by index. A station the feed gave no time for is one a + * change takes no time at. + */ +function indexInterchange(interchange: Interchange, stops: StopTable): Int32Array { + const times = new Int32Array(stops.size); + + for (const station of Object.keys(interchange)) { + const index = stops.indexOf(station); + + if (index !== UNKNOWN_STOP) { + times[index] = interchange[station]; + } + } + + return times; +} + +/** + * The footpaths out of each station. Station `s` owns `[offsets[s], offsets[s + 1])` of the rest, + * so footpath `t` leaves `origin[t]` for `destination[t]`. + */ +export interface Transfers { + /** Bounds of each station's footpaths. Its length is the number of stations + 1 */ + offsets: Int32Array; + origin: Int32Array; + destination: Int32Array; + duration: Int32Array; + /** The footpath as the feed gave it, which is what a journey returns */ + transfer: Transfer[]; +} + +/** + * Contents of the GTFS zip file + */ +export type GtfsData = { + /** every connection between two stations, sorted by arrival time */ + connections: Connections, + transfers: Transfers, + /** interchange time at each station, by index */ + interchange: Int32Array, + /** which trips run on a date */ + calendar: TripCalendar, + /** the trips the connections run on, by index */ + trips: Trip[], + /** the stations the connections run between, numbered */ + stopTable: StopTable, + /** the feed's stops, as it gave them, which may identify individual platforms */ + stops: StopIndex, + /** feed stop id to the station it belongs to, which is what journeys are planned between */ + stations: Map +}; diff --git a/src/gtfs/StopTable.ts b/src/gtfs/StopTable.ts new file mode 100644 index 0000000..99ebae4 --- /dev/null +++ b/src/gtfs/StopTable.ts @@ -0,0 +1,55 @@ +import type { StopID } from "@gb-transit/gtfs-loader"; + +/** + * A station as the scan works in it: a number, so that its state can be held in arrays rather than + * objects keyed by code. A query's codes are exchanged for these on the way in, and results name + * them again on the way out. + */ +export type StopIdx = number; + +/** A station the feed has not heard of, so nothing runs to or from it */ +export const UNKNOWN_STOP = -1; + +/** + * The stations a feed names, numbered in the order they are met. + */ +export class StopTable { + + private readonly codes: StopID[] = []; + private readonly indexes = new Map(); + + /** + * The index of a station, numbering it if it has not been seen before + */ + public intern(code: StopID): StopIdx { + const index = this.indexes.get(code); + + if (index !== undefined) { + return index; + } + + this.indexes.set(code, this.codes.length); + this.codes.push(code); + + return this.codes.length - 1; + } + + /** + * The index of a station, or UNKNOWN_STOP where the feed does not name it + */ + public indexOf(code: StopID): StopIdx { + return this.indexes.get(code) ?? UNKNOWN_STOP; + } + + /** + * The code of a station, for naming it in a result + */ + public nameOf(index: StopIdx): StopID { + return this.codes[index]; + } + + public get size(): number { + return this.codes.length; + } + +} diff --git a/src/gtfs/TripCalendar.ts b/src/gtfs/TripCalendar.ts new file mode 100644 index 0000000..eaa0381 --- /dev/null +++ b/src/gtfs/TripCalendar.ts @@ -0,0 +1,53 @@ +import type { DateNumber, DayOfWeek, ServiceCalendar, Trip } from "@gb-transit/gtfs-loader"; + +/** + * Which trips run on a date. + * + * A scan asks about every connection it reads, and there are far fewer calendars than trips, so each + * distinct calendar is asked once for the date and the answer is kept until a scan asks about + * another. + */ +export class TripCalendar { + /** Index into services of each trip's calendar */ + private readonly tripService: Int32Array; + private readonly services: ServiceCalendar[] = []; + private readonly running: Uint8Array; + private date: DateNumber = -1; + + constructor(trips: Trip[]) { + const serviceIndex = new Map(); + + this.tripService = new Int32Array(trips.length); + this.running = new Uint8Array(trips.length); + + for (let t = 0; t < trips.length; t++) { + let index = serviceIndex.get(trips[t].service); + + if (index === undefined) { + index = this.services.length; + this.services.push(trips[t].service); + serviceIndex.set(trips[t].service, index); + } + + this.tripService[t] = index; + } + } + + /** + * For each trip, 1 if it runs on the date and 0 if not + */ + public runningOn(date: DateNumber, dow: DayOfWeek): Uint8Array { + if (this.date !== date) { + const services = this.services.map(service => service.runsOn(date, dow) ? 1 : 0); + + for (let t = 0; t < this.tripService.length; t++) { + this.running[t] = services[this.tripService[t]]; + } + + this.date = date; + } + + return this.running; + } + +} diff --git a/src/index.ts b/src/index.ts index 0d558ac..de1b062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,14 @@ export type { export * from "./csa/ConnectionScanAlgorithm.js"; export * from "./csa/ScanResults.js"; +export * from "./csa/ScanResultsFactory.js"; -export * from "./timetable/Timetable.js"; +export * from "./gtfs/Connections.js"; +export * from "./gtfs/GtfsLoader.js"; +export * from "./gtfs/StopTable.js"; +export * from "./gtfs/TripCalendar.js"; +export * from "./journey/Connection.js"; export * from "./journey/Journey.js"; export * from "./journey/JourneyFactory.js"; diff --git a/src/journey/Connection.ts b/src/journey/Connection.ts new file mode 100644 index 0000000..e68c561 --- /dev/null +++ b/src/journey/Connection.ts @@ -0,0 +1,27 @@ +import type { Connections } from "../gtfs/Connections.js"; + +/** + * How a station was reached: a connection by its index into the feed's connections, or a footpath + * by its index into the feed's transfers. They are told apart by sign, so that one array can hold + * how every station was reached. + */ +export type Connection = number; + +/** A station reached by nothing: an origin, or one not reached at all */ +export const NO_CONNECTION = -1; + +export function transferConnection(transfer: number): Connection { + return -2 - transfer; +} + +export function isTransferConnection(connection: Connection): boolean { + return connection <= -2; +} + +export function transferOf(connection: Connection): number { + return -2 - connection; +} + +export function isChangeRequired(connections: Connections, a: Connection, b: Connection): boolean { + return isTransferConnection(a) || isTransferConnection(b) || connections.trip[a] !== connections.trip[b]; +} diff --git a/src/journey/JourneyFactory.ts b/src/journey/JourneyFactory.ts index 0f14ca1..e3856bf 100644 --- a/src/journey/JourneyFactory.ts +++ b/src/journey/JourneyFactory.ts @@ -1,189 +1,134 @@ -import type { Time } from "@gb-transit/gtfs-loader"; -import { isTransferArrival, transferIndex } from "../csa/ConnectionScanAlgorithm.js"; -import { NOT_ARRIVED, type ScanResults } from "../csa/ScanResults.js"; -import type { StopIdx, Timetable } from "../timetable/Timetable.js"; +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"; /** - * A leg while it is still in the timetable's terms: a footpath by its index, or a trip between two of - * its calls. - */ -type Part = FootpathPart | TripPart; - -interface FootpathPart { - transfer: number; -} - -interface TripPart { - trip: number; - /** Index into the timetable's calls of the call boarded at */ - start: number; - /** Index into the timetable's calls of the call alighted at */ - end: number; - origin: StopIdx; - destination: StopIdx; -} - -const isFootpath = (part: Part): part is FootpathPart => "transfer" in part; - -/** - * Creates journeys from the results of a connection scan. + * Creates journeys from the connection index created by the connection scan algorithm. */ export class JourneyFactory { + /** + * Connections run between stations while a trip's stop times name platforms, so a leg is cut from + * its trip by asking which station each call is at. + */ constructor( - private readonly timetable: Timetable + private readonly gtfs: GtfsData ) {} /** * Extract a result for each destination in the list. */ - public getJourneys(results: ScanResults, destinations: StopIdx[]): Journey[] { - const journeys: Journey[] = []; - - for (const destination of destinations) { - const parts = this.getParts(results, destination); - - if (parts !== null) { - journeys.push(this.getJourney(this.getCompactedParts(parts).map(part => this.toLeg(part)))); - } - } - - return journeys; + public getJourneys(connections: ConnectionIndex, destinations: StopID[]): Journey[] { + return destinations + .map(d => this.getLegs(connections, d)) + .filter((c): c is AnyLeg[] => c !== null) + .map(c => this.getCompactedLegs(c)) + .map(l => this.getJourney(l)); } /** - * Iterate backwards from the destination to the origin, collecting consecutive connections of one - * trip into a single part + * Iterate backwards from the destination to the origin collecting connections into legs */ - private getParts(results: ScanResults, destination: StopIdx): Part[] | null { - const { connections, transfers } = this.timetable; - const parts: Part[] = []; - let station = destination; + private getLegs(connections: ConnectionIndex, destination: StopID): AnyLeg[] | null { + const legs: Connection[][] = []; + let legConnections: Connection[] = []; + let previousConnection: Connection = NO_CONNECTION; + 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 = []; + } - while (results.arrivedBy[station] !== NOT_ARRIVED) { - const arrivedBy = results.arrivedBy[station]; + legConnections.push(connection); + previousConnection = connection; + station = isTransferConnection(connection) + ? this.gtfs.transfers.origin[transferOf(connection)] + : this.gtfs.connections.departureStation[connection]; + } - if (isTransferArrival(arrivedBy)) { - const transfer = transferIndex(arrivedBy); + legs.push(legConnections.reverse()); - parts.push({ transfer }); - station = transfers.origin[transfer]; - } - else { - const previous = parts[parts.length - 1]; - const trip = connections.trip[arrivedBy]; + return legConnections.length === 0 ? null : legs.reverse().map(cs => this.toLeg(cs)); + } - station = connections.departureStation[arrivedBy]; + /** + * Convert a list of connections into a Transfer or a TimetableLeg + */ + private toLeg(cs: Connection[]): AnyLeg { + const { connections, stopTable, transfers, trips } = this.gtfs; + const firstConnection = cs[0]; - if (previous !== undefined && !isFootpath(previous) && previous.trip === trip) { - previous.start = connections.board[arrivedBy]; - previous.origin = station; - } - else { - parts.push({ - trip, - start: connections.board[arrivedBy], - end: connections.alight[arrivedBy], - origin: station, - destination: connections.arrivalStation[arrivedBy] - }); - } - } + if (isTransferConnection(firstConnection)) { + return transfers.transfer[transferOf(firstConnection)]; } + 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); - return parts.length === 0 ? null : parts.reverse(); + return { origin, destination, trip, stopTimes: stopTimes || [] }; + } } /** - * Check for any redundant parts and replace them with the trip of a later one, boarded earlier. + * Check for any redundant legs and replace them with new legs from the trip. */ - private getCompactedParts(parts: Part[]): Part[] { - const { calls, transfers } = this.timetable; - const compacted: Part[] = []; + private getCompactedLegs(legs: AnyLeg[]): AnyLeg[] { + const newLegs: AnyLeg[] = []; - for (let i = parts.length - 1; i >= 0; i--) { - const partI = parts[i]; + for (let i = legs.length - 1; i >= 0; i--) { + const legI = legs[i]; - if (isFootpath(partI)) { - compacted.push(partI); + if (isTransfer(legI)) { + newLegs.push(legI); } else { - let lastDepartureTime = calls[partI.start].departureTime; + let lastDepartureTime = legI.stopTimes[0].departureTime; for (let j = i - 1; j >= 0; j--) { - const partJ = parts[j]; - const origin = isFootpath(partJ) ? transfers.origin[partJ.transfer] : partJ.origin; + 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); - lastDepartureTime = isFootpath(partJ) - ? lastDepartureTime - transfers.duration[partJ.transfer] - : calls[partJ.start].departureTime; - - const start = this.findStart(partI.trip, origin, lastDepartureTime); - const end = start === -1 ? -1 : this.findEnd(partI.trip, start, partI.destination); - - if (end !== -1) { - partI.origin = origin; - partI.start = start; - partI.end = end; + if (stopTimes) { + legI.origin = legJ.origin; + legI.stopTimes = stopTimes; i = j; } } - compacted.push(partI); - } - } - - return compacted.reverse(); - } - - /** - * The first call of the trip at the station that can be boarded no earlier than the time - */ - private findStart(trip: number, station: StopIdx, departureTime: Time): number { - const { callOffsets, calls, callStations } = this.timetable; - - for (let k = callOffsets[trip]; k < callOffsets[trip + 1]; k++) { - if (callStations[k] === station && calls[k].pickUp && calls[k].departureTime >= departureTime) { - return k; + newLegs.push(legI); } } - return -1; + return newLegs.reverse(); } /** - * The first call of the trip after the one boarded that can be alighted at the station + * 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 + * says which platform it uses at each end, but the points the trip only passes are left out. */ - private findEnd(trip: number, start: number, station: StopIdx): number { - const { callOffsets, calls, callStations } = this.timetable; - - for (let k = start + 1; k < callOffsets[trip + 1]; k++) { - if (callStations[k] === station && calls[k].dropOff) { - return k; - } - } - - return -1; + private getStopTimes(trip: Trip, origin: StopID, departureTime: Time, destination: StopID): StopTime[] | null { + const stopTimes = trip.stopTimes; + const start = stopTimes.findIndex( + c => c.pickUp && c.departureTime >= departureTime && this.stationOf(c) === origin + ); + const end = stopTimes.findIndex((c, i) => c.dropOff && i > start && this.stationOf(c) === destination); + + return start === -1 || end === -1 ? null : stopTimes.slice(start, end + 1).filter(isCall); } - /** - * A part in the terms a journey is returned in. The stop times are the feed's own, so a leg between - * two stations still says which platform it uses at each end. - */ - private toLeg(part: Part): AnyLeg { - const { stations, trips, calls, transfers } = this.timetable; - - if (isFootpath(part)) { - return transfers.transfer[part.transfer]; - } - - return { - origin: stations[part.origin], - destination: stations[part.destination], - trip: trips[part.trip], - stopTimes: calls.slice(part.start, part.end + 1) - }; + private stationOf(stopTime: StopTime): StopID { + return this.gtfs.stations.get(stopTime.stop) ?? stopTime.stop; } private getJourney(legs: AnyLeg[]): Journey { diff --git a/src/query/DepartAfterQuery.ts b/src/query/DepartAfterQuery.ts index a45833e..2542558 100644 --- a/src/query/DepartAfterQuery.ts +++ b/src/query/DepartAfterQuery.ts @@ -1,66 +1,37 @@ import type { DateNumber, DayOfWeek, StopID, Time } from "@gb-transit/gtfs-loader"; -import { ConnectionScanAlgorithm, type OriginDepartureTimes } from "../csa/ConnectionScanAlgorithm.js"; +import type { ConnectionScanAlgorithm, OriginDepartureTimes } from "../csa/ConnectionScanAlgorithm.js"; import type { Journey } from "../journey/Journey.js"; -import { JourneyFactory } from "../journey/JourneyFactory.js"; -import type { StopIdx, Timetable } from "../timetable/Timetable.js"; +import type { JourneyFactory } from "../journey/JourneyFactory.js"; import type { JourneyFilter } from "./JourneyFilter.js"; /** * Implementation of CSA that searches for journeys between a set of origin and destinations. */ export class DepartAfterQuery { - private readonly csa: ConnectionScanAlgorithm; - private readonly resultsFactory: JourneyFactory; constructor( - private readonly timetable: Timetable, + private readonly csa: ConnectionScanAlgorithm, + private readonly resultsFactory: JourneyFactory, private readonly filters: JourneyFilter[] = [] - ) { - this.csa = new ConnectionScanAlgorithm(timetable); - this.resultsFactory = new JourneyFactory(timetable); - } + ) { } /** * Plan a journey between the origin and destination set of stations on the given date and time */ public plan(origins: StopID[], destinations: StopID[], date: Date, time: Time): Journey[] { - const originTimes: OriginDepartureTimes = new Map(); - - for (const origin of this.toStationIndexes(origins)) { - originTimes.set(origin, time); - } + const originTimes: OriginDepartureTimes = {}; - const to = this.toStationIndexes(destinations); - - if (originTimes.size === 0 || to.length === 0) { - return []; + for (const origin of origins) { + originTimes[origin] = time; } - const results = this.csa.scan(originTimes, to, this.getDateNumber(date), date.getDay() as DayOfWeek); - const journeys = this.resultsFactory.getJourneys(results, to); + const results = this.csa.scan(originTimes, destinations, this.getDateNumber(date), date.getDay() as DayOfWeek); + const journeys = this.resultsFactory.getJourneys(results, destinations); // apply each filter to the results return this.filters.reduce((rs, filter) => filter.apply(rs), journeys); } - /** - * A station the timetable does not have is one nothing runs to or from, so it is left out rather - * than planned for - */ - private toStationIndexes(stations: StopID[]): StopIdx[] { - const indexes: StopIdx[] = []; - - for (const station of stations) { - const index = this.timetable.stationIndex.get(station); - - if (index !== undefined) { - indexes.push(index); - } - } - - return indexes; - } - // read in local time to agree with getDay(), otherwise the date and the day of week can describe different days private getDateNumber(date: Date): DateNumber { return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate(); diff --git a/src/timetable/Timetable.ts b/src/timetable/Timetable.ts deleted file mode 100644 index 99d3e69..0000000 --- a/src/timetable/Timetable.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { - type GTFSFeed, type GTFSSource, loadGTFS, normalise, type ServiceCalendar, type StopID, type StopIndex, - type StopTime, type Transfer, type Trip -} from "@gb-transit/gtfs-loader"; - -/** - * 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; - -/** - * Dense index of a station, in the range [0, number of stations). - */ -export type StopIdx = number; - -/** - * Every connection in the timetable, sorted by arrival time, as parallel arrays so that a scan - * reads integers in order rather than chasing an object per connection. - */ -export interface Connections { - length: number; - departureStation: Int32Array; - arrivalStation: Int32Array; - departureTime: Int32Array; - arrivalTime: Int32Array; - /** Index into the timetable's trips */ - trip: Int32Array; - /** The call the connection is boarded at, as an index into the timetable's calls */ - board: Int32Array; - /** The call the connection is alighted at, as an index into the timetable's calls */ - alight: Int32Array; -} - -/** - * The footpaths out of each station. Station `s` owns `[offsets[s], offsets[s + 1])` of the rest. - */ -export interface Transfers { - /** Bounds of each station's slice. Its length is the number of stations + 1 */ - offsets: Int32Array; - origin: Int32Array; - destination: Int32Array; - duration: Int32Array; - /** Parallel to the rest, the footpath as the feed gave it, which is what a journey returns */ - transfer: Transfer[]; -} - -/** - * The feed as the connection scan reads it. - * - * Stations are numbered as they are met, and everything the scan touches is indexed by those - * numbers. The codes, the feed's trips and its stop times are kept alongside for the journeys that - * are built from a scan's results. - */ -export interface Timetable { - /** Station code of each station index */ - stations: StopID[]; - stationIndex: Map; - /** Interchange time at each station, in seconds */ - interchange: Int32Array; - transfers: Transfers; - connections: Connections; - trips: Trip[]; - /** Index into services of each trip's calendar */ - tripService: Int32Array; - /** Each distinct calendar once, since far fewer of them than trips have to be asked about a date */ - services: ServiceCalendar[]; - /** - * The calls a passenger can use, of every trip in turn. Trip `t` owns `[callOffsets[t], - * callOffsets[t + 1])` of calls and callStations. - */ - callOffsets: Int32Array; - calls: StopTime[]; - callStations: Int32Array; - /** the feed's stops, as it gave them, which may identify individual platforms */ - stops: StopIndex; -} - -/** - * Returns the timetable of a GTFS zip. - */ -export async function loadTimetable(source: GTFSSource): Promise { - return createTimetable(await loadGTFS(source)); -} - -/** - * Puts a feed into the terms the connection scan works in. - * - * `normalise` resolves stops to the station they belong to, defines footpaths and interchange times - * at those stations, picks out the calls a passenger can actually use, and adds a trip for each - * coupling so that staying on a vehicle that carries on as another service is one trip rather than - * a change. What is left here is numbering the stations and turning each trip into connections - * between them, sorted as the scan reads them. - */ -export function createTimetable(feed: GTFSFeed): Timetable { - const { trips, calls: tripCalls, transfers, interchange, stations: stationOf } = normalise(feed); - const stations: StopID[] = []; - const stationIndex = new Map(); - - const intern = (code: StopID): StopIdx => { - let index = stationIndex.get(code); - - if (index === undefined) { - index = stations.length; - stations.push(code); - stationIndex.set(code, index); - } - - return index; - }; - - const callOffsets = new Int32Array(trips.length + 1); - const calls: StopTime[] = []; - const callStationList: StopIdx[] = []; - - for (let t = 0; t < trips.length; t++) { - callOffsets[t] = calls.length; - - for (const call of tripCalls[t]) { - calls.push(call); - callStationList.push(intern(stationOf.get(call.stop) ?? call.stop)); - } - } - - callOffsets[trips.length] = calls.length; - - const callStations = Int32Array.from(callStationList); - - for (const transfer of transfers) { - intern(transfer.origin); - intern(transfer.destination); - } - - const interchangeTimes = new Int32Array(stations.length); - - for (const station of Object.keys(interchange)) { - const index = stationIndex.get(station); - - if (index !== undefined) { - interchangeTimes[index] = interchange[station]; - } - } - - const { tripService, services } = indexServices(trips); - - return { - stations, - stationIndex, - interchange: interchangeTimes, - transfers: indexTransfers(transfers, stationIndex, stations.length), - connections: sortByArrival(createConnections(trips.length, callOffsets, calls, callStations)), - trips, - tripService, - services, - callOffsets, - calls, - callStations, - stops: feed.stops - }; -} - -/** - * Go through each trip's calls adding connections until at least one pick up and set down point has - * been passed. A stopping pattern A(p/d) -> B(d) -> C(p/d) would otherwise create A->B but never - * reach C, so this gives A->B and A->C. - * - * The connections are counted before they are written, so that each array is allocated once. - */ -function createConnections( - numTrips: number, - callOffsets: Int32Array, - calls: StopTime[], - callStations: Int32Array -): Connections { - const visit = (add: (trip: number, i: number, j: number) => void): void => { - for (let t = 0; t < numTrips; t++) { - const end = callOffsets[t + 1]; - - for (let i = callOffsets[t]; i < end - 1; i++) { - if (calls[i].pickUp) { - for (let j = i + 1; j < end; j++) { - if (calls[j].dropOff) { - // two calls at one station are a stop and a start, not a journey between places - if (callStations[i] !== callStations[j]) { - add(t, i, j); - } - - if (calls[j].pickUp) { - break; - } - } - } - } - } - } - }; - - let length = 0; - visit(() => length++); - - const connections = allocateConnections(length); - let c = 0; - - visit((t, i, j) => { - connections.departureStation[c] = callStations[i]; - connections.arrivalStation[c] = callStations[j]; - connections.departureTime[c] = calls[i].departureTime; - connections.arrivalTime[c] = calls[j].arrivalTime; - connections.trip[c] = t; - connections.board[c] = i; - connections.alight[c] = j; - c++; - }); - - return connections; -} - -/** - * A counting sort on arrival time. Times are whole seconds within a day or two, so this is linear, - * and it is stable: connections arriving together keep the order they were created in. - */ -function sortByArrival(unsorted: Connections): Connections { - const { length, arrivalTime } = unsorted; - let latest = 0; - - for (let c = 0; c < length; c++) { - latest = Math.max(latest, arrivalTime[c]); - } - - const position = new Int32Array(latest + 2); - - for (let c = 0; c < length; c++) { - position[arrivalTime[c] + 1]++; - } - - for (let time = 1; time < position.length; time++) { - position[time] += position[time - 1]; - } - - const sorted = allocateConnections(length); - - for (let c = 0; c < length; c++) { - const to = position[arrivalTime[c]]++; - - sorted.departureStation[to] = unsorted.departureStation[c]; - sorted.arrivalStation[to] = unsorted.arrivalStation[c]; - sorted.departureTime[to] = unsorted.departureTime[c]; - sorted.arrivalTime[to] = arrivalTime[c]; - sorted.trip[to] = unsorted.trip[c]; - sorted.board[to] = unsorted.board[c]; - sorted.alight[to] = unsorted.alight[c]; - } - - return sorted; -} - -function allocateConnections(length: number): Connections { - return { - length, - departureStation: new Int32Array(length), - arrivalStation: new Int32Array(length), - departureTime: new Int32Array(length), - arrivalTime: new Int32Array(length), - trip: new Int32Array(length), - board: new Int32Array(length), - alight: new Int32Array(length) - }; -} - -/** - * `normalise` returns footpaths as a flat list, the scan asks for them by origin. Each station's - * footpaths keep the order the feed gave them in. - */ -function indexTransfers( - transfers: Transfer[], - stationIndex: Map, - numStations: number -): Transfers { - const offsets = new Int32Array(numStations + 1); - - for (const transfer of transfers) { - offsets[stationIndex.get(transfer.origin)! + 1]++; - } - - for (let s = 1; s <= numStations; s++) { - offsets[s] += offsets[s - 1]; - } - - const next = offsets.slice(0, numStations); - const index: Transfers = { - offsets, - origin: new Int32Array(transfers.length), - destination: new Int32Array(transfers.length), - duration: new Int32Array(transfers.length), - transfer: new Array(transfers.length) - }; - - for (const transfer of transfers) { - const origin = stationIndex.get(transfer.origin)!; - const i = next[origin]++; - - index.origin[i] = origin; - index.destination[i] = stationIndex.get(transfer.destination)!; - index.duration[i] = transfer.duration; - index.transfer[i] = transfer; - } - - return index; -} - -function indexServices(trips: Trip[]): { tripService: Int32Array, services: ServiceCalendar[] } { - const tripService = new Int32Array(trips.length); - const services: ServiceCalendar[] = []; - const serviceIndex = new Map(); - - for (let t = 0; t < trips.length; t++) { - let index = serviceIndex.get(trips[t].service); - - if (index === undefined) { - index = services.length; - services.push(trips[t].service); - serviceIndex.set(trips[t].service, index); - } - - tripService[t] = index; - } - - return { tripService, services }; -} diff --git a/test/integration.ts b/test/integration.ts index 1e372fe..4152e06 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -1,8 +1,11 @@ import * as fs from "node:fs"; +import { ConnectionScanAlgorithm } from "../src/csa/ConnectionScanAlgorithm.js"; +import { ScanResultsFactory } from "../src/csa/ScanResultsFactory.js"; +import { loadGtfs } from "../src/gtfs/GtfsLoader.js"; import { journeyToString } from "../src/journey/Journey.js"; +import { JourneyFactory } from "../src/journey/JourneyFactory.js"; import { DepartAfterQuery } from "../src/query/DepartAfterQuery.js"; import { MultipleCriteriaFilter } from "../src/query/MultipleCriteriaFilter.js"; -import { loadTimetable } from "../src/timetable/Timetable.js"; async function run() { const filename = process.argv[2] || "gtfs.zip"; @@ -12,10 +15,11 @@ async function run() { console.log(`Loading ${filename}`); console.time("initial load"); - const timetable = await loadTimetable(fs.createReadStream(filename)); + const gtfs = await loadGtfs(fs.createReadStream(filename)); console.timeEnd("initial load"); - const query = new DepartAfterQuery(timetable, [new MultipleCriteriaFilter()]); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs), [new MultipleCriteriaFilter()]); console.time("query"); const results = query.plan(origins, destinations, new Date(), time); diff --git a/test/performance.ts b/test/performance.ts index d54be19..4940a1d 100644 --- a/test/performance.ts +++ b/test/performance.ts @@ -1,7 +1,9 @@ import * as fs from "node:fs"; -import { loadGTFS } from "@gb-transit/gtfs-loader"; +import { ConnectionScanAlgorithm } from "../src/csa/ConnectionScanAlgorithm.js"; +import { ScanResultsFactory } from "../src/csa/ScanResultsFactory.js"; +import { loadGtfs } from "../src/gtfs/GtfsLoader.js"; +import { JourneyFactory } from "../src/journey/JourneyFactory.js"; import { DepartAfterQuery } from "../src/query/DepartAfterQuery.js"; -import { createTimetable } from "../src/timetable/Timetable.js"; const queries = [ [["MRF", "LVC", "LVJ", "LIV"], ["NRW"]], @@ -62,12 +64,11 @@ const queries = [ async function run() { console.time("initial load"); - const feed = await loadGTFS(fs.createReadStream(process.argv[2] || "gtfs.zip")); + const gtfs = await loadGtfs(fs.createReadStream(process.argv[2] || "gtfs.zip")); console.timeEnd("initial load"); - console.time("timetable"); - const query = new DepartAfterQuery(createTimetable(feed)); - console.timeEnd("timetable"); + const csa = new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)); + const query = new DepartAfterQuery(csa, new JourneyFactory(gtfs)); console.time("planning"); const date = new Date(); diff --git a/test/unit/csa/ConnectionScanAlgorithm.spec.ts b/test/unit/csa/ConnectionScanAlgorithm.spec.ts index cc51788..143e184 100644 --- a/test/unit/csa/ConnectionScanAlgorithm.spec.ts +++ b/test/unit/csa/ConnectionScanAlgorithm.spec.ts @@ -1,12 +1,13 @@ import { Service } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; import { ConnectionScanAlgorithm } from "../../../src/csa/ConnectionScanAlgorithm.js"; -import { createTimetable, NOT_REACHED } from "../../../src/timetable/Timetable.js"; -import { allDays, byOrigin, feed, legsOf, pickUpOnly, plan, st, trip, walk } from "../util.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"; describe("ConnectionScanAlgorithm", () => { - it("plans a basic journey", () => { + it("plan a basic journey", () => { const [journey] = plan({ trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045), st("D", 1115)])] }, ["A"], ["D"], 900); @@ -43,7 +44,7 @@ describe("ConnectionScanAlgorithm", () => { expect(plan({ trips: [trip("1", [st("A", 1000), st("B", 1015)], septemberOnly)] }, ["A"], ["B"], 900)).toEqual([]); }); - it("plans a journey that starts with a footpath", () => { + it("plan a journey that starts with a transfer", () => { const [journey] = plan({ trips: [trip("1", [st("B", 1020), st("C", 1045), st("D", 1115)])], transfers: byOrigin(walk("A", "B", 10)) @@ -54,7 +55,7 @@ describe("ConnectionScanAlgorithm", () => { expect(journey.arrivalTime).toBe(1115); }); - it("plans a journey that ends with a footpath", () => { + it("plan a journey that ends with a transfer", () => { const [journey] = plan({ trips: [trip("1", [st("A", 1000), st("B", 1015), st("C", 1045)])], transfers: byOrigin(walk("C", "D", 10)) @@ -75,36 +76,12 @@ describe("ConnectionScanAlgorithm", () => { expect(journey.arrivalTime).toBe(1300); }); - it("does not change where there is less than the interchange time", () => { - const trips = [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1030), st("C", 1100)])]; - - expect(plan({ trips, interchange: { B: 100 } }, ["A"], ["C"], 900)).toEqual([]); - }); - - it("changes in no time at a station with no interchange time", () => { - const [journey] = plan({ - trips: [trip("1", [st("A", 1000), st("B", 1015)]), trip("2", [st("B", 1015), st("C", 1100)])] - }, ["A"], ["C"], 900); - - expect(legsOf(journey)).toEqual(["1:A-B", "2:B-C"]); - }); - - it("charges the interchange time at both ends of a footpath", () => { - const trips = (departs: number) => [ - trip("1", [st("A", 1000), st("B", 1015)]), - trip("2", [st("C", departs), st("D", 1700)]) - ]; - const overrides = { interchange: { B: 300, C: 200 }, transfers: byOrigin(walk("B", "C", 60)) }; - - expect(plan({ ...overrides, trips: trips(1574) }, ["A"], ["D"], 900)).toEqual([]); - expect(plan({ ...overrides, trips: trips(1575) }, ["A"], ["D"], 900).length).toBe(1); - }); - /** - * Two trips run in parallel. Trip 1 arrives earliest at B and C and trip 2 earliest at D. Changing - * onto trip 2 at C is ruled out by the interchange time, but it could have been boarded at A, so the - * scan reaches D on it. The connections found are trip 1 to C and trip 2 from C, and the journey is - * tidied up by realising the whole of it can be made on trip 2. + * 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). */ it("checks for connections missed because of interchange time", () => { const [journey] = plan({ @@ -152,17 +129,17 @@ describe("ConnectionScanAlgorithm", () => { expect(legsOf(journey)).toEqual(["1:A-D"]); }); - it("gives each scan results of its own", () => { - const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), st("B", 1100), st("C", 1200)])] })); - const [a, b, c] = ["A", "B", "C"].map(code => timetable.stationIndex.get(code)!); - const csa = new ConnectionScanAlgorithm(timetable); + 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)); + const [a, b] = ["A", "B"].map(code => gtfs.stopTable.indexOf(code)); - const fromA = csa.scan(new Map([[a, 900]]), [c], 20260908, 2); - const fromB = csa.scan(new Map([[b, 900]]), [c], 20260908, 2); + const fromA = csa.scan({ A: 900 }, ["C"], 20260908, 2); + const fromB = csa.scan({ B: 900 }, ["C"], 20260908, 2); - expect(fromA.earliestArrivals[b]).toBe(1100); - expect(fromB.earliestArrivals[a]).toBe(NOT_REACHED); - expect(fromB.earliestArrivals[c]).toBe(1200); + expect(fromA[b]).not.toBe(NO_CONNECTION); + expect(fromB[a]).toBe(NO_CONNECTION); + expect(fromB[b]).toBe(NO_CONNECTION); }); }); diff --git a/test/unit/csa/ScanResults.spec.ts b/test/unit/csa/ScanResults.spec.ts new file mode 100644 index 0000000..be6fd07 --- /dev/null +++ b/test/unit/csa/ScanResults.spec.ts @@ -0,0 +1,184 @@ +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"; + +describe("ScanResults", () => { + const gtfs = gtfsOf({ + trips: [ + trip("LN1111", [st("A", 1000), st("B", 1015)]), + trip("LN1112", [st("A", 1000), st("B", 1010)]), + trip("LN1113", [st("A", 1000), st("B", 1030)]), + trip("LN1114", [st("B", 1030), st("C", 1100)]) + ], + transfers: byOrigin(walk("A", "B", 10), walk("A", "C", 1000), walk("B", "C", 10)) + }); + + it("knows if a connection is reachable", () => { + const results = resultsFor(gtfs, { A: 900 }); + + expect(results.isReachable(connection(gtfs, "LN1111", "A", "B"))).toBe(true); + }); + + it("knows if a connection is not reachable", () => { + const results = resultsFor(gtfs, { A: 1200 }); + + expect(results.isReachable(connection(gtfs, "LN1111", "A", "B"))).toBe(false); + }); + + it("knows if a connection is not reachable because of interchange", () => { + const withInterchange = gtfsOf({ + trips: [trip("LN1111", [st("A", 1000), st("B", 1015)]), trip("LN1112", [st("B", 1030), st("C", 1100)])], + interchange: { B: 100 } + }); + const results = resultsFor(withInterchange, { A: 900 }); + + results.setConnection(connection(withInterchange, "LN1111", "A", "B")); + + expect(results.isReachable(connection(withInterchange, "LN1112", "B", "C"))).toBe(false); + }); + + 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")); + + expect(results.isReachable(connection(gtfs, "LN1114", "B", "C"))).toBe(true); + }); + + it("knows if a connection is better", () => { + const results = resultsFor(gtfs, { A: 900 }); + + results.setConnection(connection(gtfs, "LN1111", "A", "B")); + + 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")); + + expect(results.isBetter(connection(gtfs, "LN1113", "A", "B"))).toBe(false); + }); + + it("prefers staying aboard to changing onto a trip arriving at the same time", () => { + const coupled = gtfsOf({ + trips: [ + trip("front", [st("A", 1000), st("B", 1015)]), + trip("rear", [st("B", 1030), st("C", 1100)]), + trip("through", [st("A", 1000), st("B", 1015), st("C", 1100)]) + ], + interchange: { B: 5 } + }); + const results = resultsFor(coupled, { A: 900 }); + + for (const c of [connection(coupled, "front", "A", "B"), connection(coupled, "through", "A", "B"), connection(coupled, "rear", "B", "C")]) { + if (results.isReachable(c) && results.isBetter(c)) { + results.setConnection(c); + } + } + + const throughToC = connection(coupled, "through", "B", "C"); + + expect(results.isReachable(throughToC)).toBe(true); + expect(results.isBetter(throughToC)).toBe(true); + }); + + it("does not replace a trip with another arriving at the same time that it would have to change onto", () => { + const parallel = gtfsOf({ + trips: [ + trip("1", [st("A", 1000), st("B", 1015)]), + trip("2", [st("B", 1030), st("C", 1100)]), + trip("3", [st("B", 1030), st("C", 1100)]) + ], + interchange: { B: 5 } + }); + const results = resultsFor(parallel, { A: 900 }); + + results.setConnection(connection(parallel, "1", "A", "B")); + results.setConnection(connection(parallel, "2", "B", "C")); + + const alsoToC = connection(parallel, "3", "B", "C"); + + expect(results.isReachable(alsoToC)).toBe(true); + expect(results.isBetter(alsoToC)).toBe(false); + }); + + 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 }); + + expect(results.isReachable(connection(pickUps, "1", "A", "C"))).toBe(true); + expect(results.isReachable(connection(pickUps, "1", "B", "C"))).toBe(false); + }); + + it("knows if a transfer is better", () => { + const results = resultsFor(gtfs, { A: 900 }); + + results.setConnection(connection(gtfs, "LN1111", "A", "B")); + + expect(results.isTransferBetter(transfer(gtfs, "A", "B"))).toBe(true); + }); + + it("knows if a transfer is not better", () => { + const results = resultsFor(gtfs, { A: 900 }); + + results.setTransfer(transfer(gtfs, "A", "B")); + + expect(results.isTransferBetter(transfer(gtfs, "A", "C"))).toBe(true); + + results.setTransfer(transfer(gtfs, "B", "C")); + + expect(results.isTransferBetter(transfer(gtfs, "A", "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)])], + transfers: byOrigin(walk("B", "C", 60)), + interchange: { B: 300, C: 200 } + }); + const results = resultsFor(walking, { A: 900 }); + + results.setConnection(connection(walking, "1", "A", "B")); + results.setTransfer(transfer(walking, "B", "C")); + + expect(results.isReachable(connection(walking, "2", "C", "D"))).toBe(false); + expect(results.isReachable(connection(walking, "3", "C", "D"))).toBe(true); + }); + + it("returns the connection index", () => { + const results = resultsFor(gtfs, { A: 900 }); + const [b, c] = ["B", "C"].map(code => gtfs.stopTable.indexOf(code)); + + results.setConnection(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("is finished once a connection arrives after every destination was reached", () => { + const results = resultsFor(gtfs, { A: 900 }, ["B"]); + + expect(results.isFinished(connection(gtfs, "LN1112", "A", "B"))).toBe(false); + + results.setConnection(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); + }); + +}); + +describe("ScanResultsFactory", () => { + + it("creates a ScanResults object", () => { + const gtfs = gtfsOf({}); + + expect(new ScanResultsFactory(gtfs).create({ A: 900 }, [])).toBeInstanceOf(ScanResults); + }); + +}); diff --git a/test/unit/gtfs/GtfsLoader.spec.ts b/test/unit/gtfs/GtfsLoader.spec.ts new file mode 100644 index 0000000..ef98d46 --- /dev/null +++ b/test/unit/gtfs/GtfsLoader.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { type GtfsData, toGtfsData } from "../../../src/gtfs/GtfsLoader.js"; +import { UNKNOWN_STOP } from "../../../src/gtfs/StopTable.js"; +import { allDays, byOrigin, everyDay, feed, platforms, st, trip, walk } from "../util.js"; +import { Service } from "@gb-transit/gtfs-loader"; + +/** + * The connections as the stations they run between, in the order the scan reads them. + */ +function pairs(gtfs: GtfsData): string[] { + const { connections, stopTable } = gtfs; + + return Array.from( + { length: connections.length }, + (_, c) => `${stopTable.nameOf(connections.departureStation[c])}-${stopTable.nameOf(connections.arrivalStation[c])}` + ); +} + +describe("toGtfsData", () => { + + it("creates connections between the stations the platforms belong to", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200)])] + })); + const { connections } = gtfs; + + expect(pairs(gtfs)).toEqual(["NRW-DIS", "DIS-LST"]); + expect([...connections.departureTime]).toEqual([1000, 1100]); + expect([...connections.arrivalTime]).toEqual([1100, 1200]); + expect([...connections.board]).toEqual([0, 1]); + expect([...connections.alight]).toEqual([1, 2]); + expect(gtfs.stopTable.indexOf("NRW")).toBe(0); + expect(gtfs.stopTable.indexOf("NRW1")).toBe(UNKNOWN_STOP); + expect(gtfs.stations.get("NRW1")).toBe("NRW"); + }); + + it("sorts the connections by arrival, keeping the order they were created in where they arrive together", () => { + const gtfs = toGtfsData(feed({ + trips: [ + trip("1", [st("A", 1000), st("D", 1300)]), + trip("2", [st("B", 1100), st("C", 1200)]), + trip("3", [st("E", 1250), st("F", 1300)]) + ] + })); + + expect(pairs(gtfs)).toEqual(["B-C", "A-D", "E-F"]); + expect([...gtfs.connections.trip].map(t => gtfs.trips[t].tripId)).toEqual(["2", "1", "3"]); + }); + + it("carries on past a call that can only be alighted at until one that can be boarded", () => { + const setDownOnly = { ...st("B", 1100), pickUp: false }; + const gtfs = toGtfsData(feed({ trips: [trip("1", [st("A", 1000), setDownOnly, st("C", 1200)])] })); + + expect(pairs(gtfs)).toEqual(["A-B", "A-C"]); + }); + + it("does not create a connection to or from a passing point", () => { + const passing = { ...st("B", 1100), pickUp: false, dropOff: false }; + const gtfs = toGtfsData(feed({ trips: [trip("1", [st("A", 1000), passing, st("C", 1200)])] })); + + expect(pairs(gtfs)).toEqual(["A-C"]); + expect([...gtfs.connections.alight]).toEqual([1]); + }); + + it("does not create a connection between two platforms of one station", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200)])] + })); + + expect(pairs(gtfs)).toEqual(["NRW-LST"]); + }); + + it("adds the trip a passenger stays on across a coupling", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("front", [st("NRW1", 1000), st("DIS2", 1100)]), trip("rear", [st("DIS1", 1130), st("LST8", 1230)])], + links: [{ fromTripId: "front", toTripId: "rear", fromStop: "DIS2", toStop: "DIS1" }] + })); + const { connections, trips } = gtfs; + + expect(pairs(gtfs).map((pair, c) => `${trips[connections.trip[c]].tripId}:${pair}`)).toEqual([ + "front:NRW-DIS", + "front_rear:NRW-DIS", + "rear:DIS-LST", + "front_rear:DIS-LST" + ]); + }); + + it("indexes footpaths by the origin station and drops those within one", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + transfers: byOrigin(walk("NRW1", "NRW2", 300), walk("NRW1", "DIS2", 600), walk("DIS1", "IPS1", 900)) + })); + const { offsets, destination, duration, transfer } = gtfs.transfers; + const from = (code: string) => { + const s = gtfs.stopTable.indexOf(code); + + return Array.from({ length: offsets[s + 1] - offsets[s] }, (_, i) => offsets[s] + i); + }; + + expect(from("NRW").map(i => [gtfs.stopTable.nameOf(destination[i]), duration[i]])).toEqual([["DIS", 600]]); + expect(from("DIS").map(i => transfer[i].destination)).toEqual(["IPS"]); + expect(from("IPS")).toEqual([]); + }); + + it("reports interchange time against the station, and none where the feed gives none", () => { + const gtfs = toGtfsData(feed({ + stops: platforms, + trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], + interchange: { NRW1: 300 } + })); + + expect(gtfs.interchange[gtfs.stopTable.indexOf("NRW")]).toBe(300); + expect(gtfs.interchange[gtfs.stopTable.indexOf("DIS")]).toBe(0); + }); + + it("knows which trips run on a date, asking each calendar once", () => { + let asked = 0; + const counting = { runsOn: () => { asked++; return true; }, dayEarlier: () => counting }; + const septemberOnly = new Service(20260901, 20260907, allDays, {}); + const gtfs = toGtfsData(feed({ + trips: [ + trip("1", [st("A", 1000), st("B", 1100)], counting), + trip("2", [st("B", 1200), st("C", 1300)], counting), + trip("3", [st("C", 1400), st("D", 1500)], septemberOnly), + trip("4", [st("D", 1600), st("E", 1700)], everyDay) + ] + })); + + expect([...gtfs.calendar.runningOn(20260908, 2)]).toEqual([1, 1, 0, 1]); + expect([...gtfs.calendar.runningOn(20260908, 2)]).toEqual([1, 1, 0, 1]); + expect(asked).toBe(1); + expect([...gtfs.calendar.runningOn(20260905, 6)]).toEqual([1, 1, 1, 1]); + }); + +}); diff --git a/test/unit/journey/Connection.spec.ts b/test/unit/journey/Connection.spec.ts new file mode 100644 index 0000000..44b042d --- /dev/null +++ b/test/unit/journey/Connection.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + isChangeRequired, isTransferConnection, NO_CONNECTION, transferConnection, transferOf +} from "../../../src/journey/Connection.js"; +import { connection, gtfsOf, st, trip } from "../util.js"; + +describe("Connection", () => { + const gtfs = gtfsOf({ + trips: [trip("LN1111", [st("A", 1000), st("B", 1030), st("C", 1100)]), trip("LN1112", [st("A", 1000), st("B", 1030)])] + }); + + it("knows if it's a transfer", () => { + expect(isTransferConnection(transferConnection(0))).toBe(true); + expect(transferOf(transferConnection(3))).toBe(3); + }); + + it("knows if it's not a transfer", () => { + expect(isTransferConnection(connection(gtfs, "LN1111", "A", "B"))).toBe(false); + expect(isTransferConnection(NO_CONNECTION)).toBe(false); + }); + + it("knows if a change is required", () => { + expect(isChangeRequired(gtfs.connections, connection(gtfs, "LN1111", "A", "B"), connection(gtfs, "LN1112", "A", "B"))).toBe(true); + }); + + it("knows if a change is not required", () => { + expect(isChangeRequired(gtfs.connections, connection(gtfs, "LN1111", "A", "B"), connection(gtfs, "LN1111", "B", "C"))).toBe(false); + }); + + it("knows if a change is required between a transfer", () => { + const timetableConnection = connection(gtfs, "LN1111", "A", "B"); + + expect(isChangeRequired(gtfs.connections, timetableConnection, transferConnection(0))).toBe(true); + expect(isChangeRequired(gtfs.connections, transferConnection(0), timetableConnection)).toBe(true); + }); + +}); diff --git a/test/unit/journey/JourneyFactory.spec.ts b/test/unit/journey/JourneyFactory.spec.ts new file mode 100644 index 0000000..1de7027 --- /dev/null +++ b/test/unit/journey/JourneyFactory.spec.ts @@ -0,0 +1,98 @@ +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"; + +describe("JourneyFactory", () => { + + it("creates a journey from a connection index", () => { + 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")); + + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["B"]); + + expect(journey.origin).toBe("A"); + expect(journey.destination).toBe("B"); + expect(journey.departureTime).toBe(1000); + expect(journey.arrivalTime).toBe(1030); + }); + + it("returns no journey to a destination that was not reached or the feed does not have", () => { + const gtfs = gtfsOf({ trips: [trip("1", [st("A", 1000), st("B", 1030)])] }); + const results = resultsFor(gtfs, { A: 1000 }); + + expect(new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["B", "Z"])).toEqual([]); + }); + + it("calculates the departure time", () => { + const gtfs = gtfsOf({ + trips: [trip("1", [st("B", 1100), st("C", 1130)])], + transfers: byOrigin(walk("A", "B", 60)) + }); + const results = resultsFor(gtfs, { A: 1000 }); + + results.setTransfer(transfer(gtfs, "A", "B")); + results.setConnection(connection(gtfs, "1", "B", "C")); + + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["C"]); + + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-C"]); + expect(journey.departureTime).toBe(1040); + expect(journey.arrivalTime).toBe(1130); + }); + + it("calculates the arrival time", () => { + const gtfs = gtfsOf({ + trips: [trip("1", [st("B", 1100), st("C", 1130)])], + transfers: byOrigin(walk("A", "B", 60), walk("C", "D", 60)) + }); + const results = resultsFor(gtfs, { A: 1000 }); + + results.setTransfer(transfer(gtfs, "A", "B")); + results.setConnection(connection(gtfs, "1", "B", "C")); + results.setTransfer(transfer(gtfs, "C", "D")); + + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["D"]); + + expect(legsOf(journey)).toEqual(["walk:A-B", "1:B-C", "walk:C-D"]); + expect(journey.departureTime).toBe(1040); + 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 }); + + 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")); + + 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)])] }); + const results = resultsFor(gtfs, { NRW: 900 }); + + results.setConnection(connection(gtfs, "1", "NRW", "LST")); + + const [journey] = new JourneyFactory(gtfs).getJourneys(results.getConnectionIndex(), ["LST"]); + const leg = journey.legs[0] as TimetableLeg; + + expect(leg.origin).toBe("NRW"); + expect(leg.destination).toBe("LST"); + expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "LST8"]); + }); + +}); diff --git a/test/unit/query/DepartAfterQuery.spec.ts b/test/unit/query/DepartAfterQuery.spec.ts index 716cbad..5a0dad8 100644 --- a/test/unit/query/DepartAfterQuery.spec.ts +++ b/test/unit/query/DepartAfterQuery.spec.ts @@ -1,13 +1,11 @@ import { type GTFSFeed, Service, type ServiceCalendar } from "@gb-transit/gtfs-loader"; import { describe, expect, it } from "vitest"; import type { TimetableLeg } from "../../../src/journey/Journey.js"; -import { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; -import { MultipleCriteriaFilter } from "../../../src/query/MultipleCriteriaFilter.js"; -import { createTimetable } from "../../../src/timetable/Timetable.js"; -import { allDays, feed, platforms, st, trip, TUESDAY } from "../util.js"; +import type { DepartAfterQuery } from "../../../src/query/DepartAfterQuery.js"; +import { allDays, gtfsOf, platforms, queryOver, st, trip, TUESDAY } from "../util.js"; function query(overrides: Partial): DepartAfterQuery { - return new DepartAfterQuery(createTimetable(feed({ stops: platforms, ...overrides })), [new MultipleCriteriaFilter()]); + return queryOver(gtfsOf({ stops: platforms, ...overrides })); } function tripsOf(legs: unknown[]): string[] { @@ -29,24 +27,6 @@ describe("DepartAfterQuery", () => { expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "DIS2"]); }); - it("leaves the points a train passes through out of its leg", () => { - const passing = { ...st("DIS2", 1050), pickUp: false, dropOff: false }; - const [journey] = query({ trips: [trip("1", [st("NRW1", 1000), passing, st("LST8", 1200)])] }) - .plan(["NRW"], ["LST"], TUESDAY, 900); - const [leg] = journey.legs as TimetableLeg[]; - - expect(leg.stopTimes.map(s => s.stop)).toEqual(["NRW1", "LST8"]); - expect(leg.trip.stopTimes.length).toBe(3); - }); - - it("returns nothing where every origin or every destination is unknown", () => { - const planner = query({ trips: [front] }); - - expect(planner.plan(["XXX"], ["DIS"], TUESDAY, 900)).toEqual([]); - expect(planner.plan(["NRW"], ["XXX"], TUESDAY, 900)).toEqual([]); - expect(planner.plan(["XXX", "NRW"], ["DIS"], TUESDAY, 900).length).toBe(1); - }); - it("stays aboard across a coupling rather than changing where there is time to", () => { const journeys = query({ trips: [front, rear], links: [coupling], interchange: { DIS: 10 } }) .plan(["NRW"], ["LST"], TUESDAY, 900); diff --git a/test/unit/timetable/Timetable.spec.ts b/test/unit/timetable/Timetable.spec.ts deleted file mode 100644 index fa1bebe..0000000 --- a/test/unit/timetable/Timetable.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createTimetable, type Timetable } from "../../../src/timetable/Timetable.js"; -import { byOrigin, everyDay, feed, platforms, st, trip, walk } from "../util.js"; - -/** - * The connections as the stations they run between, in the order the scan reads them. - */ -function pairs(timetable: Timetable): string[] { - const { connections, stations } = timetable; - - return Array.from( - { length: connections.length }, - (_, c) => `${stations[connections.departureStation[c]]}-${stations[connections.arrivalStation[c]]}` - ); -} - -describe("createTimetable", () => { - - it("creates connections between the stations the platforms belong to", () => { - const timetable = createTimetable(feed({ - stops: platforms, - trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100), st("LST8", 1200)])] - })); - const { connections } = timetable; - - expect(pairs(timetable)).toEqual(["NRW-DIS", "DIS-LST"]); - expect([...connections.departureTime]).toEqual([1000, 1100]); - expect([...connections.arrivalTime]).toEqual([1100, 1200]); - expect(timetable.stationIndex.get("NRW")).toBe(0); - expect([...connections.board].map(k => timetable.calls[k].stop)).toEqual(["NRW1", "DIS2"]); - expect([...connections.alight].map(k => timetable.calls[k].stop)).toEqual(["DIS2", "LST8"]); - }); - - it("sorts the connections by arrival, keeping the order they were created in where they arrive together", () => { - const timetable = createTimetable(feed({ - trips: [ - trip("1", [st("A", 1000), st("D", 1300)]), - trip("2", [st("B", 1100), st("C", 1200)]), - trip("3", [st("E", 1250), st("F", 1300)]) - ] - })); - - expect(pairs(timetable)).toEqual(["B-C", "A-D", "E-F"]); - expect([...timetable.connections.trip].map(t => timetable.trips[t].tripId)).toEqual(["2", "1", "3"]); - }); - - it("carries on past a call that can only be alighted at until one that can be boarded", () => { - const setDownOnly = { ...st("B", 1100), pickUp: false }; - const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), setDownOnly, st("C", 1200)])] })); - - expect(pairs(timetable)).toEqual(["A-B", "A-C"]); - }); - - it("does not create a connection to or from a passing point", () => { - const passing = { ...st("B", 1100), pickUp: false, dropOff: false }; - const timetable = createTimetable(feed({ trips: [trip("1", [st("A", 1000), passing, st("C", 1200)])] })); - - expect(pairs(timetable)).toEqual(["A-C"]); - expect(timetable.calls.map(call => call.stop)).toEqual(["A", "C"]); - }); - - it("does not create a connection between two platforms of one station", () => { - const timetable = createTimetable(feed({ - stops: platforms, - trips: [trip("1", [st("NRW1", 1000), st("NRW2", 1005), st("LST8", 1200)])] - })); - - expect(pairs(timetable)).toEqual(["NRW-LST"]); - }); - - it("adds the trip a passenger stays on across a coupling", () => { - const timetable = createTimetable(feed({ - stops: platforms, - trips: [trip("front", [st("NRW1", 1000), st("DIS2", 1100)]), trip("rear", [st("DIS1", 1130), st("LST8", 1230)])], - links: [{ fromTripId: "front", toTripId: "rear", fromStop: "DIS2", toStop: "DIS1" }] - })); - const { connections, trips } = timetable; - - expect(pairs(timetable).map((pair, c) => `${trips[connections.trip[c]].tripId}:${pair}`)).toEqual([ - "front:NRW-DIS", - "front_rear:NRW-DIS", - "rear:DIS-LST", - "front_rear:DIS-LST" - ]); - }); - - it("indexes footpaths by the origin station and drops those within one", () => { - const timetable = createTimetable(feed({ - stops: platforms, - trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], - transfers: byOrigin(walk("NRW1", "NRW2", 300), walk("NRW1", "DIS2", 600), walk("DIS1", "IPS1", 900)) - })); - const { offsets, destination, duration, transfer } = timetable.transfers; - const from = (code: string) => { - const s = timetable.stationIndex.get(code)!; - - return Array.from({ length: offsets[s + 1] - offsets[s] }, (_, i) => offsets[s] + i); - }; - - expect(from("NRW").map(i => [timetable.stations[destination[i]], duration[i]])).toEqual([["DIS", 600]]); - expect(from("DIS").map(i => transfer[i].destination)).toEqual(["IPS"]); - expect(from("IPS")).toEqual([]); - }); - - it("reports interchange time against the station, and none where the feed gives none", () => { - const timetable = createTimetable(feed({ - stops: platforms, - trips: [trip("1", [st("NRW1", 1000), st("DIS2", 1100)])], - interchange: { NRW1: 300 } - })); - - expect(timetable.interchange[timetable.stationIndex.get("NRW")!]).toBe(300); - expect(timetable.interchange[timetable.stationIndex.get("DIS")!]).toBe(0); - }); - - it("holds each calendar once, however many trips run to it", () => { - const timetable = createTimetable(feed({ - trips: [trip("1", [st("A", 1000), st("B", 1100)]), trip("2", [st("B", 1200), st("C", 1300)], everyDay)] - })); - - expect(timetable.services.length).toBe(1); - expect([...timetable.tripService]).toEqual([0, 0]); - }); - -}); diff --git a/test/unit/util.ts b/test/unit/util.ts index a0055ad..2775922 100644 --- a/test/unit/util.ts +++ b/test/unit/util.ts @@ -2,10 +2,15 @@ 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 { ScanResults } from "../../src/csa/ScanResults.js"; +import { ScanResultsFactory } from "../../src/csa/ScanResultsFactory.js"; +import { type GtfsData, toGtfsData } from "../../src/gtfs/GtfsLoader.js"; +import type { Connection } from "../../src/journey/Connection.js"; import type { Journey, TimetableLeg } from "../../src/journey/Journey.js"; +import { JourneyFactory } from "../../src/journey/JourneyFactory.js"; import { DepartAfterQuery } from "../../src/query/DepartAfterQuery.js"; import { MultipleCriteriaFilter } from "../../src/query/MultipleCriteriaFilter.js"; -import { createTimetable } from "../../src/timetable/Timetable.js"; export const allDays = { 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true }; @@ -41,6 +46,20 @@ export function walk(origin: StopID, destination: StopID, duration: Time): Trans return { origin, destination, duration, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }; } +/** + * The footpaths indexed by the stop they leave from, as the loader gives them. + */ +export function byOrigin(...transfers: Transfer[]): GTFSFeed["transfers"] { + const index: GTFSFeed["transfers"] = {}; + + for (const transfer of transfers) { + index[transfer.origin] ??= []; + index[transfer.origin].push(transfer); + } + + return index; +} + /** * A feed of the given trips and footpaths. A stop the feed has no entry for is a station of its own, * so a spec that is not about platforms can name its stations directly. @@ -60,23 +79,9 @@ export function feed(overrides: Partial = {}): GTFSFeed { }; } -/** - * The same footpaths indexed by the stop they leave from, as the loader gives them. - */ -export function byOrigin(...transfers: Transfer[]): GTFSFeed["transfers"] { - const index: GTFSFeed["transfers"] = {}; - - for (const transfer of transfers) { - index[transfer.origin] ??= []; - index[transfer.origin].push(transfer); - } - - return index; -} - /** * Stops that are platforms, each belonging to a station named by its stop_code. That is how the GB - * rail feed identifies a station, and what the timetable resolves a call to. + * rail feed identifies a station, and what the loader resolves a call to. */ export const platforms: Record = { NRW: station("NRW"), @@ -99,12 +104,61 @@ function platform(id: StopID, parentStation: StopID): Stop { return { id, latitude: 0, longitude: 0, locationType: 0, parentStation }; } +export function gtfsOf(overrides: Partial): GtfsData { + return toGtfsData(feed(overrides)); +} + +/** + * The connection of a trip between two stations, which a spec names rather than numbers. + */ +export function connection(gtfs: GtfsData, tripId: string, origin: StopID, destination: StopID): Connection { + const { connections, stopTable, trips } = gtfs; + + for (let c = 0; c < connections.length; c++) { + if ( + trips[connections.trip[c]].tripId === tripId && + stopTable.nameOf(connections.departureStation[c]) === origin && + stopTable.nameOf(connections.arrivalStation[c]) === destination + ) { + return c; + } + } + + throw new Error(`No connection of ${tripId} from ${origin} to ${destination}`); +} + /** - * Plan over a feed with the query a caller would make. + * The footpath between two stations, as an index into the feed's transfers. + */ +export function transfer(gtfs: GtfsData, origin: StopID, destination: StopID): number { + const { transfers, stopTable } = gtfs; + + for (let t = 0; t < transfers.transfer.length; t++) { + if (stopTable.nameOf(transfers.origin[t]) === origin && stopTable.nameOf(transfers.destination[t]) === destination) { + return t; + } + } + + throw new Error(`No footpath from ${origin} to ${destination}`); +} + +export function resultsFor(gtfs: GtfsData, origins: Record, destinations: StopID[] = []): ScanResults { + return new ScanResultsFactory(gtfs).create(origins, destinations); +} + +/** + * Plan over a feed with the pieces a caller would wire together. */ export function plan(overrides: Partial, origins: StopID[], destinations: StopID[], time: Time): Journey[] { - return new DepartAfterQuery(createTimetable(feed(overrides)), [new MultipleCriteriaFilter()]) - .plan(origins, destinations, TUESDAY, time); + return queryOver(gtfsOf(overrides)).plan(origins, destinations, TUESDAY, time); +} + +export function queryOver(gtfs: GtfsData): DepartAfterQuery { + return new DepartAfterQuery( + new ConnectionScanAlgorithm(gtfs, new ScanResultsFactory(gtfs)), + new JourneyFactory(gtfs), + [new MultipleCriteriaFilter()] + ); } /**