Add paths
This commit is contained in:
parent
f3e3c1ee49
commit
02ec8ff4d2
4 changed files with 255 additions and 49 deletions
|
|
@ -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 ==="
|
||||
|
|
|
|||
|
|
@ -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<String> 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<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
|
||||
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<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(" Radius: %.0f km%n", Router.maxRadiusKm(mode));
|
||||
Path modeDir = outDir.resolve(mode);
|
||||
Files.createDirectories(modeDir);
|
||||
|
||||
List<Integer> remaining = findRemaining(modeDir, originNames);
|
||||
int alreadyDone = nOrigins - remaining.size();
|
||||
List<Integer> 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<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). */
|
||||
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<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) {
|
||||
String val = System.getenv(name);
|
||||
if (val == null) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue