Detecting Card Sharing with Tap Velocity Checks

The task on this page is narrow and physical: given two consecutive taps on one credential, decide whether the implied travel speed between the stations exceeds anything a rider could achieve, and if so, flag the second tap as probable card sharing or cloning with a Decimal fare-at-risk. When a single card taps in at one end of the network and, four minutes later, taps in eleven kilometres away, no legitimate journey connects the two — the credential is in two places at once. This is one signal inside the Tap Pattern Anomaly Detection scorer, part of the broader Fraud Detection & Revenue Protection subsystem, and it is written for the transit ops teams, revenue analysts, and Python developers who have to turn a hunch about a shared card into a defensible, auditable number. Both taps are assumed already normalized to canonical media events; this page owns only the great-circle distance, the implied-speed gate, and the flag it emits.

Velocity Check Flow

The check is a short pipeline of guards, each of which must pass before the speed comparison is meaningful. A zero or negative time delta is a clock problem, not a fraud signal, and must short-circuit before any division; two taps at the same station have zero distance and can never be a teleport. Only a strictly-later tap at a different station reaches the speed test. The flow below traces those guards to a single disposition:

Ordered guards of the tap-velocity check A tap pair enters four ordered guards. First, if the second tap is not strictly after the first the result is SKIP_CLOCK, deferred to the sequence detector. If the two taps share a station the result is SKIP_SAME_STATION with zero distance. Otherwise the great-circle distance and implied speed are computed. If the implied speed is at or below the physical maximum the result is CLEAN, a plausible journey. If it exceeds the maximum the result is FLAG_SHARING, an impossible-velocity card-sharing flag carrying a Decimal fare-at-risk. Each guard short-circuits before the next, so a clock glitch or a same-station re-tap can never reach the speed comparison. yes different > max no same station ≤ max Prev tap + new tap New tap strictly after previous? Different station? compute haversine km Implied km/h within max? FLAG_SHARING Decimal fare-at-risk SKIP_CLOCK SKIP_SAME_STATION CLEAN

Step 1 — Great-Circle Distance Between Stations

Station coordinates are latitude/longitude on the WGS84 ellipsoid, so straight-line distance is a great-circle arc, not a planar hypotenuse. The haversine formula, built on the standard-library math module, is numerically stable for the small distances between adjacent transit stations, where the naive spherical law of cosines loses precision. Given two points, it returns the surface distance in kilometres; that distance is the numerator of the speed the rider would have needed. The implementation clamps the argument of asin to guard against a floating-point value drifting a hair above 1.0.

import math

EARTH_RADIUS_KM = 6371.0088  # IUGG mean earth radius


