|
@@ -16,6 +16,7 @@ const _sleepTypeAsleepDeep = 4; |
|
@@ -16,6 +16,7 @@ const _sleepTypeAsleepDeep = 4; |
|
16
|
const _sleepTypeAsleepRem = 5;
|
16
|
const _sleepTypeAsleepRem = 5;
|
|
17
|
const _sleepGoalMinutes = 8 * 60.0;
|
17
|
const _sleepGoalMinutes = 8 * 60.0;
|
|
18
|
const _sleepContinuityToleranceSeconds = 1;
|
18
|
const _sleepContinuityToleranceSeconds = 1;
|
|
|
|
19
|
+const _stressConfirmedActivityBufferSeconds = 12 * 60;
|
|
19
|
|
20
|
|
|
20
|
class LocalHealthDataConvert {
|
21
|
class LocalHealthDataConvert {
|
|
21
|
const LocalHealthDataConvert._();
|
22
|
const LocalHealthDataConvert._();
|
|
@@ -38,6 +39,7 @@ class LocalHealthDataConvert { |
|
@@ -38,6 +39,7 @@ class LocalHealthDataConvert { |
|
38
|
final previousStart = switch (dateRangeType) {
|
39
|
final previousStart = switch (dateRangeType) {
|
|
39
|
0 => start.subtract(const Duration(days: 7)),
|
40
|
0 => start.subtract(const Duration(days: 7)),
|
|
40
|
1 => DateTime(start.year, start.month - 1),
|
41
|
1 => DateTime(start.year, start.month - 1),
|
|
|
|
42
|
+ 2 => DateTime(start.year - 1),
|
|
41
|
_ => null,
|
43
|
_ => null,
|
|
42
|
};
|
44
|
};
|
|
43
|
if (previousStart == null) return const <DateTime>[];
|
45
|
if (previousStart == null) return const <DateTime>[];
|
|
@@ -277,15 +279,23 @@ class LocalHealthDataConvert { |
|
@@ -277,15 +279,23 @@ class LocalHealthDataConvert { |
|
277
|
static HrvStatisticsDataV2 hrvStatistics({
|
279
|
static HrvStatisticsDataV2 hrvStatistics({
|
|
278
|
required int dateRangeType,
|
280
|
required int dateRangeType,
|
|
279
|
required List<DateTime> days,
|
281
|
required List<DateTime> days,
|
|
|
|
282
|
+ required List<DateTime> previousDays,
|
|
280
|
required List<HealthRawHrvStressPoint> hrvPoints,
|
283
|
required List<HealthRawHrvStressPoint> hrvPoints,
|
|
281
|
required List<HealthRawRealtimeStressPoint> realtimePoints,
|
284
|
required List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
282
|
}) {
|
285
|
}) {
|
|
283
|
- final daily = [
|
|
|
|
284
|
- for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints),
|
|
|
|
285
|
- ];
|
286
|
+ final daily = <_HrvDaySummary>[];
|
|
|
|
287
|
+ for (final day in days) {
|
|
|
|
288
|
+ final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
|
|
|
|
289
|
+ if (summary.hasData) daily.add(summary);
|
|
|
|
290
|
+ }
|
|
286
|
final validDaily = daily.where((e) => e.hrvAverage != null).toList();
|
291
|
final validDaily = daily.where((e) => e.hrvAverage != null).toList();
|
|
|
|
292
|
+ final previousDaily = <_HrvDaySummary>[];
|
|
|
|
293
|
+ for (final day in previousDays) {
|
|
|
|
294
|
+ final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
|
|
|
|
295
|
+ if (summary.hasData) previousDaily.add(summary);
|
|
|
|
296
|
+ }
|
|
287
|
final trendList = dateRangeType == 2
|
297
|
final trendList = dateRangeType == 2
|
|
288
|
- ? _monthlyHrvTrend(validDaily)
|
298
|
+ ? _monthlyHrvTrend(daily)
|
|
289
|
: [
|
299
|
: [
|
|
290
|
for (final item in daily)
|
300
|
for (final item in daily)
|
|
291
|
HrvTrendList(
|
301
|
HrvTrendList(
|
|
@@ -296,7 +306,8 @@ class LocalHealthDataConvert { |
|
@@ -296,7 +306,8 @@ class LocalHealthDataConvert { |
|
296
|
),
|
306
|
),
|
|
297
|
];
|
307
|
];
|
|
298
|
|
308
|
|
|
299
|
- final distribution = _hrvDistribution(validDaily);
|
309
|
+ final distribution = _stressStateDistribution(daily);
|
|
|
|
310
|
+ final previousDistribution = _stressStateDistribution(previousDaily);
|
|
300
|
final minDay = _extremeHrv(validDaily, min: true);
|
311
|
final minDay = _extremeHrv(validDaily, min: true);
|
|
301
|
final maxDay = _extremeHrv(validDaily, min: false);
|
312
|
final maxDay = _extremeHrv(validDaily, min: false);
|
|
302
|
|
313
|
|
|
@@ -309,13 +320,23 @@ class LocalHealthDataConvert { |
|
@@ -309,13 +320,23 @@ class LocalHealthDataConvert { |
|
309
|
dayCounts: entry.value,
|
320
|
dayCounts: entry.value,
|
|
310
|
),
|
321
|
),
|
|
311
|
],
|
322
|
],
|
|
312
|
- dailyDistributionList: [
|
|
|
|
313
|
- for (final item in validDaily)
|
|
|
|
314
|
- DailyDistributionList(
|
|
|
|
315
|
- date: dateKey(item.day),
|
|
|
|
316
|
- hrvLevel: item.state,
|
323
|
+ qoqHrvDistributionList: [
|
|
|
|
324
|
+ for (final entry in previousDistribution.entries)
|
|
|
|
325
|
+ QoqHrvDistributionList(
|
|
|
|
326
|
+ stressState: entry.key,
|
|
|
|
327
|
+ dayCounts: entry.value,
|
|
317
|
),
|
328
|
),
|
|
318
|
],
|
329
|
],
|
|
|
|
330
|
+ dailyDistributionList: dateRangeType == 2
|
|
|
|
331
|
+ ? [
|
|
|
|
332
|
+ for (final item in daily)
|
|
|
|
333
|
+ if (item.state != null)
|
|
|
|
334
|
+ DailyDistributionList(
|
|
|
|
335
|
+ date: dateKey(item.day),
|
|
|
|
336
|
+ hrvLevel: item.state,
|
|
|
|
337
|
+ ),
|
|
|
|
338
|
+ ]
|
|
|
|
339
|
+ : null,
|
|
319
|
hrvMin: minDay == null
|
340
|
hrvMin: minDay == null
|
|
320
|
? null
|
341
|
? null
|
|
321
|
: HrvMin(
|
342
|
: HrvMin(
|
|
@@ -451,18 +472,82 @@ class LocalHealthDataConvert { |
|
@@ -451,18 +472,82 @@ class LocalHealthDataConvert { |
|
451
|
}
|
472
|
}
|
|
452
|
|
473
|
|
|
453
|
static V2StressScore v2StressScore(
|
474
|
static V2StressScore v2StressScore(
|
|
454
|
- List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
455
|
- ) {
|
|
|
|
456
|
- final averageStress = _averageOrNull(
|
|
|
|
457
|
- realtimePoints.map((e) => e.result).toList(),
|
|
|
|
458
|
- );
|
|
|
|
459
|
- final score = averageStress?.round();
|
475
|
+ List<HealthRawRealtimeStressPoint> realtimePoints, {
|
|
|
|
476
|
+ required int startTime,
|
|
|
|
477
|
+ required int endTime,
|
|
|
|
478
|
+ }) {
|
|
|
|
479
|
+ final dayPoints = realtimePoints
|
|
|
|
480
|
+ .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
|
|
|
|
481
|
+ .toList();
|
|
|
|
482
|
+ final validValuePoints =
|
|
|
|
483
|
+ dayPoints.where((e) => _isValidStressValue(e.result)).toList();
|
|
|
|
484
|
+ final activityBuffers = _confirmedActivityBuffers(realtimePoints);
|
|
|
|
485
|
+ final validStressValues = validValuePoints
|
|
|
|
486
|
+ .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
|
|
|
|
487
|
+ .map((e) => e.result)
|
|
|
|
488
|
+ .toList()
|
|
|
|
489
|
+ ..sort();
|
|
|
|
490
|
+ final dailyStress = _dailyStress(validStressValues);
|
|
|
|
491
|
+ final score = dailyStress?.round();
|
|
460
|
return V2StressScore(
|
492
|
return V2StressScore(
|
|
461
|
state: score == null ? 0 : healthRawRealtimeStressState(score).value,
|
493
|
state: score == null ? 0 : healthRawRealtimeStressState(score).value,
|
|
462
|
comprehensiveScore: score,
|
494
|
comprehensiveScore: score,
|
|
463
|
);
|
495
|
);
|
|
464
|
}
|
496
|
}
|
|
465
|
|
497
|
|
|
|
|
498
|
+ static double? _dailyStress(List<double> sortedValues) {
|
|
|
|
499
|
+ if (sortedValues.isEmpty) return null;
|
|
|
|
500
|
+ final median = _median(sortedValues)!;
|
|
|
|
501
|
+ final p75 = _percentile75(sortedValues);
|
|
|
|
502
|
+ return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100), 1);
|
|
|
|
503
|
+ }
|
|
|
|
504
|
+
|
|
|
|
505
|
+ static List<({int start, int end})> _confirmedActivityBuffers(
|
|
|
|
506
|
+ List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
507
|
+ ) {
|
|
|
|
508
|
+ final ranges = realtimePoints
|
|
|
|
509
|
+ .where((e) =>
|
|
|
|
510
|
+ _isValidStressValue(e.result) &&
|
|
|
|
511
|
+ (e.isWorkout || e.isWorkoutRecovery))
|
|
|
|
512
|
+ .map((e) => (
|
|
|
|
513
|
+ start: e.rawEndTime - _stressConfirmedActivityBufferSeconds,
|
|
|
|
514
|
+ end: e.rawEndTime + _stressConfirmedActivityBufferSeconds,
|
|
|
|
515
|
+ ))
|
|
|
|
516
|
+ .toList()
|
|
|
|
517
|
+ ..sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
518
|
+ if (ranges.isEmpty) return const <({int start, int end})>[];
|
|
|
|
519
|
+
|
|
|
|
520
|
+ final merged = <({int start, int end})>[];
|
|
|
|
521
|
+ var current = ranges.first;
|
|
|
|
522
|
+ for (final range in ranges.skip(1)) {
|
|
|
|
523
|
+ if (range.start <= current.end) {
|
|
|
|
524
|
+ current = (
|
|
|
|
525
|
+ start: current.start,
|
|
|
|
526
|
+ end: math.max(current.end, range.end),
|
|
|
|
527
|
+ );
|
|
|
|
528
|
+ } else {
|
|
|
|
529
|
+ merged.add(current);
|
|
|
|
530
|
+ current = range;
|
|
|
|
531
|
+ }
|
|
|
|
532
|
+ }
|
|
|
|
533
|
+ merged.add(current);
|
|
|
|
534
|
+ return merged;
|
|
|
|
535
|
+ }
|
|
|
|
536
|
+
|
|
|
|
537
|
+ static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {
|
|
|
|
538
|
+ return ranges.any((e) => time >= e.start && time <= e.end);
|
|
|
|
539
|
+ }
|
|
|
|
540
|
+
|
|
|
|
541
|
+ static bool _isValidStressValue(num value) => value >= 1 && value <= 100;
|
|
|
|
542
|
+
|
|
|
|
543
|
+ static double _percentile75(List<double> sortedValues) {
|
|
|
|
544
|
+ final rank = (sortedValues.length * 0.75).ceil().clamp(
|
|
|
|
545
|
+ 1,
|
|
|
|
546
|
+ sortedValues.length,
|
|
|
|
547
|
+ );
|
|
|
|
548
|
+ return sortedValues[rank - 1];
|
|
|
|
549
|
+ }
|
|
|
|
550
|
+
|
|
466
|
static _SleepDaySummary _sleepDaySummary(
|
551
|
static _SleepDaySummary _sleepDaySummary(
|
|
467
|
DateTime day,
|
552
|
DateTime day,
|
|
468
|
List<HealthKitRawDataPoint> sleepIntervals,
|
553
|
List<HealthKitRawDataPoint> sleepIntervals,
|
|
@@ -480,7 +565,11 @@ class LocalHealthDataConvert { |
|
@@ -480,7 +565,11 @@ class LocalHealthDataConvert { |
|
480
|
point.endTime > interval.startTime &&
|
565
|
point.endTime > interval.startTime &&
|
|
481
|
point.endTime <= interval.endTime))
|
566
|
point.endTime <= interval.endTime))
|
|
482
|
.toList();
|
567
|
.toList();
|
|
483
|
- final sleepDuration = windowEnd - windowStart;
|
568
|
+ final nullableScore = score == 0 ? null : score;
|
|
|
|
569
|
+ final sleepEvaluate = _sleepEvaluate(score);
|
|
|
|
570
|
+ final sleepDuration = nullableScore == null || sleepEvaluate == null
|
|
|
|
571
|
+ ? 0
|
|
|
|
572
|
+ : windowEnd - windowStart;
|
|
484
|
|
573
|
|
|
485
|
return _SleepDaySummary(
|
574
|
return _SleepDaySummary(
|
|
486
|
day: day,
|
575
|
day: day,
|
|
@@ -490,8 +579,8 @@ class LocalHealthDataConvert { |
|
@@ -490,8 +579,8 @@ class LocalHealthDataConvert { |
|
490
|
asleepTime: intervals.isEmpty
|
579
|
asleepTime: intervals.isEmpty
|
|
491
|
? null
|
580
|
? null
|
|
492
|
: intervals.map((e) => e.startTime).reduce(math.min),
|
581
|
: intervals.map((e) => e.startTime).reduce(math.min),
|
|
493
|
- score: score == 0 ? null : score,
|
|
|
|
494
|
- evaluate: _sleepEvaluate(score),
|
582
|
+ score: nullableScore,
|
|
|
|
583
|
+ evaluate: sleepEvaluate,
|
|
495
|
sleepHr: sleepHr,
|
584
|
sleepHr: sleepHr,
|
|
496
|
);
|
585
|
);
|
|
497
|
}
|
586
|
}
|
|
@@ -571,12 +660,17 @@ class LocalHealthDataConvert { |
|
@@ -571,12 +660,17 @@ class LocalHealthDataConvert { |
|
571
|
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
|
660
|
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
|
|
572
|
.toList();
|
661
|
.toList();
|
|
573
|
final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
|
662
|
final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
|
|
|
|
663
|
+ final stressState = _dailyStressState(
|
|
|
|
664
|
+ realtime,
|
|
|
|
665
|
+ startTime: start,
|
|
|
|
666
|
+ endTime: end - 1,
|
|
|
|
667
|
+ );
|
|
574
|
return _HrvDaySummary(
|
668
|
return _HrvDaySummary(
|
|
575
|
day: day,
|
669
|
day: day,
|
|
576
|
hrvAverage: hrvAverage?.toDouble(),
|
670
|
hrvAverage: hrvAverage?.toDouble(),
|
|
577
|
hrAverage:
|
671
|
hrAverage:
|
|
578
|
_averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
|
672
|
_averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
|
|
579
|
- state: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage),
|
673
|
+ state: stressState,
|
|
580
|
);
|
674
|
);
|
|
581
|
}
|
675
|
}
|
|
582
|
|
676
|
|
|
@@ -600,7 +694,7 @@ class LocalHealthDataConvert { |
|
@@ -600,7 +694,7 @@ class LocalHealthDataConvert { |
|
600
|
];
|
694
|
];
|
|
601
|
}
|
695
|
}
|
|
602
|
|
696
|
|
|
603
|
- static Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) {
|
697
|
+ static Map<int, int> _stressStateDistribution(List<_HrvDaySummary> daily) {
|
|
604
|
final result = <int, int>{};
|
698
|
final result = <int, int>{};
|
|
605
|
for (final item in daily) {
|
699
|
for (final item in daily) {
|
|
606
|
final state = item.state;
|
700
|
final state = item.state;
|
|
@@ -610,6 +704,28 @@ class LocalHealthDataConvert { |
|
@@ -610,6 +704,28 @@ class LocalHealthDataConvert { |
|
610
|
return result;
|
704
|
return result;
|
|
611
|
}
|
705
|
}
|
|
612
|
|
706
|
|
|
|
|
707
|
+ static int? _dailyStressState(
|
|
|
|
708
|
+ List<HealthRawRealtimeStressPoint> realtimePoints, {
|
|
|
|
709
|
+ required int startTime,
|
|
|
|
710
|
+ required int endTime,
|
|
|
|
711
|
+ }) {
|
|
|
|
712
|
+ final dayPoints = realtimePoints
|
|
|
|
713
|
+ .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
|
|
|
|
714
|
+ .toList();
|
|
|
|
715
|
+ final validValuePoints =
|
|
|
|
716
|
+ dayPoints.where((e) => _isValidStressValue(e.result)).toList();
|
|
|
|
717
|
+ final activityBuffers = _confirmedActivityBuffers(realtimePoints);
|
|
|
|
718
|
+ final validStressValues = validValuePoints
|
|
|
|
719
|
+ .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
|
|
|
|
720
|
+ .map((e) => e.result)
|
|
|
|
721
|
+ .toList()
|
|
|
|
722
|
+ ..sort();
|
|
|
|
723
|
+ final dailyStress = _dailyStress(validStressValues);
|
|
|
|
724
|
+ return dailyStress == null
|
|
|
|
725
|
+ ? null
|
|
|
|
726
|
+ : healthRawRealtimeStressState(dailyStress).value;
|
|
|
|
727
|
+ }
|
|
|
|
728
|
+
|
|
613
|
static _HrvDaySummary? _extremeHrv(
|
729
|
static _HrvDaySummary? _extremeHrv(
|
|
614
|
List<_HrvDaySummary> daily, {
|
730
|
List<_HrvDaySummary> daily, {
|
|
615
|
required bool min,
|
731
|
required bool min,
|
|
@@ -790,13 +906,6 @@ class LocalHealthDataConvert { |
|
@@ -790,13 +906,6 @@ class LocalHealthDataConvert { |
|
790
|
return 3;
|
906
|
return 3;
|
|
791
|
}
|
907
|
}
|
|
792
|
|
908
|
|
|
793
|
- static int _hrvStateFromValue(num value) {
|
|
|
|
794
|
- if (value >= 30) return 4;
|
|
|
|
795
|
- if (value >= 21) return 3;
|
|
|
|
796
|
- if (value >= 17) return 2;
|
|
|
|
797
|
- return 1;
|
|
|
|
798
|
- }
|
|
|
|
799
|
-
|
|
|
|
800
|
static int? _modeState(Iterable<int> states) {
|
909
|
static int? _modeState(Iterable<int> states) {
|
|
801
|
final counts = <int, int>{};
|
910
|
final counts = <int, int>{};
|
|
802
|
for (final state in states) {
|
911
|
for (final state in states) {
|
|
@@ -836,6 +945,16 @@ class LocalHealthDataConvert { |
|
@@ -836,6 +945,16 @@ class LocalHealthDataConvert { |
|
836
|
return _sum(values) / values.length;
|
945
|
return _sum(values) / values.length;
|
|
837
|
}
|
946
|
}
|
|
838
|
|
947
|
|
|
|
|
948
|
+ static double? _median(List<double> values) {
|
|
|
|
949
|
+ if (values.isEmpty) return null;
|
|
|
|
950
|
+ final sorted = [...values]..sort();
|
|
|
|
951
|
+ final middle = sorted.length ~/ 2;
|
|
|
|
952
|
+ if (sorted.length.isEven) {
|
|
|
|
953
|
+ return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
|
|
954
|
+ }
|
|
|
|
955
|
+ return sorted[middle];
|
|
|
|
956
|
+ }
|
|
|
|
957
|
+
|
|
839
|
static num? _maxOrNull(List<num> values) {
|
958
|
static num? _maxOrNull(List<num> values) {
|
|
840
|
if (values.isEmpty) return null;
|
959
|
if (values.isEmpty) return null;
|
|
841
|
return values.reduce(math.max);
|
960
|
return values.reduce(math.max);
|
|
@@ -845,6 +964,15 @@ class LocalHealthDataConvert { |
|
@@ -845,6 +964,15 @@ class LocalHealthDataConvert { |
|
845
|
if (values.isEmpty) return null;
|
964
|
if (values.isEmpty) return null;
|
|
846
|
return values.reduce(math.min);
|
965
|
return values.reduce(math.min);
|
|
847
|
}
|
966
|
}
|
|
|
|
967
|
+
|
|
|
|
968
|
+ static double _clamp(double value, double lower, double upper) {
|
|
|
|
969
|
+ return math.min(math.max(value, lower), upper).toDouble();
|
|
|
|
970
|
+ }
|
|
|
|
971
|
+
|
|
|
|
972
|
+ static double _round(double value, int places) {
|
|
|
|
973
|
+ final scale = math.pow(10, places).toDouble();
|
|
|
|
974
|
+ return (value * scale).round() / scale;
|
|
|
|
975
|
+ }
|
|
848
|
}
|
976
|
}
|
|
849
|
|
977
|
|
|
850
|
class _SleepDaySummary {
|
978
|
class _SleepDaySummary {
|
|
@@ -933,4 +1061,6 @@ class _HrvDaySummary { |
|
@@ -933,4 +1061,6 @@ class _HrvDaySummary { |
|
933
|
final double? hrvAverage;
|
1061
|
final double? hrvAverage;
|
|
934
|
final double? hrAverage;
|
1062
|
final double? hrAverage;
|
|
935
|
final int? state;
|
1063
|
final int? state;
|
|
|
|
1064
|
+
|
|
|
|
1065
|
+ bool get hasData => hrvAverage != null || hrAverage != null || state != null;
|
|
936
|
} |
1066
|
} |