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
3 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,
|
||||
];
|
||||
}
|
||||
Loading…
Reference in a new issue