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:
2026-08-04 15:58:01 +09:00
parent 1e3610ca0f
commit 1df1a4e6cd
6 changed files with 66 additions and 26 deletions
@@ -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: 응답에서 받음)