diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHrvSummarySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ComputedHrvSummarySampleProvider.java
similarity index 68%
rename from app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHrvSummarySampleProvider.java
rename to app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ComputedHrvSummarySampleProvider.java
index 8e6456f740..b5b0f28474 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHrvSummarySampleProvider.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ComputedHrvSummarySampleProvider.java
@@ -15,39 +15,49 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . */
-package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+package nodomain.freeyourgadget.gadgetbridge.devices;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Calendar;
-import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
-import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
-import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
-import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
-import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
/**
- * Provides HRV summary data for Huami/Zepp OS devices by computing statistics
- * from the per-minute HRV value samples.
- * This provider calculates:
- * - Weekly average (7-day rolling average)
- * - Last night average and 5-min high
- * - Baseline values for status determination
+ * Generic provider that computes HRV summary data from per-minute HRV value samples.
+ * This provider can be used by any device that stores per-minute HRV data but doesn't
+ * receive summary statistics from the device firmware.
+ *
+ * The provider calculates:
+ * - Weekly average (7-day rolling average of all HRV values)
+ * - Last night average and 5-min high (8 hours before midnight to midnight)
+ * - Baseline values using Garmin's method: mean ± standard deviation of 28 overnight averages
+ *
+ * Summaries are computed on-demand and cached for performance.
*/
-public class HuamiHrvSummarySampleProvider implements TimeSampleProvider {
+public class ComputedHrvSummarySampleProvider implements TimeSampleProvider {
private static final int DAYS_FOR_WEEKLY_AVG = 7;
- private static final int DAYS_FOR_BASELINE = 14; // Use 2 weeks for baseline calculation
+ private static final int DAYS_FOR_BASELINE = 28; // Use 28 days (4 weeks) for baseline calculation
+ private static final int CACHE_SIZE = 100; // Cache up to 100 computed summaries
- private final GenericHrvValueSampleProvider valueProvider;
+ private final TimeSampleProvider extends HrvValueSample> valueProvider;
- public HuamiHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
- this.valueProvider = new GenericHrvValueSampleProvider(device, session);
+ // LRU cache for computed summaries
+ private final Map summaryCache = new LinkedHashMap<>(CACHE_SIZE, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > CACHE_SIZE;
+ }
+ };
+
+ public ComputedHrvSummarySampleProvider(final TimeSampleProvider extends HrvValueSample> valueProvider) {
+ this.valueProvider = valueProvider;
}
@NonNull
@@ -93,6 +103,11 @@ public class HuamiHrvSummarySampleProvider implements TimeSampleProvider 0 ? weeklyAvg : null,
lastNightAvg > 0 ? lastNightAvg : null,
@@ -144,6 +156,11 @@ public class HuamiHrvSummarySampleProvider implements TimeSampleProvider 0 ? baseline.balancedUpper : null,
status
);
+
+ // Cache the computed summary
+ summaryCache.put(dayEndTimestamp, summary);
+
+ return summary;
}
private int calculateAverageHrv(long timestampFrom, long timestampTo) {
@@ -181,38 +198,67 @@ public class HuamiHrvSummarySampleProvider implements TimeSampleProvider samples = valueProvider.getAllSamples(timestampFrom, timestampTo);
- if (samples.isEmpty()) {
- return new BaselineValues(0, 0, 0);
- }
+ private BaselineValues calculateBaseline(long timestampTo) {
+ // Calculate baseline using Garmin's approach:
+ // Get overnight HRV averages for the last 28 days, then calculate mean ± standard deviation
- // Get all HRV values and sort them
- final List values = new ArrayList<>();
- for (HrvValueSample sample : samples) {
- if (sample.getValue() > 0) {
- values.add(sample.getValue());
+ final List overnightAverages = new ArrayList<>();
+ final Calendar cal = Calendar.getInstance();
+ cal.setTimeInMillis(timestampTo);
+
+ // Collect overnight averages for the past 28 days
+ for (int i = 0; i < DAYS_FOR_BASELINE; i++) {
+ // Define "overnight" as 8 hours before midnight to midnight
+ final Calendar dayStart = (Calendar) cal.clone();
+ dayStart.set(Calendar.HOUR_OF_DAY, 0);
+ dayStart.set(Calendar.MINUTE, 0);
+ dayStart.set(Calendar.SECOND, 0);
+ dayStart.set(Calendar.MILLISECOND, 0);
+
+ final Calendar nightStart = (Calendar) dayStart.clone();
+ nightStart.add(Calendar.HOUR_OF_DAY, -8);
+
+ final int nightAvg = calculateAverageHrv(nightStart.getTimeInMillis(), dayStart.getTimeInMillis());
+ if (nightAvg > 0) {
+ overnightAverages.add(nightAvg);
}
+
+ // Move to previous day
+ cal.add(Calendar.DATE, -1);
}
- if (values.isEmpty()) {
+ if (overnightAverages.isEmpty()) {
return new BaselineValues(0, 0, 0);
}
- Collections.sort(values);
+ // Calculate mean
+ double sum = 0;
+ for (int value : overnightAverages) {
+ sum += value;
+ }
+ final double mean = sum / overnightAverages.size();
- // Calculate percentiles for baseline determination
- // Low: below 25th percentile
- // Balanced: 25th to 75th percentile
- // Unbalanced: above 75th percentile
- final int p25Index = values.size() / 4;
- final int p75Index = (values.size() * 3) / 4;
+ // Calculate standard deviation
+ double varianceSum = 0;
+ for (int value : overnightAverages) {
+ final double diff = value - mean;
+ varianceSum += diff * diff;
+ }
+ final double stdDev = Math.sqrt(varianceSum / overnightAverages.size());
- final int baselineLowUpper = values.get(Math.max(0, p25Index - 1));
- final int baselineBalancedLower = values.get(p25Index);
- final int baselineBalancedUpper = values.get(Math.min(values.size() - 1, p75Index));
+ // Baseline range is mean ± 1 standard deviation, rounded to whole numbers
+ final int baselineLower = (int) Math.round(mean - stdDev);
+ final int baselineUpper = (int) Math.round(mean + stdDev);
- return new BaselineValues(baselineLowUpper, baselineBalancedLower, baselineBalancedUpper);
+ // For the "low upper" threshold, we use a value below the lower baseline boundary
+ // This creates three ranges:
+ // Poor: < (mean - 1.5 * stdDev)
+ // Low: < (mean - stdDev)
+ // Balanced: (mean - stdDev) to (mean + stdDev)
+ // Unbalanced: > (mean + stdDev)
+ final int poorThreshold = (int) Math.round(mean - 1.5 * stdDev);
+
+ return new BaselineValues(poorThreshold, baselineLower, baselineUpper);
}
private HrvSummarySample.Status determineStatus(int weeklyAvg, BaselineValues baseline) {
@@ -221,16 +267,16 @@ public class HuamiHrvSummarySampleProvider implements TimeSampleProvider getHrvSummarySampleProvider(GBDevice device, DaoSession session) {
- return new HuamiHrvSummarySampleProvider(device, session);
+ return new ComputedHrvSummarySampleProvider(getHrvValueSampleProvider(device, session));
}
@Override
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java
index b7d3ac2cf1..29d46c4872 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java
@@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.ComputedHrvSummarySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
@@ -58,6 +59,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
@@ -150,6 +152,11 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator {
return new GenericHrvValueSampleProvider(device, session);
}
+ @Override
+ public TimeSampleProvider extends HrvSummarySample> getHrvSummarySampleProvider(GBDevice device, DaoSession session) {
+ return new ComputedHrvSummarySampleProvider(getHrvValueSampleProvider(device, session));
+ }
+
@Override
public TimeSampleProvider extends HeartRateSample> getHeartRateMaxSampleProvider(GBDevice device, DaoSession session) {
return new GenericHeartRateSampleProvider(device, session);