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
10 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,

View file

@ -696,8 +696,8 @@ Strava. What was found, 27 Oct:
sealed, so lines written meanwhile wait; the schedule is the phone's, for the last
person whose app opened with a band, and *Let the band go* stops it. Polar's
NONREM12 and NONREM3 are shown as light and deep sleep; the chart draws each 24/7
measurement's mean at its start; a night's times show in the phone's time zone. Not
yet tried with a Loop: whether the SDK's deletion includes its `until` day, and
measurement's mean at its start (a point a minute since D65); a night's times show
in the phone's time zone. Not yet tried with a Loop: whether the SDK's deletion includes its `until` day, and
whether the job connects to the band in the background, are for a real band.
- **D39 The Loop's training sessions are workouts** (recommended, the developer's; 28
@ -1731,6 +1731,19 @@ Strava. What was found, 27 Oct:
provider takes one from `MAP_DARK_STYLE_URL` (HTTPS, else none: the light style
in both themes). A dark Liberty made in the app (its colours inverted) was the
alternative, a style of the app's own for a provider's tiles it can't know.
- **The day's charts**: the skin temperature was drawn backwards in places, as the
band's samples aren't always in time order and nothing sorted them; they are
sorted, a time given twice kept once, as the Loop's lines are read and as a kept
day is opened (`skinInOrder`), and every line chart (`WorkoutChart`) draws its
points in the order of their x and spans their least to their most
(`chartInOrder`). The heart rate spiked down: the band writes 0 where it lost
contact, and a run's mean counted those in. A value that isn't a heart's rate
(0, or beyond 250) is 0, none read, as the lines are read, kept in its place so
the samples after keep their times, and left out of every mean, also of days
kept before. The chart draws the samples themselves a point a minute, averaged
across runs (`heartByMinute`, a second between a run's samples), not one mean a
run, which zigzagged where runs lay close. Dropping the zeros from the list was
the alternative; it would have moved the later samples' times.
## Consequences
- Train's sensors are one pattern: open profiles read by the app itself, on the phone;