From 36baca30fa42bba93240eca32c71afb9de7c6a47 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Sun, 5 Apr 2026 15:04:22 +0200 Subject: [PATCH] fix(withings,scanwatch): ECG 40ms and 200ms grid --- .../charts/AbstractActivityChartFragment.java | 3 +- .../activities/charts/EcgChartFragment.java | 62 ++++++++++++++- .../WithingsScanwatchDeviceCoordinator.java | 5 ++ .../conversation/ActivitySampleHandler.java | 15 ++++ .../StoredMeasureSignalHandler.java | 8 +- .../conversation/WithingsEcgHandler.java | 7 +- .../util/EcgInterpretationUtil.java | 77 ++++++++++++------- 7 files changed, 139 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java index d2ef81fb93..b341c5f64e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java @@ -138,7 +138,7 @@ public abstract class AbstractActivityChartFragment extend akLightSleep = new ActivityConfig(ActivityKind.LIGHT_SLEEP, getString(R.string.abstract_chart_fragment_kind_light_sleep), AK_LIGHT_SLEEP_COLOR); akDeepSleep = new ActivityConfig(ActivityKind.DEEP_SLEEP, getString(R.string.abstract_chart_fragment_kind_deep_sleep), AK_DEEP_SLEEP_COLOR); akRemSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_rem_sleep), AK_REM_SLEEP_COLOR); - akAwakeSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR); + akAwakeSleep = new ActivityConfig(ActivityKind.AWAKE_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR); akNotWorn = new ActivityConfig(ActivityKind.NOT_WORN, getString(R.string.abstract_chart_fragment_kind_not_worn), AK_NOT_WORN_COLOR); } @@ -470,4 +470,3 @@ public abstract class AbstractActivityChartFragment extend return sample; } } - diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/EcgChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/EcgChartFragment.java index c0937158ef..e4be748dfe 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/EcgChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/EcgChartFragment.java @@ -1,6 +1,8 @@ package nodomain.freeyourgadget.gadgetbridge.activities.charts; import android.os.Bundle; +import android.graphics.Color; +import android.util.Log; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.MotionEvent; @@ -56,6 +58,13 @@ public class EcgChartFragment extends AbstractChartFragment 0 + ? Math.max(maxDelta / 1000f, recordDurationMs / 1000f) + : Math.max(maxDelta / 1000f, 1f); chart.getXAxis().setAxisMaximum(durationSeconds); chart.getXAxis().setLabelCount(6, true); chart.getXAxis().setValueFormatter(new SessionXAxisFormatter(data.selectedSummary.getStartTimestamp())); @@ -226,6 +238,9 @@ public class EcgChartFragment extends AbstractChartFragment waveform) { final EcgInterpretation.DeviceHint deviceHint = toDeviceHint(record.getDeviceHintCode()); - if (waveform == null || waveform.size() < MIN_SAMPLES_FOR_ANALYSIS) { + if (waveform == null || waveform.isEmpty()) { return new EcgInterpretation(deviceHint, EcgInterpretation.SignalQuality.INCONCLUSIVE, EcgInterpretation.Rhythm.INCONCLUSIVE); } @@ -43,7 +45,6 @@ public final class EcgInterpretationUtil { float min = Float.MAX_VALUE; float max = -Float.MAX_VALUE; float totalAbsDiff = 0f; - float clippedSamples = 0f; float previous = waveform.get(0).getValue(); values[0] = previous; @@ -55,34 +56,52 @@ public final class EcgInterpretationUtil { if (i > 0) { totalAbsDiff += Math.abs(value - previous); } - if (Math.abs(value) >= 120f) { - clippedSamples++; - } previous = value; } final float range = max - min; final float averageAbsDiff = totalAbsDiff / Math.max(1, waveform.size() - 1); + + // Detect ADC clipping: count samples at the top/bottom 0.5% of the range. + // A high fraction there indicates signal saturation rather than legitimate peaks. + final float clipWindow = range * 0.005f; + float clippedSamples = 0f; + for (int i = 0; i < waveform.size(); i++) { + final float value = values[i]; + if (value >= max - clipWindow || value <= min + clipWindow) { + clippedSamples++; + } + } final float clippedRatio = clippedSamples / waveform.size(); final EcgInterpretation.SignalQuality signalQuality; - if (range < MIN_SIGNAL_RANGE) { + if (range < MIN_SIGNAL_RANGE_FOR_NORMAL_HINT) { signalQuality = EcgInterpretation.SignalQuality.NOISY; - } else if (clippedRatio > 0.12f || averageAbsDiff > range * 0.45f) { + } else if (clippedRatio > 0.40f || averageAbsDiff > range * 1.10f) { signalQuality = EcgInterpretation.SignalQuality.NOISY; } else { signalQuality = EcgInterpretation.SignalQuality.GOOD; } + final boolean normalHintWithUsableSignal = deviceHint == EcgInterpretation.DeviceHint.NORMAL + && signalQuality == EcgInterpretation.SignalQuality.GOOD + && range >= MIN_SIGNAL_RANGE_FOR_NORMAL_HINT; + + if (waveform.size() < MIN_SAMPLES_FOR_ANALYSIS) { + final EcgInterpretation.Rhythm rhythm = normalHintWithUsableSignal + ? EcgInterpretation.Rhythm.REGULAR + : EcgInterpretation.Rhythm.INCONCLUSIVE; + return new EcgInterpretation(deviceHint, signalQuality, rhythm); + } + final float sampleRateHz = estimateSampleRate(record, waveform); final float[] smoothedValues = smooth(values, Math.max(7, Math.round(sampleRateHz * 0.04f) | 1)); final List peaks = detectPeaks(smoothedValues, sampleRateHz, record.getAverageHeartRate()); if (peaks.size() < 3) { - return new EcgInterpretation(deviceHint, signalQuality, EcgInterpretation.Rhythm.INCONCLUSIVE); - } - - if (record.getAverageHeartRate() > 0 && !hasPlausiblePeakCount(record, peaks.size(), 0.55f, 1.45f)) { - return new EcgInterpretation(deviceHint, signalQuality, EcgInterpretation.Rhythm.INCONCLUSIVE); + final EcgInterpretation.Rhythm rhythm = normalHintWithUsableSignal && peaks.size() >= 2 + ? EcgInterpretation.Rhythm.REGULAR + : EcgInterpretation.Rhythm.INCONCLUSIVE; + return new EcgInterpretation(deviceHint, signalQuality, rhythm); } final List rrIntervals = new ArrayList<>(peaks.size() - 1); @@ -109,13 +128,13 @@ public final class EcgInterpretationUtil { final float rrCoeffVar = rrMean > 0 ? rrStdDev / rrMean : 0f; final float derivedHeartRate = rrMean > 0 ? 60f / rrMean : 0f; - final boolean strictPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.55f, 1.45f); - final boolean relaxedPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.50f, 1.50f); + final boolean strictPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.50f, 1.50f); + final boolean relaxedPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.45f, 1.55f); final boolean strictHeartRateMatch = record.getAverageHeartRate() <= 0 || Math.abs(derivedHeartRate - record.getAverageHeartRate()) <= Math.max(MAX_DERIVED_HR_DELTA_BPM, record.getAverageHeartRate() * MAX_DERIVED_HR_DELTA_RATIO); final boolean relaxedHeartRateMatch = record.getAverageHeartRate() <= 0 || Math.abs(derivedHeartRate - record.getAverageHeartRate()) <= Math.max(RELAXED_DERIVED_HR_DELTA_BPM, record.getAverageHeartRate() * RELAXED_DERIVED_HR_DELTA_RATIO); - final boolean looksRegular = rrCoeffVar <= 0.18f && (rrMax - rrMin) <= Math.max(0.22f, rrMean * 0.28f); + final boolean looksRegular = rrCoeffVar <= 0.20f && (rrMax - rrMin) <= Math.max(0.25f, rrMean * 0.32f); final boolean irregular = rrCoeffVar > 0.34f || (rrMax - rrMin) > Math.max(0.45f, rrMean * 0.55f); final boolean stronglyIrregular = rrCoeffVar > STRONG_IRREGULAR_RR_CV || (rrMax - rrMin) > Math.max(STRONG_IRREGULAR_RR_SPREAD_SECONDS, rrMean * STRONG_IRREGULAR_RR_SPREAD_RATIO); @@ -123,23 +142,23 @@ public final class EcgInterpretationUtil { final boolean normalHint = deviceHint == EcgInterpretation.DeviceHint.NORMAL; final boolean canTrustNormalHint = normalHint && signalQuality == EcgInterpretation.SignalQuality.GOOD - && relaxedPeakCountPlausible - && relaxedHeartRateMatch; + && rrIntervals.size() >= MIN_RR_INTERVALS_FOR_NORMAL_HINT; final EcgInterpretation.Rhythm rhythm; if (signalQuality == EcgInterpretation.SignalQuality.NOISY) { rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE; - } else if (rrIntervals.size() < MIN_RR_INTERVALS_FOR_IRREGULAR) { - rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE; - } else if (canTrustNormalHint && !stronglyIrregular) { + } else if (canTrustNormalHint) { + // Device says Normal, signal is good, enough RR intervals detected - trust the device rhythm = EcgInterpretation.Rhythm.REGULAR; + } else if (rrIntervals.size() < MIN_RR_INTERVALS_FOR_IRREGULAR) { + rhythm = normalHintWithUsableSignal ? EcgInterpretation.Rhythm.REGULAR : EcgInterpretation.Rhythm.INCONCLUSIVE; + } else if (stronglyIrregular && !normalHint) { + rhythm = EcgInterpretation.Rhythm.IRREGULAR; } else if (irregular && !canTrustNormalHint) { rhythm = EcgInterpretation.Rhythm.IRREGULAR; } else if (strictPeakCountPlausible && strictHeartRateMatch) { rhythm = EcgInterpretation.Rhythm.REGULAR; - } else if (canTrustNormalHint && looksRegular) { - rhythm = EcgInterpretation.Rhythm.REGULAR; - } else if (canTrustNormalHint) { + } else if (normalHintWithUsableSignal && (relaxedPeakCountPlausible || relaxedHeartRateMatch || looksRegular)) { rhythm = EcgInterpretation.Rhythm.REGULAR; } else { rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE;