Commit dcd3e9d15c69e1554cbe4d8e3317a70d1423bd09

Authored by 权海
1 parent 0a340fd3

feat(ui):ohos新算法

  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 +}
@@ -17,9 +17,9 @@ import '../../../network/api/harmony_api.dart'; @@ -17,9 +17,9 @@ import '../../../network/api/harmony_api.dart';
17 import '../../../result/app_result.dart'; 17 import '../../../result/app_result.dart';
18 import '../health_raw_data_source.dart'; 18 import '../health_raw_data_source.dart';
19 import '../health_raw_models.dart'; 19 import '../health_raw_models.dart';
20 -import '../health_raw_stress_calculator.dart';  
21 import '../health_sleep_calculator.dart'; 20 import '../health_sleep_calculator.dart';
22 import '../platform_ios/apple_health_raw_data_core_service.dart'; 21 import '../platform_ios/apple_health_raw_data_core_service.dart';
  22 +import 'huawei_health_raw_stress_calculator.dart';
23 import 'ohos_health_raw_data_sync_service.dart'; 23 import 'ohos_health_raw_data_sync_service.dart';
24 import 'ohos_health_raw_result_upload_service.dart'; 24 import 'ohos_health_raw_result_upload_service.dart';
25 25
@@ -316,7 +316,7 @@ class OHOSHealthRawDataCoreService { @@ -316,7 +316,7 @@ class OHOSHealthRawDataCoreService {
316 ); 316 );
317 317
318 final result = await Isolate.run( 318 final result = await Isolate.run(
319 - () => HealthRawStressCalculator(userId: userId).calculate( 319 + () => HuaweiHealthRawStressCalculator(userId: userId).calculate(
320 hrvPoints: hrvPoints, 320 hrvPoints: hrvPoints,
321 heartRatePoints: heartRatePoints, 321 heartRatePoints: heartRatePoints,
322 restingHeartRatePoints: restingHeartRatePoints, 322 restingHeartRatePoints: restingHeartRatePoints,
@@ -625,12 +625,13 @@ class OHOSHealthRawDataCoreService { @@ -625,12 +625,13 @@ class OHOSHealthRawDataCoreService {
625 startTime: startTime, 625 startTime: startTime,
626 endTime: endTime, 626 endTime: endTime,
627 ); 627 );
628 - final point = HealthRawDailyStressCalculator.calculate( 628 + final point = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
629 userId: userId, 629 userId: userId,
630 - date: date,  
631 realtimePoints: dayRealtimePoints, 630 realtimePoints: dayRealtimePoints,
  631 + startTime: startTime,
  632 + endTime: endTime,
632 dataTime: date == todayDate ? nowSeconds : startTime, 633 dataTime: date == todayDate ? nowSeconds : startTime,
633 - ); 634 + ).firstOrNull;
634 if (point == null) { 635 if (point == null) {
635 emptyDates.add(date); 636 emptyDates.add(date);
636 continue; 637 continue;
  1 +import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
  2 +import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/huawei_health_raw_stress_calculator.dart';
  3 +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
  4 +import 'package:flutter_test/flutter_test.dart';
  5 +
  6 +void main() {
  7 + const userId = 42;
  8 + final calculator = HuaweiHealthRawStressCalculator(userId: userId);
  9 +
  10 + test('skips out-of-range RMSSD hrv trend points', () {
  11 + const base = 1800000000;
  12 +
  13 + final result = calculator.calculate(
  14 + hrvPoints: [
  15 + _point(1, base + 60, 4),
  16 + _point(1, base + 120, 201),
  17 + ],
  18 + heartRatePoints: const [],
  19 + restingHeartRatePoints: const [],
  20 + startTime: base,
  21 + endTime: base + 180,
  22 + );
  23 +
  24 + expect(result.hrvStressPoints, isEmpty);
  25 + });
  26 +
  27 + test('calculates awake RMSSD trend against default baseline without HR', () {
  28 + const base = 1800000000;
  29 +
  30 + final result = calculator.calculate(
  31 + hrvPoints: [
  32 + _point(1, base + 60, 60),
  33 + _point(1, base + 120, 40),
  34 + ],
  35 + heartRatePoints: const [],
  36 + restingHeartRatePoints: const [],
  37 + startTime: base,
  38 + endTime: base + 180,
  39 + );
  40 +
  41 + expect(result.hrvStressPoints.map((e) => e.result), [58, 40]);
  42 + expect(result.hrvStressPoints.first.rawHrv, 60);
  43 + expect(result.hrvStressPoints.first.baselineHrv, 50);
  44 + expect(result.hrvStressPoints.first.state, HealthRawStressState.excellent);
  45 + });
  46 +
  47 + test('applies awake HR correction to Huawei RMSSD trend', () {
  48 + const base = 1800000000;
  49 +
  50 + final result = calculator.calculate(
  51 + hrvPoints: [_point(1, base + 300, 60)],
  52 + heartRatePoints: [_point(2, base + 300, 80)],
  53 + restingHeartRatePoints: const [],
  54 + startTime: base,
  55 + endTime: base + 360,
  56 + );
  57 +
  58 + expect(result.hrvStressPoints, hasLength(1));
  59 + expect(result.hrvStressPoints.single.result, 50);
  60 + expect(result.hrvStressPoints.single.baselineRestingHr, 65);
  61 + });
  62 +
  63 + test('calculates sleep RMSSD trend without HR correction', () {
  64 + const base = 1800000000;
  65 +
  66 + final result = calculator.calculate(
  67 + hrvPoints: [_point(1, base + 300, 40)],
  68 + heartRatePoints: [_point(2, base + 300, 120)],
  69 + restingHeartRatePoints: const [],
  70 + sleepIntervals: [_interval(base, base + 600)],
  71 + startTime: base,
  72 + endTime: base + 600,
  73 + );
  74 +
  75 + expect(result.hrvStressPoints, hasLength(1));
  76 + expect(result.hrvStressPoints.single.result, 40);
  77 + expect(result.hrvStressPoints.single.baselineSleepHrv, 50);
  78 + expect(result.hrvStressPoints.single.flags.isSleepLikely, isTrue);
  79 + });
  80 +
  81 + test('skips hrv trend points during workout and recovery', () {
  82 + const base = 1800000000;
  83 +
  84 + final result = calculator.calculate(
  85 + hrvPoints: [
  86 + _point(1, base + 120, 60),
  87 + _point(1, base + 180, 60),
  88 + ],
  89 + heartRatePoints: const [],
  90 + restingHeartRatePoints: const [],
  91 + workoutIntervals: [_workout(base + 60, base + 150)],
  92 + startTime: base,
  93 + endTime: base + 240,
  94 + );
  95 +
  96 + expect(result.hrvStressPoints, isEmpty);
  97 + });
  98 +
  99 + test('uses same-scene Huawei HRV trend to constrain realtime stress', () {
  100 + const base = 1800000000;
  101 +
  102 + final result = calculator.calculate(
  103 + hrvPoints: [_point(1, base + 60, 20)],
  104 + heartRatePoints: [_point(2, base + 60, 65)],
  105 + restingHeartRatePoints: const [],
  106 + startTime: base,
  107 + endTime: base + 60,
  108 + );
  109 +
  110 + expect(result.hrvStressPoints.single.state, HealthRawStressState.attention);
  111 + expect(result.realtimeStressPoints, hasLength(1));
  112 + expect(result.realtimeStressPoints.single.result, 61);
  113 + });
  114 +
  115 + test('calculates daily stress from Huawei realtime stress points', () {
  116 + const base = 1800000000;
  117 +
  118 + final daily = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
  119 + userId: userId,
  120 + realtimePoints: [
  121 + _realtime(base + 60, 20),
  122 + _realtime(base + 120, 40),
  123 + _realtime(base + 180, 80),
  124 + ],
  125 + startTime: base,
  126 + endTime: base + 240,
  127 + dataTime: base + 240,
  128 + );
  129 +
  130 + expect(daily, hasLength(1));
  131 + expect(daily.single.stressValue, 46);
  132 + expect(daily.single.stressScore, 56);
  133 + expect(daily.single.state, HealthRawStressState.normal);
  134 + });
  135 +}
  136 +
  137 +HealthKitRawDataPoint _point(int dataType, int time, double value) {
  138 + return HealthKitRawDataPoint(
  139 + dataType: dataType,
  140 + startTime: time,
  141 + endTime: time,
  142 + value: value,
  143 + );
  144 +}
  145 +
  146 +HealthKitRawDataPoint _interval(int startTime, int endTime) {
  147 + return HealthKitRawDataPoint(
  148 + dataType: 3,
  149 + startTime: startTime,
  150 + endTime: endTime,
  151 + );
  152 +}
  153 +
  154 +HealthKitRawWorkoutDataPoint _workout(int startTime, int endTime) {
  155 + return HealthKitRawWorkoutDataPoint(
  156 + workoutType: 90,
  157 + startTime: startTime,
  158 + endTime: endTime,
  159 + );
  160 +}
  161 +
  162 +HealthRawRealtimeStressPoint _realtime(int rawEndTime, double result) {
  163 + return HealthRawRealtimeStressPoint(
  164 + userId: 42,
  165 + rawEndTime: rawEndTime,
  166 + rawHr: 70,
  167 + result: result,
  168 + sourceStartTime: rawEndTime,
  169 + sourceEndTime: rawEndTime,
  170 + );
  171 +}
  1 +import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_source.dart';
  2 +import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
  3 +import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart';
  4 +import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_core_service.dart';
  5 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
  6 +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
  7 +import 'package:flutter_test/flutter_test.dart';
  8 +
  9 +void main() {
  10 + test('OHOS core calculates and stores Huawei hrv realtime and daily results',
  11 + () async {
  12 + final day = DateTime.now().subtract(const Duration(days: 7));
  13 + final base =
  14 + DateTime(day.year, day.month, day.day, 10).millisecondsSinceEpoch ~/
  15 + 1000;
  16 + final store = _FakeHealthRawStressLocalStore();
  17 + final service = OHOSHealthRawDataCoreService(
  18 + rawDataSource: _FakeHealthRawDataSource(
  19 + pointsByDataType: {
  20 + 1: [_point(1, base + 300, 60)],
  21 + 2: [_point(2, base + 300, 80)],
  22 + },
  23 + ),
  24 + localStore: store,
  25 + userIdProvider: () => 42,
  26 + uploadResultsAfterCalculation: false,
  27 + healthReadAuthorizationChecker: () async => false,
  28 + );
  29 +
  30 + final result = await service.syncAndStore(
  31 + startTime: base,
  32 + endTime: base + 600,
  33 + readChunkDays: 1,
  34 + );
  35 +
  36 + expect(result.hrvStressPoints, hasLength(1));
  37 + expect(result.hrvStressPoints.single.result, 50);
  38 + expect(result.realtimeStressPoints, hasLength(1));
  39 + expect(result.dailyStressPoints, hasLength(1));
  40 + expect(store.hrvStressPoints.single.result, 50);
  41 + expect(store.realtimeStressPoints, hasLength(1));
  42 + expect(store.dailyStressPoints, hasLength(1));
  43 + });
  44 +}
  45 +
  46 +class _FakeHealthRawDataSource implements HealthRawDataSource {
  47 + _FakeHealthRawDataSource({required this.pointsByDataType});
  48 +
  49 + final Map<int, List<HealthKitRawDataPoint>> pointsByDataType;
  50 +
  51 + @override
  52 + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
  53 + return null;
  54 + }
  55 +
  56 + @override
  57 + Future<List<HealthKitRawActivityDataPoint>> getRawActivityData(
  58 + int startTime,
  59 + int endTime,
  60 + ) async {
  61 + return const <HealthKitRawActivityDataPoint>[];
  62 + }
  63 +
  64 + @override
  65 + Future<List<HealthKitRawDataPoint>> getRawData(
  66 + int dataType,
  67 + int startTime,
  68 + int endTime,
  69 + ) async {
  70 + return (pointsByDataType[dataType] ?? const <HealthKitRawDataPoint>[])
  71 + .where(
  72 + (point) => point.endTime >= startTime && point.endTime <= endTime)
  73 + .toList(growable: false);
  74 + }
  75 +
  76 + @override
  77 + Future<List<HealthKitRawSleepDataPoint>> getRawSleepData(
  78 + int startTime,
  79 + int endTime,
  80 + ) async {
  81 + return const <HealthKitRawSleepDataPoint>[];
  82 + }
  83 +
  84 + @override
  85 + Future<List<HealthKitRawWorkoutDataPoint>> getRawWorkoutData(
  86 + int startTime,
  87 + int endTime,
  88 + ) async {
  89 + return const <HealthKitRawWorkoutDataPoint>[];
  90 + }
  91 +
  92 + @override
  93 + Future<bool> hasHealthData() async {
  94 + return true;
  95 + }
  96 +}
  97 +
  98 +class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
  99 + final hrvStressPoints = <HealthRawHrvStressPoint>[];
  100 + final realtimeStressPoints = <HealthRawRealtimeStressPoint>[];
  101 + final dailyStressPoints = <HealthRawDailyStressPoint>[];
  102 +
  103 + @override
  104 + Future<void> ensureReadable(int userId) async {}
  105 +
  106 + @override
  107 + Future<int?> latestHrvSourceStartTime(int userId) async {
  108 + return null;
  109 + }
  110 +
  111 + @override
  112 + Future<int?> latestRealtimeSourceStartTime(int userId) async {
  113 + return null;
  114 + }
  115 +
  116 + @override
  117 + Future<int?> latestHrvRawEndTime(int userId) async {
  118 + return hrvStressPoints.isEmpty ? null : hrvStressPoints.last.rawEndTime;
  119 + }
  120 +
  121 + @override
  122 + Future<int?> latestRealtimeRawEndTime(int userId) async {
  123 + return realtimeStressPoints.isEmpty
  124 + ? null
  125 + : realtimeStressPoints.last.rawEndTime;
  126 + }
  127 +
  128 + @override
  129 + Future<int?> latestSleepResultTime(int userId) async {
  130 + return null;
  131 + }
  132 +
  133 + @override
  134 + Future<void> upsertResult(HealthRawStressCalculationResult result) async {
  135 + hrvStressPoints.addAll(result.hrvStressPoints);
  136 + realtimeStressPoints.addAll(result.realtimeStressPoints);
  137 + }
  138 +
  139 + @override
  140 + Future<Set<int>> existingDailyStressDates({
  141 + required int userId,
  142 + required Iterable<int> dates,
  143 + }) async {
  144 + return const <int>{};
  145 + }
  146 +
  147 + @override
  148 + Future<List<HealthRawRealtimeStressPoint>> queryRealtimeStressPoints({
  149 + required int userId,
  150 + required int startTime,
  151 + required int endTime,
  152 + }) async {
  153 + return realtimeStressPoints
  154 + .where((point) =>
  155 + point.rawEndTime >= startTime && point.rawEndTime <= endTime)
  156 + .toList(growable: false);
  157 + }
  158 +
  159 + @override
  160 + Future<void> upsertDailyStressPoints({
  161 + required int userId,
  162 + required Iterable<HealthRawDailyStressPoint> points,
  163 + }) async {
  164 + dailyStressPoints.addAll(points);
  165 + }
  166 +
  167 + @override
  168 + Future<void> deleteDailyStressDates({
  169 + required int userId,
  170 + required Iterable<int> dates,
  171 + }) async {}
  172 +
  173 + @override
  174 + Future<void> upsertSleepResults({
  175 + required int userId,
  176 + required Iterable<HealthRawSleepResult> results,
  177 + }) async {}
  178 +}
  179 +
  180 +HealthKitRawDataPoint _point(int dataType, int time, double value) {
  181 + return HealthKitRawDataPoint(
  182 + dataType: dataType,
  183 + startTime: time,
  184 + endTime: time,
  185 + value: value,
  186 + );
  187 +}