From 02ec8ff4d20704df55aca146b41855880cf5ce94 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 10 Mar 2026 21:51:06 +0000 Subject: [PATCH] Add paths --- r5-java/run.sh | 12 +- r5-java/src/main/java/propertymap/App.java | 137 ++++++++++++++--- .../src/main/java/propertymap/Parquet.java | 13 +- r5-java/src/main/java/propertymap/Router.java | 142 +++++++++++++++--- 4 files changed, 255 insertions(+), 49 deletions(-) diff --git a/r5-java/run.sh b/r5-java/run.sh index ab19ac1..a22ae9c 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -17,7 +17,9 @@ set -euo pipefail # - places_ref.parquet: place order reference # # Usage: -# ./r5-java/run.sh +# ./r5-java/run.sh [--paths] [--demo] +# --paths records journey instructions (transit only, ~20x slower) +# --demo only compute Bank + TCR, transit only (quick test) # --- Defaults --- THREADS=4 @@ -25,6 +27,8 @@ HEAP=12g NETWORK_DIR=property-data/r5-network OUTPUT_BASE=property-data/travel-times R5_DIR=r5-java +PATHS_FLAG="" +DEMO_FLAG="" # --- Parse args --- while [[ $# -gt 0 ]]; do @@ -33,6 +37,8 @@ while [[ $# -gt 0 ]]; do --heap) HEAP="$2"; shift 2 ;; --network-dir) NETWORK_DIR="$2"; shift 2 ;; --output-dir) OUTPUT_BASE="$2"; shift 2 ;; + --paths) PATHS_FLAG="--paths"; shift ;; + --demo) DEMO_FLAG="--demo"; shift ;; *) echo "Unknown: $1"; exit 1 ;; esac done @@ -96,6 +102,7 @@ done if $NEEDS_COMPILE; then echo "--- Compiling Java source ---" + rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR" javac -cp "$LIB_DIR/*" -d "$OUT_DIR" "$SRC_DIR"/*.java fi @@ -128,7 +135,8 @@ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \ --postcodes property-data/arcgis_data.parquet \ --places property-data/places.parquet \ --output-dir "$OUTPUT_BASE" \ - --threads "$THREADS" + --threads "$THREADS" \ + $PATHS_FLAG $DEMO_FLAG echo "" echo "=== Complete ===" diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index afdc6d8..b71a285 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -8,7 +8,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -26,11 +28,15 @@ import java.util.concurrent.atomic.AtomicInteger; * * Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet * with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally - * includes a best_minutes SMALLINT column (5th percentile = best-case departure timing). + * includes a best_minutes SMALLINT column (5th percentile = best-case departure timing) + * and optionally a journey VARCHAR column (JSON leg instructions, when --paths is set). */ public class App { private static final String[] MODES = {"bicycle", "transit", "walking", "car"}; + private static final String[] DEMO_MODES = {"transit"}; + private static final Set DEMO_PLACES = Set.of( + "Bank tube station", "Tottenham Court Road tube station"); private static final int MAX_RETRIES = 2; public static void main(String[] args) throws Exception { @@ -38,6 +44,8 @@ public class App { String placesPath = requiredArg(args, "--places"); String outputDirStr = requiredArg(args, "--output-dir"); int threads = Integer.parseInt(optionalArg(args, "--threads", "4")); + boolean enablePaths = hasFlag(args, "--paths"); + boolean demo = hasFlag(args, "--demo"); Path outDir = Paths.get(outputDirStr); Files.createDirectories(outDir); @@ -55,7 +63,35 @@ public class App { String[] originNames = places.names(); double[] originLats = places.lats(), originLons = places.lons(); int nOrigins = originLats.length; - System.err.printf(" %,d places%n", nOrigins); + System.err.printf(" %,d places (total)%n", nOrigins); + + // Filter places to England only (must be near at least one England postcode) + Set englandIndices = filterEnglandPlaces( + originLats, originLons, postcodes.lats(), postcodes.lons()); + int excluded = nOrigins - englandIndices.size(); + if (excluded > 0) { + System.err.printf(" %,d places excluded (non-England), %,d remaining%n", + excluded, englandIndices.size()); + } + + // In demo mode, filter to just Bank + TCR and transit only + int[] originIndices; + String[] modes; + if (demo) { + List demoIdx = new ArrayList<>(); + for (int i = 0; i < nOrigins; i++) { + if (DEMO_PLACES.contains(originNames[i])) demoIdx.add(i); + } + originIndices = demoIdx.stream().mapToInt(Integer::intValue).toArray(); + modes = DEMO_MODES; + System.err.printf("DEMO MODE: %d places (transit only)%n", originIndices.length); + for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]); + } else { + // Normal mode: use all England places + originIndices = englandIndices.stream().sorted() + .mapToInt(Integer::intValue).toArray(); + modes = MODES; + } // One thread pool shared across all modes ExecutorService pool = Executors.newFixedThreadPool(threads); @@ -65,10 +101,13 @@ public class App { catch (Exception e) { throw new RuntimeException(e); } }); + if (enablePaths) System.err.println("Path recording ENABLED (transit only, ~20x slower)"); + try { - for (String mode : MODES) { + for (String mode : modes) { processMode(network, postcodes.codes(), postcodes.lats(), postcodes.lons(), - originNames, originLats, originLons, outDir, mode, today, pool, threadConn); + originNames, originLats, originLons, outDir, mode, today, pool, threadConn, enablePaths, + originIndices, !demo); } } finally { pool.shutdown(); @@ -76,21 +115,32 @@ public class App { } } + /** + * @param originIndices origin indices to process. + * @param skipCompleted if true, skip origins that already have output files. + */ private static void processMode( TransportNetwork network, String[] postcodes, double[] postcodeLats, double[] postcodeLons, String[] originNames, double[] originLats, double[] originLons, Path outDir, String mode, LocalDate date, - ExecutorService pool, ThreadLocal threadConn) throws Exception { + ExecutorService pool, ThreadLocal threadConn, + boolean enablePaths, int[] originIndices, boolean skipCompleted) throws Exception { - int nOrigins = originLats.length; System.err.printf("%n=== %s ===%n", mode.toUpperCase()); System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode)); Path modeDir = outDir.resolve(mode); Files.createDirectories(modeDir); - List remaining = findRemaining(modeDir, originNames); - int alreadyDone = nOrigins - remaining.size(); + List remaining = new ArrayList<>(); + for (int idx : originIndices) { + if (skipCompleted) { + Path f = modeDir.resolve(originFilename(idx, originNames[idx])); + if (Files.exists(f) && Files.size(f) > 0) continue; + } + remaining.add(idx); + } + int alreadyDone = originIndices.length - remaining.size(); System.err.printf(" %,d done, %,d remaining%n", alreadyDone, remaining.size()); if (remaining.isEmpty()) { @@ -126,7 +176,7 @@ public class App { try { processOrigin(network, postcodes, postcodeLats, postcodeLons, originLats[idx], originLons[idx], - modeDir, mode, date, idx, originNames[idx], threadConn.get()); + modeDir, mode, date, idx, originNames[idx], threadConn.get(), enablePaths); completed.incrementAndGet(); } catch (Exception e) { failed.incrementAndGet(); @@ -153,7 +203,7 @@ public class App { String[] postcodes, double[] postcodeLats, double[] postcodeLons, double originLat, double originLon, Path modeDir, String mode, LocalDate date, int index, String name, - DuckDBConnection conn) throws Exception { + DuckDBConnection conn, boolean enablePaths) throws Exception { Path outPath = modeDir.resolve(originFilename(index, name)); Exception lastError = null; @@ -162,7 +212,7 @@ public class App { try { Router.FilteredResult result = Router.computeForOrigin( network, postcodeLats, postcodeLons, - originLat, originLon, mode, date); + originLat, originLon, mode, date, enablePaths); // Write only reachable postcodes (sparse output) int reachable = 0; @@ -171,18 +221,20 @@ public class App { String[] codes = new String[reachable]; short[] times = new short[reachable]; short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null; + String[] journeys = result.journeys() != null ? new String[reachable] : null; int j = 0; for (int i = 0; i < result.times().length; i++) { if (result.times()[i] >= 0) { codes[j] = postcodes[result.originalIndices()[i]]; times[j] = result.times()[i]; if (bestTimes != null) bestTimes[j] = result.bestTimes()[i]; + if (journeys != null) journeys[j] = result.journeys()[i]; // may be null for some postcodes j++; } } if (bestTimes != null) { - Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes); + Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes, journeys); } else { Parquet.writeTravelTimes(conn, outPath, codes, times); } @@ -201,18 +253,6 @@ public class App { throw lastError; } - /** Find origin indices that don't yet have output parquet files. */ - private static List findRemaining(Path modeDir, String[] names) throws Exception { - List remaining = new ArrayList<>(); - for (int i = 0; i < names.length; i++) { - Path f = modeDir.resolve(originFilename(i, names[i])); - if (!Files.exists(f) || Files.size(f) == 0) { - remaining.add(i); - } - } - return remaining; - } - /** Build a filename from index + place name (index prefix prevents collisions after sanitization). */ private static String originFilename(int index, String name) { String safe = name.toLowerCase() @@ -226,11 +266,18 @@ public class App { if (args[i].equals(name)) return args[i + 1]; } System.err.println("Missing required argument: " + name); - System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N]"); + System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N] [--paths] [--demo]"); System.exit(1); return null; // unreachable } + private static boolean hasFlag(String[] args, String name) { + for (String arg : args) { + if (arg.equals(name)) return true; + } + return false; + } + private static String optionalArg(String[] args, String name, String defaultValue) { for (int i = 0; i < args.length - 1; i++) { if (args[i].equals(name)) return args[i + 1]; @@ -238,6 +285,46 @@ public class App { return defaultValue; } + /** + * Filter place indices to those near at least one England postcode. + * Uses a 0.1° grid (~11km cells) built from postcode locations — a place is kept + * if its grid cell or any adjacent cell contains an England postcode. + */ + private static Set filterEnglandPlaces( + double[] placeLats, double[] placeLons, + double[] postcodeLats, double[] postcodeLons) { + // Build grid of cells that contain at least one England postcode + Set postcodeCells = new HashSet<>(); + for (int i = 0; i < postcodeLats.length; i++) { + postcodeCells.add(gridCell(postcodeLats[i], postcodeLons[i])); + } + + // Keep places where the place's cell or any adjacent cell has postcodes + Set keep = new HashSet<>(); + for (int i = 0; i < placeLats.length; i++) { + int gLat = (int) Math.floor(placeLats[i] * 10); + int gLon = (int) Math.floor(placeLons[i] * 10); + outer: + for (int dLat = -1; dLat <= 1; dLat++) { + for (int dLon = -1; dLon <= 1; dLon++) { + if (postcodeCells.contains(packGrid(gLat + dLat, gLon + dLon))) { + keep.add(i); + break outer; + } + } + } + } + return keep; + } + + private static long gridCell(double lat, double lon) { + return packGrid((int) Math.floor(lat * 10), (int) Math.floor(lon * 10)); + } + + private static long packGrid(int gLat, int gLon) { + return ((long) gLat << 32) | (gLon & 0xFFFFFFFFL); + } + private static String requiredEnv(String name) { String val = System.getenv(name); if (val == null) { diff --git a/r5-java/src/main/java/propertymap/Parquet.java b/r5-java/src/main/java/propertymap/Parquet.java index 615566c..72f4c03 100644 --- a/r5-java/src/main/java/propertymap/Parquet.java +++ b/r5-java/src/main/java/propertymap/Parquet.java @@ -107,13 +107,19 @@ public class Parquet { Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } - /** Write transit travel times with both median and best-case columns. */ + /** + * Write transit travel times with median, best-case, and optional journey columns. + * @param journeys may be null (no journey column written) or non-null (journey VARCHAR added, individual elements may be null) + */ static void writeTransitTravelTimes(DuckDBConnection conn, Path outPath, - String[] postcodes, short[] times, short[] bestTimes) throws Exception { + String[] postcodes, short[] times, short[] bestTimes, String[] journeys) throws Exception { Path tmp = outPath.resolveSibling(outPath.getFileName() + ".tmp"); + boolean hasJourneys = journeys != null; try (Statement stmt = conn.createStatement()) { stmt.execute("DROP TABLE IF EXISTS t"); - stmt.execute("CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT)"); + stmt.execute(hasJourneys + ? "CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT, journey VARCHAR)" + : "CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT)"); } try (DuckDBAppender appender = conn.createAppender("main", "t")) { for (int i = 0; i < postcodes.length; i++) { @@ -121,6 +127,7 @@ public class Parquet { appender.append(postcodes[i]); appender.append(times[i]); appender.append(bestTimes[i]); + if (hasJourneys) appender.append(journeys[i]); // null-safe: DuckDB appends SQL NULL appender.endRow(); } } diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index bff77a8..e332114 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -3,14 +3,19 @@ package propertymap; import com.conveyal.r5.OneOriginResult; import com.conveyal.r5.analyst.FreeFormPointSet; import com.conveyal.r5.analyst.PointSet; +import com.conveyal.r5.analyst.StreetTimesAndModes; import com.conveyal.r5.analyst.TravelTimeComputer; import com.conveyal.r5.analyst.WebMercatorExtents; +import com.conveyal.r5.analyst.cluster.PathResult; import com.conveyal.r5.analyst.cluster.RegionalTask; import com.conveyal.r5.analyst.cluster.TravelTimeResult; import com.conveyal.r5.api.util.LegMode; import com.conveyal.r5.api.util.TransitModes; import com.conveyal.r5.kryo.KryoNetworkSerializer; +import com.conveyal.r5.transit.TransitLayer; import com.conveyal.r5.transit.TransportNetwork; +import com.conveyal.r5.transit.path.RouteSequence; +import com.google.common.collect.Multimap; import org.locationtech.jts.geom.Coordinate; import java.io.File; @@ -25,16 +30,19 @@ public class Router { private static final int ZOOM = 9; // R5 enforces range 9-12 private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit - private static final int DEPARTURE_FROM_TIME = 7 * 3600; // 07:00 - private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 - private static final int MAX_TRIP_DURATION_MINUTES = 120; + private static final int DEPARTURE_FROM_TIME = 7 * 3600 + 30 * 60; // 07:30 + private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30 + private static final int MAX_TRIP_DURATION_MINUTES = 90; + + /** R5 PathResult throws if destinations > 5000. Chunks must be smaller when recording paths. */ + private static final int PATH_MAX_DESTINATIONS = 5000; // Percentile indices in R5 result arrays (order must match task.percentiles in buildTask) private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only) private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0) /** Result of computing travel times for a single origin with spatial pre-filtering. */ - record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} + record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes, String[] journeys) {} /** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */ static double maxRadiusKm(String mode) { @@ -81,14 +89,14 @@ public class Router { TransportNetwork network, double[] allLats, double[] allLons, double originLat, double originLon, - String mode, LocalDate date) { + String mode, LocalDate date, boolean enablePaths) { double maxRadius = maxRadiusKm(mode); // 1. Filter destinations by bounding box int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius); if (filtered.length == 0) { - return new FilteredResult(new int[0], new short[0], null); + return new FilteredResult(new int[0], new short[0], null, null); } // 2. Extract filtered coordinate arrays @@ -99,17 +107,22 @@ public class Router { fLons[i] = allLons[filtered[i]]; } - // 3. Build chunks from filtered destinations - List chunks = buildDestinationChunks(fLats, fLons); - - // 4. Compute travel times + // 3. Build chunks — smaller when path recording is active (R5 PathResult limit: 5000) boolean isTransit = mode.equals("transit"); - short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); + boolean recordPaths = isTransit && enablePaths; + int maxDestsPerChunk = recordPaths ? PATH_MAX_DESTINATIONS : Integer.MAX_VALUE; + List chunks = buildDestinationChunks(fLats, fLons, maxDestsPerChunk); + + // 4. Compute travel times (and optionally paths) + String[] journeys = recordPaths ? new String[fLats.length] : null; + short[][] allTimes = computeTravelTimes( + network, chunks, originLat, originLon, mode, fLats.length, date, + recordPaths, journeys); // Transit requests [5th, 50th] percentiles; others request [50th] only short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0]; short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null; - return new FilteredResult(filtered, medianTimes, bestTimes); + return new FilteredResult(filtered, medianTimes, bestTimes, journeys); } /** @@ -148,10 +161,12 @@ public class Router { } /** - * Split destinations into geographic chunks that each fit within R5's grid cell limit. - * Sorts by latitude and splits into bands so each band's bounding box is under 5M cells. + * Split destinations into geographic chunks that each fit within R5's grid cell limit + * and optionally a maximum destination count (required for path recording). + * Sorts by latitude and splits into bands. */ - private static List buildDestinationChunks(double[] lats, double[] lons) { + private static List buildDestinationChunks( + double[] lats, double[] lons, int maxDestsPerChunk) { int n = lats.length; // Sort indices by latitude for geographic chunking @@ -169,7 +184,7 @@ public class Router { int gridWidth = lonToPixel(maxLon, totalPixels) - lonToPixel(minLon, totalPixels) + 1; int maxHeight = MAX_GRID_CELLS / gridWidth; - // Greedily build chunks: extend each band until it would exceed maxHeight + // Greedily build chunks: extend each band until it would exceed maxHeight or maxDestsPerChunk List chunks = new ArrayList<>(); int start = 0; while (start < n) { @@ -177,6 +192,7 @@ public class Router { int topPixel = latToPixel(lats[sorted[start]], totalPixels); while (end < n) { + if (end - start >= maxDestsPerChunk) break; int bottomPixel = latToPixel(lats[sorted[end]], totalPixels); if (Math.abs(bottomPixel - topPixel) + 1 > maxHeight) break; end++; @@ -192,10 +208,12 @@ public class Router { /** * Compute travel times from one origin to all destinations across all chunks. * Returns one short[] per requested percentile (transit gets 2: best + median, others get 1: median). + * When recordPaths is true, also extracts journey instructions into journeysOut. */ private static short[][] computeTravelTimes( TransportNetwork network, List chunks, - double originLat, double originLon, String mode, int nDest, LocalDate date) { + double originLat, double originLon, String mode, int nDest, LocalDate date, + boolean recordPaths, String[] journeysOut) { boolean isTransit = mode.equals("transit"); int nPercentiles = isTransit ? 2 : 1; @@ -203,7 +221,7 @@ public class Router { for (short[] arr : allTimes) Arrays.fill(arr, (short) -1); for (DestinationChunk chunk : chunks) { - RegionalTask task = buildTask(chunk, originLat, originLon, mode, date); + RegionalTask task = buildTask(chunk, originLat, originLon, mode, date, recordPaths); TravelTimeComputer computer = new TravelTimeComputer(task, network); OneOriginResult result = computer.computeTravelTimes(); @@ -229,10 +247,87 @@ public class Router { } } } + + // Extract path data for transit + if (recordPaths && journeysOut != null && result.paths != null) { + extractPaths(result.paths, chunk.originalIndices, network.transitLayer, journeysOut); + } } return allTimes; } + /** + * Extract the most common journey pattern for each destination in a chunk. + * Produces a JSON array of legs: [{mode, from?, to?, minutes}, ...]. + */ + @SuppressWarnings("unchecked") + private static void extractPaths( + PathResult paths, int[] originalIndices, TransitLayer transitLayer, + String[] journeysOut) { + Multimap[] allPaths = paths.iterationsForPathTemplates; + for (int i = 0; i < originalIndices.length && i < allPaths.length; i++) { + Multimap destPaths = allPaths[i]; + if (destPaths == null || destPaths.isEmpty()) continue; + + // Find the RouteSequence used by the most departure-time iterations + RouteSequence bestRoute = null; + int maxCount = 0; + for (RouteSequence rs : destPaths.keySet()) { + int count = destPaths.get(rs).size(); + if (count > maxCount) { + maxCount = count; + bestRoute = rs; + } + } + if (bestRoute == null) continue; + + journeysOut[originalIndices[i]] = buildJourneyJson(bestRoute, transitLayer); + } + } + + /** Build a JSON array of journey legs from a RouteSequence. */ + private static String buildJourneyJson(RouteSequence routeSequence, TransitLayer transitLayer) { + StringBuilder sb = new StringBuilder("["); + boolean first = true; + + // Access leg (walk/bike to first transit stop) + StreetTimesAndModes.StreetTimeAndMode access = routeSequence.stopSequence.access; + if (access != null && access.time > 0) { + int mins = (access.time + 30) / 60; // round to nearest minute + sb.append("{\"mode\":\"").append(access.mode.name().toLowerCase()) + .append("\",\"minutes\":").append(mins).append("}"); + first = false; + } + + // Transit legs + for (RouteSequence.TransitLeg leg : routeSequence.transitLegs(transitLayer)) { + if (!first) sb.append(","); + sb.append("{\"mode\":\"").append(escapeJson(leg.route)) + .append("\",\"from\":\"").append(escapeJson(leg.board)) + .append("\",\"to\":\"").append(escapeJson(leg.alight)) + .append("\",\"minutes\":").append(Math.round(leg.inVehicleTime)) + .append("}"); + first = false; + } + + // Egress leg (walk/bike from last transit stop) + StreetTimesAndModes.StreetTimeAndMode egress = routeSequence.stopSequence.egress; + if (egress != null && egress.time > 0) { + if (!first) sb.append(","); + int mins = (egress.time + 30) / 60; + sb.append("{\"mode\":\"").append(egress.mode.name().toLowerCase()) + .append("\",\"minutes\":").append(mins).append("}"); + } + + sb.append("]"); + return sb.toString(); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + // --- Private helpers --- private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {} @@ -268,7 +363,8 @@ public class Router { } private static RegionalTask buildTask( - DestinationChunk chunk, double originLat, double originLon, String mode, LocalDate date) { + DestinationChunk chunk, double originLat, double originLon, String mode, LocalDate date, + boolean recordPaths) { RegionalTask task = new RegionalTask(); task.fromLat = originLat; task.fromLon = originLon; @@ -285,6 +381,11 @@ public class Router { task.toTime = DEPARTURE_TO_TIME; task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES; + if (recordPaths) { + task.includePathResults = true; + task.nPathsPerTarget = 3; + } + configureMode(task, mode); return task; } @@ -296,6 +397,9 @@ public class Router { case "walking" -> setDirectMode(task, LegMode.WALK); case "transit" -> { task.maxRides = 4; + // R5 requires directModes ⊆ accessModes. BICYCLE egress is too expensive + // (builds cost tables from 59k stops × N destinations), so keep WALK only + // for egress and match access/direct to avoid the R5 validation error. task.accessModes = EnumSet.of(LegMode.WALK); task.egressModes = EnumSet.of(LegMode.WALK); task.directModes = EnumSet.of(LegMode.WALK);