SamplingPolicy.kt
⎇
Raw
1package org.opentracker
2
3import kotlin.math.cos
4import kotlin.math.max
5import kotlin.math.sqrt
6
7/** A position, without Android types, so the policy runs in JVM tests. */
8data class Fix(
9 val timeMs: Long,
10 val lat: Double,
11 val lon: Double,
12 val accuracy: Float? = null,
13 /** m/s, from GNSS. Network fixes have none. */
14 val speed: Float? = null,
15)
16
17enum class Mode(val label: String) {
18 VEHICLE("Driving"),
19 WALK("Moving"),
20 DWELL("Stopped"),
21 STATIONARY("Still"),
22}
23
24/** What to ask LocationManager for. */
25data class Request(val gnss: Boolean, val intervalMs: Long, val maxDelayMs: Long)
26
27/**
28 * Spends power in proportion to how fast the position changes.
29 *
30 * A faster mode starts with the first fix that shows the speed, so the start of a trip is not lost.
31 * A slower mode waits: DWELL bridges red lights and queues, and only then STATIONARY turns GNSS off.
32 * In STATIONARY, the significant-motion sensor ([wake]) or a network fix far away brings GNSS back.
33 *
34 * Not thread-safe. The tracker thread owns it.
35 */
36class SamplingPolicy {
37 var mode = Mode.WALK
38 private set
39
40 private var lastKept: Fix? = null
41 private var lastFix: Fix? = null
42 private var stillSinceMs = 0L
43 private var slowSinceMs: Long? = null
44
45 /** Updates the mode and returns whether the fix is worth uploading. */
46 fun onFix(fix: Fix): Boolean {
47 tick(fix.timeMs)
48 updateMode(fix, speedOf(fix))
49 lastFix = fix
50 return keep(fix)
51 }
52
53 /** Time-based transitions. Call it on every fix and from the watchdog. */
54 fun tick(nowMs: Long) {
55 if (mode == Mode.DWELL && nowMs - stillSinceMs >= DWELL_MS) mode = Mode.STATIONARY
56 }
57
58 /** The significant-motion sensor fired. */
59 fun wake(nowMs: Long) {
60 if (mode == Mode.STATIONARY || mode == Mode.DWELL) {
61 mode = Mode.WALK
62 stillSinceMs = nowMs
63 }
64 }
65
66 fun request(lowBattery: Boolean, charging: Boolean): Request {
67 val walk = Request(gnss = true, intervalMs = 30_000, maxDelayMs = 120_000)
68 val base = when (mode) {
69 Mode.VEHICLE -> Request(gnss = true, intervalMs = 5_000, maxDelayMs = 60_000)
70 Mode.WALK, Mode.DWELL -> walk
71 Mode.STATIONARY -> if (charging) walk else Request(gnss = false, intervalMs = HEARTBEAT_MS, maxDelayMs = 0)
72 }
73 return if (lowBattery && !charging) {
74 base.copy(intervalMs = base.intervalMs * 2, maxDelayMs = base.maxDelayMs * 2)
75 } else {
76 base
77 }
78 }
79
80 private fun updateMode(fix: Fix, speed: Float) {
81 val now = fix.timeMs
82 when {
83 speed >= VEHICLE_SPEED -> {
84 mode = Mode.VEHICLE
85 slowSinceMs = null
86 }
87 speed >= WALK_SPEED -> when (mode) {
88 Mode.VEHICLE -> {
89 val since = slowSinceMs ?: now.also { slowSinceMs = it }
90 if (now - since >= SLOWDOWN_MS) mode = Mode.WALK
91 }
92 else -> mode = Mode.WALK
93 }
94 mode == Mode.STATIONARY -> {
95 val last = lastKept
96 val accurate = (fix.accuracy ?: Float.MAX_VALUE) < DEPARTURE_M
97 if (last != null && accurate && distance(last, fix) > DEPARTURE_M) {
98 mode = Mode.WALK
99 stillSinceMs = now
100 }
101 }
102 mode != Mode.DWELL -> {
103 mode = Mode.DWELL
104 stillSinceMs = now
105 slowSinceMs = null
106 }
107 }
108 }
109
110 /** The higher of the GNSS speed and a cautious estimate from the previous fix. Some chips report 0 while moving. */
111 private fun speedOf(fix: Fix): Float = max(fix.speed ?: 0f, estimatedSpeed(fix))
112
113 private fun estimatedSpeed(fix: Fix): Float {
114 val prev = lastFix ?: return 0f
115 val dt = (fix.timeMs - prev.timeMs) / 1000.0
116 val acc = (fix.accuracy ?: return 0f) + (prev.accuracy ?: return 0f)
117 if (dt < 10 || acc > 2 * MAX_ACCURACY) return 0f
118 // Subtracting both accuracies keeps position noise from looking like movement.
119 return (max(0.0, distance(prev, fix) - acc) / dt).toFloat()
120 }
121
122 private fun keep(fix: Fix): Boolean {
123 val last = lastKept
124 val age = if (last == null) Long.MAX_VALUE else fix.timeMs - last.timeMs
125 val ok = when {
126 last == null -> true
127 // The server keeps one point per second.
128 fix.timeMs / 1000 <= last.timeMs / 1000 -> false
129 // A vague position beats none after a long gap, for example indoors.
130 (fix.accuracy ?: 0f) > MAX_ACCURACY -> age >= STALE_MS
131 age >= HEARTBEAT_MS -> true
132 mode == Mode.STATIONARY -> distance(last, fix) > DEPARTURE_M
133 mode == Mode.VEHICLE -> distance(last, fix) >= 20
134 else -> distance(last, fix) >= 10
135 }
136 if (ok) lastKept = fix
137 return ok
138 }
139
140 companion object {
141 /** About 25 km/h. */
142 const val VEHICLE_SPEED = 7f
143 /** GNSS speed noise on a still phone stays below this. */
144 const val WALK_SPEED = 0.8f
145 const val DWELL_MS = 5 * 60_000L
146 const val SLOWDOWN_MS = 2 * 60_000L
147 const val HEARTBEAT_MS = 15 * 60_000L
148 const val STALE_MS = 10 * 60_000L
149 const val MAX_ACCURACY = 50f
150 const val DEPARTURE_M = 200.0
151
152 /** Metres. Equirectangular, which is exact enough at these distances. */
153 fun distance(a: Fix, b: Fix): Double {
154 val k = 111_320.0
155 val dy = (b.lat - a.lat) * k
156 val dx = (b.lon - a.lon) * k * cos(Math.toRadians((a.lat + b.lat) / 2))
157 return sqrt(dx * dx + dy * dy)
158 }
159 }
160}
161