fix: 분석 백그라운드 Thread, 그래프 빈칸 표시, CSV 세션별 분리
성능: - 측정 콜백 분석(TGC/Otsu/BV)을 백그라운드 Thread로 이동 - UI 업데이트만 handler.post로 메인 스레드 복귀 - 메인 스레드 블로킹으로 인한 BLE 패킷 드롭 방지 - 분석 소요시간 로그 출력 (Analysis took Xms) - Toast도 handler.post로 감싸서 Thread-safe Placement: - 6채널 그래프: 데이터 없는 채널도 "No data" 빈 칸 표시 (항상 6칸) CSV: - 일별 파일 → 세션별 파일 (YYYY-MM-DD_HHmmss.csv) - BLE 연결 시 자동 newSession() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -789,6 +789,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
if (txCharacteristic != null && rxCharacteristic != null) {
|
if (txCharacteristic != null && rxCharacteristic != null) {
|
||||||
isServiceReady.value = true
|
isServiceReady.value = true
|
||||||
|
com.example.medilightv2android.services.AdcCsvLogger.newSession()
|
||||||
startBatteryPolling()
|
startBatteryPolling()
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
sendDeviceInfoQuery()
|
sendDeviceInfoQuery()
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ADC 데이터를 일별 CSV 파일로 저장.
|
* ADC 데이터를 세션별 CSV 파일로 저장.
|
||||||
*
|
*
|
||||||
* 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD.csv
|
* 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv
|
||||||
* 행: scan_id × 6채널 (측정 1회 = 6행)
|
* newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능)
|
||||||
* 열: scan_id, timestamp, volume_ml, lr_ratio, channel, sample_0~sample_99
|
|
||||||
*/
|
*/
|
||||||
object AdcCsvLogger {
|
object AdcCsvLogger {
|
||||||
|
|
||||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||||
var lastScanId: Int = 0
|
var lastScanId: Int = 0
|
||||||
private set
|
private set
|
||||||
private var scanCounter = 0
|
private var scanCounter = 0
|
||||||
|
private var sessionFile: File? = null
|
||||||
|
|
||||||
private fun getDir(): File {
|
private fun getDir(): File {
|
||||||
val dir = File(
|
val dir = File(
|
||||||
@@ -29,24 +29,24 @@ object AdcCsvLogger {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTodayFile(): File {
|
fun newSession() {
|
||||||
return File(getDir(), "${dayFmt.format(Date())}.csv")
|
scanCounter = 0
|
||||||
|
lastScanId = 0
|
||||||
|
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFile(): File {
|
||||||
|
if (sessionFile == null) newSession()
|
||||||
|
return sessionFile!!
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 6채널 ADC 데이터를 CSV에 append.
|
|
||||||
*
|
|
||||||
* @param channels 6채널 ADC 버퍼 (List<List<UShort>>)
|
|
||||||
* @param volumeMl BV 추정 결과 (null이면 측정 실패)
|
|
||||||
* @param lrRatio lr_ratio 값
|
|
||||||
*/
|
|
||||||
fun log(
|
fun log(
|
||||||
channels: List<List<UShort>>,
|
channels: List<List<UShort>>,
|
||||||
volumeMl: Double? = null,
|
volumeMl: Double? = null,
|
||||||
lrRatio: Double = 1.0
|
lrRatio: Double = 1.0
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val file = getTodayFile()
|
val file = getFile()
|
||||||
val isNew = !file.exists() || file.length() == 0L
|
val isNew = !file.exists() || file.length() == 0L
|
||||||
scanCounter++
|
scanCounter++
|
||||||
lastScanId = scanCounter
|
lastScanId = scanCounter
|
||||||
@@ -58,7 +58,7 @@ object AdcCsvLogger {
|
|||||||
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
|
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
|
||||||
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp)
|
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp)
|
||||||
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
|
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
|
||||||
val sgStr = if (com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared.useSgFilter) "ON" else "OFF"
|
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
|
||||||
|
|
||||||
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -76,7 +76,6 @@ object AdcCsvLogger {
|
|||||||
for (sample in buffer) {
|
for (sample in buffer) {
|
||||||
append(",${sample.toInt()}")
|
append(",${sample.toInt()}")
|
||||||
}
|
}
|
||||||
// 100개 미만이면 빈 칸으로 채움
|
|
||||||
for (i in buffer.size until 100) {
|
for (i in buffer.size until 100) {
|
||||||
append(",")
|
append(",")
|
||||||
}
|
}
|
||||||
@@ -85,8 +84,6 @@ object AdcCsvLogger {
|
|||||||
writer.newLine()
|
writer.newLine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {}
|
||||||
// 로깅 실패는 앱 crash 방지
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-32
@@ -117,7 +117,9 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
Log.d("PiezoMonitor", "Sending measurement command...")
|
Log.d("PiezoMonitor", "Sending measurement command...")
|
||||||
|
|
||||||
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
|
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
|
||||||
handler.post {
|
// 분석을 백그라운드에서 수행 후 UI 업데이트만 메인으로
|
||||||
|
Thread {
|
||||||
|
val analysisStart = System.currentTimeMillis()
|
||||||
lastMultiChannelResult.clear()
|
lastMultiChannelResult.clear()
|
||||||
lastMultiChannelResult.addAll(channels)
|
lastMultiChannelResult.addAll(channels)
|
||||||
|
|
||||||
@@ -235,9 +237,9 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=$centerWallCount/4 total=$totalWallCount/6")
|
bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=$centerWallCount/4 total=$totalWallCount/6")
|
||||||
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null)
|
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null)
|
||||||
if (!isAutoMeasuring) {
|
if (!isAutoMeasuring) {
|
||||||
android.widget.Toast.makeText(context,
|
handler.post { android.widget.Toast.makeText(context,
|
||||||
"측정 부족 (${centerWallCount}/4채널) — 센서 위치를 조절해주세요",
|
"측정 부족 (${centerWallCount}/4채널) — 센서 위치를 조절해주세요",
|
||||||
android.widget.Toast.LENGTH_SHORT).show()
|
android.widget.Toast.LENGTH_SHORT).show() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -246,44 +248,51 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
val failADC = channels.sortedBy { it.channel }.map { it.buffer }
|
val failADC = channels.sortedBy { it.channel }.map { it.buffer }
|
||||||
com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null)
|
com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null)
|
||||||
if (!isAutoMeasuring) {
|
if (!isAutoMeasuring) {
|
||||||
android.widget.Toast.makeText(context,
|
handler.post { android.widget.Toast.makeText(context,
|
||||||
"방광이 감지되지 않았습니다. 센서 접촉을 확인해주세요.",
|
"방광이 감지되지 않았습니다. 센서 접촉을 확인해주세요.",
|
||||||
android.widget.Toast.LENGTH_SHORT).show()
|
android.widget.Toast.LENGTH_SHORT).show() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 볼륨 업데이트: Auto면 윈도우 trimmed mean (valid≥4만), Spot이면 즉시
|
val finalRawVol = rawVolumeMl
|
||||||
if (rawVolumeMl != null && rawVolumeMl > 0) {
|
val finalTotalWalls = totalWallCount
|
||||||
if (isAutoMeasuring) {
|
val analysisMs = System.currentTimeMillis() - analysisStart
|
||||||
if (totalWallCount >= 4) {
|
Log.d("PiezoMonitor", "Analysis took ${analysisMs}ms")
|
||||||
autoVolumeWindow.add(rawVolumeMl)
|
|
||||||
Log.d("PiezoMonitor", "AUTO window[${autoVolumeWindow.size}/$autoWindowSize] +${"%.1f".format(rawVolumeMl)}ml (valid=$totalWallCount)")
|
// UI 업데이트는 메인 스레드에서
|
||||||
|
handler.post {
|
||||||
|
if (finalRawVol != null && finalRawVol > 0) {
|
||||||
|
if (isAutoMeasuring) {
|
||||||
|
if (finalTotalWalls >= 4) {
|
||||||
|
autoVolumeWindow.add(finalRawVol)
|
||||||
|
Log.d("PiezoMonitor", "AUTO window[${autoVolumeWindow.size}/$autoWindowSize] +${"%.1f".format(finalRawVol)}ml (valid=$finalTotalWalls)")
|
||||||
|
} else {
|
||||||
|
Log.d("PiezoMonitor", "AUTO skip ${"%.1f".format(finalRawVol)}ml (valid=$finalTotalWalls < 4)")
|
||||||
|
}
|
||||||
|
if (autoVolumeWindow.size >= autoWindowSize) {
|
||||||
|
val displayVol = trimmedMean(autoVolumeWindow.toList())
|
||||||
|
lastVolumeMl = displayVol
|
||||||
|
maxVolumeMl = maxOf(maxVolumeMl, displayVol)
|
||||||
|
Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}")
|
||||||
|
autoVolumeWindow.clear()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d("PiezoMonitor", "AUTO skip ${"%.1f".format(rawVolumeMl)}ml (valid=$totalWallCount < 4)")
|
lastVolumeMl = finalRawVol
|
||||||
|
maxVolumeMl = maxOf(maxVolumeMl, finalRawVol)
|
||||||
}
|
}
|
||||||
if (autoVolumeWindow.size >= autoWindowSize) {
|
|
||||||
val displayVol = trimmedMean(autoVolumeWindow.toList())
|
|
||||||
lastVolumeMl = displayVol
|
|
||||||
maxVolumeMl = maxOf(maxVolumeMl, displayVol)
|
|
||||||
Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}")
|
|
||||||
autoVolumeWindow.clear()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastVolumeMl = rawVolumeMl
|
|
||||||
maxVolumeMl = maxOf(maxVolumeMl, rawVolumeMl)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (lastVolumeMl > 0) {
|
if (lastVolumeMl > 0) {
|
||||||
val maxVol = appState.piezoSettings.maxVolume.toDouble()
|
val maxVol = appState.piezoSettings.maxVolume.toDouble()
|
||||||
val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0
|
val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0
|
||||||
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
|
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
|
||||||
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
|
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
|
||||||
|
}
|
||||||
|
isMeasuring = false
|
||||||
|
measureRetryCount = 0
|
||||||
|
try { measureDoneSignal.complete(Unit) } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
isMeasuring = false
|
}.start()
|
||||||
measureRetryCount = 0
|
|
||||||
try { measureDoneSignal.complete(Unit) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
bleManager.piezoCollector.onLog = { msg -> Log.d("PiezoCollector", msg) }
|
bleManager.piezoCollector.onLog = { msg -> Log.d("PiezoCollector", msg) }
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -368,10 +368,12 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
Text("Echo Waveforms", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
Text("Echo Waveforms", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||||
for (chData in lastChannelData.filter { it.buffer.isNotEmpty() }) {
|
for (ch in 0 until 6) {
|
||||||
val detected = if (chData.channel < channelDetected.size) channelDetected[chData.channel] else false
|
val chData = lastChannelData.find { it.channel == ch }
|
||||||
val chAnalysis = analyzer.analyzeChannel(chData.buffer, chData.channel)
|
val detected = if (ch < channelDetected.size) channelDetected[ch] else false
|
||||||
val showWalls = chAnalysis.result
|
val showWalls = if (chData != null && chData.buffer.isNotEmpty()) {
|
||||||
|
analyzer.analyzeChannel(chData.buffer, ch).result
|
||||||
|
} else null
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
Box(
|
||||||
@@ -379,7 +381,7 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
.background(if (detected) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.4f))
|
.background(if (detected) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.4f))
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("CH${chData.channel}", fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
Text("CH$ch", fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||||
if (showWalls != null) {
|
if (showWalls != null) {
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -388,16 +390,23 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlacementWaveformChart(
|
if (chData != null && chData.buffer.isNotEmpty()) {
|
||||||
buffer = chData.buffer,
|
PlacementWaveformChart(
|
||||||
peakIndex = chData.peakIndex.toInt(),
|
buffer = chData.buffer,
|
||||||
analyzer = analyzer,
|
peakIndex = chData.peakIndex.toInt(),
|
||||||
antIndex = showWalls?.ant,
|
analyzer = analyzer,
|
||||||
postIndex = showWalls?.post,
|
antIndex = showWalls?.ant,
|
||||||
modifier = Modifier.fillMaxWidth().height(80.dp)
|
postIndex = showWalls?.post,
|
||||||
)
|
modifier = Modifier.fillMaxWidth().height(80.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(80.dp).background(Color.Gray.copy(alpha = 0.05f), RoundedCornerShape(4.dp)),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
Text("No data", fontSize = 10.sp, color = MlSecondaryText.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (chData.channel < lastChannelData.last().channel) {
|
if (ch < 5) {
|
||||||
HorizontalDivider(color = Color.Gray.copy(alpha = 0.2f))
|
HorizontalDivider(color = Color.Gray.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user