fix(logs): §A+B · scan_id 정합성 · events.jsonl 조인키 (LOG_STORAGE_ANALYSIS Priority 1)
배경 (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
This commit is contained in:
@@ -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<List<UShort>>,
|
||||
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()}")
|
||||
}
|
||||
|
||||
@@ -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<Int>, otsuThreshold: Double = 0.0) {
|
||||
val scanId = AdcCsvLogger.lastScanId
|
||||
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, 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")
|
||||
|
||||
@@ -28,14 +28,15 @@ object ImuCsvLogger {
|
||||
* 임상 모드 한정. ClinicalSessionStore.currentLogDir()가 있을 때만 저장.
|
||||
* samples를 oldest→newest 순서로 그대로 기록.
|
||||
*/
|
||||
fun log(samples: List<ImuSample>) {
|
||||
fun log(samples: List<ImuSample>, 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(",")
|
||||
|
||||
@@ -59,7 +59,8 @@ class MeasurementLogService private constructor(private val context: Context) {
|
||||
fun logMeasurement(
|
||||
volumeMl: Double?,
|
||||
channels: List<ChannelLogEntry>,
|
||||
rawADC: List<List<UShort>>
|
||||
rawADC: List<List<UShort>>,
|
||||
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: 응답에서 받음)
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-13
@@ -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에서 사용)
|
||||
|
||||
Reference in New Issue
Block a user