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