Add paths

This commit is contained in:
Andras Schmelczer 2026-03-10 21:51:06 +00:00
parent f3e3c1ee49
commit 02ec8ff4d2
4 changed files with 255 additions and 49 deletions

View file

@ -17,7 +17,9 @@ set -euo pipefail
# - places_ref.parquet: place order reference # - places_ref.parquet: place order reference
# #
# Usage: # 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 --- # --- Defaults ---
THREADS=4 THREADS=4
@ -25,6 +27,8 @@ HEAP=12g
NETWORK_DIR=property-data/r5-network NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java R5_DIR=r5-java
PATHS_FLAG=""
DEMO_FLAG=""
# --- Parse args --- # --- Parse args ---
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -33,6 +37,8 @@ while [[ $# -gt 0 ]]; do
--heap) HEAP="$2"; shift 2 ;; --heap) HEAP="$2"; shift 2 ;;
--network-dir) NETWORK_DIR="$2"; shift 2 ;; --network-dir) NETWORK_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_BASE="$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 ;; *) echo "Unknown: $1"; exit 1 ;;
esac esac
done done
@ -96,6 +102,7 @@ done
if $NEEDS_COMPILE; then if $NEEDS_COMPILE; then
echo "--- Compiling Java source ---" echo "--- Compiling Java source ---"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
javac -cp "$LIB_DIR/*" -d "$OUT_DIR" "$SRC_DIR"/*.java javac -cp "$LIB_DIR/*" -d "$OUT_DIR" "$SRC_DIR"/*.java
fi fi
@ -128,7 +135,8 @@ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
--postcodes property-data/arcgis_data.parquet \ --postcodes property-data/arcgis_data.parquet \
--places property-data/places.parquet \ --places property-data/places.parquet \
--output-dir "$OUTPUT_BASE" \ --output-dir "$OUTPUT_BASE" \
--threads "$THREADS" --threads "$THREADS" \
$PATHS_FLAG $DEMO_FLAG
echo "" echo ""
echo "=== Complete ===" echo "=== Complete ==="

View file

@ -8,7 +8,9 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; 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 * Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet
* with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally * 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 { public class App {
private static final String[] MODES = {"bicycle", "transit", "walking", "car"}; private static final String[] MODES = {"bicycle", "transit", "walking", "car"};
private static final String[] DEMO_MODES = {"transit"};
private static final Set<String> DEMO_PLACES = Set.of(
"Bank tube station", "Tottenham Court Road tube station");
private static final int MAX_RETRIES = 2; private static final int MAX_RETRIES = 2;
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
@ -38,6 +44,8 @@ public class App {
String placesPath = requiredArg(args, "--places"); String placesPath = requiredArg(args, "--places");
String outputDirStr = requiredArg(args, "--output-dir"); String outputDirStr = requiredArg(args, "--output-dir");
int threads = Integer.parseInt(optionalArg(args, "--threads", "4")); int threads = Integer.parseInt(optionalArg(args, "--threads", "4"));
boolean enablePaths = hasFlag(args, "--paths");
boolean demo = hasFlag(args, "--demo");
Path outDir = Paths.get(outputDirStr); Path outDir = Paths.get(outputDirStr);
Files.createDirectories(outDir); Files.createDirectories(outDir);
@ -55,7 +63,35 @@ public class App {
String[] originNames = places.names(); String[] originNames = places.names();
double[] originLats = places.lats(), originLons = places.lons(); double[] originLats = places.lats(), originLons = places.lons();
int nOrigins = originLats.length; 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<Integer> 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<Integer> 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 // One thread pool shared across all modes
ExecutorService pool = Executors.newFixedThreadPool(threads); ExecutorService pool = Executors.newFixedThreadPool(threads);
@ -65,10 +101,13 @@ public class App {
catch (Exception e) { throw new RuntimeException(e); } catch (Exception e) { throw new RuntimeException(e); }
}); });
if (enablePaths) System.err.println("Path recording ENABLED (transit only, ~20x slower)");
try { try {
for (String mode : MODES) { for (String mode : modes) {
processMode(network, postcodes.codes(), postcodes.lats(), postcodes.lons(), 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 { } finally {
pool.shutdown(); 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( private static void processMode(
TransportNetwork network, TransportNetwork network,
String[] postcodes, double[] postcodeLats, double[] postcodeLons, String[] postcodes, double[] postcodeLats, double[] postcodeLons,
String[] originNames, double[] originLats, double[] originLons, String[] originNames, double[] originLats, double[] originLons,
Path outDir, String mode, LocalDate date, Path outDir, String mode, LocalDate date,
ExecutorService pool, ThreadLocal<DuckDBConnection> threadConn) throws Exception { ExecutorService pool, ThreadLocal<DuckDBConnection> threadConn,
boolean enablePaths, int[] originIndices, boolean skipCompleted) throws Exception {
int nOrigins = originLats.length;
System.err.printf("%n=== %s ===%n", mode.toUpperCase()); System.err.printf("%n=== %s ===%n", mode.toUpperCase());
System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode)); System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode));
Path modeDir = outDir.resolve(mode); Path modeDir = outDir.resolve(mode);
Files.createDirectories(modeDir); Files.createDirectories(modeDir);
List<Integer> remaining = findRemaining(modeDir, originNames); List<Integer> remaining = new ArrayList<>();
int alreadyDone = nOrigins - remaining.size(); 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()); System.err.printf(" %,d done, %,d remaining%n", alreadyDone, remaining.size());
if (remaining.isEmpty()) { if (remaining.isEmpty()) {
@ -126,7 +176,7 @@ public class App {
try { try {
processOrigin(network, postcodes, postcodeLats, postcodeLons, processOrigin(network, postcodes, postcodeLats, postcodeLons,
originLats[idx], originLons[idx], originLats[idx], originLons[idx],
modeDir, mode, date, idx, originNames[idx], threadConn.get()); modeDir, mode, date, idx, originNames[idx], threadConn.get(), enablePaths);
completed.incrementAndGet(); completed.incrementAndGet();
} catch (Exception e) { } catch (Exception e) {
failed.incrementAndGet(); failed.incrementAndGet();
@ -153,7 +203,7 @@ public class App {
String[] postcodes, double[] postcodeLats, double[] postcodeLons, String[] postcodes, double[] postcodeLats, double[] postcodeLons,
double originLat, double originLon, double originLat, double originLon,
Path modeDir, String mode, LocalDate date, int index, String name, 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)); Path outPath = modeDir.resolve(originFilename(index, name));
Exception lastError = null; Exception lastError = null;
@ -162,7 +212,7 @@ public class App {
try { try {
Router.FilteredResult result = Router.computeForOrigin( Router.FilteredResult result = Router.computeForOrigin(
network, postcodeLats, postcodeLons, network, postcodeLats, postcodeLons,
originLat, originLon, mode, date); originLat, originLon, mode, date, enablePaths);
// Write only reachable postcodes (sparse output) // Write only reachable postcodes (sparse output)
int reachable = 0; int reachable = 0;
@ -171,18 +221,20 @@ public class App {
String[] codes = new String[reachable]; String[] codes = new String[reachable];
short[] times = new short[reachable]; short[] times = new short[reachable];
short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null; short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null;
String[] journeys = result.journeys() != null ? new String[reachable] : null;
int j = 0; int j = 0;
for (int i = 0; i < result.times().length; i++) { for (int i = 0; i < result.times().length; i++) {
if (result.times()[i] >= 0) { if (result.times()[i] >= 0) {
codes[j] = postcodes[result.originalIndices()[i]]; codes[j] = postcodes[result.originalIndices()[i]];
times[j] = result.times()[i]; times[j] = result.times()[i];
if (bestTimes != null) bestTimes[j] = result.bestTimes()[i]; if (bestTimes != null) bestTimes[j] = result.bestTimes()[i];
if (journeys != null) journeys[j] = result.journeys()[i]; // may be null for some postcodes
j++; j++;
} }
} }
if (bestTimes != null) { if (bestTimes != null) {
Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes); Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes, journeys);
} else { } else {
Parquet.writeTravelTimes(conn, outPath, codes, times); Parquet.writeTravelTimes(conn, outPath, codes, times);
} }
@ -201,18 +253,6 @@ public class App {
throw lastError; throw lastError;
} }
/** Find origin indices that don't yet have output parquet files. */
private static List<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
List<Integer> 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). */ /** Build a filename from index + place name (index prefix prevents collisions after sanitization). */
private static String originFilename(int index, String name) { private static String originFilename(int index, String name) {
String safe = name.toLowerCase() String safe = name.toLowerCase()
@ -226,11 +266,18 @@ public class App {
if (args[i].equals(name)) return args[i + 1]; if (args[i].equals(name)) return args[i + 1];
} }
System.err.println("Missing required argument: " + name); 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); System.exit(1);
return null; // unreachable 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) { private static String optionalArg(String[] args, String name, String defaultValue) {
for (int i = 0; i < args.length - 1; i++) { for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals(name)) return args[i + 1]; if (args[i].equals(name)) return args[i + 1];
@ -238,6 +285,46 @@ public class App {
return defaultValue; 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<Integer> filterEnglandPlaces(
double[] placeLats, double[] placeLons,
double[] postcodeLats, double[] postcodeLons) {
// Build grid of cells that contain at least one England postcode
Set<Long> 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<Integer> 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) { private static String requiredEnv(String name) {
String val = System.getenv(name); String val = System.getenv(name);
if (val == null) { if (val == null) {

View file

@ -107,13 +107,19 @@ public class Parquet {
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); 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, 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"); Path tmp = outPath.resolveSibling(outPath.getFileName() + ".tmp");
boolean hasJourneys = journeys != null;
try (Statement stmt = conn.createStatement()) { try (Statement stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS t"); 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")) { try (DuckDBAppender appender = conn.createAppender("main", "t")) {
for (int i = 0; i < postcodes.length; i++) { for (int i = 0; i < postcodes.length; i++) {
@ -121,6 +127,7 @@ public class Parquet {
appender.append(postcodes[i]); appender.append(postcodes[i]);
appender.append(times[i]); appender.append(times[i]);
appender.append(bestTimes[i]); appender.append(bestTimes[i]);
if (hasJourneys) appender.append(journeys[i]); // null-safe: DuckDB appends SQL NULL
appender.endRow(); appender.endRow();
} }
} }

View file

@ -3,14 +3,19 @@ package propertymap;
import com.conveyal.r5.OneOriginResult; import com.conveyal.r5.OneOriginResult;
import com.conveyal.r5.analyst.FreeFormPointSet; import com.conveyal.r5.analyst.FreeFormPointSet;
import com.conveyal.r5.analyst.PointSet; import com.conveyal.r5.analyst.PointSet;
import com.conveyal.r5.analyst.StreetTimesAndModes;
import com.conveyal.r5.analyst.TravelTimeComputer; import com.conveyal.r5.analyst.TravelTimeComputer;
import com.conveyal.r5.analyst.WebMercatorExtents; 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.RegionalTask;
import com.conveyal.r5.analyst.cluster.TravelTimeResult; import com.conveyal.r5.analyst.cluster.TravelTimeResult;
import com.conveyal.r5.api.util.LegMode; import com.conveyal.r5.api.util.LegMode;
import com.conveyal.r5.api.util.TransitModes; import com.conveyal.r5.api.util.TransitModes;
import com.conveyal.r5.kryo.KryoNetworkSerializer; import com.conveyal.r5.kryo.KryoNetworkSerializer;
import com.conveyal.r5.transit.TransitLayer;
import com.conveyal.r5.transit.TransportNetwork; 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 org.locationtech.jts.geom.Coordinate;
import java.io.File; 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 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 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_FROM_TIME = 7 * 3600 + 30 * 60; // 07:30
private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30
private static final int MAX_TRIP_DURATION_MINUTES = 120; 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) // 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_BEST = 0; // 5th percentile (transit only)
private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0) 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. */ /** 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. */ /** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */
static double maxRadiusKm(String mode) { static double maxRadiusKm(String mode) {
@ -81,14 +89,14 @@ public class Router {
TransportNetwork network, TransportNetwork network,
double[] allLats, double[] allLons, double[] allLats, double[] allLons,
double originLat, double originLon, double originLat, double originLon,
String mode, LocalDate date) { String mode, LocalDate date, boolean enablePaths) {
double maxRadius = maxRadiusKm(mode); double maxRadius = maxRadiusKm(mode);
// 1. Filter destinations by bounding box // 1. Filter destinations by bounding box
int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius); int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius);
if (filtered.length == 0) { 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 // 2. Extract filtered coordinate arrays
@ -99,17 +107,22 @@ public class Router {
fLons[i] = allLons[filtered[i]]; fLons[i] = allLons[filtered[i]];
} }
// 3. Build chunks from filtered destinations // 3. Build chunks smaller when path recording is active (R5 PathResult limit: 5000)
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons);
// 4. Compute travel times
boolean isTransit = mode.equals("transit"); 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<DestinationChunk> 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 // Transit requests [5th, 50th] percentiles; others request [50th] only
short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0]; short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null; 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. * 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. * and optionally a maximum destination count (required for path recording).
* Sorts by latitude and splits into bands.
*/ */
private static List<DestinationChunk> buildDestinationChunks(double[] lats, double[] lons) { private static List<DestinationChunk> buildDestinationChunks(
double[] lats, double[] lons, int maxDestsPerChunk) {
int n = lats.length; int n = lats.length;
// Sort indices by latitude for geographic chunking // Sort indices by latitude for geographic chunking
@ -169,7 +184,7 @@ public class Router {
int gridWidth = lonToPixel(maxLon, totalPixels) - lonToPixel(minLon, totalPixels) + 1; int gridWidth = lonToPixel(maxLon, totalPixels) - lonToPixel(minLon, totalPixels) + 1;
int maxHeight = MAX_GRID_CELLS / gridWidth; 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<DestinationChunk> chunks = new ArrayList<>(); List<DestinationChunk> chunks = new ArrayList<>();
int start = 0; int start = 0;
while (start < n) { while (start < n) {
@ -177,6 +192,7 @@ public class Router {
int topPixel = latToPixel(lats[sorted[start]], totalPixels); int topPixel = latToPixel(lats[sorted[start]], totalPixels);
while (end < n) { while (end < n) {
if (end - start >= maxDestsPerChunk) break;
int bottomPixel = latToPixel(lats[sorted[end]], totalPixels); int bottomPixel = latToPixel(lats[sorted[end]], totalPixels);
if (Math.abs(bottomPixel - topPixel) + 1 > maxHeight) break; if (Math.abs(bottomPixel - topPixel) + 1 > maxHeight) break;
end++; end++;
@ -192,10 +208,12 @@ public class Router {
/** /**
* Compute travel times from one origin to all destinations across all chunks. * 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). * 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( private static short[][] computeTravelTimes(
TransportNetwork network, List<DestinationChunk> chunks, TransportNetwork network, List<DestinationChunk> 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"); boolean isTransit = mode.equals("transit");
int nPercentiles = isTransit ? 2 : 1; int nPercentiles = isTransit ? 2 : 1;
@ -203,7 +221,7 @@ public class Router {
for (short[] arr : allTimes) Arrays.fill(arr, (short) -1); for (short[] arr : allTimes) Arrays.fill(arr, (short) -1);
for (DestinationChunk chunk : chunks) { 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); TravelTimeComputer computer = new TravelTimeComputer(task, network);
OneOriginResult result = computer.computeTravelTimes(); 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; 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<RouteSequence, PathResult.Iteration>[] allPaths = paths.iterationsForPathTemplates;
for (int i = 0; i < originalIndices.length && i < allPaths.length; i++) {
Multimap<RouteSequence, PathResult.Iteration> 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 helpers ---
private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {} private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
@ -268,7 +363,8 @@ public class Router {
} }
private static RegionalTask buildTask( 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(); RegionalTask task = new RegionalTask();
task.fromLat = originLat; task.fromLat = originLat;
task.fromLon = originLon; task.fromLon = originLon;
@ -285,6 +381,11 @@ public class Router {
task.toTime = DEPARTURE_TO_TIME; task.toTime = DEPARTURE_TO_TIME;
task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES; task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES;
if (recordPaths) {
task.includePathResults = true;
task.nPathsPerTarget = 3;
}
configureMode(task, mode); configureMode(task, mode);
return task; return task;
} }
@ -296,6 +397,9 @@ public class Router {
case "walking" -> setDirectMode(task, LegMode.WALK); case "walking" -> setDirectMode(task, LegMode.WALK);
case "transit" -> { case "transit" -> {
task.maxRides = 4; 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.accessModes = EnumSet.of(LegMode.WALK);
task.egressModes = EnumSet.of(LegMode.WALK); task.egressModes = EnumSet.of(LegMode.WALK);
task.directModes = EnumSet.of(LegMode.WALK); task.directModes = EnumSet.of(LegMode.WALK);