Commit dcd3e9d15c69e1554cbe4d8e3317a70d1423bd09

Authored by 权海
1 parent 0a340fd3

feat(ui):ohos新算法

import 'dart:math' as math;
import '../../../../pigeon/health_kit_raw_data_api.g.dart';
import '../health_raw_models.dart';
class HuaweiHealthRawStressCalculator {
const HuaweiHealthRawStressCalculator({required this.userId});
static const int rawRmssdMin = 5;
static const int rawRmssdMax = 200;
static const int baselineSampleCount = 50;
static const double defaultBaselineAwakeRmssd = 50;
static const double trendReference = 50;
static const double huaweiTrendScaleFactorAwake = 1;
static const double huaweiTrendScaleFactorSleep = 1;
final int userId;
HealthRawStressCalculationResult calculate({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
required int startTime,
required int endTime,
}) {
final marked = _markRawData(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
);
final sortedRmssd = marked.hrv.where((e) {
final value = e.value;
return value != null && _isValidRmssd(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedHr = marked.heartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedRestingHr = marked.restingHeartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final hrvStressPoints = _calculateHrvStressPoints(
rmssdPoints: sortedRmssd,
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
);
final realtimeStressPoints = _calculateRealtimeStressPoints(
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
hrvStressPoints: hrvStressPoints,
startTime: startTime,
endTime: endTime,
);
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: hrvStressPoints,
realtimeStressPoints: realtimeStressPoints,
dailyStressPoints: calculateDailyStressPoints(
userId: userId,
realtimePoints: realtimeStressPoints,
startTime: startTime,
endTime: endTime,
dataTime: endTime,
),
);
}
static List<HealthRawDailyStressPoint> calculateDailyStressPoints({
required int userId,
required List<HealthRawRealtimeStressPoint> realtimePoints,
required int startTime,
required int endTime,
required int dataTime,
Set<int> skipExistingDates = const <int>{},
}) {
if (realtimePoints.isEmpty) return const <HealthRawDailyStressPoint>[];
final dates = <int>{
for (final point in realtimePoints)
if (point.rawEndTime >= startTime && point.rawEndTime <= endTime)
_dateKeyFromUnixSeconds(point.rawEndTime),
}.toList()
..sort();
final dailyStressPoints = <HealthRawDailyStressPoint>[];
for (final date in dates) {
if (skipExistingDates.contains(date)) continue;
final (dayStart, dayEnd) = _dayRangeFromDateKey(date);
final dayRealtimePoints = realtimePoints
.where((point) =>
point.rawEndTime >= dayStart && point.rawEndTime <= dayEnd)
.toList();
final point = HealthRawDailyStressCalculator.calculate(
userId: userId,
date: date,
realtimePoints: dayRealtimePoints,
dataTime: dataTime,
);
if (point != null) dailyStressPoints.add(point);
}
return dailyStressPoints;
}
HealthRawMarkedDataSet _markRawData({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final hrv = hrvPoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final heartRate = heartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final restingHeartRate = restingHeartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
return HealthRawMarkedDataSet(
hrv: hrv,
heartRate: heartRate,
restingHeartRate: restingHeartRate,
);
}
HealthRawPointFlags _flagsForPoint(
HealthKitRawDataPoint point, {
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final isWorkout = workoutIntervals.any(
(workout) =>
point.endTime > workout.startTime && point.endTime <= workout.endTime,
);
final isWorkoutRecovery = !isWorkout &&
workoutIntervals.any(
(workout) =>
point.endTime > workout.endTime &&
point.endTime <= workout.endTime + 30 * 60,
);
return HealthRawPointFlags(
isWorkout: isWorkout,
isWorkoutRecovery: isWorkoutRecovery,
isSleepLikely: sleepIntervals.any(
(sleep) =>
point.endTime > sleep.startTime && point.endTime <= sleep.endTime,
),
isSuspectedActivity: point.isMotionLike == true,
);
}
List<HealthRawHrvStressPoint> _calculateHrvStressPoints({
required List<HealthRawMarkedDataPoint> rmssdPoints,
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
}) {
final validRmssdHistory = <HealthRawMarkedDataPoint>[];
final results = <HealthRawHrvStressPoint>[];
for (final point in rmssdPoints) {
final pointValue = point.value!;
final isActivity = point.flags.isWorkout || point.flags.isWorkoutRecovery;
if (isActivity) {
continue;
}
final isSleep = point.flags.isSleepLikely;
final awakeRmssdHistory = _latestBefore(
validRmssdHistory
.where((e) =>
e.flags.isAwakeBaselineEligible && _isValidRmssd(e.value))
.toList(),
point.endTime,
limit: baselineSampleCount,
);
final sleepRmssdHistory = _latestBefore(
validRmssdHistory
.where((e) =>
e.flags.isSleepHrvBaselineEligible && _isValidRmssd(e.value))
.toList(),
point.endTime,
limit: baselineSampleCount,
);
final baselineAwakeRmssd = _baseline(
awakeRmssdHistory.map((e) => e.value!).toList(),
requiredCount: baselineSampleCount,
fallback: defaultBaselineAwakeRmssd,
);
final baselineSleepRmssd = _baseline(
sleepRmssdHistory.map((e) => e.value!).toList(),
requiredCount: baselineSampleCount,
fallback: baselineAwakeRmssd,
);
final baselineRmssd = isSleep ? baselineSleepRmssd : baselineAwakeRmssd;
final referenceHrv = isSleep
? trendReference * huaweiTrendScaleFactorSleep
: trendReference * huaweiTrendScaleFactorAwake;
final rmssdRatio = pointValue / baselineRmssd;
final trendDelta = referenceHrv * (rmssdRatio - 1);
final trendBaseValue = pointValue >= baselineRmssd
? referenceHrv + trendDelta * 0.8
: referenceHrv + trendDelta;
final restingHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
point.endTime,
limit: baselineSampleCount,
);
final baselineRestingHr = _baseline(
restingHistory.map((e) => e.value!).toList(),
requiredCount: baselineSampleCount,
fallback: 65,
);
final hrWindow = isSleep
? const <HealthRawMarkedDataPoint>[]
: _pointsInRange(
heartRatePoints,
point.endTime - 5 * 60,
point.endTime,
).where((e) => e.flags.isCurrentAwakeHrEligible).toList();
final currentHr = _median(hrWindow.map((e) => e.value!).toList());
final double trendHrv;
if (currentHr != null) {
final hrRatio = currentHr / baselineRestingHr;
final double hrFactor;
if (hrRatio >= 1) {
hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10);
} else if (pointValue >= baselineRmssd) {
hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10);
} else {
hrFactor = 1;
}
final mismatch = math.max(hrRatio - rmssdRatio, 0).toDouble();
final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00);
trendHrv = _clamp(
trendBaseValue * hrFactor * matchFactor,
5,
referenceHrv * 1.8,
);
} else {
trendHrv = _clamp(trendBaseValue, 5, referenceHrv * 1.8);
}
final sourceRange = _timeRange([
...awakeRmssdHistory,
...sleepRmssdHistory,
point,
...hrWindow,
...restingHistory,
]);
final normalizedTrendHrv = _integerDouble(trendHrv);
results.add(
HealthRawHrvStressPoint(
userId: userId,
rawEndTime: point.endTime,
rawHrv: pointValue,
result: normalizedTrendHrv,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
state: _hrvState(normalizedTrendHrv),
baselineHrv: baselineRmssd,
baselineAwakeHrv: baselineAwakeRmssd,
baselineSleepHrv: isSleep ? baselineSleepRmssd : null,
baselineRestingHr: baselineRestingHr,
flags: point.flags,
),
);
validRmssdHistory.add(point);
}
return results;
}
List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
required List<HealthRawHrvStressPoint> hrvStressPoints,
required int startTime,
required int endTime,
}) {
final results = <HealthRawRealtimeStressPoint>[];
final days = _daysInRange(startTime, endTime);
double? previousAwakeStress;
int? previousAwakeStressTime;
double? previousSleepStress;
int? previousSleepStressTime;
double? awakeHrvAnchorStress;
double? sleepHrvAnchorStress;
for (final dayStart in days) {
final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime);
final dayHr = heartRatePoints
.where((e) => e.endTime >= dayStart && e.endTime <= dayEnd)
.toList();
if (dayHr.isEmpty) continue;
var pointTime = dayHr.first.endTime;
while (pointTime <= dayEnd) {
final hrWindow = _pointsInRange(
dayHr,
pointTime - 6 * 60 + 1,
pointTime,
);
final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last;
final context = currentRawPoint?.flags.context;
final contextWindow = context == null
? <HealthRawMarkedDataPoint>[]
: hrWindow.where((e) => e.flags.matchesContext(context)).toList();
final currentHr = _median(contextWindow.map((e) => e.value!).toList());
if (currentRawPoint != null && currentHr != null) {
final isSleep = context == HealthRawPointContext.sleep;
final isActivity = context == HealthRawPointContext.workout ||
context == HealthRawPointContext.workoutRecovery ||
context == HealthRawPointContext.suspectedActivity;
final awakeHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
pointTime,
limit: baselineSampleCount,
);
final baselineAwakeHr = _baseline(
awakeHistory.map((e) => e.value!).toList(),
requiredCount: baselineSampleCount,
fallback: 65,
);
final sleepHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isSleepHrBaselineEligible)
.toList(),
pointTime,
limit: baselineSampleCount,
);
final baselineSleepHr = _sleepBaseline(
sleepHistory.map((e) => e.value!).toList(),
awakeHistoryCount: awakeHistory.length,
baselineAwakeHr: baselineAwakeHr,
);
final restingHistory = _latestBefore(
restingHeartRatePoints,
pointTime,
limit: baselineSampleCount,
);
final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr;
final hrChange =
(currentHr - baselineRestingHr) / baselineRestingHr * 100;
final hrStress = _clamp(_hrStress(hrChange), 1, 100);
final smoothStress = isActivity
? hrStress
: _smoothedStress(
hrStress: hrStress,
pointTime: pointTime,
previousStress:
isSleep ? previousSleepStress : previousAwakeStress,
previousStressTime: isSleep
? previousSleepStressTime
: previousAwakeStressTime,
);
final latestHrv = isActivity
? null
: _lastHrvStressWhere(
hrvStressPoints,
(e) =>
e.rawEndTime <= pointTime && e.flags.context == context,
);
final adjustment = _adjustedStress(
smoothStress: smoothStress,
pointTime: pointTime,
latestHrv: latestHrv,
hrvAnchorStress:
isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress,
);
if (adjustment.updatedAnchorStress != null) {
if (isSleep) {
sleepHrvAnchorStress = adjustment.updatedAnchorStress;
} else {
awakeHrvAnchorStress = adjustment.updatedAnchorStress;
}
}
final stress = _integerDouble(_clamp(adjustment.value, 1, 100));
final sourceRange = _sourceRangeForRealtime(
hrWindow: hrWindow,
restingHistory: restingHistory,
latestHrv: latestHrv,
);
results.add(
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: currentRawPoint.endTime,
rawHr: currentRawPoint.value ?? 0,
result: stress,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
flags: currentRawPoint.flags,
),
);
if (!isActivity) {
if (isSleep) {
previousSleepStress = stress;
previousSleepStressTime = pointTime;
} else {
previousAwakeStress = stress;
previousAwakeStressTime = pointTime;
}
}
}
pointTime += 6 * 60;
}
}
return results;
}
static ({int start, int end}) _sourceRangeForRealtime({
required List<HealthRawMarkedDataPoint> hrWindow,
required List<HealthRawMarkedDataPoint> restingHistory,
required HealthRawHrvStressPoint? latestHrv,
}) {
final pointRanges = _timeRange([...hrWindow, ...restingHistory]);
if (latestHrv == null) return pointRanges;
return (
start: math.min(pointRanges.start, latestHrv.sourceStartTime),
end: math.max(pointRanges.end, latestHrv.sourceEndTime),
);
}
static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>(
List<T> points,
int time, {
required int limit,
}) {
final history = points.where((e) => e.endTime < time).toList();
if (history.length <= limit) return history;
return history.sublist(history.length - limit);
}
static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>(
List<T> points,
int start,
int end,
) {
return points.where((e) => e.endTime >= start && e.endTime <= end).toList();
}
static ({int start, int end}) _timeRange(
List<HealthRawMarkedDataPoint> points) {
if (points.isEmpty) return (start: 0, end: 0);
var start = points.first.startTime;
var end = points.first.endTime;
for (final point in points.skip(1)) {
start = math.min(start, point.startTime);
end = math.max(end, point.endTime);
}
return (start: start, end: end);
}
static bool _isValidRmssd(double? value) {
return value != null && value >= rawRmssdMin && value <= rawRmssdMax;
}
static bool _isValidHr(double value) => value >= 30 && value <= 220;
static double _baseline(
List<double> values, {
required int requiredCount,
required double fallback,
}) {
if (values.length < requiredCount) return fallback;
final suffix = values.sublist(values.length - requiredCount);
return _median(suffix) ?? fallback;
}
static double _sleepBaseline(
List<double> sleepValues, {
required int awakeHistoryCount,
required double baselineAwakeHr,
}) {
if (sleepValues.length >= baselineSampleCount) {
return _median(sleepValues.sublist(
sleepValues.length - baselineSampleCount,
)) ??
65;
}
if (sleepValues.length >= 10) {
return _median(sleepValues) ?? 65;
}
return awakeHistoryCount >= baselineSampleCount ? baselineAwakeHr : 65;
}
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 double _hrStress(double hrChange) {
if (hrChange <= -20) return 5;
if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9;
if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4;
if (hrChange <= 10) return 28 + hrChange * 1.6;
if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6;
if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5;
if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3;
if (hrChange < 55) return 88 + (hrChange - 40) * 0.8;
return 100;
}
static double _smoothedStress({
required double hrStress,
required int pointTime,
required double? previousStress,
required int? previousStressTime,
}) {
if (previousStress == null ||
previousStressTime == null ||
pointTime - previousStressTime > 12 * 60) {
return hrStress;
}
final stressDiff = (hrStress - previousStress).abs();
if (stressDiff < 30) {
return hrStress * 0.60 + previousStress * 0.40;
}
return hrStress * 0.80 + previousStress * 0.20;
}
static ({double value, double? updatedAnchorStress}) _adjustedStress({
required double smoothStress,
required int pointTime,
required HealthRawHrvStressPoint? latestHrv,
required double? hrvAnchorStress,
}) {
if (latestHrv == null) {
return (value: smoothStress, updatedAnchorStress: null);
}
final hrvAge = (pointTime - latestHrv.rawEndTime) / 60;
if (hrvAge >= 0 && hrvAge <= 6) {
final adjusted = switch (latestHrv.state) {
HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9),
HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9),
HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9),
HealthRawStressState.overload => _clamp(smoothStress, 81, 100),
};
return (value: adjusted, updatedAnchorStress: adjusted);
}
if (hrvAge > 6 && hrvAge <= 30) {
return switch (latestHrv.state) {
HealthRawStressState.excellent => (
value: math.min(smoothStress, 60.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.normal => (
value: math.min(smoothStress, 80.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.attention => (
value: math.max(smoothStress, 21).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.overload => (
value: math.max(smoothStress, 61).toDouble(),
updatedAnchorStress: null,
),
};
}
if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) {
final hrvWeight = (60 - hrvAge) / 30 * 0.20;
return (
value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight,
updatedAnchorStress: null,
);
}
return (value: smoothStress, updatedAnchorStress: null);
}
static HealthRawStressState _hrvState(double value) {
if (value >= 30) return HealthRawStressState.excellent;
if (value >= 21) return HealthRawStressState.normal;
if (value >= 17) return HealthRawStressState.attention;
return HealthRawStressState.overload;
}
static double _clamp(double value, double lower, double upper) {
return math.min(math.max(value, lower), upper).toDouble();
}
static double _integerDouble(num value) => value.toInt().toDouble();
static List<int> _daysInRange(int startTime, int endTime) {
final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
var day = DateTime(start.year, start.month, start.day);
final finalDay = DateTime(end.year, end.month, end.day);
final days = <int>[];
while (!day.isAfter(finalDay)) {
days.add(day.millisecondsSinceEpoch ~/ 1000);
day = day.add(const Duration(days: 1));
}
return days;
}
static HealthRawHrvStressPoint? _lastHrvStressWhere(
Iterable<HealthRawHrvStressPoint> points,
bool Function(HealthRawHrvStressPoint point) test,
) {
HealthRawHrvStressPoint? result;
for (final point in points) {
if (test(point)) result = point;
}
return result;
}
static int _dateKeyFromUnixSeconds(int seconds) {
final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
}
static (int, int) _dayRangeFromDateKey(int date) {
final year = date ~/ 10000;
final month = (date ~/ 100) % 100;
final day = date % 100;
final start = DateTime(year, month, day).millisecondsSinceEpoch ~/ 1000;
return (start, start + Duration.secondsPerDay - 1);
}
}
... ...
... ... @@ -17,9 +17,9 @@ import '../../../network/api/harmony_api.dart';
import '../../../result/app_result.dart';
import '../health_raw_data_source.dart';
import '../health_raw_models.dart';
import '../health_raw_stress_calculator.dart';
import '../health_sleep_calculator.dart';
import '../platform_ios/apple_health_raw_data_core_service.dart';
import 'huawei_health_raw_stress_calculator.dart';
import 'ohos_health_raw_data_sync_service.dart';
import 'ohos_health_raw_result_upload_service.dart';
... ... @@ -316,7 +316,7 @@ class OHOSHealthRawDataCoreService {
);
final result = await Isolate.run(
() => HealthRawStressCalculator(userId: userId).calculate(
() => HuaweiHealthRawStressCalculator(userId: userId).calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
... ... @@ -625,12 +625,13 @@ class OHOSHealthRawDataCoreService {
startTime: startTime,
endTime: endTime,
);
final point = HealthRawDailyStressCalculator.calculate(
final point = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
userId: userId,
date: date,
realtimePoints: dayRealtimePoints,
startTime: startTime,
endTime: endTime,
dataTime: date == todayDate ? nowSeconds : startTime,
);
).firstOrNull;
if (point == null) {
emptyDates.add(date);
continue;
... ...
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/huawei_health_raw_stress_calculator.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const userId = 42;
final calculator = HuaweiHealthRawStressCalculator(userId: userId);
test('skips out-of-range RMSSD hrv trend points', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [
_point(1, base + 60, 4),
_point(1, base + 120, 201),
],
heartRatePoints: const [],
restingHeartRatePoints: const [],
startTime: base,
endTime: base + 180,
);
expect(result.hrvStressPoints, isEmpty);
});
test('calculates awake RMSSD trend against default baseline without HR', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [
_point(1, base + 60, 60),
_point(1, base + 120, 40),
],
heartRatePoints: const [],
restingHeartRatePoints: const [],
startTime: base,
endTime: base + 180,
);
expect(result.hrvStressPoints.map((e) => e.result), [58, 40]);
expect(result.hrvStressPoints.first.rawHrv, 60);
expect(result.hrvStressPoints.first.baselineHrv, 50);
expect(result.hrvStressPoints.first.state, HealthRawStressState.excellent);
});
test('applies awake HR correction to Huawei RMSSD trend', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [_point(1, base + 300, 60)],
heartRatePoints: [_point(2, base + 300, 80)],
restingHeartRatePoints: const [],
startTime: base,
endTime: base + 360,
);
expect(result.hrvStressPoints, hasLength(1));
expect(result.hrvStressPoints.single.result, 50);
expect(result.hrvStressPoints.single.baselineRestingHr, 65);
});
test('calculates sleep RMSSD trend without HR correction', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [_point(1, base + 300, 40)],
heartRatePoints: [_point(2, base + 300, 120)],
restingHeartRatePoints: const [],
sleepIntervals: [_interval(base, base + 600)],
startTime: base,
endTime: base + 600,
);
expect(result.hrvStressPoints, hasLength(1));
expect(result.hrvStressPoints.single.result, 40);
expect(result.hrvStressPoints.single.baselineSleepHrv, 50);
expect(result.hrvStressPoints.single.flags.isSleepLikely, isTrue);
});
test('skips hrv trend points during workout and recovery', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [
_point(1, base + 120, 60),
_point(1, base + 180, 60),
],
heartRatePoints: const [],
restingHeartRatePoints: const [],
workoutIntervals: [_workout(base + 60, base + 150)],
startTime: base,
endTime: base + 240,
);
expect(result.hrvStressPoints, isEmpty);
});
test('uses same-scene Huawei HRV trend to constrain realtime stress', () {
const base = 1800000000;
final result = calculator.calculate(
hrvPoints: [_point(1, base + 60, 20)],
heartRatePoints: [_point(2, base + 60, 65)],
restingHeartRatePoints: const [],
startTime: base,
endTime: base + 60,
);
expect(result.hrvStressPoints.single.state, HealthRawStressState.attention);
expect(result.realtimeStressPoints, hasLength(1));
expect(result.realtimeStressPoints.single.result, 61);
});
test('calculates daily stress from Huawei realtime stress points', () {
const base = 1800000000;
final daily = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
userId: userId,
realtimePoints: [
_realtime(base + 60, 20),
_realtime(base + 120, 40),
_realtime(base + 180, 80),
],
startTime: base,
endTime: base + 240,
dataTime: base + 240,
);
expect(daily, hasLength(1));
expect(daily.single.stressValue, 46);
expect(daily.single.stressScore, 56);
expect(daily.single.state, HealthRawStressState.normal);
});
}
HealthKitRawDataPoint _point(int dataType, int time, double value) {
return HealthKitRawDataPoint(
dataType: dataType,
startTime: time,
endTime: time,
value: value,
);
}
HealthKitRawDataPoint _interval(int startTime, int endTime) {
return HealthKitRawDataPoint(
dataType: 3,
startTime: startTime,
endTime: endTime,
);
}
HealthKitRawWorkoutDataPoint _workout(int startTime, int endTime) {
return HealthKitRawWorkoutDataPoint(
workoutType: 90,
startTime: startTime,
endTime: endTime,
);
}
HealthRawRealtimeStressPoint _realtime(int rawEndTime, double result) {
return HealthRawRealtimeStressPoint(
userId: 42,
rawEndTime: rawEndTime,
rawHr: 70,
result: result,
sourceStartTime: rawEndTime,
sourceEndTime: rawEndTime,
);
}
... ...
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_source.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('OHOS core calculates and stores Huawei hrv realtime and daily results',
() async {
final day = DateTime.now().subtract(const Duration(days: 7));
final base =
DateTime(day.year, day.month, day.day, 10).millisecondsSinceEpoch ~/
1000;
final store = _FakeHealthRawStressLocalStore();
final service = OHOSHealthRawDataCoreService(
rawDataSource: _FakeHealthRawDataSource(
pointsByDataType: {
1: [_point(1, base + 300, 60)],
2: [_point(2, base + 300, 80)],
},
),
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => false,
);
final result = await service.syncAndStore(
startTime: base,
endTime: base + 600,
readChunkDays: 1,
);
expect(result.hrvStressPoints, hasLength(1));
expect(result.hrvStressPoints.single.result, 50);
expect(result.realtimeStressPoints, hasLength(1));
expect(result.dailyStressPoints, hasLength(1));
expect(store.hrvStressPoints.single.result, 50);
expect(store.realtimeStressPoints, hasLength(1));
expect(store.dailyStressPoints, hasLength(1));
});
}
class _FakeHealthRawDataSource implements HealthRawDataSource {
_FakeHealthRawDataSource({required this.pointsByDataType});
final Map<int, List<HealthKitRawDataPoint>> pointsByDataType;
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
return null;
}
@override
Future<List<HealthKitRawActivityDataPoint>> getRawActivityData(
int startTime,
int endTime,
) async {
return const <HealthKitRawActivityDataPoint>[];
}
@override
Future<List<HealthKitRawDataPoint>> getRawData(
int dataType,
int startTime,
int endTime,
) async {
return (pointsByDataType[dataType] ?? const <HealthKitRawDataPoint>[])
.where(
(point) => point.endTime >= startTime && point.endTime <= endTime)
.toList(growable: false);
}
@override
Future<List<HealthKitRawSleepDataPoint>> getRawSleepData(
int startTime,
int endTime,
) async {
return const <HealthKitRawSleepDataPoint>[];
}
@override
Future<List<HealthKitRawWorkoutDataPoint>> getRawWorkoutData(
int startTime,
int endTime,
) async {
return const <HealthKitRawWorkoutDataPoint>[];
}
@override
Future<bool> hasHealthData() async {
return true;
}
}
class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
final hrvStressPoints = <HealthRawHrvStressPoint>[];
final realtimeStressPoints = <HealthRawRealtimeStressPoint>[];
final dailyStressPoints = <HealthRawDailyStressPoint>[];
@override
Future<void> ensureReadable(int userId) async {}
@override
Future<int?> latestHrvSourceStartTime(int userId) async {
return null;
}
@override
Future<int?> latestRealtimeSourceStartTime(int userId) async {
return null;
}
@override
Future<int?> latestHrvRawEndTime(int userId) async {
return hrvStressPoints.isEmpty ? null : hrvStressPoints.last.rawEndTime;
}
@override
Future<int?> latestRealtimeRawEndTime(int userId) async {
return realtimeStressPoints.isEmpty
? null
: realtimeStressPoints.last.rawEndTime;
}
@override
Future<int?> latestSleepResultTime(int userId) async {
return null;
}
@override
Future<void> upsertResult(HealthRawStressCalculationResult result) async {
hrvStressPoints.addAll(result.hrvStressPoints);
realtimeStressPoints.addAll(result.realtimeStressPoints);
}
@override
Future<Set<int>> existingDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {
return const <int>{};
}
@override
Future<List<HealthRawRealtimeStressPoint>> queryRealtimeStressPoints({
required int userId,
required int startTime,
required int endTime,
}) async {
return realtimeStressPoints
.where((point) =>
point.rawEndTime >= startTime && point.rawEndTime <= endTime)
.toList(growable: false);
}
@override
Future<void> upsertDailyStressPoints({
required int userId,
required Iterable<HealthRawDailyStressPoint> points,
}) async {
dailyStressPoints.addAll(points);
}
@override
Future<void> deleteDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {}
@override
Future<void> upsertSleepResults({
required int userId,
required Iterable<HealthRawSleepResult> results,
}) async {}
}
HealthKitRawDataPoint _point(int dataType, int time, double value) {
return HealthKitRawDataPoint(
dataType: dataType,
startTime: time,
endTime: time,
value: value,
);
}
... ...