feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)

The plan chosen on the Record sheet goes to the recording service spelled
out: each step's end, its target (zones as the heart rates they span for
the profile's highest rate) and its spoken line in the app's language.
PlanRunner.kt moves through the steps on the moving time and distance,
counts a timed step's last seconds down and cues off target from a 30 s
window; PlanCues.kt gives a tone, a vibration and the voice, ducking
music. The file's plan and step lines bring it back, and the recording
screen shows the step, what is left of it, its target, the next step and
Lap.

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-28 22:59:48 +01:00
commit d9f98449a8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 1886 additions and 26 deletions

View file

@ -16,6 +16,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- A plan's cues (ADR 0030, D42). -->
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- The heart-rate strap and the rower (ADR 0030, D26 to D28): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. -->
@ -112,6 +114,10 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- A text-to-speech engine for a plan's spoken cues (D42). -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
<!-- Health Connect as an app, before Android 14. -->
<package android:name="com.google.android.apps.healthdata"/>
<!-- The apps writing workouts to Health Connect that Train names by

View file

@ -57,9 +57,11 @@ class MainActivity : FlutterActivity() {
strap = call.argument<String>("strap"),
rower = call.argument<String>("rower"),
loop = call.argument<String>("loop"),
plan = call.argument<String>("plan"),
)
result.success(null)
}
"lap" -> result.success(RecorderService.lap().let { null })
"pause" -> result.success(RecorderService.pause().let { null })
"resume" -> result.success(RecorderService.resume().let { null })
"stop" -> result.success(RecorderService.stop().let { null })

View file

@ -0,0 +1,98 @@
package app.halkyon.train
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import java.util.Locale
/**
* A plan's cues (ADR 0030, D42): a tone and a vibration as a step changes, a
* short tone for each of a timed step's last seconds, a double vibration off
* target, and a spoken line where the phone has a text-to-speech engine
* (GrapheneOS ships none; RHVoice or eSpeak give one), other sound ducked
* while it speaks.
*/
class PlanCues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
private val audio = context.getSystemService(AudioManager::class.java)
private val tones = runCatching { ToneGenerator(AudioManager.STREAM_MUSIC, 90) }.getOrNull()
private val vibrator: Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
}
private val language = locale?.let(Locale::forLanguageTag)
private val speech = TextToSpeech(context.applicationContext, this)
private var speaks = false
private val attributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
private val focus =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK).setAudioAttributes(attributes).build()
override fun onInit(status: Int) {
if (status != TextToSpeech.SUCCESS) return
val available = language?.let { speech.isLanguageAvailable(it) >= TextToSpeech.LANG_AVAILABLE } ?: true
if (language != null && available) speech.language = language
speech.setAudioAttributes(attributes)
speech.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
audio.abandonAudioFocusRequest(focus)
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
audio.abandonAudioFocusRequest(focus)
}
},
)
speaks = available
}
/** A step changed, or the plan is done; [say] spoken. */
fun change(say: String) {
tones?.startTone(ToneGenerator.TONE_PROP_BEEP2, 400)
vibrate(longArrayOf(0, 400))
speak(say)
}
fun countdown() {
tones?.startTone(ToneGenerator.TONE_PROP_BEEP, 150)
}
fun offTarget(say: String) {
vibrate(longArrayOf(0, 150, 120, 150))
speak(say)
}
fun close() {
speech.stop()
speech.shutdown()
tones?.release()
audio.abandonAudioFocusRequest(focus)
}
private fun speak(text: String) {
if (!speaks || text.isBlank()) return
audio.requestAudioFocus(focus)
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "plan")
}
private fun vibrate(pattern: LongArray) {
vibrator?.takeIf { it.hasVibrator() }?.vibrate(VibrationEffect.createWaveform(pattern, -1))
}
}

View file

@ -0,0 +1,210 @@
package app.halkyon.train
import kotlin.math.asin
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/** A step as the recorder runs it (ADR 0030, D42): how it ends, its target, and what is said as it starts. */
data class RunStep(
val seconds: Int? = null,
val metres: Int? = null,
val target: RunTarget? = null,
val say: String = "",
)
/** A target's range, its kind one of `pace`, `speed`, `heartRate`, `split`, `watts`, `strokeRate` (zones come as heart rates). */
data class RunTarget(val kind: String, val low: Double, val high: Double) {
/** Whether a lower figure is the harder: a pace's, a split's. */
val lowerIsHarder: Boolean
get() = kind == "pace" || kind == "split"
}
/** What running a plan gives, for the recorder to write and cue. */
sealed interface PlanEvent {
data class Started(val index: Int) : PlanEvent
/** The last seconds of a timed step: 3, 2, 1. */
data class Countdown(val left: Int) : PlanEvent
/** Off target: [harder] where the person should push, else ease off. */
data class OffTarget(val harder: Boolean) : PlanEvent
data object Done : PlanEvent
}
/**
* Runs a plan's steps on a recording's moving time and distance (D42): a
* timed step ends once it has moved that long, a measured one once it went
* that far, any step with a lap tap; the last three seconds of a timed step
* are counted down; off target, a cue at most every [offTargetEveryMs],
* none in a step's first [graceMs], where [offTargetCues].
*/
class PlanRunner(
private val steps: List<RunStep>,
private val offTargetCues: Boolean,
private val graceMs: Long = 15_000,
private val offTargetEveryMs: Long = 30_000,
) {
var index = -1
private set
private var lastTick = 0L
private var wasPaused = false
private var stepMovingMs = 0L
private var stepFromMetres = 0.0
private var counted = mutableSetOf<Int>()
private var lastOffTarget: Long? = null
private var done = false
/** Starts the first step at [now], [metres] gone so far. */
fun start(now: Long, metres: Double): List<PlanEvent> {
lastTick = now
return next(metres)
}
/**
* Moves on to [now]: [paused] as the recording is now, [metres] gone in
* all, [measure] the figure a target's kind has now (null: none).
*/
fun tick(now: Long, paused: Boolean, metres: Double, measure: (String) -> Double?): List<PlanEvent> {
if (done || index < 0) return emptyList()
if (!wasPaused) stepMovingMs += now - lastTick
lastTick = now
wasPaused = paused
val step = steps[index]
val events = mutableListOf<PlanEvent>()
step.seconds?.let { seconds ->
val leftMs = seconds * 1000L - stepMovingMs
if (leftMs <= 0) return next(metres)
val left = ((leftMs + 999) / 1000).toInt()
if (left <= 3 && counted.add(left)) events += PlanEvent.Countdown(left)
}
step.metres?.let { if (metres - stepFromMetres >= it) return next(metres) }
val target = step.target
if (target != null && offTargetCues && !paused && stepMovingMs >= graceMs) {
val value = measure(target.kind)
val since = lastOffTarget
if (value != null && (since == null || now - since >= offTargetEveryMs)) {
val below = value < target.low
val above = value > target.high
if (below || above) {
lastOffTarget = now
events += PlanEvent.OffTarget(harder = if (target.lowerIsHarder) above else below)
}
}
}
return events
}
/** The person tapped Lap: the step ends, whatever it ends by. */
fun lap(metres: Double): List<PlanEvent> = if (done || index < 0) emptyList() else next(metres)
private fun next(metres: Double): List<PlanEvent> {
index++
stepMovingMs = 0
stepFromMetres = metres
counted = mutableSetOf()
lastOffTarget = null
if (index >= steps.size) {
done = true
return listOf(PlanEvent.Done)
}
return listOf(PlanEvent.Started(index))
}
}
/**
* 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.
*/
class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Long = 5_000) {
private data class At(val at: Long, val metres: Double)
private var lastLat: Double? = null
private var lastLon: Double? = null
private var paused = false
private var gpsMetres = 0.0
private var rowedMetres: Double? = null
private val recent = ArrayDeque<At>()
private var heart: Pair<Long, Int>? = null
private var rowing: Triple<Long, Double?, Int?>? = null
/** How far it went so far. */
val metres: Double
get() = rowedMetres ?: gpsMetres
fun fix(at: Long, lat: Double, lon: Double, accuracyCm: Long) {
if (paused || accuracyCm > MAX_ACCURACY_CM) return
val fromLat = lastLat
val fromLon = lastLon
if (fromLat != null && fromLon != null) gpsMetres += metresBetween(fromLat, fromLon, lat, lon)
lastLat = lat
lastLon = lon
remember(at)
}
fun pause(paused: Boolean) {
this.paused = paused
// A pause breaks the line, as the app's distance does.
lastLat = null
lastLon = null
recent.clear()
}
fun rowed(at: Long, metres: Int?, strokeRate: Double?, watts: Int?) {
metres?.let {
rowedMetres = it.toDouble()
remember(at)
}
rowing = Triple(at, strokeRate, watts)
}
fun heart(at: Long, bpm: Int) {
heart = at to bpm
}
/** The figure [kind] has at [now]; null where there is none. */
fun measure(kind: String, now: Long): Double? =
when (kind) {
"heartRate" -> heart?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
"strokeRate" -> rowing?.takeIf { now - it.first <= freshMs }?.second
"watts" -> rowing?.takeIf { now - it.first <= freshMs }?.third?.toDouble()
else -> {
val first = recent.firstOrNull()
val last = recent.lastOrNull()
val seconds = if (first == null || last == null) 0.0 else (last.at - first.at) / 1000.0
val gone = if (first == null || last == null) 0.0 else last.metres - first.metres
if (seconds < 10 || gone < 10) {
null
} else {
when (kind) {
"pace" -> seconds * 1000 / gone
"speed" -> gone / seconds * 3.6
"split" -> seconds * 500 / gone
else -> null
}
}
}
}
private fun remember(at: Long) {
recent.addLast(At(at, metres))
while (recent.isNotEmpty() && at - recent.first().at > windowMs) recent.removeFirst()
}
companion object {
/** The app's limit for a fix counted (D20). */
const val MAX_ACCURACY_CM = 2000L
fun metresBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val p1 = Math.toRadians(lat1)
val p2 = Math.toRadians(lat2)
val dp = p2 - p1
val dl = Math.toRadians(lon2 - lon1)
val h = sin(dp / 2) * sin(dp / 2) + cos(p1) * cos(p2) * sin(dl / 2) * sin(dl / 2)
return 2 * 6_371_008.8 * asin(sqrt(h))
}
}
}

