|
...
|
...
|
@@ -16,6 +16,7 @@ const _sleepTypeAsleepDeep = 4; |
|
|
|
const _sleepTypeAsleepRem = 5;
|
|
|
|
const _sleepGoalMinutes = 8 * 60.0;
|
|
|
|
const _sleepContinuityToleranceSeconds = 1;
|
|
|
|
const _stressConfirmedActivityBufferSeconds = 12 * 60;
|
|
|
|
|
|
|
|
class LocalHealthDataConvert {
|
|
|
|
const LocalHealthDataConvert._();
|
|
...
|
...
|
@@ -38,6 +39,7 @@ class LocalHealthDataConvert { |
|
|
|
final previousStart = switch (dateRangeType) {
|
|
|
|
0 => start.subtract(const Duration(days: 7)),
|
|
|
|
1 => DateTime(start.year, start.month - 1),
|
|
|
|
2 => DateTime(start.year - 1),
|
|
|
|
_ => null,
|
|
|
|
};
|
|
|
|
if (previousStart == null) return const <DateTime>[];
|
|
...
|
...
|
@@ -277,15 +279,23 @@ class LocalHealthDataConvert { |
|
|
|
static HrvStatisticsDataV2 hrvStatistics({
|
|
|
|
required int dateRangeType,
|
|
|
|
required List<DateTime> days,
|
|
|
|
required List<DateTime> previousDays,
|
|
|
|
required List<HealthRawHrvStressPoint> hrvPoints,
|
|
|
|
required List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
}) {
|
|
|
|
final daily = [
|
|
|
|
for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints),
|
|
|
|
];
|
|
|
|
final daily = <_HrvDaySummary>[];
|
|
|
|
for (final day in days) {
|
|
|
|
final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
|
|
|
|
if (summary.hasData) daily.add(summary);
|
|
|
|
}
|
|
|
|
final validDaily = daily.where((e) => e.hrvAverage != null).toList();
|
|
|
|
final previousDaily = <_HrvDaySummary>[];
|
|
|
|
for (final day in previousDays) {
|
|
|
|
final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
|
|
|
|
if (summary.hasData) previousDaily.add(summary);
|
|
|
|
}
|
|
|
|
final trendList = dateRangeType == 2
|
|
|
|
? _monthlyHrvTrend(validDaily)
|
|
|
|
? _monthlyHrvTrend(daily)
|
|
|
|
: [
|
|
|
|
for (final item in daily)
|
|
|
|
HrvTrendList(
|
|
...
|
...
|
@@ -296,7 +306,8 @@ class LocalHealthDataConvert { |
|
|
|
),
|
|
|
|
];
|
|
|
|
|
|
|
|
final distribution = _hrvDistribution(validDaily);
|
|
|
|
final distribution = _stressStateDistribution(daily);
|
|
|
|
final previousDistribution = _stressStateDistribution(previousDaily);
|
|
|
|
final minDay = _extremeHrv(validDaily, min: true);
|
|
|
|
final maxDay = _extremeHrv(validDaily, min: false);
|
|
|
|
|
|
...
|
...
|
@@ -309,13 +320,23 @@ class LocalHealthDataConvert { |
|
|
|
dayCounts: entry.value,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
dailyDistributionList: [
|
|
|
|
for (final item in validDaily)
|
|
|
|
DailyDistributionList(
|
|
|
|
date: dateKey(item.day),
|
|
|
|
hrvLevel: item.state,
|
|
|
|
qoqHrvDistributionList: [
|
|
|
|
for (final entry in previousDistribution.entries)
|
|
|
|
QoqHrvDistributionList(
|
|
|
|
stressState: entry.key,
|
|
|
|
dayCounts: entry.value,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
dailyDistributionList: dateRangeType == 2
|
|
|
|
? [
|
|
|
|
for (final item in daily)
|
|
|
|
if (item.state != null)
|
|
|
|
DailyDistributionList(
|
|
|
|
date: dateKey(item.day),
|
|
|
|
hrvLevel: item.state,
|
|
|
|
),
|
|
|
|
]
|
|
|
|
: null,
|
|
|
|
hrvMin: minDay == null
|
|
|
|
? null
|
|
|
|
: HrvMin(
|
|
...
|
...
|
@@ -451,18 +472,82 @@ class LocalHealthDataConvert { |
|
|
|
}
|
|
|
|
|
|
|
|
static V2StressScore v2StressScore(
|
|
|
|
List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
) {
|
|
|
|
final averageStress = _averageOrNull(
|
|
|
|
realtimePoints.map((e) => e.result).toList(),
|
|
|
|
);
|
|
|
|
final score = averageStress?.round();
|
|
|
|
List<HealthRawRealtimeStressPoint> realtimePoints, {
|
|
|
|
required int startTime,
|
|
|
|
required int endTime,
|
|
|
|
}) {
|
|
|
|
final dayPoints = realtimePoints
|
|
|
|
.where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
|
|
|
|
.toList();
|
|
|
|
final validValuePoints =
|
|
|
|
dayPoints.where((e) => _isValidStressValue(e.result)).toList();
|
|
|
|
final activityBuffers = _confirmedActivityBuffers(realtimePoints);
|
|
|
|
final validStressValues = validValuePoints
|
|
|
|
.where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
|
|
|
|
.map((e) => e.result)
|
|
|
|
.toList()
|
|
|
|
..sort();
|
|
|
|
final dailyStress = _dailyStress(validStressValues);
|
|
|
|
final score = dailyStress?.round();
|
|
|
|
return V2StressScore(
|
|
|
|
state: score == null ? 0 : healthRawRealtimeStressState(score).value,
|
|
|
|
comprehensiveScore: score,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
static double? _dailyStress(List<double> sortedValues) {
|
|
|
|
if (sortedValues.isEmpty) return null;
|
|
|
|
final median = _median(sortedValues)!;
|
|
|
|
final p75 = _percentile75(sortedValues);
|
|
|
|
return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100), 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
static List<({int start, int end})> _confirmedActivityBuffers(
|
|
|
|
List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
) {
|
|
|
|
final ranges = realtimePoints
|
|
|
|
.where((e) =>
|
|
|
|
_isValidStressValue(e.result) &&
|
|
|
|
(e.isWorkout || e.isWorkoutRecovery))
|
|
|
|
.map((e) => (
|
|
|
|
start: e.rawEndTime - _stressConfirmedActivityBufferSeconds,
|
|
|
|
end: e.rawEndTime + _stressConfirmedActivityBufferSeconds,
|
|
|
|
))
|
|
|
|
.toList()
|
|
|
|
..sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
if (ranges.isEmpty) return const <({int start, int end})>[];
|
|
|
|
|
|
|
|
final merged = <({int start, int end})>[];
|
|
|
|
var current = ranges.first;
|
|
|
|
for (final range in ranges.skip(1)) {
|
|
|
|
if (range.start <= current.end) {
|
|
|
|
current = (
|
|
|
|
start: current.start,
|
|
|
|
end: math.max(current.end, range.end),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
merged.add(current);
|
|
|
|
current = range;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
merged.add(current);
|
|
|
|
return merged;
|
|
|
|
}
|
|
|
|
|
|
|
|
static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {
|
|
|
|
return ranges.any((e) => time >= e.start && time <= e.end);
|
|
|
|
}
|
|
|
|
|
|
|
|
static bool _isValidStressValue(num value) => value >= 1 && value <= 100;
|
|
|
|
|
|
|
|
static double _percentile75(List<double> sortedValues) {
|
|
|
|
final rank = (sortedValues.length * 0.75).ceil().clamp(
|
|
|
|
1,
|
|
|
|
sortedValues.length,
|
|
|
|
);
|
|
|
|
return sortedValues[rank - 1];
|
|
|
|
}
|
|
|
|
|
|
|
|
static _SleepDaySummary _sleepDaySummary(
|
|
|
|
DateTime day,
|
|
|
|
List<HealthKitRawDataPoint> sleepIntervals,
|
|
...
|
...
|
@@ -480,7 +565,11 @@ class LocalHealthDataConvert { |
|
|
|
point.endTime > interval.startTime &&
|
|
|
|
point.endTime <= interval.endTime))
|
|
|
|
.toList();
|
|
|
|
final sleepDuration = windowEnd - windowStart;
|
|
|
|
final nullableScore = score == 0 ? null : score;
|
|
|
|
final sleepEvaluate = _sleepEvaluate(score);
|
|
|
|
final sleepDuration = nullableScore == null || sleepEvaluate == null
|
|
|
|
? 0
|
|
|
|
: windowEnd - windowStart;
|
|
|
|
|
|
|
|
return _SleepDaySummary(
|
|
|
|
day: day,
|
|
...
|
...
|
@@ -490,8 +579,8 @@ class LocalHealthDataConvert { |
|
|
|
asleepTime: intervals.isEmpty
|
|
|
|
? null
|
|
|
|
: intervals.map((e) => e.startTime).reduce(math.min),
|
|
|
|
score: score == 0 ? null : score,
|
|
|
|
evaluate: _sleepEvaluate(score),
|
|
|
|
score: nullableScore,
|
|
|
|
evaluate: sleepEvaluate,
|
|
|
|
sleepHr: sleepHr,
|
|
|
|
);
|
|
|
|
}
|
|
...
|
...
|
@@ -571,12 +660,17 @@ class LocalHealthDataConvert { |
|
|
|
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
|
|
|
|
.toList();
|
|
|
|
final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
|
|
|
|
final stressState = _dailyStressState(
|
|
|
|
realtime,
|
|
|
|
startTime: start,
|
|
|
|
endTime: end - 1,
|
|
|
|
);
|
|
|
|
return _HrvDaySummary(
|
|
|
|
day: day,
|
|
|
|
hrvAverage: hrvAverage?.toDouble(),
|
|
|
|
hrAverage:
|
|
|
|
_averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
|
|
|
|
state: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage),
|
|
|
|
state: stressState,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -600,7 +694,7 @@ class LocalHealthDataConvert { |
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
static Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) {
|
|
|
|
static Map<int, int> _stressStateDistribution(List<_HrvDaySummary> daily) {
|
|
|
|
final result = <int, int>{};
|
|
|
|
for (final item in daily) {
|
|
|
|
final state = item.state;
|
|
...
|
...
|
@@ -610,6 +704,28 @@ class LocalHealthDataConvert { |
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
static int? _dailyStressState(
|
|
|
|
List<HealthRawRealtimeStressPoint> realtimePoints, {
|
|
|
|
required int startTime,
|
|
|
|
required int endTime,
|
|
|
|
}) {
|
|
|
|
final dayPoints = realtimePoints
|
|
|
|
.where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
|
|
|
|
.toList();
|
|
|
|
final validValuePoints =
|
|
|
|
dayPoints.where((e) => _isValidStressValue(e.result)).toList();
|
|
|
|
final activityBuffers = _confirmedActivityBuffers(realtimePoints);
|
|
|
|
final validStressValues = validValuePoints
|
|
|
|
.where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
|
|
|
|
.map((e) => e.result)
|
|
|
|
.toList()
|
|
|
|
..sort();
|
|
|
|
final dailyStress = _dailyStress(validStressValues);
|
|
|
|
return dailyStress == null
|
|
|
|
? null
|
|
|
|
: healthRawRealtimeStressState(dailyStress).value;
|
|
|
|
}
|
|
|
|
|
|
|
|
static _HrvDaySummary? _extremeHrv(
|
|
|
|
List<_HrvDaySummary> daily, {
|
|
|
|
required bool min,
|
|
...
|
...
|
@@ -790,13 +906,6 @@ class LocalHealthDataConvert { |
|
|
|
return 3;
|
|
|
|
}
|
|
|
|
|
|
|
|
static int _hrvStateFromValue(num value) {
|
|
|
|
if (value >= 30) return 4;
|
|
|
|
if (value >= 21) return 3;
|
|
|
|
if (value >= 17) return 2;
|
|
|
|
return 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
static int? _modeState(Iterable<int> states) {
|
|
|
|
final counts = <int, int>{};
|
|
|
|
for (final state in states) {
|
|
...
|
...
|
@@ -836,6 +945,16 @@ class LocalHealthDataConvert { |
|
|
|
return _sum(values) / values.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
static double? _median(List<double> values) {
|
|
|
|
if (values.isEmpty) return null;
|
|
|
|
final sorted = [...values]..sort();
|
|
|
|
final middle = sorted.length ~/ 2;
|
|
|
|
if (sorted.length.isEven) {
|
|
|
|
return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
|
|
}
|
|
|
|
return sorted[middle];
|
|
|
|
}
|
|
|
|
|
|
|
|
static num? _maxOrNull(List<num> values) {
|
|
|
|
if (values.isEmpty) return null;
|
|
|
|
return values.reduce(math.max);
|
|
...
|
...
|
@@ -845,6 +964,15 @@ class LocalHealthDataConvert { |
|
|
|
if (values.isEmpty) return null;
|
|
|
|
return values.reduce(math.min);
|
|
|
|
}
|
|
|
|
|
|
|
|
static double _clamp(double value, double lower, double upper) {
|
|
|
|
return math.min(math.max(value, lower), upper).toDouble();
|
|
|
|
}
|
|
|
|
|
|
|
|
static double _round(double value, int places) {
|
|
|
|
final scale = math.pow(10, places).toDouble();
|
|
|
|
return (value * scale).round() / scale;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _SleepDaySummary {
|
|
...
|
...
|
@@ -933,4 +1061,6 @@ class _HrvDaySummary { |
|
|
|
final double? hrvAverage;
|
|
|
|
final double? hrAverage;
|
|
|
|
final int? state;
|
|
|
|
|
|
|
|
bool get hasData => hrvAverage != null || hrAverage != null || state != null;
|
|
|
|
} |
...
|
...
|
|