Added sensor validation option and function

This commit is contained in:
2026-05-04 22:19:42 +00:00
parent ecc1804519
commit 09a17f3623
7 changed files with 856 additions and 1 deletions

View File

@@ -50,6 +50,7 @@
<activity
android:name=".MainActivity2"
android:exported="false" />
<activity android:name=".SensorValidationActivity" />
<!--<activity
android:name=".MlModelManager"
android:exported="false"

View File

@@ -0,0 +1,165 @@
package uk.org.openseizuredetector;
import org.jtransforms.fft.DoubleFFT_1D;
import java.util.ArrayList;
import java.util.Locale;
/**
* Standalone FFT-based frequency validation helper.
*
* This deliberately does not call SdDataSource.doAnalysis(), because doAnalysis() also performs
* alarm, fall, HR, O2, and notification checks. This class reuses the same JTransforms FFT style
* used by SdDataSource, but only returns measurement/validation values.
*/
public class FrequencyValidationAnalyzer {
public static final double DEFAULT_SAMPLE_RATE_HZ = 25.0;
public static final double DEFAULT_MAX_DISPLAY_FREQ_HZ = 10.0;
public static final double DEFAULT_DISPLAY_BIN_WIDTH_HZ = 0.5;
public static final double DEFAULT_MIN_PEAK_FREQ_HZ = 0.5;
public static class SpectrumBar {
public final double freqStartHz;
public final double freqEndHz;
public final double power;
SpectrumBar(double freqStartHz, double freqEndHz, double power) {
this.freqStartHz = freqStartHz;
this.freqEndHz = freqEndHz;
this.power = power;
}
public String label() {
return String.format(Locale.UK, "%.1f-%.1f Hz", freqStartHz, freqEndHz);
}
}
public static class Result {
public double expectedHz;
public double measuredHz;
public double errorHz;
public double errorPercent;
public double tolerancePercent;
public double sampleRateHz;
public int sampleCount;
public double durationSeconds;
public double peakPower;
public boolean pass;
public ArrayList<SpectrumBar> displaySpectrum = new ArrayList<>();
public double[] rawSamples;
}
public static Result analyze(double[] rawSamples,
double sampleRateHz,
double expectedHz,
double tolerancePercent) {
if (rawSamples == null || rawSamples.length < 4) {
throw new IllegalArgumentException("Not enough samples for FFT analysis");
}
if (sampleRateHz <= 0) sampleRateHz = DEFAULT_SAMPLE_RATE_HZ;
if (tolerancePercent <= 0) tolerancePercent = 5.0;
Result result = new Result();
result.expectedHz = expectedHz;
result.tolerancePercent = tolerancePercent;
result.sampleRateHz = sampleRateHz;
result.sampleCount = rawSamples.length;
result.durationSeconds = rawSamples.length / sampleRateHz;
result.rawSamples = rawSamples.clone();
double mean = 0.0;
for (double sample : rawSamples) {
mean += sample;
}
mean = mean / rawSamples.length;
double[] fft = new double[rawSamples.length * 2];
for (int i = 0; i < rawSamples.length; i++) {
// Remove the static/gravity/DC component. This makes the validation peak clearer.
double centered = rawSamples[i] - mean;
// Apply a Hann window. This reduces spectral leakage at the edges of the 30/60 sec
// validation window and helps prevent very low-frequency drift from dominating.
double window = 0.5 * (1.0 - Math.cos((2.0 * Math.PI * i) / (rawSamples.length - 1)));
fft[i] = centered * window;
}
DoubleFFT_1D fftDo = new DoubleFFT_1D(rawSamples.length);
fftDo.realForward(fft);
int maxUsefulBin = Math.min(rawSamples.length / 2 - 1,
Math.max(1, (int) Math.floor(DEFAULT_MAX_DISPLAY_FREQ_HZ * rawSamples.length / sampleRateHz)));
// For validation, do not simply pick the strongest bin from the entire spectrum.
// Hand/watch motion often has slow drift below 0.5 Hz that can be stronger than the
// simulated test frequency. Instead, look near the expected frequency and ignore
// near-DC bins. This is for calibration/measurement, not detection.
double searchHalfWidthHz;
if (expectedHz > 0) {
searchHalfWidthHz = Math.max(0.75, expectedHz * tolerancePercent / 100.0 * 2.0);
} else {
searchHalfWidthHz = DEFAULT_MAX_DISPLAY_FREQ_HZ;
}
double searchMinHz = expectedHz > 0
? Math.max(DEFAULT_MIN_PEAK_FREQ_HZ, expectedHz - searchHalfWidthHz)
: DEFAULT_MIN_PEAK_FREQ_HZ;
double searchMaxHz = expectedHz > 0
? Math.min(DEFAULT_MAX_DISPLAY_FREQ_HZ, expectedHz + searchHalfWidthHz)
: DEFAULT_MAX_DISPLAY_FREQ_HZ;
int startBin = Math.max(1, (int) Math.ceil(searchMinHz * rawSamples.length / sampleRateHz));
int endBin = Math.min(maxUsefulBin, (int) Math.floor(searchMaxHz * rawSamples.length / sampleRateHz));
if (endBin < startBin) {
startBin = Math.max(1, (int) Math.ceil(DEFAULT_MIN_PEAK_FREQ_HZ * rawSamples.length / sampleRateHz));
endBin = maxUsefulBin;
}
int dominantBin = startBin;
double dominantPower = 0.0;
for (int i = startBin; i <= endBin; i++) {
double power = getMagnitude(fft, i);
if (power > dominantPower) {
dominantPower = power;
dominantBin = i;
}
}
result.measuredHz = dominantBin * sampleRateHz / rawSamples.length;
result.peakPower = dominantPower;
result.errorHz = result.measuredHz - expectedHz;
if (expectedHz != 0) {
result.errorPercent = 100.0 * result.errorHz / expectedHz;
} else {
result.errorPercent = 0.0;
}
result.pass = Math.abs(result.errorPercent) <= tolerancePercent;
int displayBins = (int) Math.ceil(DEFAULT_MAX_DISPLAY_FREQ_HZ / DEFAULT_DISPLAY_BIN_WIDTH_HZ);
for (int b = 0; b < displayBins; b++) {
double fStart = b * DEFAULT_DISPLAY_BIN_WIDTH_HZ;
double fEnd = fStart + DEFAULT_DISPLAY_BIN_WIDTH_HZ;
int binStart = Math.max(1, (int) Math.ceil(fStart * rawSamples.length / sampleRateHz));
int binEnd = Math.max(binStart, (int) Math.floor(fEnd * rawSamples.length / sampleRateHz));
binEnd = Math.min(binEnd, rawSamples.length / 2 - 1);
double powerSum = 0.0;
int count = 0;
for (int i = binStart; i <= binEnd; i++) {
powerSum += getMagnitude(fft, i);
count++;
}
double averagePower = count > 0 ? powerSum / count : 0.0;
result.displaySpectrum.add(new SpectrumBar(fStart, fEnd, averagePower));
}
return result;
}
/**
* Same magnitude calculation style as SdDataSource.getMagnitude().
*/
private static double getMagnitude(double[] fft, int i) {
return (fft[2 * i] * fft[2 * i]) + (fft[2 * i + 1] * fft[2 * i + 1]);
}
}

