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
15 changed files with 320 additions and 11 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue