perfect-postcode/r5-java/src/main/java/propertymap/Router.java

464 lines
20 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.EnumSet;
import java.util.List;
/** R5 routing: network loading, spatial filtering, travel time computation. */
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 + 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.
* Kept well below R5's limit to reduce per-chunk memory (fewer destinations = smaller PathResult).
*/
private static final int PATH_MAX_DESTINATIONS = 2000;
// 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, String[] journeys) {}
/** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */
static double maxRadiusKm(String mode) {
return switch (mode) {
case "car" -> 150;
case "transit" -> 150;
case "bicycle" -> 60;
case "walking" -> 12;
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
};
}
/**
* Load or build the transport network with Kryo caching.
* The returned network is read-only after buildDistanceTables — safe for concurrent use.
*/
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
System.err.println("Loading transport network...");
File cacheFile = new File(cacheDir, "network.dat");
TransportNetwork network;
if (cacheFile.exists()) {
System.err.println(" Loading cached network from " + cacheFile);
network = KryoNetworkSerializer.read(cacheFile);
} else {
System.err.println(" Building network (first run, takes a few minutes)...");
network = TransportNetwork.fromDirectory(new File(dataDir));
new File(cacheDir).mkdirs();
KryoNetworkSerializer.write(network, cacheFile);
System.err.println(" Cached to " + cacheFile);
}
validateTransitNetwork(network, cacheFile, dataDir);
System.err.println(" Building distance tables...");
network.transitLayer.buildDistanceTables(null);
System.err.println(" Network ready");
return network;
}
private static void validateTransitNetwork(TransportNetwork network, File cacheFile, String dataDir) {
TransitLayer transitLayer = network.transitLayer;
int stops = transitLayer == null ? 0 : transitLayer.getStopCount();
int routes = transitLayer == null || transitLayer.routes == null ? 0 : transitLayer.routes.size();
int patterns = transitLayer == null || transitLayer.tripPatterns == null ? 0 : transitLayer.tripPatterns.size();
int services = transitLayer == null || transitLayer.services == null ? 0 : transitLayer.services.size();
if (stops == 0 || routes == 0 || patterns == 0) {
throw new IllegalStateException(String.format(
"R5 network has no usable transit data (stops=%d, routes=%d, patterns=%d). "
+ "The cache at %s was likely built without GTFS. Ensure %s contains GTFS .zip files, "
+ "then delete %s and rerun.",
stops, routes, patterns, cacheFile.getPath(), dataDir, cacheFile.getPath()));
}
System.err.printf(" Transit: %,d stops, %,d routes, %,d patterns, %,d services%n",
stops, routes, patterns, services);
}
static void validateTransitServices(TransportNetwork network, LocalDate date) {
BitSet activeServices = network.transitLayer.getActiveServicesForDate(date);
if (activeServices.cardinality() == 0) {
throw new IllegalStateException("R5 network has transit data, but no active services on "
+ date + ". Rebuild property-data/transit from current feeds or choose a date covered by GTFS.");
}
System.err.printf(" Active transit services on %s: %,d%n", date, activeServices.cardinality());
}
/**
* Filter destinations by distance, build chunks, compute travel times for one origin.
* Returns only the filtered subset indices and their travel times.
*/
static FilteredResult computeForOrigin(
TransportNetwork network,
double[] allLats, double[] allLons,
double originLat, double originLon,
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, null);
}
// 2. Extract filtered coordinate arrays
double[] fLats = new double[filtered.length];
double[] fLons = new double[filtered.length];
for (int i = 0; i < filtered.length; i++) {
fLats[i] = allLats[filtered[i]];
fLons[i] = allLons[filtered[i]];
}
// 3. Build chunks — smaller when path recording is active (R5 PathResult limit: 5000)
boolean isTransit = mode.equals("transit");
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
short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null;
return new FilteredResult(filtered, medianTimes, bestTimes, journeys);
}
/**
* Filter destination indices to those within a bounding box of maxRadiusKm from origin.
* Uses degree-based approximation — slightly overestimates at corners, which is fine.
*/
private static int[] filterByDistance(
double[] lats, double[] lons,
double originLat, double originLon,
double maxRadiusKm) {
double degLat = maxRadiusKm / 111.0;
double degLon = maxRadiusKm / (111.0 * Math.cos(Math.toRadians(originLat)));
double minLat = originLat - degLat;
double maxLat = originLat + degLat;
double minLon = originLon - degLon;
double maxLon = originLon + degLon;
// Two-pass: count then fill (avoids ArrayList/boxing overhead)
int count = 0;
for (int i = 0; i < lats.length; i++) {
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
count++;
}
}
int[] result = new int[count];
int j = 0;
for (int i = 0; i < lats.length; i++) {
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
result[j++] = i;
}
}
return result;
}
/**
* 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<DestinationChunk> buildDestinationChunks(
double[] lats, double[] lons, int maxDestsPerChunk) {
int n = lats.length;
// Sort indices by latitude for geographic chunking
Integer[] sorted = new Integer[n];
for (int i = 0; i < n; i++) sorted[i] = i;
Arrays.sort(sorted, (a, b) -> Double.compare(lats[a], lats[b]));
// Determine grid width (longitude span is the same for all chunks)
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
for (double lon : lons) {
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
int totalPixels = 256 << ZOOM;
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 or maxDestsPerChunk
List<DestinationChunk> chunks = new ArrayList<>();
int start = 0;
while (start < n) {
int end = start + 1;
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++;
}
chunks.add(buildChunk(lats, lons, sorted, start, end));
start = end;
}
return 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).
* When recordPaths is true, also extracts journey instructions into journeysOut.
*/
private static short[][] computeTravelTimes(
TransportNetwork network, List<DestinationChunk> chunks,
double originLat, double originLon, String mode, int nDest, LocalDate date,
boolean recordPaths, String[] journeysOut) {
boolean isTransit = mode.equals("transit");
int nPercentiles = isTransit ? 2 : 1;
short[][] allTimes = new short[nPercentiles][nDest];
for (short[] arr : allTimes) Arrays.fill(arr, (short) -1);
for (DestinationChunk chunk : chunks) {
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date, recordPaths);
TravelTimeComputer computer = new TravelTimeComputer(task, network);
OneOriginResult result = computer.computeTravelTimes();
TravelTimeResult tt = result.travelTimes;
if (tt == null) {
throw new RuntimeException("R5 returned null travelTimes for chunk with "
+ chunk.originalIndices.length + " destinations");
}
int[][] values = tt.getValues();
if (values.length < nPercentiles) {
throw new RuntimeException("R5 returned " + values.length + " percentiles, expected "
+ nPercentiles);
}
for (int p = 0; p < nPercentiles; p++) {
if (values[p].length < chunk.originalIndices.length) {
throw new RuntimeException("R5 returned " + values[p].length
+ " travel times for percentile " + p + ", expected "
+ chunk.originalIndices.length);
}
for (int i = 0; i < chunk.originalIndices.length; i++) {
if (values[p][i] != Integer.MAX_VALUE) {
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
}
}
}
// 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<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 record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
private static DestinationChunk buildChunk(
double[] lats, double[] lons, Integer[] sorted, int start, int end) {
int size = end - start;
int[] originalIndices = new int[size];
Coordinate[] coords = new Coordinate[size];
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
for (int i = 0; i < size; i++) {
int idx = sorted[start + i];
originalIndices[i] = idx;
double lat = lats[idx], lon = lons[idx];
coords[i] = new Coordinate(lon, lat); // x=lon, y=lat
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
FreeFormPointSet pointSet = new FreeFormPointSet(coords);
int totalPixels = 256 << ZOOM;
int west = lonToPixel(minLon, totalPixels);
int north = latToPixel(maxLat, totalPixels);
int width = lonToPixel(maxLon, totalPixels) - west + 1;
int height = latToPixel(minLat, totalPixels) - north + 1;
WebMercatorExtents extents = new WebMercatorExtents(west, north, width, height, ZOOM);
return new DestinationChunk(pointSet, extents, originalIndices);
}
private static RegionalTask buildTask(
DestinationChunk chunk, double originLat, double originLon, String mode, LocalDate date,
boolean recordPaths) {
RegionalTask task = new RegionalTask();
task.fromLat = originLat;
task.fromLon = originLon;
task.date = date;
task.percentiles = mode.equals("transit") ? new int[]{5, 50} : new int[]{50};
task.recordTimes = true;
task.destinationPointSets = new PointSet[]{chunk.pointSet};
task.zoom = chunk.extents.zoom;
task.west = chunk.extents.west;
task.north = chunk.extents.north;
task.width = chunk.extents.width;
task.height = chunk.extents.height;
task.fromTime = DEPARTURE_FROM_TIME;
task.toTime = DEPARTURE_TO_TIME;
task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES;
if (recordPaths) {
task.includePathResults = true;
// We only use the most common RouteSequence (see extractPaths), so 1 path
// per target is sufficient and cuts path memory by ~67% vs 3.
task.nPathsPerTarget = 1;
}
configureMode(task, mode);
return task;
}
private static void configureMode(RegionalTask task, String mode) {
switch (mode) {
case "car" -> setDirectMode(task, LegMode.CAR);
case "bicycle" -> setDirectMode(task, LegMode.BICYCLE);
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);
task.transitModes = EnumSet.allOf(TransitModes.class);
}
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
}
}
private static void setDirectMode(RegionalTask task, LegMode legMode) {
task.maxRides = 0;
task.accessModes = EnumSet.of(legMode);
task.egressModes = EnumSet.of(legMode);
task.directModes = EnumSet.of(legMode);
task.transitModes = EnumSet.noneOf(TransitModes.class);
}
private static int lonToPixel(double lon, int totalPixels) {
return (int) Math.floor(totalPixels * (lon + 180.0) / 360.0);
}
private static int latToPixel(double lat, int totalPixels) {
double latRad = Math.toRadians(lat);
return (int) Math.floor(totalPixels * (1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0);
}
}