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
15 changed files with 320 additions and 11 deletions

File diff suppressed because one or more lines are too long

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

View file

@ -110,7 +110,7 @@ class RecordingBloc(
final Stream<void> _ticks =
ticks ?? Stream<void>.periodic(const Duration(seconds: 1));
static final _sample = RegExp('^[phs] ');
static final _sample = RegExp('^[phsb] ');
/// The lines the recorder wrote so far.
final _lines = StringBuffer();

View file

@ -228,6 +228,7 @@ List<SeriesChunk> seriesOf(Recording recording) {
final rates = recording.heartRates;
final pauses = recording.pauses;
final rowing = recording.rowing;
final pressures = recording.pressures;
return [
...chunksOf(
SeriesKind.position,
@ -275,6 +276,14 @@ List<SeriesChunk> seriesOf(Recording recording) {
},
),
},
...chunksOf(
SeriesKind.pressure,
start: start,
times: times(pressures.map((pressure) => pressure.time)),
columns: {
'dpa': [for (final pressure in pressures) pressure.decipascals],
},
),
...chunksOf(
SeriesKind.pauses,
start: start,

View file

@ -23,6 +23,7 @@ const recordingFileMagic = 'halkyon-train-recording';
/// p <time> <lat e7> <lon e7> <accuracy cm> <altitude cm or ->
/// h <time> <bpm>
/// s <time>
/// b <time> <the air's pressure, in tenths of a pascal>
/// pause <time> auto|manual
/// resume <time> auto|manual
/// strap <time> connecting|connected|lost
@ -66,6 +67,7 @@ Recording? readRecording(String text) {
final heartRates = <HeartRate>[];
final steps = <DateTime>[];
final pauses = <PauseMark>[];
final pressures = <Pressure>[];
final straps = <LinkMark>[];
final loops = <LinkMark>[];
final rowing = <RowingSample>[];
@ -153,6 +155,10 @@ Recording? readRecording(String text) {
}
case ['s', _]:
steps.add(time);
case ['b', _, final decipascals]:
if (int.tryParse(decipascals) case final decipascals?) {
pressures.add(Pressure(time: time, decipascals: decipascals));
}
case [final mark, _, final by]
when (mark == 'pause' || mark == 'resume') &&
(by == 'auto' || by == 'manual'):
@ -189,6 +195,7 @@ Recording? readRecording(String text) {
heartRates: heartRates,
steps: steps,
pauses: pauses,
pressures: pressures,
straps: straps,
loops: loops,
rowing: rowing,

View file

@ -31,6 +31,16 @@ final class const HeartRate({
List<Object?> get props => [time, bpm];
}
/// The air's pressure at [time], in tenths of a pascal, from the phone's
/// barometer (D49): averaged over the few seconds before it.
final class const Pressure({
required final DateTime time,
required final int decipascals,
}) with Equatable {
@override
List<Object?> get props => [time, decipascals];
}
/// A frame of the rower's (D28): its stroke rate, and its strokes and metres
/// since the recording's first frame; each null where the frame lacks it.
final class const RowingSample({
@ -138,6 +148,9 @@ final class const Recording({
final List<DateTime> steps = const [],
final List<PauseMark> pauses = const [],
/// The barometer's readings, where the phone has one (D49).
final List<Pressure> pressures = const [],
/// The strap's states, where it was recorded with one (D27).
final List<LinkMark> straps = const [],
@ -174,6 +187,7 @@ final class const Recording({
...steps,
for (final mark in pauses) mark.time,
for (final sample in rowing) sample.time,
for (final pressure in pressures) pressure.time,
].reduce((a, b) => b.isAfter(a) ? b : a);
@override
@ -184,6 +198,7 @@ final class const Recording({
heartRates,
steps,
pauses,
pressures,
straps,
loops,
rowing,

View file

@ -64,8 +64,8 @@ final class const WorkoutAnalysis({
/// Per kilometre on foot, per 5 km cycling, per 500 m on the rower.
required final List<Split> splits,
/// The climbs above [climbThresholdMetres], added up; null without
/// altitudes.
/// The climbs above [climbThresholdMetres] (above [barometerClimbMetres]
/// by the barometer, D49), added up; null without altitudes.
required final int? elevationGainMetres,
/// The pace (seconds a kilometre takes) on foot, the speed (km/h)
@ -110,7 +110,7 @@ int splitMetresOf(Activity activity) => switch (activity) {
/// [content] and its [series] read for its page.
WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
final recording = recordingOf(content, series);
final recording = withBarometer(recordingOf(content, series));
final points = _moving(recording);
final rowed = recording.rowing.isNotEmpty;
// How far it had gone over its moving time: by the track, or the rower.
@ -157,7 +157,10 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
strokeRates: strokeRates,
watts: watts,
),
elevationGainMetres: _gain(points),
elevationGainMetres: _gain(
points,
recording.pressures.isEmpty ? climbThresholdMetres : barometerClimbMetres,
),
chart: _chart(
progress,
per: switch (content.activity) {
@ -420,6 +423,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final pauses = <PauseMark>[];
final heartRates = <HeartRate>[];
final rowing = <RowingSample>[];
final pressures = <Pressure>[];
for (final chunk in series) {
final times = chunk.absoluteTimes;
final columns = chunk.columns;
@ -474,6 +478,12 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
),
);
}
case SeriesKind.pressure:
for (var i = 0; i < times.length; i++) {
if (columns['dpa']?[i] case final decipascals?) {
pressures.add(Pressure(time: times[i], decipascals: decipascals));
}
}
case SeriesKind.cadence:
// A recording holds its steps, which a cadence can't give back; the
// music reads it from the series (`cadenceModelOf`).
@ -483,6 +493,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
fixes.sort((a, b) => a.time.compareTo(b.time));
pauses.sort((a, b) => a.time.compareTo(b.time));
heartRates.sort((a, b) => a.time.compareTo(b.time));
pressures.sort((a, b) => a.time.compareTo(b.time));
return Recording(
activity: content.activity,
startedAt: content.startedAt,
@ -490,10 +501,96 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
pauses: pauses,
heartRates: heartRates,
rowing: rowing,
pressures: pressures,
endedAt: content.endedAt,
);
}
/// The climb, in metres, the barometer's elevation must make before it
/// counts (D49): its noise is a fraction of a metre.
const barometerClimbMetres = 1;
/// [recording] with its fixes' altitudes from the barometer (D49), where it
/// kept the air's pressure: each fix's height by the standard atmosphere at
/// the pressure then (between two readings, as it went; a minute from the
/// nearest at most), moved by the median of the GPS altitudes less those,
/// so that its changes are the barometer's and its level GPS's on the
/// whole; as it is without readings.
Recording withBarometer(Recording recording) {
final pressures = recording.pressures;
if (pressures.isEmpty || recording.fixes.isEmpty) {
return recording;
}
double height(double decipascals) =>
44330 * (1 - pow(decipascals / 1013250, 1 / 5.255).toDouble());
var next = 0;
double? pressureAt(DateTime time) {
while (next < pressures.length && pressures[next].time.isBefore(time)) {
next++;
}
final after = next < pressures.length ? pressures[next] : null;
final before = next > 0 ? pressures[next - 1] : null;
if (before != null && after != null) {
final span = after.time.difference(before.time).inMilliseconds;
final share = span == 0
? 0
: time.difference(before.time).inMilliseconds / span;
return before.decipascals +
(after.decipascals - before.decipascals) * share.toDouble();
}
final nearest = before ?? after!;
return time.difference(nearest.time).abs() <= const Duration(minutes: 1)
? nearest.decipascals.toDouble()
: null;
}
final heights = [
for (final fix in recording.fixes)
switch (pressureAt(fix.time)) {
final decipascals? => height(decipascals),
null => null,
},
];
final offsets = [
for (final (i, fix) in recording.fixes.indexed)
if ((fix.altitudeCm, heights[i]) case (final cm?, final height?)
when fix.accuracyCm <= maxAccuracyCm)
cm / 100 - height,
]..sort();
final offset = offsets.isEmpty ? 0.0 : offsets[offsets.length ~/ 2];
return Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: [
for (final (i, fix) in recording.fixes.indexed)
Fix(
time: fix.time,
latE7: fix.latE7,
lonE7: fix.lonE7,
accuracyCm: fix.accuracyCm,
altitudeCm: switch (heights[i]) {
final height? => ((height + offset) * 100).round(),
null => fix.altitudeCm,
},
),
],
heartRates: recording.heartRates,
steps: recording.steps,
pauses: recording.pauses,
pressures: pressures,
straps: recording.straps,
loops: recording.loops,
rowing: recording.rowing,
rowers: recording.rowers,
plan: recording.plan,
planSteps: recording.planSteps,
planMaxHeartRate: recording.planMaxHeartRate,
route: recording.route,
guidance: recording.guidance,
endedAt: recording.endedAt,
);
}
/// A fix that counts, with how far and how long it had moved by then, and
/// whether a pause came before it since the fix before.
typedef _Point = ({Fix fix, double metres, Duration moving, bool afterPause});
@ -611,7 +708,7 @@ List<Split> _splits(
}
}
int? _gain(List<_Point> points) {
int? _gain(List<_Point> points, num threshold) {
final altitudes = [
for (final p in points)
if (p.fix.altitudeCm case final cm?) cm / 100,
@ -622,10 +719,10 @@ int? _gain(List<_Point> points) {
var reference = altitudes.first;
var gain = 0.0;
for (final altitude in altitudes.skip(1)) {
if (altitude > reference + climbThresholdMetres) {
if (altitude > reference + threshold) {
gain += altitude - reference;
reference = altitude;
} else if (altitude < reference - climbThresholdMetres) {
} else if (altitude < reference - threshold) {
reference = altitude;
}
}

View file

@ -65,7 +65,7 @@ final class const ExportTrack({
/// last 5 s, the cadence of the window it fell in (on foot, as strides) and
/// the rower's stroke rate and watts.
ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
final recording = recordingOf(content, series);
final recording = withBarometer(recordingOf(content, series));
final heart = _Latest(
[for (final rate in recording.heartRates) (rate.time, rate.bpm)],
const Duration(seconds: 5),

View file

@ -21,6 +21,10 @@ enum SeriesKind() {
/// The steps' cadence (D48): `spm`, the steps a minute over the 10 s
/// before each sample, taken while moving.
cadence,
/// The air's pressure from the barometer (D49): `dpa`, in tenths of a
/// pascal, every few seconds.
pressure,
}
/// How long a chunk runs at most: a 3-hour ride is 18 chunks of a few KB

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
@ -120,6 +121,27 @@ void main() {
expect(series, isEmpty);
});
test("the barometer's pressure is a series of its own, read back as it "
'was (D49)', () {
final pressures = [
Pressure(time: at(5), decipascals: 1001234),
Pressure(time: at(10), decipascals: 1001200),
];
final (content, series) = recordedWorkout(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [north(0, 0), north(10, 30)],
pressures: pressures,
endedAt: at(10),
),
);
final chunk = series.singleWhere((c) => c.kind == SeriesKind.pressure);
expect(chunk.columns['dpa'], [1001234, 1001200]);
expect(recordingOf(content, series).pressures, pressures);
});
test('every sample goes into its series, in chunks of ten minutes', () {
final (_, series) = recordedWorkout(
run(

View file

@ -17,6 +17,26 @@ void main() {
start.add(Duration(seconds: seconds)).millisecondsSinceEpoch;
final header = 'halkyon-train-recording 1 running ${at(0)}\n';
test("the barometer's pressure reads in tenths of a pascal (D49)", () {
final recording = readRecording(
'$header'
'b ${at(5)} 1001234\n'
'b ${at(10)} x\n'
'b ${at(15)} 1001200\n',
)!;
expect(recording.pressures, [
Pressure(
time: start.add(const Duration(seconds: 5)),
decipascals: 1001234,
),
Pressure(
time: start.add(const Duration(seconds: 15)),
decipascals: 1001200,
),
]);
});
test('a finished recording reads with every sample, in its order', () {
final recording = readRecording(
'$header'

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
@ -131,6 +133,67 @@ void main() {
expect(analysis.elevationGainMetres, 12);
});
group("the barometer's elevation (D49)", () {
/// The pressure, in tenths of a pascal, [metres] above the standard sea
/// level.
int pressureAt(num metres) =>
(1013250 * pow(1 - metres / 44330, 5.255)).round();
test("it gives the climb, its level the GPS altitudes' median", () {
// GPS wobbles by 6 m around 100 m; the air says a steady 20 m climb.
final noisy = [100, 106, 94, 106, 94, 106, 94, 106, 94, 106, 100];
final recording = Recording(
activity: Activity.running,
startedAt: start,
fixes: [
for (final (i, m) in noisy.indexed)
north(i * 10, i * 30, altitudeCm: m * 100),
],
pressures: [
for (var i = 0; i <= 10; i++)
Pressure(time: at(i * 10), decipascals: pressureAt(500 + 2 * i)),
],
endedAt: at(100),
);
final analysis = analysed(recording);
// Each 2 m counts: the barometer's noise is a fraction of a metre.
expect(analysis.elevationGainMetres, 20);
// 500 m by the standard air, moved by the median of GPS's altitudes
// less the barometer's (410 m).
expect(analysis.elevation.first.value, closeTo(90, 0.1));
expect(analysis.elevation.last.value, closeTo(110, 0.1));
final gps = analysed(
Recording(
activity: Activity.running,
startedAt: start,
fixes: recording.fixes,
endedAt: at(100),
),
);
expect(gps.elevationGainMetres, greaterThan(20));
});
test('without GPS altitudes, it stands by the standard air', () {
final analysis = analysed(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var i = 0; i <= 4; i++) north(i * 10, i * 30)],
pressures: [
Pressure(time: at(0), decipascals: pressureAt(300)),
Pressure(time: at(40), decipascals: pressureAt(320)),
],
endedAt: at(40),
),
);
expect(analysis.elevationGainMetres, 20);
// Between two readings, the pressure is taken as it went.
expect(analysis.elevation[2].value, closeTo(310, 0.5));
});
});
test('without altitudes it has no elevation gained, nor its chart', () {
final analysis = analysed(run([north(0, 0), north(60, 200)]));

View file

@ -1154,6 +1154,17 @@ Strava. What was found, 27 Oct:
XML parsed, the FIT by a reader written from the specification in the tests
(`test/support/fit_reader.dart`), not by Garmin's SDK. Not tried yet: an upload to
Strava, intervals.icu or Garmin Connect.
- Built: 11c. Where a recording has GPS and the phone a barometer, the service averages
its readings over 5 s (`PressureAverage.kt`, tested on the JVM) into the file's `b`
lines and the workout's `pressure` series (`dpa`, tenths of a pascal, some 2 KB an
hour sealed). The page, Health Connect's totals and the files out read the fixes'
altitudes from it (`withBarometer`): each fix's height by the standard atmosphere at
the pressure then (between two readings as it went), moved by the median of GPS's
altitudes less those, so that its changes are the barometer's and its level GPS's;
its climbs count above 1 m rather than GPS's 3 m (the barometer's noise, averaged,
a fraction of a metre). A workout recorded before keeps GPS's altitudes. Not tried
on a phone yet: weather fronts over a long ride (a change of 1 hPa an hour reads as
some 8 m), and pockets' gusts.
## Consequences