├── .gitignore ├── .travis.yml ├── .npmignore ├── src ├── query │ ├── JourneyFilter.ts │ ├── DepartAfterQuery.ts │ └── MultipleCriteriaFilter.ts ├── csa │ ├── ScanResultsFactory.spec.ts │ ├── ScanResultsFactory.ts │ ├── ConnectionScanAlgorithm.ts │ ├── ScanResults.ts │ ├── ScanResults.spec.ts │ └── ConnectionScanAlgorithm.spec.ts ├── index.ts ├── gtfs │ ├── TimeParser.spec.ts │ ├── TimeParser.ts │ ├── Service.ts │ ├── Gtfs.ts │ ├── Service.spec.ts │ └── GtfsLoader.ts ├── journey │ ├── Connection.ts │ ├── Connection.spec.ts │ ├── Journey.ts │ ├── JourneyFactory.spec.ts │ └── JourneyFactory.ts ├── cli.ts ├── transfer-patterns.ts ├── transfer-pattern │ ├── TransferPatternRepository.ts │ └── TransferPatternConnectionScan.ts ├── transfer-pattern-worker.ts └── performance.ts ├── tsconfig.json ├── package.json ├── tslint.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | dist/ 4 | .nyc_output/ 5 | report*.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 11 4 | 5 | install: npm install 6 | script: npm test 7 | 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | npm-debug.log 4 | src/ 5 | tsconfig.json 6 | .travis.yml 7 | .nyc_output/ 8 | report*.json -------------------------------------------------------------------------------- /src/query/JourneyFilter.ts: -------------------------------------------------------------------------------- 1 | import { Journey } from "../journey/Journey"; 2 | 3 | /** 4 | * Filter a number journeys 5 | */ 6 | export interface JourneyFilter { 7 | apply(journeys: Journey[]): Journey[]; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "sourceMap": false, 7 | "experimentalDecorators": true, 8 | "noImplicitAny": false, 9 | "strict": true, 10 | "declaration": true, 11 | "lib": [ 12 | "esnext", 13 | "dom" 14 | ] 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "dist/" 19 | ] 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/csa/ScanResultsFactory.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { ScanResults } from "./ScanResults"; 3 | import { ScanResultsFactory } from "./ScanResultsFactory"; 4 | 5 | describe("ScanResultsFactory", () => { 6 | 7 | it("creates a ScanResults object", () => { 8 | const factory = new ScanResultsFactory({}); 9 | const actual = factory.create({ "A": 900 }); 10 | 11 | chai.expect(actual).to.be.instanceOf(ScanResults); 12 | }); 13 | 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export * from "./csa/ConnectionScanAlgorithm"; 3 | export * from "./csa/ScanResults"; 4 | export * from "./csa/ScanResultsFactory"; 5 | 6 | export * from "./gtfs/GtfsLoader"; 7 | export * from "./gtfs/Gtfs"; 8 | 9 | export * from "./journey/Connection"; 10 | export * from "./journey/Journey"; 11 | export * from "./journey/JourneyFactory"; 12 | 13 | export * from "./query/DepartAfterQuery"; 14 | export * from "./query/JourneyFilter"; 15 | export * from "./query/MultipleCriteriaFilter"; 16 | -------------------------------------------------------------------------------- /src/gtfs/TimeParser.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { TimeParser } from "./TimeParser"; 3 | 4 | describe("TimeParser", () => { 5 | 6 | it("turns a time string into seconds from midnight", () => { 7 | const parser = new TimeParser(); 8 | 9 | chai.expect(0).to.equal(parser.getTime("00:00:00")); 10 | chai.expect(10).to.equal(parser.getTime("00:00:10")); 11 | chai.expect(130).to.equal(parser.getTime("00:02:10")); 12 | chai.expect(10930).to.equal(parser.getTime("03:02:10")); 13 | }); 14 | 15 | }); 16 | -------------------------------------------------------------------------------- /src/csa/ScanResultsFactory.ts: -------------------------------------------------------------------------------- 1 | import { Interchange } from "../gtfs/GtfsLoader"; 2 | import { OriginDepartureTimes } from "./ConnectionScanAlgorithm"; 3 | import { ScanResults } from "./ScanResults"; 4 | 5 | /** 6 | * Creates a new ScanResults object for a given set of origins 7 | */ 8 | export class ScanResultsFactory { 9 | 10 | constructor( 11 | private readonly interchange: Interchange 12 | ) { } 13 | 14 | public create(origins: OriginDepartureTimes): ScanResults { 15 | return new ScanResults(this.interchange, origins); 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /src/gtfs/TimeParser.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Parses time strings and returns them as seconds from midnight. Caches results 4 | */ 5 | export class TimeParser { 6 | 7 | private readonly timeCache = {}; 8 | 9 | /** 10 | * Convert a time string to seconds from midnight 11 | */ 12 | public getTime(time: string) { 13 | if (!this.timeCache.hasOwnProperty(time)) { 14 | const [hh, mm, ss] = time.split(":"); 15 | 16 | this.timeCache[time] = (+hh) * 60 * 60 + (+mm) * 60 + (+ss); 17 | } 18 | 19 | return this.timeCache[time]; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/gtfs/Service.ts: -------------------------------------------------------------------------------- 1 | import { DateIndex, DateNumber, DayOfWeek } from "./Gtfs"; 2 | 3 | export class Service { 4 | 5 | constructor( 6 | private readonly startDate: DateNumber, 7 | private readonly endDate: DateNumber, 8 | private readonly days: Record, 9 | private readonly dates: DateIndex, 10 | ) {} 11 | 12 | public runsOn(date: number, dow: DayOfWeek): boolean { 13 | return this.dates[date] || ( 14 | !this.dates.hasOwnProperty(date) && 15 | this.startDate <= date && 16 | this.endDate >= date && 17 | this.days[dow] 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/journey/Connection.ts: -------------------------------------------------------------------------------- 1 | import { StopID, Time, Trip } from "../gtfs/Gtfs"; 2 | import { AnyLeg, Transfer } from "./Journey"; 3 | 4 | export type Connection = TimetableConnection | Transfer; 5 | 6 | export interface TimetableConnection { 7 | origin: StopID; 8 | destination: StopID; 9 | departureTime: Time; 10 | arrivalTime: Time; 11 | trip: Trip; 12 | } 13 | 14 | export function isTransfer(connection: Connection | AnyLeg): connection is Transfer { 15 | return connection.hasOwnProperty("duration"); 16 | } 17 | 18 | export function isChangeRequired(a: Connection, b: Connection): boolean { 19 | return isTransfer(a) || isTransfer(b) || a.trip.tripId !== b.trip.tripId; 20 | } -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { GtfsLoader } from "./gtfs/GtfsLoader"; 3 | import { TimeParser } from "./gtfs/TimeParser"; 4 | import { ConnectionScanAlgorithm } from "./csa/ConnectionScanAlgorithm"; 5 | import { ScanResultsFactory } from "./csa/ScanResultsFactory"; 6 | import { JourneyFactory } from "./journey/JourneyFactory"; 7 | import { DepartAfterQuery } from "./query/DepartAfterQuery"; 8 | import { MultipleCriteriaFilter } from "./query/MultipleCriteriaFilter"; 9 | import { journeyToString } from "./journey/Journey"; 10 | 11 | async function main() { 12 | const loader = new GtfsLoader(new TimeParser()); 13 | 14 | console.time("initial load"); 15 | const gtfs = await loader.load(fs.createReadStream("/home/linus/Downloads/gb-rail-latest.zip")); 16 | console.timeEnd("initial load"); 17 | 18 | const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); 19 | const query = new DepartAfterQuery(csa, new JourneyFactory(), [new MultipleCriteriaFilter()]); 20 | 21 | console.time("query"); 22 | const results = query.plan(["TBW"], ["NRW"], new Date(), 9 * 3600); 23 | console.timeEnd("query"); 24 | 25 | results.forEach(result => console.log(journeyToString(result))); 26 | } 27 | 28 | main().catch(e => console.error(e)); 29 | -------------------------------------------------------------------------------- /src/journey/Connection.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { c, t } from "../csa/ScanResults.spec"; 3 | import {isChangeRequired, isTransfer} from "./Connection"; 4 | 5 | describe("Connection", () => { 6 | 7 | it("knows if it's a transfer", () => { 8 | const transfer = t("A", "B", 10); 9 | 10 | chai.expect(isTransfer(transfer)).to.equal(true); 11 | }); 12 | 13 | it("knows if it's not a transfer", () => { 14 | const timetableConnection = c("A", "B", 1000, 1030); 15 | 16 | chai.expect(isTransfer(timetableConnection)).to.equal(false); 17 | }); 18 | 19 | it("knows if a change is required", () => { 20 | const timetableConnection1 = c("A", "B", 1000, 1030); 21 | const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); 22 | 23 | chai.expect(isChangeRequired(timetableConnection1, timetableConnection2)).to.equal(true); 24 | }); 25 | 26 | it("knows if a change is not required", () => { 27 | const timetableConnection1 = c("A", "B", 1000, 1030, "LN1112"); 28 | const timetableConnection2 = c("A", "B", 1000, 1030, "LN1112"); 29 | 30 | chai.expect(isChangeRequired(timetableConnection1, timetableConnection2)).to.equal(false); 31 | }); 32 | 33 | it("knows if a change is required between a transfer", () => { 34 | const timetableConnection = c("A", "B", 1000, 1030, "LN1112"); 35 | const transfer = t("A", "B", 1000); 36 | 37 | chai.expect(isChangeRequired(timetableConnection, transfer)).to.equal(true); 38 | chai.expect(isChangeRequired(transfer, timetableConnection)).to.equal(true); 39 | }); 40 | 41 | }); 42 | -------------------------------------------------------------------------------- /src/journey/Journey.ts: -------------------------------------------------------------------------------- 1 | import { Duration, StopID, StopTime, Time, Trip } from "../gtfs/Gtfs"; 2 | 3 | /** 4 | * A leg 5 | */ 6 | export type AnyLeg = Transfer | TimetableLeg; 7 | 8 | /** 9 | * A journey is a collection of legs 10 | */ 11 | export interface Journey { 12 | origin: StopID, 13 | destination: StopID, 14 | legs: AnyLeg[]; 15 | departureTime: Time, 16 | arrivalTime: Time 17 | } 18 | 19 | /** 20 | * Leg of a journey 21 | */ 22 | export interface Leg { 23 | origin: StopID; 24 | destination: StopID; 25 | } 26 | 27 | /** 28 | * Leg with a defined departureTime and arrivalTime time 29 | */ 30 | export interface TimetableLeg extends Leg { 31 | stopTimes: StopTime[]; 32 | trip: Trip; 33 | } 34 | 35 | /** 36 | * Leg with a duration instead of departureTime and arrivalTime time 37 | */ 38 | export interface Transfer extends Leg { 39 | duration: Duration; 40 | startTime: Time; 41 | endTime: Time; 42 | } 43 | 44 | export function journeyToString(j: Journey) { 45 | return toTime(j.departureTime) + ", " + 46 | toTime(j.arrivalTime) + ", " + 47 | [j.legs[0].origin, ...j.legs.map(l => l.destination)].join("-"); 48 | } 49 | 50 | function toTime(time: number) { 51 | let hours: any = Math.floor(time / 3600); 52 | let minutes: any = Math.floor((time - (hours * 3600)) / 60); 53 | let seconds: any = time - (hours * 3600) - (minutes * 60); 54 | 55 | if (hours < 10) { hours = "0" + hours; } 56 | if (minutes < 10) { minutes = "0" + minutes; } 57 | if (seconds < 10) { seconds = "0" + seconds; } 58 | 59 | return hours + ":" + minutes + ":" + seconds; 60 | } -------------------------------------------------------------------------------- /src/query/DepartAfterQuery.ts: -------------------------------------------------------------------------------- 1 | 2 | import { keyValue } from "ts-array-utils"; 3 | import { ConnectionScanAlgorithm } from "../csa/ConnectionScanAlgorithm"; 4 | import { JourneyFactory } from "../journey/JourneyFactory"; 5 | import { StopID, Time, DayOfWeek } from "../gtfs/Gtfs"; 6 | import { Journey } from "../journey/Journey"; 7 | import { JourneyFilter } from "./JourneyFilter"; 8 | 9 | /** 10 | * Implementation of CSA that searches for journeys between a set of origin and destinations. 11 | */ 12 | export class DepartAfterQuery { 13 | 14 | constructor( 15 | private readonly csa: ConnectionScanAlgorithm, 16 | private readonly resultsFactory: JourneyFactory, 17 | private readonly filters: JourneyFilter[] = [] 18 | ) { } 19 | 20 | /** 21 | * Plan a journey between the origin and destination set of stops on the given date and time 22 | */ 23 | public plan(origins: StopID[], destinations: StopID[], date: Date, time: Time): Journey[] { 24 | const originTimes = origins.reduce(keyValue(origin => [origin, time]), {}); 25 | const dateNumber = this.getDateNumber(date); 26 | const dayOfWeek = date.getDay() as DayOfWeek; 27 | const results = this.csa.scan(originTimes, destinations, dateNumber, dayOfWeek); 28 | const journeys = this.resultsFactory.getJourneys(results, destinations); 29 | 30 | // apply each filter to the results 31 | return this.filters.reduce((rs, filter) => filter.apply(rs), journeys); 32 | } 33 | 34 | private getDateNumber(date: Date): number { 35 | const str = date.toISOString(); 36 | 37 | return parseInt(str.slice(0, 4) + str.slice(5, 7) + str.slice(8, 10), 10); 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/transfer-patterns.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process"; 2 | import * as ProgressBar from "progress"; 3 | import * as fs from "fs"; 4 | import * as gtfs from "gtfs-stream"; 5 | import { StopID } from "./gtfs/Gtfs"; 6 | 7 | const numCPUs = require("os").cpus().length; 8 | 9 | async function run(filename: string, dateString: string) { 10 | const date = new Date(dateString); 11 | console.time("load stops"); 12 | const stops = await getStops(filename); 13 | console.timeEnd("load stops"); 14 | 15 | const bar = new ProgressBar(" [:current of :total] [:bar] :percent eta :eta ", { total: stops.length }); 16 | 17 | for (let i = 0; i < Math.min(numCPUs - 2, stops.length); i++) { 18 | const worker = cp.fork(__dirname + "/transfer-pattern-worker", [filename, date.toISOString()]); 19 | 20 | worker.on("message", () => { 21 | if (stops.length > 0) { 22 | bar.tick(); 23 | 24 | worker.send(stops.pop()); 25 | } 26 | else { 27 | worker.kill("SIGUSR2"); 28 | } 29 | }); 30 | } 31 | } 32 | 33 | async function getStops(filename: string): Promise { 34 | return new Promise((resolve, reject) => { 35 | const stops = [] as StopID[]; 36 | 37 | fs.createReadStream(filename) 38 | .pipe(gtfs({ raw: true })) 39 | .on("data", entity => entity.type === "stop" 40 | && entity.data.stop_timezone === "Europe/London" 41 | && stops.push(entity.data.stop_id) 42 | ) 43 | .on("error", e => reject(e)) 44 | .on("end", () => resolve(stops)); 45 | }); 46 | } 47 | 48 | if (process.argv[2] && process.argv[3]) { 49 | run(process.argv[2], process.argv[3]).catch(e => console.error(e)); 50 | } 51 | else { 52 | console.log("Please specify a GTFS file and date."); 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "connection-scan-algorithm", 3 | "version": "1.1.0", 4 | "description": "Connection Scan Algorithm", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/planarnetwork/connection-scan-algorithm.git" 10 | }, 11 | "scripts": { 12 | "test": "npm run lint && mocha --require ts-node/register **/*.spec.ts", 13 | "prepublishOnly": "rm -rf ./dist/ && tsc -p ./ --outDir dist/", 14 | "lint-raw": "tslint --project tsconfig.json", 15 | "lint": "npm run lint-raw -- -t stylish", 16 | "coverage": "nyc --reporter=text npm test", 17 | "patterns": "ts-node ./src/transfer-patterns.ts", 18 | "start": "NODE_OPTIONS=$NODE_DEBUG_OPTION ts-node src/cli.ts", 19 | "perf": "NODE_OPTIONS=$NODE_DEBUG_OPTION ts-node src/performance.ts" 20 | }, 21 | "keywords": [ 22 | "Journey", 23 | "Planning", 24 | "Public", 25 | "Transport" 26 | ], 27 | "author": "Linus Norton ", 28 | "license": "GPL-3.0", 29 | "devDependencies": { 30 | "@types/chai": "^4.2.9", 31 | "@types/lru-cache": "^5.1.0", 32 | "@types/mocha": "^5.2.7", 33 | "@types/node": "^12.12.28", 34 | "@types/progress": "^2.0.3", 35 | "chai": "^4.2.0", 36 | "mocha": "^10.2.0", 37 | "nyc": "^14.1.1", 38 | "ts-node": "^8.6.2", 39 | "tslint": "^5.20.1", 40 | "typescript": "^3.8.2" 41 | }, 42 | "dependencies": { 43 | "gtfs-stream": "^2.1.0", 44 | "mysql2": "^2.1.0", 45 | "progress": "^2.0.3", 46 | "ts-array-utils": "^0.5.0" 47 | }, 48 | "nyc": { 49 | "extends": "@istanbul/nyc-config-typescript", 50 | "all": true, 51 | "check-coverage": true, 52 | "extension": [ 53 | ".ts" 54 | ], 55 | "include": [ 56 | "src/**/*.ts" 57 | ], 58 | "exclude": [ 59 | "src/*.ts" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/query/MultipleCriteriaFilter.ts: -------------------------------------------------------------------------------- 1 | import { JourneyFilter } from "./JourneyFilter"; 2 | import { Journey } from "../journey/Journey"; 3 | 4 | /** 5 | * Returns true if b arrives before or at the same time as a 6 | */ 7 | export const earliestArrival = (a, b) => b.arrivalTime <= a.arrivalTime; 8 | 9 | /** 10 | * Returns true if b has the same or fewer changes than a 11 | */ 12 | export const leastChanges = (a, b) => b.legs.length <= a.legs.length; 13 | 14 | /** 15 | * Filters journeys based on a number of configurable criteria 16 | */ 17 | export class MultipleCriteriaFilter implements JourneyFilter { 18 | 19 | constructor( 20 | private readonly criteria: FilterCriteria[] = [earliestArrival, leastChanges] 21 | ) {} 22 | 23 | /** 24 | * Sort the journeys and then apply the criteria 25 | */ 26 | public apply(journeys: Journey[]): Journey[] { 27 | journeys.sort(this.sort); 28 | 29 | return journeys.filter((a, i, js) => this.compare(a, i, js)); 30 | } 31 | 32 | /** 33 | * Sort by departure time ascending and arrival time descending as a tie breaker 34 | */ 35 | private sort(a: Journey, b: Journey): number { 36 | return a.departureTime !== b.departureTime ? a.departureTime - b.departureTime : b.arrivalTime - a.arrivalTime; 37 | } 38 | 39 | /** 40 | * Keeps the journey as long as there is no subsequent journey that is better in every regard. 41 | */ 42 | private compare(journeyA: Journey, index: number, journeys: Journey[]): boolean { 43 | for (let j = index + 1; j < journeys.length; j++) { 44 | const journeyB = journeys[j]; 45 | 46 | if (this.criteria.every(criteria => criteria(journeyA, journeyB))) { 47 | return false; 48 | } 49 | } 50 | 51 | return true; 52 | } 53 | } 54 | 55 | /** 56 | * Function that compares two journeys and returns true if the second is better than the first 57 | */ 58 | export type FilterCriteria = (a: Journey, b: Journey) => boolean; 59 | -------------------------------------------------------------------------------- /src/transfer-pattern/TransferPatternRepository.ts: -------------------------------------------------------------------------------- 1 | import { MST } from "./TransferPatternConnectionScan"; 2 | 3 | /** 4 | * Access to the transfer_patterns table in a mysql compatible database 5 | */ 6 | export class TransferPatternRepository { 7 | 8 | constructor( 9 | private readonly db: any 10 | ) { } 11 | 12 | /** 13 | * Store every transfer pattern in the tree 14 | */ 15 | public async storeTransferPatterns(patterns: MST): Promise { 16 | const journeys: object[] = []; 17 | 18 | for (const destination of Object.keys(patterns)) { 19 | for (const journey of Object.values(patterns[destination])) { 20 | const stops = journey.legs.slice(1).map(l => l.origin); 21 | const pattern = journey.origin > journey.destination ? stops.reverse().join(",") : stops.join(","); 22 | const key = journey.origin > journey.destination 23 | ? journey.destination + journey.origin 24 | : journey.origin + journey.destination; 25 | 26 | journeys.push([key, pattern]); 27 | } 28 | } 29 | 30 | if (journeys.length > 0) { 31 | await this.retryQuery("INSERT IGNORE INTO transfer_patterns VALUES ?", [journeys]); 32 | } 33 | } 34 | 35 | private async retryQuery(sql: string, data: any[], numRetries: number = 3) { 36 | try { 37 | await this.db.query(sql, data); 38 | } 39 | catch (err) { 40 | if (numRetries > 0) { 41 | await this.retryQuery(sql, data, numRetries - 1); 42 | } 43 | else { 44 | console.error(err); 45 | } 46 | } 47 | 48 | } 49 | 50 | /** 51 | * Create the transfer pattern table if it does not already exist 52 | */ 53 | public async initTables(): Promise { 54 | await this.db.query(` 55 | CREATE TABLE IF NOT EXISTS transfer_patterns ( 56 | journey char(6) NOT NULL, 57 | pattern varchar(255) NOT NULL, 58 | PRIMARY KEY (journey,pattern) 59 | ) ENGINE=InnoDB DEFAULT CHARSET=latin1 60 | ` 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/gtfs/Gtfs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * StopID e.g. NRW 3 | */ 4 | import { Service } from "./Service"; 5 | 6 | export type StopID = string; 7 | 8 | /** 9 | * Time in seconds since midnight (note this may be greater than 24 hours). 10 | */ 11 | export type Time = number; 12 | 13 | /** 14 | * Duration in seconds 15 | */ 16 | export type Duration = number; 17 | 18 | /** 19 | * GTFS stop time 20 | */ 21 | export interface StopTime { 22 | stop: StopID; 23 | arrivalTime: Time; 24 | departureTime: Time; 25 | pickUp: boolean; 26 | dropOff: boolean; 27 | } 28 | 29 | /** 30 | * GTFS trip_id 31 | */ 32 | export type TripID = string; 33 | 34 | /** 35 | * GTFS service_id, used to determine the trip's calendar 36 | */ 37 | export type ServiceID = string; 38 | 39 | /** 40 | * GTFS trip 41 | */ 42 | export interface Trip { 43 | tripId: TripID; 44 | stopTimes: StopTime[]; 45 | serviceId: ServiceID; 46 | service: Service; 47 | } 48 | 49 | /** 50 | * Date stored as a number, e.g 20181225 51 | */ 52 | export type DateNumber = number; 53 | 54 | /** 55 | * Index of dates, used to access exclude/include dates in O(1) time 56 | */ 57 | export type DateIndex = Record; 58 | 59 | /** 60 | * Sunday = 0, Monday = 1... don't blame me, blame JavaScript .getDay 61 | */ 62 | export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; 63 | 64 | /** 65 | * GTFS calendar 66 | */ 67 | export interface Calendar { 68 | serviceId: ServiceID; 69 | startDate: DateNumber; 70 | endDate: DateNumber; 71 | days: Record; 72 | exclude: DateIndex; 73 | include: DateIndex; 74 | } 75 | 76 | /** 77 | * Calendars indexed by service ID 78 | */ 79 | export type CalendarIndex = Record; 80 | 81 | /** 82 | * GTFS stop 83 | */ 84 | export interface Stop { 85 | id: StopID, 86 | code: string, 87 | name: string, 88 | description: string, 89 | latitude: number, 90 | longitude: number, 91 | timezone: string 92 | } 93 | 94 | /** 95 | * Stops indexed by ID 96 | */ 97 | export type StopIndex = Record; 98 | -------------------------------------------------------------------------------- /src/gtfs/Service.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { Service } from "./Service"; 3 | 4 | export const allDays = { 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true }; 5 | 6 | describe("Service", () => { 7 | 8 | it("checks the start date", () => { 9 | const service = new Service( 10 | 20181001, 11 | 20181015, 12 | allDays, 13 | {} 14 | ); 15 | 16 | const result = service.runsOn(20180930, 1); 17 | 18 | chai.expect(result).to.equal(false); 19 | }); 20 | 21 | it("checks the end date", () => { 22 | const service = new Service( 23 | 20181001, 24 | 20181015, 25 | allDays, 26 | {} 27 | ); 28 | 29 | const result = service.runsOn(20181016, 1); 30 | 31 | chai.expect(result).to.equal(false); 32 | }); 33 | 34 | it("checks dates within range", () => { 35 | const service = new Service( 36 | 20181001, 37 | 20181015, 38 | allDays, 39 | {} 40 | ); 41 | 42 | const result = service.runsOn(20181010, 1); 43 | 44 | chai.expect(result).to.equal(true); 45 | }); 46 | 47 | it("checks the day of the week", () => { 48 | const days = Object.assign({}, allDays, { 1: false }); 49 | const service = new Service( 50 | 20181001, 51 | 20991231, 52 | days, 53 | {} 54 | ); 55 | const result = service.runsOn(20181016, 1); 56 | 57 | chai.expect(result).to.equal(false); 58 | }); 59 | 60 | it("checks include days", () => { 61 | const service = new Service( 62 | 20991231, 63 | 20991231, 64 | allDays, 65 | { 20181022: true } 66 | ); 67 | 68 | const result = service.runsOn(20181022, 1); 69 | 70 | chai.expect(result).to.equal(true); 71 | }); 72 | 73 | it("checks exclude days", () => { 74 | const service = new Service( 75 | 20181001, 76 | 20991231, 77 | allDays, 78 | { 20181022: false } 79 | ); 80 | 81 | const result = service.runsOn(20181022, 1); 82 | 83 | chai.expect(result).to.equal(false); 84 | }); 85 | 86 | }); 87 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": [ 4 | true, 5 | "parameters", 6 | "statements" 7 | ], 8 | "ban": false, 9 | "class-name": true, 10 | "comment-format": [ 11 | true, 12 | "check-space" 13 | ], 14 | "curly": false, 15 | "eofline": false, 16 | "forin": false, 17 | "indent": [ true, "spaces" ], 18 | "interface-name": [true, "never-prefix"], 19 | "jsdoc-format": true, 20 | "jsx-no-lambda": false, 21 | "jsx-no-multiline-js": false, 22 | "label-position": true, 23 | "max-line-length": [ true, 120 ], 24 | "member-ordering": false, 25 | "no-any": false, 26 | "no-arg": true, 27 | "no-bitwise": false, 28 | "no-console": false, 29 | "no-consecutive-blank-lines": true, 30 | "no-construct": true, 31 | "no-debugger": true, 32 | "no-duplicate-variable": true, 33 | "no-empty": false, 34 | "no-eval": true, 35 | "no-shadowed-variable": true, 36 | "no-string-literal": false, 37 | "no-switch-case-fall-through": true, 38 | "no-trailing-whitespace": false, 39 | "no-unused-expression": true, 40 | "one-line": [ 41 | true, 42 | "check-open-brace", 43 | "check-whitespace" 44 | ], 45 | "quotemark": [true, "double", "jsx-double"], 46 | "radix": true, 47 | "semicolon": [true, "always", "ignore-interfaces", "ignore-bound-class-methods"], 48 | "switch-default": true, 49 | 50 | "trailing-comma": [false], 51 | 52 | "triple-equals": [ true, "allow-null-check" ], 53 | "typedef": [ 54 | true, 55 | "parameter", 56 | "property-declaration" 57 | ], 58 | "typedef-whitespace": [ 59 | true, 60 | { 61 | "call-signature": "nospace", 62 | "index-signature": "nospace", 63 | "parameter": "nospace", 64 | "property-declaration": "nospace", 65 | "variable-declaration": "nospace" 66 | } 67 | ], 68 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"], 69 | "whitespace": [ 70 | true, 71 | "check-branch", 72 | "check-decl", 73 | "check-operator", 74 | "check-separator", 75 | "check-type", 76 | "check-typecast" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/csa/ConnectionScanAlgorithm.ts: -------------------------------------------------------------------------------- 1 | import { Connection, TimetableConnection } from "../journey/Connection"; 2 | import { TransfersByOrigin } from "../gtfs/GtfsLoader"; 3 | import { DayOfWeek, StopID, Time } from "../gtfs/Gtfs"; 4 | import { ScanResults } from "./ScanResults"; 5 | import { ScanResultsFactory } from "./ScanResultsFactory"; 6 | 7 | /** 8 | * Implementation of the connection scan algorithm. 9 | */ 10 | export class ConnectionScanAlgorithm { 11 | 12 | constructor( 13 | private readonly connections: TimetableConnection[], 14 | private readonly transfers: TransfersByOrigin, 15 | private readonly resultsFactory: ScanResultsFactory 16 | ) {} 17 | 18 | /** 19 | * Return an index of connections that achieve the earliest arrival time at each stop. 20 | */ 21 | public scan(origins: OriginDepartureTimes, destinations: StopID[], date: number, dow: DayOfWeek): ConnectionIndex { 22 | const results = this.resultsFactory.create({ ...origins }); 23 | 24 | for (const origin in origins) { 25 | this.scanTransfers(results, origin); 26 | } 27 | 28 | for (const c of this.connections) { 29 | if (c.trip.service.runsOn(date, dow) && results.isReachable(c) && results.isBetter(c)) { 30 | const newStopReached = results.setConnection(c); 31 | 32 | if (newStopReached) { 33 | this.scanTransfers(results, c.destination); 34 | } 35 | if (results.isFinished(destinations, c.departureTime)) { 36 | break; 37 | } 38 | } 39 | } 40 | 41 | return results.getConnectionIndex(); 42 | } 43 | 44 | private scanTransfers(results: ScanResults, origin: StopID): void { 45 | for (const transfer of this.transfers[origin]) { 46 | if (results.isTransferBetter(transfer)) { 47 | const newStopReached = results.setTransfer(transfer); 48 | 49 | if (newStopReached) { 50 | this.scanTransfers(results, transfer.destination); 51 | } 52 | } 53 | } 54 | } 55 | 56 | } 57 | 58 | /** 59 | * Index of connections that achieve the earliest arrivalTime time at each stop. 60 | */ 61 | export type ConnectionIndex = Record; 62 | 63 | /** 64 | * Index of departure stations and their departure time 65 | */ 66 | export type OriginDepartureTimes = Record; 67 | -------------------------------------------------------------------------------- /src/transfer-pattern-worker.ts: -------------------------------------------------------------------------------- 1 | import { TransferPatternRepository } from "./transfer-pattern/TransferPatternRepository"; 2 | import * as fs from "fs"; 3 | import { GtfsLoader } from "./gtfs/GtfsLoader"; 4 | import { TimeParser } from "./gtfs/TimeParser"; 5 | import { ScanResultsFactory } from "./csa/ScanResultsFactory"; 6 | import { JourneyFactory } from "./journey/JourneyFactory"; 7 | import { TransferPatternConnectionScan } from "./transfer-pattern/TransferPatternConnectionScan"; 8 | import { DayOfWeek } from "./gtfs/Gtfs"; 9 | 10 | /** 11 | * Worker that finds transfer patterns for a given station 12 | */ 13 | async function worker(filename: string, date: Date): Promise { 14 | const db = getDatabase(); 15 | const repository = new TransferPatternRepository(db); 16 | const dateNumber = getDateNumber(date); 17 | const dayOfWeek = date.getDay() as DayOfWeek; 18 | const loader = new GtfsLoader(new TimeParser()); 19 | const gtfs = await loader.load(fs.createReadStream(filename)); 20 | const connections = gtfs.connections.filter(c => c.trip.service.runsOn(dateNumber, dayOfWeek)); 21 | const csa = new TransferPatternConnectionScan( 22 | connections, 23 | gtfs.transfers, 24 | new ScanResultsFactory(gtfs.interchange), 25 | new JourneyFactory() 26 | ); 27 | 28 | process.on("message", async stop => { 29 | const results = csa.getShortestPathTree(stop); 30 | 31 | await repository.storeTransferPatterns(results); 32 | 33 | morePlease(); 34 | }); 35 | 36 | process.on("SIGUSR2", () => db.end().then(() => process.exit())); 37 | 38 | morePlease(); 39 | } 40 | 41 | function morePlease() { 42 | (process as any).send("ready"); 43 | } 44 | 45 | function getDatabase() { 46 | return require("mysql2/promise").createPool({ 47 | // host: process.env.DATABASE_HOSTNAME || "localhost", 48 | socketPath: "/run/mysqld/mysqld.sock", 49 | user: process.env.DATABASE_USERNAME || "root", 50 | password: process.env.DATABASE_PASSWORD || "", 51 | database: process.env.OJP_DATABASE_NAME || "ojp", 52 | connectionLimit: 3, 53 | }); 54 | } 55 | 56 | function getDateNumber(date: Date): number { 57 | const str = date.toISOString(); 58 | 59 | return parseInt(str.slice(0, 4) + str.slice(5, 7) + str.slice(8, 10), 10); 60 | } 61 | 62 | if (process.argv[2] && process.argv[3]) { 63 | worker(process.argv[2], new Date(process.argv[3])).catch(err => { 64 | console.error(err); 65 | process.exit(); 66 | }); 67 | } 68 | else { 69 | console.log("Please specify a date and GTFS file."); 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Connection Scan Algorithm 3 | ========================= 4 | [![Travis](https://img.shields.io/travis/planarnetwork/connection-scan-algorithm.svg?style=flat-square)](https://travis-ci.org/planarnetwork/connection-scan-algorithm) ![npm](https://img.shields.io/npm/v/connection-scan-algorithm.svg?style=flat-square) ![David](https://img.shields.io/david/planarnetwork/connection-scan-algorithm.svg?style=flat-square) 5 | 6 | Implementation of the [Connection Scan Algorithm](https://arxiv.org/pdf/1703.05997) in TypeScript. 7 | 8 | Additional features not in the paper implementation: 9 | - Various [fixes](https://ljn.io/posts/CSA-workarounds) in order to improve the quality of results. 10 | - Calendars are checked to ensure services are running on the specified day 11 | - The origin and destination may be a set of stops 12 | - Interchange time at each station is applied 13 | - Pickup / set down marker of stop times are obeyed 14 | - Multi-criteria journey filtering 15 | - Transfers (footpaths) can be used 16 | 17 | ## Usage 18 | 19 | It will work with any well formed GTFS data set. 20 | 21 | Node +11 is required for all examples. 22 | 23 | ``` 24 | npm install --save connection-scan-algorithm 25 | ``` 26 | 27 | ### Depart After Query 28 | 29 | Find the first results that depart after a specific time 30 | 31 | ```javascript 32 | const {GtfsLoader, JourneyFactory, ConnectionScanAlgorithm, ScanResultsFactory, TimeParser, MultipleCriteriaFilter, DepartAfterQuery} = require("connection-scan-algorithm"); 33 | 34 | const gtfsLoader = new GtfsLoader(new TimeParser()); 35 | const gtfs = await gtfsLoader.load(fs.createReadStream("gtfs.zip")); 36 | const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); 37 | const query = new DepartAfterQuery(csa, new JourneyFactory(), [new MultipleCriteriaFilter()]); 38 | const results = query.plan(["TBW"], ["NRW"], new Date(), 9 * 3600); 39 | ``` 40 | 41 | ## TODO 42 | 43 | - Short circuit connection scan once all destinations found 44 | - Fake trip ID for transfers to (removes branch) 45 | - Only scan transfers for stops once (avoid re-scan when time is improved) 46 | 47 | ## Contributing 48 | 49 | Issues and PRs are very welcome. To get the project set up run: 50 | 51 | ``` 52 | git clone git@github.com:planarnetwork/connection-scan-algorithm 53 | npm install --dev 54 | npm test 55 | ``` 56 | 57 | If you would like to send a pull request please write your contribution in TypeScript and if possible, add a test. 58 | 59 | ## License 60 | 61 | This software is licensed under [GNU GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html). 62 | 63 | -------------------------------------------------------------------------------- /src/transfer-pattern/TransferPatternConnectionScan.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isTransfer, 3 | Journey, 4 | JourneyFactory, 5 | ScanResults, 6 | ScanResultsFactory, 7 | StopID, 8 | Time, 9 | TimetableConnection, 10 | TransfersByOrigin 11 | } from ".."; 12 | import { setNested } from "ts-array-utils"; 13 | 14 | export class TransferPatternConnectionScan { 15 | 16 | constructor( 17 | private readonly connections: TimetableConnection[], 18 | private readonly transfers: TransfersByOrigin, 19 | private readonly resultsFactory: ScanResultsFactory, 20 | private readonly journeyFactory: JourneyFactory 21 | ) { } 22 | 23 | /** 24 | * Return a earliest arrival tree 25 | */ 26 | public getShortestPathTree(origin: StopID): MST { 27 | const bestJourneys = {}; 28 | let workingTimetable = this.connections; 29 | let nextDepartureTime = 0; 30 | 31 | while (workingTimetable.length > 0) { 32 | const arrivals = { [origin]: nextDepartureTime }; 33 | const results = this.resultsFactory.create(arrivals); 34 | 35 | workingTimetable = this.scan(workingTimetable, results, origin, nextDepartureTime); 36 | nextDepartureTime = Number.MAX_SAFE_INTEGER; 37 | 38 | for (const destination in arrivals) { 39 | const [journey] = this.journeyFactory.getJourneys(results.getConnectionIndex(), [destination]); 40 | 41 | if (journey && journey.legs.some(l => !isTransfer(l))) { 42 | setNested(journey, bestJourneys, destination, "" + arrivals[destination]); 43 | nextDepartureTime = Math.min(nextDepartureTime, journey.departureTime + 1); 44 | } 45 | } 46 | } 47 | 48 | return bestJourneys; 49 | } 50 | 51 | private scan( 52 | connections: TimetableConnection[], 53 | results: ScanResults, 54 | origin: StopID, 55 | departure: Time 56 | ): TimetableConnection[] { 57 | 58 | const newTimetable = [] as TimetableConnection[]; 59 | this.scanTransfers(results, origin); 60 | 61 | for (const c of connections) { 62 | 63 | if (c.departureTime > departure) { 64 | newTimetable.push(c); 65 | } 66 | 67 | if (results.isReachable(c) && results.isBetter(c)) { 68 | const newStopReached = results.setConnection(c); 69 | 70 | if (newStopReached) { 71 | this.scanTransfers(results, c.destination); 72 | } 73 | } 74 | } 75 | 76 | return newTimetable; 77 | } 78 | 79 | private scanTransfers(results: ScanResults, origin: StopID): void { 80 | for (const transfer of this.transfers[origin]) { 81 | if (results.isTransferBetter(transfer)) { 82 | const newStopReached = results.setTransfer(transfer); 83 | 84 | if (newStopReached) { 85 | this.scanTransfers(results, transfer.destination); 86 | } 87 | } 88 | } 89 | } 90 | 91 | } 92 | 93 | export type MST = Record>; 94 | -------------------------------------------------------------------------------- /src/csa/ScanResults.ts: -------------------------------------------------------------------------------- 1 | import { Interchange } from "../gtfs/GtfsLoader"; 2 | import { TimetableConnection } from "../journey/Connection"; 3 | import { StopID, Time } from "../gtfs/Gtfs"; 4 | import { ConnectionIndex, OriginDepartureTimes } from "./ConnectionScanAlgorithm"; 5 | import { Transfer } from "../journey/Journey"; 6 | 7 | /** 8 | * Mutable object that stores the current earliest arrival and best connection indexes as the 9 | * connections are being scanned. 10 | */ 11 | export class ScanResults { 12 | private readonly connectionIndex: ConnectionIndex = {}; 13 | private readonly tripArrivals: Record = {}; 14 | 15 | constructor( 16 | private readonly interchange: Interchange, 17 | private readonly earliestArrivals: OriginDepartureTimes 18 | ) {} 19 | 20 | public isReachable(connection: TimetableConnection): boolean { 21 | const reachable = this.isReachableWithChange(connection) || this.isReachableFromSameService(connection); 22 | 23 | if (reachable) { 24 | this.tripArrivals[connection.trip.tripId] = this.tripArrivals[connection.trip.tripId] || {}; 25 | this.tripArrivals[connection.trip.tripId][connection.destination] = connection.arrivalTime; 26 | } 27 | 28 | return reachable; 29 | } 30 | 31 | private isReachableFromSameService(connection: TimetableConnection): boolean { 32 | return this.tripArrivals.hasOwnProperty(connection.trip.tripId) && 33 | this.tripArrivals[connection.trip.tripId][connection.origin] <= connection.departureTime; 34 | } 35 | 36 | private isReachableWithChange(connection: TimetableConnection): boolean { 37 | const interchange = this.connectionIndex[connection.origin] ? this.interchange[connection.origin] : 0; 38 | 39 | return this.earliestArrivals.hasOwnProperty(connection.origin) 40 | && this.earliestArrivals[connection.origin] + interchange <= connection.departureTime; 41 | } 42 | 43 | public isBetter(connection: TimetableConnection): boolean { 44 | return !this.earliestArrivals.hasOwnProperty(connection.destination) 45 | || this.earliestArrivals[connection.destination] > connection.arrivalTime; 46 | } 47 | 48 | public setConnection(connection: TimetableConnection): boolean { 49 | const exists = this.connectionIndex.hasOwnProperty(connection.destination); 50 | this.earliestArrivals[connection.destination] = connection.arrivalTime; 51 | this.connectionIndex[connection.destination] = connection; 52 | 53 | return !exists; 54 | } 55 | 56 | public isTransferBetter(transfer: Transfer): boolean { 57 | return !this.earliestArrivals.hasOwnProperty(transfer.destination) 58 | || this.earliestArrivals[transfer.destination] > this.getTransferArrivalTime(transfer); 59 | } 60 | 61 | public setTransfer(transfer: Transfer): boolean { 62 | const exists = this.connectionIndex.hasOwnProperty(transfer.destination); 63 | this.earliestArrivals[transfer.destination] = this.getTransferArrivalTime(transfer); 64 | this.connectionIndex[transfer.destination] = transfer; 65 | 66 | return !exists; 67 | } 68 | 69 | private getTransferArrivalTime(transfer: Transfer): Time { 70 | return this.earliestArrivals[transfer.origin] + transfer.duration + this.interchange[transfer.origin]; 71 | } 72 | 73 | public getConnectionIndex(): ConnectionIndex { 74 | return this.connectionIndex; 75 | } 76 | 77 | public isFinished(destinations: StopID[], departureTime: Time): boolean { 78 | return !destinations.some(d => !this.earliestArrivals[d] || departureTime < this.earliestArrivals[d]); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/performance.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { GtfsLoader } from "./gtfs/GtfsLoader"; 3 | import { TimeParser } from "./gtfs/TimeParser"; 4 | import { ConnectionScanAlgorithm } from "./csa/ConnectionScanAlgorithm"; 5 | import { ScanResultsFactory } from "./csa/ScanResultsFactory"; 6 | import { DepartAfterQuery } from "./query/DepartAfterQuery"; 7 | import { JourneyFactory } from "./journey/JourneyFactory"; 8 | import { MultipleCriteriaFilter } from "./query/MultipleCriteriaFilter"; 9 | 10 | const queries = [ 11 | [["MRF", "LVC", "LVJ", "LIV"], ["NRW"]], 12 | [["TBW", "PDW"], ["HGS"]], 13 | [["PDW", "MRN"], ["LVC", "LVJ", "LIV"]], 14 | [["PDW", "AFK"], ["NRW"]], 15 | [["PDW"], ["BHM", "BMO", "BSW", "BHI"]], 16 | [["PNZ"], ["DIS"]], 17 | [["YRK"], ["DIS"]], 18 | [["WEY"], ["RDG"]], 19 | [["YRK"], ["NRW"]], 20 | [["BHM", "BMO", "BSW", "BHI"], ["MCO", "MAN", "MCV", "EXD"]], 21 | [["BHM", "BMO", "BSW", "BHI"], ["EDB"]], 22 | [["COV", "RUG"], ["MAN", "MCV"]], 23 | [["YRK"], ["MCO", "MAN", "MCV", "EXD"]], 24 | [["STA"], ["PBO"]], 25 | [["PNZ"], ["EDB"]], 26 | [["RDG"], ["IPS"]], 27 | [["DVP"], ["BHM", "BMO", "BSW", "BHI"]], 28 | [["BXB"], ["DVP"]], 29 | [["MCO", "MAN", "MCV", "EXD"], ["CBW", "CBE"]], 30 | [ 31 | ["MCO", "MAN", "MCV", "EXD"], 32 | [ 33 | "EUS", "MYB", "STP", "PAD", "BFR", "CTK", "CST", "CHX", "LBG", 34 | "WAE", "VIC", "VXH", "WAT", "OLD", "MOG", "KGX", "LST", "FST" 35 | ] 36 | ], 37 | [ 38 | ["BHM", "BMO", "BSW", "BHI"], 39 | [ 40 | "EUS", "MYB", "STP", "PAD", "BFR", "CTK", "CST", "CHX", "LBG", 41 | "WAE", "VIC", "VXH", "WAT", "OLD", "MOG", "KGX", "LST", "FST" 42 | ] 43 | ], 44 | [ 45 | ["ORP"], 46 | [ 47 | "EUS", "MYB", "STP", "PAD", "BFR", "CTK", "CST", "CHX", "LBG", 48 | "WAE", "VIC", "VXH", "WAT", "OLD", "MOG", "KGX", "LST", "FST" 49 | ] 50 | ], 51 | [ 52 | ["EDB"], 53 | [ 54 | "EUS", "MYB", "STP", "PAD", "BFR", "CTK", "CST", "CHX", "LBG", 55 | "WAE", "VIC", "VXH", "WAT", "OLD", "MOG", "KGX", "LST", "FST" 56 | ] 57 | ], 58 | [ 59 | ["CBE", "CBW"], 60 | [ 61 | "EUS", "MYB", "STP", "PAD", "BFR", "CTK", "CST", "CHX", "LBG", 62 | "WAE", "VIC", "VXH", "WAT", "OLD", "MOG", "KGX", "LST", "FST" 63 | ] 64 | ] 65 | ]; 66 | 67 | async function run() { 68 | const loader = new GtfsLoader(new TimeParser()); 69 | console.time("initial load"); 70 | const gtfs = await loader.load(fs.createReadStream("/home/linus/Downloads/gb-rail-latest.zip")); 71 | console.timeEnd("initial load"); 72 | 73 | const csa = new ConnectionScanAlgorithm(gtfs.connections, gtfs.transfers, new ScanResultsFactory(gtfs.interchange)); 74 | const query = new DepartAfterQuery(csa, new JourneyFactory()); 75 | 76 | console.time("planning"); 77 | const date = new Date(); 78 | let numResults = 0; 79 | 80 | for (let i = 0; i < 3; i++) { 81 | for (const [origins, destinations] of queries) { 82 | const key = origins.join() + ":" + destinations.join(); 83 | 84 | console.time(key); 85 | const results = query.plan(origins, destinations, date, 36000); 86 | console.timeEnd(key); 87 | 88 | if (results.length === 0) { 89 | console.log("No results between " + key); 90 | } 91 | 92 | numResults += results.length; 93 | } 94 | } 95 | 96 | console.timeEnd("planning"); 97 | console.log("Num journeys: " + numResults); 98 | console.log(`Memory usage: ${Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100} MB`); 99 | } 100 | 101 | run().catch(e => console.error(e)); 102 | -------------------------------------------------------------------------------- /src/journey/JourneyFactory.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { c, t } from "../csa/ScanResults.spec"; 3 | import { JourneyFactory, ScanResults } from ".."; 4 | import { setStopTimes } from "../csa/ConnectionScanAlgorithm.spec"; 5 | 6 | describe("JourneyFactory", () => { 7 | const factory = new JourneyFactory(); 8 | 9 | it("creates a journey from a connection index", () => { 10 | const results = new ScanResults({ A: 1000 }, {}); 11 | const connections = [ 12 | c("A", "B", 1000, 1030) 13 | ]; 14 | 15 | setStopTimes(connections); 16 | 17 | for (const connection of connections) { 18 | results.setConnection(connection); 19 | } 20 | 21 | const [journey] = factory.getJourneys(results.getConnectionIndex(), ["B"]); 22 | 23 | chai.expect(journey.origin).to.equal("A"); 24 | chai.expect(journey.destination).to.equal("B"); 25 | chai.expect(journey.departureTime).to.equal(1000); 26 | chai.expect(journey.arrivalTime).to.equal(1030); 27 | }); 28 | 29 | it("calculates the departure time", () => { 30 | const results = new ScanResults({ A: 1000 }, {}); 31 | const transfers = [ 32 | t("A", "B", 60), 33 | ]; 34 | const connections = [ 35 | c("B", "C", 1100, 1130) 36 | ]; 37 | 38 | setStopTimes(connections); 39 | 40 | for (const transfer of transfers ) { 41 | results.setTransfer(transfer); 42 | } 43 | 44 | for (const connection of connections) { 45 | results.setConnection(connection); 46 | } 47 | 48 | const [journey] = factory.getJourneys(results.getConnectionIndex(), ["C"]); 49 | 50 | chai.expect(journey.origin).to.equal("A"); 51 | chai.expect(journey.destination).to.equal("C"); 52 | chai.expect(journey.departureTime).to.equal(1040); 53 | chai.expect(journey.arrivalTime).to.equal(1130); 54 | }); 55 | 56 | it("calculates the arrival time", () => { 57 | const results = new ScanResults({ A: 1000 }, {}); 58 | const transfers = [ 59 | t("A", "B", 60), 60 | t("C", "D", 60), 61 | ]; 62 | const connections = [ 63 | c("B", "C", 1100, 1130) 64 | ]; 65 | 66 | setStopTimes(connections); 67 | 68 | for (const transfer of transfers ) { 69 | results.setTransfer(transfer); 70 | } 71 | 72 | for (const connection of connections) { 73 | results.setConnection(connection); 74 | } 75 | 76 | const [journey] = factory.getJourneys(results.getConnectionIndex(), ["D"]); 77 | 78 | chai.expect(journey.origin).to.equal("A"); 79 | chai.expect(journey.destination).to.equal("D"); 80 | chai.expect(journey.departureTime).to.equal(1040); 81 | chai.expect(journey.arrivalTime).to.equal(1190); 82 | }); 83 | 84 | it("removes pointless legs", () => { 85 | const results = new ScanResults({ A: 1000 }, {}); 86 | const connections = [ 87 | c("A", "B", 1000, 1010, "LN1111"), 88 | c("B", "C", 1010, 1020, "LN1112"), 89 | c("C", "D", 1020, 1030, "LN1113"), 90 | c("D", "E", 1030, 1040, "LN1114") 91 | ]; 92 | const stopTimes = [ 93 | { stop: "A", dropOff: true, pickUp: true, arrivalTime: 1000, departureTime: 1000 }, 94 | { stop: "B", dropOff: true, pickUp: true, arrivalTime: 1010, departureTime: 1010 }, 95 | { stop: "C", dropOff: true, pickUp: true, arrivalTime: 1020, departureTime: 1020 }, 96 | { stop: "D", dropOff: true, pickUp: true, arrivalTime: 1030, departureTime: 1030 }, 97 | { stop: "E", dropOff: true, pickUp: true, arrivalTime: 1040, departureTime: 1040 }, 98 | ]; 99 | 100 | for (const connection of connections) { 101 | connection.trip.stopTimes = stopTimes; 102 | results.setConnection(connection); 103 | } 104 | 105 | const [journey] = factory.getJourneys(results.getConnectionIndex(), ["E"]); 106 | 107 | chai.expect(journey.origin).to.equal("A"); 108 | chai.expect(journey.destination).to.equal("E"); 109 | chai.expect(journey.departureTime).to.equal(1000); 110 | chai.expect(journey.arrivalTime).to.equal(1040); 111 | chai.expect(journey.legs.length).to.equal(1); 112 | }); 113 | 114 | }); 115 | -------------------------------------------------------------------------------- /src/csa/ScanResults.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { ScanResults } from "./ScanResults"; 3 | import { StopID, Time, TripID } from "../gtfs/Gtfs"; 4 | import { TimetableConnection } from "../journey/Connection"; 5 | import { Service } from "../gtfs/Service"; 6 | import { Transfer } from "../journey/Journey"; 7 | import { allDays } from "../gtfs/Service.spec"; 8 | 9 | export const defaultInterchange = { 10 | "A": 0, 11 | "B": 0 12 | }; 13 | 14 | describe("ScanResults", () => { 15 | 16 | it("knows if a connection is reachable", () => { 17 | const connection = c("A", "B", 1000, 1015); 18 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 19 | const actual = results.isReachable(connection); 20 | const expected = true; 21 | 22 | chai.expect(actual).to.deep.equal(expected); 23 | }); 24 | 25 | it("knows if a connection is not reachable", () => { 26 | const connection = c("A", "B", 1000, 1015); 27 | const results = new ScanResults(defaultInterchange, { "A": 1200 }); 28 | const actual = results.isReachable(connection); 29 | const expected = false; 30 | 31 | chai.expect(actual).to.deep.equal(expected); 32 | }); 33 | 34 | it("knows if a connection is not reachable because of interchange", () => { 35 | const connection1 = c("A", "B", 1000, 1015, "LN1111"); 36 | const connection2 = c("B", "C", 1030, 1100, "LN1112"); 37 | const results = new ScanResults({ "B": 100 }, { "A": 900 }); 38 | 39 | results.setConnection(connection1); 40 | 41 | const actual = results.isReachable(connection2); 42 | const expected = false; 43 | 44 | chai.expect(actual).to.deep.equal(expected); 45 | }); 46 | 47 | it("knows if a connection is better", () => { 48 | const connection1 = c("A", "B", 1000, 1015); 49 | const connection2 = c("A", "B", 1000, 1010); 50 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 51 | 52 | results.setConnection(connection1); 53 | 54 | const actual = results.isBetter(connection2); 55 | const expected = true; 56 | 57 | chai.expect(actual).to.deep.equal(expected); 58 | }); 59 | 60 | it("knows if a connection is not better", () => { 61 | const connection1 = c("A", "B", 1000, 1015); 62 | const connection2 = c("A", "B", 1000, 1030); 63 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 64 | 65 | results.setConnection(connection1); 66 | 67 | const actual = results.isBetter(connection2); 68 | const expected = false; 69 | 70 | chai.expect(actual).to.deep.equal(expected); 71 | }); 72 | 73 | it("knows if a transfer is better", () => { 74 | const connection1 = c("A", "B", 1000, 1015); 75 | const connection2 = t("A", "B", 10); 76 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 77 | 78 | results.setConnection(connection1); 79 | 80 | const actual = results.isTransferBetter(connection2); 81 | const expected = true; 82 | 83 | chai.expect(actual).to.deep.equal(expected); 84 | }); 85 | 86 | it("knows if a transfer is not better", () => { 87 | const connection1 = c("A", "B", 1000, 1015); 88 | const connection2 = t("A", "B", 1000); 89 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 90 | 91 | results.setConnection(connection1); 92 | 93 | const actual = results.isTransferBetter(connection2); 94 | const expected = false; 95 | 96 | chai.expect(actual).to.deep.equal(expected); 97 | }); 98 | 99 | it("knows if a transfer is better than a transfer", () => { 100 | const connection1 = t("A", "B", 20); 101 | const connection2 = t("A", "B", 10); 102 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 103 | 104 | results.setTransfer(connection1); 105 | 106 | const actual = results.isTransferBetter(connection2); 107 | const expected = true; 108 | 109 | chai.expect(actual).to.deep.equal(expected); 110 | }); 111 | 112 | it("returns the connection index", () => { 113 | const connection1 = c("A", "B", 1000, 1015); 114 | const connection2 = t("B", "C", 10); 115 | const results = new ScanResults(defaultInterchange, { "A": 900 }); 116 | 117 | results.setConnection(connection1); 118 | results.setTransfer(connection2); 119 | 120 | const actual = results.getConnectionIndex(); 121 | 122 | chai.expect(actual["B"]).to.deep.equal(connection1); 123 | chai.expect(actual["C"]).to.deep.equal(connection2); 124 | }); 125 | 126 | }); 127 | 128 | export function c( 129 | origin: StopID, 130 | destination: StopID, 131 | departureTime: Time, 132 | arrivalTime: Time, 133 | tripId: TripID = "LN1111" 134 | ): TimetableConnection { 135 | return { 136 | origin, 137 | destination, 138 | departureTime, 139 | arrivalTime, 140 | trip: { 141 | tripId, 142 | serviceId: "1", 143 | stopTimes: [], 144 | service: new Service(20190101, 20991231, allDays, {}) 145 | } 146 | }; 147 | } 148 | 149 | export function t(origin: TripID, destination: TripID, duration: Time): Transfer { 150 | return { origin, destination, duration, startTime: 0, endTime: 2359 }; 151 | } 152 | -------------------------------------------------------------------------------- /src/journey/JourneyFactory.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionIndex } from "../csa/ConnectionScanAlgorithm"; 2 | import { AnyLeg, Journey, TimetableLeg } from "./Journey"; 3 | import { StopID, StopTime, Time, Trip } from "../gtfs/Gtfs"; 4 | import { Connection, isChangeRequired, isTransfer } from "./Connection"; 5 | 6 | /** 7 | * Creates journeys from the connection index created by the connection scan algorithm. 8 | */ 9 | export class JourneyFactory { 10 | 11 | /** 12 | * Extract a result for each destination in the list. 13 | */ 14 | public getJourneys(connections: ConnectionIndex, destinations: StopID[]): Journey[] { 15 | return destinations 16 | .map(d => this.getLegs(connections, d)) 17 | .filter((c): c is AnyLeg[] => c !== null) 18 | .map(c => this.getCompactedLegs(c)) 19 | .map(l => this.getJourney(l)); 20 | } 21 | 22 | /** 23 | * Iterate backwards from the destination to the origin collecting connections into legs 24 | */ 25 | private getLegs(connections: ConnectionIndex, destination: string): AnyLeg[] | null { 26 | let legs: Connection[][] = []; 27 | let legConnections: Connection[] = []; 28 | let previousConnection: Connection | null = null; 29 | 30 | while (connections[destination]) { 31 | const connection = connections[destination]; 32 | 33 | if (previousConnection && isChangeRequired(previousConnection, connection)) { 34 | legs.push(legConnections.reverse()); 35 | legConnections = []; 36 | } 37 | 38 | legConnections.push(connection); 39 | previousConnection = connection; 40 | destination = connection.origin; 41 | } 42 | 43 | legs.push(legConnections.reverse()); 44 | 45 | return legConnections.length === 0 ? null : legs.reverse().map(cs => this.toLeg(cs)); 46 | } 47 | 48 | /** 49 | * Convert a list of connections into a Transfer or a TimetableLeg 50 | */ 51 | private toLeg(cs: Connection[]): AnyLeg { 52 | const firstConnection = cs[0]; 53 | 54 | if (isTransfer(firstConnection)) { 55 | return firstConnection; 56 | } 57 | else { 58 | const origin = firstConnection.origin; 59 | const destination = cs[cs.length - 1].destination; 60 | const trip = firstConnection.trip; 61 | const stopTimes = this.getStopTimes(firstConnection.trip, origin, firstConnection.departureTime, destination); 62 | 63 | return { origin, destination, trip, stopTimes: stopTimes || [] }; 64 | } 65 | } 66 | 67 | /** 68 | * Check for any redundant legs and replace them with new legs from the trip. 69 | */ 70 | private getCompactedLegs(legs: AnyLeg[]): AnyLeg[] { 71 | const newLegs: AnyLeg[] = []; 72 | 73 | for (let i = legs.length - 1; i >= 0; i--) { 74 | if (isTransfer(legs[i])) { 75 | newLegs.push(legs[i]); 76 | } 77 | else { 78 | let legI = legs[i] as TimetableLeg; 79 | let lastDepartureTime = legI.stopTimes[0].departureTime; 80 | 81 | for (let j = i - 1; j >= 0; j--) { 82 | const legJ = legs[j]; 83 | lastDepartureTime = isTransfer(legJ) ? lastDepartureTime - legJ.duration : legJ.stopTimes[0].departureTime; 84 | const stopTimes = this.getStopTimes(legI.trip, legJ.origin, lastDepartureTime, legI.destination); 85 | 86 | if (stopTimes) { 87 | legI.origin = legJ.origin; 88 | legI.stopTimes = stopTimes; 89 | i = j; 90 | } 91 | } 92 | 93 | newLegs.push(legI); 94 | } 95 | } 96 | 97 | return newLegs.reverse(); 98 | } 99 | 100 | /** 101 | * Try to create a new leg from the trip, ensuring the new leg departs the origin no earlier than the given 102 | * departure time. 103 | */ 104 | private getStopTimes(trip: Trip, origin: StopID, departureTime: Time, destination: StopID): StopTime[] | null { 105 | const start = trip.stopTimes.findIndex(c => c.pickUp && c.stop === origin && c.departureTime >= departureTime); 106 | const end = trip.stopTimes.findIndex((c, i) => c.dropOff && i > start && c.stop === destination); 107 | 108 | return start === -1 || end === -1 ? null : trip.stopTimes.slice(start, end + 1); 109 | } 110 | 111 | private getJourney(legs: AnyLeg[]): Journey { 112 | return { 113 | origin: legs[0].origin, 114 | destination: legs[legs.length - 1].destination, 115 | arrivalTime: this.getArrivalTime(legs), 116 | departureTime: this.getDepartureTime(legs), 117 | legs: legs 118 | }; 119 | } 120 | 121 | private getDepartureTime(legs: AnyLeg[]): Time { 122 | let transferDuration = 0; 123 | 124 | for (const leg of legs) { 125 | if (isTransfer(leg)) { 126 | transferDuration += leg.duration; 127 | } 128 | else { 129 | return leg.stopTimes[0].departureTime - transferDuration; 130 | } 131 | } 132 | 133 | return 0; 134 | } 135 | 136 | private getArrivalTime(legs: AnyLeg[]): Time { 137 | let transferDuration = 0; 138 | 139 | for (let i = legs.length - 1; i >= 0; i--) { 140 | const leg = legs[i]; 141 | 142 | if (isTransfer(leg)) { 143 | transferDuration += leg.duration; 144 | } 145 | else { 146 | return leg.stopTimes[leg.stopTimes.length - 1].arrivalTime + transferDuration; 147 | } 148 | } 149 | 150 | return 0; 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /src/gtfs/GtfsLoader.ts: -------------------------------------------------------------------------------- 1 | import * as gtfs from "gtfs-stream"; 2 | import { pushNested, setNested } from "ts-array-utils"; 3 | import { Readable } from "stream"; 4 | import { TimeParser } from "./TimeParser"; 5 | import { Service } from "./Service"; 6 | import {CalendarIndex, StopID, StopIndex, StopTime, Time, Trip} from "./Gtfs"; 7 | import { TimetableConnection } from "../journey/Connection"; 8 | import { Transfer } from ".."; 9 | 10 | /** 11 | * Returns trips, transfers, interchange time and calendars from a GTFS zip. 12 | */ 13 | export class GtfsLoader { 14 | 15 | constructor( 16 | private readonly timeParser: TimeParser 17 | ) {} 18 | 19 | public load(input: Readable): Promise { 20 | return new Promise(resolve => { 21 | const processor = new StatefulGtfsLoader(this.timeParser); 22 | 23 | input 24 | .pipe(gtfs({ raw: true })) 25 | .on("data", entity => processor[entity.type] && processor[entity.type](entity.data)) 26 | .on("end", () => resolve(processor.finalize())); 27 | }); 28 | 29 | } 30 | 31 | } 32 | 33 | /** 34 | * Encapsulation of the GTFS data while it is being loaded from the zip 35 | */ 36 | class StatefulGtfsLoader { 37 | private readonly trips: Trip[] = []; 38 | private readonly transfers = {}; 39 | private readonly interchange = {}; 40 | private readonly calendars: CalendarIndex = {}; 41 | private readonly dates = {}; 42 | private readonly stopTimes = {}; 43 | private readonly stops = {}; 44 | 45 | constructor( 46 | private readonly timeParser: TimeParser 47 | ) {} 48 | 49 | public link(row: any): void { 50 | const t = { 51 | origin: row.from_stop_id, 52 | destination: row.to_stop_id, 53 | duration: +row.duration, 54 | startTime: this.timeParser.getTime(row.start_time), 55 | endTime: this.timeParser.getTime(row.end_time) 56 | }; 57 | 58 | pushNested(t, this.transfers, row.from_stop_id); 59 | } 60 | 61 | public calendar(row: any): void { 62 | this.calendars[row.service_id] = { 63 | serviceId: row.service_id, 64 | startDate: +row.start_date, 65 | endDate: +row.end_date, 66 | days: { 67 | 0: row.sunday === "1", 68 | 1: row.monday === "1", 69 | 2: row.tuesday === "1", 70 | 3: row.wednesday === "1", 71 | 4: row.thursday === "1", 72 | 5: row.friday === "1", 73 | 6: row.saturday === "1" 74 | }, 75 | include: {}, 76 | exclude: {} 77 | }; 78 | } 79 | 80 | public calendar_date(row: any): void { 81 | setNested(row.exception_type === "1", this.dates, row.service_id, row.date); 82 | } 83 | 84 | public trip(row: any): void { 85 | this.trips.push({ serviceId: row.service_id, tripId: row.trip_id, stopTimes: [], service: {} as any }); 86 | } 87 | 88 | public stop_time(row: any): void { 89 | const stopTime = { 90 | stop: row.stop_id, 91 | departureTime: this.timeParser.getTime(row.departure_time), 92 | arrivalTime: this.timeParser.getTime(row.arrival_time), 93 | pickUp: row.pickup_type === "0", 94 | dropOff: row.drop_off_type === "0" 95 | }; 96 | 97 | pushNested(stopTime, this.stopTimes, row.trip_id); 98 | } 99 | 100 | public transfer(row: any): void { 101 | if (row.from_stop_id === row.to_stop_id) { 102 | this.interchange[row.from_stop_id] = +row.min_transfer_time; 103 | } 104 | else { 105 | const t = { 106 | origin: row.from_stop_id, 107 | destination: row.to_stop_id, 108 | duration: +row.min_transfer_time, 109 | startTime: 0, 110 | endTime: Number.MAX_SAFE_INTEGER 111 | }; 112 | 113 | pushNested(t, this.transfers, row.from_stop_id); 114 | } 115 | } 116 | 117 | public stop(row: any): void { 118 | const stop = { 119 | id: row.stop_id, 120 | code: row.stop_code, 121 | name: row.stop_name, 122 | description: row.stop_desc, 123 | latitude: +row.stop_lat, 124 | longitude: +row.stop_lon, 125 | timezone: row.zone_id 126 | }; 127 | 128 | setNested(stop, this.stops, row.stop_id); 129 | } 130 | 131 | public finalize(): GtfsData { 132 | const services = {}; 133 | const connections: TimetableConnection[] = []; 134 | 135 | for (const c of Object.values(this.calendars)) { 136 | services[c.serviceId] = new Service(c.startDate, c.endDate, c.days, this.dates[c.serviceId] || {}); 137 | } 138 | 139 | for (const t of this.trips) { 140 | t.stopTimes = this.stopTimes[t.tripId]; 141 | t.service = services[t.serviceId]; 142 | 143 | connections.push(...this.getConnectionsFromTrip(t)); 144 | } 145 | 146 | connections.sort((a, b) => a.arrivalTime - b.arrivalTime); 147 | 148 | for (const stop of Object.keys(this.stops)) { 149 | this.transfers[stop] = this.transfers[stop] || []; 150 | } 151 | 152 | return { connections, transfers: this.transfers, interchange: this.interchange, stops: this.stops }; 153 | } 154 | 155 | private getConnectionsFromTrip(t: Trip): TimetableConnection[] { 156 | const connections: TimetableConnection[] = []; 157 | 158 | for (let i = 0; i < t.stopTimes.length - 1; i++) { 159 | if (t.stopTimes[i].pickUp) { 160 | // go through the stops adding connections until we have passed at least one pick up and drop off point 161 | // need the check for pick up points as a stopping pattern A(p/d) -> B(d) -> C(p/d) would create connections 162 | // A->B but not get you to C. This way we get A->B + A->C 163 | for (let j = i + 1; j < t.stopTimes.length; j++) { 164 | if (t.stopTimes[j].dropOff) { 165 | connections.push({ 166 | origin: t.stopTimes[i].stop, 167 | destination: t.stopTimes[j].stop, 168 | departureTime: t.stopTimes[i].departureTime, 169 | arrivalTime: t.stopTimes[j].arrivalTime, 170 | trip: t 171 | }); 172 | 173 | if (t.stopTimes[j].pickUp) { 174 | break; 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | return connections; 182 | } 183 | 184 | } 185 | 186 | /** 187 | * Transfers indexed by origin 188 | */ 189 | export type TransfersByOrigin = Record; 190 | 191 | /** 192 | * Index of stop to interchange time 193 | */ 194 | export type Interchange = Record; 195 | 196 | /** 197 | * Contents of the GTFS zip file 198 | */ 199 | export type GtfsData = { 200 | connections: TimetableConnection[], 201 | transfers: TransfersByOrigin, 202 | interchange: Interchange, 203 | stops: StopIndex 204 | }; 205 | -------------------------------------------------------------------------------- /src/csa/ConnectionScanAlgorithm.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { ConnectionScanAlgorithm } from "./ConnectionScanAlgorithm"; 3 | import { c, defaultInterchange, t } from "./ScanResults.spec"; 4 | import { ScanResultsFactory } from "./ScanResultsFactory"; 5 | import { JourneyFactory, TimetableConnection } from ".."; 6 | 7 | describe("ConnectionScanAlgorithm", () => { 8 | const scanResultsFactory = new ScanResultsFactory(defaultInterchange); 9 | const journeyResultsFactory = new JourneyFactory(); 10 | const noTransfers = { "A": [], "B": [], "C": [], "D": [] }; 11 | 12 | it("plan a basic journey", () => { 13 | const timetable = [ 14 | c("A", "B", 1000, 1015), 15 | c("B", "C", 1020, 1045), 16 | c("C", "D", 1100, 1115), 17 | ]; 18 | 19 | setStopTimes(timetable); 20 | 21 | const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); 22 | const results = scanner.scan({ "A": 900 }, ["D"], 20190101, 0); 23 | const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); 24 | 25 | chai.expect(journey.origin).to.equal("A"); 26 | chai.expect(journey.destination).to.equal("D"); 27 | chai.expect(journey.legs.length).to.equal(1); 28 | chai.expect(journey.departureTime).to.equal(1000); 29 | chai.expect(journey.arrivalTime).to.equal(1115); 30 | }); 31 | 32 | it("returns no results when there is no connection", () => { 33 | const timetable = [ 34 | c("A", "B", 1000, 1015), 35 | c("C", "D", 1100, 1115), 36 | ]; 37 | 38 | setStopTimes(timetable); 39 | 40 | const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); 41 | const results = scanner.scan({ "A": 900 }, ["D"], 20190101, 0); 42 | const journeys = journeyResultsFactory.getJourneys(results, ["D"]); 43 | 44 | chai.expect(journeys.length).to.equal(0); 45 | }); 46 | 47 | it("returns no results when there is a missed connection", () => { 48 | const timetable = [ 49 | c("A", "B", 1000, 1015), 50 | c("B", "C", 1000, 1030), 51 | c("C", "D", 1100, 1115), 52 | ]; 53 | 54 | setStopTimes(timetable); 55 | 56 | const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, scanResultsFactory); 57 | const results = scanner.scan({ "A": 900 }, ["D"], 20190101, 0); 58 | const journeys = journeyResultsFactory.getJourneys(results, ["D"]); 59 | 60 | chai.expect(journeys.length).to.equal(0); 61 | }); 62 | 63 | it("plan a journey that starts with a transfer", () => { 64 | const timetable = [ 65 | c("B", "C", 1020, 1045), 66 | c("C", "D", 1100, 1115), 67 | ]; 68 | 69 | setStopTimes(timetable); 70 | 71 | const transfers = { 72 | ...noTransfers, 73 | "A": [ 74 | { origin: "A", destination: "B", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, 75 | ] 76 | }; 77 | 78 | const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); 79 | const results = scanner.scan({ "A": 900 }, ["D"], 20190101, 0); 80 | const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); 81 | 82 | chai.expect(journey.origin).to.equal("A"); 83 | chai.expect(journey.destination).to.equal("D"); 84 | chai.expect(journey.legs.length).to.equal(2); 85 | chai.expect(journey.departureTime).to.equal(1010); 86 | chai.expect(journey.arrivalTime).to.equal(1115); 87 | }); 88 | 89 | it("plan a journey that ends with a transfer", () => { 90 | const timetable = [ 91 | c("A", "B", 1000, 1015), 92 | c("B", "C", 1020, 1045), 93 | ]; 94 | 95 | setStopTimes(timetable); 96 | 97 | const transfers = { 98 | ...noTransfers, 99 | "C": [ 100 | { origin: "C", destination: "D", duration: 10, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }, 101 | ] 102 | }; 103 | 104 | const scanner = new ConnectionScanAlgorithm(timetable, transfers, scanResultsFactory); 105 | const results = scanner.scan({ "A": 900 }, ["D"], 20190101, 0); 106 | const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); 107 | 108 | chai.expect(journey.origin).to.equal("A"); 109 | chai.expect(journey.destination).to.equal("D"); 110 | chai.expect(journey.legs.length).to.equal(2); 111 | chai.expect(journey.departureTime).to.equal(1000); 112 | chai.expect(journey.arrivalTime).to.equal(1055); 113 | }); 114 | 115 | /** 116 | * In this scenario there are two trips running in parallel. Trip 1 arrives earliest at A, B and C and Trip 2 arrives 117 | * earliest at D. It is not possible to change onto the second trip at C because of the interchange change, however 118 | * the algorithm should detect that it was possible to board at A and add the connection. The list of connections 119 | * 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 120 | * up by realising that the whole journey could be made on a single trip (trip 2). 121 | */ 122 | it("checks for connections missed because of interchange time", () => { 123 | const trip1 = [ 124 | c("A", "B", 1000, 1010, "1"), 125 | c("B", "C", 1010, 1020, "1"), 126 | c("C", "D", 1020, 1040, "1"), 127 | ]; 128 | 129 | setStopTimes(trip1); 130 | 131 | const trip2 = [ 132 | c("A", "B", 1005, 1015, "2"), 133 | c("B", "C", 1015, 1025, "2"), 134 | c("C", "D", 1025, 1035, "2"), 135 | ]; 136 | 137 | setStopTimes(trip2); 138 | 139 | const timetable = [...trip1, ...trip2].sort((a, b) => a.arrivalTime - b.arrivalTime); 140 | 141 | const resultsFactory = new ScanResultsFactory({ "A": 10, "B": 10, "C": 10, "D": 10 }); 142 | const scanner = new ConnectionScanAlgorithm(timetable, noTransfers, resultsFactory); 143 | const results = scanner.scan({ "A": 900 }, ["D"], 20200101, 0); 144 | const [journey] = journeyResultsFactory.getJourneys(results, ["D"]); 145 | 146 | chai.expect(journey.origin).to.equal("A"); 147 | chai.expect(journey.destination).to.equal("D"); 148 | chai.expect(journey.legs.length).to.equal(1); 149 | chai.expect(journey.departureTime).to.equal(1005); 150 | chai.expect(journey.arrivalTime).to.equal(1035); 151 | }); 152 | 153 | }); 154 | 155 | export function setStopTimes(connections: TimetableConnection[]) { 156 | const stopTimes = connections 157 | .map(connection => ({ 158 | stop: connection.origin, 159 | pickUp: true, 160 | dropOff: true, 161 | departureTime: connection.departureTime, 162 | arrivalTime: connection.departureTime 163 | })); 164 | 165 | stopTimes.push({ 166 | stop: connections[connections.length - 1].destination, 167 | pickUp: true, 168 | dropOff: true, 169 | departureTime: connections[connections.length - 1].arrivalTime, 170 | arrivalTime: connections[connections.length - 1].arrivalTime 171 | }); 172 | 173 | for (const connection of connections) { 174 | connection.trip.stopTimes = stopTimes; 175 | } 176 | } 177 | --------------------------------------------------------------------------------