fix(train): a day's skin temperature in time order, and its heart rate a point a minute without lost contact
The skin temperature's line ran backwards in places: the band's samples aren't always in time order, and neither the Loop's lines, nor the kept day, nor the chart sorted them. They are sorted now, a time given twice kept once, as the lines are read and as a kept day is opened, and WorkoutChart draws any points in the order of their x, from the least to the most. The heart rate spiked down: the band writes 0 where it lost contact, and each run's mean, the chart's one point a run, counted those in. A value that isn't a heart's rate is 0, none read, in its place (so the samples after keep their times), left out of every mean, days kept before included; the chart draws the samples a point a minute, averaged across runs, rather than one mean a run, which zigzagged where runs lay close. ADR 0030, D65. Whats-New: A day's skin temperature no longer runs backwards, and its heart rate no longer drops where the band lost contact. Whats-New-De: Die Hauttemperatur eines Tages läuft nicht mehr rückwärts, und sein Puls fällt nicht mehr ab, wo das Band den Kontakt verlor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1c67310c93
commit
ae3121310a
5 changed files with 232 additions and 16 deletions
|
|
@ -89,11 +89,12 @@ DayContent? decodeDay(ByteData payload) => _read(payload, 'day', (json) {
|
|||
),
|
||||
_ => null,
|
||||
},
|
||||
skin: [
|
||||
// In order also where a day was kept before it was sorted (D65).
|
||||
skin: skinInOrder([
|
||||
for (final sample in _list(json['skin']))
|
||||
if (sample case [final int ms, final num celsius])
|
||||
(Duration(milliseconds: ms), celsius.toDouble()),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ final class LoopLines() {
|
|||
HeartRun(
|
||||
at: Duration(seconds: run['at']! as int),
|
||||
trigger: _triggers[run['trigger']] ?? HeartTrigger.unknown,
|
||||
// What the band couldn't read is none, in its place (D65).
|
||||
bpm: [
|
||||
for (final bpm in _list(run['bpm']))
|
||||
if (bpm is int) bpm,
|
||||
if (bpm is num) heartRateOrNone(bpm.round()),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
|
@ -102,11 +103,11 @@ final class LoopLines() {
|
|||
sessions[session.id] = session;
|
||||
}
|
||||
case 'skin':
|
||||
skins[date] = [
|
||||
skins[date] = skinInOrder([
|
||||
for (final sample in _list(json['samples']))
|
||||
if (sample case [final num ms, final num celsius])
|
||||
(Duration(milliseconds: ms.round()), celsius.toDouble()),
|
||||
];
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,9 +166,14 @@ final class const HeartRun({
|
|||
required final HeartTrigger trigger,
|
||||
required final List<int> bpm,
|
||||
}) with Equatable {
|
||||
/// Its samples' mean, as the day's chart shows it; null without one.
|
||||
int? get mean =>
|
||||
bpm.isEmpty ? null : (bpm.reduce((a, b) => a + b) / bpm.length).round();
|
||||
/// Its samples' mean, those the band couldn't read (0) left out (D65);
|
||||
/// null without one read.
|
||||
int? get mean {
|
||||
final read = bpm.where(isHeartRate).toList();
|
||||
return read.isEmpty
|
||||
? null
|
||||
: (read.reduce((a, b) => a + b) / read.length).round();
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [at, trigger, bpm];
|
||||
|
|
@ -261,3 +266,49 @@ String dayKey(DateTime date) =>
|
|||
DateTime? dateOfKey(Object? key) => key is String && key.length == 10
|
||||
? DateTime.tryParse('${key}T00:00:00Z')
|
||||
: null;
|
||||
|
||||
/// Whether [bpm] is a heart's rate the band read: the band writes 0 where it
|
||||
/// lost contact (D65), and nothing beyond 250 is one.
|
||||
bool isHeartRate(int bpm) => bpm > 0 && bpm <= 250;
|
||||
|
||||
/// [bpm] as a run keeps it: what isn't a heart's rate (`isHeartRate`) is 0,
|
||||
/// none read, in its place, so the samples after keep their times (D65).
|
||||
int heartRateOrNone(int bpm) => isHeartRate(bpm) ? bpm : 0;
|
||||
|
||||
/// How far apart a 24/7 run's samples are: a second, as the Loop measures
|
||||
/// (Polar's SDK gives a run's start and its samples alone).
|
||||
const heartSampleInterval = Duration(seconds: 1);
|
||||
|
||||
/// [runs]' samples a point a minute (D65): each minute's start in the band's
|
||||
/// day and the mean of the samples read in it, across runs, in time order.
|
||||
/// One mean a run zigzagged where runs lay close, and dropped to the zeros
|
||||
/// of contact lost.
|
||||
List<(Duration, int)> heartByMinute(Iterable<HeartRun> runs) {
|
||||
final sums = <int, (int, int)>{};
|
||||
for (final run in runs) {
|
||||
for (final (index, bpm) in run.bpm.indexed) {
|
||||
if (!isHeartRate(bpm)) {
|
||||
continue;
|
||||
}
|
||||
final minute = (run.at + heartSampleInterval * index).inMinutes;
|
||||
final (sum, count) = sums[minute] ?? (0, 0);
|
||||
sums[minute] = (sum + bpm, count + 1);
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final minute in sums.keys.toList()..sort())
|
||||
if (sums[minute] case (final sum, final count))
|
||||
(Duration(minutes: minute), (sum / count).round()),
|
||||
];
|
||||
}
|
||||
|
||||
/// [samples] of skin temperature in time order, a time given twice once, its
|
||||
/// first (D65): the band's list isn't always in order, and the chart drew it
|
||||
/// backwards.
|
||||
List<(Duration, double)> skinInOrder(Iterable<(Duration, double)> samples) {
|
||||
final seen = <Duration>{};
|
||||
return [
|
||||
for (final sample in samples.toList()..sort((a, b) => a.$1.compareTo(b.$1)))
|
||||
if (sample.$2.isFinite && seen.add(sample.$1)) sample,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ class const _Times(final DayContent _day) extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The day's 24/7 heart rate: each measurement's mean at its start.
|
||||
/// The day's 24/7 heart rate: its samples a point a minute (D65).
|
||||
class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -269,9 +269,8 @@ class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
|
|||
return WorkoutChart(
|
||||
title: l10n.dayHeartRate,
|
||||
points: [
|
||||
for (final run in _runs)
|
||||
if (run.mean case final mean?)
|
||||
(x: run.at.inSeconds.toDouble(), value: mean.toDouble()),
|
||||
for (final (at, bpm) in heartByMinute(_runs))
|
||||
(x: at.inSeconds.toDouble(), value: bpm.toDouble()),
|
||||
],
|
||||
label: (value) => l10n.dayBpm(value.round()),
|
||||
// Each run's start is the band's time of day.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class _WorkoutChartState() extends State<WorkoutChart> {
|
|||
|
||||
/// The point whose x is nearest to [dx] across a plot [width] wide.
|
||||
ChartPoint _nearest(double dx, double width) {
|
||||
final points = widget.points;
|
||||
final points = chartInOrder(widget.points);
|
||||
final first = points.first.x;
|
||||
final span = max(points.last.x - first, 1);
|
||||
final x = first + (dx / max(width, 1)).clamp(0, 1) * span;
|
||||
|
|
@ -63,7 +63,7 @@ class _WorkoutChartState() extends State<WorkoutChart> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final points = widget.points;
|
||||
final points = chartInOrder(widget.points);
|
||||
final label = widget.label;
|
||||
final inverted = widget.inverted;
|
||||
final values = [for (final point in points) point.value];
|
||||
|
|
@ -156,6 +156,17 @@ class _WorkoutChartState() extends State<WorkoutChart> {
|
|||
}
|
||||
}
|
||||
|
||||
/// [points] in the order of their x, so that a line never runs backwards
|
||||
/// (D65); [points] itself where they are in order already.
|
||||
List<ChartPoint> chartInOrder(List<ChartPoint> points) {
|
||||
for (var i = 1; i < points.length; i++) {
|
||||
if (points[i].x < points[i - 1].x) {
|
||||
return [...points]..sort((a, b) => a.x.compareTo(b.x));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
class _LinePainter(
|
||||
final List<ChartPoint> _points, {
|
||||
required final List<ChartBand> bands,
|
||||
|
|
@ -180,6 +191,7 @@ class _LinePainter(
|
|||
Offset(size.width, size.height),
|
||||
gridPaint,
|
||||
);
|
||||
// In order (chartInOrder): the first is the least, the last the most.
|
||||
final first = _points.first.x;
|
||||
final span = max(_points.last.x - first, 1);
|
||||
double xOf(double x) => ((x - first) / span).clamp(0, 1) * size.width;
|
||||
|
|
|
|||
Loading…
Reference in a new issue