diff --git a/app/src/main/java/uk/org/openseizuredetector/SdData.java b/app/src/main/java/uk/org/openseizuredetector/SdData.java index 9bbcd4e..e481fa8 100644 --- a/app/src/main/java/uk/org/openseizuredetector/SdData.java +++ b/app/src/main/java/uk/org/openseizuredetector/SdData.java @@ -132,6 +132,20 @@ public class SdData implements Parcelable { public boolean mAdaptiveHrAlarmStanding = false; public boolean mAverageHrAlarmStanding = false; public double mHR = 0; + + // Heart-rate diagnostics. + // These are used to distinguish fresh BLE HR measurements from stale values. + public long mHrLastUpdateMillis = 0; + public int mHrNotificationCount = 0; + public int mHrRawValue = -1; + + // BLE Heart Rate Measurement sensor contact status. + // -1 = unknown / not provided + // 0 = contact feature not supported or not detected depending on flags + // 1 = contact not detected + // 2 = contact detected + // 3 = reserved/unknown + public int mHrSensorContactStatus = -1; public boolean mO2SatAlarmStanding = false; public boolean mO2SatFaultStanding = false; diff --git a/app/src/main/java/uk/org/openseizuredetector/SdDataSourceBLE2.java b/app/src/main/java/uk/org/openseizuredetector/SdDataSourceBLE2.java index 17875f4..fb41455 100644 --- a/app/src/main/java/uk/org/openseizuredetector/SdDataSourceBLE2.java +++ b/app/src/main/java/uk/org/openseizuredetector/SdDataSourceBLE2.java @@ -363,16 +363,38 @@ public class SdDataSourceBLE2 extends SdDataSource { Log.v(TAG,"onCharacteristicUpdate() - Characteristic "+charUuidStr+" updated"); if (charUuidStr.equals(CHAR_HEART_RATE_MEASUREMENT)) { - Log.v(TAG, String.format("%s", "HR Measurement")); - // Parse the flags - int flags = parser.getUInt8(); - final int unit = flags & 0x01; - final int sensorContactStatus = (flags & 0x06) >> 1; - final boolean energyExpenditurePresent = (flags & 0x08) > 0; - final boolean rrIntervalPresent = (flags & 0x10) > 0; - // Parse heart rate - mSdData.mHR = (unit == 0) ? parser.getUInt8() : parser.getUInt16(); - Log.d(TAG,"Received HR="+mSdData.mHR); + Log.v(TAG, "HR Measurement"); + + int flags = parser.getUInt8(); + + // Bit 0: 0 = UINT8 HR, 1 = UINT16 HR + final int unit = flags & 0x01; + + // Bits 1-2: sensor contact status, if provided by the device + final int sensorContactStatus = (flags & 0x06) >> 1; + + final boolean energyExpenditurePresent = (flags & 0x08) > 0; + final boolean rrIntervalPresent = (flags & 0x10) > 0; + + int heartRate = (unit == 0) ? parser.getUInt8() : parser.getUInt16(); + + // Timestamp every actual HR BLE notification, even if the value is invalid. + mSdData.mHrLastUpdateMillis = System.currentTimeMillis(); + mSdData.mHrNotificationCount++; + mSdData.mHrRawValue = heartRate; + mSdData.mHrSensorContactStatus = sensorContactStatus; + + // Treat 0 and 255 as invalid/fault values, matching the older BLE datasource behavior. + if (heartRate == 0 || heartRate == 255) { + mSdData.mHR = -1; + } else { + mSdData.mHR = (double) heartRate; + } + + Log.d(TAG, "Received HR raw=" + heartRate + + ", stored=" + mSdData.mHR + + ", contactStatus=" + sensorContactStatus + + ", notificationCount=" + mSdData.mHrNotificationCount); } else if (charUuidStr.equals(CHAR_OSD_ACC_DATA) || charUuidStr.equals(CHAR_INFINITIME_ACC_DATA)) { diff --git a/app/src/main/java/uk/org/openseizuredetector/SensorValidationActivity.java b/app/src/main/java/uk/org/openseizuredetector/SensorValidationActivity.java index 0e36d5a..90253f7 100644 --- a/app/src/main/java/uk/org/openseizuredetector/SensorValidationActivity.java +++ b/app/src/main/java/uk/org/openseizuredetector/SensorValidationActivity.java @@ -88,8 +88,18 @@ public class SensorValidationActivity extends AppCompatActivity { private double currentHrSum = 0.0; private int currentHrValidCount = 0; private int currentHrReadCount = 0; - private final ArrayList hrPoints = new ArrayList<>(); + + // Counts actual fresh BLE HR notifications per one-second bucket. + private int currentHrFreshNotificationCount = 0; + private long currentHrLastSeenNotificationCount = -1; + private long currentHrLastUpdateMillisInSecond = 0; + private int currentHrRawValue = -1; + private int currentHrSensorContactStatus = -1; + private long hrStartNotificationCount = 0; + private long hrLastSeenNotificationCount = -1; + +private final ArrayList hrPoints = new ArrayList<>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -120,13 +130,32 @@ public class SensorValidationActivity extends AppCompatActivity { ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, - new String[]{"30 seconds", "60 seconds"}); + new String[]{"30 seconds", "60 seconds", "2 minutes", "3 minutes", "5 minutes"}); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); durationSpinner.setAdapter(adapter); durationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - targetSeconds = position == 0 ? 30 : 60; + switch (position) { + case 0: + targetSeconds = 30; + break; + case 1: + targetSeconds = 60; + break; + case 2: + targetSeconds = 120; + break; + case 3: + targetSeconds = 180; + break; + case 4: + targetSeconds = 300; + break; + default: + targetSeconds = 30; + break; + } updateProgressText(); } @@ -471,10 +500,20 @@ public class SensorValidationActivity extends AppCompatActivity { hrPoints.clear(); currentHrSecond = -1; currentHrSum = 0.0; - currentHrValidCount = 0; - currentHrReadCount = 0; - hrStartMillis = System.currentTimeMillis(); - collectingHr = true; + currentHrValidCount = 0; + currentHrReadCount = 0; + currentHrFreshNotificationCount = 0; + currentHrLastUpdateMillisInSecond = 0; + currentHrRawValue = -1; + currentHrSensorContactStatus = -1; + + SdData sdData = mConnection.mSdServer.mSdData; + hrStartNotificationCount = sdData != null ? sdData.mHrNotificationCount : 0; + hrLastSeenNotificationCount = hrStartNotificationCount; + currentHrLastSeenNotificationCount = hrStartNotificationCount; + + hrStartMillis = System.currentTimeMillis(); + collectingHr = true; hrResultTv.setText("Collecting heart-rate data..."); updateHeartRateProgressText(); @@ -492,56 +531,87 @@ public class SensorValidationActivity extends AppCompatActivity { } private void pollHeartRateSample() { - if (!collectingHr) return; - if (!mConnection.mBound || mConnection.mSdServer == null || mConnection.mSdServer.mSdData == null) { - return; - } + if (!collectingHr) return; + if (!mConnection.mBound || mConnection.mSdServer == null || mConnection.mSdServer.mSdData == null) { + return; + } - long now = System.currentTimeMillis(); - double elapsedSeconds = (now - hrStartMillis) / 1000.0; - int secondIndex = (int) Math.floor(elapsedSeconds); + long now = System.currentTimeMillis(); + double elapsedSeconds = (now - hrStartMillis) / 1000.0; + int secondIndex = (int) Math.floor(elapsedSeconds); - if (secondIndex >= targetSeconds) { - stopHeartRateTest(null); - return; - } + if (secondIndex >= targetSeconds) { + stopHeartRateTest(null); + return; + } - if (currentHrSecond != secondIndex) { - flushCurrentHeartRateSecond(); - currentHrSecond = secondIndex; - currentHrSum = 0.0; - currentHrValidCount = 0; - currentHrReadCount = 0; - } + if (currentHrSecond != secondIndex) { + flushCurrentHeartRateSecond(); + currentHrSecond = secondIndex; + currentHrSum = 0.0; + currentHrValidCount = 0; + currentHrReadCount = 0; + currentHrFreshNotificationCount = 0; + currentHrLastUpdateMillisInSecond = 0; + currentHrRawValue = -1; + currentHrSensorContactStatus = -1; + currentHrLastSeenNotificationCount = hrLastSeenNotificationCount; + } - double hr = mConnection.mSdServer.mSdData.mHR; - currentHrReadCount++; - if (hr >= 0.0) { - currentHrSum += hr; - currentHrValidCount++; - } + SdData sdData = mConnection.mSdServer.mSdData; - updateHeartRateProgressText(); - } + currentHrReadCount++; + + boolean freshNotification = sdData.mHrNotificationCount != hrLastSeenNotificationCount; + if (freshNotification) { + int newNotifications = (int) (sdData.mHrNotificationCount - hrLastSeenNotificationCount); + if (newNotifications < 0) newNotifications = 0; + + currentHrFreshNotificationCount += newNotifications; + hrLastSeenNotificationCount = sdData.mHrNotificationCount; + + currentHrLastUpdateMillisInSecond = sdData.mHrLastUpdateMillis; + currentHrRawValue = sdData.mHrRawValue; + currentHrSensorContactStatus = sdData.mHrSensorContactStatus; + + double hr = sdData.mHR; + if (hr >= 0.0) { + currentHrSum += hr; + currentHrValidCount++; + } + } + + updateHeartRateProgressText(); + } private void flushCurrentHeartRateSecond() { - if (currentHrSecond < 0) return; + if (currentHrSecond < 0) return; - double averageHr = currentHrValidCount > 0 ? currentHrSum / currentHrValidCount : -1.0; - long timestampMillis = hrStartMillis + currentHrSecond * 1000L; - String timestampIso = timestampIsoLocal(timestampMillis); - String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.UK).format(new Date(timestampMillis)); + double averageHr = currentHrValidCount > 0 ? currentHrSum / currentHrValidCount : -1.0; + long timestampMillis = hrStartMillis + currentHrSecond * 1000L; + String timestampIso = timestampIsoLocal(timestampMillis); + String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.UK).format(new Date(timestampMillis)); - hrPoints.add(new HeartRatePoint( - hrPoints.size(), - timestampIso, - timestampMillis, - timestamp, - currentHrSecond, - averageHr, - currentHrValidCount, - currentHrReadCount)); - } + long lastHrAgeMs = -1; + if (currentHrLastUpdateMillisInSecond > 0) { + lastHrAgeMs = timestampMillis - currentHrLastUpdateMillisInSecond; + if (lastHrAgeMs < 0) lastHrAgeMs = 0; + } + + hrPoints.add(new HeartRatePoint( + hrPoints.size(), + timestampIso, + timestampMillis, + timestamp, + currentHrSecond, + averageHr, + currentHrValidCount, + currentHrReadCount, + currentHrFreshNotificationCount, + lastHrAgeMs, + currentHrRawValue, + currentHrSensorContactStatus)); + } private void updateHeartRateProgressText() { double elapsedSeconds = 0.0; @@ -568,12 +638,18 @@ public class SensorValidationActivity extends AppCompatActivity { double min = Double.MAX_VALUE; double max = -Double.MAX_VALUE; int validSeconds = 0; - int totalReadings = 0; - int validReadings = 0; + int totalReadings = 0; + int validReadings = 0; + int freshNotificationSeconds = 0; + int totalFreshNotifications = 0; for (HeartRatePoint point : hrPoints) { totalReadings += point.readingsInSecond; validReadings += point.validReadingsInSecond; + totalFreshNotifications += point.freshNotificationsInSecond; + if (point.freshNotificationsInSecond > 0) { + freshNotificationSeconds++; + } if (point.valid) { sum += point.averageHr; min = Math.min(min, point.averageHr); @@ -593,6 +669,8 @@ public class SensorValidationActivity extends AppCompatActivity { sb.append("Seconds with valid HR: ").append(validSeconds).append("\n"); sb.append("Total reads: ").append(totalReadings).append("\n"); sb.append("Valid reads: ").append(validReadings).append("\n"); + sb.append("Seconds with fresh HR notifications: ").append(freshNotificationSeconds).append("\n"); + sb.append("Fresh HR notifications: ").append(totalFreshNotifications).append("\n"); if (validSeconds > 0) { sb.append("Average HR: ").append(df2.format(sum / validSeconds)).append(" bpm\n"); sb.append("Min HR: ").append(df2.format(min)).append(" bpm\n"); @@ -608,17 +686,21 @@ public class SensorValidationActivity extends AppCompatActivity { File file = new File(getOutputDir(), "validation_hr_" + timestampForFilename() + ".csv"); try { OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file)); - writer.write("point_index,timestamp_iso,epoch_ms,time_hhmmss,elapsed_seconds,avg_hr_bpm,valid,valid_readings_in_second,total_readings_in_second\n"); + writer.write("point_index,timestamp_iso,epoch_ms,time_hhmmss,elapsed_seconds,avg_hr_bpm,valid,valid_readings_in_second,total_reads_in_second,fresh_notifications_in_second,last_hr_age_ms,raw_hr_value,sensor_contact_status\n"); for (HeartRatePoint point : hrPoints) { writer.write(point.index + "," - + point.timestampIso + "," - + point.epochMillis + "," - + point.timestampHhMmSs + "," - + point.elapsedSecond + "," - + (point.valid ? String.format(Locale.UK, "%.3f", point.averageHr) : "") + "," - + point.valid + "," - + point.validReadingsInSecond + "," - + point.readingsInSecond + "\n"); + + point.timestampIso + "," + + point.epochMillis + "," + + point.timestampHhMmSs + "," + + point.elapsedSecond + "," + + (point.valid ? String.format(Locale.UK, "%.3f", point.averageHr) : "") + "," + + point.valid + "," + + point.validReadingsInSecond + "," + + point.readingsInSecond + "," + + point.freshNotificationsInSecond + "," + + point.lastHrAgeMs + "," + + point.rawHrValue + "," + + point.sensorContactStatus + "\n"); } writer.close(); mUtil.showToast("Saved HR CSV: " + file.getAbsolutePath()); @@ -629,35 +711,48 @@ public class SensorValidationActivity extends AppCompatActivity { } private static class HeartRatePoint { - final int index; - final String timestampIso; - final long epochMillis; - final String timestampHhMmSs; - final int elapsedSecond; - final double averageHr; - final boolean valid; - final int validReadingsInSecond; - final int readingsInSecond; + final int index; + final String timestampIso; + final long epochMillis; + final String timestampHhMmSs; + final int elapsedSecond; + final double averageHr; + final boolean valid; + final int validReadingsInSecond; + final int readingsInSecond; - HeartRatePoint(int index, - String timestampIso, - long epochMillis, - String timestampHhMmSs, - int elapsedSecond, - double averageHr, - int validReadingsInSecond, - int readingsInSecond) { - this.index = index; - this.timestampIso = timestampIso; - this.epochMillis = epochMillis; - this.timestampHhMmSs = timestampHhMmSs; - this.elapsedSecond = elapsedSecond; - this.averageHr = averageHr; - this.valid = validReadingsInSecond > 0; - this.validReadingsInSecond = validReadingsInSecond; - this.readingsInSecond = readingsInSecond; - } - } + final int freshNotificationsInSecond; + final long lastHrAgeMs; + final int rawHrValue; + final int sensorContactStatus; + + HeartRatePoint(int index, + String timestampIso, + long epochMillis, + String timestampHhMmSs, + int elapsedSecond, + double averageHr, + int validReadingsInSecond, + int readingsInSecond, + int freshNotificationsInSecond, + long lastHrAgeMs, + int rawHrValue, + int sensorContactStatus) { + this.index = index; + this.timestampIso = timestampIso; + this.epochMillis = epochMillis; + this.timestampHhMmSs = timestampHhMmSs; + this.elapsedSecond = elapsedSecond; + this.averageHr = averageHr; + this.valid = validReadingsInSecond > 0; + this.validReadingsInSecond = validReadingsInSecond; + this.readingsInSecond = readingsInSecond; + this.freshNotificationsInSecond = freshNotificationsInSecond; + this.lastHrAgeMs = lastHrAgeMs; + this.rawHrValue = rawHrValue; + this.sensorContactStatus = sensorContactStatus; + } + } private File getOutputDir() { File dir = new File(getExternalFilesDir(null), "sensor_validation");