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:
parent
128ba1f6c8
commit
57cbd97fd1
7 changed files with 320 additions and 11 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue