diff --git a/worker/src/jobs/build-valhalla.ts b/worker/src/jobs/build-valhalla.ts index 581e23d..461339d 100644 --- a/worker/src/jobs/build-valhalla.ts +++ b/worker/src/jobs/build-valhalla.ts @@ -95,6 +95,106 @@ function runProcess(cmd: string, args: string[]): Promise { }); } +class ProcessError extends Error { + constructor(message: string, public readonly lines: string[], public readonly signal: string | null) { + super(message); + } +} + +/** Like runProcess but captures stdout+stderr (still printed) for error analysis. */ +function runProcessCapture(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + console.log(`[build-valhalla] Running: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + const lines: string[] = []; + const onData = (d: Buffer) => { + process.stdout.write(d); + lines.push(...d.toString().split(/\r?\n/).filter((l) => l.length > 0)); + }; + child.stdout!.on("data", onData); + child.stderr!.on("data", onData); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new ProcessError( + `${cmd} exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`, + lines, signal, + )); + }); + }); +} + +// ─── Transit stop quarantine ─────────────────────────────────────────────────── + +/** Path to the quarantine file (outside feed/ so it survives re-filters). */ +function quarantineFilePath(citySlug: string): string { + return `${GTFS_DATA_DIR}/${citySlug}/.quarantine`; +} + +function readQuarantine(citySlug: string): [number, number][] { + const p = quarantineFilePath(citySlug); + if (!existsSync(p)) return []; + try { return JSON.parse(readFileSync(p, "utf8")) as [number, number][]; } catch { return []; } +} + +/** Append new bad coordinates to the quarantine file. */ +function appendQuarantine(citySlug: string, badCoords: [number, number][]): void { + const merged = [...readQuarantine(citySlug), ...badCoords]; + writeFileSync(quarantineFilePath(citySlug), JSON.stringify(merged)); + console.log(`[build-valhalla] Quarantine updated for ${citySlug}: ${merged.length} stop(s) total`); +} + +/** + * Remove quarantined stops from the city's filtered GTFS feed (stops.txt + stop_times.txt). + * Called before valhalla_ingest_transit so the bad stops never enter the transit graph. + */ +function applyQuarantine(citySlug: string): void { + const badCoords = readQuarantine(citySlug); + if (badCoords.length === 0) return; + + const feedDir = cityGtfsFeedDir(citySlug); + const stopsPath = `${feedDir}/stops.txt`; + if (!existsSync(stopsPath)) return; + + const stopLines = readFileSync(stopsPath, "utf8").split(/\r?\n/).filter((l) => l.trim()); + if (stopLines.length < 2) return; + const headers = stopLines[0].split(",").map((h) => h.trim().replace(/^\uFEFF/, "")); + const stopIdCol = headers.indexOf("stop_id"); + const latCol = headers.indexOf("stop_lat"); + const lonCol = headers.indexOf("stop_lon"); + if (stopIdCol < 0 || latCol < 0 || lonCol < 0) return; + + const removedIds = new Set(); + const keptLines = [stopLines[0]]; + for (let i = 1; i < stopLines.length; i++) { + const fields = stopLines[i].split(","); + const lat = parseFloat(fields[latCol] ?? ""); + const lon = parseFloat(fields[lonCol] ?? ""); + if (badCoords.some(([bLat, bLon]) => Math.abs(lat - bLat) < 1e-5 && Math.abs(lon - bLon) < 1e-5)) { + removedIds.add(fields[stopIdCol] ?? ""); + } else { + keptLines.push(stopLines[i]); + } + } + if (removedIds.size === 0) return; + + console.log(`[build-valhalla] Quarantine: removing stop(s) ${[...removedIds].join(", ")} from feed`); + writeFileSync(stopsPath, keptLines.join("\n") + "\n"); + + const stPath = `${feedDir}/stop_times.txt`; + if (!existsSync(stPath)) return; + const stLines = readFileSync(stPath, "utf8").split(/\r?\n/).filter((l) => l.trim()); + if (stLines.length < 2) return; + const stHeaders = stLines[0].split(",").map((h) => h.trim()); + const stStopCol = stHeaders.indexOf("stop_id"); + if (stStopCol < 0) return; + const stOut = [stLines[0]]; + for (let i = 1; i < stLines.length; i++) { + if (!removedIds.has(stLines[i].split(",")[stStopCol] ?? "")) stOut.push(stLines[i]); + } + writeFileSync(stPath, stOut.join("\n") + "\n"); +} + function buildTimezoneDb(): Promise { return new Promise((resolve, reject) => { console.log("[build-valhalla] Running: valhalla_build_timezones → " + TIMEZONE_SQLITE);