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:
Tizian 「ティツィアーン」 2026-10-28 12:31:09 +01:00
commit aab25bf932
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 658 additions and 2 deletions

View file

@ -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)],
},
),
];
}

View file

@ -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,
};

View file

@ -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,
];
}