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
10 changed files with 232 additions and 16 deletions
|
|
@ -89,11 +89,12 @@ DayContent? decodeDay(ByteData payload) => _read(payload, 'day', (json) {
|
||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
skin: [
|
// In order also where a day was kept before it was sorted (D65).
|
||||||
|
skin: skinInOrder([
|
||||||
for (final sample in _list(json['skin']))
|
for (final sample in _list(json['skin']))
|
||||||
if (sample case [final int ms, final num celsius])
|
if (sample case [final int ms, final num celsius])
|
||||||
(Duration(milliseconds: ms), celsius.toDouble()),
|
(Duration(milliseconds: ms), celsius.toDouble()),
|
||||||
],
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,10 @@ final class LoopLines() {
|
||||||
HeartRun(
|
HeartRun(
|
||||||
at: Duration(seconds: run['at']! as int),
|
at: Duration(seconds: run['at']! as int),
|
||||||
trigger: _triggers[run['trigger']] ?? HeartTrigger.unknown,
|
trigger: _triggers[run['trigger']] ?? HeartTrigger.unknown,
|
||||||
|
// What the band couldn't read is none, in its place (D65).
|
||||||
bpm: [
|
bpm: [
|
||||||
for (final bpm in _list(run['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;
|
sessions[session.id] = session;
|
||||||
}
|
}
|
||||||
case 'skin':
|
case 'skin':
|
||||||
skins[date] = [
|
skins[date] = skinInOrder([
|
||||||
for (final sample in _list(json['samples']))
|
for (final sample in _list(json['samples']))
|
||||||
if (sample case [final num ms, final num celsius])
|
if (sample case [final num ms, final num celsius])
|
||||||
(Duration(milliseconds: ms.round()), celsius.toDouble()),
|
(Duration(milliseconds: ms.round()), celsius.toDouble()),
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,9 +166,14 @@ final class const HeartRun({
|
||||||
required final HeartTrigger trigger,
|
required final HeartTrigger trigger,
|
||||||
required final List<int> bpm,
|
required final List<int> bpm,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// Its samples' mean, as the day's chart shows it; null without one.
|
/// Its samples' mean, those the band couldn't read (0) left out (D65);
|
||||||
int? get mean =>
|
/// null without one read.
|
||||||
bpm.isEmpty ? null : (bpm.reduce((a, b) => a + b) / bpm.length).round();
|
int? get mean {
|
||||||
|
final read = bpm.where(isHeartRate).toList();
|
||||||
|
return read.isEmpty
|
||||||
|
? null
|
||||||
|
: (read.reduce((a, b) => a + b) / read.length).round();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [at, trigger, bpm];
|
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? dateOfKey(Object? key) => key is String && key.length == 10
|
||||||
? DateTime.tryParse('${key}T00:00:00Z')
|
? DateTime.tryParse('${key}T00:00:00Z')
|
||||||
: null;
|
: 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 {
|
class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -269,9 +269,8 @@ class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
|
||||||
return WorkoutChart(
|
return WorkoutChart(
|
||||||
title: l10n.dayHeartRate,
|
title: l10n.dayHeartRate,
|
||||||
points: [
|
points: [
|
||||||
for (final run in _runs)
|
for (final (at, bpm) in heartByMinute(_runs))
|
||||||
if (run.mean case final mean?)
|
(x: at.inSeconds.toDouble(), value: bpm.toDouble()),
|
||||||
(x: run.at.inSeconds.toDouble(), value: mean.toDouble()),
|
|
||||||
],
|
],
|
||||||
label: (value) => l10n.dayBpm(value.round()),
|
label: (value) => l10n.dayBpm(value.round()),
|
||||||
// Each run's start is the band's time of day.
|
// 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.
|
/// The point whose x is nearest to [dx] across a plot [width] wide.
|
||||||
ChartPoint _nearest(double dx, double width) {
|
ChartPoint _nearest(double dx, double width) {
|
||||||
final points = widget.points;
|
final points = chartInOrder(widget.points);
|
||||||
final first = points.first.x;
|
final first = points.first.x;
|
||||||
final span = max(points.last.x - first, 1);
|
final span = max(points.last.x - first, 1);
|
||||||
final x = first + (dx / max(width, 1)).clamp(0, 1) * span;
|
final x = first + (dx / max(width, 1)).clamp(0, 1) * span;
|
||||||
|
|
@ -63,7 +63,7 @@ class _WorkoutChartState() extends State<WorkoutChart> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final points = widget.points;
|
final points = chartInOrder(widget.points);
|
||||||
final label = widget.label;
|
final label = widget.label;
|
||||||
final inverted = widget.inverted;
|
final inverted = widget.inverted;
|
||||||
final values = [for (final point in points) point.value];
|
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(
|
class _LinePainter(
|
||||||
final List<ChartPoint> _points, {
|
final List<ChartPoint> _points, {
|
||||||
required final List<ChartBand> bands,
|
required final List<ChartBand> bands,
|
||||||
|
|
@ -180,6 +191,7 @@ class _LinePainter(
|
||||||
Offset(size.width, size.height),
|
Offset(size.width, size.height),
|
||||||
gridPaint,
|
gridPaint,
|
||||||
);
|
);
|
||||||
|
// In order (chartInOrder): the first is the least, the last the most.
|
||||||
final first = _points.first.x;
|
final first = _points.first.x;
|
||||||
final span = max(_points.last.x - first, 1);
|
final span = max(_points.last.x - first, 1);
|
||||||
double xOf(double x) => ((x - first) / span).clamp(0, 1) * size.width;
|
double xOf(double x) => ((x - first) / span).clamp(0, 1) * size.width;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,25 @@ void main() {
|
||||||
expect(decodeHeart(encodeHeart(heart)), heart);
|
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', () {
|
test('a day with nothing but its date comes back so', () {
|
||||||
final bare = DayContent(date: date);
|
final bare = DayContent(date: date);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
test('a later line of a kind and date replaces an earlier one', () {
|
||||||
final lines = LoopLines()
|
final lines = LoopLines()
|
||||||
..add(summary)
|
..add(summary)
|
||||||
|
|
|
||||||
|
|
@ -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 '
|
testWidgets('held, the value and its moment under the finger show beside '
|
||||||
'the title, following the finger, and go when it lifts', (
|
'the title, following the finger, and go when it lifts', (
|
||||||
tester,
|
tester,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
measurement's mean at its start (a point a minute since D65); a night's times show
|
||||||
yet tried with a Loop: whether the SDK's deletion includes its `until` day, and
|
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.
|
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
|
- **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
|
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
|
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.
|
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
|
## Consequences
|
||||||
|
|
||||||
- Train's sensors are one pattern: open profiles read by the app itself, on the phone;
|
- Train's sensors are one pattern: open profiles read by the app itself, on the phone;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue