10 min read 5 sections

Segment-Crossing Detection Between GPS Samples

The predicate change described in dead reckoning and trajectory interpolation — from “is this point inside” to “does this segment cross the boundary” — is a one-line change in principle and a performance problem in practice. A segment query has a larger candidate envelope than a point query, and every surviving candidate requires an intersection computation that allocates. This page, sitting within Core Architecture & Latency Constraints, covers the predicate itself, its degenerate cases, and the analytic pre-filter that makes it affordable on the hot path.

Concept and specification

The question a segment test answers is richer than a containment test, and the richness is the reason a naive implementation gets it wrong. For a segment $S$ from to and a fence polygon $F$, four outcomes are possible, and each implies different transitions:

The fourth case is the pass-through, and it is the one that motivated the whole change: both endpoints are outside, so no point test can see it, and it emits two transitions from one input event. A pipeline whose emission stage assumes at most one transition per event drops the EXIT, leaving the pair permanently INSIDE — a worse outcome than the missed crossing the change was meant to fix.

Parameter Symbol Typical value Effect if wrong
Maximum join gap 30–60 s Too large fabricates crossings across detours
Maximum join distance 2 km Bounds the candidate envelope; too large explodes fan-out
Coordinate frame local metres Degrees make the tolerance anisotropic
Snap tolerance 1e-9 of extent Too small leaves duplicate intersection points
Intersections per segment 0–2 typical More than 2 indicates a concave fence or a self-touching ring
Parameter at a glance: Symbol, Typical value A row per parameter, a column per option, so a single axis can be compared across options in one sweep. Parameter at a glance: Symbol, Typical value the same trade-offs, read across instead of down Symbol Typical value Effect if wrong Maximum join gap Δ t_ 30–60 s Too large fabricates crossings across detours Maximum join distance s_ 2 km Bounds the candidate envelope; too large explodes fan-out Coordinate frame local metres Degrees make the tolerance anisotropic Snap tolerance ε 1e-9 of extent Too small leaves duplicate intersection points Intersections per segment 0–2 typical More than 2 indicates a concave fence or a self-touching ring
The parameter table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Symbol, Typical value, Effect if wrong.

The join-gap bound is the safety parameter. A segment is a claim that the device travelled in a straight line between two observations, and that claim gets weaker with time and distance. Beyond the bound the honest representation is a discontinuity in the track rather than a straight line, and the pipeline should emit nothing rather than a fabrication.

Step-by-step implementation

1. Reject cheaply before constructing anything. The dominant cost of the naive implementation is object construction, so the pre-filter must be pure arithmetic on primitives — no Point, no LineString, no allocation. A slab test against the fence’s bounding box rejects the vast majority of candidates:

python
from __future__ import annotations

def segment_bbox_hits(
    x0: float, y0: float, x1: float, y1: float,
    minx: float, miny: float, maxx: float, maxy: float,
) -> bool:
    """Liang-Barsky slab clip: True when the segment could touch the box.
    Pure float arithmetic — no geometry objects, no allocation."""
    if max(x0, x1) < minx or min(x0, x1) > maxx:
        return False
    if max(y0, y1) < miny or min(y0, y1) > maxy:
        return False
    dx, dy = x1 - x0, y1 - y0
    t0, t1 = 0.0, 1.0
    for p, q in ((-dx, x0 - minx), (dx, maxx - x0),
                 (-dy, y0 - miny), (dy, maxy - y0)):
        if p == 0.0:
            if q < 0.0:
                return False          # parallel and outside this slab
            continue
        r = q / p
        if p < 0.0:
            if r > t1:
                return False
            t0 = max(t0, r)
        else:
            if r < t0:
                return False
            t1 = min(t1, r)
    return t0 <= t1

2. Build geometry only for survivors. On the fleet measured here the slab test rejected 94% of index candidates at 60 ns each, so only 6% ever reach Shapely. That single reordering is worth more than any micro-optimisation inside the intersection itself.

3. Derive transitions from endpoint containment plus the intersection. Compute the two endpoint containments once and reuse them; do not infer them from the intersection result, which is ambiguous for a segment that merely touches a vertex.

4. Interpolate the event time by arc-length fraction. The crossing’s event time is where $f$ is the fraction of the segment’s length at the intersection. Compute $f$ with project() on the segment rather than by comparing coordinates, which is wrong for a segment that doubles back.

5. Quantise the interpolated instant to the idempotency bucket. An interpolated instant is an estimate, and a later, better fix can shift it by tens of milliseconds — producing a different idempotency key and therefore a duplicate. Round the instant to the same bucket width the key uses before publishing.

