package propertymap; import com.conveyal.r5.OneOriginResult; import com.conveyal.r5.analyst.FreeFormPointSet; import com.conveyal.r5.analyst.PointSet; import com.conveyal.r5.analyst.TravelTimeComputer; import com.conveyal.r5.analyst.WebMercatorExtents; 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.TransportNetwork; 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.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; // 07:00 private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 private static final int MAX_TRIP_DURATION_MINUTES = 120; // 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) {} /** 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); } System.err.println(" Building distance tables..."); network.transitLayer.buildDistanceTables(null); System.err.println(" Network ready"); return network; } /** * 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) { 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); } // 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 from filtered destinations List chunks = buildDestinationChunks(fLats, fLons); // 4. Compute travel times boolean isTransit = mode.equals("transit"); short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); // 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); } /** * 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. * Sorts by latitude and splits into bands so each band's bounding box is under 5M cells. */ private static List buildDestinationChunks(double[] lats, double[] lons) { 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 List chunks = new ArrayList<>(); int start = 0; while (start < n) { int end = start + 1; int topPixel = latToPixel(lats[sorted[start]], totalPixels); while (end < n) { 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). */ private static short[][] computeTravelTimes( TransportNetwork network, List chunks, double originLat, double originLon, String mode, int nDest, LocalDate date) { 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); 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]; } } } } return allTimes; } // --- 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) { 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; 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; 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); } }