View File

@@ -269,7 +269,6 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "action_install_watch_app");
mConnection.mSdServer.mSdDataSource.installWatchApp();
return true;
case R.id.action_accept_alarm:
Log.i(TAG, "action_accept_alarm");
if (mConnection.mBound) {
@@ -401,6 +400,20 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "exception starting Report Seizure activity " + ex.toString());
}
return true;
case R.id.action_sensor_validation:
Log.i(TAG, "action_sensor_validation selected");
mUtil.showToast("Opening Sensor Validation Test");
try {
Intent intent = new Intent(
MainActivity.this,
SensorValidationActivity.class);
this.startActivity(intent);
} catch (Exception ex) {
Log.e(TAG, "exception starting Sensor Validation activity", ex);
mUtil.showToast("Error opening Sensor Validation: " + ex.toString());
}
return true;
case R.id.action_settings:
Log.i(TAG, "action_settings");
try {

View File

@@ -290,6 +290,20 @@ public class MainActivity2 extends AppCompatActivity {
Log.i(TAG, "exception starting log manager activity " + ex.toString());
}
return true;
case R.id.action_sensor_validation:
Log.i(TAG, "action_sensor_validation selected");
mUtil.showToast("Opening Sensor Validation Test");
try {
Intent intent = new Intent(
MainActivity2.this,
SensorValidationActivity.class);
this.startActivity(intent);
} catch (Exception ex) {
Log.e(TAG, "exception starting Sensor Validation activity", ex);
mUtil.showToast("Error opening Sensor Validation: " + ex.toString());
}
return true;
case R.id.action_report_seizure:
Log.i(TAG, "action_report_seizure");
try {

View File

@@ -0,0 +1,479 @@
package uk.org.openseizuredetector;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.text.format.Time;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.github.mikephil.charting.charts.BarChart;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.BarData;
import com.github.mikephil.charting.data.BarDataSet;
import com.github.mikephil.charting.data.BarEntry;
import com.github.mikephil.charting.utils.ValueFormatter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;
import java.util.Timer;
import java.util.TimerTask;
/**
* Test/validation screen for measuring a known simulated acceleration frequency.
*
* This screen does not perform seizure detection. It binds to SdServer, watches the latest 5 second
* BLE2 rawData blocks already being produced by the app, accumulates 30/60 seconds, then runs a
* longer-window FFT and displays/saves the result.
*/
public class SensorValidationActivity extends AppCompatActivity {
private static final String TAG = "SensorValidation";
private static final double SAMPLE_RATE_HZ = 25.0;
private static final int RAW_BLOCK_SECONDS = 5;
private OsdUtil mUtil;
private SdServiceConnection mConnection;
private final Handler mHandler = new Handler();
private Timer mUiTimer;
private Context mContext;
private EditText expectedHzEt;
private EditText toleranceEt;
private Spinner durationSpinner;
private Button startButton;
private Button stopButton;
private Button saveRawButton;
private Button saveSpectrumButton;
private TextView statusTv;
private TextView progressTv;
private TextView resultTv;
private BarChart chart;
private boolean collecting = false;
private int targetSeconds = 30;
private long lastBlockTimeMillis = -1;
private long collectionStartMillis = -1;
private double lastAnalysisDurationSeconds = 0.0;
private int collectedBlockCount = 0;
private final ArrayList<Double> sampleBuffer = new ArrayList<>();
private FrequencyValidationAnalyzer.Result lastResult = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.i(TAG, "onCreate()");
mContext = this;
mUtil = new OsdUtil(getApplicationContext(), mHandler);
mConnection = new SdServiceConnection(getApplicationContext());
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
setContentView(R.layout.activity_sensor_validation);
expectedHzEt = (EditText) findViewById(R.id.validationExpectedHzEt);
toleranceEt = (EditText) findViewById(R.id.validationToleranceEt);
durationSpinner = (Spinner) findViewById(R.id.validationDurationSpinner);
startButton = (Button) findViewById(R.id.validationStartButton);
stopButton = (Button) findViewById(R.id.validationStopButton);
saveRawButton = (Button) findViewById(R.id.validationSaveRawButton);
saveSpectrumButton = (Button) findViewById(R.id.validationSaveSpectrumButton);
statusTv = (TextView) findViewById(R.id.validationStatusTv);
progressTv = (TextView) findViewById(R.id.validationProgressTv);
resultTv = (TextView) findViewById(R.id.validationResultTv);
chart = (BarChart) findViewById(R.id.validationChart);
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item,
new String[]{"30 seconds", "60 seconds"});
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;
updateProgressText();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
targetSeconds = 30;
}
});
startButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startValidationTest();
}
});
stopButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
stopValidationTest("Stopped by user.");
}
});
saveRawButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
saveRawCsv();
}
});
saveSpectrumButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
saveSpectrumCsv();
}
});
configureChart();
updateButtons();
updateProgressText();
}
@Override
protected void onStart() {
super.onStart();
Log.i(TAG, "onStart()");
if (mUtil.isServerRunning()) {
mUtil.bindToServer(getApplicationContext(), mConnection);
} else {
statusTv.setText("Background service is not running. Start the OSD service before validation.");
statusTv.setBackgroundColor(Color.rgb(245, 158, 11));
}
mUiTimer = new Timer();
mUiTimer.schedule(new TimerTask() {
@Override
public void run() {
mHandler.post(new Runnable() {
@Override
public void run() {
pollLatestDataBlock();
updateStatusText();
}
});
}
}, 0, 500);
}
@Override
protected void onStop() {
super.onStop();
Log.i(TAG, "onStop()");
if (mUiTimer != null) {
mUiTimer.cancel();
mUiTimer = null;
}
mUtil.unbindFromServer(getApplicationContext(), mConnection);
}
private void startValidationTest() {
if (!mConnection.mBound || mConnection.mSdServer == null) {
mUtil.showToast("Cannot start: background service is not connected.");
return;
}
sampleBuffer.clear();
lastResult = null;
lastAnalysisDurationSeconds = 0.0;
collectedBlockCount = 0;
// Start from the next fresh data block so we do not immediately count an old 5-second
// block that arrived before the user pressed Start.
SdData current = mConnection.mSdServer.mSdData;
lastBlockTimeMillis = current != null ? getDataTimeMillis(current) : -1;
collectionStartMillis = System.currentTimeMillis();
collecting = true;
resultTv.setText("Collecting validation data... Waiting for fresh BLE2 data blocks.");
chart.clear();
updateButtons();
updateProgressText();
}
private void stopValidationTest(String message) {
collecting = false;
updateButtons();
if (message != null) {
resultTv.setText(message);
}
}
private void pollLatestDataBlock() {
if (!collecting) return;
if (!mConnection.mBound || mConnection.mSdServer == null || mConnection.mSdServer.mSdData == null) {
return;
}
SdData sdData = mConnection.mSdServer.mSdData;
if (!sdData.haveData || sdData.rawData == null || sdData.mNsamp <= 0) {
return;
}
long blockTime = getDataTimeMillis(sdData);
if (blockTime <= 0 || blockTime == lastBlockTimeMillis) {
return;
}
lastBlockTimeMillis = blockTime;
int nSamples = Math.min(sdData.mNsamp, sdData.rawData.length);
for (int i = 0; i < nSamples; i++) {
sampleBuffer.add(sdData.rawData[i]);
}
collectedBlockCount++;
updateProgressText();
long elapsedMillis = collectionStartMillis > 0
? System.currentTimeMillis() - collectionStartMillis
: 0;
if (elapsedMillis >= targetSeconds * 1000L && sampleBuffer.size() >= 4) {
collecting = false;
lastAnalysisDurationSeconds = elapsedMillis / 1000.0;
runLongWindowAnalysis();
updateButtons();
}
}
private long getDataTimeMillis(SdData sdData) {
try {
Time t = sdData.dataTime;
if (t != null) {
return t.toMillis(false);
}
} catch (Exception ignored) {
}
return 0;
}
private void runLongWindowAnalysis() {
try {
double expectedHz = parseDoubleOrDefault(expectedHzEt.getText().toString(), 3.0);
double tolerancePct = parseDoubleOrDefault(toleranceEt.getText().toString(), 5.0);
double[] samples = new double[sampleBuffer.size()];
for (int i = 0; i < sampleBuffer.size(); i++) {
samples[i] = sampleBuffer.get(i);
}
double analysisSampleRate = SAMPLE_RATE_HZ;
if (lastAnalysisDurationSeconds > 0.0) {
analysisSampleRate = samples.length / lastAnalysisDurationSeconds;
}
lastResult = FrequencyValidationAnalyzer.analyze(samples, analysisSampleRate, expectedHz, tolerancePct);
showResult(lastResult);
updateChart(lastResult);
} catch (Exception e) {
Log.e(TAG, "Validation analysis failed: " + e.toString());
resultTv.setText("Validation analysis failed: " + e.toString());
}
}
private double parseDoubleOrDefault(String str, double defaultValue) {
try {
return Double.parseDouble(str.trim());
} catch (Exception e) {
return defaultValue;
}
}
private void showResult(FrequencyValidationAnalyzer.Result result) {
DecimalFormat df2 = new DecimalFormat("0.00");
DecimalFormat df3 = new DecimalFormat("0.000");
StringBuilder sb = new StringBuilder();
sb.append(result.pass ? "PASS" : "FAIL").append("\n\n");
sb.append("Expected frequency: ").append(df2.format(result.expectedHz)).append(" Hz\n");
sb.append("Measured frequency: ").append(df3.format(result.measuredHz)).append(" Hz\n");
sb.append("Error: ").append(df3.format(result.errorHz)).append(" Hz\n");
sb.append("Error percent: ").append(df2.format(result.errorPercent)).append("%\n");
sb.append("Tolerance: ±").append(df2.format(result.tolerancePercent)).append("%\n");
sb.append("Actual elapsed window: ").append(df2.format(result.durationSeconds)).append(" seconds\n");
sb.append("Samples: ").append(result.sampleCount).append("\n");
sb.append("Data blocks: ").append(collectedBlockCount).append("\n");
if (collectedBlockCount > 0) {
sb.append("Average block length: ").append(df2.format(result.durationSeconds / collectedBlockCount)).append(" seconds\n");
sb.append("Average samples/block: ").append(df2.format(result.sampleCount / (double) collectedBlockCount)).append("\n");
}
sb.append("Estimated sample rate: ").append(df2.format(result.sampleRateHz)).append(" Hz\n");
if (Math.abs(result.sampleRateHz - SAMPLE_RATE_HZ) > 2.0) {
sb.append("Note: using estimated sample rate, not the original 25 Hz assumption.\n");
}
sb.append("Peak power: ").append(df2.format(result.peakPower));
resultTv.setText(sb.toString());
}
private void configureChart() {
chart.setDrawBarShadow(false);
chart.setNoDataTextDescription("Run a validation test to show the long-window spectrum.");
chart.setDescription("");
XAxis xAxis = chart.getXAxis();
xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxis.setTextSize(9f);
xAxis.setDrawAxisLine(true);
xAxis.setDrawLabels(true);
xAxis.setDrawGridLines(false);
YAxis left = chart.getAxisLeft();
left.setAxisMinValue(0f);
YAxis right = chart.getAxisRight();
right.setEnabled(false);
}
private void updateChart(FrequencyValidationAnalyzer.Result result) {
ArrayList<String> xVals = new ArrayList<>();
ArrayList<BarEntry> yVals = new ArrayList<>();
int[] colors = new int[result.displaySpectrum.size()];
for (int i = 0; i < result.displaySpectrum.size(); i++) {
FrequencyValidationAnalyzer.SpectrumBar bar = result.displaySpectrum.get(i);
xVals.add(bar.label());
yVals.add(new BarEntry((float) bar.power, i));
if (result.expectedHz >= bar.freqStartHz && result.expectedHz < bar.freqEndHz) {
colors[i] = Color.rgb(239, 68, 68);
} else {
colors[i] = Color.rgb(96, 165, 250);
}
}
BarDataSet dataSet = new BarDataSet(yVals, "Long-window spectrum");
dataSet.setColors(colors);
dataSet.setBarSpacePercent(20f);
BarData data = new BarData(xVals, dataSet);
data.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float v) {
DecimalFormat format = new DecimalFormat("####");
return format.format(v);
}
});
chart.setData(data);
chart.invalidate();
}
private void updateStatusText() {
if (!mConnection.mBound || mConnection.mSdServer == null) {
statusTv.setText("Service: not connected");
statusTv.setBackgroundColor(Color.rgb(245, 158, 11));
return;
}
SdData sdData = mConnection.mSdServer.mSdData;
String dataSource = mConnection.mSdServer.mSdDataSourceName;
String watchStatus = (sdData != null && sdData.watchConnected) ? "connected" : "not connected";
statusTv.setText("Service: connected | Datasource: " + dataSource + " | Watch: " + watchStatus);
statusTv.setBackgroundColor(Color.rgb(37, 99, 235));
statusTv.setTextColor(Color.WHITE);
}
private void updateProgressText() {
int collectedSamples = sampleBuffer.size();
double elapsedSeconds = 0.0;
if (collecting && collectionStartMillis > 0) {
elapsedSeconds = (System.currentTimeMillis() - collectionStartMillis) / 1000.0;
} else if (lastAnalysisDurationSeconds > 0.0) {
elapsedSeconds = lastAnalysisDurationSeconds;
}
double estimatedRate = elapsedSeconds > 0.0 ? collectedSamples / elapsedSeconds : 0.0;
double averageBlockSeconds = collectedBlockCount > 0 ? elapsedSeconds / collectedBlockCount : 0.0;
progressTv.setText("Progress: "
+ String.format(Locale.UK, "%.1f", elapsedSeconds)
+ " / " + targetSeconds + " sec elapsed, "
+ collectedSamples + " samples, "
+ collectedBlockCount + " blocks, "
+ "estimated rate " + String.format(Locale.UK, "%.1f", estimatedRate)
+ " Hz, avg block " + String.format(Locale.UK, "%.2f", averageBlockSeconds)
+ " sec");
}
private void updateButtons() {
startButton.setEnabled(!collecting);
stopButton.setEnabled(collecting);
saveRawButton.setEnabled(lastResult != null);
saveSpectrumButton.setEnabled(lastResult != null);
}
private File getOutputDir() {
File dir = new File(getExternalFilesDir(null), "sensor_validation");
if (!dir.exists()) {
//noinspection ResultOfMethodCallIgnored
dir.mkdirs();
}
return dir;
}
private String timestampForFilename() {
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.UK).format(new Date());
}
private void saveRawCsv() {
if (lastResult == null) return;
File file = new File(getOutputDir(), "validation_raw_" + timestampForFilename() + ".csv");
try {
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
writer.write("sample_index,time_seconds,accel_magnitude\n");
for (int i = 0; i < lastResult.rawSamples.length; i++) {
double t = i / lastResult.sampleRateHz;
writer.write(i + "," + String.format(Locale.UK, "%.4f", t) + ","
+ String.format(Locale.UK, "%.6f", lastResult.rawSamples[i]) + "\n");
}
writer.close();
mUtil.showToast("Saved raw CSV: " + file.getAbsolutePath());
} catch (Exception e) {
Log.e(TAG, "Error saving raw CSV: " + e.toString());
mUtil.showToast("Error saving raw CSV: " + e.toString());
}
}
private void saveSpectrumCsv() {
if (lastResult == null) return;
File file = new File(getOutputDir(), "validation_spectrum_" + timestampForFilename() + ".csv");
try {
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
writer.write("expected_hz,measured_hz,error_hz,error_percent,tolerance_percent,pass,sample_rate_hz,sample_count,duration_seconds,block_count,average_block_seconds\n");
double averageBlockSeconds = collectedBlockCount > 0 ? lastResult.durationSeconds / collectedBlockCount : 0.0;
writer.write(String.format(Locale.UK, "%.6f,%.6f,%.6f,%.6f,%.6f,%s,%.6f,%d,%.6f,%d,%.6f\n",
lastResult.expectedHz,
lastResult.measuredHz,
lastResult.errorHz,
lastResult.errorPercent,
lastResult.tolerancePercent,
lastResult.pass ? "PASS" : "FAIL",
lastResult.sampleRateHz,
lastResult.sampleCount,
lastResult.durationSeconds,
collectedBlockCount,
averageBlockSeconds));
writer.write("\nfrequency_start_hz,frequency_end_hz,power\n");
for (FrequencyValidationAnalyzer.SpectrumBar bar : lastResult.displaySpectrum) {
writer.write(String.format(Locale.UK, "%.6f,%.6f,%.6f\n",
bar.freqStartHz, bar.freqEndHz, bar.power));
}
writer.close();
mUtil.showToast("Saved spectrum CSV: " + file.getAbsolutePath());
} catch (Exception e) {
Log.e(TAG, "Error saving spectrum CSV: " + e.toString());
mUtil.showToast("Error saving spectrum CSV: " + e.toString());
}
}
}

View File

@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f1f5f9"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sensor Frequency Validation"
android:textColor="#111827"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
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:textColor="#4b5563"
android:textSize="14sp" />
<TextView
android:id="@+id/validationStatusTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="#f59e0b"
android:padding="10dp"
android:text="Service: not connected"
android:textColor="#ffffff"
android:textSize="14sp" />
<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="Test setup"
android:textColor="#111827"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Expected frequency (Hz)" />
<EditText
android:id="@+id/validationExpectedHzEt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:text="3.0" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Pass tolerance (%)" />
<EditText
android:id="@+id/validationToleranceEt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:text="5.0" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Validation window" />
<Spinner
android:id="@+id/validationDurationSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/validationStartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start" />
<Button
android:id="@+id/validationStopButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Stop" />
</LinearLayout>
<TextView
android:id="@+id/validationProgressTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Progress: 0 / 750 samples"
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="Result"
android:textColor="#111827"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/validationResultTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="No test run yet."
android:textColor="#111827"
android:textSize="15sp" />
<com.github.mikephil.charting.charts.BarChart
android:id="@+id/validationChart"
android:layout_width="match_parent"
android:layout_height="320dp"
android:layout_marginTop="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/validationSaveRawButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Save Raw CSV" />
<Button
android:id="@+id/validationSaveSpectrumButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Save Spectrum CSV" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -100,6 +100,10 @@
android:icon="@drawable/ic_action_settings"
app:showAsAction="never|withText"
android:title="@string/settings" />
<item
android:id="@+id/action_sensor_validation"
android:title="Sensor Validation Test"
android:showAsAction="never|withText" />
</group>