fix(train): a ride by the wheel goes out with its power, cadence and distance, and learns no wheel from tunnels (ADR 0030, D49, D51)

A trainer ride exported heart rates alone (or nothing) and a ride through tunnels the wheel's totals over GPS's distances; GPX's type came before its description and TCX's watts before the cadence, against their schemas. The wheel's size was learnt from rides GPS covered only in part. The live screen parsed the whole recording again on every sensor frame; now it reads each line once and shows sensors on the second's tick. The offline maps' requests time out after 20 s.

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-30 12:55:41 +01:00
commit c8f16686ec
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 636 additions and 214 deletions

View file

@ -59,6 +59,10 @@ class OfflineMaps(
final Directory _folder, {
final http.Client? client,
final int parallel = 4,
/// How long a tile, a style or its sprites may take to come before it
/// counts as not come: a stalled connection would hold a worker for good.
final Duration timeout = const Duration(seconds: 20),
}) {
late final http.Client _http = client ?? http.Client();
@ -194,10 +198,9 @@ class OfflineMaps(
.replaceAll('{x}', x)
.replaceAll('{y}', y);
try {
final response = await _http.get(
Uri.parse(url),
headers: tileHeadersOf(source),
);
final response = await _http
.get(Uri.parse(url), headers: tileHeadersOf(source))
.timeout(timeout);
if (response.statusCode == 200) {
await _write(file, response.bodyBytes);
size = response.bodyBytes.length;
@ -372,9 +375,11 @@ class OfflineMaps(
for (final ratio in ['@2x', '']) {
try {
index = _object(await _get(_sprite(sprite, ratio, '.json')));
image = (await _http.get(
Uri.parse(_sprite(sprite, ratio, '.png')),
)).bodyBytes;
image =
(await _http
.get(Uri.parse(_sprite(sprite, ratio, '.png')))
.timeout(timeout))
.bodyBytes;
break;
} on Object {
index = null;
@ -467,7 +472,9 @@ class OfflineMaps(
}
Future<Object?> _get(String url, {Map<String, String>? headers}) async {
final response = await _http.get(Uri.parse(url), headers: headers);
final response = await _http
.get(Uri.parse(url), headers: headers)
.timeout(timeout);
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
}

View file

@ -106,9 +106,9 @@ class RecordingBloc(
emit(state._withDevice(event.kind, null));
});
on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line);
// A pause, its end or the end show at once; samples with the next
// second.
_lines.add(event.line);
// A pause, its end or the end show at once; samples, the sensors'
// readings among them, with the next second.
if (!_sample.hasMatch(event.line)) {
_refresh(emit);
}
@ -120,10 +120,10 @@ class RecordingBloc(
final Stream<void> _ticks =
ticks ?? Stream<void>.periodic(const Duration(seconds: 1));
static final _sample = RegExp('^[phsb] ');
static final _sample = RegExp('^(?:[phsbwcr]|pw) ');
/// The lines the recorder wrote so far.
final _lines = StringBuffer();
/// The lines the recorder wrote so far, each read as it came.
var _lines = RecordingReader();
StreamSubscription<String>? _following;
StreamSubscription<void>? _ticking;
@ -316,8 +316,9 @@ class RecordingBloc(
}
/// 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.
/// where the person didn't type it and GPS covered the ride (D49,
/// `measuredWheelMm`): 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);
@ -349,7 +350,7 @@ class RecordingBloc(
/// Follows the recorder's lines, from the file's first.
void _follow() {
_lines.clear();
_lines = RecordingReader();
_following = _platform.lines().listen(
(line) => add(RecordingEventLineWritten(line)),
);
@ -372,7 +373,7 @@ class RecordingBloc(
if (state.phase != RecordingPhase.recording) {
return;
}
final recording = readRecording(_lines.toString());
final recording = _lines.recording;
if (recording == null) {
return;
}

View file

@ -167,12 +167,18 @@ int? _wheelTurns(Recording recording) {
}
/// 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.
/// (D49): its track's length over the wheel's revolutions, where GPS
/// covered the ride (its distance is GPS's: a tunnel's stretch would read
/// as a smaller wheel), 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) {
if (gps == null ||
turns == null ||
turns == 0 ||
gps < 2000 ||
usesWheel(gps, wheelMetresOf(recording))) {
return null;
}
final mm = (gps * 1000 / turns).round();

View file

@ -51,197 +51,234 @@ const recordingFileMagic = 'halkyon-train-recording';
Recording? readRecording(String text) {
// What follows the last line break is a line cut short, or nothing.
final lines = text.split('\n')..removeLast();
if (lines.isEmpty) {
return null;
}
final header = lines.first.split(' ');
if (header case [
recordingFileMagic,
final version,
final activityName,
final started,
]) {
final activity = Activity.values.asNameMap()[activityName];
final startedAt = _time(started);
if (int.tryParse(version) != recordingFileVersion ||
activity == null ||
startedAt == null) {
log('A recording not read: $version, $activityName', name: 'Recording');
return null;
final reader = RecordingReader();
lines.forEach(reader.add);
return reader.recording;
}
/// A recording file read a line at a time as the recorder appends it, as
/// [readRecording] reads it whole: what shows while it records takes in
/// only the lines new since it last looked.
final class RecordingReader() {
/// The file's activity and start once its header was read; null before,
/// or where it can't be read.
(Activity, DateTime)? _header;
/// Whether its first line was read.
var _started = false;
final _fixes = <Fix>[];
final _heartRates = <HeartRate>[];
final _steps = <DateTime>[];
final _pauses = <PauseMark>[];
final _pressures = <Pressure>[];
final _straps = <LinkMark>[];
final _loops = <LinkMark>[];
final _rowing = <RowingSample>[];
final _rowers = <LinkMark>[];
final _wheel = <Reading>[];
final _pedalling = <Reading>[];
final _power = <Reading>[];
final _speedSensors = <LinkMark>[];
final _powerMeters = <LinkMark>[];
int? _wheelMm;
PlanContent? _plan;
int? _planMaxHeartRate;
final _planSteps = <StepMark>[];
RouteContent? _route;
final _guidance = <GuideMark>[];
DateTime? _endedAt;
/// Takes in [line], whole and without its line break.
void add(String line) {
if (!_started) {
_started = true;
_header = _headerOf(line);
return;
}
final fixes = <Fix>[];
final heartRates = <HeartRate>[];
final steps = <DateTime>[];
final pauses = <PauseMark>[];
final pressures = <Pressure>[];
final straps = <LinkMark>[];
final loops = <LinkMark>[];
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
final wheel = <Reading>[];
final pedalling = <Reading>[];
final power = <Reading>[];
final speedSensors = <LinkMark>[];
final powerMeters = <LinkMark>[];
int? wheelMm;
PlanContent? plan;
int? planMaxHeartRate;
final planSteps = <StepMark>[];
RouteContent? route;
final guidance = <GuideMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
final fields = line.split(' ');
final time = fields.length > 1 ? _time(fields[1]) : null;
if (time == null) {
continue;
}
if (fields.first == 'plan' && fields.length > 2) {
// Its JSON may hold spaces: all after the time.
final json = line.substring('plan '.length + fields[1].length + 1);
plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
planMaxHeartRate = switch (_jsonOf(json)) {
{'maxHeartRate': final int max} => max,
_ => null,
};
continue;
}
switch (fields) {
case ['route', _, final payload]:
route = _routeOf(payload);
case ['guide', _, final kind, final index]
when kind == 'next' || kind == 'turn':
if (int.tryParse(index) case final index? when index >= 0) {
guidance.add(
GuideMark(
time: time,
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
index: index,
),
);
}
case ['guide', _, 'off', final metres, final bearing]:
if ((int.tryParse(metres), int.tryParse(bearing)) case (
final metres?,
final bearing?,
)) {
guidance.add(
GuideMark(
time: time,
kind: GuideKind.off,
metres: metres,
bearing: bearing,
),
);
}
case ['guide', _, 'on']:
guidance.add(GuideMark(time: time, kind: GuideKind.on));
case ['guide', _, 'arrived']:
guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
case ['step', _, 'done']:
planSteps.add(StepMark(time: time));
case ['step', _, final index]:
if (int.tryParse(index) case final index? when index >= 0) {
planSteps.add(StepMark(time: time, index: index));
}
case ['p', _, final lat, final lon, final accuracy, final altitude]:
final (latE7, lonE7, accuracyCm) = (
int.tryParse(lat),
int.tryParse(lon),
int.tryParse(accuracy),
);
if (latE7 != null && lonE7 != null && accuracyCm != null) {
fixes.add(
Fix(
time: time,
latE7: latE7,
lonE7: lonE7,
accuracyCm: accuracyCm,
altitudeCm: int.tryParse(altitude),
),
);
}
case ['h', _, final bpm]:
if (int.tryParse(bpm) case final bpm?) {
heartRates.add(HeartRate(time: time, bpm: bpm));
}
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'):
pauses.add(
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case [final link, _, final name]
when const {'strap', 'rower', 'loop', 'csc', 'power'}.contains(
link,
):
if (LinkState.values.asNameMap()[name] case final state?) {
(switch (link) {
'strap' => straps,
'loop' => loops,
'csc' => speedSensors,
'power' => powerMeters,
_ => 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?) {
(switch (kind) {
'w' => wheel,
'c' => pedalling,
_ => power,
}).add(Reading(time: time, value: value));
}
case ['r', _, final rate, final strokes, final metres, final watts]:
rowing.add(
RowingSample(
if (_header == null) {
return;
}
final fields = line.split(' ');
final time = fields.length > 1 ? _time(fields[1]) : null;
if (time == null) {
return;
}
if (fields.first == 'plan' && fields.length > 2) {
// Its JSON may hold spaces: all after the time.
final json = line.substring('plan '.length + fields[1].length + 1);
_plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
_planMaxHeartRate = switch (_jsonOf(json)) {
{'maxHeartRate': final int max} => max,
_ => null,
};
return;
}
switch (fields) {
case ['route', _, final payload]:
_route = _routeOf(payload);
case ['guide', _, final kind, final index]
when kind == 'next' || kind == 'turn':
if (int.tryParse(index) case final index? when index >= 0) {
_guidance.add(
GuideMark(
time: time,
strokeRate: double.tryParse(rate),
strokes: int.tryParse(strokes),
metres: int.tryParse(metres),
watts: int.tryParse(watts),
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
index: index,
),
);
case ['end', _]:
endedAt = time;
}
}
case ['guide', _, 'off', final metres, final bearing]:
if ((int.tryParse(metres), int.tryParse(bearing)) case (
final metres?,
final bearing?,
)) {
_guidance.add(
GuideMark(
time: time,
kind: GuideKind.off,
metres: metres,
bearing: bearing,
),
);
}
case ['guide', _, 'on']:
_guidance.add(GuideMark(time: time, kind: GuideKind.on));
case ['guide', _, 'arrived']:
_guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
case ['step', _, 'done']:
_planSteps.add(StepMark(time: time));
case ['step', _, final index]:
if (int.tryParse(index) case final index? when index >= 0) {
_planSteps.add(StepMark(time: time, index: index));
}
case ['p', _, final lat, final lon, final accuracy, final altitude]:
final (latE7, lonE7, accuracyCm) = (
int.tryParse(lat),
int.tryParse(lon),
int.tryParse(accuracy),
);
if (latE7 != null && lonE7 != null && accuracyCm != null) {
_fixes.add(
Fix(
time: time,
latE7: latE7,
lonE7: lonE7,
accuracyCm: accuracyCm,
altitudeCm: int.tryParse(altitude),
),
);
}
case ['h', _, final bpm]:
if (int.tryParse(bpm) case final bpm?) {
_heartRates.add(HeartRate(time: time, bpm: bpm));
}
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'):
_pauses.add(
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case [final link, _, final name]
when const {'strap', 'rower', 'loop', 'csc', 'power'}.contains(
link,
):
if (LinkState.values.asNameMap()[name] case final state?) {
(switch (link) {
'strap' => _straps,
'loop' => _loops,
'csc' => _speedSensors,
'power' => _powerMeters,
_ => _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?) {
(switch (kind) {
'w' => _wheel,
'c' => _pedalling,
_ => _power,
}).add(Reading(time: time, value: value));
}
case ['r', _, final rate, final strokes, final metres, final watts]:
_rowing.add(
RowingSample(
time: time,
strokeRate: double.tryParse(rate),
strokes: int.tryParse(strokes),
metres: int.tryParse(metres),
watts: int.tryParse(watts),
),
);
case ['end', _]:
_endedAt = time;
}
}
/// The recording read so far: null where the header is missing, of a
/// newer version or names an activity this app doesn't know.
Recording? get recording {
final header = _header;
if (header == null) {
return null;
}
final (activity, startedAt) = header;
// Copies: the reader goes on taking lines in.
return Recording(
activity: activity,
startedAt: startedAt,
fixes: fixes,
heartRates: heartRates,
steps: steps,
pauses: pauses,
pressures: pressures,
straps: straps,
loops: loops,
rowing: rowing,
rowers: rowers,
wheel: wheel,
pedalling: pedalling,
power: power,
speedSensors: speedSensors,
powerMeters: powerMeters,
wheelMm: wheelMm,
plan: plan,
planSteps: planSteps,
planMaxHeartRate: planMaxHeartRate,
route: route,
guidance: guidance,
endedAt: endedAt,
fixes: [..._fixes],
heartRates: [..._heartRates],
steps: [..._steps],
pauses: [..._pauses],
pressures: [..._pressures],
straps: [..._straps],
loops: [..._loops],
rowing: [..._rowing],
rowers: [..._rowers],
wheel: [..._wheel],
pedalling: [..._pedalling],
power: [..._power],
speedSensors: [..._speedSensors],
powerMeters: [..._powerMeters],
wheelMm: _wheelMm,
plan: _plan,
planSteps: [..._planSteps],
planMaxHeartRate: _planMaxHeartRate,
route: _route,
guidance: [..._guidance],
endedAt: _endedAt,
);
}
return null;
static (Activity, DateTime)? _headerOf(String line) {
if (line.split(' ') case [
recordingFileMagic,
final version,
final activityName,
final started,
]) {
final activity = Activity.values.asNameMap()[activityName];
final startedAt = _time(started);
if (int.tryParse(version) != recordingFileVersion ||
activity == null ||
startedAt == null) {
log('A recording not read: $version, $activityName', name: 'Recording');
return null;
}
return (activity, startedAt);
}
return null;
}
}
RouteContent? _routeOf(String payload) {

View file

@ -59,9 +59,12 @@ final class const ExportTrack({
bool get located => points.any((point) => point.latE7 != null);
}
/// [content] and its [series] as the formats write them: the fixes precise
/// enough taken while moving, as the saved distance counts them, else the
/// rower's frames, else the heart rates; each with the heart rate of the
/// [content] and its [series] as the formats write them: on a ride whose
/// distance is the wheel's (D49), the wheel's readings while moving with
/// its way, each with the precise fix taken since the one before, if any;
/// else the fixes precise enough taken while moving, as the saved distance
/// counts them; else the rower's frames; else the power meter's readings,
/// else the crank's; else the heart rates. Each with the heart rate of the
/// last 5 s, the cadence of the window it fell in (on foot, as strides),
/// a bike's crank cadence and watts of the last 5 s (D49), and the rower's
/// stroke rate and watts.
@ -89,8 +92,42 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
[for (final reading in recording.power) (reading.time, reading.value)],
const Duration(seconds: 5),
);
int? cadenceAt(DateTime time) => switch (steps.at(time)) {
final spm? => (spm / 2).round(),
null => crank.at(time),
};
final points = <ExportPoint>[];
if (recording.fixes.isNotEmpty) {
if (rodeByWheel(recording)) {
// The points' way the saved distance's: GPS's would fall short of it.
final fixes = _movingFixes(recording);
var nextFix = 0;
for (final point in _wheelWay(recording)) {
Fix? fix;
while (nextFix < fixes.length &&
!fixes[nextFix].fix.time.isAfter(point.time)) {
fix = fixes[nextFix++].fix;
}
if (fix != null && point.time.difference(fix.time) > _freshFix) {
fix = null;
}
points.add(
ExportPoint(
time: point.time,
latE7: fix?.latE7,
lonE7: fix?.lonE7,
altitude: switch (fix?.altitudeCm) {
final cm? => cm / 100,
null => null,
},
metres: point.metres,
bpm: heart.at(point.time),
cadence: cadenceAt(point.time),
watts: power.at(point.time),
afterPause: point.afterPause,
),
);
}
} else if (recording.fixes.isNotEmpty) {
for (final point in _movingFixes(recording)) {
final fix = point.fix;
points.add(
@ -104,10 +141,7 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
},
metres: point.metres,
bpm: heart.at(fix.time),
cadence: switch (steps.at(fix.time)) {
final spm? => (spm / 2).round(),
null => crank.at(fix.time),
},
cadence: cadenceAt(fix.time),
watts: power.at(fix.time),
afterPause: point.afterPause,
),
@ -125,6 +159,21 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
),
);
}
} else if (recording.power.isNotEmpty || recording.pedalling.isNotEmpty) {
// A trainer without the wheel: the power meter's, else the crank's.
final readings = recording.power.isNotEmpty
? recording.power
: recording.pedalling;
for (final reading in readings) {
points.add(
ExportPoint(
time: reading.time,
bpm: heart.at(reading.time),
cadence: crank.at(reading.time),
watts: power.at(reading.time),
),
);
}
} else {
for (final rate in recording.heartRates) {
points.add(ExportPoint(time: rate.time, bpm: rate.bpm));
@ -242,12 +291,12 @@ String gpxOfWorkout(
builder.element(
'trk',
nest: () {
builder
..element('name', nest: name)
..element('type', nest: content.activity.name);
// The schema's order: name, cmt, desc, src, link, number, type.
builder.element('name', nest: name);
if (content.notes case final notes?) {
builder.element('desc', nest: notes);
}
builder.element('type', nest: content.activity.name);
final located = [
for (final point in track.points)
if (point.latE7 != null && point.lonE7 != null) point,
@ -324,15 +373,16 @@ String tcxOf(WorkoutContent content, ExportTrack track) {
'TPX',
attributes: {'xmlns': _tcxExtension},
nest: () {
if (point.watts case final watts?) {
builder.element('Watts', nest: '$watts');
}
// The schema's order: Speed, RunCadence, Watts.
if (runCadence != null) {
builder.element(
'RunCadence',
nest: '${runCadence.clamp(0, 254)}',
);
}
if (point.watts case final watts?) {
builder.element('Watts', nest: '$watts');
}
},
),
);
@ -475,6 +525,45 @@ List<({Fix fix, double metres, bool afterPause})> _movingFixes(
return points;
}
/// How old a fix may be to place a reading of the wheel.
const _freshFix = Duration(seconds: 5);
/// The wheel's readings while moving (D49), with its metres up to each, as
/// the saved distance counts them (`wheelMetresOf`: its growth while not
/// paused, by its circumference), and whether a pause came before.
List<({DateTime time, double metres, bool afterPause})> _wheelWay(
Recording recording,
) {
final mm = recording.wheelMm ?? 0;
final pauses = recording.pauses;
final points = <({DateTime time, double metres, bool afterPause})>[];
var next = 0;
var paused = false;
var broken = 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;
broken = true;
}
if (before != null && !paused && reading.value > before) {
turns += reading.value - before;
}
before = reading.value;
if (paused) {
continue;
}
points.add((
time: reading.time,
metres: turns * mm / 1000,
afterPause: broken && points.isNotEmpty,
));
broken = false;
}
return points;
}
/// The latest of timed values at or before a time, no older than [_fresh];
/// asked in time order.
class _Latest(final List<(DateTime, int)> _values, final Duration _fresh) {

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -124,6 +125,30 @@ void main() {
expect(offline.kept.value['route-1']!.done, tilesOf(north).length);
});
test('a tile that stalls is given up after the timeout, counted as one '
"that didn't come", () async {
server.stalling = true;
final offline = OfflineMaps(
folder,
client: server.client,
timeout: const Duration(milliseconds: 50),
);
await offline.keep('route-1', north, source);
final stalled = offline.kept.value['route-1']!;
expect(stalled.missing, stalled.total);
expect(stalled.loading, isFalse);
});
test('a style that stalls is out of reach', () async {
server.stallingStyle = true;
final offline = OfflineMaps(
folder,
client: server.client,
timeout: const Duration(milliseconds: 50),
);
expect(await offline.keep('route-1', north, source), isFalse);
});
test(
'with the style out of reach nothing is kept, and that is said',
() async {
@ -302,11 +327,19 @@ class _Tiles() {
bool empty = false;
bool offline = false;
/// Whether the tiles, or the style, are asked for and never come.
bool stalling = false;
bool stallingStyle = false;
late final http.Client client = MockClient((request) async {
if (offline) {
throw const SocketException('offline');
}
final path = request.url.path;
if ((stalling && path.startsWith('/planet/')) ||
(stallingStyle && path.startsWith('/styles/'))) {
return await Completer<http.Response>().future;
}
return switch (path) {
'/styles/liberty' ||
'/map/style/light.json' ||

View file

@ -164,6 +164,14 @@ void main() {
expect(measuredWheelMm(ride(seconds: 600, gpsMetres: 8)), isNull);
},
);
test("one whose GPS fell short of the wheel's way (a tunnel) measures "
'none: its quotient would read as a smaller wheel', () {
// 3.6 m a second by GPS, 4.2 by the wheel: 86 %, a 1.8 m wheel.
final tunnel = ride(seconds: 1500, gpsMetres: 3.6);
expect(rodeByWheel(tunnel), isTrue);
expect(measuredWheelMm(tunnel), isNull);
});
});
test("the bike's readings are series of their own, read back as they "

View file

@ -236,6 +236,46 @@ void main() {
});
});
test("samples and the sensors' readings show with the next second, not "
'each as it comes; a pause at once', () async {
await withClock(
Clock.fixed(start.add(const Duration(seconds: 5))),
() async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere(
(s) => s.phase == RecordingPhase.recording,
);
await pumpEventQueue();
final shown = <RecordingState>[];
final watching = bloc.stream.listen(shown.add);
for (var s = 1; s <= 5; s++) {
platform
..now = start.add(Duration(seconds: s))
..write('p @ ${s * 500} 0 500 -')
..write('h @ 140')
..write('w @ ${s * 2}')
..write('c @ 90')
..write('pw @ 200')
..write('r @ 24.0 $s ${s * 4} 200');
}
await pumpEventQueue();
expect(shown, isEmpty);
await tick(bloc);
expect(shown, hasLength(1));
expect(shown.single.live!.cadence, 90);
expect(shown.single.live!.watts, 200);
platform.write('pause @ manual');
await pumpEventQueue();
expect(shown, hasLength(2));
expect(shown.last.live!.paused, isTrue);
await watching.cancel();
},
);
});
test('stopped, it is saved as a workout with its series, and the file '
'goes', () async {
final bloc = await open();

View file

@ -307,6 +307,207 @@ void main() {
expect(record[7], 230);
});
group("a ride whose distance is the wheel's (D49)", () {
/// A ride of a minute, the wheel turning 4 times a second (2.1 m round:
/// 8.4 m/s), paused from 20 s to 30 s, the crank at 90, 200 W and a
/// heart rate each second, GPS's fixes those of [fixes].
Recording ride({List<Fix> fixes = const []}) => Recording(
activity: Activity.cycling,
startedAt: start,
fixes: fixes,
wheel: [
for (var s = 0; s <= 60; s++)
Reading(
time: at(s),
value:
(s <= 20
? s
: s < 30
? 20
: s - 10) *
4,
),
],
wheelMm: 2100,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 200)],
heartRates: [
for (var s = 0; s <= 60; s++) HeartRate(time: at(s), bpm: 130),
],
pauses: [
PauseMark(time: at(20.5)),
PauseMark(time: at(30), paused: false),
],
endedAt: at(60),
);
test('on a trainer, a point each reading of the wheel while moving, '
"with its way, the crank's cadence and the watts; FIT and TCX carry "
'them, their distances the totals', () {
final (rode, rideSeries) = recordedWorkout(ride());
final rideTrack = exportTrackOf(rode, rideSeries);
// 50 s turning: 200 turns, 420 m; 52 readings while moving.
expect(rode.distanceMetres, 420);
expect(rideTrack.located, isFalse);
expect(ExportFormat.gpx.takes(rideTrack), isFalse);
expect(rideTrack.points, hasLength(52));
expect(rideTrack.points.first.metres, 0);
expect(rideTrack.points[1].metres, closeTo(8.4, 0.01));
expect(rideTrack.points.last.metres, closeTo(420, 0.01));
expect(
rideTrack.points.where((point) => point.afterPause),
hasLength(1),
);
expect(
rideTrack.points.firstWhere((point) => point.afterPause).time,
at(30),
);
for (final point in rideTrack.points) {
expect((point.bpm, point.cadence, point.watts), (130, 90, 200));
}
final tcx = XmlDocument.parse(tcxOf(rode, rideTrack));
final points = tcx.findAllElements('Trackpoint').toList();
expect(points, hasLength(52));
expect(points.last.getElement('DistanceMeters')!.innerText, '420.0');
expect(points.last.getElement('Cadence')!.innerText, '90');
expect(points.last.findAllElements('Watts').single.innerText, '200');
expect(
tcx
.findAllElements('Lap')
.single
.getElement('DistanceMeters')!
.innerText,
'420',
);
final messages = readFit(fitOf(rode, rideTrack));
final records = [
for (final message in messages)
if (message.global == 20) message.fields,
];
expect(records, hasLength(52));
expect(records.last[4], 90);
expect(records.last[5], 42000);
expect(records.last[7], 200);
final session = messages.singleWhere((message) => message.global == 18);
expect(session.fields[9], 42000);
});
test("with GPS short of the wheel's way (a tunnel), the distances are "
"the wheel's, the fixes where it had them", () {
// GPS's first 20 s alone, at the wheel's pace.
final (rode, rideSeries) = recordedWorkout(
ride(fixes: [for (var s = 0; s <= 20; s++) north(s, s * 8.4)]),
);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rode.distanceMetres, 420);
expect(rideTrack.points, hasLength(52));
expect(rideTrack.points.last.metres, closeTo(420, 0.01));
expect(
[for (final point in rideTrack.points) point.latE7 != null],
[for (var i = 0; i < 52; i++) i <= 20],
);
expect(rideTrack.points[10].metres, closeTo(84, 0.01));
final gpx = XmlDocument.parse(
gpxOfWorkout(rode, rideTrack, name: 'Tunnel'),
);
expect(gpx.findAllElements('trkpt'), hasLength(21));
final records = [
for (final message in readFit(fitOf(rode, rideTrack)))
if (message.global == 20) message.fields,
];
expect(records.last[5], 42000);
});
test("on a trainer without the wheel's sensor, a point each reading of "
'the power meter', () {
final trainer = Recording(
activity: Activity.cycling,
startedAt: start,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [
for (var s = 0; s <= 60; s += 2) Reading(time: at(s), value: 200),
],
endedAt: at(60),
);
final (rode, rideSeries) = recordedWorkout(trainer);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rideTrack.points, hasLength(31));
expect(rideTrack.points.last.cadence, 90);
expect(rideTrack.points.last.watts, 200);
expect(rideTrack.points.last.metres, isNull);
});
test('a ride by GPS keeps its fixes and their way', () {
// GPS all along, at the wheel's pace.
final (rode, rideSeries) = recordedWorkout(
ride(
fixes: [
for (var s = 0; s <= 60; s++)
north(
s,
(s <= 20
? s
: s < 30
? 20
: s - 10) *
8.4,
),
],
),
);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rideTrack.points.every((point) => point.latE7 != null), isTrue);
expect(
rideTrack.points.last.metres,
closeTo(rode.distanceMetres!, 0.5),
);
});
});
test("GPX: the track's name, description and type in the schema's "
'order', () {
final described = WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: at(120),
notes: 'Easy',
);
final gpx = XmlDocument.parse(
gpxOfWorkout(described, track, name: 'Morning run'),
);
expect(
[
for (final element in gpx.findAllElements('trk').single.childElements)
element.localName,
],
['name', 'desc', 'type', 'trkseg', 'trkseg'],
);
});
test("TCX: the extension's RunCadence before the Watts, in the schema's "
'order', () {
final stepped = ExportTrack(
points: [ExportPoint(time: start, cadence: 80, watts: 250)],
laps: [
ExportLap(start: start, end: at(1), movingTime: Duration.zero),
],
);
final tcx = XmlDocument.parse(tcxOf(content, stepped));
expect(
[
for (final element in tcx.findAllElements('TPX').single.childElements)
element.localName,
],
['RunCadence', 'Watts'],
);
});
test('the file is named by when the workout started, in UTC', () {
expect(
exportNameOf(content, ExportFormat.fit),