Concepts

How inference works

Inference turns a stream of GPS fixes into the diary you see. It is rule-based and deterministic, with no machine learning and no network calls, so every result can be explained and reproduced.

  1. 1🧹CleanDrop inaccurate fixes and GPS spikes
  2. 2📍StaysCluster fixes into visits
  3. 3🏠PlacesMatch a known place or create one
  4. 4🚇TripsConnect visits, split by mode
  5. 5🛡️ProtectNever touch your edits🔒 locked

Every threshold is a field of InferenceConfig (source). Defaults are shown in brackets. The full specification is docs/inference.md.

1. Cleaning

2. Stay detection

Fixes are walked in time order. Starting from a seed fix, fixes are added while they're within stayRadius [100 m] of the running, accuracy-weighted centroid. A fix outside the radius is either:

Data gaps are normal

The collector deliberately stops GPS when you're stationary, so gaps longer than gapThreshold [15 min] are expected. A gap counts as stationary if CoreMotion shows less than gapMovingSeconds [5 min] of movement during it, or, without motion data, if the last fix was recorded in stationary mode.

GapNext fixResult
stationaryinside radiusThe stay continues through the gap
stationaryoutside radiusDeparture is back-dated from the next fix by distance ÷ plausible speed
movinginside radiusThe stay ends: you left and came back, and the loop becomes a trip

A cluster becomes a visit if it lasts at least minStayDuration [5 min]. Arrival and departure are trimmed to the core radius [35 m, or 1.5 × median spread] to remove the walk-in / walk-out bias. Consecutive stays within mergeDistance [150 m], separated by a short excursion, are merged.

3. Places

Each stay is matched to the nearest place whose radius + placeMatchSlack [40 m] contains its centroid; named places win over unnamed ones. Otherwise a new unnamed place is created with radius newPlaceRadius [80 m]. This is why naming a place once is enough: every later visit is matched to it.

4. Trips and modes

A trip connects consecutive visits. Its path is every cleaned fix in between plus both visit centroids.

Per-interval classification

Between each pair of fixes, speed and the CoreMotion activity at the midpoint give a class:

Segmentation and refinement

Classes are run-length encoded; runs shorter than minSegmentDuration [120 s] are absorbed into their longer neighbour. Vehicle segments are then refined:

Refined modeRule
airplaneAverage ≥ 0.6 × air speed over > 150 km
intercity rail≥ 60 km with p90 speed 38–70 m/s
subway> 40 % of time is signal loss, at 4–25 m/s, over < 40 km
carEverything else. Bus vs car can't be separated without transit schedules.

The trip's main mode is the highest segment in the HHTS hierarchy (airplane > rail > transit > car > bike > walk), among segments of at least 2 min or 300 m.

Personalisation

If you've corrected earlier trips between the same origin and destination and at least 60 % of those corrections agree, that mode is used. A trip is flagged needs review when it is untouched and its mode is unknown or has confidence < 0.5.

5. Reprocessing without losing edits

processNew() reprocesses from the latest visit, loading contextMargin [30 min] of earlier fixes so the anchor stay is detected exactly as in a full run. process(from:to:) rebuilds any range. These rules keep your corrections safe:

  1. Records with user_status ≠ auto (and auto visits they reference) are locked and never modified.
  2. A detected stay overlapping a locked trip or a user-deleted visit is ignored.
  3. A detected stay overlapping a locked visit is absorbed by it.
  4. Matching auto visits are updated in place, so IDs stay stable.
  5. No trip is created where a locked trip already covers the interval.
  6. Self-reported stamps add stays before rules 2–5 apply, so a deletion or a locked trip still wins over a stamp.
i

The test suite asserts that incremental runs equal one-shot runs, repeated runs change nothing, edits survive full rebuilds, and deleted stops stay deleted.

6. Self-reported stamps

A stamp (Stamp this moment) means something worth recording happened around here, around now, never this coordinate is the stop. It stores the tap time and a coarse location: the latest fix rounded to 3 decimals, with an uncertainty radius of fix accuracy + half a rounding cell + 1.4 m/s × fix age (no location if the fix is over 10 min old). After stays are detected, each stamp is handled in StampAnchoring:

  1. Match: a stay within ± stampTimeTolerance [10 min] of the tap and within the stamp's radius + stayRadius + placeMatchSlack already accounts for it.
  2. Conflict: a tap inside a stay that is too far away contradicts the sensors and stays unmatched.
  3. Short stop: otherwise, fixes within ± stampSearchWindow [10 min] are searched; a cluster grows from the fix nearest the tap with no minimum dwell, and its fixes give the place, arrival and departure. With no fix nearby the coarse coordinate is used. Such stops last at least stampMinStayDuration [60 s] and get confidence 0.8.
  4. Link: after each run, stamps.visit_id names the visit that accounts for the stamp (or NULL). That is the only thing inference writes to a stamp.

Visits built from stamps are ordinary inferred visits; the stamp rows record which visits the user vouched for. The calibration replay ignores stamps, as it ignores every human input.

Known limitations