fix(train): a sensor's count reset is no rollover, and a plan's metres are the wheel's on a trainer (ADR 0030, D49, D51)
A speed sensor resetting its 32-bit count after sleep or a new battery had its drop read as a rollover, some 4.29 billion turns; a crank's after more than 64 s coasting a cadence of its wrapped time. A plan's distance steps and the km announced took GPS or the rower alone, so on a trainer they never came, and the rower's counted its paused metres; the fallback announcement said 0 km for half a kilometre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
acb291afcb
commit
236ac95eab
8 changed files with 164 additions and 28 deletions
|
|
@ -74,8 +74,11 @@ data class AnnounceWords(
|
|||
|
||||
/** The line said for [announcement], with the heart rate [bpm] where one came lately. */
|
||||
fun say(announcement: Announcement, bpm: Int?): String {
|
||||
// Past the spelled ones in km, fractions kept: every 500 m's first is 0.5 km.
|
||||
val km = announcement.count * every / 1000.0
|
||||
val distance =
|
||||
distances.getOrNull(announcement.count - 1) ?: "${announcement.count * every / 1000} km"
|
||||
distances.getOrNull(announcement.count - 1)
|
||||
?: "${NumberFormat.getNumberInstance(locale ?: Locale.getDefault()).format(km)} km"
|
||||
val parts = mutableListOf(distance, time.replace("{time}", spoken(announcement.movingMs)))
|
||||
if (announcement.splitMs > 0 && announcement.splitMetres > 0) {
|
||||
pace?.let {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ object PowerMeasurement {
|
|||
/**
|
||||
* The crank's cadence, in turns a minute, from its cumulative revolutions and last event's time
|
||||
* (16 bits each, 1/1024 s) as a sensor or a power meter sends them: one new for each new event,
|
||||
* 0 once no new revolution came for [coastMs], none otherwise.
|
||||
* 0 once no new revolution came for [coastMs], none otherwise. An event over 64 s after the last,
|
||||
* past what the time can span before it wraps, or one past [MAX_RPM], a count reset, starts afresh
|
||||
* from its frame.
|
||||
*/
|
||||
class CrankCadence(private val coastMs: Long = 3_000) {
|
||||
private var last: Pair<Int, Int>? = null
|
||||
|
|
@ -85,13 +87,28 @@ class CrankCadence(private val coastMs: Long = 3_000) {
|
|||
}
|
||||
return null
|
||||
}
|
||||
val since = at - lastNewAt
|
||||
lastNewAt = at
|
||||
val rpm = (turns * 60 * 1024.0 / ticks).toInt()
|
||||
if (since >= SPAN_MS || rpm > MAX_RPM) return null
|
||||
coasting = false
|
||||
return (turns * 60 * 1024.0 / ticks).toInt()
|
||||
return rpm
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** What the 16-bit time spans in 1/1024 s before it wraps. */
|
||||
const val SPAN_MS = 65_536 * 1000L / 1024
|
||||
|
||||
/** More than any rider turns: a sensor that reset its count, gone back to 0. */
|
||||
const val MAX_RPM = 250
|
||||
}
|
||||
}
|
||||
|
||||
/** The wheel's revolutions counted from the first frame on (32 bits as sent); none until they grew. */
|
||||
/**
|
||||
* The wheel's revolutions counted from the first frame on; none until they grew. A count gone
|
||||
* down is a sensor that reset it (asleep, a new battery, paired anew), counting on from 0 as a
|
||||
* rower's console does (`Counted`): its 32 bits roll over only after some 9 million km.
|
||||
*/
|
||||
class WheelCount {
|
||||
private var last: Long? = null
|
||||
private var total = 0L
|
||||
|
|
@ -100,7 +117,7 @@ class WheelCount {
|
|||
val before = last
|
||||
last = revolutions
|
||||
if (before == null) return null
|
||||
val grown = (revolutions - before) and 0xFFFF_FFFFL
|
||||
val grown = if (revolutions < before) revolutions else revolutions - before
|
||||
if (grown == 0L) return null
|
||||
total += grown
|
||||
return total
|
||||
|
|
@ -109,9 +126,9 @@ class WheelCount {
|
|||
|
||||
/**
|
||||
* The wheel's speed (ADR 0030, D51), in metres a second, from its
|
||||
* revolutions over the 32-bit counter's rollover, each over at least half a
|
||||
* second since the last, by its [mm] round; null for the first frame and
|
||||
* those too soon after one.
|
||||
* revolutions, each over at least half a second since the last, by its [mm]
|
||||
* round; null for the first frame, those too soon after one, and the one its
|
||||
* count reset at (as [WheelCount] has it), when within the span unknown.
|
||||
*/
|
||||
class WheelSpeed(private val mm: Int) {
|
||||
private var lastRevolutions: Long? = null
|
||||
|
|
@ -123,9 +140,8 @@ class WheelSpeed(private val mm: Int) {
|
|||
lastRevolutions = revolutions
|
||||
val span = at - lastAt
|
||||
lastAt = at
|
||||
if (before == null || span <= 0) return null
|
||||
val grown = (revolutions - before) and 0xFFFF_FFFFL
|
||||
return grown * mm / 1000.0 / (span / 1000.0)
|
||||
if (before == null || span <= 0 || revolutions < before) return null
|
||||
return (revolutions - before) * mm / 1000.0 / (span / 1000.0)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
|
|
|||
|
|
@ -119,9 +119,10 @@ class PlanRunner(
|
|||
|
||||
/**
|
||||
* What a plan's targets are measured by, as the recorder sees it: the
|
||||
* distance through fixes precise enough and not paused (as the app counts
|
||||
* it, D20), or the rower's metres; the pace and speed over the last 30 s;
|
||||
* the last heart rate, stroke rate and watts, each fresh for 5 s.
|
||||
* distance as the app saves it (`distanceOf`): the rower's metres, the
|
||||
* wheel's where GPS fell short of it (D49), else through fixes precise
|
||||
* enough, each counted while not paused (D20); the pace and speed over the
|
||||
* last 30 s; the last heart rate, stroke rate and watts, each fresh for 5 s.
|
||||
*/
|
||||
class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Long = 5_000) {
|
||||
private data class At(val at: Long, val metres: Double)
|
||||
|
|
@ -130,15 +131,26 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
private var lastLon: Double? = null
|
||||
private var paused = false
|
||||
private var gpsMetres = 0.0
|
||||
private var lastRowed: Int? = null
|
||||
private var rowedMetres: Double? = null
|
||||
private var lastWheel: Long? = null
|
||||
private var wheelTurns = 0L
|
||||
private val recent = ArrayDeque<At>()
|
||||
private var heart: Pair<Long, Int>? = null
|
||||
private var rowing: Triple<Long, Double?, Int?>? = null
|
||||
private var power: Pair<Long, Int>? = null
|
||||
|
||||
/** The wheel's circumference in millimetres (D49); null: no wheel distance, as in the app. */
|
||||
var wheelMm: Int? = null
|
||||
|
||||
/** How far it went so far. */
|
||||
val metres: Double
|
||||
get() = rowedMetres ?: gpsMetres
|
||||
get() {
|
||||
rowedMetres?.let { return it }
|
||||
val wheel = wheelMm?.let { wheelTurns * it / 1000.0 }
|
||||
// The app's `usesWheel`: GPS below 90 % of the wheel couldn't follow it (a trainer, a tunnel).
|
||||
return if (wheel != null && wheel > 0 && gpsMetres < wheel * WHEEL_SHARE) wheel else gpsMetres
|
||||
}
|
||||
|
||||
fun fix(at: Long, lat: Double, lon: Double, accuracyCm: Long) {
|
||||
if (paused || accuracyCm > MAX_ACCURACY_CM) return
|
||||
|
|
@ -158,14 +170,26 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
recent.clear()
|
||||
}
|
||||
|
||||
/** A rower's frame, [metres] its total from the first: its growth counted while not paused, as the app's. */
|
||||
fun rowed(at: Long, metres: Int?, strokeRate: Double?, watts: Int?) {
|
||||
metres?.let {
|
||||
rowedMetres = it.toDouble()
|
||||
val before = lastRowed
|
||||
lastRowed = it
|
||||
val grown = if (before != null && !paused && it > before) it - before else 0
|
||||
rowedMetres = (rowedMetres ?: 0.0) + grown
|
||||
remember(at)
|
||||
}
|
||||
rowing = Triple(at, strokeRate, watts)
|
||||
}
|
||||
|
||||
/** The wheel's [revolutions] from its first frame (`WheelCount`): their growth counted while not paused. */
|
||||
fun wheeled(at: Long, revolutions: Long) {
|
||||
val before = lastWheel
|
||||
lastWheel = revolutions
|
||||
if (before != null && !paused && revolutions > before) wheelTurns += revolutions - before
|
||||
remember(at)
|
||||
}
|
||||
|
||||
/** The bike's power meter's watts (D49), which a watts target reads as it reads the rower's. */
|
||||
fun powered(at: Long, watts: Int) {
|
||||
power = at to watts
|
||||
|
|
@ -210,6 +234,9 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
/** The app's limit for a fix counted (D20). */
|
||||
const val MAX_ACCURACY_CM = 2000L
|
||||
|
||||
/** The app's share of the wheel's distance GPS must reach to give the distance (D49). */
|
||||
const val WHEEL_SHARE = 0.9
|
||||
|
||||
fun metresBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val p1 = Math.toRadians(lat1)
|
||||
val p2 = Math.toRadians(lat2)
|
||||
|
|
|
|||
|
|
@ -356,8 +356,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
// What the app works the wheel's distance out by (D49).
|
||||
intent.getIntExtra(EXTRA_WHEEL_MM, 0).takeIf { it > 0 }?.let { mm ->
|
||||
write("wheelsize $now $mm")
|
||||
// And auto-pause by it (D51).
|
||||
// And auto-pause by it (D51), and the plan's distance where GPS falls short.
|
||||
wheelSpeed = WheelSpeed(mm)
|
||||
measures.wheelMm = mm
|
||||
}
|
||||
speedSensor =
|
||||
NotifyLink(
|
||||
|
|
@ -632,7 +633,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private fun sensed(csc: Csc) {
|
||||
val at = System.currentTimeMillis()
|
||||
csc.wheelRevolutions?.let { revolutions ->
|
||||
wheel.add(revolutions)?.let { write("w $at $it") }
|
||||
wheel.add(revolutions)?.let {
|
||||
write("w $at $it")
|
||||
measures.wheeled(at, it)
|
||||
}
|
||||
// Auto-pause by the wheel while it sends (D51).
|
||||
wheelSpeed?.add(at, revolutions)?.let { metresPerSecond ->
|
||||
autoPause?.wheel(at, metresPerSecond)?.let { paused -> mark(paused, auto = true, at = at) }
|
||||
|
|
|
|||
|
|
@ -105,4 +105,13 @@ class AnnouncerTest {
|
|||
val far = ride.say(Announcement(count = 2, movingMs = 1_200_000, splitMs = 600_000, splitMetres = 5000.0), bpm = null)
|
||||
assertEquals("10 km. Time 20 minutes. Tempo 30,0 Kilometer pro Stunde.", far)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a distance past the spelled ones under a whole km says its fraction`() {
|
||||
val half = run.copy(every = 500, distances = emptyList(), pace = null, locale = Locale.GERMANY)
|
||||
val line = half.say(Announcement(count = 1, movingMs = 120_000, splitMs = 120_000, splitMetres = 500.0), bpm = null)
|
||||
assertEquals("0,5 km. Time 2 minutes.", line)
|
||||
val three = half.say(Announcement(count = 6, movingMs = 720_000, splitMs = 120_000, splitMetres = 500.0), bpm = null)
|
||||
assertEquals("3 km. Time 12 minutes.", three)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,12 +120,15 @@ class AutoPauseTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel's speed is its turns by its round over the time between frames, past the rollover`() {
|
||||
fun `the wheel's speed is its turns by its round over the time between frames, none where its count reset`() {
|
||||
val speed = WheelSpeed(2_100)
|
||||
assertNull(speed.add(0, 0xFFFF_FFFEL))
|
||||
assertNull(speed.add(200, 0xFFFF_FFFFL))
|
||||
assertEquals(8.4, speed.add(1_000, 2L)!!, 0.001)
|
||||
assertEquals(0.0, speed.add(2_000, 2L)!!, 0.001)
|
||||
assertNull(speed.add(0, 12_340L))
|
||||
assertNull(speed.add(200, 12_341L))
|
||||
assertEquals(8.4, speed.add(1_000, 12_344L)!!, 0.001)
|
||||
assertEquals(0.0, speed.add(2_000, 12_344L)!!, 0.001)
|
||||
// A sensor that reset its count: no speed for that frame, then on from there.
|
||||
assertNull(speed.add(3_000, 2L))
|
||||
assertEquals(8.4, speed.add(4_000, 6L)!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -58,11 +58,42 @@ class CyclingSensorsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel's revolutions counted from the first frame, over the rollover`() {
|
||||
fun `a coast past the crank time's 64 s span starts the cadence afresh`() {
|
||||
val crank = CrankCadence()
|
||||
assertNull(crank.add(at = 0, revolutions = 10, time = 0))
|
||||
assertEquals(0, crank.add(at = 3_000, revolutions = 10, time = 0))
|
||||
// 64.5 s on, one turn: the time wrapped, its 512 ticks say nothing of the gap.
|
||||
assertNull(crank.add(at = 64_500, revolutions = 11, time = 512))
|
||||
assertEquals(60, crank.add(at = 65_500, revolutions = 12, time = 1536))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a crank count reset starts the cadence afresh`() {
|
||||
val crank = CrankCadence()
|
||||
assertNull(crank.add(at = 0, revolutions = 5_000, time = 10_000))
|
||||
assertEquals(60, crank.add(at = 1_000, revolutions = 5_001, time = 11_024))
|
||||
// The sensor slept and woke at 2 turns: no 60 000 rpm.
|
||||
assertNull(crank.add(at = 2_000, revolutions = 2, time = 300))
|
||||
assertEquals(60, crank.add(at = 3_000, revolutions = 3, time = 1_324))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel's revolutions counted from the first frame, a reset counting on from there`() {
|
||||
val wheel = WheelCount()
|
||||
assertNull(wheel.add(0xFFFF_FFF0L))
|
||||
assertEquals(16L, wheel.add(0L))
|
||||
assertNull(wheel.add(0L))
|
||||
assertEquals(26L, wheel.add(10L))
|
||||
assertNull(wheel.add(12_335L))
|
||||
assertEquals(10L, wheel.add(12_345L))
|
||||
assertNull(wheel.add(12_345L))
|
||||
// The sensor reset (sleep, a new battery): its 3 turns since, not 4.29 billion.
|
||||
assertEquals(13L, wheel.add(3L))
|
||||
assertEquals(23L, wheel.add(13L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel's speed, none for the frame its count reset at`() {
|
||||
val speed = WheelSpeed(mm = 2_000)
|
||||
assertNull(speed.add(at = 0, revolutions = 12_345L))
|
||||
assertEquals(10.0, speed.add(at = 1_000, revolutions = 12_350L)!!, 1e-9)
|
||||
assertNull(speed.add(at = 2_000, revolutions = 3L))
|
||||
assertEquals(10.0, speed.add(at = 3_000, revolutions = 8L)!!, 1e-9)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,4 +111,47 @@ class PlanRunnerTest {
|
|||
assertEquals(150.0, measures.measure("heartRate", 25_000)!!, 0.0)
|
||||
assertNull(measures.measure("watts", 26_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rower's distance leaves out the metres rowed while paused, as the saved one does`() {
|
||||
val measures = PlanMeasures()
|
||||
measures.rowed(0, metres = 0, strokeRate = 24.0, watts = 180)
|
||||
measures.rowed(10_000, metres = 50, strokeRate = 24.0, watts = 180)
|
||||
measures.pause(true)
|
||||
measures.rowed(20_000, metres = 80, strokeRate = 24.0, watts = 180)
|
||||
measures.pause(false)
|
||||
measures.rowed(30_000, metres = 130, strokeRate = 24.0, watts = 180)
|
||||
assertEquals(100.0, measures.metres, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on a trainer the wheel's metres stand for the distance, its paused turns left out`() {
|
||||
val measures = PlanMeasures()
|
||||
measures.wheelMm = 2_000
|
||||
// The sensor's totals (WheelCount): 10 m/s, no fix at all.
|
||||
for (s in 0..20) measures.wheeled(s * 1000L, revolutions = s * 5L)
|
||||
assertEquals(200.0, measures.metres, 0.0)
|
||||
assertEquals(36.0, measures.measure("speed", 20_000)!!, 1e-9)
|
||||
measures.pause(true)
|
||||
measures.wheeled(21_000, revolutions = 110L)
|
||||
measures.pause(false)
|
||||
measures.wheeled(22_000, revolutions = 115L)
|
||||
assertEquals(210.0, measures.metres, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel stands for the distance only where GPS fell short of 90 % of it, as the app has it`() {
|
||||
val road = PlanMeasures()
|
||||
road.wheelMm = 2_000
|
||||
// About 11.1 m a fix, and the wheel's 10 m a second: GPS's distance holds.
|
||||
for (s in 0..20) {
|
||||
road.fix(s * 1000L, 52.0 + s * 0.0001, 13.0, accuracyCm = 500)
|
||||
road.wheeled(s * 1000L, revolutions = s * 5L)
|
||||
}
|
||||
assertEquals(222.4, road.metres, 0.5)
|
||||
// Without its size the wheel gives no distance, as in the app.
|
||||
val unsized = PlanMeasures()
|
||||
for (s in 0..20) unsized.wheeled(s * 1000L, revolutions = s * 5L)
|
||||
assertEquals(0.0, unsized.metres, 0.0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue