feat(train): a recording follows a route, its turns told (ADR 0030, D45)
The Record sheet offers the routes with a way planned; the recorder's service follows the one chosen offline (RouteGuide.kt): turns told 50 m before them (100 m on a bike) after a tone, off the route past 40 m for 10 s a cue with the way back, told again on rejoining, the end told. The recording file keeps the route and the guidance; the recording screen shows the next turn or the way back, and swipes to a map of the route, the track and the position, north or the heading up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aea99c68ca
commit
b0c0eeb3d5
5 changed files with 2153 additions and 171 deletions
|
|
@ -0,0 +1,120 @@
|
|||
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.Handler
|
||||
import android.os.Looper
|
||||
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 recording's cues: a plan's (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; a route's (D45), a tone before a turn, a
|
||||
* double vibration off the route, a tone back on it; 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 Cues(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 handler = Handler(Looper.getMainLooper())
|
||||
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)
|
||||
}
|
||||
|
||||
/** A turn of the route ahead. */
|
||||
fun turn(say: String) {
|
||||
tones?.startTone(ToneGenerator.TONE_PROP_ACK, 300)
|
||||
// Spoken once the tone has sounded.
|
||||
handler.postDelayed({ speak(say) }, 400)
|
||||
}
|
||||
|
||||
fun offRoute(say: String) {
|
||||
vibrate(longArrayOf(0, 150, 120, 150))
|
||||
speak(say)
|
||||
}
|
||||
|
||||
fun rejoined(say: String) {
|
||||
tones?.startTone(ToneGenerator.TONE_PROP_ACK, 300)
|
||||
handler.postDelayed({ speak(say) }, 400)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
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, "cue")
|
||||
}
|
||||
|
||||
private fun vibrate(pattern: LongArray) {
|
||||
vibrator?.takeIf { it.hasVibrator() }?.vibrate(VibrationEffect.createWaveform(pattern, -1))
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import io.flutter.embedding.android.FlutterActivity
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
/** The app's question for permissions, answered once Android has asked. */
|
||||
|
|
@ -64,6 +65,12 @@ class MainActivity : FlutterActivity() {
|
|||
rower = call.argument<String>("rower"),
|
||||
loop = call.argument<String>("loop"),
|
||||
plan = call.argument<String>("plan"),
|
||||
// In a file: a route's thousands of points are more than an
|
||||
// intent carries (D45).
|
||||
route =
|
||||
call.argument<String>("route")?.let { route ->
|
||||
File(noBackupFilesDir, "route.json").apply { writeText(route) }.path
|
||||
},
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_ROWER = "rower"
|
||||
private const val EXTRA_LOOP = "loop"
|
||||
private const val EXTRA_PLAN = "plan"
|
||||
private const val EXTRA_ROUTE = "route"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
|
@ -78,7 +79,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
* Starts recording [activity] into the file at [path], pausing by itself
|
||||
* by [autoPause] (null: never), with the strap at [strap], the rower
|
||||
* at [rower] and the Loop [loop] (null: none), its notification as
|
||||
* [notice] says.
|
||||
* [notice] says, running [plan] (JSON) and following the route in the
|
||||
* file at [route] (D45; null: none of either).
|
||||
*/
|
||||
fun start(
|
||||
context: Context,
|
||||
|
|
@ -90,6 +92,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
rower: String?,
|
||||
loop: String?,
|
||||
plan: String?,
|
||||
route: String?,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
|
|
@ -102,7 +105,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_STRAP, strap)
|
||||
.putExtra(EXTRA_ROWER, rower)
|
||||
.putExtra(EXTRA_LOOP, loop)
|
||||
.putExtra(EXTRA_PLAN, plan),
|
||||
.putExtra(EXTRA_PLAN, plan)
|
||||
.putExtra(EXTRA_ROUTE, route),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +152,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private var plan: PlanRunner? = null
|
||||
private var planSteps: List<RunStep> = emptyList()
|
||||
private var planWords: JSONObject? = null
|
||||
private var cues: PlanCues? = null
|
||||
private var cues: Cues? = null
|
||||
private val measures = PlanMeasures()
|
||||
|
||||
/** The route it follows (D45) and its words. */
|
||||
private var guide: RouteGuide? = null
|
||||
private var guideWords: JSONObject? = null
|
||||
|
||||
/** The rower's strokes and metres, from its first frame on (D28). */
|
||||
private val strokes = Counted()
|
||||
private val metres = Counted()
|
||||
|
|
@ -276,6 +284,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
}
|
||||
loopAt?.let(::readLoop)
|
||||
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
||||
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
||||
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { followRoute(it, now) }
|
||||
}
|
||||
lastFlush = now
|
||||
handler.postDelayed(ticker, 1_000)
|
||||
}
|
||||
|
|
@ -303,12 +314,79 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
)
|
||||
}
|
||||
planWords = json.optJSONObject("say")
|
||||
cues = PlanCues(this, json.optString("locale").takeIf { it.isNotEmpty() })
|
||||
cuesIn(json.optString("locale"))
|
||||
plan = PlanRunner(planSteps, offTargetCues = json.optBoolean("offTargetCues", true)).also {
|
||||
cue(it.start(now, measures.metres), now)
|
||||
}
|
||||
}
|
||||
|
||||
/** The cues, in the app's language [locale], made once for the plan and the route. */
|
||||
private fun cuesIn(locale: String) {
|
||||
if (cues == null) cues = Cues(this, locale.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the route [json] holds (D45): `route`, its payload as the app
|
||||
* keeps it (base64), written into the file; `points`, its latitudes and
|
||||
* longitudes in turn; `turns`, each with its point's index and what is
|
||||
* said; `lead`, how far before a turn it is told; `say`, the words off
|
||||
* the route (`off`, with `{metres}` and `{direction}`, one of the eight
|
||||
* `directions` from north on), back on it and at its end; `locale`.
|
||||
*/
|
||||
private fun followRoute(json: JSONObject, now: Long) {
|
||||
json.optString("route").takeIf { it.isNotEmpty() }?.let { write("route $now $it") }
|
||||
val points = json.optJSONArray("points") ?: return
|
||||
val count = points.length() / 2
|
||||
val turns = json.optJSONArray("turns")
|
||||
guideWords = json.optJSONObject("say")
|
||||
cuesIn(json.optString("locale"))
|
||||
guide =
|
||||
RouteGuide(
|
||||
lats = DoubleArray(count) { points.getDouble(it * 2) },
|
||||
lons = DoubleArray(count) { points.getDouble(it * 2 + 1) },
|
||||
turns =
|
||||
(0 until (turns?.length() ?: 0)).map { i ->
|
||||
val turn = turns!!.getJSONObject(i)
|
||||
GuideTurn(turn.getInt("index"), turn.optString("say"))
|
||||
},
|
||||
leadMetres = json.optInt("lead", 50),
|
||||
)
|
||||
writer?.flush()
|
||||
}
|
||||
|
||||
/** Writes what following the route gave and gives its cues. */
|
||||
private fun guided(events: List<GuideEvent>, at: Long) {
|
||||
val guide = guide ?: return
|
||||
val words = guideWords
|
||||
for (event in events) {
|
||||
when (event) {
|
||||
is GuideEvent.Next -> write("guide $at next ${event.index}")
|
||||
is GuideEvent.Turn -> {
|
||||
write("guide $at turn ${event.index}")
|
||||
cues?.turn(guide.say(event.index))
|
||||
}
|
||||
is GuideEvent.OffRoute -> {
|
||||
write("guide $at off ${event.metres} ${event.bearing}")
|
||||
val directions = words?.optJSONArray("directions")
|
||||
val direction = directions?.optString(((event.bearing + 22) / 45) % 8).orEmpty()
|
||||
cues?.offRoute(
|
||||
words?.optString("off").orEmpty()
|
||||
.replace("{metres}", event.metres.toString())
|
||||
.replace("{direction}", direction),
|
||||
)
|
||||
}
|
||||
GuideEvent.Rejoined -> {
|
||||
write("guide $at on")
|
||||
cues?.rejoined(words?.optString("on").orEmpty())
|
||||
}
|
||||
GuideEvent.Arrived -> {
|
||||
write("guide $at arrived")
|
||||
cues?.turn(words?.optString("arrived").orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lapNow() {
|
||||
val runner = plan ?: return
|
||||
cue(runner.lap(measures.metres), System.currentTimeMillis())
|
||||
|
|
@ -432,6 +510,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
"$accuracy $altitude",
|
||||
)
|
||||
measures.fix(at, location.latitude, location.longitude, accuracy)
|
||||
if (!isPaused()) guide?.let { guided(it.fix(at, location.latitude, location.longitude, accuracy), at) }
|
||||
if (location.hasSpeed() && !pausedByHand) {
|
||||
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
|
||||
}
|
||||
|
|
@ -485,6 +564,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
loopReading?.cancel()
|
||||
loopReading = null
|
||||
plan = null
|
||||
guide = null
|
||||
cues?.close()
|
||||
cues = null
|
||||
loopId?.let { PolarSdk.reads(it, now = false) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** A turn of the route as the recorder tells it (ADR 0030, D45): at the route's point [index], with what is said. */
|
||||
data class GuideTurn(val index: Int, val say: String = "")
|
||||
|
||||
/** What following a route gives, for the recorder to write and cue. */
|
||||
sealed interface GuideEvent {
|
||||
/** The turn to come is the route's turn [index] now; the number of turns where none is left. */
|
||||
data class Next(val index: Int) : GuideEvent
|
||||
|
||||
/** Turn [index] is the lead ahead: told. */
|
||||
data class Turn(val index: Int) : GuideEvent
|
||||
|
||||
/** Off the route: [metres] to its nearest point (to 10 m), which lies at [bearing] degrees from north. */
|
||||
data class OffRoute(val metres: Int, val bearing: Int) : GuideEvent
|
||||
|
||||
data object Rejoined : GuideEvent
|
||||
|
||||
/** Within [ARRIVED_METRES] of the route's end. */
|
||||
data object Arrived : GuideEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a route along its points (D45), offline, on each fix: where on
|
||||
* the route the person is, the next turn told [leadMetres] before it; more
|
||||
* than [OFF_METRES] off for [OFF_FOR_MS], a cue with the way back to the
|
||||
* route's nearest point, again every [OFF_AGAIN_MS] while off, and told
|
||||
* again once back within [ON_METRES]. Fixes less precise than the app counts
|
||||
* (D20) are left out, as are those while paused (the recorder doesn't hand
|
||||
* them in).
|
||||
*/
|
||||
class RouteGuide(
|
||||
private val lats: DoubleArray,
|
||||
private val lons: DoubleArray,
|
||||
private val turns: List<GuideTurn>,
|
||||
private val leadMetres: Int,
|
||||
) {
|
||||
companion object {
|
||||
const val OFF_METRES = 40.0
|
||||
const val ON_METRES = 30.0
|
||||
const val OFF_FOR_MS = 10_000L
|
||||
const val OFF_AGAIN_MS = 60_000L
|
||||
const val ARRIVED_METRES = 30.0
|
||||
|
||||
/** How far ahead of where it stands, and behind, a fix is looked for first. */
|
||||
private const val AHEAD_METRES = 500.0
|
||||
private const val BEHIND_METRES = 100.0
|
||||
|
||||
/** How far past a turn it counts as passed. */
|
||||
private const val PASSED_METRES = 10.0
|
||||
private const val EARTH_METRES = 6_371_008.8
|
||||
|
||||
/** The bearing from one point to another, in whole degrees from north, clockwise. */
|
||||
fun bearing(fromLat: Double, fromLon: Double, toLat: Double, toLon: Double): Int {
|
||||
val p1 = Math.toRadians(fromLat)
|
||||
val p2 = Math.toRadians(toLat)
|
||||
val dl = Math.toRadians(toLon - fromLon)
|
||||
val y = Math.sin(dl) * cos(p2)
|
||||
val x = cos(p1) * Math.sin(p2) - Math.sin(p1) * cos(p2) * cos(dl)
|
||||
return ((Math.toDegrees(atan2(y, x)) + 360).roundToInt()) % 360
|
||||
}
|
||||
}
|
||||
|
||||
/** How far along the route each point is. */
|
||||
private val along =
|
||||
DoubleArray(lats.size).also { along ->
|
||||
for (i in 1 until lats.size) {
|
||||
along[i] = along[i - 1] + PlanMeasures.metresBetween(lats[i - 1], lons[i - 1], lats[i], lons[i])
|
||||
}
|
||||
}
|
||||
private val turnAlong = turns.map { along.getOrElse(it.index) { along.lastOrNull() ?: 0.0 } }
|
||||
|
||||
/** How far along the route the person was last seen on it; null before. */
|
||||
private var progress: Double? = null
|
||||
private var next = 0
|
||||
private var nextTold: Int? = null
|
||||
private val told = mutableSetOf<Int>()
|
||||
private var offSince: Long? = null
|
||||
private var lastOffCue: Long? = null
|
||||
private var arrived = false
|
||||
|
||||
/** Whether the person is off the route now. */
|
||||
var off = false
|
||||
private set
|
||||
|
||||
/** Takes the fix [lat], [lon] at [at] in. */
|
||||
fun fix(at: Long, lat: Double, lon: Double, accuracyCm: Long = 0): List<GuideEvent> {
|
||||
if (lats.size < 2 || accuracyCm > PlanMeasures.MAX_ACCURACY_CM) return emptyList()
|
||||
val events = mutableListOf<GuideEvent>()
|
||||
var nearest = nearest(lat, lon, progress)
|
||||
if (nearest.metres > OFF_METRES && progress != null) nearest = nearest(lat, lon, null)
|
||||
if (nearest.metres > OFF_METRES) {
|
||||
val since = offSince ?: at.also { offSince = it }
|
||||
val last = lastOffCue
|
||||
if (at - since >= OFF_FOR_MS && (last == null || at - last >= OFF_AGAIN_MS)) {
|
||||
off = true
|
||||
lastOffCue = at
|
||||
events +=
|
||||
GuideEvent.OffRoute(
|
||||
metres = (nearest.metres / 10).roundToInt() * 10,
|
||||
bearing = bearing(lat, lon, nearest.lat, nearest.lon),
|
||||
)
|
||||
}
|
||||
return events
|
||||
}
|
||||
if (off) {
|
||||
if (nearest.metres > ON_METRES) return events
|
||||
off = false
|
||||
lastOffCue = null
|
||||
events += GuideEvent.Rejoined
|
||||
}
|
||||
offSince = null
|
||||
val now = nearest.along
|
||||
progress = now
|
||||
while (next < turns.size && now > turnAlong[next] + PASSED_METRES) next++
|
||||
if (nextTold != next) {
|
||||
nextTold = next
|
||||
events += GuideEvent.Next(next)
|
||||
}
|
||||
if (next < turns.size && next !in told && turnAlong[next] - now <= leadMetres) {
|
||||
told += next
|
||||
events += GuideEvent.Turn(next)
|
||||
}
|
||||
if (!arrived && along.last() - now <= ARRIVED_METRES) {
|
||||
arrived = true
|
||||
events += GuideEvent.Arrived
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** What is said for turn [index]. */
|
||||
fun say(index: Int): String = turns.getOrNull(index)?.say.orEmpty()
|
||||
|
||||
private class Nearest(val metres: Double, val along: Double, val lat: Double, val lon: Double)
|
||||
|
||||
/**
|
||||
* The route's point nearest to [lat], [lon]: among the segments around
|
||||
* [around] where given, else among all.
|
||||
*/
|
||||
private fun nearest(lat: Double, lon: Double, around: Double?): Nearest {
|
||||
var best: Nearest? = null
|
||||
val metresPerLon = EARTH_METRES * Math.toRadians(1.0) * cos(Math.toRadians(lat))
|
||||
val metresPerLat = EARTH_METRES * Math.toRadians(1.0)
|
||||
for (i in 0 until lats.size - 1) {
|
||||
if (around != null && (along[i] > around + AHEAD_METRES || along[i + 1] < around - BEHIND_METRES)) continue
|
||||
// In metres on a plane about the fix, near enough over a segment.
|
||||
val ax = (lons[i] - lon) * metresPerLon
|
||||
val ay = (lats[i] - lat) * metresPerLat
|
||||
val bx = (lons[i + 1] - lon) * metresPerLon
|
||||
val by = (lats[i + 1] - lat) * metresPerLat
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val length = dx * dx + dy * dy
|
||||
val t = if (length == 0.0) 0.0 else (-(ax * dx + ay * dy) / length).coerceIn(0.0, 1.0)
|
||||
val x = ax + t * dx
|
||||
val y = ay + t * dy
|
||||
val metres = sqrt(x * x + y * y)
|
||||
if (best == null || metres < best.metres) {
|
||||
best =
|
||||
Nearest(
|
||||
metres = metres,
|
||||
along = along[i] + t * (along[i + 1] - along[i]),
|
||||
lat = lats[i] + t * (lats[i + 1] - lats[i]),
|
||||
lon = lons[i] + t * (lons[i + 1] - lons[i]),
|
||||
)
|
||||
}
|
||||
}
|
||||
return best ?: Nearest(Double.MAX_VALUE, 0.0, lat, lon)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Following a route in the recorder (ADR 0030, D45). */
|
||||
class RouteGuideTest {
|
||||
// North along 10° E for about 556 m, then east for about 715 m.
|
||||
private val lats = doubleArrayOf(50.0, 50.005, 50.005)
|
||||
private val lons = doubleArrayOf(10.0, 10.0, 10.01)
|
||||
private val turns = listOf(GuideTurn(index = 1), GuideTurn(index = 2))
|
||||
|
||||
/** A point [metres] north of the start, on the first leg. */
|
||||
private fun north(metres: Double) = 50.0 + metres / 111_195.0
|
||||
|
||||
private fun guide(lead: Int = 50) = RouteGuide(lats, lons, turns, leadMetres = lead)
|
||||
|
||||
@Test
|
||||
fun `a turn is told once, its lead before it, and the next turn follows it`() {
|
||||
val guide = guide()
|
||||
val events = mutableListOf<GuideEvent>()
|
||||
events += guide.fix(0, north(0.0), 10.0)
|
||||
events += guide.fix(1_000, north(400.0), 10.0)
|
||||
events += guide.fix(2_000, north(510.0), 10.0)
|
||||
events += guide.fix(3_000, north(520.0), 10.0)
|
||||
events += guide.fix(4_000, 50.005, 10.001)
|
||||
|
||||
assertEquals(
|
||||
listOf(GuideEvent.Next(0), GuideEvent.Turn(0), GuideEvent.Next(1)),
|
||||
events,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on a bike a turn is told 100 m before it`() {
|
||||
val guide = guide(lead = 100)
|
||||
guide.fix(0, north(0.0), 10.0)
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(1_000, north(440.0), 10.0))
|
||||
assertEquals(listOf(GuideEvent.Turn(0)), guide.fix(2_000, north(460.0), 10.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `more than 40 m off for 10 s, a cue and the way back, told again on rejoining`() {
|
||||
val guide = guide()
|
||||
guide.fix(0, north(100.0), 10.0)
|
||||
// 60 m west of the route: 0.00084° of longitude at 50° N.
|
||||
val west = 10.0 - 60 / (111_195.0 * Math.cos(Math.toRadians(50.0)))
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(1_000, north(200.0), west))
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(9_000, north(200.0), west))
|
||||
val off = guide.fix(11_000, north(200.0), west).single() as GuideEvent.OffRoute
|
||||
assertEquals(60, off.metres)
|
||||
// The route lies east.
|
||||
assertTrue("${off.bearing}", off.bearing in 85..95)
|
||||
assertTrue(guide.off)
|
||||
// Told again a minute on, while still off.
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(40_000, north(200.0), west))
|
||||
assertTrue(guide.fix(71_000, north(200.0), west).single() is GuideEvent.OffRoute)
|
||||
assertEquals(listOf(GuideEvent.Rejoined), guide.fix(72_000, north(210.0), 10.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short stray, or a fix too imprecise, says nothing`() {
|
||||
val guide = guide()
|
||||
guide.fix(0, north(100.0), 10.0)
|
||||
val west = 10.0 - 60 / (111_195.0 * Math.cos(Math.toRadians(50.0)))
|
||||
guide.fix(1_000, north(150.0), west)
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(5_000, north(200.0), 10.0))
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(20_000, north(200.0), west, accuracyCm = 5_000))
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(40_000, north(200.0), west, accuracyCm = 5_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no turn is told while off the route, and a turn passed unseen is skipped`() {
|
||||
val guide = guide()
|
||||
guide.fix(0, north(0.0), 10.0)
|
||||
// Straight on to the second leg's middle: the first turn passed unseen.
|
||||
assertEquals(listOf(GuideEvent.Next(1)), guide.fix(1_000, 50.005, 10.005))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the end is told once, within 30 m of it`() {
|
||||
val guide = guide()
|
||||
guide.fix(0, 50.005, 10.0)
|
||||
guide.fix(1_000, 50.005, 10.009)
|
||||
val events = guide.fix(2_000, 50.005, 10.0097)
|
||||
assertTrue(events.toString(), GuideEvent.Arrived in events)
|
||||
assertEquals(emptyList<GuideEvent>(), guide.fix(3_000, 50.005, 10.01))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the way back points to the nearest point of the route`() {
|
||||
// North of the turn by 100 m: the route lies south.
|
||||
assertEquals(180, RouteGuide.bearing(50.0059, 10.0, 50.005, 10.0))
|
||||
assertEquals(0, RouteGuide.bearing(50.0, 10.0, 50.001, 10.0))
|
||||
assertEquals(270, RouteGuide.bearing(50.0, 10.0, 50.0, 9.999))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue