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
23 changed files with 527 additions and 27 deletions

View file

@ -88,6 +88,7 @@ class MainActivity : FlutterActivity() {
announce = call.argument<String>("announce"),
speedSensor = call.argument<String>("speedSensor"),
powerMeter = call.argument<String>("powerMeter"),
wheelMm = call.argument<Int>("wheelMm"),
)
result.success(null)
}

View file

@ -66,6 +66,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_ANNOUNCE = "announce"
private const val EXTRA_SPEED_SENSOR = "speedSensor"
private const val EXTRA_POWER_METER = "powerMeter"
private const val EXTRA_WHEEL_MM = "wheelMm"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -88,7 +89,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
* file at [music] says (D47, D48; null: none), announcing each distance
* passed in the words of [announce] (JSON, D49; null: never), with the
* bike's speed and cadence sensor at [speedSensor] and its power meter at
* [powerMeter] (D49; null: none).
* [powerMeter] (D49; null: none), its wheel [wheelMm] round.
*/
fun start(
context: Context,
@ -105,6 +106,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
announce: String?,
speedSensor: String?,
powerMeter: String?,
wheelMm: Int?,
) {
ContextCompat.startForegroundService(
context,
@ -122,7 +124,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_MUSIC, music)
.putExtra(EXTRA_ANNOUNCE, announce)
.putExtra(EXTRA_SPEED_SENSOR, speedSensor)
.putExtra(EXTRA_POWER_METER, powerMeter),
.putExtra(EXTRA_POWER_METER, powerMeter)
.putExtra(EXTRA_WHEEL_MM, wheelMm ?: 0),
)
}
@ -349,6 +352,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
).also { it.open() }
}
speedSensorAt?.let { address ->
// What the app works the wheel's distance out by (D49).
intent.getIntExtra(EXTRA_WHEEL_MM, 0).takeIf { it > 0 }?.let { write("wheelsize $now $it") }
speedSensor =
NotifyLink(
this,

View file

@ -540,5 +540,11 @@
"workoutPedalling": "Trittfrequenz",
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
"workoutNormalisedPower": "normalisierte Leistung",
"workoutRpmValue": "{rpm} U/min"
"workoutRpmValue": "{rpm} U/min",
"recordWheel": "Radumfang",
"recordWheelMeasured": "{mm} mm, per GPS gemessen",
"recordWheelTyped": "{mm} mm, wie du ihn eingegeben hast",
"recordWheelDefault": "{mm} mm, bis GPS ihn auf 2 km Fahrt gemessen hat",
"recordWheelHint": "In Millimetern, wie auf dem Reifen (700×25C: 2105); leer, damit GPS ihn misst",
"recordWheelSave": "Speichern"
}

View file

@ -680,5 +680,14 @@
"workoutAverageCadence": "average cadence",
"workoutNormalisedPower": "normalised power",
"workoutRpmValue": "{rpm} rpm",
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}}
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}},
"recordWheel": "Wheel circumference",
"recordWheelMeasured": "{mm} mm, measured by GPS",
"@recordWheelMeasured": {"placeholders": {"mm": {"type": "int"}}},
"recordWheelTyped": "{mm} mm, as you typed it",
"@recordWheelTyped": {"placeholders": {"mm": {"type": "int"}}},
"recordWheelDefault": "{mm} mm until GPS has measured it on a ride of 2 km",
"@recordWheelDefault": {"placeholders": {"mm": {"type": "int"}}},
"recordWheelHint": "In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it",
"recordWheelSave": "Save"
}

View file

