|
|
|
1
|
+import 'dart:math' as math;
|
|
|
|
2
|
+
|
|
|
|
3
|
+import '../../../../pigeon/health_kit_raw_data_api.g.dart';
|
|
|
|
4
|
+import '../health_raw_models.dart';
|
|
|
|
5
|
+
|
|
|
|
6
|
+class HuaweiHealthRawStressCalculator {
|
|
|
|
7
|
+ const HuaweiHealthRawStressCalculator({required this.userId});
|
|
|
|
8
|
+
|
|
|
|
9
|
+ static const int rawRmssdMin = 5;
|
|
|
|
10
|
+ static const int rawRmssdMax = 200;
|
|
|
|
11
|
+ static const int baselineSampleCount = 50;
|
|
|
|
12
|
+ static const double defaultBaselineAwakeRmssd = 50;
|
|
|
|
13
|
+ static const double trendReference = 50;
|
|
|
|
14
|
+ static const double huaweiTrendScaleFactorAwake = 1;
|
|
|
|
15
|
+ static const double huaweiTrendScaleFactorSleep = 1;
|
|
|
|
16
|
+
|
|
|
|
17
|
+ final int userId;
|
|
|
|
18
|
+
|
|
|
|
19
|
+ HealthRawStressCalculationResult calculate({
|
|
|
|
20
|
+ required List<HealthKitRawDataPoint> hrvPoints,
|
|
|
|
21
|
+ required List<HealthKitRawDataPoint> heartRatePoints,
|
|
|
|
22
|
+ required List<HealthKitRawDataPoint> restingHeartRatePoints,
|
|
|
|
23
|
+ List<HealthKitRawDataPoint> sleepIntervals =
|
|
|
|
24
|
+ const <HealthKitRawDataPoint>[],
|
|
|
|
25
|
+ List<HealthKitRawWorkoutDataPoint> workoutIntervals =
|
|
|
|
26
|
+ const <HealthKitRawWorkoutDataPoint>[],
|
|
|
|
27
|
+ required int startTime,
|
|
|
|
28
|
+ required int endTime,
|
|
|
|
29
|
+ }) {
|
|
|
|
30
|
+ final marked = _markRawData(
|
|
|
|
31
|
+ hrvPoints: hrvPoints,
|
|
|
|
32
|
+ heartRatePoints: heartRatePoints,
|
|
|
|
33
|
+ restingHeartRatePoints: restingHeartRatePoints,
|
|
|
|
34
|
+ sleepIntervals: sleepIntervals,
|
|
|
|
35
|
+ workoutIntervals: workoutIntervals,
|
|
|
|
36
|
+ );
|
|
|
|
37
|
+ final sortedRmssd = marked.hrv.where((e) {
|
|
|
|
38
|
+ final value = e.value;
|
|
|
|
39
|
+ return value != null && _isValidRmssd(value);
|
|
|
|
40
|
+ }).toList()
|
|
|
|
41
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
42
|
+ final sortedHr = marked.heartRate.where((e) {
|
|
|
|
43
|
+ final value = e.value;
|
|
|
|
44
|
+ return value != null && _isValidHr(value);
|
|
|
|
45
|
+ }).toList()
|
|
|
|
46
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
47
|
+ final sortedRestingHr = marked.restingHeartRate.where((e) {
|
|
|
|
48
|
+ final value = e.value;
|
|
|
|
49
|
+ return value != null && _isValidHr(value);
|
|
|
|
50
|
+ }).toList()
|
|
|
|
51
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
52
|
+
|
|
|
|
53
|
+ final hrvStressPoints = _calculateHrvStressPoints(
|
|
|
|
54
|
+ rmssdPoints: sortedRmssd,
|
|
|
|
55
|
+ heartRatePoints: sortedHr,
|
|
|
|
56
|
+ restingHeartRatePoints: sortedRestingHr,
|
|
|
|
57
|
+ );
|
|
|
|
58
|
+ final realtimeStressPoints = _calculateRealtimeStressPoints(
|
|
|
|
59
|
+ heartRatePoints: sortedHr,
|
|
|
|
60
|
+ restingHeartRatePoints: sortedRestingHr,
|
|
|
|
61
|
+ hrvStressPoints: hrvStressPoints,
|
|
|
|
62
|
+ startTime: startTime,
|
|
|
|
63
|
+ endTime: endTime,
|
|
|
|
64
|
+ );
|
|
|
|
65
|
+
|
|
|
|
66
|
+ return HealthRawStressCalculationResult(
|
|
|
|
67
|
+ userId: userId,
|
|
|
|
68
|
+ hrvStressPoints: hrvStressPoints,
|
|
|
|
69
|
+ realtimeStressPoints: realtimeStressPoints,
|
|
|
|
70
|
+ dailyStressPoints: calculateDailyStressPoints(
|
|
|
|
71
|
+ userId: userId,
|
|
|
|
72
|
+ realtimePoints: realtimeStressPoints,
|
|
|
|
73
|
+ startTime: startTime,
|
|
|
|
74
|
+ endTime: endTime,
|
|
|
|
75
|
+ dataTime: endTime,
|
|
|
|
76
|
+ ),
|
|
|
|
77
|
+ );
|
|
|
|
78
|
+ }
|
|
|
|
79
|
+
|
|
|
|
80
|
+ static List<HealthRawDailyStressPoint> calculateDailyStressPoints({
|
|
|
|
81
|
+ required int userId,
|
|
|
|
82
|
+ required List<HealthRawRealtimeStressPoint> realtimePoints,
|
|
|
|
83
|
+ required int startTime,
|
|
|
|
84
|
+ required int endTime,
|
|
|
|
85
|
+ required int dataTime,
|
|
|
|
86
|
+ Set<int> skipExistingDates = const <int>{},
|
|
|
|
87
|
+ }) {
|
|
|
|
88
|
+ if (realtimePoints.isEmpty) return const <HealthRawDailyStressPoint>[];
|
|
|
|
89
|
+ final dates = <int>{
|
|
|
|
90
|
+ for (final point in realtimePoints)
|
|
|
|
91
|
+ if (point.rawEndTime >= startTime && point.rawEndTime <= endTime)
|
|
|
|
92
|
+ _dateKeyFromUnixSeconds(point.rawEndTime),
|
|
|
|
93
|
+ }.toList()
|
|
|
|
94
|
+ ..sort();
|
|
|
|
95
|
+ final dailyStressPoints = <HealthRawDailyStressPoint>[];
|
|
|
|
96
|
+ for (final date in dates) {
|
|
|
|
97
|
+ if (skipExistingDates.contains(date)) continue;
|
|
|
|
98
|
+ final (dayStart, dayEnd) = _dayRangeFromDateKey(date);
|
|
|
|
99
|
+ final dayRealtimePoints = realtimePoints
|
|
|
|
100
|
+ .where((point) =>
|
|
|
|
101
|
+ point.rawEndTime >= dayStart && point.rawEndTime <= dayEnd)
|
|
|
|
102
|
+ .toList();
|
|
|
|
103
|
+ final point = HealthRawDailyStressCalculator.calculate(
|
|
|
|
104
|
+ userId: userId,
|
|
|
|
105
|
+ date: date,
|
|
|
|
106
|
+ realtimePoints: dayRealtimePoints,
|
|
|
|
107
|
+ dataTime: dataTime,
|
|
|
|
108
|
+ );
|
|
|
|
109
|
+ if (point != null) dailyStressPoints.add(point);
|
|
|
|
110
|
+ }
|
|
|
|
111
|
+ return dailyStressPoints;
|
|
|
|
112
|
+ }
|
|
|
|
113
|
+
|
|
|
|
114
|
+ HealthRawMarkedDataSet _markRawData({
|
|
|
|
115
|
+ required List<HealthKitRawDataPoint> hrvPoints,
|
|
|
|
116
|
+ required List<HealthKitRawDataPoint> heartRatePoints,
|
|
|
|
117
|
+ required List<HealthKitRawDataPoint> restingHeartRatePoints,
|
|
|
|
118
|
+ required List<HealthKitRawDataPoint> sleepIntervals,
|
|
|
|
119
|
+ required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
|
|
|
|
120
|
+ }) {
|
|
|
|
121
|
+ final hrv = hrvPoints
|
|
|
|
122
|
+ .map((point) => HealthRawMarkedDataPoint(
|
|
|
|
123
|
+ raw: point,
|
|
|
|
124
|
+ flags: _flagsForPoint(
|
|
|
|
125
|
+ point,
|
|
|
|
126
|
+ sleepIntervals: sleepIntervals,
|
|
|
|
127
|
+ workoutIntervals: workoutIntervals,
|
|
|
|
128
|
+ ),
|
|
|
|
129
|
+ ))
|
|
|
|
130
|
+ .toList()
|
|
|
|
131
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
132
|
+ final heartRate = heartRatePoints
|
|
|
|
133
|
+ .map((point) => HealthRawMarkedDataPoint(
|
|
|
|
134
|
+ raw: point,
|
|
|
|
135
|
+ flags: _flagsForPoint(
|
|
|
|
136
|
+ point,
|
|
|
|
137
|
+ sleepIntervals: sleepIntervals,
|
|
|
|
138
|
+ workoutIntervals: workoutIntervals,
|
|
|
|
139
|
+ ),
|
|
|
|
140
|
+ ))
|
|
|
|
141
|
+ .toList()
|
|
|
|
142
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
143
|
+ final restingHeartRate = restingHeartRatePoints
|
|
|
|
144
|
+ .map((point) => HealthRawMarkedDataPoint(
|
|
|
|
145
|
+ raw: point,
|
|
|
|
146
|
+ flags: _flagsForPoint(
|
|
|
|
147
|
+ point,
|
|
|
|
148
|
+ sleepIntervals: sleepIntervals,
|
|
|
|
149
|
+ workoutIntervals: workoutIntervals,
|
|
|
|
150
|
+ ),
|
|
|
|
151
|
+ ))
|
|
|
|
152
|
+ .toList()
|
|
|
|
153
|
+ ..sort((a, b) => a.endTime.compareTo(b.endTime));
|
|
|
|
154
|
+
|
|
|
|
155
|
+ return HealthRawMarkedDataSet(
|
|
|
|
156
|
+ hrv: hrv,
|
|
|
|
157
|
+ heartRate: heartRate,
|
|
|
|
158
|
+ restingHeartRate: restingHeartRate,
|
|
|
|
159
|
+ );
|
|
|
|
160
|
+ }
|
|
|
|
161
|
+
|
|
|
|
162
|
+ HealthRawPointFlags _flagsForPoint(
|
|
|
|
163
|
+ HealthKitRawDataPoint point, {
|
|
|
|
164
|
+ required List<HealthKitRawDataPoint> sleepIntervals,
|
|
|
|
165
|
+ required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
|
|
|
|
166
|
+ }) {
|
|
|
|
167
|
+ final isWorkout = workoutIntervals.any(
|
|
|
|
168
|
+ (workout) =>
|
|
|
|
169
|
+ point.endTime > workout.startTime && point.endTime <= workout.endTime,
|
|
|
|
170
|
+ );
|
|
|
|
171
|
+ final isWorkoutRecovery = !isWorkout &&
|
|
|
|
172
|
+ workoutIntervals.any(
|
|
|
|
173
|
+ (workout) =>
|
|
|
|
174
|
+ point.endTime > workout.endTime &&
|
|
|
|
175
|
+ point.endTime <= workout.endTime + 30 * 60,
|
|
|
|
176
|
+ );
|
|
|
|
177
|
+ return HealthRawPointFlags(
|
|
|
|
178
|
+ isWorkout: isWorkout,
|
|
|
|
179
|
+ isWorkoutRecovery: isWorkoutRecovery,
|
|
|
|
180
|
+ isSleepLikely: sleepIntervals.any(
|
|
|
|
181
|
+ (sleep) =>
|
|
|
|
182
|
+ point.endTime > sleep.startTime && point.endTime <= sleep.endTime,
|
|
|
|
183
|
+ ),
|
|
|
|
184
|
+ isSuspectedActivity: point.isMotionLike == true,
|
|
|
|
185
|
+ );
|
|
|
|
186
|
+ }
|
|
|
|
187
|
+
|
|
|
|
188
|
+ List<HealthRawHrvStressPoint> _calculateHrvStressPoints({
|
|
|
|
189
|
+ required List<HealthRawMarkedDataPoint> rmssdPoints,
|
|
|
|
190
|
+ required List<HealthRawMarkedDataPoint> heartRatePoints,
|
|
|
|
191
|
+ required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
|
|
|
|
192
|
+ }) {
|
|
|
|
193
|
+ final validRmssdHistory = <HealthRawMarkedDataPoint>[];
|
|
|
|
194
|
+ final results = <HealthRawHrvStressPoint>[];
|
|
|
|
195
|
+
|
|
|
|
196
|
+ for (final point in rmssdPoints) {
|
|
|
|
197
|
+ final pointValue = point.value!;
|
|
|
|
198
|
+ final isActivity = point.flags.isWorkout || point.flags.isWorkoutRecovery;
|
|
|
|
199
|
+ if (isActivity) {
|
|
|
|
200
|
+ continue;
|
|
|
|
201
|
+ }
|
|
|
|
202
|
+
|
|
|
|
203
|
+ final isSleep = point.flags.isSleepLikely;
|
|
|
|
204
|
+ final awakeRmssdHistory = _latestBefore(
|
|
|
|
205
|
+ validRmssdHistory
|
|
|
|
206
|
+ .where((e) =>
|
|
|
|
207
|
+ e.flags.isAwakeBaselineEligible && _isValidRmssd(e.value))
|
|
|
|
208
|
+ .toList(),
|
|
|
|
209
|
+ point.endTime,
|
|
|
|
210
|
+ limit: baselineSampleCount,
|
|
|
|
211
|
+ );
|
|
|
|
212
|
+ final sleepRmssdHistory = _latestBefore(
|
|
|
|
213
|
+ validRmssdHistory
|
|
|
|
214
|
+ .where((e) =>
|
|
|
|
215
|
+ e.flags.isSleepHrvBaselineEligible && _isValidRmssd(e.value))
|
|
|
|
216
|
+ .toList(),
|
|
|
|
217
|
+ point.endTime,
|
|
|
|
218
|
+ limit: baselineSampleCount,
|
|
|
|
219
|
+ );
|
|
|
|
220
|
+ final baselineAwakeRmssd = _baseline(
|
|
|
|
221
|
+ awakeRmssdHistory.map((e) => e.value!).toList(),
|
|
|
|
222
|
+ requiredCount: baselineSampleCount,
|
|
|
|
223
|
+ fallback: defaultBaselineAwakeRmssd,
|
|
|
|
224
|
+ );
|
|
|
|
225
|
+ final baselineSleepRmssd = _baseline(
|
|
|
|
226
|
+ sleepRmssdHistory.map((e) => e.value!).toList(),
|
|
|
|
227
|
+ requiredCount: baselineSampleCount,
|
|
|
|
228
|
+ fallback: baselineAwakeRmssd,
|
|
|
|
229
|
+ );
|
|
|
|
230
|
+ final baselineRmssd = isSleep ? baselineSleepRmssd : baselineAwakeRmssd;
|
|
|
|
231
|
+ final referenceHrv = isSleep
|
|
|
|
232
|
+ ? trendReference * huaweiTrendScaleFactorSleep
|
|
|
|
233
|
+ : trendReference * huaweiTrendScaleFactorAwake;
|
|
|
|
234
|
+ final rmssdRatio = pointValue / baselineRmssd;
|
|
|
|
235
|
+ final trendDelta = referenceHrv * (rmssdRatio - 1);
|
|
|
|
236
|
+ final trendBaseValue = pointValue >= baselineRmssd
|
|
|
|
237
|
+ ? referenceHrv + trendDelta * 0.8
|
|
|
|
238
|
+ : referenceHrv + trendDelta;
|
|
|
|
239
|
+
|
|
|
|
240
|
+ final restingHistory = _latestBefore(
|
|
|
|
241
|
+ restingHeartRatePoints
|
|
|
|
242
|
+ .where((e) => e.flags.isAwakeHrBaselineEligible)
|
|
|
|
243
|
+ .toList(),
|
|
|
|
244
|
+ point.endTime,
|
|
|
|
245
|
+ limit: baselineSampleCount,
|
|
|
|
246
|
+ );
|
|
|
|
247
|
+ final baselineRestingHr = _baseline(
|
|
|
|
248
|
+ restingHistory.map((e) => e.value!).toList(),
|
|
|
|
249
|
+ requiredCount: baselineSampleCount,
|
|
|
|
250
|
+ fallback: 65,
|
|
|
|
251
|
+ );
|
|
|
|
252
|
+ final hrWindow = isSleep
|
|
|
|
253
|
+ ? const <HealthRawMarkedDataPoint>[]
|
|
|
|
254
|
+ : _pointsInRange(
|
|
|
|
255
|
+ heartRatePoints,
|
|
|
|
256
|
+ point.endTime - 5 * 60,
|
|
|
|
257
|
+ point.endTime,
|
|
|
|
258
|
+ ).where((e) => e.flags.isCurrentAwakeHrEligible).toList();
|
|
|
|
259
|
+ final currentHr = _median(hrWindow.map((e) => e.value!).toList());
|
|
|
|
260
|
+
|
|
|
|
261
|
+ final double trendHrv;
|
|
|
|
262
|
+ if (currentHr != null) {
|
|
|
|
263
|
+ final hrRatio = currentHr / baselineRestingHr;
|
|
|
|
264
|
+ final double hrFactor;
|
|
|
|
265
|
+ if (hrRatio >= 1) {
|
|
|
|
266
|
+ hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10);
|
|
|
|
267
|
+ } else if (pointValue >= baselineRmssd) {
|
|
|
|
268
|
+ hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10);
|
|
|
|
269
|
+ } else {
|
|
|
|
270
|
+ hrFactor = 1;
|
|
|
|
271
|
+ }
|
|
|
|
272
|
+ final mismatch = math.max(hrRatio - rmssdRatio, 0).toDouble();
|
|
|
|
273
|
+ final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00);
|
|
|
|
274
|
+ trendHrv = _clamp(
|
|
|
|
275
|
+ trendBaseValue * hrFactor * matchFactor,
|
|
|
|
276
|
+ 5,
|
|
|
|
277
|
+ referenceHrv * 1.8,
|
|
|
|
278
|
+ );
|
|
|
|
279
|
+ } else {
|
|
|
|
280
|
+ trendHrv = _clamp(trendBaseValue, 5, referenceHrv * 1.8);
|
|
|
|
281
|
+ }
|
|
|
|
282
|
+
|
|
|
|
283
|
+ final sourceRange = _timeRange([
|
|
|
|
284
|
+ ...awakeRmssdHistory,
|
|
|
|
285
|
+ ...sleepRmssdHistory,
|
|
|
|
286
|
+ point,
|
|
|
|
287
|
+ ...hrWindow,
|
|
|
|
288
|
+ ...restingHistory,
|
|
|
|
289
|
+ ]);
|
|
|
|
290
|
+ final normalizedTrendHrv = _integerDouble(trendHrv);
|
|
|
|
291
|
+ results.add(
|
|
|
|
292
|
+ HealthRawHrvStressPoint(
|
|
|
|
293
|
+ userId: userId,
|
|
|
|
294
|
+ rawEndTime: point.endTime,
|
|
|
|
295
|
+ rawHrv: pointValue,
|
|
|
|
296
|
+ result: normalizedTrendHrv,
|
|
|
|
297
|
+ sourceStartTime: sourceRange.start,
|
|
|
|
298
|
+ sourceEndTime: sourceRange.end,
|
|
|
|
299
|
+ state: _hrvState(normalizedTrendHrv),
|
|
|
|
300
|
+ baselineHrv: baselineRmssd,
|
|
|
|
301
|
+ baselineAwakeHrv: baselineAwakeRmssd,
|
|
|
|
302
|
+ baselineSleepHrv: isSleep ? baselineSleepRmssd : null,
|
|
|
|
303
|
+ baselineRestingHr: baselineRestingHr,
|
|
|
|
304
|
+ flags: point.flags,
|
|
|
|
305
|
+ ),
|
|
|
|
306
|
+ );
|
|
|
|
307
|
+ validRmssdHistory.add(point);
|
|
|
|
308
|
+ }
|
|
|
|
309
|
+
|
|
|
|
310
|
+ return results;
|
|
|
|
311
|
+ }
|
|
|
|
312
|
+
|
|
|
|
313
|
+ List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({
|
|
|
|
314
|
+ required List<HealthRawMarkedDataPoint> heartRatePoints,
|
|
|
|
315
|
+ required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
|
|
|
|
316
|
+ required List<HealthRawHrvStressPoint> hrvStressPoints,
|
|
|
|
317
|
+ required int startTime,
|
|
|
|
318
|
+ required int endTime,
|
|
|
|
319
|
+ }) {
|
|
|
|
320
|
+ final results = <HealthRawRealtimeStressPoint>[];
|
|
|
|
321
|
+ final days = _daysInRange(startTime, endTime);
|
|
|
|
322
|
+ double? previousAwakeStress;
|
|
|
|
323
|
+ int? previousAwakeStressTime;
|
|
|
|
324
|
+ double? previousSleepStress;
|
|
|
|
325
|
+ int? previousSleepStressTime;
|
|
|
|
326
|
+ double? awakeHrvAnchorStress;
|
|
|
|
327
|
+ double? sleepHrvAnchorStress;
|
|
|
|
328
|
+
|
|
|
|
329
|
+ for (final dayStart in days) {
|
|
|
|
330
|
+ final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime);
|
|
|
|
331
|
+ final dayHr = heartRatePoints
|
|
|
|
332
|
+ .where((e) => e.endTime >= dayStart && e.endTime <= dayEnd)
|
|
|
|
333
|
+ .toList();
|
|
|
|
334
|
+ if (dayHr.isEmpty) continue;
|
|
|
|
335
|
+
|
|
|
|
336
|
+ var pointTime = dayHr.first.endTime;
|
|
|
|
337
|
+ while (pointTime <= dayEnd) {
|
|
|
|
338
|
+ final hrWindow = _pointsInRange(
|
|
|
|
339
|
+ dayHr,
|
|
|
|
340
|
+ pointTime - 6 * 60 + 1,
|
|
|
|
341
|
+ pointTime,
|
|
|
|
342
|
+ );
|
|
|
|
343
|
+ final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last;
|
|
|
|
344
|
+ final context = currentRawPoint?.flags.context;
|
|
|
|
345
|
+ final contextWindow = context == null
|
|
|
|
346
|
+ ? <HealthRawMarkedDataPoint>[]
|
|
|
|
347
|
+ : hrWindow.where((e) => e.flags.matchesContext(context)).toList();
|
|
|
|
348
|
+ final currentHr = _median(contextWindow.map((e) => e.value!).toList());
|
|
|
|
349
|
+ if (currentRawPoint != null && currentHr != null) {
|
|
|
|
350
|
+ final isSleep = context == HealthRawPointContext.sleep;
|
|
|
|
351
|
+ final isActivity = context == HealthRawPointContext.workout ||
|
|
|
|
352
|
+ context == HealthRawPointContext.workoutRecovery ||
|
|
|
|
353
|
+ context == HealthRawPointContext.suspectedActivity;
|
|
|
|
354
|
+ final awakeHistory = _latestBefore(
|
|
|
|
355
|
+ restingHeartRatePoints
|
|
|
|
356
|
+ .where((e) => e.flags.isAwakeHrBaselineEligible)
|
|
|
|
357
|
+ .toList(),
|
|
|
|
358
|
+ pointTime,
|
|
|
|
359
|
+ limit: baselineSampleCount,
|
|
|
|
360
|
+ );
|
|
|
|
361
|
+ final baselineAwakeHr = _baseline(
|
|
|
|
362
|
+ awakeHistory.map((e) => e.value!).toList(),
|
|
|
|
363
|
+ requiredCount: baselineSampleCount,
|
|
|
|
364
|
+ fallback: 65,
|
|
|
|
365
|
+ );
|
|
|
|
366
|
+ final sleepHistory = _latestBefore(
|
|
|
|
367
|
+ restingHeartRatePoints
|
|
|
|
368
|
+ .where((e) => e.flags.isSleepHrBaselineEligible)
|
|
|
|
369
|
+ .toList(),
|
|
|
|
370
|
+ pointTime,
|
|
|
|
371
|
+ limit: baselineSampleCount,
|
|
|
|
372
|
+ );
|
|
|
|
373
|
+ final baselineSleepHr = _sleepBaseline(
|
|
|
|
374
|
+ sleepHistory.map((e) => e.value!).toList(),
|
|
|
|
375
|
+ awakeHistoryCount: awakeHistory.length,
|
|
|
|
376
|
+ baselineAwakeHr: baselineAwakeHr,
|
|
|
|
377
|
+ );
|
|
|
|
378
|
+ final restingHistory = _latestBefore(
|
|
|
|
379
|
+ restingHeartRatePoints,
|
|
|
|
380
|
+ pointTime,
|
|
|
|
381
|
+ limit: baselineSampleCount,
|
|
|
|
382
|
+ );
|
|
|
|
383
|
+ final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr;
|
|
|
|
384
|
+ final hrChange =
|
|
|
|
385
|
+ (currentHr - baselineRestingHr) / baselineRestingHr * 100;
|
|
|
|
386
|
+ final hrStress = _clamp(_hrStress(hrChange), 1, 100);
|
|
|
|
387
|
+ final smoothStress = isActivity
|
|
|
|
388
|
+ ? hrStress
|
|
|
|
389
|
+ : _smoothedStress(
|
|
|
|
390
|
+ hrStress: hrStress,
|
|
|
|
391
|
+ pointTime: pointTime,
|
|
|
|
392
|
+ previousStress:
|
|
|
|
393
|
+ isSleep ? previousSleepStress : previousAwakeStress,
|
|
|
|
394
|
+ previousStressTime: isSleep
|
|
|
|
395
|
+ ? previousSleepStressTime
|
|
|
|
396
|
+ : previousAwakeStressTime,
|
|
|
|
397
|
+ );
|
|
|
|
398
|
+ final latestHrv = isActivity
|
|
|
|
399
|
+ ? null
|
|
|
|
400
|
+ : _lastHrvStressWhere(
|
|
|
|
401
|
+ hrvStressPoints,
|
|
|
|
402
|
+ (e) =>
|
|
|
|
403
|
+ e.rawEndTime <= pointTime && e.flags.context == context,
|
|
|
|
404
|
+ );
|
|
|
|
405
|
+ final adjustment = _adjustedStress(
|
|
|
|
406
|
+ smoothStress: smoothStress,
|
|
|
|
407
|
+ pointTime: pointTime,
|
|
|
|
408
|
+ latestHrv: latestHrv,
|
|
|
|
409
|
+ hrvAnchorStress:
|
|
|
|
410
|
+ isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress,
|
|
|
|
411
|
+ );
|
|
|
|
412
|
+ if (adjustment.updatedAnchorStress != null) {
|
|
|
|
413
|
+ if (isSleep) {
|
|
|
|
414
|
+ sleepHrvAnchorStress = adjustment.updatedAnchorStress;
|
|
|
|
415
|
+ } else {
|
|
|
|
416
|
+ awakeHrvAnchorStress = adjustment.updatedAnchorStress;
|
|
|
|
417
|
+ }
|
|
|
|
418
|
+ }
|
|
|
|
419
|
+
|
|
|
|
420
|
+ final stress = _integerDouble(_clamp(adjustment.value, 1, 100));
|
|
|
|
421
|
+ final sourceRange = _sourceRangeForRealtime(
|
|
|
|
422
|
+ hrWindow: hrWindow,
|
|
|
|
423
|
+ restingHistory: restingHistory,
|
|
|
|
424
|
+ latestHrv: latestHrv,
|
|
|
|
425
|
+ );
|
|
|
|
426
|
+ results.add(
|
|
|
|
427
|
+ HealthRawRealtimeStressPoint(
|
|
|
|
428
|
+ userId: userId,
|
|
|
|
429
|
+ rawEndTime: currentRawPoint.endTime,
|
|
|
|
430
|
+ rawHr: currentRawPoint.value ?? 0,
|
|
|
|
431
|
+ result: stress,
|
|
|
|
432
|
+ sourceStartTime: sourceRange.start,
|
|
|
|
433
|
+ sourceEndTime: sourceRange.end,
|
|
|
|
434
|
+ flags: currentRawPoint.flags,
|
|
|
|
435
|
+ ),
|
|
|
|
436
|
+ );
|
|
|
|
437
|
+ if (!isActivity) {
|
|
|
|
438
|
+ if (isSleep) {
|
|
|
|
439
|
+ previousSleepStress = stress;
|
|
|
|
440
|
+ previousSleepStressTime = pointTime;
|
|
|
|
441
|
+ } else {
|
|
|
|
442
|
+ previousAwakeStress = stress;
|
|
|
|
443
|
+ previousAwakeStressTime = pointTime;
|
|
|
|
444
|
+ }
|
|
|
|
445
|
+ }
|
|
|
|
446
|
+ }
|
|
|
|
447
|
+
|
|
|
|
448
|
+ pointTime += 6 * 60;
|
|
|
|
449
|
+ }
|
|
|
|
450
|
+ }
|
|
|
|
451
|
+
|
|
|
|
452
|
+ return results;
|
|
|
|
453
|
+ }
|
|
|
|
454
|
+
|
|
|
|
455
|
+ static ({int start, int end}) _sourceRangeForRealtime({
|
|
|
|
456
|
+ required List<HealthRawMarkedDataPoint> hrWindow,
|
|
|
|
457
|
+ required List<HealthRawMarkedDataPoint> restingHistory,
|
|
|
|
458
|
+ required HealthRawHrvStressPoint? latestHrv,
|
|
|
|
459
|
+ }) {
|
|
|
|
460
|
+ final pointRanges = _timeRange([...hrWindow, ...restingHistory]);
|
|
|
|
461
|
+ if (latestHrv == null) return pointRanges;
|
|
|
|
462
|
+ return (
|
|
|
|
463
|
+ start: math.min(pointRanges.start, latestHrv.sourceStartTime),
|
|
|
|
464
|
+ end: math.max(pointRanges.end, latestHrv.sourceEndTime),
|
|
|
|
465
|
+ );
|
|
|
|
466
|
+ }
|
|
|
|
467
|
+
|
|
|
|
468
|
+ static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>(
|
|
|
|
469
|
+ List<T> points,
|
|
|
|
470
|
+ int time, {
|
|
|
|
471
|
+ required int limit,
|
|
|
|
472
|
+ }) {
|
|
|
|
473
|
+ final history = points.where((e) => e.endTime < time).toList();
|
|
|
|
474
|
+ if (history.length <= limit) return history;
|
|
|
|
475
|
+ return history.sublist(history.length - limit);
|
|
|
|
476
|
+ }
|
|
|
|
477
|
+
|
|
|
|
478
|
+ static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>(
|
|
|
|
479
|
+ List<T> points,
|
|
|
|
480
|
+ int start,
|
|
|
|
481
|
+ int end,
|
|
|
|
482
|
+ ) {
|
|
|
|
483
|
+ return points.where((e) => e.endTime >= start && e.endTime <= end).toList();
|
|
|
|
484
|
+ }
|
|
|
|
485
|
+
|
|
|
|
486
|
+ static ({int start, int end}) _timeRange(
|
|
|
|
487
|
+ List<HealthRawMarkedDataPoint> points) {
|
|
|
|
488
|
+ if (points.isEmpty) return (start: 0, end: 0);
|
|
|
|
489
|
+ var start = points.first.startTime;
|
|
|
|
490
|
+ var end = points.first.endTime;
|
|
|
|
491
|
+ for (final point in points.skip(1)) {
|
|
|
|
492
|
+ start = math.min(start, point.startTime);
|
|
|
|
493
|
+ end = math.max(end, point.endTime);
|
|
|
|
494
|
+ }
|
|
|
|
495
|
+ return (start: start, end: end);
|
|
|
|
496
|
+ }
|
|
|
|
497
|
+
|
|
|
|
498
|
+ static bool _isValidRmssd(double? value) {
|
|
|
|
499
|
+ return value != null && value >= rawRmssdMin && value <= rawRmssdMax;
|
|
|
|
500
|
+ }
|
|
|
|
501
|
+
|
|
|
|
502
|
+ static bool _isValidHr(double value) => value >= 30 && value <= 220;
|
|
|
|
503
|
+
|
|
|
|
504
|
+ static double _baseline(
|
|
|
|
505
|
+ List<double> values, {
|
|
|
|
506
|
+ required int requiredCount,
|
|
|
|
507
|
+ required double fallback,
|
|
|
|
508
|
+ }) {
|
|
|
|
509
|
+ if (values.length < requiredCount) return fallback;
|
|
|
|
510
|
+ final suffix = values.sublist(values.length - requiredCount);
|
|
|
|
511
|
+ return _median(suffix) ?? fallback;
|
|
|
|
512
|
+ }
|
|
|
|
513
|
+
|
|
|
|
514
|
+ static double _sleepBaseline(
|
|
|
|
515
|
+ List<double> sleepValues, {
|
|
|
|
516
|
+ required int awakeHistoryCount,
|
|
|
|
517
|
+ required double baselineAwakeHr,
|
|
|
|
518
|
+ }) {
|
|
|
|
519
|
+ if (sleepValues.length >= baselineSampleCount) {
|
|
|
|
520
|
+ return _median(sleepValues.sublist(
|
|
|
|
521
|
+ sleepValues.length - baselineSampleCount,
|
|
|
|
522
|
+ )) ??
|
|
|
|
523
|
+ 65;
|
|
|
|
524
|
+ }
|
|
|
|
525
|
+ if (sleepValues.length >= 10) {
|
|
|
|
526
|
+ return _median(sleepValues) ?? 65;
|
|
|
|
527
|
+ }
|
|
|
|
528
|
+ return awakeHistoryCount >= baselineSampleCount ? baselineAwakeHr : 65;
|
|
|
|
529
|
+ }
|
|
|
|
530
|
+
|
|
|
|
531
|
+ static double? _median(List<double> values) {
|
|
|
|
532
|
+ if (values.isEmpty) return null;
|
|
|
|
533
|
+ final sorted = [...values]..sort();
|
|
|
|
534
|
+ final middle = sorted.length ~/ 2;
|
|
|
|
535
|
+ if (sorted.length.isEven) {
|
|
|
|
536
|
+ return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
|
|
537
|
+ }
|
|
|
|
538
|
+ return sorted[middle];
|
|
|
|
539
|
+ }
|
|
|
|
540
|
+
|
|
|
|
541
|
+ static double _hrStress(double hrChange) {
|
|
|
|
542
|
+ if (hrChange <= -20) return 5;
|
|
|
|
543
|
+ if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9;
|
|
|
|
544
|
+ if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4;
|
|
|
|
545
|
+ if (hrChange <= 10) return 28 + hrChange * 1.6;
|
|
|
|
546
|
+ if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6;
|
|
|
|
547
|
+ if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5;
|
|
|
|
548
|
+ if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3;
|
|
|
|
549
|
+ if (hrChange < 55) return 88 + (hrChange - 40) * 0.8;
|
|
|
|
550
|
+ return 100;
|
|
|
|
551
|
+ }
|
|
|
|
552
|
+
|
|
|
|
553
|
+ static double _smoothedStress({
|
|
|
|
554
|
+ required double hrStress,
|
|
|
|
555
|
+ required int pointTime,
|
|
|
|
556
|
+ required double? previousStress,
|
|
|
|
557
|
+ required int? previousStressTime,
|
|
|
|
558
|
+ }) {
|
|
|
|
559
|
+ if (previousStress == null ||
|
|
|
|
560
|
+ previousStressTime == null ||
|
|
|
|
561
|
+ pointTime - previousStressTime > 12 * 60) {
|
|
|
|
562
|
+ return hrStress;
|
|
|
|
563
|
+ }
|
|
|
|
564
|
+
|
|
|
|
565
|
+ final stressDiff = (hrStress - previousStress).abs();
|
|
|
|
566
|
+ if (stressDiff < 30) {
|
|
|
|
567
|
+ return hrStress * 0.60 + previousStress * 0.40;
|
|
|
|
568
|
+ }
|
|
|
|
569
|
+ return hrStress * 0.80 + previousStress * 0.20;
|
|
|
|
570
|
+ }
|
|
|
|
571
|
+
|
|
|
|
572
|
+ static ({double value, double? updatedAnchorStress}) _adjustedStress({
|
|
|
|
573
|
+ required double smoothStress,
|
|
|
|
574
|
+ required int pointTime,
|
|
|
|
575
|
+ required HealthRawHrvStressPoint? latestHrv,
|
|
|
|
576
|
+ required double? hrvAnchorStress,
|
|
|
|
577
|
+ }) {
|
|
|
|
578
|
+ if (latestHrv == null) {
|
|
|
|
579
|
+ return (value: smoothStress, updatedAnchorStress: null);
|
|
|
|
580
|
+ }
|
|
|
|
581
|
+
|
|
|
|
582
|
+ final hrvAge = (pointTime - latestHrv.rawEndTime) / 60;
|
|
|
|
583
|
+ if (hrvAge >= 0 && hrvAge <= 6) {
|
|
|
|
584
|
+ final adjusted = switch (latestHrv.state) {
|
|
|
|
585
|
+ HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9),
|
|
|
|
586
|
+ HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9),
|
|
|
|
587
|
+ HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9),
|
|
|
|
588
|
+ HealthRawStressState.overload => _clamp(smoothStress, 81, 100),
|
|
|
|
589
|
+ };
|
|
|
|
590
|
+ return (value: adjusted, updatedAnchorStress: adjusted);
|
|
|
|
591
|
+ }
|
|
|
|
592
|
+
|
|
|
|
593
|
+ if (hrvAge > 6 && hrvAge <= 30) {
|
|
|
|
594
|
+ return switch (latestHrv.state) {
|
|
|
|
595
|
+ HealthRawStressState.excellent => (
|
|
|
|
596
|
+ value: math.min(smoothStress, 60.9).toDouble(),
|
|
|
|
597
|
+ updatedAnchorStress: null,
|
|
|
|
598
|
+ ),
|
|
|
|
599
|
+ HealthRawStressState.normal => (
|
|
|
|
600
|
+ value: math.min(smoothStress, 80.9).toDouble(),
|
|
|
|
601
|
+ updatedAnchorStress: null,
|
|
|
|
602
|
+ ),
|
|
|
|
603
|
+ HealthRawStressState.attention => (
|
|
|
|
604
|
+ value: math.max(smoothStress, 21).toDouble(),
|
|
|
|
605
|
+ updatedAnchorStress: null,
|
|
|
|
606
|
+ ),
|
|
|
|
607
|
+ HealthRawStressState.overload => (
|
|
|
|
608
|
+ value: math.max(smoothStress, 61).toDouble(),
|
|
|
|
609
|
+ updatedAnchorStress: null,
|
|
|
|
610
|
+ ),
|
|
|
|
611
|
+ };
|
|
|
|
612
|
+ }
|
|
|
|
613
|
+
|
|
|
|
614
|
+ if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) {
|
|
|
|
615
|
+ final hrvWeight = (60 - hrvAge) / 30 * 0.20;
|
|
|
|
616
|
+ return (
|
|
|
|
617
|
+ value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight,
|
|
|
|
618
|
+ updatedAnchorStress: null,
|
|
|
|
619
|
+ );
|
|
|
|
620
|
+ }
|
|
|
|
621
|
+
|
|
|
|
622
|
+ return (value: smoothStress, updatedAnchorStress: null);
|
|
|
|
623
|
+ }
|
|
|
|
624
|
+
|
|
|
|
625
|
+ static HealthRawStressState _hrvState(double value) {
|
|
|
|
626
|
+ if (value >= 30) return HealthRawStressState.excellent;
|
|
|
|
627
|
+ if (value >= 21) return HealthRawStressState.normal;
|
|
|
|
628
|
+ if (value >= 17) return HealthRawStressState.attention;
|
|
|
|
629
|
+ return HealthRawStressState.overload;
|
|
|
|
630
|
+ }
|
|
|
|
631
|
+
|
|
|
|
632
|
+ static double _clamp(double value, double lower, double upper) {
|
|
|
|
633
|
+ return math.min(math.max(value, lower), upper).toDouble();
|
|
|
|
634
|
+ }
|
|
|
|
635
|
+
|
|
|
|
636
|
+ static double _integerDouble(num value) => value.toInt().toDouble();
|
|
|
|
637
|
+
|
|
|
|
638
|
+ static List<int> _daysInRange(int startTime, int endTime) {
|
|
|
|
639
|
+ final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
|
|
|
|
640
|
+ final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
|
|
|
|
641
|
+ var day = DateTime(start.year, start.month, start.day);
|
|
|
|
642
|
+ final finalDay = DateTime(end.year, end.month, end.day);
|
|
|
|
643
|
+ final days = <int>[];
|
|
|
|
644
|
+ while (!day.isAfter(finalDay)) {
|
|
|
|
645
|
+ days.add(day.millisecondsSinceEpoch ~/ 1000);
|
|
|
|
646
|
+ day = day.add(const Duration(days: 1));
|
|
|
|
647
|
+ }
|
|
|
|
648
|
+ return days;
|
|
|
|
649
|
+ }
|
|
|
|
650
|
+
|
|
|
|
651
|
+ static HealthRawHrvStressPoint? _lastHrvStressWhere(
|
|
|
|
652
|
+ Iterable<HealthRawHrvStressPoint> points,
|
|
|
|
653
|
+ bool Function(HealthRawHrvStressPoint point) test,
|
|
|
|
654
|
+ ) {
|
|
|
|
655
|
+ HealthRawHrvStressPoint? result;
|
|
|
|
656
|
+ for (final point in points) {
|
|
|
|
657
|
+ if (test(point)) result = point;
|
|
|
|
658
|
+ }
|
|
|
|
659
|
+ return result;
|
|
|
|
660
|
+ }
|
|
|
|
661
|
+
|
|
|
|
662
|
+ static int _dateKeyFromUnixSeconds(int seconds) {
|
|
|
|
663
|
+ final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
|
|
|
664
|
+ return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
|
|
|
|
665
|
+ }
|
|
|
|
666
|
+
|
|
|
|
667
|
+ static (int, int) _dayRangeFromDateKey(int date) {
|
|
|
|
668
|
+ final year = date ~/ 10000;
|
|
|
|
669
|
+ final month = (date ~/ 100) % 100;
|
|
|
|
670
|
+ final day = date % 100;
|
|
|
|
671
|
+ final start = DateTime(year, month, day).millisecondsSinceEpoch ~/ 1000;
|
|
|
|
672
|
+ return (start, start + Duration.secondsPerDay - 1);
|
|
|
|
673
|
+ }
|
|
|
|
674
|
+} |