From 1df1a4e6cdf4a4401431aaf2bad873feaf2493c3 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 4 Aug 2026 15:58:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(logs):=20=C2=A7A+B=20=C2=B7=20scan=5Fid=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=C2=B7=20events.jsonl=20=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=ED=82=A4=20(LOG=5FSTORAGE=5FANALYSIS=20Priority=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경 (LOG_STORAGE_ANALYSIS 2026-08-04): §A · ble.log 의 scan= 값이 실제 adc.csv scan_id 대비 1씩 밀림 (off-by-one) - 원인: measurementResult() 가 AdcCsvLogger.lastScanId 를 읽은 후에 AdcCsvLogger.log() 가 scanCounter 를 증가시켜 실제 CSV 는 N+1 로 기록. §B · events.jsonl 에 scan_id 필드 부재 → 4개 로그 (adc.csv · imu.csv · ble.log · events.jsonl) 간 조인 키 없음. timestamp 밀리초만으로는 autoScanIntervalMs=600ms 에서 신뢰 불가. §F · BV 실패 케이스가 events.jsonl 에 아무 흔적 없음. 수정: AdcCsvLogger: - nextScanId(): Int 신규 · thread-safe counter 증가 후 id 반환 - log(...): 새 scanId 파라미터 · 호출자가 미리 발급한 id 우선 사용 - counterLock 으로 동시성 방어 MeasurementLogService.logMeasurement(...): - scanId: Int? 파라미터 추가 · events.jsonl 에 "scan_id" 필드 기록 BleDebugLogger.measurementResult(...): - scanId: Int? 파라미터 · 호출자가 발급한 id 우선 (미제공 시 legacy fallback) ImuCsvLogger.log(...): - scanId: Int? 파라미터 · 호출자가 발급한 id 우선 PiezoMonitoringView (5 sites · onMultiChannelComplete): - 사이클 진입 시 val scanId = AdcCsvLogger.nextScanId() 미리 발급 - useV41Bv · useMethodDBv · effectiveCenterWalls>=4 · totalWallCount>0 · no-valid-channels 5경로 모두 scanId 전달 - §F: BV_SKIP · BV_FAIL 케이스도 events.jsonl 에 기록 (null volume) ClinicalLiveView (1 site): - autoCapture 시 val sid = AdcCsvLogger.nextScanId() → AdcCsvLogger + ImuCsvLogger 에 동일 id 전달 효과: ble.log · adc.csv · imu.csv · events.jsonl 이 scan_id 로 정합 조인 가능. Guardian 앱이 이벤트 재구성 시 timestamp 대신 scan_id 단일 키 사용. BV 실패도 audit trail 에 기록. 검증: BUILD SUCCESSFUL 8s --- .../vesiscan/services/AdcCsvLogger.kt | 30 +++++++++++++--- .../vesiscan/services/BleDebugLogger.kt | 8 +++-- .../vesiscan/services/ImuCsvLogger.kt | 7 ++-- .../services/MeasurementLogService.kt | 5 ++- .../ui/views/clinical/ClinicalLiveView.kt | 6 ++-- .../views/monitoring/PiezoMonitoringView.kt | 36 ++++++++++++------- 6 files changed, 66 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/services/AdcCsvLogger.kt b/app/src/main/java/com/medithings/vesiscan/services/AdcCsvLogger.kt index 64fbd2a..37e39fb 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/AdcCsvLogger.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/AdcCsvLogger.kt @@ -19,6 +19,21 @@ object AdcCsvLogger { private set private var scanCounter = 0 private var sessionFile: File? = null + private val counterLock = Any() + + /** + * 다음 scan_id 를 미리 발급 · scanCounter 증가. + * 2026-08-04: LOG_STORAGE_ANALYSIS §A off-by-one 수정 · 호출자가 미리 id 를 + * 확보한 뒤 measurementResult() · logMeasurement() · log() 셋 다 동일 id 로 + * 기록해 4개 로그 파일이 정합하게 조인됨. + */ + fun nextScanId(): Int { + synchronized(counterLock) { + scanCounter++ + lastScanId = scanCounter + return scanCounter + } + } private fun getDir(): File { val dir = File( @@ -49,16 +64,23 @@ object AdcCsvLogger { return sessionFile!! } + /** + * ADC CSV 기록. + * @param scanId 호출자가 nextScanId() 로 미리 발급한 id. null 이면 auto-increment (legacy). + */ fun log( channels: List>, volumeMl: Double? = null, - lrRatio: Double = 1.0 + lrRatio: Double = 1.0, + scanId: Int? = null, ) { try { val file = getFile() val isNew = !file.exists() || file.length() == 0L - scanCounter++ - lastScanId = scanCounter + val id = scanId ?: nextScanId() + // legacy: scanCounter 는 nextScanId 안에서만 증가 · id 미제공 시에도 동일 경로. + scanCounter = id + lastScanId = id val timestamp = timeFmt.format(Date()) val volStr = volumeMl?.let { "%.1f".format(it) } ?: "" @@ -85,7 +107,7 @@ object AdcCsvLogger { for ((chIdx, buffer) in channels.withIndex()) { val row = buildString { - append("$scanCounter,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx") + append("$id,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx") for (sample in buffer) { append(",${sample.toInt()}") } diff --git a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt index bb1d393..dbb11c1 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt @@ -198,10 +198,12 @@ class BleDebugLogger private constructor(private val context: Context) { info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)") } - fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List, otsuThreshold: Double = 0.0) { - val scanId = AdcCsvLogger.lastScanId + fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List, otsuThreshold: Double = 0.0, scanId: Int? = null) { + // 2026-08-04: LOG_STORAGE_ANALYSIS §A fix — 호출자가 명시적 scanId 전달 시 사용. + // 미제공 시 legacy fallback (AdcCsvLogger.lastScanId · off-by-one 이었던 원인). + val id = scanId ?: AdcCsvLogger.lastScanId val msg = buildString { - append("MEASUREMENT scan=$scanId") + append("MEASUREMENT scan=$id") append(" vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml") append(" lr=${"%.2f".format(lrRatio)}") append(" valid=$validChannels/6") diff --git a/app/src/main/java/com/medithings/vesiscan/services/ImuCsvLogger.kt b/app/src/main/java/com/medithings/vesiscan/services/ImuCsvLogger.kt index 897c764..43c1663 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/ImuCsvLogger.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/ImuCsvLogger.kt @@ -28,14 +28,15 @@ object ImuCsvLogger { * 임상 모드 한정. ClinicalSessionStore.currentLogDir()가 있을 때만 저장. * samples를 oldest→newest 순서로 그대로 기록. */ - fun log(samples: List) { + fun log(samples: List, scanId: Int? = null) { val dir = ClinicalSessionStore.currentLogDir() ?: return if (samples.isEmpty()) return try { dir.mkdirs() val file = File(dir, "imu.csv") val isNew = !file.exists() || file.length() == 0L - val scanId = AdcCsvLogger.lastScanId // piezo adc.csv와 매칭용 + // 2026-08-04 §A fix: 호출자가 미리 발급한 scanId 우선. 미제공 시 legacy. + val sid = scanId ?: AdcCsvLogger.lastScanId val timestamp = timeFmt.format(Date()) val clinicalLabel = ClinicalSessionStore.currentLabel() val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: "" @@ -48,7 +49,7 @@ object ImuCsvLogger { } for ((idx, s) in samples.withIndex()) { writer.write(buildString { - append("$scanId,$timestamp,$clinicalLabel,$fwVersion,$idx,") + append("$sid,$timestamp,$clinicalLabel,$fwVersion,$idx,") append("%.4f".format(s.ax)); append(",") append("%.4f".format(s.ay)); append(",") append("%.4f".format(s.az)); append(",") diff --git a/app/src/main/java/com/medithings/vesiscan/services/MeasurementLogService.kt b/app/src/main/java/com/medithings/vesiscan/services/MeasurementLogService.kt index d73a00c..cadc6ad 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/MeasurementLogService.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/MeasurementLogService.kt @@ -59,7 +59,8 @@ class MeasurementLogService private constructor(private val context: Context) { fun logMeasurement( volumeMl: Double?, channels: List, - rawADC: List> + rawADC: List>, + scanId: Int? = null, ) { val entry = JSONObject().apply { put("event", "measurement") @@ -67,6 +68,8 @@ class MeasurementLogService private constructor(private val context: Context) { put("device", Build.MODEL) put("app_version", appVersionName) put("app_version_code", appVersionCode) + // 2026-08-04: LOG_STORAGE_ANALYSIS §B fix — 4개 파일 조인 키. + scanId?.let { put("scan_id", it) } val clinical = ClinicalSessionStore.currentLabel() if (clinical.isNotEmpty()) put("clinical_label", clinical) // BLE 펌웨어 버전 (연결 시 mid?/rid: 응답에서 받음) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt index 1f3f951..e2f663f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt @@ -153,10 +153,12 @@ fun ClinicalLiveView(appState: AppState) { // 자동 저장: piezo + imu 묶어서 한 사이클로 누적 + 기존 CSV 저장 (재현용) val piezo = pendingPiezo if (autoCapture && piezo != null && ClinicalSessionStore.currentSession != null) { + // 2026-08-04 §A+B: scan_id 미리 발급 · adc.csv + imu.csv 동일 id 사용. + val sid = AdcCsvLogger.nextScanId() ClinicalSessionStore.addCycle(piezo, imuSamples) val rawADC = piezo.sortedBy { it.channel }.map { it.buffer } - AdcCsvLogger.log(rawADC, null) - ImuCsvLogger.log(imuSamples) + AdcCsvLogger.log(rawADC, null, scanId = sid) + ImuCsvLogger.log(imuSamples, scanId = sid) capturedCount++ } pendingPiezo = null diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 7331870..66e896a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -290,6 +290,10 @@ fun PiezoMonitoringView(appState: AppState) { val validChannels = channels.filter { it.isValid } val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6 + // 2026-08-04: LOG_STORAGE_ANALYSIS §A+B fix — scan_id 를 outer scope 에서 미리 발급. + // 이 사이클에서 발생할 모든 로그 (adc.csv · events.jsonl · ble.log · imu.csv) 가 + // 동일 id 로 정합하게 조인됨. else 브랜치 (no valid channels) 도 실패 로깅용 사용. + val scanId = com.medithings.vesiscan.services.AdcCsvLogger.nextScanId() if (validChannels.isNotEmpty() && allChannelsReceived) { val method = com.medithings.vesiscan.managers.GreenZoneConstants.detectionMethod val analyzerA = com.medithings.vesiscan.managers.PiezoEchoAnalyzerA.shared @@ -461,9 +465,9 @@ fun PiezoMonitoringView(appState: AppState) { rawVolumeMl = methodCBvMl!! Log.d("PiezoMonitor", "BV[C-V41]: ${"%.1f".format(methodCBvMl)}ml (SphereFit, walls=${totalWallCount}/6)") bleManager.debugLogger.measurementResult(methodCBvMl, 1.0, totalWallCount, - (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) - logService.logMeasurement(methodCBvMl, channelLogs, rawADC) - com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, methodCBvMl) + (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, scanId = scanId) + logService.logMeasurement(methodCBvMl, channelLogs, rawADC, scanId = scanId) + com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, methodCBvMl, scanId = scanId) } else if (useMethodDBv && methodDDets != null && effectiveCenterWalls >= 4) { // 2026-07-27 (D2 fix): Method D BV → estimateBv(walls) wrapper 로 라우팅. // Refined ant/post + span 전달 → wrapper 가 lumen_inset(0.15) + @@ -486,9 +490,9 @@ fun PiezoMonitoringView(appState: AppState) { "($mode, lr=${"%.2f".format(bvResult.lrRatio)}, valid=${bvResult.validChannels.size}/6)") bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, - otsuThreshold = analyzer.lastOtsuThreshold) - logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC) - com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) + otsuThreshold = analyzer.lastOtsuThreshold, scanId = scanId) + logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC, scanId = scanId) + com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio, scanId = scanId) } } else if (effectiveCenterWalls >= 4) { // 검증 로그 — ant offset 비활성(0)이 아닐 때 chord 변화 출력 @@ -511,20 +515,26 @@ fun PiezoMonitoringView(appState: AppState) { Log.d("PiezoMonitor", "BV[$methodLabel]: ${"%.1f".format(bvResult.volumeMl)}ml ($mode, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)") bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, - otsuThreshold = analyzer.lastOtsuThreshold) - logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC) - com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) + otsuThreshold = analyzer.lastOtsuThreshold, scanId = scanId) + logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC, scanId = scanId) + com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio, scanId = scanId) } } else if (totalWallCount > 0) { Log.w("PiezoMonitor", "No center channels: center=0 total=$totalWallCount/6") - bleManager.debugLogger.warn("BV_SKIP scan=${com.medithings.vesiscan.services.AdcCsvLogger.lastScanId} center=0 total=$totalWallCount/6") - com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, null) + bleManager.debugLogger.warn("BV_SKIP scan=$scanId center=0 total=$totalWallCount/6") + // 2026-08-04 §F: 실패 케이스도 events.jsonl 에 기록 (null volume · scan_id 로 조인). + logService.logMeasurement(null, channelLogs, rawADC, scanId = scanId) + com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, null, scanId = scanId) } } else { Log.d("PiezoMonitor", "No valid channels — volume unchanged") - bleManager.debugLogger.warn("BV_FAIL scan=${com.medithings.vesiscan.services.AdcCsvLogger.lastScanId} no valid channels") + bleManager.debugLogger.warn("BV_FAIL scan=$scanId no valid channels") val failADC = channels.sortedBy { it.channel }.map { it.buffer } - com.medithings.vesiscan.services.AdcCsvLogger.log(failADC, null) + // 2026-08-04 §F: BV 실패도 events.jsonl 에 기록 (validChannels 없어 channelLogs 비어있음). + val emptyChannelLogs = (0..5).map { com.medithings.vesiscan.services.ChannelLogEntry(channel = it) } + val failLogService = com.medithings.vesiscan.services.MeasurementLogService.getInstance(context) + failLogService.logMeasurement(null, emptyChannelLogs, failADC, scanId = scanId) + com.medithings.vesiscan.services.AdcCsvLogger.log(failADC, null, scanId = scanId) } // allWalls를 UI state에 저장 (ChannelPanel에서 사용)