feat(train): a ride's distance from the wheel where GPS falls short (ADR 0030, D49)

The speed sensor's wheel gives a ride's distance, splits, progress and live
speed where GPS made less than 90 % of its way (a turbo trainer, a tunnel);
its circumference starts at 2105 mm, is learnt from rides with 2 km of GPS
or typed on the Record sheet.

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 15:22:48 +01:00
commit a848b71bd4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 527 additions and 27 deletions

View file

@ -30,6 +30,23 @@ void main() {
expect(store.read(), isNull);
});
test('a speed sensor keeps its wheel, typed or measured (D49)', () {
final store = DeviceStore(File('${folder.path}/speed.json'));
const sensor = KnownDevice(address: 'AA:01', name: 'Speed');
store.save(sensor.withWheel(2096, typed: true));
expect(
store.read(),
const KnownDevice(
address: 'AA:01',
name: 'Speed',
wheelMm: 2096,
wheelTyped: true,
),
);
store.save(sensor.withWheel(null, typed: true));
expect(store.read(), sensor);
});
test("a file it can't read is no strap", () {
final file = File('${folder.path}/strap.json')..writeAsStringSync('{');

View file

@ -121,6 +121,51 @@ void main() {
expect(series, isEmpty);
});
group("the wheel's distance (D49)", () {
/// A ride of [seconds] with GPS [gpsMetres] a second and the wheel
/// turning twice a second, its 2.1 m round.
Recording ride({required int seconds, required num gpsMetres}) => Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [
for (var s = 0; s <= seconds; s += 10)
north(s, (s * gpsMetres).round()),
],
wheel: [
for (var s = 0; s <= seconds; s++) Reading(time: at(s), value: s * 2),
],
wheelMm: 2100,
endedAt: at(seconds),
);
test('on a turbo trainer, GPS standing still, the wheel gives it', () {
final trainer = ride(seconds: 600, gpsMetres: 0);
expect(rodeByWheel(trainer), isTrue);
expect(distanceOf(trainer), 2520);
final (content, series) = recordedWorkout(trainer);
expect(content.distanceMetres, 2520);
expect(analyse(content, series).splits, isNotEmpty);
});
test(
'where GPS follows the wheel, GPS gives it, and measures the wheel',
() {
final road = ride(seconds: 600, gpsMetres: 4.2);
expect(rodeByWheel(road), isFalse);
expect(distanceOf(road), closeTo(2520, 5));
expect(measuredWheelMm(road), closeTo(2100, 5));
},
);
test(
'a ride under 2 km, or one whose wheel makes no wheel, measures none',
() {
expect(measuredWheelMm(ride(seconds: 200, gpsMetres: 4.2)), isNull);
expect(measuredWheelMm(ride(seconds: 600, gpsMetres: 8)), isNull);
},
);
});
test("the bike's readings are series of their own, read back as they "
'were (D49)', () {
final wheel = [

View file

@ -544,6 +544,46 @@ void main() {
expect(platform.started.last.powerMeter, isNull);
});
test("a ride's GPS measures the speed sensor's wheel, which the next ride "
'starts with; one typed stays (D49)', () async {
final bloc = await open();
bloc.add(
const RecordingEventDeviceChosen(
DeviceKind.speedSensor,
KnownDevice(address: 'AA:01'),
),
);
await bloc.stream.firstWhere((s) => s.speedSensor != null);
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.started.single.wheelMm, defaultWheelMm);
platform.write('wheelsize @ 2105');
// 2.5 km in 500 s, the wheel turning 1190 times: a 2.1 m wheel.
for (var s = 0; s <= 500; s += 10) {
platform
..now = start.add(Duration(seconds: s))
..write('p @ ${(s * 5 * 89.932).round()} 0 500 -')
..write('w @ ${(s * 2.38).round()}');
}
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.stopped);
bloc.add(const RecordingEventSaveRequested());
final saved = await bloc.stream.firstWhere(
(s) => s.phase == RecordingPhase.idle,
);
expect(saved.speedSensor!.wheelMm, closeTo(2100, 5));
expect(saved.speedSensor!.wheelTyped, isFalse);
bloc.add(const RecordingEventWheelSet(2096));
final typed = await bloc.stream.firstWhere(
(s) => s.speedSensor?.wheelTyped ?? false,
);
expect(typed.speedSensor!.wheelMm, 2096);
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.started.last.wheelMm, 2096);
});
test('announcements go to the recorder where switched on (D49)', () async {
final bloc = await open();
Future<void> record(Activity activity, {required bool announce}) async {

View file

@ -22,6 +22,7 @@ void main() {
final recording = readRecording(
'$header'
'csc ${at(0)} connected\n'
'wheelsize ${at(0)} 2105\n'
'power ${at(0)} connecting\n'
'w ${at(1)} 3\n'
'c ${at(1)} 88\n'
@ -31,6 +32,7 @@ void main() {
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
expect(recording.wheel, [Reading(time: time(1), value: 3)]);
expect(recording.wheelMm, 2105);
expect(recording.pedalling, [Reading(time: time(1), value: 88)]);
expect(recording.power, [Reading(time: time(2), value: 240)]);
expect(recording.speedSensors, [