This commit is contained in:
Andras Schmelczer 2026-07-12 21:31:13 +01:00
parent ca771a7edf
commit d7f844d566
12 changed files with 198 additions and 251 deletions

View file

@ -14,6 +14,7 @@ import com.conveyal.r5.api.util.LegMode;
import com.conveyal.r5.api.util.TransitModes;
import com.conveyal.r5.kryo.KryoNetworkSerializer;
import com.conveyal.r5.profile.StreetMode;
import com.conveyal.gtfs.model.Stop;
import com.conveyal.r5.transit.TransitLayer;
import com.conveyal.r5.transit.TransportNetwork;
import com.conveyal.r5.transit.path.RouteSequence;
@ -33,6 +34,19 @@ import java.util.List;
/** R5 routing: network loading, spatial filtering, travel time computation. */
public class Router {
// Coarse GB bounding box used to tell "a stop that should have linked to a
// street but didn't" (inside GB) from stops that legitimately never link
// (continental coach termini, neutralised out-of-area stops).
private static final double UK_MIN_LAT = 49.0;
private static final double UK_MAX_LAT = 61.0;
private static final double UK_MIN_LON = -9.0;
private static final double UK_MAX_LON = 2.5;
// If more than this fraction of transit stops fail to link to the street
// network the build is systemically broken (wrong/incomplete OSM extract, or
// corrupt coordinates) rather than the usual tiny residue of unroutable
// stops, so fail loudly instead of silently shipping a half-linked network.
private static final double MAX_UNLINKED_STOP_FRACTION = 0.10;
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
// 30-minute peak window: RAPTOR cost is linear in (toTime-fromTime)/60.
@ -168,6 +182,85 @@ public class Router {
System.err.printf(" Transit: %,d stops, %,d routes, %,d patterns, %,d services%n",
stops, routes, patterns, services);
validateStopLinkage(transitLayer);
}
/**
* Verify transit stops are connected to the walkable street network. A stop
* whose coordinate lands nowhere near a street gets streetVertexForStop == -1
* (R5's "unlinked" sentinel); it is then silently unroutable no access,
* egress, or distance table so trains pass through but nobody can board or
* alight (the Tottenham Court Road failure mode, upstream of the coordinate
* fix). A handful of stops legitimately never link (continental coach termini,
* neutralised out-of-area stops); a large fraction signals a systemic break
* (wrong OSM extract, or a whole mode's coordinates corrupt) and fails.
*
* streetVertexForStop is persisted in the network cache, so unlinked stops are
* counted on both fresh and cached loads. Per-stop coordinates come from the
* transient stopForIndex, which is only populated on a fresh fromDirectory
* build; on a cached load coordinates are unavailable and only counts are logged.
*/
private static void validateStopLinkage(TransitLayer transitLayer) {
int n = transitLayer.getStopCount();
// stopForIndex is transient: on a cached (Kryo) load it comes back empty
// (size 0), not null, so require it to be fully populated (fresh build)
// before indexing into it; otherwise fall back to counts-only.
boolean haveCoords = transitLayer.stopForIndex != null
&& transitLayer.stopForIndex.size() == n;
int unlinked = 0;
int unlinkedInGb = 0;
List<String> sample = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (transitLayer.streetVertexForStop.get(i) != -1) {
continue; // linked
}
unlinked++;
if (!haveCoords) {
continue;
}
Stop stop = transitLayer.stopForIndex.get(i);
if (stop == null) {
continue;
}
double lat = stop.stop_lat;
double lon = stop.stop_lon;
boolean inGb = lat >= UK_MIN_LAT && lat <= UK_MAX_LAT
&& lon >= UK_MIN_LON && lon <= UK_MAX_LON;
if (inGb) {
unlinkedInGb++;
if (sample.size() < 25) {
String id = transitLayer.stopIdForIndex.get(i);
String name = transitLayer.stopNames != null && i < transitLayer.stopNames.size()
? transitLayer.stopNames.get(i) : "";
sample.add(String.format("%s '%s' (%.5f, %.5f)", id, name, lat, lon));
}
}
}
double fraction = n == 0 ? 0.0 : (double) unlinked / n;
if (haveCoords) {
System.err.printf(
" Stop linkage: %,d/%,d stops NOT linked to street network (%.2f%%), %,d inside GB%n",
unlinked, n, fraction * 100.0, unlinkedInGb);
for (String s : sample) {
System.err.println(" unlinked (GB): " + s);
}
} else {
System.err.printf(
" Stop linkage: %,d/%,d stops NOT linked to street network (%.2f%%) "
+ "(coords unavailable on cached load)%n",
unlinked, n, fraction * 100.0);
}
if (fraction > MAX_UNLINKED_STOP_FRACTION) {
throw new IllegalStateException(String.format(
"R5 linked only %.1f%% of transit stops to the street network "
+ "(%,d of %,d unlinked). This indicates a systemic linking failure "
+ "(wrong/incomplete OSM extract, or corrupt stop coordinates); "
+ "unlinked stops are silently unroutable.",
(1.0 - fraction) * 100.0, unlinked, n));
}
}
static void validateTransitServices(TransportNetwork network, LocalDate date) {