minotor
Version:
A lightweight client-side transit routing library.
487 lines (411 loc) • 15.7 kB
text/typescript
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));
};