feat(train): elevation from the barometer, its pressure kept raw (ADR 0030, D49)

11c: where a recording has GPS and the phone a barometer, the service
averages its readings over 5 s into the file's `b` lines and the
workout's new `pressure` series (tenths of a pascal). The page, Health
Connect's totals and the exported files take the fixes' altitudes from
it: the standard atmosphere's height at the pressure, levelled by the
median of GPS's altitudes less those; climbs count above 1 m. Workouts
recorded before keep GPS's altitudes; an older app skips the series.

Not tried on a phone yet.

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-29 14:48:28 +01:00
commit 57cbd97fd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 320 additions and 11 deletions

View file

@ -0,0 +1,26 @@
package app.halkyon.train
import kotlin.math.roundToLong
/**
* The barometer's readings (hPa, several a second) averaged over [windowMs] (ADR 0030, D49):
* each window's mean, in tenths of a pascal, as the reading that closes it comes.
*/
class PressureAverage(private val windowMs: Long = 5_000) {
private var from: Long? = null
private var sum = 0.0
private var count = 0
/** Takes the reading [hPa] at [at]; the window's mean where it closed. */
fun add(at: Long, hPa: Float): Long? {
val start = from ?: at.also { from = it }
sum += hPa
count++
if (at - start < windowMs) return null
val mean = (sum / count * 1_000).roundToLong()
from = null
sum = 0.0
count = 0
return mean
}
}

View file

@ -39,7 +39,7 @@ import org.json.JSONObject
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
// foreground service, started from the open app, that takes GPS fixes from
// Android's LocationManager (no Play services) and steps from the step
// detector, reads the heart-rate strap and the rower where the person chose
// detector and the air's pressure from the barometer (D49), reads the heart-rate strap and the rower where the person chose
// them (D26 to D28), and the Loop's heart rate where no strap gives one (D41),
// pauses by itself, and appends each sample as a line to the
// recording file, flushed every few seconds, so a recording survives the app
@ -174,6 +174,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var usualCadence = 165
private var lastMusicAt = 0L
/** The barometer's readings, averaged every 5 s (D49). */
private val pressure = PressureAverage()
/** What tells each distance passed (D49), and its words. */
private var announcer: Announcer? = null
private var announceWords: AnnounceWords? = null
@ -272,6 +275,15 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(LocationManager::class.java)
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
}
// The air's pressure where the phone has a barometer: the elevation
// GPS gives poorly (D49).
if (located) {
getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_PRESSURE)?.let {
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
}
}
}
if (stepping) {
getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
@ -595,6 +607,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type == Sensor.TYPE_PRESSURE) {
val at = wallTime(event.timestamp)
pressure.add(at, event.values[0])?.let { decipascals -> write("b $at $decipascals") }
return
}
if (event.sensor.type != Sensor.TYPE_STEP_DETECTOR) return
val at = wallTime(event.timestamp)
write("s $at")

View file

@ -0,0 +1,18 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The barometer's readings averaged every 5 s (ADR 0030, D49). */
class PressureAverageTest {
@Test
fun `a window's readings come out as their mean in tenths of a pascal, once it closes`() {
val average = PressureAverage()
assertNull(average.add(0, 1000.0f))
assertNull(average.add(2_000, 1000.2f))
assertEquals(1_000_100L, average.add(5_000, 1000.1f))
assertNull(average.add(5_200, 990.0f))
assertEquals(990_000L, average.add(10_200, 990.0f))
}
}