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:
Tizian 「ティツィアーン」 2026-10-30 12:55:32 +01:00
commit 236ac95eab
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 164 additions and 28 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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) }

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}
}