View file

@ -60,6 +60,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_STRAP = "strap"
private const val EXTRA_ROWER = "rower"
private const val EXTRA_LOOP = "loop"
private const val EXTRA_PLAN = "plan"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -88,6 +89,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
strap: String?,
rower: String?,
loop: String?,
plan: String?,
) {
ContextCompat.startForegroundService(
context,
@ -99,7 +101,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap)
.putExtra(EXTRA_ROWER, rower)
.putExtra(EXTRA_LOOP, loop),
.putExtra(EXTRA_LOOP, loop)
.putExtra(EXTRA_PLAN, plan),
)
}
@ -108,6 +111,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
fun resume() = running?.byHand(paused = false)
/** The person tapped Lap: the plan's step ends (D42). */
fun lap() = running?.lapNow()
/** Ends the recording: its last line written, the file closed, the service gone. */
fun stop() = running?.finish()
@ -138,6 +144,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var strapSends = false
private val scope: CoroutineScope = MainScope()
/** The interval plan it runs (D42), what it is measured by, its cues and words. */
private var plan: PlanRunner? = null
private var planSteps: List<RunStep> = emptyList()
private var planWords: JSONObject? = null
private var cues: PlanCues? = null
private val measures = PlanMeasures()
/** The rower's strokes and metres, from its first frame on (D28). */
private val strokes = Counted()
private val metres = Counted()
@ -149,6 +162,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
override fun run() {
val now = System.currentTimeMillis()
autoPause?.tick(now)?.let { paused -> mark(paused, auto = true, at = now) }
plan?.let { runner ->
cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now)
}
if (now - lastFlush >= FLUSH_EVERY_MS) {
writer?.flush()
lastFlush = now
@ -242,7 +258,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
write("strap ${System.currentTimeMillis()} $state")
},
onValue = { value ->
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
HeartRateMeasurement.bpmOf(value)?.let { bpm -> heart(System.currentTimeMillis(), bpm) }
},
).also { it.open() }
}
@ -259,10 +275,73 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
).also { it.open() }
}
loopAt?.let(::readLoop)
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/**
* Runs the plan [json] holds (D42): `plan`, its content as the app keeps
* it, written into the file; `steps`, each with its end, its target in
* bpm rather than zones and what is said; `say`, the words for off target
* and done; `locale`, the app's language.
*/
private fun runPlan(json: JSONObject, now: Long) {
json.optJSONObject("plan")?.let { write("plan $now $it") }
val steps = json.optJSONArray("steps") ?: return
planSteps =
(0 until steps.length()).map { i ->
val step = steps.getJSONObject(i)
RunStep(
seconds = step.optInt("seconds").takeIf { it > 0 },
metres = step.optInt("metres").takeIf { it > 0 },
target =
step.optJSONObject("target")?.let {
RunTarget(it.getString("kind"), it.getDouble("low"), it.getDouble("high"))
},
say = step.optString("say"),
)
}
planWords = json.optJSONObject("say")
cues = PlanCues(this, json.optString("locale").takeIf { it.isNotEmpty() })
plan = PlanRunner(planSteps, offTargetCues = json.optBoolean("offTargetCues", true)).also {
cue(it.start(now, measures.metres), now)
}
}
private fun lapNow() {
val runner = plan ?: return
cue(runner.lap(measures.metres), System.currentTimeMillis())
}
/** Writes what the plan did and gives its cues. */
private fun cue(events: List<PlanEvent>, at: Long) {
for (event in events) {
when (event) {
is PlanEvent.Started -> {
write("step $at ${event.index}")
cues?.change(planSteps[event.index].say)
}
is PlanEvent.Countdown -> cues?.countdown()
is PlanEvent.OffTarget ->
cues?.offTarget(planWords?.optString(if (event.harder) "harder" else "easier").orEmpty())
PlanEvent.Done -> {
write("step $at done")
cues?.change(planWords?.optString("done").orEmpty())
}
}
}
if (events.any { it is PlanEvent.Started || it == PlanEvent.Done }) writer?.flush()
}
private fun isPaused(): Boolean = pausedByHand || autoPause?.paused == true
/** A heart rate, written and measured. */
private fun heart(at: Long, bpm: Int) {
write("h $at $bpm")
measures.heart(at, bpm)
}
/**
* Reads the Loop [id]'s heart rate through Polar's SDK, held for the
* recording so that the band stays connected with the app gone (D41); a
@ -280,7 +359,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
LoopHeart(
stream = { api.startHrStreaming(id) },
onState = { state -> write("loop ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> if (!strapSends) write("h ${System.currentTimeMillis()} $bpm") },
onBpm = { bpm -> if (!strapSends) heart(System.currentTimeMillis(), bpm) },
).run()
}
}
@ -318,7 +397,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
data.metres?.let { metres.add(it) }
fun field(value: Any?) = value?.toString() ?: "-"
write("r $at ${field(data.strokeRate)} ${field(strokes.total)} ${field(metres.total)} ${field(data.watts)}")
if (strap == null && loopId == null) data.bpm?.let { write("h $at $it") }
if (strap == null && loopId == null) data.bpm?.let { heart(at, it) }
measures.rowed(at, metres.total, data.strokeRate, data.watts)
}
/** A console's running total, counted from the first reading. */
@ -351,6 +431,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
"p $at ${(location.latitude * 1e7).roundToLong()} ${(location.longitude * 1e7).roundToLong()} " +
"$accuracy $altitude",
)
measures.fix(at, location.latitude, location.longitude, accuracy)
if (location.hasSpeed() && !pausedByHand) {
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
}
@ -378,6 +459,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/** Writes a pause or its end, and shows it at once, the app there or not. */
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
measures.pause(paused)
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
writer?.flush()
repost()
@ -402,6 +484,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
rower = null
loopReading?.cancel()
loopReading = null
plan = null
cues?.close()
cues = null
loopId?.let { PolarSdk.reads(it, now = false) }
if (loopId != null) PolarSdk.release(this)
loopId = null

