feat(train): a recording's file read and saved as a workout (ADR 0030, D18, D20)
The recorder's file is text, one sample a line (D18): positions with their accuracy, heart rates, steps, pauses and the end. Reading it drops a last line cut short and skips any line it can't read; a file without its end is a recording cut off, which ends at its last sample. A recording becomes its workout and series: every fix kept with its accuracy (D20), the distance through those within 20 m and never across a pause, the moving time without the pauses (D16), each kind of sample in chunks of ten minutes (D14). D18 to D20 are recorded in ADR 0030. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6171ff10a0
commit
aab25bf932
6 changed files with 658 additions and 2 deletions
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
/// The least accurate fix that counts for distance (ADR 0030, D20), as
|
||||||
|
/// OpenTracks' default; adjustable once Train has settings.
|
||||||
|
const maxAccuracyCm = 2000;
|
||||||
|
|
||||||
|
/// [recording] as the workout it saves (D14) and its series: every sample
|
||||||
|
/// kept (D16, D20), the distance through the fixes precise enough and never
|
||||||
|
/// across a pause, the moving time without the pauses. One cut off ends at
|
||||||
|
/// its last sample (D18).
|
||||||
|
(WorkoutContent, List<SeriesChunk>) recordedWorkout(Recording recording) {
|
||||||
|
final endedAt = recording.endedAt ?? recording.lastSampleAt;
|
||||||
|
return (
|
||||||
|
WorkoutContent(
|
||||||
|
activity: recording.activity,
|
||||||
|
startedAt: recording.startedAt,
|
||||||
|
endedAt: endedAt,
|
||||||
|
distanceMetres: _distance(recording),
|
||||||
|
movingTime: _movingTime(recording, endedAt),
|
||||||
|
),
|
||||||
|
_series(recording),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The track's length in metres, null without fixes: from fix to fix while
|
||||||
|
/// it moves, the imprecise ones left out, starting afresh after each pause.
|
||||||
|
int? _distance(Recording recording) {
|
||||||
|
if (recording.fixes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final pauses = recording.pauses;
|
||||||
|
var next = 0;
|
||||||
|
var paused = false;
|
||||||
|
Fix? last;
|
||||||
|
var metres = 0.0;
|
||||||
|
for (final fix in recording.fixes) {
|
||||||
|
while (next < pauses.length && !pauses[next].time.isAfter(fix.time)) {
|
||||||
|
paused = pauses[next].paused;
|
||||||
|
next++;
|
||||||
|
last = null;
|
||||||
|
}
|
||||||
|
if (paused || fix.accuracyCm > maxAccuracyCm) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (last != null) {
|
||||||
|
metres += _metresBetween(last, fix);
|
||||||
|
}
|
||||||
|
last = fix;
|
||||||
|
}
|
||||||
|
return metres.round();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long it moved: from start to [endedAt], each pause's time left out, one
|
||||||
|
/// still open at the end running to it.
|
||||||
|
Duration _movingTime(Recording recording, DateTime endedAt) {
|
||||||
|
var moving = endedAt.difference(recording.startedAt);
|
||||||
|
DateTime? pausedAt;
|
||||||
|
for (final mark in recording.pauses) {
|
||||||
|
if (mark.paused) {
|
||||||
|
pausedAt ??= mark.time;
|
||||||
|
} else if (pausedAt != null) {
|
||||||
|
moving -= mark.time.difference(pausedAt);
|
||||||
|
pausedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pausedAt != null) {
|
||||||
|
moving -= endedAt.difference(pausedAt);
|
||||||
|
}
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The great-circle distance between two fixes (haversine), on the mean
|
||||||
|
/// earth radius.
|
||||||
|
double _metresBetween(Fix a, Fix b) {
|
||||||
|
const radius = 6371008.8;
|
||||||
|
const perUnit = pi / 180 / 1e7;
|
||||||
|
final (lat1, lat2) = (a.latE7 * perUnit, b.latE7 * perUnit);
|
||||||
|
final dLat = lat2 - lat1;
|
||||||
|
final dLon = (b.lonE7 - a.lonE7) * perUnit;
|
||||||
|
final h =
|
||||||
|
pow(sin(dLat / 2), 2) + cos(lat1) * cos(lat2) * pow(sin(dLon / 2), 2);
|
||||||
|
return 2 * radius * asin(sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every sample as its series' chunks, a kind without samples left out.
|
||||||
|
List<SeriesChunk> _series(Recording recording) {
|
||||||
|
final start = recording.startedAt;
|
||||||
|
List<int> times(Iterable<DateTime> of) => [
|
||||||
|
for (final time in of) time.difference(start).inMilliseconds,
|
||||||
|
];
|
||||||
|
int flag(bool value) => value ? 1 : 0;
|
||||||
|
final fixes = recording.fixes;
|
||||||
|
final rates = recording.heartRates;
|
||||||
|
final pauses = recording.pauses;
|
||||||
|
return [
|
||||||
|
...chunksOf(
|
||||||
|
SeriesKind.position,
|
||||||
|
start: start,
|
||||||
|
times: times(fixes.map((fix) => fix.time)),
|
||||||
|
columns: {
|
||||||
|
'lat': [for (final fix in fixes) fix.latE7],
|
||||||
|
'lon': [for (final fix in fixes) fix.lonE7],
|
||||||
|
'alt': [for (final fix in fixes) fix.altitudeCm],
|
||||||
|
'acc': [for (final fix in fixes) fix.accuracyCm],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
...chunksOf(
|
||||||
|
SeriesKind.heartRate,
|
||||||
|
start: start,
|
||||||
|
times: times(rates.map((rate) => rate.time)),
|
||||||
|
columns: {
|
||||||
|
'bpm': [for (final rate in rates) rate.bpm],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
...chunksOf(
|
||||||
|
SeriesKind.pauses,
|
||||||
|
start: start,
|
||||||
|
times: times(pauses.map((mark) => mark.time)),
|
||||||
|
columns: {
|
||||||
|
'paused': [for (final mark in pauses) flag(mark.paused)],
|
||||||
|
'auto': [for (final mark in pauses) flag(mark.auto)],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
/// The version of the recording file this app writes and reads.
|
||||||
|
const recordingFileVersion = 1;
|
||||||
|
|
||||||
|
/// The recording file's first word, which says what it is.
|
||||||
|
const recordingFileMagic = 'halkyon-train-recording';
|
||||||
|
|
||||||
|
/// The recording [text] holds (ADR 0030, D18), as the recorder appends it, a
|
||||||
|
/// line each, its fields apart by spaces, times in UTC milliseconds:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// halkyon-train-recording 1 <activity> <started>
|
||||||
|
/// p <time> <lat e7> <lon e7> <accuracy cm> <altitude cm or ->
|
||||||
|
/// h <time> <bpm>
|
||||||
|
/// s <time>
|
||||||
|
/// pause <time> auto|manual
|
||||||
|
/// resume <time> auto|manual
|
||||||
|
/// end <time>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A last line without its line break was cut short as it was written, and
|
||||||
|
/// is dropped; a line it can't read is skipped. Null where the header is
|
||||||
|
/// missing, of a newer version or names an activity this app doesn't know.
|
||||||
|
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 fixes = <Fix>[];
|
||||||
|
final heartRates = <HeartRate>[];
|
||||||
|
final steps = <DateTime>[];
|
||||||
|
final pauses = <PauseMark>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
switch (fields) {
|
||||||
|
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 [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 ['end', _]:
|
||||||
|
endedAt = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Recording(
|
||||||
|
activity: activity,
|
||||||
|
startedAt: startedAt,
|
||||||
|
fixes: fixes,
|
||||||
|
heartRates: heartRates,
|
||||||
|
steps: steps,
|
||||||
|
pauses: pauses,
|
||||||
|
endedAt: endedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _time(String field) => switch (int.tryParse(field)) {
|
||||||
|
final ms? => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
/// A position the phone delivered (ADR 0030, D20: every one, with its
|
||||||
|
/// accuracy), in the units of the position series.
|
||||||
|
final class const Fix({
|
||||||
|
required final DateTime time,
|
||||||
|
|
||||||
|
/// Where, in 1e-7 degrees.
|
||||||
|
required final int latE7,
|
||||||
|
required final int lonE7,
|
||||||
|
|
||||||
|
/// How far off it may be, in centimetres, as the phone reported it.
|
||||||
|
required final int accuracyCm,
|
||||||
|
|
||||||
|
/// How high, in centimetres; null where the fix had none.
|
||||||
|
final int? altitudeCm,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [time, latE7, lonE7, accuracyCm, altitudeCm];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heart's rate at [time].
|
||||||
|
final class const HeartRate({
|
||||||
|
required final DateTime time,
|
||||||
|
required final int bpm,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [time, bpm];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paused, or going on again (D16), by the person or by Train itself.
|
||||||
|
final class const PauseMark({
|
||||||
|
required final DateTime time,
|
||||||
|
|
||||||
|
/// Whether it paused here; false: it went on.
|
||||||
|
final bool paused = true,
|
||||||
|
|
||||||
|
/// Whether Train did it by itself (auto-pause).
|
||||||
|
final bool auto = false,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [time, paused, auto];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording as its file holds it (D18): what the recorder took, in the
|
||||||
|
/// order it took it, finished or cut off.
|
||||||
|
final class const Recording({
|
||||||
|
required final Activity activity,
|
||||||
|
|
||||||
|
/// When it started, in UTC.
|
||||||
|
required final DateTime startedAt,
|
||||||
|
final List<Fix> fixes = const [],
|
||||||
|
final List<HeartRate> heartRates = const [],
|
||||||
|
|
||||||
|
/// Each step the step detector counted (D16).
|
||||||
|
final List<DateTime> steps = const [],
|
||||||
|
final List<PauseMark> pauses = const [],
|
||||||
|
|
||||||
|
/// When the person stopped it; null for one cut off (D18).
|
||||||
|
final DateTime? endedAt,
|
||||||
|
}) with Equatable {
|
||||||
|
/// Whether the person stopped it, rather than it being cut off.
|
||||||
|
bool get finished => endedAt != null;
|
||||||
|
|
||||||
|
/// When its last sample was taken, or it started where it has none: where
|
||||||
|
/// an unfinished one is saved up to (D18).
|
||||||
|
DateTime get lastSampleAt => [
|
||||||
|
startedAt,
|
||||||
|
for (final fix in fixes) fix.time,
|
||||||
|
for (final rate in heartRates) rate.time,
|
||||||
|
...steps,
|
||||||
|
for (final mark in pauses) mark.time,
|
||||||
|
].reduce((a, b) => b.isAfter(a) ? b : a);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
activity,
|
||||||
|
startedAt,
|
||||||
|
fixes,
|
||||||
|
heartRates,
|
||||||
|
steps,
|
||||||
|
pauses,
|
||||||
|
endedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
import 'package:flutter_test/flutter_test.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/model/series_chunk.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
/// A recording saved as a workout (ADR 0030, D14, D16, D18, D20): its
|
||||||
|
/// distance from the fixes precise enough, its moving time without its
|
||||||
|
/// pauses, and every sample in its series.
|
||||||
|
void main() {
|
||||||
|
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||||
|
DateTime at(int seconds) => start.add(Duration(seconds: seconds));
|
||||||
|
|
||||||
|
/// A fix [metres] north of the equator's crossing with Greenwich, 1e-7
|
||||||
|
/// degrees of latitude being 1.112 cm there.
|
||||||
|
Fix north(int seconds, int metres, {int accuracyCm = 500}) => Fix(
|
||||||
|
time: at(seconds),
|
||||||
|
latE7: (metres * 89.932).round(),
|
||||||
|
lonE7: 0,
|
||||||
|
accuracyCm: accuracyCm,
|
||||||
|
);
|
||||||
|
|
||||||
|
Recording run({
|
||||||
|
List<Fix> fixes = const [],
|
||||||
|
List<PauseMark> pauses = const [],
|
||||||
|
List<HeartRate> heartRates = const [],
|
||||||
|
DateTime? endedAt,
|
||||||
|
Activity activity = Activity.running,
|
||||||
|
}) => Recording(
|
||||||
|
activity: activity,
|
||||||
|
startedAt: start,
|
||||||
|
fixes: fixes,
|
||||||
|
heartRates: heartRates,
|
||||||
|
pauses: pauses,
|
||||||
|
endedAt: endedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('its distance is the track through its fixes', () {
|
||||||
|
final (content, _) = recordedWorkout(
|
||||||
|
run(
|
||||||
|
fixes: [north(0, 0), north(30, 100), north(60, 200)],
|
||||||
|
endedAt: at(60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.activity, Activity.running);
|
||||||
|
expect(content.startedAt, start);
|
||||||
|
expect(content.endedAt, at(60));
|
||||||
|
expect(content.distanceMetres, 200);
|
||||||
|
expect(content.movingTime, const Duration(seconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fix less accurate than 20 m counts for no distance, though it is '
|
||||||
|
'kept', () {
|
||||||
|
final imprecise = Fix(
|
||||||
|
time: at(30),
|
||||||
|
latE7: (100 * 89.932).round(),
|
||||||
|
lonE7: 5000,
|
||||||
|
accuracyCm: 2001,
|
||||||
|
);
|
||||||
|
final (content, series) = recordedWorkout(
|
||||||
|
run(
|
||||||
|
fixes: [north(0, 0), imprecise, north(60, 200, accuracyCm: 2000)],
|
||||||
|
endedAt: at(60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.distanceMetres, 200);
|
||||||
|
final positions = series.singleWhere((c) => c.kind == SeriesKind.position);
|
||||||
|
expect(positions.columns['acc'], [500, 2001, 2000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no distance bridges a pause, and its time is not moving time', () {
|
||||||
|
final (content, _) = recordedWorkout(
|
||||||
|
run(
|
||||||
|
fixes: [
|
||||||
|
north(0, 0),
|
||||||
|
north(10, 100),
|
||||||
|
// Carried on a tram while paused.
|
||||||
|
north(30, 900),
|
||||||
|
north(40, 1200),
|
||||||
|
north(60, 1500),
|
||||||
|
north(70, 1600),
|
||||||
|
],
|
||||||
|
pauses: [
|
||||||
|
PauseMark(time: at(15), auto: true),
|
||||||
|
PauseMark(time: at(55), paused: false, auto: true),
|
||||||
|
],
|
||||||
|
endedAt: at(70),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.distanceMetres, 200);
|
||||||
|
expect(content.movingTime, const Duration(seconds: 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a recording cut off ends at its last sample, a pause still open '
|
||||||
|
'running to its end', () {
|
||||||
|
final (content, _) = recordedWorkout(
|
||||||
|
run(
|
||||||
|
fixes: [north(0, 0), north(20, 100)],
|
||||||
|
pauses: [PauseMark(time: at(30))],
|
||||||
|
heartRates: [HeartRate(time: at(50), bpm: 101)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.endedAt, at(50));
|
||||||
|
expect(content.movingTime, const Duration(seconds: 30));
|
||||||
|
expect(content.distanceMetres, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a workout without fixes has no distance', () {
|
||||||
|
final (content, series) = recordedWorkout(
|
||||||
|
run(activity: Activity.indoorRowing, endedAt: at(600)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.distanceMetres, isNull);
|
||||||
|
expect(content.movingTime, const Duration(minutes: 10));
|
||||||
|
expect(series, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every sample goes into its series, in chunks of ten minutes', () {
|
||||||
|
final (_, series) = recordedWorkout(
|
||||||
|
run(
|
||||||
|
fixes: [
|
||||||
|
for (var s = 0; s < 15 * 60; s += 60)
|
||||||
|
Fix(
|
||||||
|
time: at(s),
|
||||||
|
latE7: s,
|
||||||
|
lonE7: -s,
|
||||||
|
accuracyCm: 400,
|
||||||
|
altitudeCm: (s ~/ 60).isEven ? 3000 : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
heartRates: [
|
||||||
|
HeartRate(time: at(5), bpm: 100),
|
||||||
|
HeartRate(time: at(65), bpm: 120),
|
||||||
|
],
|
||||||
|
pauses: [
|
||||||
|
PauseMark(time: at(100), auto: true),
|
||||||
|
PauseMark(time: at(130), paused: false),
|
||||||
|
],
|
||||||
|
endedAt: at(15 * 60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final positions = series.where((c) => c.kind == SeriesKind.position);
|
||||||
|
expect(positions.map((c) => c.start), [start, at(600)]);
|
||||||
|
expect(positions.first.times, [for (var s = 0; s < 600; s += 60) s * 1000]);
|
||||||
|
expect(positions.first.columns['lat']!.take(2), [0, 60]);
|
||||||
|
expect(positions.first.columns['lon']!.take(2), [0, -60]);
|
||||||
|
expect(positions.first.columns['alt']!.take(2), [3000, null]);
|
||||||
|
expect(
|
||||||
|
series.singleWhere((c) => c.kind == SeriesKind.heartRate),
|
||||||
|
SeriesChunk(
|
||||||
|
kind: SeriesKind.heartRate,
|
||||||
|
start: at(5),
|
||||||
|
times: const [0, 60000],
|
||||||
|
columns: const {
|
||||||
|
'bpm': [100, 120],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
series.singleWhere((c) => c.kind == SeriesKind.pauses),
|
||||||
|
SeriesChunk(
|
||||||
|
kind: SeriesKind.pauses,
|
||||||
|
start: at(100),
|
||||||
|
times: const [0, 30000],
|
||||||
|
columns: const {
|
||||||
|
'paused': [1, 0],
|
||||||
|
'auto': [1, 0],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
/// The file the recorder appends to (ADR 0030, D18): a sample a line, read
|
||||||
|
/// back whole, a line cut short dropped.
|
||||||
|
void main() {
|
||||||
|
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||||
|
int at(int seconds) =>
|
||||||
|
start.add(Duration(seconds: seconds)).millisecondsSinceEpoch;
|
||||||
|
final header = 'halkyon-train-recording 1 running ${at(0)}\n';
|
||||||
|
|
||||||
|
test('a finished recording reads with every sample, in its order', () {
|
||||||
|
final recording = readRecording(
|
||||||
|
'$header'
|
||||||
|
'p ${at(1)} 525200000 134050000 450 3400\n'
|
||||||
|
'h ${at(1)} 121\n'
|
||||||
|
's ${at(2)}\n'
|
||||||
|
'pause ${at(3)} auto\n'
|
||||||
|
'resume ${at(9)} auto\n'
|
||||||
|
'p ${at(10)} 525201000 134051000 2900 -\n'
|
||||||
|
'pause ${at(11)} manual\n'
|
||||||
|
'resume ${at(12)} manual\n'
|
||||||
|
'end ${at(20)}\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
recording,
|
||||||
|
Recording(
|
||||||
|
activity: Activity.running,
|
||||||
|
startedAt: start,
|
||||||
|
fixes: [
|
||||||
|
Fix(
|
||||||
|
time: start.add(const Duration(seconds: 1)),
|
||||||
|
latE7: 525200000,
|
||||||
|
lonE7: 134050000,
|
||||||
|
accuracyCm: 450,
|
||||||
|
altitudeCm: 3400,
|
||||||
|
),
|
||||||
|
Fix(
|
||||||
|
time: start.add(const Duration(seconds: 10)),
|
||||||
|
latE7: 525201000,
|
||||||
|
lonE7: 134051000,
|
||||||
|
accuracyCm: 2900,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
heartRates: [
|
||||||
|
HeartRate(time: start.add(const Duration(seconds: 1)), bpm: 121),
|
||||||
|
],
|
||||||
|
steps: [start.add(const Duration(seconds: 2))],
|
||||||
|
pauses: [
|
||||||
|
PauseMark(time: start.add(const Duration(seconds: 3)), auto: true),
|
||||||
|
PauseMark(
|
||||||
|
time: start.add(const Duration(seconds: 9)),
|
||||||
|
paused: false,
|
||||||
|
auto: true,
|
||||||
|
),
|
||||||
|
PauseMark(time: start.add(const Duration(seconds: 11))),
|
||||||
|
PauseMark(
|
||||||
|
time: start.add(const Duration(seconds: 12)),
|
||||||
|
paused: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
endedAt: start.add(const Duration(seconds: 20)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(recording!.finished, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a recording cut off is unfinished, its last line dropped where it '
|
||||||
|
'was cut short', () {
|
||||||
|
final recording = readRecording(
|
||||||
|
'$header'
|
||||||
|
'p ${at(1)} 525200000 134050000 450 -\n'
|
||||||
|
'h ${at(4)} 130\n'
|
||||||
|
'p ${at(5)} 5252',
|
||||||
|
)!;
|
||||||
|
|
||||||
|
expect(recording.finished, isFalse);
|
||||||
|
expect(recording.endedAt, isNull);
|
||||||
|
expect(recording.fixes, hasLength(1));
|
||||||
|
expect(recording.lastSampleAt, start.add(const Duration(seconds: 4)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a line it can't read is skipped, the rest kept", () {
|
||||||
|
final recording = readRecording(
|
||||||
|
'$header'
|
||||||
|
'p ${at(1)} 525200000 134050000 450 -\n'
|
||||||
|
'p ${at(2)} north east 450 -\n'
|
||||||
|
'cadence ${at(3)} 88\n'
|
||||||
|
'h ${at(4)}\n'
|
||||||
|
'h ${at(5)} 140\n',
|
||||||
|
)!;
|
||||||
|
|
||||||
|
expect(recording.fixes, hasLength(1));
|
||||||
|
expect(recording.heartRates, [
|
||||||
|
HeartRate(time: start.add(const Duration(seconds: 5)), bpm: 140),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file without its header, of a newer version or an activity this '
|
||||||
|
"app doesn't know, is no recording", () {
|
||||||
|
expect(readRecording(''), isNull);
|
||||||
|
expect(readRecording('p ${at(1)} 525200000 134050000 450 -\n'), isNull);
|
||||||
|
expect(
|
||||||
|
readRecording('halkyon-train-recording 2 running ${at(0)}\n'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readRecording('halkyon-train-recording 1 skating ${at(0)}\n'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a recording just begun has no sample and was last seen as it '
|
||||||
|
'started', () {
|
||||||
|
final recording = readRecording(header)!;
|
||||||
|
|
||||||
|
expect(recording.fixes, isEmpty);
|
||||||
|
expect(recording.lastSampleAt, start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# ADR 0030: Halkyon Train, what it does and where its data comes from
|
# ADR 0030: Halkyon Train, what it does and where its data comes from
|
||||||
|
|
||||||
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D17 below). The shell (D12)
|
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D20 below). The shell (D12)
|
||||||
is built; recording (D13–D17) is being built.
|
is built; recording (D13–D20) is being built.
|
||||||
- **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET
|
- **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET
|
||||||
rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next
|
rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next
|
||||||
free number so that neither is ever read as this one.
|
free number so that neither is ever read as this one.
|
||||||
|
|
@ -283,6 +283,37 @@ Strava. What was found, 27 Oct:
|
||||||
FTMS, from frames recorded with nRF Connect. Health Connect (D6) and the Loop (D3)
|
FTMS, from frames recorded with nRF Connect. Health Connect (D6) and the Loop (D3)
|
||||||
follow recording. Each type maps onto one of Health Connect's exercise types, and is a
|
follow recording. Each type maps onto one of Health Connect's exercise types, and is a
|
||||||
name in the sealed payload, so new ones need no migration.
|
name in the sealed payload, so new ones need no migration.
|
||||||
|
- **D18 The recording file is text, one sample a line, appended** (recommended, over
|
||||||
|
fixed-size binary records, smaller only while recording, as a saved workout is
|
||||||
|
delta-coded JSON anyway, and over samples handed straight to Dart, lost when Android
|
||||||
|
stops the Flutter engine while the service runs, against D13): each line a sample with
|
||||||
|
its kind (a position, a heart rate, a step, a pause's start or end) and its time,
|
||||||
|
flushed every few seconds. A line cut short is dropped as it is read; an unfinished
|
||||||
|
file the app finds when it next opens is offered to be saved up to its last sample or
|
||||||
|
thrown away. It lies in the person's folder, in the app's private storage, unsealed,
|
||||||
|
as their local database does, until it is saved as a workout and sealed (D7).
|
||||||
|
- **D19 Permissions asked when first needed, never background location**
|
||||||
|
(recommended, over background location, which would let a recording start with Train
|
||||||
|
closed and go on after Android killed its process, but asks to be allowed all the time
|
||||||
|
on a settings page of its own since Android 11 and a declaration on Google Play, more
|
||||||
|
than Train's privacy fits; OpenTracks does without it too): precise location and, on
|
||||||
|
Android 13 and later, notifications as an activity with GPS first starts, not at
|
||||||
|
install or first launch (refused notifications hide the recording's notification and
|
||||||
|
leave it running); physical activity only as running, walking or hiking first starts
|
||||||
|
with auto-pause on, for the step detector (D16), auto-pause falling back to GPS speed
|
||||||
|
for that workout where it is refused. The service is a foreground service of the types
|
||||||
|
`location` and `health`, started from the app while it is open, which keeps its access
|
||||||
|
to location with the screen off; a process Android kills leaves its file (D18) to be
|
||||||
|
found when Train next opens.
|
||||||
|
- **D20 Every fix is kept with its accuracy, and filtered as it is read**
|
||||||
|
(recommended, over dropping imprecise fixes in the service, which keeps the series
|
||||||
|
smaller and the reading simpler but can't be undone for the workouts recorded under a
|
||||||
|
threshold that proves wrong): the file and the saved series hold each fix the phone
|
||||||
|
delivered with the accuracy it reported; distance, pace, splits and the track drawn
|
||||||
|
leave out the fixes less accurate than a threshold, 20 m to start with (as OpenTracks'
|
||||||
|
default), adjustable once Train has settings. A better filter later (smoothing, a
|
||||||
|
Kalman filter) reads old workouts anew, as nothing was thrown away. An hour at a fix a
|
||||||
|
second is 3,600 fixes, tens of kilobytes delta-coded and gzipped.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue