step 7: PointQueue and SamplingPolicy

AuthorKonata <konata@posteo.jp>
Date
Commit88e632eee7ee2eb4429425b32a0daa88837a53f6
Parent6bef68d
4 files changed, 757 insertions(+)
▾Aandroid/app/src/main/java/net/lexcom/opentracker/loc/SamplingPolicy.kt
@@ -0,0 +1,268 @@
package net.lexcom.opentracker.loc
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* How often the app asks for a fix, and which fixes are worth sending.
*
* Battery is the product risk of a location tracker. A phone that samples GNSS
* every second draws more than everything else the user runs, and the app gets
* uninstalled long before anyone complains about the map. So the policy spends
* power in proportion to how fast the position is actually changing.
*
* The mode ladder, fastest first:
*
* ```text
* VEHICLE driving; position changes every second, the trail must look
* like a road, so short interval and small min distance
* WALK moving on foot; a fix every half minute still draws a usable line
* DWELL was moving, is not moving now; a grace state, not a conclusion
* STATIONARY parked, asleep, at a desk; the state the phone is in most of the
* day, and therefore the one that decides the battery bill
* ```
*
* Transitions are deliberately asymmetric. Going faster happens on the first
* fix that shows it, because a missed departure loses the start of a trip and
* that hole cannot be filled in later. Going slower waits for
* [DWELL_TIMEOUT_MS], because a red light, a shop queue or a platform wait is
* not the end of a journey. DWELL is where that wait is served: stopping drops
* into DWELL at once, and only continued stillness turns it into STATIONARY.
*
* Pure Kotlin on purpose: no Android types, no clock, no I/O. The caller passes
* `nowMs` and maps `android.location.Location` onto [Fix]. That is what lets
* this run on the JVM with no emulator.
*/
/** Ceiling for a fix worth keeping, in metres. Coarser than this draws a trail
* that wanders through neighbours' gardens. */
const val ACCURACY_CEILING_M = 75f
/** After this long with nothing kept, a coarse fix is accepted anyway and
* flagged. Reporting a vague position beats reporting none because the sky is
* cloudy or the user is indoors. */
const val STALENESS_TIMEOUT_MS = 4L * 60 * 1000
/** After this long with nothing kept, the displacement gate is bypassed. A
* parked phone must still report, so the web UI can tell "here, still" from
* "gone". */
const val HEARTBEAT_MS = 15L * 60 * 1000
/** Roughly 25 km/h. Above this the user is in a vehicle, not jogging. */
const val VEHICLE_SPEED_MPS = 7f
/** Below this, GNSS speed noise on a still phone is indistinguishable from
* slow walking, so anything under it counts as not moving. */
const val WALK_SPEED_MPS = 0.7f
/** How long a slower observation must hold before the mode actually drops.
* Longer than a traffic light, shorter than a coffee. */
const val DWELL_TIMEOUT_MS = 3L * 60 * 1000
private const val VEHICLE_INTERVAL_MS = 5_000L
private const val WALK_INTERVAL_MS = 30_000L
private const val DWELL_INTERVAL_MS = 60_000L
private const val STATIONARY_INTERVAL_MS = 5L * 60 * 1000
private const val VEHICLE_MIN_DISTANCE_M = 25f
private const val WALK_MIN_DISTANCE_M = 15f
private const val DWELL_MIN_DISTANCE_M = 30f
private const val STATIONARY_MIN_DISTANCE_M = 100f
private const val EARTH_RADIUS_M = 6_371_000.0
/** Widest gap between two fixes that still yields a usable derived speed.
* Over a longer gap the straight line between them says nothing about how
* fast the phone was moving.
*
* It has to stay above [STATIONARY_INTERVAL_MS]. A device that reports no
* speed at all only ever sees gaps of one sampling interval, so a shorter
* limit would leave it unable to derive anything in the slowest mode, and it
* would stay STATIONARY for the rest of the day no matter where it went.
*
* A straight line understates a winding path, so a derived speed is a lower
* bound. It can delay a mode change by one fix. It cannot invent one. */
private const val MAX_DERIVE_GAP_MS = 10L * 60 * 1000
/** Declaration order is the ladder: a larger ordinal is a faster mode. */
enum class Motion { STATIONARY, DWELL, WALK, VEHICLE }
/**
* One candidate fix, stripped of Android.
*
* Optional fields are null when the device could not measure them, which is
* exactly what [Point] encodes as the wire sentinel.
*/
data class Fix(
val tsMs: Long,
val latE7: Int,
val lonE7: Int,
val accM: Float,
val speedMps: Float?,
val altM: Float?,
val bearingDeg: Float?,
val fromNetwork: Boolean,
val isMock: Boolean,
)
/** What the caller should ask the OS location API for. */
data class Request(val intervalMs: Long, val minDistanceM: Float)
/**
* The answer to one [SamplingPolicy.offer].
*
* [keep] is null when the fix is discarded. [motion] and [requestChanged] are
* reported either way, because a discarded fix can still move the mode.
*/
data class Decision(val keep: Point?, val motion: Motion, val requestChanged: Boolean)
class SamplingPolicy {
/** Nothing is known at start, so assume the cheap mode. The first moving
* fix corrects it immediately. */
var motion: Motion = Motion.STATIONARY
private set
val request: Request get() = requestFor(motion)
private var lastKeptMs: Long? = null
private var lastKeptLatE7: Int = 0
private var lastKeptLonE7: Int = 0
private var lastSeen: Fix? = null
/** When the current mode first looked too fast for what the fixes show. */
private var slowerSince: Long? = null
fun offer(fix: Fix, nowMs: Long): Decision {
val before = request
val stale = elapsedSinceKeep(nowMs) >= STALENESS_TIMEOUT_MS
if (fix.accM > ACCURACY_CEILING_M && !stale) {
// Too vague to trust, and something better arrived recently enough.
return Decision(null, motion, false)
}
val lowAccuracy = fix.accM > ACCURACY_CEILING_M
// Only fixes that got this far feed the state machine: a fix too vague
// for the trail is also too vague to judge movement by.
updateMotion(speedOf(fix), nowMs)
lastSeen = fix
val after = request
val changed = after != before
val moved = lastKeptMs == null ||
distanceM(lastKeptLatE7, lastKeptLonE7, fix.latE7, fix.lonE7) >= after.minDistanceM
if (!moved && elapsedSinceKeep(nowMs) < HEARTBEAT_MS) {
return Decision(null, motion, changed)
}
lastKeptMs = nowMs
lastKeptLatE7 = fix.latE7
lastKeptLonE7 = fix.lonE7
return Decision(toPoint(fix, lowAccuracy), motion, changed)
}
/** Long.MAX_VALUE before the first keep, so a cold start reports at once. */
private fun elapsedSinceKeep(nowMs: Long): Long =
lastKeptMs?.let { nowMs - it } ?: Long.MAX_VALUE
/**
* Ground speed in m/s, or null when it cannot be told.
*
* Network fixes often carry no speed at all, and without a fallback such a
* device would never leave STATIONARY. Two positions and their timestamps
* are enough of an answer.
*/
private fun speedOf(fix: Fix): Float? {
fix.speedMps?.let { return it }
val prev = lastSeen ?: return null
val dtMs = fix.tsMs - prev.tsMs
if (dtMs <= 0 || dtMs > MAX_DERIVE_GAP_MS) return null
val d = distanceM(prev.latE7, prev.lonE7, fix.latE7, fix.lonE7)
return (d / (dtMs / 1000.0)).toFloat()
}
private fun updateMotion(speedMps: Float?, nowMs: Long) {
val observed = when {
speedMps == null -> return // no evidence either way: leave the mode alone
speedMps >= VEHICLE_SPEED_MPS -> Motion.VEHICLE
speedMps >= WALK_SPEED_MPS -> Motion.WALK
else -> Motion.STATIONARY
}
if (observed.ordinal >= motion.ordinal) {
// Faster, or unchanged. Faster is never delayed: the start of a
// trip is the part a coarse trail misses worst.
motion = observed
slowerSince = null
return
}
if (observed == Motion.STATIONARY && motion.ordinal > Motion.DWELL.ordinal) {
// Stopping enters the grace state at once. It is not yet a claim
// that the journey ended.
motion = Motion.DWELL
slowerSince = nowMs
return
}
val since = slowerSince ?: nowMs.also { slowerSince = it }
if (nowMs - since >= DWELL_TIMEOUT_MS) {
motion = observed
slowerSince = null
}
}
private fun toPoint(fix: Fix, lowAccuracy: Boolean): Point {
var flags = PointFlags.NONE
if (lowAccuracy) flags = flags or PointFlags.LOW_ACCURACY
if (fix.fromNetwork) flags = flags or PointFlags.NETWORK_FIX
if (fix.isMock) flags = flags or PointFlags.MOCK
// CHARGING and batPct are the service's business, not the policy's:
// this class never touches a BatteryManager. Step 9 fills them in.
return Point(
ts = (fix.tsMs / 1000).coerceIn(0L, 0xFFFF_FFFFL),
latE7 = fix.latE7,
lonE7 = fix.lonE7,
accDm = (fix.accM * 10f).roundToInt().coerceAtLeast(0),
altM = fix.altM?.roundToInt(),
spdCms = fix.speedMps?.let { (it * 100f).roundToInt().coerceAtLeast(0) },
brgCdeg = fix.bearingDeg?.let { normalizedCdeg(it) },
flags = flags,
)
}
}
private fun requestFor(motion: Motion): Request = when (motion) {
Motion.VEHICLE -> Request(VEHICLE_INTERVAL_MS, VEHICLE_MIN_DISTANCE_M)
Motion.WALK -> Request(WALK_INTERVAL_MS, WALK_MIN_DISTANCE_M)
Motion.DWELL -> Request(DWELL_INTERVAL_MS, DWELL_MIN_DISTANCE_M)
Motion.STATIONARY -> Request(STATIONARY_INTERVAL_MS, STATIONARY_MIN_DISTANCE_M)
}
/** Bearings arrive as 0..360 but a negative or a 360 must not become 36000,
* which the wire reads as out of range. */
private fun normalizedCdeg(deg: Float): Int {
val wrapped = ((deg % 360f) + 360f) % 360f
return (wrapped * 100f).roundToLong().toInt().coerceIn(0, 35_999)
}
/**
* Equirectangular approximation, in metres.
*
* Exact enough well past a kilometre, which is far beyond any distance this
* policy compares, and it costs one cosine instead of the four transcendentals
* haversine wants on every fix. Ceiling: it is wrong at the poles and across
* the antimeridian, where it reports a huge distance and the gate simply lets
* the fix through.
*/
private fun distanceM(aLatE7: Int, aLonE7: Int, bLatE7: Int, bLonE7: Int): Double {
val lat1 = Math.toRadians(aLatE7 / 1e7)
val lat2 = Math.toRadians(bLatE7 / 1e7)
val dLat = lat2 - lat1
val dLon = Math.toRadians((bLonE7.toLong() - aLonE7.toLong()) / 1e7)
val x = dLon * cos((lat1 + lat2) / 2)
return hypot(x, dLat) * EARTH_RADIUS_M
}
▾Aandroid/app/src/main/java/net/lexcom/opentracker/queue/PointQueue.kt
@@ -0,0 +1,169 @@
package net.lexcom.opentracker.queue
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
import java.util.zip.CRC32
/**
* The crash-durable outbox: a bounded FIFO of sampled points, backed by one file.
*
* The sampling loop appends; the uplink peeks a batch, sends it as a `LOC`, and
* acks it only once the server confirms. The OS can kill this process at any
* instant, including between two bytes of a write, so the file has to be
* recoverable on its own. It carries no journal, no header and no cursor file:
* every slot is self-describing, and the queue state is rebuilt by reading them.
*
* The file is a fixed ring of `capacity` slots of 32 bytes each:
*
* ```text
* off size field meaning
* 0 4 seq u32 BE sequence number, from 1; 0 = slot never written
* 4 24 point the 24-byte record, exactly Point.toBytes()
* 28 4 crc u32 BE CRC32 over bytes 0..28 of this slot
* ```
*
* The CRC is not there to catch bit rot. It is there so a write interrupted
* halfway reads back as an invalid slot rather than as a point built from half
* of one record and half of the previous one. A slot counts as live only if
* `seq != 0` and the CRC matches, so a torn write can only ever lose the point
* being written, never corrupt an older one.
*
* The whole file is preallocated with zeroes on first open. All later writes go
* to blocks that already exist, so a disk that fills up after the queue starts
* cannot make an append fail.
*
* Slot index is `seq % capacity`, so a full ring overwrites its oldest point.
* That is the right thing to drop: an hour-old position nobody has seen is worth
* far less than the current one, and the alternative — refusing new points —
* would blind the user exactly while the network is down.
*
* Not thread-safe. The service's single sampling loop owns the instance and
* calls it from that one thread; peek/ack are only ever paired on that thread.
*/
class PointQueue(file: File, private val capacity: Int) : Closeable {
init {
require(capacity > 0) { "capacity must be positive, got $capacity" }
}
private val raf = RandomAccessFile(file, "rwd")
/** Live sequence numbers, oldest first. Holes appear where a slot was lost. */
private val live = ArrayDeque<Long>()
/** Sequence number the next append will use. */
private var nextSeq = 1L
/** Oldest sequence number handed out by the last [peek]. See [ack]. */
private var peeked: Long? = null
val size: Int get() = live.size
init {
// A length mismatch means the capacity changed between runs. Resizing
// would move every slot's index, so the old contents are discarded.
if (raf.length() != capacity.toLong() * SLOT_LEN) {
raf.setLength(0)
raf.setLength(capacity.toLong() * SLOT_LEN)
}
recover()
}
fun append(point: Point) {
val seq = nextSeq++
val slot = ByteArray(SLOT_LEN)
writeU32(slot, 0, seq)
point.toBytes().copyInto(slot, SEQ_LEN)
writeU32(slot, CRC_OFF, crcOf(slot))
raf.seek(slotOffset(seq))
raf.write(slot)
// The write just landed on the slot holding seq - capacity, so that
// point is gone. It is always the oldest one still live.
if (live.firstOrNull() == seq - capacity) live.removeFirst()
live.addLast(seq)
}
/** The [max] oldest points, oldest first. Nothing is removed. */
fun peek(max: Int): List<Point> {
peeked = live.firstOrNull()
return live.asSequence().take(max.coerceAtLeast(0)).map(::readPoint).toList()
}
/**
* Drop the [count] oldest points, once the server has stored them.
*
* An ack arrives one network round trip after the matching [peek], and the
* sampling loop keeps appending in the meantime. If it appended enough to
* lap the ring, the points being acked have already been overwritten and
* the oldest ones now are points that were never sent. Dropping those would
* lose positions that are still deliverable, so the ack is ignored instead.
* The acked points are gone either way; that loss is the ring overflowing,
* which is already the documented behaviour.
*/
fun ack(count: Int) {
if (live.firstOrNull() != peeked) return
repeat(count.coerceAtMost(live.size)) {
// Zeroing the seq field is a single 4-byte store and is what makes
// the slot free again for recovery. The point bytes stay behind but
// are unreachable, since seq == 0 fails the validity test.
raf.seek(slotOffset(live.removeFirst()))
raf.writeInt(0)
}
}
override fun close() = raf.close()
/**
* Rebuild the live range by reading every slot.
*
* Sequence numbers only ever grow, so the newest valid slot names the head.
* Anything more than `capacity` behind it belongs to a lap of the ring that
* has since been overwritten, and any slot surviving from that lap is a
* stale leftover, not a queued point.
*/
private fun recover() {
val slot = ByteArray(SLOT_LEN)
val found = ArrayList<Long>(capacity)
for (i in 0 until capacity) {
raf.seek(i.toLong() * SLOT_LEN)
raf.readFully(slot)
val seq = readU32(slot, 0)
if (seq != 0L && readU32(slot, CRC_OFF) == crcOf(slot)) found.add(seq)
}
val head = found.maxOrNull() ?: return
found.filterTo(live) { it > head - capacity }
live.sort()
nextSeq = head + 1
}
private fun readPoint(seq: Long): Point {
val record = ByteArray(POINT_LEN)
raf.seek(slotOffset(seq) + SEQ_LEN)
raf.readFully(record)
return Point.fromBytes(record)
}
private fun slotOffset(seq: Long) = (seq % capacity) * SLOT_LEN
private companion object {
const val SEQ_LEN = 4
const val CRC_OFF = SEQ_LEN + POINT_LEN
const val SLOT_LEN = CRC_OFF + 4
fun crcOf(slot: ByteArray): Long =
CRC32().apply { update(slot, 0, CRC_OFF) }.value
fun writeU32(b: ByteArray, off: Int, v: Long) {
for (i in 0 until 4) b[off + i] = (v ushr (24 - 8 * i)).toByte()
}
fun readU32(b: ByteArray, off: Int): Long {
var v = 0L
for (i in 0 until 4) v = (v shl 8) or (b[off + i].toLong() and 0xFF)
return v
}
}
}
▾Aandroid/app/src/test/java/net/lexcom/opentracker/PointQueueTest.kt
@@ -0,0 +1,150 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.wire.Point
import java.io.File
import java.io.RandomAccessFile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The queue's contract under the two things that actually happen to a phone:
* being killed mid-write, and running out of ring while the network is down.
*/
class PointQueueTest {
private val file = File.createTempFile("pointqueue", ".bin").also { it.delete() }
@AfterTest
fun cleanUp() {
file.delete()
}
/** Distinct enough that a mixed-up order or a stale slot is visible. */
private fun point(i: Int) = Point(ts = 1_785_000_000L + i, latE7 = 525_200_000 + i, lonE7 = i)
private fun open(capacity: Int) = PointQueue(file, capacity)
private fun slotSize() = 32L
@Test
fun `a fresh file is empty`() {
open(8).use { q ->
assertEquals(0, q.size)
assertEquals(emptyList(), q.peek(10))
}
assertEquals(8 * slotSize(), file.length())
}
@Test
fun `points come back in the order they were appended`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
assertEquals(5, q.size)
assertEquals(List(5) { point(it) }, q.peek(10))
}
}
@Test
fun `peek returns at most max and never more than it holds`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
assertEquals(List(2) { point(it) }, q.peek(2))
assertEquals(5, q.peek(99).size)
assertEquals(emptyList(), q.peek(0))
// Peeking does not consume.
assertEquals(5, q.size)
}
}
@Test
fun `ack removes exactly the acked prefix`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
q.peek(2)
q.ack(2)
assertEquals(3, q.size)
assertEquals(listOf(point(2), point(3), point(4)), q.peek(10))
q.ack(99) // over-acking drains rather than throwing
assertEquals(0, q.size)
}
}
@Test
fun `an ack is ignored when the ring lapped past the peeked batch`() {
open(4).use { q ->
repeat(3) { q.append(point(it)) }
assertEquals(listOf(point(0), point(1)), q.peek(2))
// The send is in flight while sampling keeps going, and the ring
// wraps past everything that was peeked.
repeat(4) { q.append(point(10 + it)) }
q.ack(2)
// Points 10 and 11 were never sent, so they must still be here.
assertEquals(listOf(point(10), point(11), point(12), point(13)), q.peek(10))
}
}
@Test
fun `reopening recovers the unacked points in order`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
q.peek(2)
q.ack(2)
}
open(8).use { q ->
assertEquals(3, q.size)
assertEquals(listOf(point(2), point(3), point(4)), q.peek(10))
// The recovered sequence continues, it does not restart over a live slot.
q.append(point(9))
assertEquals(listOf(point(2), point(3), point(4), point(9)), q.peek(10))
}
}
@Test
fun `a torn write is not read back as a point`() {
open(8).use { q -> repeat(3) { q.append(point(it)) } }
// Point 1 lands in slot 2 (seq 2). Damage its CRC the way a kill between
// two writes would: the slot is no longer self-consistent.
RandomAccessFile(file, "rwd").use {
it.seek(2 * slotSize() + 28)
it.writeInt(0x0BADC0DE)
}
open(8).use { q ->
assertEquals(2, q.size)
assertEquals(listOf(point(0), point(2)), q.peek(10))
}
}
@Test
fun `a full ring drops the oldest points and keeps the newest`() {
val capacity = 4
open(capacity).use { q ->
repeat(capacity + 5) { q.append(point(it)) }
assertEquals(capacity, q.size)
assertEquals(listOf(point(5), point(6), point(7), point(8)), q.peek(10))
}
// The overwrite is durable, not just an in-memory bookkeeping trick.
open(capacity).use { q ->
assertEquals(listOf(point(5), point(6), point(7), point(8)), q.peek(10))
}
}
@Test
fun `the file never grows beyond its preallocated size`() {
val capacity = 4
open(capacity).use { q -> repeat(100) { q.append(point(it)) } }
assertEquals(capacity * slotSize(), file.length())
}
@Test
fun `a file written with another capacity is discarded rather than misread`() {
open(8).use { q -> repeat(5) { q.append(point(it)) } }
open(16).use { q ->
// Slot indices would all move, so keeping the old bytes would hand
// back points in the wrong order. Starting empty is the honest answer.
assertEquals(0, q.size)
}
assertEquals(16 * slotSize(), file.length())
}
}
▾Aandroid/app/src/test/java/net/lexcom/opentracker/SamplingPolicyTest.kt
@@ -0,0 +1,170 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.loc.DWELL_TIMEOUT_MS
import net.lexcom.opentracker.loc.Fix
import net.lexcom.opentracker.loc.HEARTBEAT_MS
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.loc.STALENESS_TIMEOUT_MS
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.wire.PointFlags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The policy is a state machine over a fake clock: every test passes its own
* `nowMs`, so nothing here waits and nothing here needs an emulator.
*/
class SamplingPolicyTest {
private val baseLatE7 = 525_200_080
private val baseLonE7 = 134_050_000
private val baseTsMs = 1_785_000_042_000L
/** ~1.11 cm per 1e-7 degree of latitude, so this is metres north. */
private fun northE7(metres: Double): Int = (metres / 0.0111).toInt()
private fun fix(
atMs: Long,
northM: Double = 0.0,
accM: Float = 8f,
speedMps: Float? = null,
altM: Float? = null,
bearingDeg: Float? = null,
fromNetwork: Boolean = false,
isMock: Boolean = false,
) = Fix(
tsMs = baseTsMs + atMs,
latE7 = baseLatE7 + northE7(northM),
lonE7 = baseLonE7,
accM = accM,
speedMps = speedMps,
altM = altM,
bearingDeg = bearingDeg,
fromNetwork = fromNetwork,
isMock = isMock,
)
@Test
fun `a device that reports no speed still leaves STATIONARY`() {
// Network-only fixes carry no speed field. The policy derives one from
// two positions, and the gap it tolerates has to cover a full
// STATIONARY sampling interval or the device would never wake up.
val policy = SamplingPolicy()
val interval = policy.request.intervalMs
policy.offer(fix(0, fromNetwork = true), 0)
// One interval later, 3 km further north: unmistakably driving.
val d = policy.offer(fix(interval, northM = 3_000.0, fromNetwork = true), interval)
assertEquals(Motion.VEHICLE, d.motion)
assertNotNull(d.keep)
}
@Test
fun `a sustained fast speed enters VEHICLE and shortens the request interval`() {
val policy = SamplingPolicy()
val idle = policy.request
var d = policy.offer(fix(0, speedMps = 12f), 0)
assertEquals(Motion.VEHICLE, d.motion)
assertTrue(d.requestChanged)
d = policy.offer(fix(5_000, northM = 60.0, speedMps = 12f), 5_000)
assertEquals(Motion.VEHICLE, d.motion)
assertTrue(policy.request.intervalMs < idle.intervalMs)
assertTrue(policy.request.minDistanceM < idle.minDistanceM)
}
@Test
fun `movement stops but the mode stays in DWELL until the dwell timeout`() {
val policy = SamplingPolicy()
policy.offer(fix(0, speedMps = 2f), 0)
assertEquals(Motion.WALK, policy.motion)
val stopped = 10_000L
assertEquals(Motion.DWELL, policy.offer(fix(stopped, speedMps = 0f), stopped).motion)
val nearly = stopped + DWELL_TIMEOUT_MS - 1
assertEquals(Motion.DWELL, policy.offer(fix(nearly, speedMps = 0f), nearly).motion)
val over = stopped + DWELL_TIMEOUT_MS
assertEquals(Motion.STATIONARY, policy.offer(fix(over, speedMps = 0f), over).motion)
}
@Test
fun `entering a faster mode takes one fix but leaving one does not`() {
val policy = SamplingPolicy()
assertEquals(Motion.VEHICLE, policy.offer(fix(0, speedMps = 30f), 0).motion)
// Walking pace while in VEHICLE is a traffic jam until it persists.
assertEquals(Motion.VEHICLE, policy.offer(fix(1_000, speedMps = 2f), 1_000).motion)
val over = 1_000 + DWELL_TIMEOUT_MS
assertEquals(Motion.WALK, policy.offer(fix(over, northM = 400.0, speedMps = 2f), over).motion)
}
@Test
fun `a fix worse than the accuracy ceiling is discarded`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0), 0).keep)
assertNull(policy.offer(fix(1_000, northM = 400.0, accM = 300f), 1_000).keep)
}
@Test
fun `a coarse fix is kept with LOW_ACCURACY once nothing has been kept for the staleness timeout`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0), 0).keep)
val late = STALENESS_TIMEOUT_MS
val kept = policy.offer(fix(late, northM = 400.0, accM = 300f), late).keep
assertNotNull(kept)
assertEquals(PointFlags.LOW_ACCURACY, kept.flags and PointFlags.LOW_ACCURACY)
}
@Test
fun `a fix within the mode min distance of the last kept point is discarded`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0, speedMps = 0f), 0).keep)
assertNull(policy.offer(fix(60_000, northM = 4.0, speedMps = 0f), 60_000).keep)
}
@Test
fun `the heartbeat interval still reports a parked phone`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0, speedMps = 0f), 0).keep)
val late = HEARTBEAT_MS
assertNotNull(policy.offer(fix(late, northM = 4.0, speedMps = 0f), late).keep)
}
@Test
fun `a fix converts to a point with the documented units`() {
val policy = SamplingPolicy()
val kept = policy.offer(
fix(0, accM = 12.3f, speedMps = 5.5f, altM = 33.4f, bearingDeg = 91.5f),
0,
).keep
assertNotNull(kept)
assertEquals(1_785_000_042L, kept.ts)
assertEquals(baseLatE7, kept.latE7)
assertEquals(123, kept.accDm)
assertEquals(550, kept.spdCms)
assertEquals(9_150, kept.brgCdeg)
assertEquals(33, kept.altM)
// Battery belongs to the service, not the policy.
assertNull(kept.batPct)
}
@Test
fun `unmeasured fields stay null and a wrapped bearing stays in range`() {
val policy = SamplingPolicy()
val kept = policy.offer(fix(0, bearingDeg = 360f), 0).keep
assertNotNull(kept)
assertNull(kept.spdCms)
assertNull(kept.altM)
assertEquals(0, kept.brgCdeg)
}
@Test
fun `network and mock flags come from the fix`() {
val policy = SamplingPolicy()
val kept = policy.offer(fix(0, fromNetwork = true, isMock = true), 0).keep
assertNotNull(kept)
assertEquals(PointFlags.NETWORK_FIX or PointFlags.MOCK, kept.flags)
}
}