@transportme/load-ptv-gtfs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/unit-test.yml +46 -0
- package/lib/constants.mjs +18 -0
- package/lib/gtfs-package/PTVGTFS.mjs +96 -0
- package/lib/gtfs-parser/GTFSAgency.mjs +15 -0
- package/lib/gtfs-parser/GTFSCalendar.mjs +63 -0
- package/lib/gtfs-parser/GTFSCalendarDate.mjs +15 -0
- package/lib/gtfs-parser/GTFSRoute.mjs +106 -0
- package/lib/gtfs-parser/GTFSShape.mjs +39 -0
- package/lib/gtfs-parser/GTFSStop.mjs +212 -0
- package/lib/gtfs-parser/GTFSStopTime.mjs +30 -0
- package/lib/gtfs-parser/GTFSTrip.mjs +169 -0
- package/lib/gtfs-parser/readers/GTFSAgencyReader.mjs +27 -0
- package/lib/gtfs-parser/readers/GTFSCalendarDatesReader.mjs +25 -0
- package/lib/gtfs-parser/readers/GTFSCalendarReader.mjs +63 -0
- package/lib/gtfs-parser/readers/GTFSReader.mjs +48 -0
- package/lib/gtfs-parser/readers/GTFSRouteReader.mjs +35 -0
- package/lib/gtfs-parser/readers/GTFSShapeReader.mjs +75 -0
- package/lib/gtfs-parser/readers/GTFSStopTimesReader.mjs +85 -0
- package/lib/gtfs-parser/readers/GTFSStopsReader.mjs +36 -0
- package/lib/gtfs-parser/readers/GTFSTripReader.mjs +61 -0
- package/lib/gtfs-parser/readers/line-reader.mjs +112 -0
- package/lib/loader/RouteLoader.mjs +101 -0
- package/lib/loader/ShapeLoader.mjs +86 -0
- package/lib/loader/StopsLoader.mjs +139 -0
- package/lib/loader/TripLoader.mjs +187 -0
- package/lib/post-processing/merge-stops.mjs +85 -0
- package/lib/post-processing/set-route-stops.mjs +73 -0
- package/lib/post-processing/set-stop-services.mjs +64 -0
- package/lib/utils.mjs +16 -0
- package/package.json +27 -0
- package/test/gtfs-calendar.test.mjs +182 -0
- package/test/gtfs-route-loader.test.mjs +190 -0
- package/test/gtfs-routes.test.mjs +77 -0
- package/test/gtfs-shapes-loader.test.mjs +105 -0
- package/test/gtfs-shapes.test.mjs +23 -0
- package/test/gtfs-stop-loader-metro.test.mjs +49 -0
- package/test/gtfs-stop-loader.test.mjs +223 -0
- package/test/gtfs-stop-times.test.mjs +37 -0
- package/test/gtfs-stops.test.mjs +290 -0
- package/test/gtfs-trip-loader.test.mjs +142 -0
- package/test/gtfs-trips.test.mjs +109 -0
- package/test/line-reader.test.mjs +94 -0
- package/test/merge-stops.test.mjs +376 -0
- package/test/ptv-gtfs.test.mjs +106 -0
- package/test/sample-data/calendar/calendar.txt +34 -0
- package/test/sample-data/calendar/calendar_dates.txt +26 -0
- package/test/sample-data/gtfs-splitting/force_unique_stops_bus.txt +7 -0
- package/test/sample-data/gtfs-splitting/force_unique_stops_tram.txt +3 -0
- package/test/sample-data/gtfs-splitting/line_reader_test.txt +8 -0
- package/test/sample-data/gtfs-splitting/name_overrides.txt +4 -0
- package/test/sample-data/gtfs-splitting/nov_2024_train_data.txt +6 -0
- package/test/sample-data/gtfs-splitting/sample_stops.txt +20 -0
- package/test/sample-data/gtfs-splitting/special_chars_stops_csv.txt +6 -0
- package/test/sample-data/gtfs-splitting/stops_without_secondary.txt +6 -0
- package/test/sample-data/gtfs-unzipping/gtfs.zip +0 -0
- package/test/sample-data/one_line.csv +2 -0
- package/test/sample-data/routes/agency.txt +11 -0
- package/test/sample-data/routes/metro_bus_routes.txt +4 -0
- package/test/sample-data/routes/metro_lines.txt +5 -0
- package/test/sample-data/routes/regional_bus_routes.txt +7 -0
- package/test/sample-data/routes/vline.txt +7 -0
- package/test/sample-data/routes/west_gipp_transit.txt +20 -0
- package/test/sample-data/shapes/shapes.txt +89 -0
- package/test/sample-data/shapes/stop_times.txt +9 -0
- package/test/sample-data/shapes/stops.txt +4 -0
- package/test/sample-data/shapes/trips.txt +4 -0
- package/test/sample-data/stop-data/name-overrides.json +25 -0
- package/test/sample-data/stop-data/unique-stops.json +1 -0
- package/test/sample-data/suburbs.json +77 -0
- package/test/sample-data/trips/bus_trips.txt +5 -0
- package/test/sample-data/trips/stop_times.txt +29 -0
- package/test/sample-data/trips/stop_times_new.txt +8 -0
- package/test/sample-data/trips/stops.txt +8 -0
- package/test/sample-data/trips/trips.txt +5 -0
- package/test/sample-data/trips/trips_new.txt +2 -0
- package/test/set-stop-services.test.mjs +69 -0
- package/test/utils-test.mjs +26 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import lineReader from 'line-reader'
|
|
2
|
+
|
|
3
|
+
export default class CSVLineReader {
|
|
4
|
+
|
|
5
|
+
/** @type {String[]} */
|
|
6
|
+
#headers
|
|
7
|
+
/** @type {string} */
|
|
8
|
+
#file
|
|
9
|
+
/** @type {boolean} */
|
|
10
|
+
#unread
|
|
11
|
+
/** @type {Object.<string, string>} */
|
|
12
|
+
#previousLine
|
|
13
|
+
/** @type {LineReader} */
|
|
14
|
+
#reader
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Constructs a new CSVLineReader for a given file.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} file The CSV file path
|
|
20
|
+
*/
|
|
21
|
+
constructor(file) {
|
|
22
|
+
this.#headers = []
|
|
23
|
+
this.#file = file
|
|
24
|
+
this.#unread = false
|
|
25
|
+
this.#previousLine = null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Opens the CSV file for reading, and reads the first line for the headers.
|
|
30
|
+
*/
|
|
31
|
+
async open() {
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
lineReader.open(this.#file, async (err, reader) => {
|
|
34
|
+
if (err) return reject(err)
|
|
35
|
+
|
|
36
|
+
this.#reader = reader
|
|
37
|
+
this.#headers = (await this.#readNextLine()).split(',')
|
|
38
|
+
resolve()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks if there is still more data available for reading
|
|
45
|
+
*
|
|
46
|
+
* @returns {boolean} If there is still a line available
|
|
47
|
+
*/
|
|
48
|
+
available() {
|
|
49
|
+
return this.#unread || this.#reader.hasNextLine()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#readNextLine() {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
this.#reader.nextLine((err, line) => {
|
|
55
|
+
resolve(line.trim())
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#setHeaders(line) {
|
|
61
|
+
let parts = line.match(/"([^"]*)"/g).map(f => f.slice(1, -1))
|
|
62
|
+
return this.#headers.reduce((acc, header, i) => {
|
|
63
|
+
acc[header] = parts[i]
|
|
64
|
+
return acc
|
|
65
|
+
}, {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the next line of CSV data, taking into account any unreads.
|
|
70
|
+
*
|
|
71
|
+
* @async
|
|
72
|
+
* @returns {Promise<Object.<string,string>>} The line data, parsed according to the headers
|
|
73
|
+
*/
|
|
74
|
+
async nextLine() {
|
|
75
|
+
if (this.#unread) {
|
|
76
|
+
this.#unread = false
|
|
77
|
+
return this.#previousLine
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let line = this.#setHeaders(await this.#readNextLine())
|
|
81
|
+
this.#previousLine = line
|
|
82
|
+
|
|
83
|
+
return line
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Unreads a line, causing it to be returned again during the next call to `nextLine()`
|
|
88
|
+
*/
|
|
89
|
+
unreadLine() {
|
|
90
|
+
this.#unread = true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Closes the CSV line reader.
|
|
95
|
+
*/
|
|
96
|
+
async close() {
|
|
97
|
+
await new Promise(resolve => {
|
|
98
|
+
this.#reader.close(() => {
|
|
99
|
+
resolve()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns the CSV headers.
|
|
106
|
+
*
|
|
107
|
+
* @returns {string[]} A list of headers
|
|
108
|
+
*/
|
|
109
|
+
getHeaders() {
|
|
110
|
+
return this.#headers
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import GTFSRouteReader from '../gtfs-parser/readers/GTFSRouteReader.mjs'
|
|
2
|
+
import GTFSAgencyReader from '../gtfs-parser/readers/GTFSAgencyReader.mjs'
|
|
3
|
+
import { STOP_UTILS } from '@transportme/transportvic-utils'
|
|
4
|
+
|
|
5
|
+
const { sanitiseName } = STOP_UTILS
|
|
6
|
+
|
|
7
|
+
export default class RouteLoader {
|
|
8
|
+
|
|
9
|
+
#routes
|
|
10
|
+
#mode
|
|
11
|
+
#routesReader
|
|
12
|
+
#operators = {}
|
|
13
|
+
#agencyFile
|
|
14
|
+
|
|
15
|
+
#routeIDMap = {}
|
|
16
|
+
|
|
17
|
+
constructor(routesFile, agencyFile, mode, database) {
|
|
18
|
+
this.#routes = database.getCollection('routes')
|
|
19
|
+
this.#mode = mode
|
|
20
|
+
this.#routesReader = new GTFSRouteReader(routesFile, mode)
|
|
21
|
+
this.#agencyFile = agencyFile
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async loadAgencies() {
|
|
25
|
+
let agencyReader = new GTFSAgencyReader(this.#agencyFile)
|
|
26
|
+
await agencyReader.open()
|
|
27
|
+
while (agencyReader.available()) {
|
|
28
|
+
let agency = await agencyReader.getNextEntity()
|
|
29
|
+
this.#operators[agency.id] = agency
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async findMatchingRoute(routeData) {
|
|
34
|
+
return await this.#routes.findDocument({
|
|
35
|
+
routeGTFSID: routeData.routeGTFSID
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
generateDBRouteData(route) {
|
|
40
|
+
return {
|
|
41
|
+
mode: this.#mode,
|
|
42
|
+
routeName: route.routeName,
|
|
43
|
+
cleanName: sanitiseName(route.routeName),
|
|
44
|
+
routeNumber: route.routeNumber,
|
|
45
|
+
routeGTFSID: route.routeGTFSID,
|
|
46
|
+
routePath: [],
|
|
47
|
+
operators: [ this.#operators[route.agencyID].name ],
|
|
48
|
+
directions: [],
|
|
49
|
+
|
|
50
|
+
codedName: sanitiseName(route.routeName)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async mergeRoutes(matchingRoute, routeData) {
|
|
55
|
+
if (!matchingRoute.operators.includes(routeData.operators[0])) {
|
|
56
|
+
matchingRoute.operators.push(routeData.operators[0])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await this.#routes.replaceDocument({
|
|
60
|
+
_id: matchingRoute._id
|
|
61
|
+
}, matchingRoute)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Loads the route data into the database.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} options An optional parameter to allow for custom route processing
|
|
68
|
+
* @param {function} options.processRoute A function to modify the route data. If a route should be dropped, it should return null.
|
|
69
|
+
*/
|
|
70
|
+
async loadRoutes({ processRoute } = { processRoute: r => r }) {
|
|
71
|
+
await this.loadAgencies()
|
|
72
|
+
|
|
73
|
+
await this.#routesReader.open()
|
|
74
|
+
while (this.#routesReader.available()) {
|
|
75
|
+
let route = await this.#routesReader.getNextEntity()
|
|
76
|
+
|
|
77
|
+
let originalRouteID = route.originalRouteGTFSID
|
|
78
|
+
let routeData = this.generateDBRouteData(route)
|
|
79
|
+
let processedData = await processRoute(routeData)
|
|
80
|
+
if (!processedData) continue
|
|
81
|
+
|
|
82
|
+
this.#routeIDMap[originalRouteID] = processedData.routeGTFSID
|
|
83
|
+
|
|
84
|
+
let matchingRoute = await this.findMatchingRoute(processedData)
|
|
85
|
+
|
|
86
|
+
if (matchingRoute) {
|
|
87
|
+
await this.mergeRoutes(matchingRoute, processedData)
|
|
88
|
+
} else {
|
|
89
|
+
await this.#routes.createDocument(processedData)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getOperator(id) {
|
|
95
|
+
return this.#operators[id]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getRouteIDMap() {
|
|
99
|
+
return this.#routeIDMap
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { GTFSStop } from '../gtfs-parser/GTFSStop.mjs'
|
|
2
|
+
import GTFSShapeReader from '../gtfs-parser/readers/GTFSShapeReader.mjs'
|
|
3
|
+
|
|
4
|
+
export default class ShapeLoader {
|
|
5
|
+
|
|
6
|
+
#routes
|
|
7
|
+
#shapeReader
|
|
8
|
+
|
|
9
|
+
#routeCache = {}
|
|
10
|
+
|
|
11
|
+
constructor(shapeFile, database) {
|
|
12
|
+
this.#routes = database.getCollection('routes')
|
|
13
|
+
this.#shapeReader = new GTFSShapeReader(shapeFile)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async findMatchingRoute(routeGTFSID) {
|
|
17
|
+
if (this.#routeCache[routeGTFSID]) return this.#routeCache[routeGTFSID]
|
|
18
|
+
|
|
19
|
+
let route = await this.#routes.findDocument({
|
|
20
|
+
routeGTFSID: routeGTFSID
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
this.#routeCache[routeGTFSID] = route
|
|
24
|
+
return route
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async updateRoute(routeGTFSID, routePath) {
|
|
28
|
+
this.#routeCache[routeGTFSID].routePath = routePath
|
|
29
|
+
await this.#routes.updateDocument({
|
|
30
|
+
routeGTFSID
|
|
31
|
+
}, { $set: { routePath } })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
generateDBShapeData(shape) {
|
|
35
|
+
return {
|
|
36
|
+
fullGTFSIDs: [ shape.shapeID ],
|
|
37
|
+
hash: shape.hash,
|
|
38
|
+
length: shape.shapeLength,
|
|
39
|
+
path: {
|
|
40
|
+
type: 'LineString',
|
|
41
|
+
coordinates: shape.points.map(point => point.coordinates)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Loads a shape into the database.
|
|
48
|
+
*
|
|
49
|
+
* @param {GTFSStop} shape The shape being added
|
|
50
|
+
* @param {function} options.shapeIDMap A mapping of shape IDs to their trip IDs.
|
|
51
|
+
*/
|
|
52
|
+
async loadShape(shape, shapeIDMap) {
|
|
53
|
+
let routeGTFSID = shapeIDMap[shape.shapeID]
|
|
54
|
+
if (!routeGTFSID) return // All trips corresponding to that shape must have been dropped
|
|
55
|
+
|
|
56
|
+
let routeData = await this.findMatchingRoute(routeGTFSID)
|
|
57
|
+
|
|
58
|
+
let matchingShape = routeData.routePath.find(path => path.hash === shape.hash)
|
|
59
|
+
if (matchingShape) {
|
|
60
|
+
matchingShape.fullGTFSIDs.push(shape.shapeID)
|
|
61
|
+
} else {
|
|
62
|
+
routeData.routePath.push(this.generateDBShapeData(shape))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await this.updateRoute(routeGTFSID, routeData.routePath)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Loads the shapes into the database.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} options Options for loading the shape data in
|
|
72
|
+
* @param {function} options.shapeIDMap A mapping of shape IDs to their trip IDs.
|
|
73
|
+
*/
|
|
74
|
+
async loadShapes({ shapeIDMap }) {
|
|
75
|
+
await this.#shapeReader.open()
|
|
76
|
+
|
|
77
|
+
let shapesLoaded = 0
|
|
78
|
+
|
|
79
|
+
while (this.#shapeReader.available()) {
|
|
80
|
+
let shape = await this.#shapeReader.getNextEntity()
|
|
81
|
+
await this.loadShape(shape, shapeIDMap)
|
|
82
|
+
|
|
83
|
+
if (++shapesLoaded === 1000) this.#routeCache = {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { STOP_UTILS } from '@transportme/transportvic-utils'
|
|
2
|
+
import { GTFSStop } from '../gtfs-parser/GTFSStop.mjs'
|
|
3
|
+
import GTFSStopsReader from '../gtfs-parser/readers/GTFSStopsReader.mjs'
|
|
4
|
+
|
|
5
|
+
const { getPrimaryStopName, isStreetStop, sanitiseName } = STOP_UTILS
|
|
6
|
+
|
|
7
|
+
export default class StopsLoader {
|
|
8
|
+
|
|
9
|
+
#stops
|
|
10
|
+
#mode
|
|
11
|
+
#stopsReader
|
|
12
|
+
|
|
13
|
+
constructor(stopsFile, suburbs, mode, database) {
|
|
14
|
+
this.#stops = database.getCollection('stops')
|
|
15
|
+
this.#mode = mode
|
|
16
|
+
this.#stopsReader = new GTFSStopsReader(stopsFile, suburbs)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getMergeName(stop) {
|
|
20
|
+
if (isStreetStop(stop.fullStopName)) return stop.fullStopName
|
|
21
|
+
|
|
22
|
+
return getPrimaryStopName(stop.fullStopName)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findMatchingStop(stop, mergeName) {
|
|
26
|
+
return await this.#stops.findDocument({
|
|
27
|
+
mergeName,
|
|
28
|
+
location: {
|
|
29
|
+
$nearSphere: {
|
|
30
|
+
$geometry: stop.location,
|
|
31
|
+
$maxDistance: 500
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
generateDBStopData(stop, mergeName) {
|
|
38
|
+
let cleanName = sanitiseName(stop.fullStopName)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
stopName: stop.fullStopName,
|
|
42
|
+
suburb: [ stop.suburb ],
|
|
43
|
+
cleanSuburbs: [ sanitiseName(stop.suburb) ],
|
|
44
|
+
bays: [ stop.getBayData(this.#mode) ],
|
|
45
|
+
location: {
|
|
46
|
+
type: 'MultiPoint',
|
|
47
|
+
coordinates: [ stop.location.coordinates ]
|
|
48
|
+
},
|
|
49
|
+
mergeName,
|
|
50
|
+
services: [],
|
|
51
|
+
screenServices: [],
|
|
52
|
+
cleanName,
|
|
53
|
+
cleanNames: [ cleanName ],
|
|
54
|
+
|
|
55
|
+
codedSuburbs: [ sanitiseName(stop.suburb) ], // parity purposes
|
|
56
|
+
codedName: cleanName, // parity purposes
|
|
57
|
+
codedNames: [ cleanName ] // parity purposes
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
updateStopName(stop) {
|
|
62
|
+
stop.cleanNames = stop.bays
|
|
63
|
+
.map(bay => sanitiseName(bay.fullStopName))
|
|
64
|
+
.filter((e, i, a) => a.indexOf(e) === i)
|
|
65
|
+
|
|
66
|
+
if (stop.cleanNames.length > 1) {
|
|
67
|
+
stop.stopName = stop.mergeName
|
|
68
|
+
stop.cleanName = sanitiseName(stop.stopName)
|
|
69
|
+
|
|
70
|
+
if (!stop.cleanNames.includes(stop.cleanName)) stop.cleanNames.push(stop.cleanName)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stop.codedName = stop.cleanName
|
|
74
|
+
stop.codedNames = stop.cleanNames
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
updateStopSuburb(stop) {
|
|
78
|
+
stop.cleanSuburbs = stop.bays
|
|
79
|
+
.map(bay => sanitiseName(bay.suburb))
|
|
80
|
+
.filter((e, i, a) => a.indexOf(e) === i)
|
|
81
|
+
|
|
82
|
+
stop.suburb = stop.bays
|
|
83
|
+
.map(bay => bay.suburb)
|
|
84
|
+
.filter((e, i, a) => a.indexOf(e) === i)
|
|
85
|
+
.sort()
|
|
86
|
+
|
|
87
|
+
stop.codedSuburbs = stop.cleanSuburbs
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async mergeStops(matchingStop, stopData) {
|
|
91
|
+
matchingStop.bays.push(stopData.bays[0])
|
|
92
|
+
|
|
93
|
+
this.updateStopSuburb(matchingStop)
|
|
94
|
+
this.updateStopName(matchingStop)
|
|
95
|
+
|
|
96
|
+
await this.#stops.replaceDocument({
|
|
97
|
+
_id: matchingStop._id
|
|
98
|
+
}, matchingStop)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Loads a stop into the database.
|
|
103
|
+
*
|
|
104
|
+
* @param {GTFSStop} stop The stop being added
|
|
105
|
+
* @param {object} options Options for loading the stop data in
|
|
106
|
+
* @param {function} options.processStop Used to process as stop's data.
|
|
107
|
+
* @param {function} options.getMergeName Used to get a stop's merge name. Return null to use the default merge name.
|
|
108
|
+
*/
|
|
109
|
+
async loadStop(stop, {processStop, getMergeName} = {}) {
|
|
110
|
+
let processedData = processStop ? processStop(stop) : stop
|
|
111
|
+
|
|
112
|
+
let defaultMergeName = this.getMergeName(processedData)
|
|
113
|
+
let mergeName = getMergeName ? (getMergeName(processedData) || defaultMergeName) : defaultMergeName
|
|
114
|
+
|
|
115
|
+
let stopData = this.generateDBStopData(processedData, mergeName)
|
|
116
|
+
let matchingStop = await this.findMatchingStop(processedData, mergeName)
|
|
117
|
+
|
|
118
|
+
if (matchingStop) {
|
|
119
|
+
await this.mergeStops(matchingStop, stopData)
|
|
120
|
+
} else {
|
|
121
|
+
await this.#stops.createDocument(stopData)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Loads the stops into the database.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} options Options for loading the stop data in
|
|
129
|
+
* @param {function} options.processStop Used to process as stop's data.
|
|
130
|
+
* @param {function} options.getMergeName Used to get a stop's merge name. Return null to use the default merge name.
|
|
131
|
+
*/
|
|
132
|
+
async loadStops(options) {
|
|
133
|
+
await this.#stopsReader.open()
|
|
134
|
+
while (this.#stopsReader.available()) {
|
|
135
|
+
let stop = await this.#stopsReader.getNextEntity()
|
|
136
|
+
await this.loadStop(stop, options)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import GTFSCalendarReader from '../gtfs-parser/readers/GTFSCalendarReader.mjs'
|
|
2
|
+
import GTFSStopTimesReader from '../gtfs-parser/readers/GTFSStopTimesReader.mjs'
|
|
3
|
+
import GTFSTripReader from '../gtfs-parser/readers/GTFSTripReader.mjs'
|
|
4
|
+
import async from 'async'
|
|
5
|
+
|
|
6
|
+
export default class TripLoader {
|
|
7
|
+
|
|
8
|
+
#routes
|
|
9
|
+
#stops
|
|
10
|
+
#trips
|
|
11
|
+
|
|
12
|
+
#tripsReader
|
|
13
|
+
#stopTimesReader
|
|
14
|
+
#calendarReader
|
|
15
|
+
|
|
16
|
+
#mode
|
|
17
|
+
|
|
18
|
+
#tripDetails = {}
|
|
19
|
+
|
|
20
|
+
#calendarMap = {}
|
|
21
|
+
#routeIDMap = {}
|
|
22
|
+
|
|
23
|
+
#routeCache = {}
|
|
24
|
+
#stopCache = {}
|
|
25
|
+
|
|
26
|
+
#shapeIDMap = {}
|
|
27
|
+
|
|
28
|
+
constructor({ tripsFile, stopTimesFile, calendarFile, calendarDatesFile }, mode, database) {
|
|
29
|
+
this.#routes = database.getCollection('routes')
|
|
30
|
+
this.#stops = database.getCollection('stops')
|
|
31
|
+
this.#trips = database.getCollection('gtfs timetables')
|
|
32
|
+
|
|
33
|
+
this.#mode = mode
|
|
34
|
+
|
|
35
|
+
this.#stopTimesReader = new GTFSStopTimesReader(stopTimesFile, mode)
|
|
36
|
+
this.#tripsReader = new GTFSTripReader(tripsFile, null, null, mode)
|
|
37
|
+
this.#calendarReader = new GTFSCalendarReader(calendarFile, calendarDatesFile)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async loadTripsData() {
|
|
41
|
+
let tripsReader = this.#tripsReader
|
|
42
|
+
tripsReader.setCalendar(this.#calendarMap)
|
|
43
|
+
tripsReader.setRouteMappings(this.#routeIDMap)
|
|
44
|
+
|
|
45
|
+
await tripsReader.open()
|
|
46
|
+
while (tripsReader.available()) {
|
|
47
|
+
let trip = await tripsReader.getNextEntity()
|
|
48
|
+
this.#tripDetails[trip.getTripID()] = trip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async loadCalendarData() {
|
|
53
|
+
let calendarReader = this.#calendarReader
|
|
54
|
+
|
|
55
|
+
await calendarReader.open()
|
|
56
|
+
while (calendarReader.available()) {
|
|
57
|
+
let calendar = await calendarReader.getNextEntity()
|
|
58
|
+
this.#calendarMap[calendar.id] = calendar
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async findMatchingRoute(routeGTFSID) {
|
|
63
|
+
if (this.#routeCache[routeGTFSID]) return this.#routeCache[routeGTFSID]
|
|
64
|
+
|
|
65
|
+
let route = await this.#routes.findDocument({
|
|
66
|
+
routeGTFSID: routeGTFSID
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
this.#routeCache[routeGTFSID] = route
|
|
70
|
+
return route
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async findMatchingStop(stopGTFSID) {
|
|
74
|
+
if (this.#stopCache[stopGTFSID]) return this.#stopCache[stopGTFSID]
|
|
75
|
+
|
|
76
|
+
let stop = await this.#stops.findDocument({
|
|
77
|
+
'bays.stopGTFSID': stopGTFSID
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
this.#stopCache[stopGTFSID] = stop
|
|
81
|
+
return stop
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async generateDBTripData(tripData, stops) {
|
|
85
|
+
let routeData = await this.findMatchingRoute(tripData.getRouteGTFSID())
|
|
86
|
+
if (!routeData) return null
|
|
87
|
+
|
|
88
|
+
let stopTimings = await async.map(stops, async stop => {
|
|
89
|
+
let stopData = await this.findMatchingStop(stop.stopID)
|
|
90
|
+
let bay = stopData.bays.find(bay => bay.stopGTFSID === stop.stopID)
|
|
91
|
+
|
|
92
|
+
let data = {
|
|
93
|
+
stopName: bay.fullStopName,
|
|
94
|
+
stopNumber: bay.stopNumber,
|
|
95
|
+
suburb: bay.suburb,
|
|
96
|
+
stopGTFSID: stop.stopID,
|
|
97
|
+
arrivalTime: stop.arrivalTime,
|
|
98
|
+
arrivalTimeMinutes: stop.arrivalTimeMinutes,
|
|
99
|
+
departureTime: stop.departureTime,
|
|
100
|
+
departureTimeMinutes: stop.departureTimeMinutes,
|
|
101
|
+
stopConditions: { pickup: stop.pickup, dropoff: stop.dropoff },
|
|
102
|
+
stopDistance: stop.distanceTravelled
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (bay.platform) data.platform = bay.platform
|
|
106
|
+
|
|
107
|
+
return data
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
let dbTrip = {
|
|
112
|
+
mode: this.#mode,
|
|
113
|
+
...tripData.getTripData(),
|
|
114
|
+
routeName: routeData.routeName,
|
|
115
|
+
routeNumber: routeData.routeNumber,
|
|
116
|
+
stopTimings
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dbTrip.origin = stopTimings[0].stopName
|
|
120
|
+
dbTrip.departureTime = stopTimings[0].departureTime
|
|
121
|
+
stopTimings[0].arrivalTime = null
|
|
122
|
+
stopTimings[0].arrivalTimeMinutes = null
|
|
123
|
+
stopTimings[0].stopConditions.dropoff = 1
|
|
124
|
+
|
|
125
|
+
let lastStop = stopTimings[stopTimings.length - 1]
|
|
126
|
+
dbTrip.destination = lastStop.stopName
|
|
127
|
+
dbTrip.destinationArrivalTime = lastStop.arrivalTime
|
|
128
|
+
lastStop.departureTime = null
|
|
129
|
+
lastStop.departureTimeMinutes = null
|
|
130
|
+
lastStop.stopConditions.pickup = 1
|
|
131
|
+
|
|
132
|
+
return dbTrip
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Loads the trip data into the database.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} options A list of options for the trip loader
|
|
139
|
+
* @param {object} options.routeIDMap A mapping of full route IDs to their simplified IDs.
|
|
140
|
+
* @param {function} options.processTrip A function to modify trip data on a per-trip basis. It should return null to discard the trip.
|
|
141
|
+
*/
|
|
142
|
+
async loadTrips({ routeIDMap, processTrip }) {
|
|
143
|
+
this.#routeIDMap = routeIDMap
|
|
144
|
+
|
|
145
|
+
await this.loadCalendarData()
|
|
146
|
+
await this.loadTripsData()
|
|
147
|
+
|
|
148
|
+
let tripsLoaded = 0
|
|
149
|
+
let trips = []
|
|
150
|
+
|
|
151
|
+
await this.#stopTimesReader.open()
|
|
152
|
+
while (this.#stopTimesReader.available()) {
|
|
153
|
+
let { tripID, stops } = await this.#stopTimesReader.getNextEntity()
|
|
154
|
+
|
|
155
|
+
let rawTripData = this.#tripDetails[tripID]
|
|
156
|
+
let tripData = await this.generateDBTripData(rawTripData, stops)
|
|
157
|
+
if (!tripData) continue
|
|
158
|
+
|
|
159
|
+
let processedData = processTrip ? await processTrip(tripData) : tripData
|
|
160
|
+
if (!processedData) continue
|
|
161
|
+
|
|
162
|
+
this.#shapeIDMap[tripData.shapeID] = tripData.routeGTFSID
|
|
163
|
+
|
|
164
|
+
trips.push(processedData)
|
|
165
|
+
|
|
166
|
+
tripsLoaded += 1
|
|
167
|
+
|
|
168
|
+
if (tripsLoaded === 5000) { // Flush the cache every 5000 trips
|
|
169
|
+
this.#routeCache = {}
|
|
170
|
+
this.#stopCache = {}
|
|
171
|
+
|
|
172
|
+
await this.#trips.createDocuments(trips)
|
|
173
|
+
trips = []
|
|
174
|
+
|
|
175
|
+
tripsLoaded = 0
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (trips.length) {
|
|
180
|
+
await this.#trips.createDocuments(trips)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getShapeIDMap() {
|
|
185
|
+
return this.#shapeIDMap
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { hash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function tripHash(trip) {
|
|
4
|
+
return parseInt(hash('md5', trip.map(stop => stop.stopGTFSID).join('-')), 16)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isLoop(trip) {
|
|
8
|
+
return trip[0].stopName === trip[trip.length - 1].stopName
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merges different stopping patterns together to determine the overall stops for a route.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object[][]} inputVariants A list of different stopping patterns being merged together
|
|
15
|
+
*/
|
|
16
|
+
export default function merge(inputVariants) {
|
|
17
|
+
let loopVariants = [], nonLoopVariants = []
|
|
18
|
+
|
|
19
|
+
inputVariants.toSorted((a, b) => {
|
|
20
|
+
return b.length - a.length || tripHash(a) - tripHash(b)
|
|
21
|
+
}).forEach(trip => {
|
|
22
|
+
if (trip.length === 1) return
|
|
23
|
+
if (isLoop(trip)) loopVariants.push(trip)
|
|
24
|
+
else nonLoopVariants.push(trip)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
let variants = loopVariants.concat(nonLoopVariants)
|
|
28
|
+
let stopsList = variants[0]
|
|
29
|
+
|
|
30
|
+
for (let variant of variants.slice(1)) {
|
|
31
|
+
let branch = []
|
|
32
|
+
let lastMainMatch = 0
|
|
33
|
+
let isLoopVariant = isLoop(variant)
|
|
34
|
+
|
|
35
|
+
let stopIndex = -1
|
|
36
|
+
|
|
37
|
+
for (let variantStop of variant) {
|
|
38
|
+
stopIndex++
|
|
39
|
+
|
|
40
|
+
let matchIndex = lastMainMatch - 1
|
|
41
|
+
|
|
42
|
+
let hasMatched = false
|
|
43
|
+
for (let stop of stopsList.slice(lastMainMatch)) {
|
|
44
|
+
matchIndex++
|
|
45
|
+
|
|
46
|
+
hasMatched = stop.stopName === variantStop.stopName
|
|
47
|
+
if (hasMatched) {
|
|
48
|
+
if (branch.length > 0) { // lines are out of sync, but match detected = rejoining
|
|
49
|
+
let firstHalf = stopsList.slice(0, matchIndex)
|
|
50
|
+
let backHalf = stopsList.slice(matchIndex)
|
|
51
|
+
|
|
52
|
+
stopsList = firstHalf.concat(branch).concat(backHalf)
|
|
53
|
+
branch = []
|
|
54
|
+
} else { // otherwise we're on sync, all good
|
|
55
|
+
if (matchIndex < lastMainMatch) {
|
|
56
|
+
let jumpPart = variant.slice(matchIndex, lastMainMatch + 1)
|
|
57
|
+
let subBranch = []
|
|
58
|
+
for (let jumpStop of jumpPart) {
|
|
59
|
+
if (matched(jumpStop, variantStop)) break
|
|
60
|
+
subBranch.push(jumpStop)
|
|
61
|
+
}
|
|
62
|
+
branch = branch.concat(subBranch)
|
|
63
|
+
}
|
|
64
|
+
lastMainMatch = matchIndex
|
|
65
|
+
}
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// no match, we're on a branch
|
|
71
|
+
if (!hasMatched) branch.push(variantStop)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (branch.length) { // we're still on a branch after completing the stops, means they have different destiantions
|
|
75
|
+
// look at where they deviated, and join it in between
|
|
76
|
+
|
|
77
|
+
let firstHalf = stopsList.slice(0, lastMainMatch + 1)
|
|
78
|
+
let backHalf = stopsList.slice(lastMainMatch + 1)
|
|
79
|
+
|
|
80
|
+
stopsList = firstHalf.concat(branch).concat(backHalf)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return stopsList
|
|
85
|
+
}
|