6. Order multiple transitions by fraction. A segment crossing a concave fence can produce four or more intersection points. Sort them by $f$ and emit alternating ENTER/EXIT starting from the state implied by ; emitting them in the order the geometry library returns them is not guaranteed to be spatial order.

Benchmark and verification

Measured over 12 million segment evaluations against a 1.2M-polygon index, mean 3.9 candidates per evaluation:

Implementation Mean cost Allocations/sec at 25k evals Gen-2 interval Missed crossings
Point-in-polygon on endpoints 0.18 ms 96k 90 s 47.5%
Shapely intersects, no pre-filter 0.41 ms 302k 4 s 0.7%
Slab pre-filter + Shapely 0.21 ms 88k 26 s 0.7%
Slab + prepared geometry 0.16 ms 71k 34 s 0.7%
Allocations/sec at 25k evals, Gen-2 interval, Missed crossings — 4 options Each panel scales on its own, so Allocations/sec at 25k evals, Gen-2 interval, Missed crossings are compared across 4 options without sharing an axis they do not share a unit with. Allocations/sec at 25k evals, Gen-2 interval, Missed crossings — 4 options Implementation — the table above, drawn to scale Point-in-polygon on endpoints Shapely intersects, no pre-filter Slab pre-filter + Shapely Slab + prepared geometry Allocations/sec at 25k evals 96k 302k 88k 71k Gen-2 interval 90 s 4 s 26 s 34 s Missed crossings 47.5% 0.7% 0.7% 0.7%
Allocations/sec at 25k evals, Gen-2 interval and 1 more for Point-in-polygon on endpoints, Shapely intersects, no pre-filter, Slab pre-filter + Shapely and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The third row is the headline: the pre-filter halves the cost and returns the gen-2 collection interval from 4 s to 26 s while changing the answer not at all — the slab test is conservative, so it never rejects a segment that genuinely intersects. The fourth row adds shapely.prepared.prep on the fence geometry, which pays for itself once a fence is tested more than about three times, and on a fleet with geographic concentration most fences are tested thousands of times per second.

Verification is a differential replay. Run a captured stream through the point evaluator and the segment evaluator and assert the segment output is a strict superset. Any transition the point path emits and the segment path does not is a bug — in practice, nearly always a fence whose ring is invalid, where contains() and intersects() disagree. Feed those fences to the validity repair described in handling polygon edge cases in high-frequency telemetry rather than working around them in the evaluator.

Failure modes and edge cases

Failure mode Signature Mitigation
Zero-length segment Division by zero in the fraction computation Guard on segment length; fall back to a point test
Segment endpoint exactly on the boundary Intersection returns a point, containment is arbitrary Apply a snap tolerance and treat on-boundary as inside consistently
Concave fence, four intersections Transitions emitted out of order Sort intersections by arc-length fraction before emitting
Segment spans a detour Fabricated crossing of an avoided fence Cap the join gap in both time and distance
Emission stage assumes one transition per event Pass-through EXIT dropped; pair stuck INSIDE Make the emission API accept a list
Candidate query still using the point envelope Segment tests only fences containing an endpoint Query the index with the segment’s bounding box
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Zero-length segment Division by zero in the fraction computation Guard on segment length; fall back to a point test Segment endpoint exactly on the boundary Intersection returns a point, containment is arbitrary Apply a snap tolerance and treat on-boundary as inside consistently Concave fence, four intersections Transitions emitted out of order Sort intersections by arc-length fraction before emitting Segment spans a detour Fabricated crossing of an avoided fence Cap the join gap in both time and distance Emission stage assumes one transition per event Pass-through EXIT dropped; pair stuck INSIDE Make the emission API accept a list Candidate query still using the point envelope Segment tests only fences containing an endpoint Query the index with the segment's bounding box
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The last row is the mistake that makes the whole change silently ineffective. If the index is still queried with the point, the candidate set contains only fences containing that point, and no pass-through fence is ever a candidate — so the expensive segment predicate runs against a candidate set that could never contain the crossing it was added to find. The symptom is a change that costs 2× the CPU and improves the miss rate by nothing, and it is worth asserting in a test: a fence entirely between two endpoints must appear in the candidate set.

Touching cases deserve one more note. A segment that grazes a fence vertex without entering produces an intersection of dimension zero and containment False at both endpoints, which the fourth case above would report as ENTER-then-EXIT at the same instant. Filter out pass-throughs whose two crossing fractions differ by less than the snap tolerance; they are tangencies, not traversals, and emitting them produces zero-duration dwells that downstream consumers treat as data errors.