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

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