@ -3267,6 +3267,42 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{rpm} rpm'**
String workoutRpmValue(int rpm);
/// No description provided for @recordWheel.
///
/// In en, this message translates to:
/// **'Wheel circumference'**
String get recordWheel;
/// No description provided for @recordWheelMeasured.
///
/// In en, this message translates to:
/// **'{mm} mm, measured by GPS'**
String recordWheelMeasured(int mm);
/// No description provided for @recordWheelTyped.
///
/// In en, this message translates to:
/// **'{mm} mm, as you typed it'**
String recordWheelTyped(int mm);
/// No description provided for @recordWheelDefault.
///
/// In en, this message translates to:
/// **'{mm} mm until GPS has measured it on a ride of 2 km'**
String recordWheelDefault(int mm);
/// No description provided for @recordWheelHint.
///
/// In en, this message translates to:
/// **'In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it'**
String get recordWheelHint;
/// No description provided for @recordWheelSave.
///
/// In en, this message translates to:
/// **'Save'**
String get recordWheelSave;
}
class _AppLocalizationsDelegate

View file

@ -1959,4 +1959,29 @@ class AppLocalizationsDe extends AppLocalizations {
String workoutRpmValue(int rpm) {
return '$rpm U/min';
}
@override
String get recordWheel => 'Radumfang';
@override
String recordWheelMeasured(int mm) {
return '$mm mm, per GPS gemessen';
}
@override
String recordWheelTyped(int mm) {
return '$mm mm, wie du ihn eingegeben hast';
}
@override
String recordWheelDefault(int mm) {
return '$mm mm, bis GPS ihn auf 2 km Fahrt gemessen hat';
}
@override
String get recordWheelHint =>
'In Millimetern, wie auf dem Reifen (700×25C: 2105); leer, damit GPS ihn misst';
@override
String get recordWheelSave => 'Speichern';
}

View file

@ -1947,6 +1947,31 @@ class AppLocalizationsEn extends AppLocalizations {
String workoutRpmValue(int rpm) {
return '$rpm rpm';
}
@override
String get recordWheel => 'Wheel circumference';
@override
String recordWheelMeasured(int mm) {
return '$mm mm, measured by GPS';
}
@override
String recordWheelTyped(int mm) {
return '$mm mm, as you typed it';
}
@override
String recordWheelDefault(int mm) {
return '$mm mm until GPS has measured it on a ride of 2 km';
}
@override
String get recordWheelHint =>
'In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it';
@override
String get recordWheelSave => 'Save';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -92,6 +92,15 @@ class RecordingBloc(
_storeOf(event.kind).save(event.device);
emit(state._withDevice(event.kind, event.device));
});
on<RecordingEventWheelSet>((event, emit) {
final sensor = state.speedSensor;
if (sensor == null) {
return;
}
final updated = sensor.withWheel(event.mm, typed: true);
_storeOf(DeviceKind.speedSensor).save(updated);
emit(state._withDevice(DeviceKind.speedSensor, updated));
});
on<RecordingEventDeviceForgotten>((event, emit) {
_storeOf(event.kind).forget();
emit(state._withDevice(event.kind, null));
@ -238,6 +247,9 @@ class RecordingBloc(
speedSensor: activity == Activity.cycling
? state.speedSensor?.address
: null,
wheelMm: activity == Activity.cycling && state.speedSensor != null
? state.speedSensor?.wheelMm ?? defaultWheelMm
: null,
powerMeter: activity == Activity.cycling
? state.powerMeter?.address
: null,
@ -298,11 +310,29 @@ class RecordingBloc(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
devices: state.devices,
devices: _measured(recording),
),
);
}
/// The devices, the speed sensor's wheel measured by [recording]'s GPS
/// where the person didn't type it (D49): the first measure as it is,
/// later ones moving it by a quarter.
Map<DeviceKind, KnownDevice> _measured(Recording recording) {
final sensor = state.speedSensor;
final measured = measuredWheelMm(recording);
if (sensor == null || sensor.wheelTyped || measured == null) {
return state.devices;
}
final mm = switch (sensor.wheelMm) {
final before? => ((3 * before + measured) / 4).round(),
null => measured,
};
final updated = sensor.withWheel(mm, typed: false);
_storeOf(DeviceKind.speedSensor).save(updated);
return {...state.devices, DeviceKind.speedSensor: updated};
}
Future<void> _onDiscard(
RecordingEventDiscardRequested event,
Emitter<RecordingState> emit,
@ -524,6 +554,10 @@ final class const RecordingEventDeviceChosen(
final KnownDevice device,
) extends RecordingEvent;
/// The person typed their speed sensor's wheel's circumference [mm] (D49);
/// null: GPS measures it again.
final class const RecordingEventWheelSet(final int? mm) extends RecordingEvent;
/// The person forgot their [kind] on this device.
final class const RecordingEventDeviceForgotten(final DeviceKind kind)
extends RecordingEvent;

View file

@ -49,6 +49,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
Map<String, Object?>? announce,
String? speedSensor,
String? powerMeter,
int? wheelMm,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
@ -63,6 +64,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'announce': announce == null ? null : jsonEncode(announce),
'speedSensor': speedSensor,
'powerMeter': powerMeter,
'wheelMm': wheelMm,
});
@override

