Add sensor validation for heart rate monitor

This commit is contained in:
2026-05-05 10:30:46 +00:00
parent 09a17f3623
commit 671a6d6fb4
2 changed files with 313 additions and 6 deletions

View File

@@ -63,9 +63,14 @@ public class SensorValidationActivity extends AppCompatActivity {
private Button stopButton;
private Button saveRawButton;
private Button saveSpectrumButton;
private Button startHrButton;
private Button stopHrButton;
private Button saveHrButton;
private TextView statusTv;
private TextView progressTv;
private TextView resultTv;
private TextView hrProgressTv;
private TextView hrResultTv;
private BarChart chart;
private boolean collecting = false;
@@ -77,6 +82,14 @@ public class SensorValidationActivity extends AppCompatActivity {
private final ArrayList<Double> sampleBuffer = new ArrayList<>();
private FrequencyValidationAnalyzer.Result lastResult = null;
private boolean collectingHr = false;
private long hrStartMillis = -1;
private int currentHrSecond = -1;
private double currentHrSum = 0.0;
private int currentHrValidCount = 0;
private int currentHrReadCount = 0;
private final ArrayList<HeartRatePoint> hrPoints = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -95,9 +108,14 @@ public class SensorValidationActivity extends AppCompatActivity {
stopButton = (Button) findViewById(R.id.validationStopButton);
saveRawButton = (Button) findViewById(R.id.validationSaveRawButton);
saveSpectrumButton = (Button) findViewById(R.id.validationSaveSpectrumButton);
startHrButton = (Button) findViewById(R.id.validationStartHrButton);
stopHrButton = (Button) findViewById(R.id.validationStopHrButton);
saveHrButton = (Button) findViewById(R.id.validationSaveHrButton);
statusTv = (TextView) findViewById(R.id.validationStatusTv);
progressTv = (TextView) findViewById(R.id.validationProgressTv);
resultTv = (TextView) findViewById(R.id.validationResultTv);
hrProgressTv = (TextView) findViewById(R.id.validationHrProgressTv);
hrResultTv = (TextView) findViewById(R.id.validationHrResultTv);
chart = (BarChart) findViewById(R.id.validationChart);
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
@@ -142,6 +160,24 @@ public class SensorValidationActivity extends AppCompatActivity {
saveSpectrumCsv();
}
});
startHrButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startHeartRateTest();
}
});
stopHrButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
stopHeartRateTest("HR test stopped by user.");
}
});
saveHrButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
saveHeartRateCsv();
}
});
configureChart();
updateButtons();
@@ -167,6 +203,7 @@ public class SensorValidationActivity extends AppCompatActivity {
@Override
public void run() {
pollLatestDataBlock();
pollHeartRateSample();
updateStatusText();
}
});
@@ -191,6 +228,10 @@ public class SensorValidationActivity extends AppCompatActivity {
return;
}
if (collectingHr) {
stopHeartRateTest("HR test stopped because accelerometer validation started.");
}
sampleBuffer.clear();
lastResult = null;
lastAnalysisDurationSeconds = 0.0;
@@ -407,10 +448,204 @@ public class SensorValidationActivity extends AppCompatActivity {
}
private void updateButtons() {
startButton.setEnabled(!collecting);
startButton.setEnabled(!collecting && !collectingHr);
stopButton.setEnabled(collecting);
saveRawButton.setEnabled(lastResult != null);
saveSpectrumButton.setEnabled(lastResult != null);
startHrButton.setEnabled(!collecting && !collectingHr);
stopHrButton.setEnabled(collectingHr);
saveHrButton.setEnabled(!hrPoints.isEmpty() && !collectingHr);
}
private void startHeartRateTest() {
if (!mConnection.mBound || mConnection.mSdServer == null) {
mUtil.showToast("Cannot start HR test: background service is not connected.");
return;
}
if (collecting) {
stopValidationTest("Accelerometer validation stopped because HR test started.");
}
hrPoints.clear();
currentHrSecond = -1;
currentHrSum = 0.0;
currentHrValidCount = 0;
currentHrReadCount = 0;
hrStartMillis = System.currentTimeMillis();
collectingHr = true;
hrResultTv.setText("Collecting heart-rate data...");
updateHeartRateProgressText();
updateButtons();
}
private void stopHeartRateTest(String message) {
if (collectingHr) {
flushCurrentHeartRateSecond();
}
collectingHr = false;
updateHeartRateProgressText();
showHeartRateSummary(message);
updateButtons();
}
private void pollHeartRateSample() {
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);
if (secondIndex >= targetSeconds) {
stopHeartRateTest(null);
return;
}
if (currentHrSecond != secondIndex) {
flushCurrentHeartRateSecond();
currentHrSecond = secondIndex;
currentHrSum = 0.0;
currentHrValidCount = 0;
currentHrReadCount = 0;
}
double hr = mConnection.mSdServer.mSdData.mHR;
currentHrReadCount++;
if (hr >= 0.0) {
currentHrSum += hr;
currentHrValidCount++;
}
updateHeartRateProgressText();
}
private void flushCurrentHeartRateSecond() {
if (currentHrSecond < 0) return;
double averageHr = currentHrValidCount > 0 ? currentHrSum / currentHrValidCount : -1.0;
long timestampMillis = hrStartMillis + currentHrSecond * 1000L;
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.UK).format(new Date(timestampMillis));
hrPoints.add(new HeartRatePoint(
hrPoints.size(),
timestamp,
currentHrSecond,
averageHr,
currentHrValidCount,
currentHrReadCount));
}
private void updateHeartRateProgressText() {
double elapsedSeconds = 0.0;
if (collectingHr && hrStartMillis > 0) {
elapsedSeconds = (System.currentTimeMillis() - hrStartMillis) / 1000.0;
} else if (!hrPoints.isEmpty()) {
elapsedSeconds = hrPoints.size();
}
hrProgressTv.setText("HR progress: "
+ String.format(Locale.UK, "%.1f", elapsedSeconds)
+ " / " + targetSeconds
+ " sec, " + hrPoints.size()
+ " one-second points saved");
}
private void showHeartRateSummary(String message) {
if (hrPoints.isEmpty()) {
hrResultTv.setText(message != null ? message : "No HR data collected.");
return;
}
double sum = 0.0;
double min = Double.MAX_VALUE;
double max = -Double.MAX_VALUE;
int validSeconds = 0;
int totalReadings = 0;
int validReadings = 0;
for (HeartRatePoint point : hrPoints) {
totalReadings += point.readingsInSecond;
validReadings += point.validReadingsInSecond;
if (point.valid) {
sum += point.averageHr;
min = Math.min(min, point.averageHr);
max = Math.max(max, point.averageHr);
validSeconds++;
}
}
DecimalFormat df2 = new DecimalFormat("0.00");
StringBuilder sb = new StringBuilder();
if (message != null) {
sb.append(message).append("\n\n");
}
sb.append("Heart-rate data collected.\n\n");
sb.append("Window: ").append(targetSeconds).append(" seconds\n");
sb.append("One-second points: ").append(hrPoints.size()).append("\n");
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");
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");
sb.append("Max HR: ").append(df2.format(max)).append(" bpm");
} else {
sb.append("Average HR: N/A");
}
hrResultTv.setText(sb.toString());
}
private void saveHeartRateCsv() {
if (hrPoints.isEmpty()) return;
File file = new File(getOutputDir(), "validation_hr_" + timestampForFilename() + ".csv");
try {
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
writer.write("point_index,time_hhmmss,elapsed_seconds,avg_hr_bpm,valid,valid_readings_in_second,total_readings_in_second\n");
for (HeartRatePoint point : hrPoints) {
writer.write(point.index + ","
+ point.timestampHhMmSs + ","
+ point.elapsedSecond + ","
+ (point.valid ? String.format(Locale.UK, "%.3f", point.averageHr) : "") + ","
+ point.valid + ","
+ point.validReadingsInSecond + ","
+ point.readingsInSecond + "\n");
}
writer.close();
mUtil.showToast("Saved HR CSV: " + file.getAbsolutePath());
} catch (Exception e) {
Log.e(TAG, "Error saving HR CSV: " + e.toString());
mUtil.showToast("Error saving HR CSV: " + e.toString());
}
}
private static class HeartRatePoint {
final int index;
final String timestampHhMmSs;
final int elapsedSecond;
final double averageHr;
final boolean valid;
final int validReadingsInSecond;
final int readingsInSecond;
HeartRatePoint(int index,
String timestampHhMmSs,
int elapsedSecond,
double averageHr,
int validReadingsInSecond,
int readingsInSecond) {
this.index = index;
this.timestampHhMmSs = timestampHhMmSs;
this.elapsedSecond = elapsedSecond;
this.averageHr = averageHr;
this.valid = validReadingsInSecond > 0;
this.validReadingsInSecond = validReadingsInSecond;
this.readingsInSecond = readingsInSecond;
}
}
private File getOutputDir() {

View File

@@ -14,7 +14,7 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sensor Frequency Validation"
android:text="Sensor Validation"
android:textColor="#111827"
android:textSize="22sp"
android:textStyle="bold" />
@@ -23,7 +23,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Use a known simulated motion frequency and measure the long-window FFT output from the watch data. This does not affect seizure detection."
android:text="Measure long-window accelerometer frequency output or collect timestamped heart-rate data. This does not affect seizure detection."
android:textColor="#4b5563"
android:textSize="14sp" />
@@ -49,7 +49,7 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Test setup"
android:text="Accelerometer frequency test"
android:textColor="#111827"
android:textSize="18sp"
android:textStyle="bold" />
@@ -102,7 +102,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start" />
android:text="Start Accel" />
<Button
android:id="@+id/validationStopButton"
@@ -110,7 +110,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Stop" />
android:text="Stop Accel" />
</LinearLayout>
<TextView
@@ -122,6 +122,78 @@
android:textColor="#374151" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="#ffffff"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Heart-rate monitor test"
android:textColor="#111827"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Uses the same 30/60 second window. Saves one timestamped heart-rate datapoint per second using the average of the HR reads seen during that second."
android:textColor="#4b5563"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/validationStartHrButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start HR" />
<Button
android:id="@+id/validationStopHrButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Stop HR" />
</LinearLayout>
<TextView
android:id="@+id/validationHrProgressTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="HR progress: 0.0 / 30 sec, 0 one-second points saved"
android:textColor="#374151" />
<TextView
android:id="@+id/validationHrResultTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="No HR test run yet."
android:textColor="#111827"
android:textSize="15sp" />
<Button
android:id="@+id/validationSaveHrButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Save HR CSV" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"