UNPKG

gtfs-to-blocks

Version:

Generate CSV of transit departure times organized by block_id in GTFS.

345 lines (339 loc) 13.5 kB
import { join, resolve } from "node:path"; import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { noop, sortBy, uniq } from "lodash-es"; import { getCalendars, getDeadheadTimes, getOpsLocations, getRoutes, getStops, getStoptimes, importGtfs, openDb } from "gtfs"; import sanitize from "sanitize-filename"; import Timer from "timer-machine"; import { homedir } from "node:os"; import { clearLine, cursorTo } from "node:readline"; import PrettyError from "pretty-error"; import chalk from "chalk"; import Table from "cli-table"; import { stringify } from "csv-stringify"; import moment from "moment"; //#region src/lib/file-utils.ts const homeDirectory = homedir(); async function getConfig(argv) { try { const data = await readFile(resolve(untildify(argv.configPath)), "utf8").catch((error) => { console.error(/* @__PURE__ */ new Error(`Cannot find configuration file at \`${argv.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`)); throw error; }); const config = JSON.parse(data); if (argv.skipImport === true) config.skipImport = argv.skipImport; return config; } catch (error) { console.error(/* @__PURE__ */ new Error(`Cannot parse configuration file at \`${argv.configPath}\`. Check to ensure that it is valid JSON.`)); throw error; } } async function prepDirectory(outputPath, config) { try { await access(outputPath); } catch { try { await mkdir(outputPath, { recursive: true }); } catch (error) { if (error?.code === "ENOENT") throw new Error(`Unable to write to ${outputPath}. Try running this command from a writable directory.`); throw error; } } const files = await readdir(outputPath); if (config.overwriteExistingFiles === false && files.length > 0) throw new Error(`Output directory ${outputPath} is not empty. Please specify an empty directory.`); if (config.overwriteExistingFiles === true) await rm(join(outputPath, "*"), { recursive: true, force: true }); } /** * Converts a tilde path to a full path * @param pathWithTilde The path to convert * @returns The full path */ function untildify(pathWithTilde) { return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde; } //#endregion //#region src/lib/log-utils.ts new PrettyError().start(); function log(config) { if (config.verbose === false) return noop; if (config.logFunction) return config.logFunction; return (text, overwrite) => { if (overwrite === true && process.stdout.isTTY) { clearLine(process.stdout, 0); cursorTo(process.stdout, 0); } else process.stdout.write("\n"); process.stdout.write(text); }; } function logWarning(config) { if (config.logFunction) return config.logFunction; return (text) => { process.stdout.write(`\n${formatWarning(text)}\n`); }; } function formatWarning(text) { return `${chalk.yellow.underline("Warning")}${chalk.yellow(":")} ${chalk.yellow(text)}`; } function formatError(error) { const message = error instanceof Error ? error.message : error; return `${chalk.red.underline("Error")}${chalk.red(":")} ${chalk.red(message.replace("Error: ", ""))}`; } function logStats(config) { if (config.logFunction) return noop; return (stats) => { const table = new Table({ colWidths: [40, 20], head: ["Item", "Count"] }); table.push(["🚍 Trips", stats.trips], ["🕑 Trip Segments", stats.tripSegments], ["⛔️ Warnings", stats.warnings.length]); log(config)(table.toString()); }; } const generateProgressBarString = (barTotal, barProgress, size = 40) => { const line = "-"; const slider = "="; if (!barTotal) throw new Error("Total value is either not provided or invalid"); if (!barProgress && barProgress !== 0) throw new Error("Current value is either not provided or invalid"); if (isNaN(barTotal)) throw new Error("Total value is not an integer"); if (isNaN(barProgress)) throw new Error("Current value is not an integer"); if (isNaN(size)) throw new Error("Size is not an integer"); if (barProgress > barTotal) return slider.repeat(size + 2); const percentage = barProgress / barTotal; const progress = Math.round(size * percentage); const emptyProgress = size - progress; return slider.repeat(progress) + line.repeat(emptyProgress); }; function progressBar(formatString, barTotal, config) { let barProgress = 0; if (config.verbose === false) return { increment: noop, interrupt: noop }; if (barTotal === 0) return null; const renderProgressString = () => formatString.replace("{value}", barProgress).replace("{total}", barTotal).replace("{bar}", generateProgressBarString(barTotal, barProgress)); log(config)(renderProgressString(), true); return { interrupt(text) { logWarning(config)(text); logWarning(config)(""); }, increment() { barProgress += 1; log(config)(renderProgressString(), true); } }; } //#endregion //#region src/lib/utils.ts function setDefaultConfig(initialConfig) { const defaults = { timeFormat: "HH:mm:ss", date: moment().format("YYYYMMDD"), includeDeadheads: true, overwriteExistingFiles: true }; return Object.assign(defaults, initialConfig); } async function generateCSV(tripSegments) { const lines = []; lines.push([ "Block ID", "Route ID", "Route", "Trip ID", "Direction ID", "Days", "Departure Location", "Arrival Location", "Departure Time", "Arrival Time", "Trip Headsign", "Stop Headsign", "Is Deadhead" ]); for (const tripSegment of tripSegments) lines.push([ tripSegment.blockId, tripSegment.routeId, tripSegment.routeName, tripSegment.tripId, tripSegment.directionId, tripSegment.dayList, tripSegment.departureLocation, tripSegment.arrivalLocation, tripSegment.departureTime, tripSegment.arrivalTime, tripSegment.tripHeadsign, tripSegment.stopHeadsign, tripSegment.isDeadhead ]); return stringify(lines); } function fromGTFSTime(timeString) { const duration = moment.duration(timeString); return moment({ hour: duration.hours(), minute: duration.minutes(), second: duration.seconds() }); } //#endregion //#region src/lib/formatters.ts const days = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; function formatDays(calendar) { const daysShort = [ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" ]; let daysInARow = 0; let dayString = ""; if (!calendar) return ""; for (let i = 0; i <= 6; i += 1) { const currentDayOperating = calendar[days[i]] === 1; const previousDayOperating = i > 0 ? calendar[days[i - 1]] === 1 : false; const nextDayOperating = i < 6 ? calendar[days[i + 1]] === 1 : false; if (currentDayOperating) { if (dayString.length > 0) { if (!previousDayOperating) dayString += ", "; else if (daysInARow === 1) dayString += "-"; } daysInARow += 1; if (dayString.length === 0 || !nextDayOperating || i === 6 || !previousDayOperating) dayString += daysShort[i]; } else daysInARow = 0; } if (dayString.length === 0) dayString = "No regular service days"; return dayString; } function formatTripSegments(tripSegments, config) { const stopIds = uniq(tripSegments.flatMap((tripSegment) => [tripSegment.departureStopId, tripSegment.arrivalStopId])); const serviceIds = uniq(tripSegments.map((tripSegment) => tripSegment.serviceId)); const stops = getStops({ stop_id: stopIds }); const opsLocations = getOpsLocations({ ops_location_id: stopIds }); const calendars = getCalendars({ service_id: serviceIds }); return tripSegments.map((tripSegment) => { const calendar = calendars.find((calendar) => calendar.service_id === tripSegment.serviceId); const departureStop = stops.find((stop) => stop.stop_id === tripSegment.departureStopId) ?? opsLocations.find((opsLocation) => opsLocation.ops_location_id === tripSegment.departureStopId); const arrivalStop = stops.find((stop) => stop.stop_id === tripSegment.arrivalStopId) ?? opsLocations.find((opsLocation) => opsLocation.ops_location_id === tripSegment.arrivalStopId); return { blockId: tripSegment.blockId, routeId: tripSegment.routeId, routeName: tripSegment.route?.route_short_name ?? tripSegment.route?.route_long_name ?? "", tripId: tripSegment.tripId, directionId: tripSegment.directionId, departureTime: fromGTFSTime(tripSegment.departureTime).format(config.timeFormat), arrivalTime: fromGTFSTime(tripSegment.arrivalTime).format(config.timeFormat), dayList: formatDays(calendar), departureLocation: departureStop.stop_name ?? departureStop.ops_location_name, arrivalLocation: arrivalStop.stop_name ?? arrivalStop.ops_location_name, tripHeadsign: tripSegment.tripHeadsign, stopHeadsign: tripSegment.stopHeadsign, isDeadhead: tripSegment.isDeadhead.toString() }; }); } //#endregion //#region src/lib/gtfs-to-blocks.ts const gtfsToBlocks = async (initialConfig) => { const config = setDefaultConfig(initialConfig); const timer = new Timer(); timer.start(); const db = openDb({ sqlitePath: config.sqlitePath }); if (!config.agencies || config.agencies.length === 0) throw new Error("No agencies defined in `config.json`"); if (!config.skipImport) await importGtfs(config); const agencyKey = config.agencies.map((agency) => agency.agency_key).join("-"); const outputPath = config.outputPath ? untildify(config.outputPath) : join(process.cwd(), "output", sanitize(agencyKey)); const outputStats = { trips: 0, tripSegments: 0, warnings: [] }; const calendars = db.prepare("SELECT service_id FROM calendar WHERE start_date <= ? AND end_date >= ?").all([config.date, config.date]); const calendarDates = db.prepare("SELECT service_id, exception_type FROM calendar_dates WHERE date = ?").all([config.date]); const serviceIds = new Set(calendars.map((calendar) => calendar.service_id)); for (const calendarDate of calendarDates) if (calendarDate.exception_type === 1) serviceIds.add(calendarDate.service_id); else if (calendarDate.exception_type === 2) serviceIds.delete(calendarDate.service_id); if (serviceIds.size === 0) throw new Error(`No calendar or calendar dates found for ${moment(config.date, "YYYYMMDD").format("MMM D, YYYY")}`); const routes = getRoutes(); const trips = db.prepare(`SELECT trip_id, direction_id, service_id, block_id, route_id, trip_headsign FROM trips where service_id IN (${[...serviceIds].map(() => "?").join(", ")})`).all([...serviceIds]); const deadheads = config.includeDeadheads ? db.prepare(`SELECT deadhead_id, service_id, block_id FROM deadheads where service_id IN (${[...serviceIds].map(() => "?").join(", ")})`).all([...serviceIds]) : []; const tripSegments = []; const bar = progressBar(`${agencyKey}: Generating trip segments {bar} {value}/{total}`, trips.length, config); for (const trip of trips) try { const stoptimes = getStoptimes({ trip_id: trip.trip_id }, [], [["stop_sequence", "ASC"]]); for (const [index, stoptime] of stoptimes.entries()) if (index < stoptimes.length - 1) { tripSegments.push({ blockId: trip.block_id, routeId: trip.route_id, route: routes.find((route) => route.route_id === trip.route_id), tripId: trip.trip_id, tripHeadsign: trip.trip_headsign, stopHeadsign: stoptime.stop_headsign, directionId: trip.direction_id, serviceId: trip.service_id, departureStopId: stoptime.stop_id, arrivalStopId: stoptimes[index + 1].stop_id, departureTime: stoptime.departure_time, arrivalTime: stoptimes[index + 1].arrival_time, isDeadhead: false }); outputStats.tripSegments += 1; } outputStats.trips += 1; bar?.increment(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); outputStats.warnings.push(errorMessage); bar?.interrupt(errorMessage); } for (const deadhead of deadheads) try { const deadheadTimes = await getDeadheadTimes({ deadhead_id: deadhead.deadhead_id }, [], [["location_sequence", "ASC"]]); for (const [index, deadheadTime] of deadheadTimes.entries()) if (index < deadheadTimes.length - 1) { tripSegments.push({ blockId: deadhead.block_id, tripId: deadhead.deadhead_id, serviceId: deadhead.service_id, departureStopId: deadheadTime.ops_location_id ?? deadheadTime.stop_id, arrivalStopId: deadheadTimes[index + 1].ops_location_id ?? deadheadTimes[index + 1].stop_id, departureTime: deadheadTime.departure_time, arrivalTime: deadheadTimes[index + 1].arrival_time, isDeadhead: true }); outputStats.tripSegments += 1; } outputStats.trips += 1; bar?.increment(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); outputStats.warnings.push(errorMessage); bar?.interrupt(errorMessage); } const formattedTripSegments = formatTripSegments(sortBy(tripSegments, [(tripSegment) => parseInt(tripSegment.blockId, 10) || tripSegment.blockId, (tripSegment) => fromGTFSTime(tripSegment.departureTime)]), config); await prepDirectory(outputPath, config); config.assetPath = "../"; const csv = await generateCSV(formattedTripSegments); const csvPath = join(outputPath, "blocks.csv"); await writeFile(csvPath, csv); log(config)(`${agencyKey}: block export for ${moment(config.date, "YYYYMMDD").format("MMM D, YYYY")} created at ${csvPath}`); logStats(config)(outputStats); const seconds = Math.round(timer.time() / 1e3); log(config)(`${agencyKey}: block export generation required ${seconds} seconds`); timer.stop(); return csvPath; }; //#endregion export { formatError as n, getConfig as r, gtfsToBlocks as t }; //# sourceMappingURL=src-Cv2nPRQE.js.map