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:
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 (km) and elapsed time (seconds), the pair is flagged when the implied speed exceeds the physical maximum :
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:
- 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_sis below the fleet clock tolerance rather than trusting a delta of a couple of seconds; the same tolerance governs Transfer Window Logic. - Missing coordinates. A
station_idabsent 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. - 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. - Express and airport lines. A single global
max_speed_kmhover-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’sevidencefor 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?
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?
Does a flag mean the fare should be refused at the gate?
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?
float and only the monetary field is quantized Decimal.
Related
- Tap Pattern Anomaly Detection — the parent scorer this velocity signal plugs into.
- Flagging Impossible Journey Sequences in Tap Streams — the sibling signal that owns the out-of-order and orphan-tap cases this check defers.
- Fare Evasion Analytics — where flagged-pair rates roll up into network-level evasion estimates.
- Transfer Window Logic — the evaluator that shares this signal’s clock tolerance contract.
- Smart Card Schema Mapping — where raw taps become the normalized media events this check consumes.
Part of Tap Pattern Anomaly Detection, within Fraud Detection & Revenue Protection.