-
Notifications
You must be signed in to change notification settings - Fork 97
Stop Direction Calculation
direction is an optional field on stop objects across the OBA API — a compass value
("N", "SW", etc.) indicating which way a vehicle is travelling when it serves the
stop. GTFS's stop_direction extension supplies this directly for feeds that publish
it, but most feeds don't. When it's absent, both legacy Java and Maglev fall back to
inferring a direction from the geometry of the shapes that pass through the stop,
using the same statistical algorithm. This page documents that shared algorithm once;
individual endpoint pages link back here instead of each re-describing it.
Java computes stop direction once, offline, during GTFS bundle build — not
per-request — and stores the result in the narrative provider for later lookup.
GenerateNarrativesTask.computeStopDirection
(lines 407–473):
-
GTFS extension first. If the feed's
stop_directionvalue translates to a compass direction, use it directly and stop. -
Gather shape orientations. Otherwise, collect the bearing of every shape
segment the stop sits on, via
DistanceTraveledShapePointIndex, which locates the shape segment bracketing the stop usingshape_dist_traveled(falling back to geographic distance matching when a feed omitsshape_dist_traveled— confirmed as Java-matching behavior by the introducing Maglev commit message, though not independently re-traced to a Java line citation here). -
Vector-average the orientations. Convert each bearing to a unit vector
(
cos/sin) and average the x and y components separately, then takeatan2(yMean, xMean)as the circular mean bearing. -
Reject high-variance stops. Compute the sample standard deviation of the x and
y components. If either exceeds
0.7(_stopDirectionStandardDeviationThreshold), the stop sits at too ambiguous/multi-directional a junction to assign a direction confidently, and the field is left absent rather than guessed. - Median, not mean, for the final angle. Normalize each orientation relative to the circular mean, sort them, and take the median as the final bearing — this resists outlier shape points that the mean alone would be skewed by.
- Convert the final angle to one of the eight compass directions (N/NE/E/SE/S/SW/W/NW).
internal/gtfs/advanced_direction_calculator.go's AdvancedDirectionCalculator is a
faithful port of the algorithm above — same two-step GTFS-then-shapes order, same
vector-averaging circular mean, same 0.7 standard-deviation rejection threshold (the
literal same constant, not a coincidence), same median-of-normalized-angles final step.
The introducing commit (d03c9e0, "feat: implement OneBusAway stop direction
calculation algorithm") states this directly and cites GenerateNarrativesTask.java
as its source.
Maglev also mirrors Java's "precompute once, not per-request" design: DirectionPrecomputer
batch-processes all stops at startup after GTFS data loads, so CalculateStopDirection
is an O(1) cache lookup at request time for the common case, with the same shape-based
computation available lazily for cache misses.
Every endpoint that returns a stop object (in data.entry, data.list[], or
data.references.stops[]) populates direction through this same shared
AdvancedDirectionCalculator, via the common buildStopModel helper in
internal/restapi/reference_utils.go or a direct call. As of this writing that's:
stop, stops-for-agency, search-stop, stops-for-location,
stops-for-route, arrival-and-departure-for-stop,
arrivals-and-departures-for-stop, and trips-for-location.
Rejects near-zero mean vectors using an epsilon comparison, rather than Java's exact == 0.0 check (deviates from legacy, in effect only).
Java's undefined-case guard for directly-opposing shape orientations is a literal equality check:
if (yMu == 0.0 && xMu == 0.0)
return null;In practice, a floating-point mean of cos/sin values is vanishingly unlikely to
land on exactly 0.0, so this guard rarely fires even when the true orientation is
genuinely undefined (e.g. a stop sitting exactly between two opposite-facing shape
segments) — the mean will be some tiny non-zero value that survives the check and
produces an arbitrary, low-confidence direction instead of the intended "leave it
absent" outcome.
Maglev's port
(advanced_direction_calculator.go:252-256)
uses an epsilon comparison instead:
if math.Abs(xMu) < 1e-6 && math.Abs(yMu) < 1e-6 {
return "", nil
}This makes the guard actually catch the near-zero cases it was written for, at the cost
of no longer being a literal translation of Java's condition. The practical effect is
that a small number of stops at genuinely ambiguous junctions, which legacy Java would
assign an arbitrary/low-confidence direction to due to the guard's ineffectiveness, get
direction: "" from Maglev instead.
If this proves too aggressive in practice (rejecting stops a human would consider to have an obvious direction), the fallback is to loosen or remove the epsilon check and revert to matching Java's literal (largely inert) condition.