View file

@ -0,0 +1,114 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Running an interval plan in the recorder (ADR 0030, D42). */
class PlanRunnerTest {
private val none: (String) -> Double? = { null }
@Test
fun `a timed step ends on moving time, its last seconds counted down, pauses left out`() {
val runner = PlanRunner(listOf(RunStep(seconds = 10), RunStep(seconds = 5)), offTargetCues = true)
val events = mutableListOf<PlanEvent>()
events += runner.start(0, 0.0)
for (s in 1..6) events += runner.tick(s * 1000L, paused = false, metres = 0.0, measure = none)
// Paused for 20 s: the step's clock stands.
events += runner.tick(7_000, paused = true, metres = 0.0, measure = none)
events += runner.tick(27_000, paused = false, metres = 0.0, measure = none)
for (s in 28..30) events += runner.tick(s * 1000L, paused = false, metres = 0.0, measure = none)
assertEquals(
listOf(
PlanEvent.Started(0),
PlanEvent.Countdown(3),
PlanEvent.Countdown(2),
PlanEvent.Countdown(1),
PlanEvent.Started(1),
),
events,
)
assertEquals(1, runner.index)
}
@Test
fun `a measured step ends once it went its distance, a lap tap ends any, and the last ends the plan`() {
val runner = PlanRunner(listOf(RunStep(metres = 400), RunStep(), RunStep(seconds = 60)), offTargetCues = true)
runner.start(0, 1_000.0)
assertEquals(emptyList<PlanEvent>(), runner.tick(1_000, false, 1_399.0, none))
assertEquals(listOf(PlanEvent.Started(1)), runner.tick(2_000, false, 1_400.0, none))
// Ends by lap alone.
assertEquals(emptyList<PlanEvent>(), runner.tick(90_000, false, 2_000.0, none))
assertEquals(listOf(PlanEvent.Started(2)), runner.lap(2_000.0))
assertEquals(listOf(PlanEvent.Done), runner.lap(2_000.0))
assertEquals(emptyList<PlanEvent>(), runner.lap(2_000.0))
}
@Test
fun `off target, a cue says which way, after the grace and at most every 30 s`() {
val pace = RunTarget("pace", 270.0, 290.0)
val runner = PlanRunner(listOf(RunStep(seconds = 600, target = pace)), offTargetCues = true)
runner.start(0, 0.0)
var value = 320.0 // slower than 4:50
val events = mutableListOf<PlanEvent>()
for (s in 1..50) {
if (s == 40) value = 250.0 // faster than 4:30
events += runner.tick(s * 1000L, false, 0.0) { value }
}
assertEquals(
listOf(PlanEvent.OffTarget(harder = true), PlanEvent.OffTarget(harder = false)),
events,
)
}
@Test
fun `a heart rate below its range asks for more, and none is cued where the plan says so`() {
val heart = RunTarget("heartRate", 150.0, 165.0)
val cued = PlanRunner(listOf(RunStep(seconds = 600, target = heart)), offTargetCues = true)
val quiet = PlanRunner(listOf(RunStep(seconds = 600, target = heart)), offTargetCues = false)
cued.start(0, 0.0)
quiet.start(0, 0.0)
val told = mutableListOf<PlanEvent>()
val untold = mutableListOf<PlanEvent>()
for (s in 1..20) {
told += cued.tick(s * 1000L, false, 0.0) { 140.0 }
untold += quiet.tick(s * 1000L, false, 0.0) { 140.0 }
}
assertEquals(listOf(PlanEvent.OffTarget(harder = true)), told)
assertEquals(emptyList<PlanEvent>(), untold)
}
@Test
fun `the distance counts fixes precise enough and not paused, the pace over the last 30 s`() {
val measures = PlanMeasures()
// About 11.1 m a step north, a fix a second.
for (s in 0..20) measures.fix(s * 1000L, 52.0 + s * 0.0001, 13.0, accuracyCm = 500)
measures.fix(21_000, 53.0, 13.0, accuracyCm = 5_000)
assertEquals(222.4, measures.metres, 0.5)
// 11.1 m/s: 90 s per km, 40 km/h.
assertEquals(89.9, measures.measure("pace", 20_000)!!, 0.5)
assertEquals(40.0, measures.measure("speed", 20_000)!!, 0.2)
measures.pause(true)
measures.fix(22_000, 52.1, 13.0, accuracyCm = 500)
measures.pause(false)
measures.fix(23_000, 52.2, 13.0, accuracyCm = 500)
assertEquals(222.4, measures.metres, 0.5)
assertNull(measures.measure("pace", 23_000))
}
@Test
fun `the rower's metres stand for the distance, its figures fresh for 5 s, the heart rate too`() {
val measures = PlanMeasures()
measures.rowed(0, metres = 0, strokeRate = 24.0, watts = 180)
measures.rowed(20_000, metres = 100, strokeRate = 26.0, watts = 190)
measures.heart(20_000, 150)
assertEquals(100.0, measures.metres, 0.0)
assertEquals(100.0, measures.measure("split", 20_000)!!, 0.1)
assertEquals(26.0, measures.measure("strokeRate", 24_000)!!, 0.0)
assertEquals(190.0, measures.measure("watts", 24_000)!!, 0.0)
assertEquals(150.0, measures.measure("heartRate", 25_000)!!, 0.0)
assertNull(measures.measure("watts", 26_000))
}
}