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

@ -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<DestinationChunk> 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<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);
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<DestinationChunk> buildDestinationChunks(double[] lats, double[] lons) {
private static List<DestinationChunk> 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<DestinationChunk> 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<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");
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<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) {}
@ -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);