feat: 측정 가로모드 대응 + Ant Offset + Scan Log + 다중 sanity check
## 측정 화면 (PiezoMonitoringView)
- BoxWithConstraints + donutFitSize: 가로/작은 화면에서도 donut+Bladdy+buttons
모두 들어오도록 height-aware sizing (cap 120~360dp)
- nonDevSpacing: available height 기반 단계 (18/22/28/36) — 큰 화면 답답함 해소
- center 4 중 1개라도 missing → 즉시 realign prompt (이전: AutoMeasure 5회 누적)
- 6채널 중 3개 이상 missing 시 위치 이탈 알림 (모드 무관)
- 다이얼로그 문구: "Position Lost or Insufficient Gel"
- V41 결과 후처리 — walls를 PiezoEchoAnalyzer 기준으로 강제 (UI 차트와 일관)
· ant = analyzer.result.ant
· post = analyzer.result.lowEnd + 3
- CH3 secondary echo 잡힘 / CH4·CH5 lateral ant too-deep 문제 해결
- Bladdy 3-tap → Developer Mode toggle (측정 화면에서도)
## Placement 가이드 (PlacementGuideView)
- 가로 모드 대응: bodyW = min(w, h*0.7f) — 태블릿 가로에서 portrait 비율 유지
- Sensor 이미지를 Canvas drawImage 로 이동 — bladder oval 좌표계와 정확히 정렬
- Start Alignment 버튼: enabled = true 명시
## Settings overlay
- "Ant Offset" 숫자 입력 필드 (0~20mm, dev mode 전용)
· BV 계산 시 ant 를 입력값만큼 probe 쪽으로 당김 (chord 증가 → BV 증가)
· mm→samples 변환은 PiezoHW.distancePerSample
· V41 SphereFit BV 는 offset≠0 일 때 우회 → 6ch estimate
- "Version" row 추가 (BuildConfig.VERSION_NAME)
- Save 버튼: 실제 UserStorage 영구 저장 + state 재전파
· AppState.savePiezoSettingsNow() 추가
- 전체 overlay 에 verticalScroll + Card maxHeight 90% — dev 컨텐츠 길어도 Save 도달
## DPS 동기화 (긴급 수정)
설정의 DPS 변경이 V41Detector(Geometry/AnatomicalGate)에 반영 안 되던 문제:
- WdConfig.DPS_DEFAULT: const val → @Volatile @JvmField var
- PiezoHW.distancePerSample setter 가 WdConfig.DPS_DEFAULT 자동 동기화
- 시작 시점 1.968.also { WdConfig.DPS_DEFAULT = it } 로 두 값 일치
## 버전
- versionName: VBTAND0101 → 2026005-101
- versionCode: 2 → 3
## 신규 모듈
- services/ScanLogStore.kt — dev mode 진단 로그 (마지막 Single Scan summary)
· WALLS CSV (ch,ant,post,urine), DATA CSV (raw ADC), antOffsetMm
- ui/components/ScanLogDialog.kt — 클립보드 복사 다이얼로그
· xbench (CharlesKWONsLaw) MbbLogDialog 패턴 차용
· SelectionContainer + monospace + Copy/Clear/Close
- 측정 화면 dev panel 에 "Scan Log (N)" 버튼 추가
## 데이터 모델
- PiezoSettings.antOffsetMm: Int = 0 필드 추가
## 단위 테스트
- V41DetectorCH4Test.kt — 2026-05-06 floating-signal 캡쳐 6채널 raw 를
V41Detector.detect 에 통과시켜 결과 출력 (CH4 가짜 wall 진단용)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import com.example.medilightv2android.ble.PiezoChannelData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Holds the **last** Single Scan summary line so it can be inspected and
|
||||
* copied to the clipboard from the developer-mode log dialog. Each new
|
||||
* Single Scan replaces the previous entry. Format and clipboard interface
|
||||
* mirror the xbench WallCompareViewModel mbb log so the same downstream
|
||||
* parsers / spreadsheets work unchanged.
|
||||
*/
|
||||
object ScanLogStore {
|
||||
private val tsFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
private val _scanLog = MutableStateFlow<List<String>>(emptyList())
|
||||
val scanLog: StateFlow<List<String>> = _scanLog.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_scanLog.value = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored line with the latest Single Scan summary. If the
|
||||
* latest successful measurement produced anterior/posterior wall indices,
|
||||
* a CSV block is appended so the walls can be pasted into Excel / numpy.
|
||||
* `successfulWalls[ch] = (ant, post)` or `null` if that channel failed.
|
||||
*/
|
||||
fun appendScanSummary(
|
||||
attemptCount: Int,
|
||||
spotVolumes: List<Double>,
|
||||
trimmedMeanMl: Double?,
|
||||
successfulWalls: List<Pair<Int, Int>?>? = null,
|
||||
successfulChannels: List<PiezoChannelData>? = null,
|
||||
antOffsetMm: Int = 0,
|
||||
) {
|
||||
val ts = tsFmt.format(System.currentTimeMillis())
|
||||
val sb = StringBuilder()
|
||||
sb.append("[$ts] SUMMARY attempts=$attemptCount results=${spotVolumes.size}")
|
||||
if (trimmedMeanMl != null) {
|
||||
sb.append(" trimmedMean=${"%.1f".format(trimmedMeanMl)}ml")
|
||||
if (spotVolumes.isNotEmpty()) {
|
||||
sb.append(" values=[${spotVolumes.joinToString(", ") { "%.1f".format(it) }}]")
|
||||
}
|
||||
} else {
|
||||
sb.append(" ✗ insufficient results")
|
||||
}
|
||||
if (antOffsetMm != 0) {
|
||||
sb.append(" antOffset=-${antOffsetMm}mm")
|
||||
}
|
||||
if (successfulWalls != null) {
|
||||
sb.append("\n WALLS (CSV — ch,ant,post,urine):")
|
||||
successfulWalls.forEachIndexed { ch, w ->
|
||||
if (w != null) {
|
||||
sb.append("\n CH$ch,${w.first},${w.second},${w.second - w.first}")
|
||||
} else {
|
||||
sb.append("\n CH$ch,,,")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (successfulChannels != null) {
|
||||
sb.append("\n DATA (CSV — one row per channel, comma-separated samples):")
|
||||
successfulChannels.sortedBy { it.channel }.forEach { ch ->
|
||||
val s = ch.buffer
|
||||
val csv = if (s.isEmpty()) "—"
|
||||
else s.joinToString(",") { (it.toInt() and 0xFFFF).toString() }
|
||||
sb.append("\n CH${ch.channel}[${s.size}]: $csv")
|
||||
}
|
||||
}
|
||||
_scanLog.value = listOf(sb.toString())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user