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🧹CleanDrop inaccurate fixes and GPS spikes
- 2📍StaysCluster fixes into visits
- 3🏠PlacesMatch a known place or create one
- 4🚇TripsConnect visits, split by mode
- 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
- Ignore fixes with horizontal accuracy worse than
maxHorizontalAccuracy[100 m] (iOS visit events: [250 m]). They stay in the raw table. - Drop single-point spikes: A→B and B→C both faster than
maxPlausibleSpeed[320 m/s] while A→C is not.
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:
- Drift: the cluster is established and a later fix within
driftMaxDuration[120 s] comes back inside, so the excursion is skipped. - Departure: otherwise the cluster ends.
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.
| Gap | Next fix | Result |
|---|---|---|
| stationary | inside radius | The stay continues through the gap |
| stationary | outside radius | Departure is back-dated from the next fix by distance ÷ plausible speed |
| moving | inside radius | The 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:
- ≥
airMinSpeed[70 m/s] → air - motion says walking / running / cycling → walk / run / bike; automotive → vehicle
- no motion: < 0.3 m/s still; ≤
walkMaxSpeed[2.2] walk; ≤bikeMaxSpeed[7] bike; else vehicle - "still" intervals take the class of the next moving interval, because waiting comes before boarding
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 mode | Rule |
|---|---|
| airplane | Average ≥ 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 |
| car | Everything 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:
- Records with
user_status ≠ auto(and auto visits they reference) are locked and never modified. - A detected stay overlapping a locked trip or a user-deleted visit is ignored.
- A detected stay overlapping a locked visit is absorbed by it.
- Matching auto visits are updated in place, so IDs stay stable.
- No trip is created where a locked trip already covers the interval.
- Self-reported stamps add stays before rules 2–5 apply, so a deletion or a locked trip still wins over a stamp.
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:
- Match: a stay within ±
stampTimeTolerance[10 min] of the tap and within the stamp's radius +stayRadius+placeMatchSlackalready accounts for it. - Conflict: a tap inside a stay that is too far away contradicts the sensors and stays unmatched.
- 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 leaststampMinStayDuration[60 s] and get confidence 0.8. - Link: after each run,
stamps.visit_idnames 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
- Bus vs car vs ride-hail needs GTFS / route matching.
- Access and egress walks shorter than 2 min are folded into the main segment.
- The region-exit wake-up can lag the real departure by a few minutes; back-dating corrects most of it.
- Centroids of inferred places aren't re-estimated from later visits yet.