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 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 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 getHrvSummarySampleProvider(GBDevice device, DaoSession session) { + return new ComputedHrvSummarySampleProvider(getHrvValueSampleProvider(device, session)); + } + @Override public TimeSampleProvider getHeartRateMaxSampleProvider(GBDevice device, DaoSession session) { return new GenericHeartRateSampleProvider(device, session);