View file

@ -4,15 +4,31 @@ import 'dart:io';
import 'package:equatable/equatable.dart';
/// The person's heart-rate strap or rower on this device (ADR 0030, D27,
/// D28): where it is and what it is called. Never synced, as each phone finds
/// it anew.
/// The circumference a speed sensor's wheel is taken to have until GPS
/// measures it (D49): a road bike's 700×25C.
const defaultWheelMm = 2105;
/// The person's heart-rate strap, rower or bike's sensor on this device
/// (ADR 0030, D27, D28, D49): where it is and what it is called, and for a
/// speed sensor its wheel's circumference in millimetres, measured by GPS
/// or as the person typed it ([wheelTyped]). Never synced, as each phone
/// finds it anew.
final class const KnownDevice({
required final String address,
final String? name,
final int? wheelMm,
final bool wheelTyped = false,
}) with Equatable {
/// As it is, its wheel [mm] round, typed or measured; null forgets it.
KnownDevice withWheel(int? mm, {required bool typed}) => KnownDevice(
address: address,
name: name,
wheelMm: mm,
wheelTyped: mm != null && typed,
);
@override
List<Object?> get props => [address, name];
List<Object?> get props => [address, name, wheelMm, wheelTyped];
}
/// Remembers a [KnownDevice] in a file of the person's folder; a file it
@ -24,10 +40,19 @@ class DeviceStore(final File _file) {
}
try {
return switch (jsonDecode(_file.readAsStringSync())) {
{'address': final String address, 'name': final String? name} =>
{
'address': final String address,
'name': final String? name,
} &&
final json =>
KnownDevice(
address: address,
name: name,
wheelMm: switch (json['wheelMm']) {
final int mm => mm,
_ => null,
},
wheelTyped: json['wheelTyped'] == true,
),
_ => null,
};
@ -37,8 +62,13 @@ class DeviceStore(final File _file) {
}
}
void save(KnownDevice strap) => _file.writeAsStringSync(
jsonEncode({'address': strap.address, 'name': strap.name}),
void save(KnownDevice device) => _file.writeAsStringSync(
jsonEncode({
'address': device.address,
'name': device.name,
'wheelMm': ?device.wheelMm,
if (device.wheelTyped) 'wheelTyped': true,
}),
);
void forget() {

View file

@ -35,13 +35,34 @@ Duration? _splitOf(
return Duration(milliseconds: (time.inMilliseconds * 500 / metres).round());
}
/// How far the wheel went over the last 30 s up to [now] and how long that
/// took (D49), as [recentOf] has GPS's.
(double, Duration)? _wheelRecent(Recording recording, DateTime now) {
final from = now.subtract(const Duration(seconds: 30));
final recent = [
for (final reading in recording.wheel)
if (!reading.time.isBefore(from)) reading,
];
if (recent.length < 2 || recording.wheelMm == null) {
return null;
}
return (
(recent.last.value - recent.first.value) * recording.wheelMm! / 1000,
recent.last.time.difference(recent.first.time),
);
}
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
/// worked out as a saved workout's totals are (D16, D20).
LiveRecording liveOf(Recording recording, DateTime now) {
final gps = usesGps(recording.activity);
final last = recording.pauses.lastOrNull;
final paused = last?.paused ?? false;
final recent = paused ? null : recentOf(recording, now);
final recent = paused
? null
: rodeByWheel(recording)
? _wheelRecent(recording, now)
: recentOf(recording, now);
final (pace, kmh) = switch (recent) {
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),

View file

@ -95,18 +95,89 @@ Recording recordingUntil(Recording recording, DateTime time) => Recording(
for (final sample in recording.rowing)
if (!sample.time.isAfter(time)) sample,
],
wheel: [
for (final reading in recording.wheel)
if (!reading.time.isAfter(time)) reading,
],
wheelMm: recording.wheelMm,
);
/// How far it went in metres: on the rower, the metres it counted while
/// moving (D28); else the track's length, null without fixes, from fix to fix
/// while it moves, the imprecise ones left out, starting afresh after each
/// pause.
int? distanceOf(Recording recording) => recording.rowing.isNotEmpty
? rowedMetresOf(recording)
: switch (_metres(recording)) {
final metres? => metres.round(),
null => null,
};
/// moving (D28); on a bike whose wheel GPS fell short of (D49), the wheel's;
/// else the track's length, null without fixes, from fix to fix while it
/// moves, the imprecise ones left out, starting afresh after each pause.
int? distanceOf(Recording recording) {
if (recording.rowing.isNotEmpty) {
return rowedMetresOf(recording);
}
final gps = _metres(recording);
final wheel = wheelMetresOf(recording);
if (usesWheel(gps, wheel)) {
return wheel!.round();
}
return gps?.round();
}
/// The wheel's share GPS must reach for its track to give the distance
/// (D49): below it, GPS couldn't (a turbo trainer, a tunnel).
const _wheelShare = 0.9;
/// Whether the distance is the wheel's [wheel] metres rather than GPS's
/// [gps].
bool usesWheel(double? gps, double? wheel) =>
wheel != null && wheel > 0 && (gps == null || gps < wheel * _wheelShare);
/// Whether [recording]'s distance is its wheel's (D49).
bool rodeByWheel(Recording recording) =>
recording.rowing.isEmpty &&
usesWheel(_metres(recording), wheelMetresOf(recording));
/// The metres the wheel turned while not paused (D49), by its
/// circumference; null without either.
double? wheelMetresOf(Recording recording) {
final mm = recording.wheelMm;
final revolutions = _wheelTurns(recording);
if (mm == null || revolutions == null) {
return null;
}
return revolutions * mm / 1000;
}
/// The wheel's revolutions while not paused: each reading's growth over
/// the one before, those taken while paused left out; null without any.
int? _wheelTurns(Recording recording) {
if (recording.wheel.isEmpty) {
return null;
}
final pauses = recording.pauses;
var next = 0;
var paused = false;
int? before;
var turns = 0;
for (final reading in recording.wheel) {
while (next < pauses.length && !pauses[next].time.isAfter(reading.time)) {
paused = pauses[next++].paused;
}
if (before != null && !paused && reading.value > before) {
turns += reading.value - before;
}
before = reading.value;
}
return turns;
}
/// The wheel's circumference, in millimetres, [recording] measured by GPS
/// (D49): its track's length over the wheel's revolutions, where it went
/// 2 km at least by both and they make a wheel (1.5 to 2.5 m); else null.
int? measuredWheelMm(Recording recording) {
final gps = _metres(recording);
final turns = _wheelTurns(recording);
if (gps == null || turns == null || turns == 0 || gps < 2000) {
return null;
}
final mm = (gps * 1000 / turns).round();
return mm >= 1500 && mm <= 2500 ? mm : null;
}
/// The metres the rower counted while not paused: each frame's growth over
/// the one before, those taken while paused left out.
@ -296,7 +367,16 @@ List<SeriesChunk> seriesOf(Recording recording) {
'dpa': [for (final pressure in pressures) pressure.decipascals],
},
),
...readings(SeriesKind.wheel, 'revs', recording.wheel),
...chunksOf(
SeriesKind.wheel,
start: start,
times: times(recording.wheel.map((reading) => reading.time)),
columns: {
'revs': [for (final reading in recording.wheel) reading.value],
// The same for each: a column delta-coded costs next to nothing.
'mm': [for (final _ in recording.wheel) recording.wheelMm],
},
),
...readings(SeriesKind.pedalling, 'rpm', recording.pedalling),
...readings(SeriesKind.power, 'watts', recording.power),
...chunksOf(

View file

@ -129,6 +129,9 @@ abstract interface class RecorderPlatform() {
/// The bike's speed and cadence sensor and power meter it reads (D49).
String? speedSensor,
String? powerMeter,
/// The speed sensor's wheel's circumference in millimetres (D49).
int? wheelMm,
});
/// Pauses, or goes on, by hand.

View file

@ -34,6 +34,7 @@ const recordingFileMagic = 'halkyon-train-recording';
/// pw <time> <watts>
/// csc <time> connecting|connected|lost
/// power <time> connecting|connected|lost
/// wheelsize <time> <the wheel's circumference, mm>
/// loop <time> connecting|connected|lost
/// plan <time> <the plan's payload, JSON>
/// step <time> <its index in the plan's run>|done
@ -82,6 +83,7 @@ Recording? readRecording(String text) {
final power = <Reading>[];
final speedSensors = <LinkMark>[];
final powerMeters = <LinkMark>[];
int? wheelMm;
PlanContent? plan;
int? planMaxHeartRate;
final planSteps = <StepMark>[];
@ -188,6 +190,8 @@ Recording? readRecording(String text) {
_ => rowers,
}).add(LinkMark(time: time, state: state));
}
case ['wheelsize', _, final mm]:
wheelMm = int.tryParse(mm) ?? wheelMm;
case [final kind, _, final value]
when kind == 'w' || kind == 'c' || kind == 'pw':
if (int.tryParse(value) case final value?) {
@ -228,6 +232,7 @@ Recording? readRecording(String text) {
power: power,
speedSensors: speedSensors,
powerMeters: powerMeters,
wheelMm: wheelMm,
plan: plan,
planSteps: planSteps,
planMaxHeartRate: planMaxHeartRate,

View file

@ -181,6 +181,10 @@ final class const Recording({
final List<LinkMark> speedSensors = const [],
final List<LinkMark> powerMeters = const [],
/// The wheel's circumference in millimetres the recording started with,
/// where it read a speed sensor (D49).
final int? wheelMm,
/// The interval plan it ran, where it ran one, its steps' starts, and
/// the highest heart rate its zones were worked out from (D42).
final PlanContent? plan,
@ -229,6 +233,7 @@ final class const Recording({
power,
speedSensors,
powerMeters,
wheelMm,
plan,
planSteps,
planMaxHeartRate,

View file

@ -125,7 +125,13 @@ class const _DeviceSheet({
bloc.add(
RecordingEventDeviceChosen(
kind,
KnownDevice(address: device.address, name: device.name),
// The one remembered keeps its wheel (D49).
remembered?.address == device.address
? remembered!
: KnownDevice(
address: device.address,
name: device.name,
),
),
);
Navigator.of(context).pop();

View file

@ -8,6 +8,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.dart';
@ -200,6 +201,28 @@ class _RecordSheetState() extends State<_RecordSheet> {
onTap: () => _chooseDevice(context, kind),
),
),
if (_activity == Activity.cycling)
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) => before.speedSensor != now.speedSensor,
builder: (context, state) => switch (state.speedSensor) {
null => const SizedBox.shrink(),
final sensor => ListTile(
leading: const Icon(LucideIcons.circleDot),
title: Text(l10n.recordWheel),
subtitle: Text(switch (sensor) {
KnownDevice(:final wheelMm?, wheelTyped: true) =>
l10n.recordWheelTyped(wheelMm),
KnownDevice(:final wheelMm?) => l10n.recordWheelMeasured(
wheelMm,
),
_ => l10n.recordWheelDefault(defaultWheelMm),
}),
trailing: const Icon(LucideIcons.pencil),
onTap: () => _typeWheel(context, sensor),
),
},
),
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) => before.strap != now.strap,
@ -346,6 +369,46 @@ class _RecordSheetState() extends State<_RecordSheet> {
);
}
/// Asks for the wheel's circumference (D49); empty has GPS measure it.
Future<void> _typeWheel(BuildContext context, KnownDevice sensor) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(
text: sensor.wheelTyped ? '${sensor.wheelMm}' : '',
);
final typed = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.recordWheel),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(helperText: l10n.recordWheelHint),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: Text(l10n.recordWheelSave),
),
],
),
);
controller.dispose();
if (typed == null) {
return;
}
final mm = int.tryParse(typed.trim());
widget.bloc.add(
RecordingEventWheelSet(
mm != null && mm >= 1000 && mm <= 3000 ? mm : null,
),
);
}
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
showDeviceSheet(
context,

View file

@ -123,6 +123,8 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
// How far it had gone over its moving time: by the track, or the rower.
final progress = rowed
? _rowedProgress(recording)
: rodeByWheel(recording)
? _wheelProgress(recording)
: [for (final p in points) (metres: p.metres, moving: p.moving)];
final byDistance = progress.length > 1 && progress.last.metres > 0;
double xAt(Duration moving) =>
@ -391,6 +393,26 @@ List<_Progress> _rowedProgress(Recording recording) {
return progress;
}
/// The wheel's metres over its moving time (D49), by its circumference.
List<_Progress> _wheelProgress(Recording recording) {
final mm = recording.wheelMm ?? 0;
final progress = <_Progress>[];
int? before;
var turns = 0;
for (final (reading, moving) in _whileMoving(
recording,
recording.wheel,
(reading) => reading.time,
)) {
if (before != null && reading.value > before) {
turns += reading.value - before;
}
before = reading.value;
progress.add((metres: turns * mm / 1000, moving: moving));
}
return progress;
}
/// The strokes rowed while moving.
int _rowedStrokes(Recording recording) {
int? before;
@ -457,6 +479,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final heartRates = <HeartRate>[];
final rowing = <RowingSample>[];
final pressures = <Pressure>[];
int? wheelMm;
final readings = {
SeriesKind.wheel: <Reading>[],
SeriesKind.pedalling: <Reading>[],
@ -523,6 +546,9 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
}
}
case SeriesKind.wheel || SeriesKind.pedalling || SeriesKind.power:
if (chunk.kind == SeriesKind.wheel) {
wheelMm ??= columns['mm']?.nonNulls.firstOrNull;
}
final column = switch (chunk.kind) {
SeriesKind.wheel => 'revs',
SeriesKind.pedalling => 'rpm',
@ -557,6 +583,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
wheel: readings[SeriesKind.wheel]!,
pedalling: readings[SeriesKind.pedalling]!,
power: readings[SeriesKind.power]!,
wheelMm: wheelMm,
endedAt: content.endedAt,
);
}
@ -683,6 +710,7 @@ Recording withBarometer(Recording recording) {
power: recording.power,
speedSensors: recording.speedSensors,
powerMeters: recording.powerMeters,
wheelMm: recording.wheelMm,
plan: recording.plan,
planSteps: recording.planSteps,
planMaxHeartRate: recording.planMaxHeartRate,

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 = [

Some files were not shown because too many files have changed in this diff Show more