def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Great-circle distance in kilometres between two WGS84 coordinates."""
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    d_phi = math.radians(lat2 - lat1)
    d_lambda = math.radians(lon2 - lon1)
    a = (
        math.sin(d_phi / 2.0) ** 2
        + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2.0) ** 2
    )
    # clamp to [0, 1] so a rounding overshoot never raises a domain error
    return 2.0 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))

Distance is a float here deliberately: it is a physical measurement, not money, and geographic coordinates are inherently floating-point. The Decimal discipline applies only to the fare-at-risk the flag carries, which never touches this computation.

Step 2 — The Implied-Speed Gate

With a distance and an elapsed time, the implied speed is a single division — but the guards around it are what keep it honest. A non-positive delta means the clocks disagree and the pair belongs to the sequence detector, not here; a same-station pair has zero distance and can never be a teleport, however fast the re-tap. Only when both pass do we divide distance by hours and compare against a configured physical maximum. The maximum is not hard-coded: it is loaded per network from the tuning store, because a metro with express rail tolerates a higher plausible speed than an all-bus system.

import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional

logger = logging.getLogger("transit.velocity_check")
MONEY = Decimal("0.01")


class VelocityVerdict(str, Enum):
    CLEAN = "clean"
    FLAG_SHARING = "flag_sharing"
    SKIP_CLOCK = "skip_clock"
    SKIP_SAME_STATION = "skip_same_station"


@dataclass(frozen=True)
class StationTap:
    media_hash: str
    station_id: str
    lat: float
    lon: float
    tap_utc: datetime
    fare_value: Decimal      # fare booked on this tap, minor units


@dataclass(frozen=True)
class VelocityFlag:
    media_hash: str
    verdict: VelocityVerdict
    distance_km: float
    elapsed_s: float
    implied_kmh: float
    fare_at_risk: Decimal
    evidence: str


class VelocityChecker:
    """Flags one credential appearing in two places too far apart in time."""

    def __init__(self, max_speed_kmh: float = 240.0) -> None:
        if max_speed_kmh <= 0:
            raise ValueError("max_speed_kmh must be positive.")
        self.max_speed_kmh = max_speed_kmh

    def check(self, prev: StationTap, curr: StationTap) -> VelocityFlag:
        elapsed_s = (curr.tap_utc - prev.tap_utc).total_seconds()
        if elapsed_s <= 0.0:
            return self._skip(curr, VelocityVerdict.SKIP_CLOCK, 0.0, elapsed_s, 0.0,
                              "Non-positive time delta; deferring to sequence detector.")
        if prev.station_id == curr.station_id:
            return self._skip(curr, VelocityVerdict.SKIP_SAME_STATION, 0.0, elapsed_s, 0.0,
                              "Same station; zero distance cannot imply teleport.")

        distance_km = haversine_km(prev.lat, prev.lon, curr.lat, curr.lon)
        implied_kmh = distance_km / (elapsed_s / 3600.0)

        if implied_kmh <= self.max_speed_kmh:
            return VelocityFlag(
                media_hash=curr.media_hash,
                verdict=VelocityVerdict.CLEAN,
                distance_km=distance_km,
                elapsed_s=elapsed_s,
                implied_kmh=implied_kmh,
                fare_at_risk=Decimal("0.00"),
                evidence=f"{implied_kmh:.0f} km/h within max {self.max_speed_kmh:.0f}.",
            )

        fare_at_risk = curr.fare_value.quantize(MONEY, rounding=ROUND_HALF_UP)
        evidence = (
            f"{distance_km:.1f} km {prev.station_id}->{curr.station_id} in "
            f"{elapsed_s:.0f}s implies {implied_kmh:.0f} km/h "
            f"(max {self.max_speed_kmh:.0f}); probable shared/cloned card."
        )
        logger.warning("media=%s FLAG_SHARING %s", curr.media_hash, evidence)
        return VelocityFlag(
            media_hash=curr.media_hash,
            verdict=VelocityVerdict.FLAG_SHARING,
            distance_km=distance_km,
            elapsed_s=elapsed_s,
            implied_kmh=implied_kmh,
            fare_at_risk=fare_at_risk,
            evidence=evidence,
        )

    def _skip(self, curr: StationTap, verdict: VelocityVerdict, distance_km: float,
              elapsed_s: float, implied_kmh: float, evidence: str) -> VelocityFlag:
        return VelocityFlag(
            media_hash=curr.media_hash,
            verdict=verdict,
            distance_km=distance_km,
            elapsed_s=elapsed_s,
            implied_kmh=implied_kmh,
            fare_at_risk=Decimal("0.00"),
            evidence=evidence,
        )

The verdict is an explicit enum rather than a boolean, so a downstream analyst can tell an impossible pair from one merely skipped on a clock glitch — the two demand completely different follow-ups. The evidence string embeds the raw distance, elapsed time, and implied speed, so the flag justifies itself without a re-run.

The core inequality is a single comparison. Given great-circle distance dd (km) and elapsed time Δt\Delta t (seconds), the pair is flagged when the implied speed exceeds the physical maximum vmaxv_{max}:

vimplied=dΔt/3600>vmaxv_{implied} = \frac{d}{\Delta t / 3600} > v_{max}

Validation & Test Cases

Exercise the checker against a normal commute and two edge cases: a genuine teleport that must flag, and a clock glitch that must not. Coordinates below are London Underground stations; the express-rail maximum is 240 km/h.

from datetime import datetime, timezone

chk = VelocityChecker(max_speed_kmh=240.0)

def tap(station, lat, lon, hhmm, fare="2.80"):
    h, m = hhmm.split(":")
    return StationTap("CARD_A", station, lat, lon,
                      datetime(2026, 7, 3, int(h), int(m), tzinfo=timezone.utc),
                      Decimal(fare))

# Normal commute: ~0.6 km in 6 min -> ~6 km/h -> CLEAN
oxford = tap("OXF", 51.5154, -0.1410, "08:00")
bond = tap("BND", 51.5142, -0.1494, "08:06")
r = chk.check(oxford, bond)
assert r.verdict is VelocityVerdict.CLEAN
assert r.fare_at_risk == Decimal("0.00")

# Teleport: ~5.5 km apart, 3 min -> ~110 km/h... still plausible on express;
# push it: Heathrow to Stratford ~30 km in 4 min -> ~450 km/h -> FLAG
heathrow = tap("LHR", 51.4700, -0.4543, "09:00")
stratford = tap("STR", 51.5416, -0.0042, "09:04")
f = chk.check(heathrow, stratford)
assert f.verdict is VelocityVerdict.FLAG_SHARING
assert f.implied_kmh > 240.0
assert f.fare_at_risk == Decimal("2.80")   # the second tap's fare is at risk

# Edge: clocks disagree, second tap timestamped before first -> SKIP_CLOCK
backwards = chk.check(stratford, heathrow)
assert backwards.verdict is VelocityVerdict.SKIP_CLOCK
assert backwards.fare_at_risk == Decimal("0.00")

# Edge: same station re-tap 5s apart -> SKIP_SAME_STATION, never a teleport
retap = chk.check(oxford, tap("OXF", 51.5154, -0.1410, "08:00", "2.80"))
assert retap.verdict in (VelocityVerdict.SKIP_SAME_STATION, VelocityVerdict.SKIP_CLOCK)

The teleport case is the one that protects revenue: Heathrow to Stratford is roughly 30 km, and covering it in four minutes demands about 450 km/h — impossible for any rider, so the second tap flags with its Decimal("2.80") fare booked as at-risk. The SKIP_CLOCK case proves the guard ordering: a backwards clock produces a skip, not a spurious flag, so an NTP incident cannot masquerade as a fraud wave.

Edge Cases & Debugging for Transit Ops

Physical impossibility is a strong signal, but the boundaries are where false positives live:

  1. NTP drift near the gate. A validator running a few seconds fast compresses a normal trip into an implied over-speed. Suppress flags when elapsed_s is below the fleet clock tolerance rather than trusting a delta of a couple of seconds; the same tolerance governs Transfer Window Logic.
  2. Missing coordinates. A station_id absent from the reference table has no (lat, lon). Fail the signal closed — skip velocity for that pair — rather than scoring a fabricated distance or dropping the tap outright.
  3. Interchange geography. Two platforms of one interchange may carry different station_ids a few hundred metres apart; a fast cross-platform transfer can imply a high speed over a tiny distance. Set a minimum distance floor (for example, ignore pairs under 400 m) so platform hops never flag.
  4. Express and airport lines. A single global max_speed_kmh over-flags on high-speed rail and under-flags on an all-bus network. Load the maximum per line-set from the tuning store, and keep every flagged pair’s evidence for replay when a threshold changes.

For high-throughput scoring, the checker is stateless per call — the caller holds the previous tap — so it parallelises cleanly as long as the stream is sharded by media_hash, guaranteeing both taps of a pair reach the same worker.

Integration Note

This check is one signal inside the parent Tap Pattern Anomaly Detection scorer: the scorer holds the per-media last_tap, hands it plus the new tap to this checker, and folds the returned VelocityFlag into its weighted risk score. Its closest sibling is Flagging Impossible Journey Sequences in Tap Streams, which owns the cases this check deliberately defers — a SKIP_CLOCK verdict here is precisely a candidate for the sequence state machine, because a tap that arrives out of order is a sequence problem, not a speed one. Together the two signals cover both axes of impossibility: geography in space and validity in order.

FAQ

Why haversine instead of just Euclidean distance on lat/lon?
Because latitude and longitude are angles on a sphere, not metres on a plane. Treating them as planar coordinates makes a degree of longitude the same length everywhere, which is wrong away from the equator and gets worse toward the poles. The haversine_km function converts to great-circle arc length so the distance is correct in kilometres, and it stays numerically stable for the short hops between adjacent stations where the spherical law of cosines loses precision.
What is a sensible maximum speed to flag on?
Set it per line-set from your tuning store, not globally. An all-bus network rarely exceeds 60 km/h door to door, so a threshold around 120 km/h leaves comfortable headroom; a metro with express or airport rail needs 200 to 240 km/h to avoid flagging legitimate fast trips. The point is to catch the physically impossible, not the merely fast, so pick a value well above your fastest real service and let the flag rate on labelled history calibrate it.
Does a flag mean the fare should be refused at the gate?
No. The velocity check accepts the tap and emits a flag with a Decimal fare-at-risk; enforcement happens out of band. Refusing a rider inline on a probabilistic signal creates complaints and safety risk that cost far more than the fare, and a single over-speed pair can have innocent causes such as a mislocated station coordinate. Route the flag to a hot-list or an analyst, and let a sustained pattern — not one pair — drive account action.
Why keep distance in float but fare-at-risk in Decimal?
They are different kinds of quantity. Distance is a physical measurement derived from floating-point coordinates, where a sub-metre rounding error is irrelevant to whether a speed is impossible. Money is exact: the fare-at-risk feeds loss reports and dispute evidence, where a drifted cent compounds across millions of taps into a reconciliation discrepancy. So the geography stays float and only the monetary field is quantized Decimal.

Part of Tap Pattern Anomaly Detection, within Fraud Detection & Revenue Protection.