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:
Tizian 「ティツィアーン」 2026-11-04 20:48:42 +01:00
commit ae3121310a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 232 additions and 16 deletions

View file

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

View file

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

View file

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

View file

@ -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.

View file

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

View file

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// A day's 24/7 heart rate as its chart draws it (ADR 0030, D37, D65): the
/// samples a point a minute, what the band couldn't read left out.
void main() {
test("a run's mean leaves out what the band couldn't read", () {
const run = HeartRun(
at: Duration.zero,
trigger: HeartTrigger.timed,
bpm: [70, 0, 72, 0],
);
const none = HeartRun(
at: Duration.zero,
trigger: HeartTrigger.timed,
bpm: [0, 0],
);
expect(run.mean, 71);
expect(none.mean, isNull);
});
test('the samples come a point a minute, averaged across runs, in time '
'order, half a run lost contact or not', () {
const at = Duration(hours: 7, minutes: 30);
final points = heartByMinute([
// Later, a run of its own.
HeartRun(
at: at + const Duration(minutes: 10),
trigger: HeartTrigger.highActivity,
bpm: const [140, 142],
),
// Two minutes a sample a second, every other one not read.
HeartRun(
at: at,
trigger: HeartTrigger.timed,
bpm: [
for (var i = 0; i < 120; i++)
if (i.isOdd) 0 else if (i < 60) 60 else 64,
],
),
// Within the first minute too.
HeartRun(
at: at + const Duration(seconds: 50),
trigger: HeartTrigger.manual,
bpm: const [90],
),
// Nothing read at all.
HeartRun(
at: at + const Duration(minutes: 20),
trigger: HeartTrigger.timed,
bpm: const [0, 0, 0],
),
]);
expect(points, [
// 30 samples of 60 and one of 90.
(at, 61),
(at + const Duration(minutes: 1), 64),
(at + const Duration(minutes: 10), 141),
]);
});
}

View file

@ -65,6 +65,25 @@ void main() {
expect(decodeHeart(encodeHeart(heart)), heart);
});
test('a day kept with its skin temperature out of order comes back in '
'order, a time given twice once (D65)', () {
final kept = DayContent(
date: date,
skin: const [
(Duration(minutes: 2), 33.5),
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration.zero, 33.1),
],
);
expect(decodeDay(encodeDay(kept))!.skin, const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration(minutes: 2), 33.5),
]);
});
test('a day with nothing but its date comes back so', () {
final bare = DayContent(date: date);

View file

@ -188,6 +188,48 @@ void main() {
},
);
test('skin temperature comes in time order, a time given twice once '
'(D65)', () {
final lines = LoopLines()
..add(const {
'kind': 'skin',
'date': '2026-10-28',
'samples': [
[120000, 33.5],
[0, 33.1],
[60000, 33.3],
[0, 40.0],
[60000, 33.3],
],
});
expect(lines.skins[today], const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration(minutes: 2), 33.5),
]);
});
test("heart rate the band couldn't read, 0 or beyond a heart's, counts "
'as none read, in its place (D65)', () {
final lines = LoopLines()
..add(const {
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [60, 0, 62, 0, 300, -1],
},
],
});
final run = lines.hearts[yesterday]!.single;
expect(run.bpm, [60, 0, 62, 0, 0, 0]);
expect(run.mean, 61);
});
test('a later line of a kind and date replaces an earlier one', () {
final lines = LoopLines()
..add(summary)

View file

@ -24,6 +24,21 @@ void main() {
),
);
test('its points are drawn in the order of their x, never backwards '
'(D65); points in order are taken as they are', () {
const sorted = [(x: 0.0, value: 1.0), (x: 1.0, value: 2.0)];
expect(
chartInOrder(const [
(x: 2.0, value: 3.0),
(x: 0.0, value: 1.0),
(x: 1.0, value: 2.0),
]),
[(x: 0.0, value: 1.0), (x: 1.0, value: 2.0), (x: 2.0, value: 3.0)],
);
expect(identical(chartInOrder(sorted), sorted), isTrue);
});
testWidgets('held, the value and its moment under the finger show beside '
'the title, following the finger, and go when it lifts', (
tester,