Added sensor validation option and function
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity2"
|
||||
android:exported="false" />
|
||||
<activity android:name=".SensorValidationActivity" />
|
||||
<!--<activity
|
||||
android:name=".MlModelManager"
|
||||
android:exported="false"
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
179
app/src/main/res/layout/activity_sensor_validation.xml
Normal file
179
app/src/main/res/layout/activity_sensor_validation.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user