UNPKG

minotor

Version:

A lightweight client-side transit routing library.

487 lines (411 loc) 15.7 kB
import fs from 'fs'; import { performance } from 'perf_hooks'; import { Query, RangeQuery, RangeResult, Router, StopsIndex, } from '../router.js'; import { timeFromString, timeToString } from '../timetable/time.js'; type PerformanceResult = { label: string; meanTimeUs: number; meanMemoryMb: number; }; type SerializedQuery = { from: string; to: string[]; departureTime: string; lastDepartureTime?: string; maxTransfers?: number; }; // ─── Table renderer ─────────────────────────────────────────────────────────── type Column = { header: string; width: number; align: 'left' | 'right'; }; const renderTable = ( columns: Column[], rows: string[][], footerRow: string[], ): string => { const bar = (l: string, m: string, r: string) => l + columns.map((c) => '─'.repeat(c.width + 2)).join(m) + r; const renderRow = (cells: string[]) => '│' + cells .map((cell, i) => { const width = columns[i]?.width ?? 0; const align = columns[i]?.align ?? 'left'; const padded = align === 'right' ? cell.padStart(width) : cell.padEnd(width); return ` ${padded} `; }) .join('│') + '│'; return [ bar('┌', '┬', '┐'), renderRow(columns.map((c) => c.header)), bar('├', '┼', '┤'), ...rows.map(renderRow), bar('├', '┼', '┤'), renderRow(footerRow), bar('└', '┴', '┘'), ].join('\n'); }; // ─── Query label ────────────────────────────────────────────────────────────── const buildQueryLabel = (query: Query, stopsIndex: StopsIndex): string => { const fromName = stopsIndex.findStopById(query.from)?.name ?? String(query.from); const toNames = [...query.to] .map((id) => stopsIndex.findStopById(id)?.name ?? String(id)) .join(' / '); const dep = timeToString(query.departureTime); if (query instanceof RangeQuery) { const lastDep = timeToString(query.lastDepartureTime); return `${fromName}${toNames} ${dep}${lastDep}`; } return `${fromName}${toNames} ${dep}`; }; // ─── Query loaders ──────────────────────────────────────────────────────────── /** * Loads a list of routing queries from a JSON file and resolves the * human-readable stop IDs to the internal numeric IDs used by the router. * * Only entries that do **not** carry a `lastDepartureTime` field are loaded — * range-query entries are silently skipped. * * @param filePath - Path to the JSON file containing the serialized queries. * @param stopsIndex - The stops index used to resolve source stop IDs to the * internal numeric IDs expected by the router. * @returns An array of fully constructed {@link Query} objects ready to be * passed to {@link Router.route}. * @throws If the file cannot be read, the JSON is malformed, or any stop ID * referenced in the file cannot be found in the stops index. */ export const loadQueriesFromJson = ( filePath: string, stopsIndex: StopsIndex, ): Query[] => { const fileContent = fs.readFileSync(filePath, 'utf-8'); const serializedQueries: SerializedQuery[] = JSON.parse( fileContent, ) as SerializedQuery[]; return serializedQueries .filter((q) => q.lastDepartureTime === undefined) .map((serializedQuery) => { const fromStop = stopsIndex.findStopBySourceStopId(serializedQuery.from); const toStops = Array.from(serializedQuery.to).map((stopId) => stopsIndex.findStopBySourceStopId(stopId), ); if (!fromStop || toStops.some((toStop) => !toStop)) { throw new Error( `Invalid task: Start or end station not found for task ${JSON.stringify(serializedQuery)}`, ); } const queryBuilder = new Query.Builder() .from(fromStop.id) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .to(new Set(toStops.map((stop) => stop!.id))) .departureTime(timeFromString(serializedQuery.departureTime)) .maxDuration(6 * 60); if (serializedQuery.maxTransfers !== undefined) { queryBuilder.maxTransfers(serializedQuery.maxTransfers); } return queryBuilder.build(); }); }; /** * Loads a list of range routing queries from a JSON file and resolves the * human-readable stop IDs to the internal numeric IDs used by the router. * * Only entries that carry a `lastDepartureTime` field are loaded — plain * point-query entries are silently skipped. * * @param filePath - Path to the JSON file containing the serialized queries. * @param stopsIndex - The stops index used to resolve source stop IDs to the * internal numeric IDs expected by the router. * @returns An array of fully constructed {@link RangeQuery} objects ready to * be passed to {@link Router.rangeRoute}. * @throws If the file cannot be read, the JSON is malformed, or any stop ID * referenced in the file cannot be found in the stops index. */ export const loadRangeQueriesFromJson = ( filePath: string, stopsIndex: StopsIndex, ): RangeQuery[] => { const fileContent = fs.readFileSync(filePath, 'utf-8'); const serializedQueries: SerializedQuery[] = JSON.parse( fileContent, ) as SerializedQuery[]; return serializedQueries .filter((q) => q.lastDepartureTime !== undefined) .map((serializedQuery) => { const fromStop = stopsIndex.findStopBySourceStopId(serializedQuery.from); const toStops = Array.from(serializedQuery.to).map((stopId) => stopsIndex.findStopBySourceStopId(stopId), ); if (!fromStop || toStops.some((toStop) => !toStop)) { throw new Error( `Invalid task: Start or end station not found for task ${JSON.stringify(serializedQuery)}`, ); } const queryBuilder = new RangeQuery.Builder() .from(fromStop.id) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .to(new Set(toStops.map((stop) => stop!.id))) .departureTime(timeFromString(serializedQuery.departureTime)) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!)) .maxDuration(6 * 60); if (serializedQuery.maxTransfers !== undefined) { queryBuilder.maxTransfers(serializedQuery.maxTransfers); } return queryBuilder.build(); }); }; // ─── Benchmark runners ──────────────────────────────────────────────────────── /** * Benchmarks {@link Router.route} across a set of queries. * * @param router - The router instance to benchmark. * @param tasks - The list of queries to run. One {@link PerformanceResult} is * produced per query. * @param iterations - Number of times each query is repeated. Higher values * yield a more stable mean at the cost of longer wall-clock time. * @param stopsIndex - Used to resolve stop names for result labels. * @returns An array of {@link PerformanceResult} objects, one per query, each * containing the mean wall-clock time (µs) and mean heap delta (MB). */ export const testRouterPerformance = ( router: Router, tasks: Query[], iterations: number, stopsIndex: StopsIndex, ): PerformanceResult[] => { const results: PerformanceResult[] = []; for (const task of tasks) { let totalTime = 0; let totalMemory = 0; for (let i = 0; i < iterations; i++) { if (global.gc) { global.gc(); } const startMemory = process.memoryUsage().heapUsed; const startTime = performance.now(); router.route(task); const endTime = performance.now(); const endMemory = process.memoryUsage().heapUsed; totalTime += (endTime - startTime) * 1_000; if (endMemory >= startMemory) { totalMemory += endMemory - startMemory; } } results.push({ label: buildQueryLabel(task, stopsIndex), meanTimeUs: totalTime / iterations, meanMemoryMb: totalMemory / iterations / (1024 * 1024), }); } return results; }; /** * Benchmarks {@link Result.bestRoute} — the path-reconstruction phase — * independently of the routing phase. * * @param router - The router instance used to produce the routing results that * are then fed into `bestRoute`. * @param tasks - The list of queries to benchmark. One {@link PerformanceResult} * is produced per query. * @param iterations - Number of times `bestRoute` is called per query. * @param stopsIndex - Used to resolve stop names for result labels. * @returns An array of {@link PerformanceResult} objects, one per query, each * containing the mean wall-clock time (µs) and mean heap delta (MB) for the * `bestRoute` call alone. */ export const testBestRoutePerformance = ( router: Router, tasks: Query[], iterations: number, stopsIndex: StopsIndex, ): PerformanceResult[] => { const results: PerformanceResult[] = []; for (const task of tasks) { const result = router.route(task); let totalTime = 0; let totalMemory = 0; for (let i = 0; i < iterations; i++) { if (global.gc) { global.gc(); } const startMemory = process.memoryUsage().heapUsed; const startTime = performance.now(); result.bestRoute(); const endTime = performance.now(); const endMemory = process.memoryUsage().heapUsed; totalTime += (endTime - startTime) * 1_000; if (endMemory >= startMemory) { totalMemory += endMemory - startMemory; } } results.push({ label: buildQueryLabel(task, stopsIndex), meanTimeUs: totalTime / iterations, meanMemoryMb: totalMemory / iterations / (1024 * 1024), }); } return results; }; /** * Benchmarks {@link Router.rangeRoute} across a set of range queries. * * @param router - The router instance to benchmark. * @param tasks - The list of range queries to run. One {@link PerformanceResult} * is produced per query. * @param iterations - Number of times each query is repeated. * @param stopsIndex - Used to resolve stop names for result labels. * @returns An array of {@link PerformanceResult} objects, one per query, each * containing the mean wall-clock time (µs) and mean heap delta (MB). */ export const testRangeRouterPerformance = ( router: Router, tasks: RangeQuery[], iterations: number, stopsIndex: StopsIndex, ): PerformanceResult[] => { const results: PerformanceResult[] = []; for (const task of tasks) { let totalTime = 0; let totalMemory = 0; for (let i = 0; i < iterations; i++) { if (global.gc) { global.gc(); } const startMemory = process.memoryUsage().heapUsed; const startTime = performance.now(); router.rangeRoute(task); const endTime = performance.now(); const endMemory = process.memoryUsage().heapUsed; totalTime += (endTime - startTime) * 1_000; if (endMemory >= startMemory) { totalMemory += endMemory - startMemory; } } results.push({ label: buildQueryLabel(task, stopsIndex), meanTimeUs: totalTime / iterations, meanMemoryMb: totalMemory / iterations / (1024 * 1024), }); } return results; }; /** * Benchmarks {@link RangeResult.getRoutes} — the full Pareto-frontier * reconstruction phase — independently of the range routing phase. * * @param router - The router instance used to produce the range results that * are then fed into `getRoutes`. * @param tasks - The list of range queries to benchmark. One * {@link PerformanceResult} is produced per query. * @param iterations - Number of times `getRoutes` is called per query. * @param stopsIndex - Used to resolve stop names for result labels. * @returns An array of {@link PerformanceResult} objects, one per query, each * containing the mean wall-clock time (µs) and mean heap delta (MB) for the * `getRoutes` call alone. */ export const testRangeResultPerformance = ( router: Router, tasks: RangeQuery[], iterations: number, stopsIndex: StopsIndex, ): PerformanceResult[] => { const results: PerformanceResult[] = []; for (const task of tasks) { const rangeResult: RangeResult = router.rangeRoute(task); let totalTime = 0; let totalMemory = 0; for (let i = 0; i < iterations; i++) { if (global.gc) { global.gc(); } const startMemory = process.memoryUsage().heapUsed; const startTime = performance.now(); rangeResult.getRoutes(); const endTime = performance.now(); const endMemory = process.memoryUsage().heapUsed; totalTime += (endTime - startTime) * 1_000; if (endMemory >= startMemory) { totalMemory += endMemory - startMemory; } } results.push({ label: buildQueryLabel(task, stopsIndex), meanTimeUs: totalTime / iterations, meanMemoryMb: totalMemory / iterations / (1024 * 1024), }); } return results; }; // ─── Output ─────────────────────────────────────────────────────────────────── /** * Prints a table summary of performance results to stdout. * * Each row corresponds to one task, identified by a human-readable query label * (origin → destination + departure time). A footer row shows the mean across * all tasks. An optional `label` is printed as a section header above the table. * * @param results - The performance results to display. * @param label - Heading printed above the table. Defaults to `'Performance Results'`. */ export const prettyPrintPerformanceResults = ( results: PerformanceResult[], label = 'Performance Results', ): void => { console.log(`\n${label}`); if (results.length === 0) { console.log(' (no results)'); return; } const fmtTime = (n: number) => Math.round(n).toLocaleString('en-US'); const fmtMem = (n: number) => n.toFixed(2); const meanTime = results.reduce((s, r) => s + r.meanTimeUs, 0) / results.length; const meanMem = results.reduce((s, r) => s + r.meanMemoryMb, 0) / results.length; const queryHeader = 'Query'; const timeHeader = 'Time (µs)'; const memHeader = 'Mem (MB)'; const timeVals = results.map((r) => fmtTime(r.meanTimeUs)); const memVals = results.map((r) => fmtMem(r.meanMemoryMb)); const meanTimeStr = fmtTime(meanTime); const meanMemStr = fmtMem(meanMem); const queryWidth = Math.max( queryHeader.length, 'mean'.length, ...results.map((r) => r.label.length), ); const timeWidth = Math.max( timeHeader.length, meanTimeStr.length, ...timeVals.map((v) => v.length), ); const memWidth = Math.max( memHeader.length, meanMemStr.length, ...memVals.map((v) => v.length), ); const columns: Column[] = [ { header: queryHeader, width: queryWidth, align: 'left' }, { header: timeHeader, width: timeWidth, align: 'right' }, { header: memHeader, width: memWidth, align: 'right' }, ]; const rows = results.map((r, i) => [ r.label, timeVals[i] ?? '', memVals[i] ?? '', ]); const footer = ['mean', meanTimeStr, meanMemStr]; console.log(renderTable(columns, rows, footer)); };