refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan

사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).

## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
  (main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)

## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
  Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
@@ -0,0 +1,270 @@
package com.medithings.vesiscan
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.*
enum class AppScreen {
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
NIRS_CALIBRATION, PIEZO_PERSONALIZATION,
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
}
class AppState(private val context: Context) {
var currentScreen by mutableStateOf(AppScreen.ONBOARDING)
var user by mutableStateOf(User())
var sensorMode by mutableStateOf(SensorMode.PIEZO)
var bladderLevel by mutableStateOf(BladderLevel())
var isDeviceConnected by mutableStateOf(false)
var isDemoMode by mutableStateOf(false)
var isDevMode by mutableStateOf(false)
var piezoSettings by mutableStateOf(PiezoSettings())
var catheterCount = mutableIntStateOf(0)
// Placement 진입 경로 분기용 — Monitoring에서 재진입 시 true, Personalization에서 진입 시 false
var placementFromMonitoring by mutableStateOf(false)
private val storage = UserStorage.getInstance(context)
private val historyStore = MeasurementHistoryStore.getInstance(context)
private val notificationService = NotificationService.getInstance(context)
private val bleManager = BleManager.getInstance(context)
private val reminderService = ReminderService.getInstance(context)
init {
loadState()
}
fun loadState() {
user = storage.user
sensorMode = storage.sensorMode
bladderLevel = bladderLevel.copy(
currentLevel = storage.bladderLevel,
catheterizationThreshold = storage.catheterizationLevel
)
piezoSettings = storage.piezoSettings
catheterCount.intValue = storage.catheterCount
currentScreen = when {
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
!storage.isRegistered -> AppScreen.REGISTRATION
!storage.hasPin -> AppScreen.PIN_SETUP
else -> AppScreen.PIN_ENTRY
}
}
fun completeOnboarding() {
storage.hasCompletedOnboarding = true
storage.hasConsented = true
currentScreen = AppScreen.REGISTRATION
}
fun completeConsent() {
storage.hasConsented = true
storage.consentTimestamp = System.currentTimeMillis()
currentScreen = AppScreen.REGISTRATION
}
fun completeRegistration() {
storage.user = user
storage.isRegistered = true
currentScreen = AppScreen.PIN_SETUP
}
fun goHome() {
NirsManager.instance.stopStreaming()
NirsManager.instance.reset()
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
// 바로 PiezoMonitoring으로 진입할 수 있도록.
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
if (isDemoMode) isDeviceConnected = false
isDemoMode = false
currentScreen = AppScreen.HOME
}
fun enterDemoMode() {
isDemoMode = true
isDeviceConnected = true
currentScreen = if (sensorMode == SensorMode.NIRS) {
AppScreen.VIVAMYO_MONITORING
} else {
AppScreen.PIEZO_MONITORING
}
}
fun startFromHome() {
sensorMode = SensorMode.PIEZO
storage.sensorMode = SensorMode.PIEZO
// 회의 결정: 이미 연결된 상태이면 설정/Placement skip하고 바로 도넛차트.
// 연결되지 않은 상태에서만 DeviceScan부터 일련의 과정 진행.
currentScreen = if (isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.DEVICE_SCAN
}
fun selectSensor(mode: SensorMode) {
sensorMode = mode
storage.sensorMode = mode
currentScreen = AppScreen.DEVICE_SCAN
}
fun backToSensorSelect() {
currentScreen = AppScreen.HOME
}
fun deviceConnected() {
isDeviceConnected = true
// 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회)
if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow) {
currentScreen = AppScreen.CLINICAL_HOME
return
}
currentScreen = if (sensorMode == SensorMode.NIRS) {
AppScreen.NIRS_CALIBRATION
} else {
AppScreen.PIEZO_PERSONALIZATION
}
}
fun calibrationComplete() {
currentScreen = AppScreen.VIVAMYO_MONITORING
}
fun personalizationComplete() {
storage.piezoSettings = piezoSettings
placementFromMonitoring = false
// 회의 #8: AutoScanInterval을 Personalization 페이지에 통합 — 별도 화면 폐기.
currentScreen = AppScreen.PLACEMENT_GUIDE
}
/** Persist piezoSettings (called from in-session settings save). */
fun savePiezoSettingsNow() {
// Re-assign to trigger the mutableStateOf delegate so any composables
// observing piezoSettings recompose with the latest value, then persist
// to disk so the change survives process death.
piezoSettings = piezoSettings.copy()
storage.piezoSettings = piezoSettings
}
fun placementComplete() {
bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold)
storage.bladderLevel = 0
placementFromMonitoring = false
currentScreen = AppScreen.PIEZO_MONITORING
}
fun enterPlacementFromMonitoring() {
placementFromMonitoring = true
currentScreen = AppScreen.PLACEMENT_GUIDE
}
/** Clinical Mode alignment session 진입 — PlacementGuide 로 이동.
* ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */
fun enterAlignmentSession() {
placementFromMonitoring = false
currentScreen = AppScreen.PLACEMENT_GUIDE
}
fun backFromPlacementGuide() {
// 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은
// 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기).
currentScreen = if (placementFromMonitoring) AppScreen.PIEZO_MONITORING
else AppScreen.PIEZO_PERSONALIZATION
placementFromMonitoring = false
}
fun pinSet(pin: String) {
storage.pin = pin
storage.hasPin = true
currentScreen = AppScreen.HOME
}
fun pinVerified() {
currentScreen = AppScreen.HOME
}
fun incrementLevel() {
val previousUrgency = bladderLevel.urgency
val newLevel = bladderLevel.copy()
newLevel.increment()
bladderLevel = newLevel
storage.bladderLevel = bladderLevel.currentLevel
val record = MeasurementRecord(
eventType = MeasurementEventType.MEASUREMENT,
level = bladderLevel.currentLevel,
estimatedVolume = estimatedVolume,
urgencyLevel = bladderLevel.urgency,
fillPercentage = fillPercentage
)
historyStore.addRecord(record)
val newUrgency = bladderLevel.urgency
if (newUrgency != previousUrgency) {
notificationService.sendUrgencyNotification(newUrgency, estimatedVolume)
}
}
fun resetLevel() {
val record = MeasurementRecord(
eventType = MeasurementEventType.CATHETERIZATION,
level = bladderLevel.currentLevel,
estimatedVolume = estimatedVolume,
urgencyLevel = bladderLevel.urgency,
fillPercentage = fillPercentage
)
historyStore.addRecord(record)
val newLevel = bladderLevel.copy()
newLevel.reset()
bladderLevel = newLevel
storage.bladderLevel = 0
notificationService.cancelAll()
reminderService.reschedule()
if (catheterCount.intValue > 0) {
catheterCount.intValue--
storage.catheterCount = catheterCount.intValue
}
}
fun showHistory() {
currentScreen = AppScreen.MEASUREMENT_HISTORY
}
fun backToMonitoring() {
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
}
fun addCatheterCount(amount: Int = 1) {
catheterCount.intValue += amount
storage.catheterCount = catheterCount.intValue
}
fun updateCatheterizationThreshold(threshold: Int) {
bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold)
storage.catheterizationLevel = threshold
}
fun savePiezoSettings() {
storage.piezoSettings = piezoSettings
}
val estimatedVolume: Int
get() {
if (BladderLevel.MAX_LEVEL <= 0) return 0
return (bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * piezoSettings.maxVolume).toInt()
}
val fillPercentage: Double
get() {
if (BladderLevel.MAX_LEVEL <= 0) return 0.0
return bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * 100.0
}
}
@@ -0,0 +1,183 @@
package com.medithings.vesiscan
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.rive.runtime.kotlin.core.Rive
import com.medithings.vesiscan.services.KeychainService
import com.medithings.vesiscan.services.NotificationService
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ui.theme.Medilightv2androidTheme
import com.medithings.vesiscan.ui.views.calibration.CalibrationView
import com.medithings.vesiscan.ui.views.connection.DeviceScanView
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
import com.medithings.vesiscan.ui.views.home.HomeView
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
import com.medithings.vesiscan.ui.views.monitoring.VivamyoMonitoringView
import com.medithings.vesiscan.ui.views.monitoring.UrineCameraScreen
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
import com.medithings.vesiscan.ui.views.personalization.PiezoPersonalizationView
import com.medithings.vesiscan.ui.views.pin.PinMode
import com.medithings.vesiscan.ui.views.pin.PinView
import com.medithings.vesiscan.ui.views.registration.RegistrationView
import com.medithings.vesiscan.ui.views.consent.ConsentView
import com.medithings.vesiscan.ui.views.reminder.ReminderSettingsView
import com.medithings.vesiscan.ui.views.sensorselect.SensorSelectView
import com.medithings.vesiscan.ui.views.clinical.ClinicalHomeView
import com.medithings.vesiscan.ui.views.clinical.ClinicalLiveView
class MainActivity : ComponentActivity() {
/** 사용자가 선택한 앱 언어 적용 — Activity 생성 전에 Locale wrap. */
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(com.medithings.vesiscan.services.LocalePref.wrap(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize services
Rive.init(this)
KeychainService.init(this)
NotificationService.getInstance(this)
enableEdgeToEdge()
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
Medilightv2androidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
MedilightApp(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun MedilightApp(modifier: Modifier = Modifier) {
val context = LocalContext.current
val appState = remember { AppState(context) }
// HOME에서 뒤로가기 → 앱 종료 확인
var showExitDialog by remember { mutableStateOf(false) }
BackHandler(enabled = appState.currentScreen == AppScreen.HOME) {
showExitDialog = true
}
if (showExitDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { androidx.compose.material3.Text(stringResource(R.string.exit_app_title)) },
text = { androidx.compose.material3.Text(stringResource(R.string.exit_app_message)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { (context as? android.app.Activity)?.finish() }) {
androidx.compose.material3.Text(stringResource(R.string.exit_app_confirm), color = Color.Red)
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showExitDialog = false }) {
androidx.compose.material3.Text(stringResource(R.string.cancel))
}
}
)
}
// 다른 화면에서 뒤로가기 = 바로 전 화면
BackHandler(enabled = appState.currentScreen != AppScreen.HOME
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
when (appState.currentScreen) {
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
// DeviceScan Back: 임상 흐름이면 ClinicalHome으로, 아니면 HOME
AppScreen.DEVICE_SCAN ->
appState.currentScreen =
if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow)
AppScreen.CLINICAL_HOME
else AppScreen.HOME
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
// 임상 모드 측정 중이면 endMeasurement → ClinicalHome 복귀
AppScreen.PLACEMENT_GUIDE ->
if (com.medithings.vesiscan.services.ClinicalSessionStore.currentSession != null) {
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
} else appState.backFromPlacementGuide()
AppScreen.PIEZO_MONITORING ->
if (com.medithings.vesiscan.services.ClinicalSessionStore.currentSession != null) {
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
} else appState.currentScreen = AppScreen.PLACEMENT_GUIDE
AppScreen.CLINICAL_HOME -> {
com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow = false
appState.currentScreen = AppScreen.HOME
}
AppScreen.CLINICAL_LIVE -> {
// 측정 폴더 finalize 후 ClinicalHome 복귀
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
}
// 모니터링 하위 화면 → 모니터링
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING
// 기타
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
AppScreen.VIVAMYO_MONITORING -> appState.currentScreen = AppScreen.HOME
AppScreen.NIRS_CALIBRATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
else -> appState.currentScreen = AppScreen.HOME
}
}
Crossfade(
targetState = appState.currentScreen,
animationSpec = tween(300),
modifier = modifier,
label = "screen"
) { screen ->
when (screen) {
AppScreen.ONBOARDING -> OnboardingView(appState)
AppScreen.CONSENT -> ConsentView(appState)
AppScreen.REGISTRATION -> RegistrationView(appState)
AppScreen.HOME -> HomeView(appState)
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
AppScreen.URINE_CAMERA -> UrineCameraScreen(
onDone = { volume ->
if (volume != null && volume > 0) appState.resetLevel()
appState.currentScreen = AppScreen.PIEZO_MONITORING
},
onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING }
)
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
onBack = { appState.backToMonitoring() }
)
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
package com.medithings.vesiscan.ble
object CRC16 {
private const val POLYNOMIAL: Int = 0x1021
private const val INITIAL_VALUE: Int = 0xFFFF
fun calculate(data: ByteArray): Int {
var crc = INITIAL_VALUE
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
for (j in 0 until 8) {
crc = if ((crc and 0x8000) != 0) {
(crc shl 1) xor POLYNOMIAL
} else {
crc shl 1
}
crc = crc and 0xFFFF
}
}
return crc
}
fun calculate(string: String): Int = calculate(string.toByteArray(Charsets.UTF_8))
fun appendCRC(data: ByteArray): ByteArray {
val crc = calculate(data)
return data + byteArrayOf(
(crc and 0xFF).toByte(), // Low byte first (Little Endian)
((crc shr 8) and 0xFF).toByte() // High byte
)
}
fun appendCRC(string: String): ByteArray = appendCRC(string.toByteArray(Charsets.UTF_8))
fun verify(data: ByteArray): Boolean {
if (data.size < 2) return false
val payload = data.copyOfRange(0, data.size - 2)
val receivedCRC = (data[data.size - 2].toInt() and 0xFF) or
((data[data.size - 1].toInt() and 0xFF) shl 8)
return calculate(payload) == receivedCRC
}
fun buildCommand(cmd: String, params: IntArray = intArrayOf()): ByteArray {
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
val paramBytes = ByteArray(params.size * 2)
for (i in params.indices) {
paramBytes[i * 2] = (params[i] and 0xFF).toByte() // Low byte
paramBytes[i * 2 + 1] = ((params[i] shr 8) and 0xFF).toByte() // High byte
}
return appendCRC(cmdBytes + paramBytes)
}
fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray {
return appendCRC("$cmd?$asciiParams".toByteArray(Charsets.UTF_8))
}
/** 파라미터를 16비트 Big Endian으로 추가 (펌웨어 테스터와 동일 포맷) */
fun buildCommandBE(cmd: String, params: IntArray = intArrayOf()): ByteArray {
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
val paramBytes = ByteArray(params.size * 2)
for (i in params.indices) {
paramBytes[i * 2] = ((params[i] shr 8) and 0xFF).toByte() // High byte first
paramBytes[i * 2 + 1] = (params[i] and 0xFF).toByte() // Low byte
}
return appendCRC(cmdBytes + paramBytes)
}
fun buildSimpleCommand(cmd: String): ByteArray {
return appendCRC(cmd.toByteArray(Charsets.UTF_8))
}
}
@@ -0,0 +1,186 @@
package com.medithings.vesiscan.ble
/**
* IMU 6축 raw 샘플 (Accel xyz + Gyro xyz).
*
* FW VBTFW0116+ rim: 응답 한 패킷에 15 샘플 (ODR 50Hz → 약 300ms 분량).
* Sensitivity:
* - Accel ±4g: 1 LSB = 1/8192 g
* - Gyro ±500 dps: 1 LSB = 1/65.536 dps
*/
data class ImuSample(
val ax: Float, val ay: Float, val az: Float, // g 단위 (중력 가속도)
val gx: Float, val gy: Float, val gz: Float // dps 단위 (degrees/sec)
) {
/** 전체 가속도 크기 — 정지 시 ≈ 1g */
val accelMagnitude: Float get() = kotlin.math.sqrt(ax * ax + ay * ay + az * az)
/** Gyro 전체 크기 — 정지 시 ≈ 0 dps */
val gyroMagnitude: Float get() = kotlin.math.sqrt(gx * gx + gy * gy + gz * gz)
}
/**
* rim: 패킷 파서.
*
* 패킷 구조 (총 188B):
* [tag 4B "rim:"] [num_sample 2B BE] [imu_raw_data 180B] [crc 2B]
*
* imu_raw_data = 15 samples × 12B/sample, 모두 BE int16:
* [Ax 2B] [Ay 2B] [Az 2B] [Gx 2B] [Gy 2B] [Gz 2B]
*
* 샘플 시간 순서: samples[0] = oldest (FIFO 순), samples[N-1] = newest.
* 실측 검증 완료 (Az ≈ +1g 평평 자세, 2026-05-27).
*/
class ImuPacketCollector {
var samples: List<ImuSample> = emptyList()
private set
var onComplete: ((List<ImuSample>) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
companion object {
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR
private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
private const val SAMPLE_SIZE_BYTES = 12
private const val HEADER_BYTES = 6 // tag(4) + num(2)
private const val CRC_BYTES = 2
}
fun reset() {
samples = emptyList()
}
/**
* rsp: 패킷 처리 — msp 명령 응답.
* Format 가정 (firmware spec 미확정 — defensive parsing):
* 변형 1 (multi-sample, rim: 와 동일): [tag 4B][num 2B BE][N×12B sample][crc 2B]
* 변형 2 (single-sample): [tag 4B][12B sample][crc 2B] = 18B
*
* 시도 순서: 변형 1 → 실패 시 변형 2 → 둘 다 실패 시 hex log + 무시.
*/
fun parseRsp(data: ByteArray) {
if (data.size < 4 + CRC_BYTES) {
onLog?.invoke("rsp: too short (${data.size}B)")
return
}
// 변형 1 시도 (rim: 와 동일 format)
val num1 = readBe16(data, 4)
val expected1 = HEADER_BYTES + num1 * SAMPLE_SIZE_BYTES + CRC_BYTES
if (num1 in 1..15 && data.size >= expected1) {
parseMultiSample(data, num1)
return
}
// 변형 2 시도 (single sample, 4B tag + 12B sample + 2B crc = 18B)
if (data.size >= 18) {
val parsed = ArrayList<ImuSample>(1)
val off = 4
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
samples = parsed
val first = parsed[0]
onLog?.invoke("rsp: 1 sample (single fmt, Az=${"%.3f".format(first.az)}g)")
onComplete?.invoke(parsed)
return
}
// 둘 다 실패 — hex dump
val hex = data.take(kotlin.math.min(data.size, 24)).joinToString(" ") { "%02X".format(it) }
onLog?.invoke("rsp: unknown format (${data.size}B) hex=$hex")
}
private fun parseMultiSample(data: ByteArray, numSamples: Int) {
val parsed = ArrayList<ImuSample>(numSamples)
for (i in 0 until numSamples) {
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
}
samples = parsed
onLog?.invoke("rsp: $numSamples samples (multi fmt, last Az=${"%.3f".format(parsed.last().az)}g)")
onComplete?.invoke(parsed)
}
/** rim: 패킷 한 번 받으면 호출. 188B 전체 ByteArray. */
fun parseRim(data: ByteArray) {
if (data.size < HEADER_BYTES + CRC_BYTES) {
onLog?.invoke("rim: too short (${data.size}B)")
return
}
// RAW DUMP — division 전 원본 byte + int16 값 logcat 으로 출력 (디버그용).
val totalHex = data.joinToString(" ") { "%02X".format(it) }
android.util.Log.d("ImuRaw", "rim FULL HEX (${data.size}B): $totalHex")
val numSamples = readBe16(data, 4)
val payloadBytes = data.size - HEADER_BYTES - CRC_BYTES
val expectedBytes = numSamples * SAMPLE_SIZE_BYTES
if (payloadBytes < expectedBytes) {
onLog?.invoke("rim: payload short ($payloadBytes < $expectedBytes for $numSamples samples)")
return
}
val parsed = ArrayList<ImuSample>(numSamples)
for (i in 0 until numSamples) {
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
// 첫 sample / 마지막 sample 만 raw int16 dump (모든 sample 로깅하면 폭주)
if (i == 0 || i == numSamples - 1) {
android.util.Log.d("ImuRaw",
"rim sample[$i] raw int16: ax=$axRaw ay=$ayRaw az=$azRaw " +
"gx=$gxRaw gy=$gyRaw gz=$gzRaw " +
"(g if /8192: ax=${"%.4f".format(axRaw / 8192f)} ay=${"%.4f".format(ayRaw / 8192f)} az=${"%.4f".format(azRaw / 8192f)})")
}
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
}
samples = parsed
onLog?.invoke("rim: $numSamples samples parsed (last: Az=${"%.3f".format(parsed.last().az)}g)")
onComplete?.invoke(parsed)
}
private fun readBe16(data: ByteArray, idx: Int): Int {
val hi = data[idx].toInt() and 0xFF
val lo = data[idx + 1].toInt() and 0xFF
return (hi shl 8) or lo
}
private fun readBe16Signed(data: ByteArray, idx: Int): Int {
var v = readBe16(data, idx)
if (v >= 0x8000) v -= 0x10000
return v
}
}
@@ -0,0 +1,314 @@
package com.medithings.vesiscan.ble
/**
* Collects multi-packet Piezo echo responses: reb: → (red:) → raa:
*
* NEW packet layout (FW update — VBT post-2026-06):
* reb: [tag 4B][ch_info 2B = ch_session 1B + ch_num 1B][num_sample 2B][adc 200B][crc 2B] = 210B
* raa: [tag 4B][state 2B][crc 2B] = 8B (set 종료 마커, 변동 없음)
*
* Channel order is determined by the ch_num field (not arrival order). All 6 reb packets
* within the same ch_session form one measurement set. If ch_session changes mid-set or
* a duplicate ch_num arrives, the entire set is dropped and only the next valid set
* triggers onMultiChannelComplete.
*
* Big Endian / Little Endian: VBT FW = BE. Endian is detected from the first ADC sample of
* ch_num=0 and locked for the remaining channels in that set.
*/
class PiezoPacketCollector {
// Header metadata (last received reb 기준 — 호환용)
var peakRaw: UShort = 0u; private set
var peakIndex: UShort = 0u; private set
var baselineRaw: UShort = 0u; private set
var numSamples: UShort = 0u; private set
var totalPackets: Int = 0; private set
// Collection state
var currentPacket: Int = 0; private set
var isComplete: Boolean = false; private set
var hasError: Boolean = false; private set
var errorMessage: String = ""; private set
private var rawData = mutableListOf<Byte>() // 단일 채널 모드용
private var hasFirstPacket = false // 단일 채널 모드용
private var detectedBigEndian = false
var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정
// Multi-channel + session tracking
var isMultiChannel: Boolean = false; private set
var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set
private var currentSetSession: Int? = null
var isSetDropped: Boolean = false; private set
// Callbacks
var onComplete: ((PiezoEchoResult) -> Unit)? = null
var onMultiChannelComplete: ((List<PiezoChannelData>) -> Unit)? = null
/** true 면 다음 onMultiChannelComplete 1회 skip (자동 reset).
* posture polling 으로 mtb 보낼 때 측정 callback 가 spurious fire 되는 거 차단용. */
var skipNextComplete: Boolean = false
var onLog: ((String) -> Unit)? = null
private var recursionDepth = 0
fun reset() {
peakRaw = 0u; peakIndex = 0u; baselineRaw = 0u; numSamples = 0u
totalPackets = 0; currentPacket = 0
isComplete = false; hasError = false; errorMessage = ""
rawData.clear(); hasFirstPacket = false
isMultiChannel = false
channelResults.clear(); detectedBigEndian = false
currentSetSession = null
isSetDropped = false
}
fun startMultiChannel(channelCount: Int = 6) {
reset()
isMultiChannel = true
channelResults = MutableList(channelCount) { null }
}
fun addPacket(data: ByteArray) {
if (isComplete || hasError) return
if (data.size < 4) return
recursionDepth++
if (recursionDepth >= 10) {
markError("Max recursion depth"); recursionDepth--; return
}
val prefix = String(data, 0, 4, Charsets.US_ASCII)
when (prefix) {
"rbb:" -> {
onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:")
}
"reb:" -> processRebPacket(data)
"red:" -> processRedPacket(data)
"ree:", "raa:" -> processEndPacket(prefix, data)
}
recursionDepth--
}
// reb: Data packet (NEW format, FW post-2026-06)
// Structure: reb:(4) + ch_session(1) + ch_num(1) + num_sample(2, BE/LE) + 100 ADC(200) + CRC(2) = 210B
private fun processRebPacket(bytes: ByteArray) {
if (bytes.size < 10) { markError("reb: too short (${bytes.size}, expected ≥10)"); return }
val chSession = bytes[4].toInt() and 0xFF
val chNum = bytes[5].toInt() and 0xFF
if (isMultiChannel) {
// Session tracking — drop previous incomplete set when session changes
when {
currentSetSession == null -> {
currentSetSession = chSession
isSetDropped = false
channelResults = MutableList(channelResults.size) { null }
}
chSession != currentSetSession -> {
onLog?.invoke("reb: session change (was=$currentSetSession, got=$chSession) — drop previous incomplete set")
currentSetSession = chSession
isSetDropped = false
channelResults = MutableList(channelResults.size) { null }
}
}
if (chNum !in channelResults.indices) {
onLog?.invoke("reb: invalid ch_num=$chNum (range 0..${channelResults.size - 1}) — set marked dropped")
isSetDropped = true
return
}
if (channelResults[chNum] != null) {
onLog?.invoke("reb: duplicate ch_num=$chNum in session=$chSession — set marked dropped")
isSetDropped = true
return
}
}
// Endian detection — first ADC sample at bytes[8..9]. Lock for the remaining channels.
// VBT = Big Endian, 2025MEDIP = Little Endian. Fallback: forceBigEndian flag.
val isFirstChannelOfSet = if (isMultiChannel) (chNum == 0) else (!hasFirstPacket)
if (isFirstChannelOfSet) {
val b8 = bytes.getOrElse(8) { 0 }.toInt() and 0xFF
val b9 = bytes.getOrElse(9) { 0 }.toInt() and 0xFF
val firstSampleLE = b8 or (b9 shl 8)
val firstSampleBE = (b8 shl 8) or b9
detectedBigEndian = when {
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false
else -> forceBigEndian
}
onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)")
}
// num_sample at bytes[6..7]
val b6 = bytes[6].toInt() and 0xFF
val b7 = bytes[7].toInt() and 0xFF
val repeatCount = if (detectedBigEndian) (b6 shl 8) or b7 else b6 or (b7 shl 8)
// ADC data at bytes[8..size-3] (exclude trailing CRC 2B)
val adcStart = 8
val adcEnd = bytes.size - 2
if (adcEnd <= adcStart) { markError("reb: no ADC data"); return }
val sampleCount = (adcEnd - adcStart) / 2
if (isMultiChannel) {
// Parse this channel's ADC directly into a buffer and store at ch_num position
val buffer = parseADCBufferRange(bytes, adcStart, adcEnd)
channelResults[chNum] = PiezoChannelData(chNum, buffer)
totalPackets++
peakRaw = buffer.maxOrNull() ?: 0u
peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
baselineRaw = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
numSamples = sampleCount.toUShort()
val endianStr = if (detectedBigEndian) "BE" else "LE"
onLog?.invoke("reb: session=$chSession CH$chNum [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex")
} else {
// Single-channel mode — buffer to rawData, finalize on ree:/raa:
currentPacket = 0
hasFirstPacket = true
rawData.clear()
for (i in adcStart until adcEnd) rawData.add(bytes[i])
val buffer = parseADCBuffer()
peakRaw = buffer.maxOrNull() ?: 0u
peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
baselineRaw = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
numSamples = sampleCount.toUShort()
totalPackets = 1
val endianStr = if (detectedBigEndian) "BE" else "LE"
onLog?.invoke("reb: single [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex")
}
}
private fun parseADCBufferRange(bytes: ByteArray, start: Int, end: Int): List<UShort> {
val buffer = mutableListOf<UShort>()
var i = start
while (i + 1 < end) {
val b0 = bytes[i].toInt() and 0xFF
val b1 = bytes[i + 1].toInt() and 0xFF
val value: UShort = if (detectedBigEndian) {
((b0 shl 8) or b1).toUShort()
} else {
(b0 or (b1 shl 8)).toUShort()
}
buffer.add(value)
i += 2
}
return buffer
}
// red: continuation packet (legacy — 신구조에서는 210B reb 한 번에 들어와 사실상 미사용).
// multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리.
private fun processRedPacket(bytes: ByteArray) {
if (isMultiChannel) {
onLog?.invoke("red: ignored in multi-channel mode (ch_num-indexed set)")
return
}
if (!hasFirstPacket) { markError("red: before reb:"); return }
if (bytes.size < 6) return
for (i in 6 until bytes.size) rawData.add(bytes[i])
currentPacket++
}
private fun processEndPacket(prefix: String, bytes: ByteArray) {
if (isMultiChannel && prefix == "raa:") {
// Set 종료 마커 — 완전성 검사 후 콜백 결정
isComplete = true
when {
isSetDropped -> {
onLog?.invoke("raa: set dropped (session=$currentSetSession) — no callback")
}
channelResults.any { it == null } -> {
val missing = channelResults.mapIndexedNotNull { i, c -> if (c == null) i else null }
onLog?.invoke("raa: incomplete set (session=$currentSetSession, missing CH=$missing) — drop")
}
else -> {
val validChannels = channelResults.filterNotNull()
onLog?.invoke("raa: session=$currentSetSession complete — ${validChannels.size} channels")
if (skipNextComplete) {
skipNextComplete = false
onLog?.invoke("raa: skip onComplete (posture poll)")
} else {
onMultiChannelComplete?.invoke(validChannels)
}
}
}
// 다음 set을 위해 reset (isMultiChannel/onComplete 등 유지)
currentSetSession = null
isSetDropped = false
channelResults = MutableList(channelResults.size) { null }
} else if (isMultiChannel) {
// ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시.
onLog?.invoke("ree: ignored in multi-channel mode")
} else {
// Single-channel mode 완료 (ree: 또는 raa:)
finishSingleChannel()
}
}
private fun finishSingleChannel() {
isComplete = true
val buffer = parseADCBuffer()
val peak = buffer.maxOrNull() ?: 0u
val peakIdx = buffer.indexOf(peak).coerceAtLeast(0).toUShort()
val base: UShort = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
val result = PiezoEchoResult(0, buffer, peak, peakIdx, base, buffer.size, rawData.size)
onLog?.invoke("Complete: ${buffer.size} samples, peak=$peak @ idx $peakIdx")
onComplete?.invoke(result)
}
private fun parseADCBuffer(): List<UShort> {
val buffer = mutableListOf<UShort>()
var i = 0
while (i + 1 < rawData.size) {
val b0 = rawData[i].toInt() and 0xFF
val b1 = rawData[i + 1].toInt() and 0xFF
val value: UShort = if (detectedBigEndian) {
((b0 shl 8) or b1).toUShort()
} else {
(b0 or (b1 shl 8)).toUShort()
}
buffer.add(value)
i += 2
}
return buffer
}
private fun markError(msg: String) {
hasError = true; errorMessage = msg
onLog?.invoke("ERROR: $msg")
}
}
// Data Models
data class PiezoEchoResult(
val channel: Int,
val buffer: List<UShort>,
val peakRaw: UShort,
val peakIndex: UShort,
val baselineRaw: UShort,
val numSamples: Int,
val rawByteCount: Int
)
data class PiezoChannelData(
val channel: Int,
val buffer: List<UShort>
) {
val peakRaw: UShort get() = buffer.maxOrNull() ?: 0u
val peakIndex: UShort get() = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
val baselineRaw: UShort get() = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
val numSamples: Int get() = buffer.size
val isValid: Boolean get() = buffer.size >= 20 && peakRaw > 200u && peakRaw < 4090u
}
@@ -0,0 +1,614 @@
/*
* Alignment Advisor V2 — port of vesiscan_test/alignment.py.
*
* 새 알고리즘 (한 팀장 / 대표님 회의 결정사항):
* ─ method_d 기반 urine_len 으로 정렬 판단
* ─ 2-phase: V (CH3 검출 → 머리방향) → L (CH4/CH5 urine_len 균형)
* ─ |u4 − u5| ≤ LAT_TOL(8 samples ≈ 16mm) 이면 STOP
* ─ ulen 큰 쪽으로 이동 (= 큰 쪽이 방광 중심)
* ─ 둘 다 미검출 → 좌우 PROBE 모드 (hill-climbing 으로 최대점 추적)
*
* 입력은 raw 6채널 (각 길이 100, UShort/Int 어느 쪽이든 toDouble() 가능).
* RollingAligner 가 N=accumK(default 10) 프레임 평균 후 한 번씩 commit.
*
* UI 측에서는 `push(raw6)` 호출 → AdvisorState.action 확인 → directionIcon/Hint
* 매핑. 위치 이동 직후엔 `reset()` 으로 누적 버퍼 비우기 권장.
*
* 기존 PlacementGuide computePlacementGuide(...) 와 병행 — V1/V2 토글로 분기.
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.R
import com.medithings.vesiscan.walldetect.MethodDResult
import com.medithings.vesiscan.walldetect.MethodDRunner
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
import kotlin.math.abs
import kotlin.math.max
/** action 상수. MOVE_DOWN 은 4-stage guide 의 set 회복 (잃은 center channel 다시 찾기) 용. */
enum class AlignAction { MOVE_UP, MOVE_DOWN, MOVE_LEFT, MOVE_RIGHT, PROBE_LR, STOP }
/** RollingAligner.push() 결과. */
data class AdvisorState(
val state: String, // 'accum' 또는 'commit'
val action: AlignAction?,
val msg: String,
/** Localized message string resource id (null if no localized message). */
val msgKey: Int? = null,
/** Args for msgKey (formatted into string). */
val msgArgs: List<Any> = emptyList(),
val dets: List<MethodDResult?>?,
val summary: AlignSummary?,
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
val accumTarget: Int = 0,
)
data class AlignSummary(
val ch3: Boolean,
val u4: Int?,
val u5: Int?,
val chN: Int,
/** Per-channel detection (size 6). center=[0,1,2,3], lateral=[4,5]. */
val detected: BooleanArray = BooleanArray(6),
/** 2026-07-02: 각 채널 chord (angle-corrected mm). CENTER_OPTIMIZE CV 계산용.
* chord[ch] = (post - ant) × cos(angle) × distancePerSample. null 이면 미검출. */
val chords: Array<Double?> = Array(6) { null },
)
object AlignmentConstants {
const val CH3 = 3 // 치골쪽 최하단 center 채널
const val LAT_TOL = 8 // 좌우 균형 허용 |u4-u5| (samples, ≈16mm)
val CENTER_CH = intArrayOf(0, 1, 2, 3)
val LATERAL_CH = intArrayOf(4, 5)
// 02bed02 (4494098 VBTWD201): cross-channel ant tie-break — config_6ch.py 매칭.
/** Cross-channel 전벽 tie-break: top1/top2 score 비율 < TIEBREAK_RATIO 이면 동률 → 합의 z 로 선택.
* 1.3 = 명확한 동률(top1/top2 1.1~1.2)만 잡고 압승(>=1.5)은 측정값 보존. */
const val ANT_TIEBREAK_RATIO = 1.3
/** Near-field 가드: ant 가 이웃 합의 z 보다 이 값 이상 "얕은" (probe 쪽) 경우 entry 복합체
* 아티팩트로 보고 후보 교체. 깊은(urine 쪽) 이탈은 dome 곡률로 정상이라 허용. 8mm ≈ 4 sample. */
const val ANT_NEARFIELD_TOL_MM = 8.0
// 2026-07-02: CENTER_OPTIMIZE phase — CH0~CH3 chord CV 로 최적 vertical 위치 판정.
/** CV 통과 임계 — V1 (computePlacementGuide) GREEN 조건 실증값 참조. */
const val CV_THRESHOLD_STRICT = 0.15
/** 방향 힌트 활성 임계 — CV 이 이상이면 사용자에게 up/down 힌트. */
const val CV_THRESHOLD_LOOSE = 0.20
/** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */
const val CENTER_OPT_CONFIRM_STREAK = 3
}
/** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */
object AlignmentAdvice {
fun advise(ch3: Boolean, u4: Int?, u5: Int?, latTol: Int = AlignmentConstants.LAT_TOL): Pair<AlignAction, String> {
if (!ch3) return Pair(AlignAction.MOVE_UP, "↑ 머리방향으로 이동 (ch3 미검출)")
if (u4 != null && u5 != null) {
val d = abs(u4 - u5)
if (d <= latTol) return Pair(AlignAction.STOP, "■ 정렬 완료 — 정지 (|Δ|=$d)")
return if (u4 < u5)
Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 $u4 < ch5 $u5, |Δ|=$d)")
else
Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 $u5 < ch4 $u4, |Δ|=$d)")
}
if (u4 == null && u5 == null)
return Pair(AlignAction.PROBE_LR, "↔ 좌우로 천천히 이동해 lateral 탐색 (ch4·ch5 미검출)")
return if (u4 == null) Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 미검출)")
else Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 미검출)")
}
}
/**
* 2-phase stateful guide — Python `AlignGuide`.
* (RollingAligner 는 단일 commit 마다 stateless advice 만 사용하지만, 향후
* imbal hill-climbing 모드에서 쓸 수 있도록 같이 포팅.)
*/
class AlignGuide(private val latTol: Int = AlignmentConstants.LAT_TOL) {
private enum class Phase { V, L }
private var phase = Phase.V
private var imbal: Int? = null
private var u: Int? = null
private var dir: AlignAction? = null
private var triedBoth = false
fun reset() {
phase = Phase.V
imbal = null; u = null; dir = null; triedBoth = false
}
fun step(summary: AlignSummary): Pair<AlignAction, String> {
if (phase == Phase.V) {
if (!summary.ch3) return Pair(AlignAction.MOVE_UP, "ch3 미검출 — 머리방향 이동")
phase = Phase.L
}
return lateral(summary)
}
private fun lateral(s: AlignSummary): Pair<AlignAction, String> {
val u4 = s.u4
val u5 = s.u5
if (u4 != null && u5 != null) {
val curImbal = abs(u4 - u5)
if (curImbal <= latTol) return Pair(AlignAction.STOP, "좌우 균형 |Δulen|=$curImbal<=$latTol")
if (dir == null) {
dir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
} else if (imbal != null && curImbal > imbal!!) {
// 악화 → 반대로
dir = if (dir == AlignAction.MOVE_LEFT) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
}
imbal = curImbal
return Pair(dir!!, "불균형 |Δulen|=$curImbal 감소 방향")
}
// 미검출 (probe 모드)
val cur = max(u4 ?: 0, u5 ?: 0)
if (dir == null) {
dir = AlignAction.MOVE_LEFT
u = cur
return Pair(AlignAction.MOVE_LEFT, "lateral 미검출 — 좌측 probe")
}
if (cur > (u ?: 0)) {
u = cur
return Pair(dir!!, "ulen 증가($cur) — ${dir!!.name} 계속")
}
if (!triedBoth && dir == AlignAction.MOVE_LEFT) {
dir = AlignAction.MOVE_RIGHT
triedBoth = true
u = cur
return Pair(AlignAction.MOVE_RIGHT, "좌측 미개선 — 우측 probe")
}
return Pair(AlignAction.STOP, "ulen 최대(${u ?: 0}) 위치 정지")
}
}
/**
* 4-stage stateful guide — 사용자 설계 (2026-06-16).
*
* 단계:
* 1. INITIAL_ACCUM — 첫 10 cycle warm-up, 안내 X (msg 만)
* 2. VERTICAL_CLIMB — ch3 검출까지 ↑
* 3. CH3_STABILIZE — ch3 검출 후 10 cycle 또 누적 (자세 확정)
* 4. LR_BALANCE — current center set 유지하며 좌우 균형
* set 잃으면 회복 (lost ch3 → ↑, else → ↓)
*
* Stage 4 의 bestCenterSet 은 ever-best — set 이 한 번 ch3+ch2+ch1 까지 갔다가
* ch1 사라져도, 목표는 여전히 ch3+ch2+ch1. 회복 후 좌우 계속.
*/
class AlignGuide4Stage(
private val latTol: Int = AlignmentConstants.LAT_TOL,
private val initialTarget: Int = 10,
private val stabilizeTarget: Int = 10,
/** VERTICAL_CLIMB 에서 CH3_STABILIZE 로 넘어가기 위한 ch3 연속 commit 수. */
private val verticalHitRequired: Int = 3,
/** LR_BALANCE 반전 deadband (samples). */
private val lrReverseDeadband: Int = 3,
/** LR_BALANCE 반전 요구 연속 commit 수. */
private val lrReverseConsecutive: Int = 2,
/**
* LR_BALANCE 의 `lost` 감지 hysteresis — N 연속 commit 에서 같은 채널이
* 빠져있어야 진짜 "회복 필요" 로 인정. 1 cycle noise 깜빡임에 화살표
* 흔들리는 현상 차단 (2026-06-30 실측 분석 결과).
*/
private val lostStreakRequired: Int = 3,
/** FINAL_CONFIRM 누적 프레임 수 — flowchart 의 \"마지막 측정 5회 반복\". */
val finalConfirmTarget: Int = 5,
) {
// 2026-07-02: CENTER_OPTIMIZE phase 추가 — CH0~CH3 chord CV 로 최적 vertical 위치 판정.
enum class Phase4 {
INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE,
CENTER_OPTIMIZE, // ← 신규
LR_BALANCE, FINAL_CONFIRM
}
data class Out(
val action: AlignAction?,
val msg: String,
val phase: Phase4,
val progress: Int = 0,
/** Localized string resource id for user-facing UI (msg is dev/debug). */
val msgKey: Int? = null,
/** Args for msgKey formatting. */
val msgArgs: List<Any> = emptyList(),
)
var phase: Phase4 = Phase4.INITIAL_ACCUM
private set
var bestCenterSet: Set<Int> = emptySet()
private set
private var initialCount = 0
private var verticalHitStreak = 0
private var stabilizeCount = 0
private var lateralDir: AlignAction? = null
private var lateralImbal: Int? = null
private var lateralWorseStreak = 0
private var finalConfirmCount = 0 // FINAL_CONFIRM 누적 카운터
private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis
private var centerOptimizeStreak = 0 // CENTER_OPTIMIZE CV 통과 연속 카운터
fun reset() {
phase = Phase4.INITIAL_ACCUM
bestCenterSet = emptySet()
initialCount = 0
verticalHitStreak = 0
stabilizeCount = 0
lateralDir = null
lateralImbal = null
lateralWorseStreak = 0
finalConfirmCount = 0
lostStreak = 0
centerOptimizeStreak = 0
}
fun step(s: AlignSummary): Out {
val curSet = (0..3).filter { it < s.detected.size && s.detected[it] }.toSet()
// === Phase 1: INITIAL_ACCUM ===
if (phase == Phase4.INITIAL_ACCUM) {
initialCount++
if (initialCount < initialTarget) {
return Out(null, "측정 준비 중... (${initialCount}/${initialTarget})",
phase, initialCount,
msgKey = R.string.align_hold_still,
msgArgs = listOf(initialCount, initialTarget))
}
phase = Phase4.VERTICAL_CLIMB
}
// === Phase 2: VERTICAL_CLIMB ===
if (phase == Phase4.VERTICAL_CLIMB) {
if (!s.ch3) {
verticalHitStreak = 0 // 연속 카운터 리셋 — false detection 거부
return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)",
phase, 0,
msgKey = R.string.align_move_up)
}
verticalHitStreak++
if (verticalHitStreak < verticalHitRequired) {
// ch3 일시 검출 — verticalHitRequired 회 연속 확인 전까지 사용자 정지.
// (현재 위치가 진짜 ch3 인지 평균이 만든 false echo 인지 확인 중)
return Out(AlignAction.STOP,
"✓ ch3 일시 검출 — 확인 중 (${verticalHitStreak}/${verticalHitRequired})",
phase, verticalHitStreak,
msgKey = R.string.align_hold_position,
msgArgs = listOf(verticalHitStreak, verticalHitRequired))
}
phase = Phase4.CH3_STABILIZE
stabilizeCount = 0
bestCenterSet = curSet
}
// === Phase 3: CH3_STABILIZE ===
if (phase == Phase4.CH3_STABILIZE) {
stabilizeCount++
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
if (!s.ch3) {
// ch3 잃었으면 → VERTICAL_CLIMB 회귀
phase = Phase4.VERTICAL_CLIMB
stabilizeCount = 0
return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로",
phase, 0,
msgKey = R.string.align_recover_up)
}
if (stabilizeCount < stabilizeTarget) {
return Out(AlignAction.STOP,
"✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})",
phase, stabilizeCount,
msgKey = R.string.align_hold_position,
msgArgs = listOf(stabilizeCount, stabilizeTarget))
}
// 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인)
phase = Phase4.CENTER_OPTIMIZE
centerOptimizeStreak = 0
}
// === Phase 4a: CENTER_OPTIMIZE (신규) — CH0~CH3 chord CV 로 최적 vertical 정밀 조정 ===
// 전제: CH3 는 이미 안정 검출됨. 4채널 chord CV 가 낮으면 (0.15 이하) 정렬 완료로 인정.
// CV 높으면: chord[3] > chord[0] 이면 probe 가 high → MOVE_DOWN,
// chord[0] > chord[3] 이면 probe 가 low → MOVE_UP.
if (phase == Phase4.CENTER_OPTIMIZE) {
val center4Detected = (0..3).all { it < s.detected.size && s.detected[it] }
if (!s.ch3) {
// CH3 잃으면 CH3_STABILIZE 로 복귀
phase = Phase4.CH3_STABILIZE
stabilizeCount = 0
centerOptimizeStreak = 0
return Out(AlignAction.MOVE_UP, "↑ ch3 재확인 필요",
phase, 0, msgKey = R.string.align_recover_up)
}
if (!center4Detected) {
// CH0~2 중 일부 미검출 → probe 를 위로 올려 CH0 잡기
centerOptimizeStreak = 0
val missing = (0..3).filter { !s.detected[it] }
val missingLabel = missing.joinToString(",") { "ch$it" }
return Out(AlignAction.MOVE_UP,
"↑ 조금 더 위로 ($missingLabel 미검출)", phase, 0,
msgKey = R.string.align_move_up)
}
// 4채널 모두 검출됨 → chord CV 계산 (summarize 에서 angle-corrected mm 값 저장됨)
val chords = DoubleArray(4) { ch -> s.chords[ch] ?: 0.0 }
val chordMean = chords.average()
if (chordMean <= 0.1) {
phase = Phase4.VERTICAL_CLIMB
verticalHitStreak = 0
centerOptimizeStreak = 0
return Out(AlignAction.MOVE_UP, "↑ 재정렬 필요", phase, 0,
msgKey = R.string.align_move_up)
}
val variance = chords.map { (it - chordMean) * (it - chordMean) }.average()
val chordStd = kotlin.math.sqrt(variance)
val cv = chordStd / chordMean
if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) {
centerOptimizeStreak++
if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) {
bestCenterSet = (0..3).toSet()
phase = Phase4.LR_BALANCE
return Out(AlignAction.STOP,
"✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}) — 좌우 균형 확인",
phase, 0,
msgKey = R.string.align_hold_position,
msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK))
}
return Out(AlignAction.STOP,
"✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " +
"(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})",
phase, centerOptimizeStreak,
msgKey = R.string.align_hold_position,
msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK))
}
// CV 너무 큼 → 방향 힌트
centerOptimizeStreak = 0
val ch0Chord = chords[0]
val ch3Chord = chords[3]
return if (ch3Chord > ch0Chord) {
Out(AlignAction.MOVE_DOWN,
"↓ 조금 아래로 (CV=${"%.2f".format(cv)}, CH3 극대)", phase, 0,
msgKey = R.string.align_recover_down_short)
} else {
Out(AlignAction.MOVE_UP,
"↑ 조금 위로 (CV=${"%.2f".format(cv)}, CH0 극대)", phase, 0,
msgKey = R.string.align_recover_up_short)
}
}
// === Phase 4b (기존 4): LR_BALANCE ===
if (phase == Phase4.LR_BALANCE) {
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" }
val lost = bestCenterSet - curSet
if (lost.isNotEmpty()) {
// 2026-06-30 hysteresis: N 연속 commit 에서 같은 채널이 lost 여야 진짜 회복 모드.
// 1 cycle 노이즈 깜빡임 (실측 CH1=15%, CH3=12% flap) 에 화살표 흔들리는 현상 차단.
lostStreak++
if (lostStreak >= lostStreakRequired) {
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
val lostLabel = lost.sortedDescending().joinToString(",") { "ch$it" }
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP)
R.string.align_recover_up_short else R.string.align_recover_down_short
return Out(recoveryDir, "$arrow $lostLabel 잃음 — 위치 회복", phase, 0,
msgKey = recoveryKey)
}
// streak 미달 → noise 로 보고 STOP 유지.
return Out(AlignAction.STOP,
"✓ 그대로 측정 가능 (noise check $lostStreak/$lostStreakRequired)", phase, 0,
msgKey = R.string.align_complete)
} else {
lostStreak = 0 // full set 복귀 — streak 리셋
}
// full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단)
val u4 = s.u4
val u5 = s.u5
if (u4 != null && u5 != null) {
val imbal = kotlin.math.abs(u4 - u5)
if (imbal <= latTol) {
// 균형 도달 → FINAL_CONFIRM 으로 전이. RollingAligner 가 buf clear → 5 fresh 프레임 누적.
phase = Phase4.FINAL_CONFIRM
finalConfirmCount = 0
return Out(AlignAction.STOP,
"✓ 균형 도달 — 최종 확인 중 (0/${finalConfirmTarget})", phase, 0,
msgKey = R.string.align_hold_position,
msgArgs = listOf(0, finalConfirmTarget))
}
if (lateralDir == null) {
lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
lateralWorseStreak = 0
} else if (lateralImbal != null) {
val significantlyWorse = imbal > lateralImbal!! + lrReverseDeadband
if (significantlyWorse) {
lateralWorseStreak++
if (lateralWorseStreak >= lrReverseConsecutive) {
lateralDir = if (lateralDir == AlignAction.MOVE_LEFT)
AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
lateralWorseStreak = 0
}
} else {
lateralWorseStreak = 0
}
}
lateralImbal = imbal
val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→"
val sideKey = if (lateralDir == AlignAction.MOVE_LEFT)
R.string.align_left else R.string.align_right
return Out(lateralDir!!, "$arrow $setLabel 유지 + 좌우 균형 (|Δ|=$imbal)",
phase, 0,
msgKey = sideKey)
}
return Out(AlignAction.PROBE_LR, "↔ $setLabel 유지 + 좌우 천천히 (lateral 미검출)",
phase, 0,
msgKey = R.string.align_probe_lr)
}
// === Phase 5: FINAL_CONFIRM ===
// RollingAligner 가 진입 시 buf 를 clear → 5 fresh 프레임 누적 후 imbal 재평가.
if (phase == Phase4.FINAL_CONFIRM) {
// center set 유지 확인 — 잃었으면 LR_BALANCE 회귀.
val lost = bestCenterSet - curSet
if (lost.isNotEmpty()) {
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
phase = Phase4.LR_BALANCE
finalConfirmCount = 0
val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP)
R.string.align_recover_up_short else R.string.align_recover_down_short
return Out(recoveryDir, "$arrow 채널 잃음 — 재정렬", phase, 0,
msgKey = recoveryKey)
}
finalConfirmCount++
// 누적 중 — 5 프레임 모일 때까지 정지 안내
if (finalConfirmCount < finalConfirmTarget) {
return Out(AlignAction.STOP,
"✓ 최종 확인 중 (${finalConfirmCount}/${finalConfirmTarget})",
phase, finalConfirmCount,
msgKey = R.string.align_hold_position,
msgArgs = listOf(finalConfirmCount, finalConfirmTarget))
}
// 5 프레임 도달 — imbal 재평가
val u4 = s.u4; val u5 = s.u5
if (u4 == null || u5 == null) {
phase = Phase4.LR_BALANCE
finalConfirmCount = 0
return Out(AlignAction.PROBE_LR, "lateral 미검출 — 좌우 재확인", phase, 0,
msgKey = R.string.align_probe_lr)
}
val imbal = kotlin.math.abs(u4 - u5)
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" }
return if (imbal <= latTol) {
// 최종 확인 통과 → 진짜 STOP
Out(AlignAction.STOP,
"■ 정렬 완료 ($setLabel, 확인 OK, |Δ|=$imbal)", phase, finalConfirmTarget,
msgKey = R.string.align_complete)
} else {
// 재평가 실패 → LR_BALANCE 회귀하여 추가 조정
phase = Phase4.LR_BALANCE
finalConfirmCount = 0
val arrow = if (u4 < u5) "→" else "←"
val sideKey = if (u4 < u5) R.string.align_right else R.string.align_left
Out(if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT,
"$arrow 재조정 필요 — 좌우 균형 (|Δ|=$imbal)", phase, 0,
msgKey = sideKey)
}
}
// 도달 불가 (defensive)
return Out(AlignAction.STOP, "unknown phase: $phase", phase, 0)
}
}
/**
* k-프레임 누적 + commit 한 번 — Python `RollingAligner`.
*
* push(raw6) 호출 시:
* - buf.size < accumK 인 동안: state='accum'
* - 충분히 모이면: 평균 → method_d → summarize → advice → state='commit'
*
* UI 는 commit 일 때만 화살표 갱신, accum 일 때는 "측정 중… N/K" 표시.
*/
class RollingAligner(
private val latTol: Int = AlignmentConstants.LAT_TOL,
/** INITIAL_ACCUM / VERTICAL_CLIMB / CH3_STABILIZE 단계 window 크기. flowchart 의 \"10회 측정 평균\". */
private val accumKVertical: Int = 10,
/** LR_BALANCE 단계 window 크기. flowchart 의 \"5회 측정 평균\" (빠른 반응). */
private val accumKLateral: Int = 5,
/** FINAL_CONFIRM 단계 window 크기. flowchart 의 \"마지막 5회 반복 측정\". */
private val accumKConfirm: Int = 5,
private val methodDParams: MethodDParams = MethodDParams.DEFAULT,
) {
private val buf = ArrayDeque<List<DoubleArray>>()
private val guide4 = AlignGuide4Stage(latTol = latTol,
initialTarget = accumKVertical, stabilizeTarget = accumKVertical,
finalConfirmTarget = accumKConfirm)
private var lastSeenPhase: AlignGuide4Stage.Phase4? = null
/** 마지막 commit 결과. */
var lastCommit: AdvisorState? = null
private set
val phase: AlignGuide4Stage.Phase4 get() = guide4.phase
val bestCenterSet: Set<Int> get() = guide4.bestCenterSet
/** UI 그래프에서 N-cycle 평균 신호를 보여주기 위해 마지막 avg 노출. */
var lastAvg: List<DoubleArray>? = null
private set
fun reset() {
buf.clear()
guide4.reset()
lastSeenPhase = null
lastAvg = null
}
/** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */
fun push(raw6: List<DoubleArray>): AdvisorState {
// Phase 전이 감지 — FINAL_CONFIRM 진입 시 buf clear 로 fresh 5 프레임 누적 보장.
// (flowchart \"마지막 측정 및 확인 — 5회 반복 측정\")
val curPhase = guide4.phase
if (lastSeenPhase != null && lastSeenPhase != AlignGuide4Stage.Phase4.FINAL_CONFIRM
&& curPhase == AlignGuide4Stage.Phase4.FINAL_CONFIRM) {
buf.clear()
}
// Phase 별 동적 window 크기.
val windowSize = when (curPhase) {
AlignGuide4Stage.Phase4.LR_BALANCE -> accumKLateral
AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm
else -> accumKVertical
}
buf.addLast(raw6)
while (buf.size > windowSize) buf.removeFirst()
val nCh = raw6.size
val avg = List(nCh) { ch ->
val len = buf.first()[ch].size
DoubleArray(len) { i ->
var sum = 0.0
for (frame in buf) sum += frame[ch][i]
sum / buf.size
}
}
lastAvg = avg
val dets = MethodDRunner.detectMultichannel(avg, methodDParams)
val summary = summarize(dets)
val out = guide4.step(summary)
lastSeenPhase = guide4.phase
// accum state — INITIAL_ACCUM 의 첫 N 프레임 + FINAL_CONFIRM 누적 중.
val isAccumulating = out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM ||
(out.phase == AlignGuide4Stage.Phase4.FINAL_CONFIRM &&
out.progress in 1 until accumKConfirm)
val state = if (isAccumulating) "accum" else "commit"
val effectiveTarget = when (out.phase) {
AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm
else -> accumKVertical
}
val st = AdvisorState(
state = state, action = out.action, msg = out.msg,
msgKey = out.msgKey, msgArgs = out.msgArgs,
dets = dets, summary = summary,
accumProgress = if (state == "accum") out.progress else effectiveTarget,
accumTarget = effectiveTarget,
)
lastCommit = st
return st
}
private fun summarize(dets: List<MethodDResult?>): AlignSummary {
fun u(i: Int) = if (i < dets.size) dets[i]?.urineLen else null
val ch3 = dets.size > AlignmentConstants.CH3 && dets[AlignmentConstants.CH3] != null
val chN = AlignmentConstants.CENTER_CH.count { it < dets.size && dets[it] != null }
val detected = BooleanArray(6) { i -> i < dets.size && dets[i] != null }
// 2026-07-02: per-channel chord (angle-corrected mm) — CENTER_OPTIMIZE CV 계산용
val chords = Array<Double?>(6) { ch ->
val d = if (ch < dets.size) dets[ch] else null
if (d == null) null
else {
val urineLen = kotlin.math.max(0, d.post - d.ant - 1).toDouble()
val angleRad = Math.toRadians(PiezoHW.degreeAll[ch])
urineLen * kotlin.math.cos(angleRad) * PiezoHW.distancePerSample
}
}
return AlignSummary(ch3 = ch3, u4 = u(4), u5 = u(5), chN = chN,
detected = detected, chords = chords)
}
}
@@ -0,0 +1,268 @@
package com.medithings.vesiscan.managers
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* 후면반사 탐지+제거 시스템 — Python span_utils.py + pipeline.py 1:1 포팅
*
* 흐름: build_candidate → consensus → is_reflection_only → resolve → suppress → rerun → choose
*/
object BackReflection {
private const val MIN_BACK_REFLECTION_PROM = 50.0
private const val POST_MATCH_TOL = 3
private const val OUTLIER_TOL = 4
private const val SHARED_TOL = 3
private const val MIN_CHANNELS = 3
private const val RADIUS = 6
private const val EXTEND = 20
// ── Valley helpers (50.0 rise early-stop) ──
private fun findLeftValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = x[peakIdx]
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
val cur = x[i]
if (cur < v) v = cur
else if (cur > v + 50.0) break
}
return v
}
private fun findRightValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = x[peakIdx]
for (i in (peakIdx + 1) until min(x.size, peakIdx + maxDist + 1)) {
val cur = x[i]
if (cur < v) v = cur
else if (cur > v + 50.0) break
}
return v
}
// ── Strongest significant peak ──
private fun strongestSignificantPeak(x: DoubleArray, lo: Int, hi: Int, minProm: Double = MIN_BACK_REFLECTION_PROM): Int? {
if (lo >= hi || lo < 0 || hi > x.size) return null
val seg: DoubleArray = x.sliceArray(lo until hi)
val peaks: List<Int> = PiezoEchoAnalyzer.shared.findPeaks1D(seg)
if (peaks.isEmpty()) return null
var bestIdx: Int? = null
var bestAmp = Double.NEGATIVE_INFINITY
for (p: Int in peaks) {
val gp = p + lo
val leftV = findLeftValleyValue(x, gp)
val rightV = findRightValleyValue(x, gp)
val prom = x[gp] - min(leftV, rightV)
if (prom < minProm) continue
if (x[gp] > bestAmp) { bestAmp = x[gp]; bestIdx = gp }
}
return bestIdx
}
// ── find_back_reflection_idx (legacy, 전체 범위) ──
fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = EXTEND): Int {
val n = tgcSg.size
val searchEnd = min(n, postMaxIdx + extend)
if (minSearchIdx >= searchEnd) return postMaxIdx
val seg: DoubleArray = tgcSg.sliceArray(minSearchIdx until searchEnd)
val peaks: List<Int> = PiezoEchoAnalyzer.shared.findPeaks1D(seg)
if (peaks.isEmpty()) return postMaxIdx
val globalPeaks: List<Int> = peaks.map { p -> p + minSearchIdx }
return globalPeaks.maxByOrNull { idx -> tgcSg[idx] } ?: postMaxIdx
}
// ── find_back_reflection_after_post ──
fun findBackReflectionAfterPost(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, extend: Int = EXTEND): Int? {
val searchStart = postIdx + 1
val searchEnd = min(tgcSg.size, postMaxIdx + extend)
if (searchStart >= searchEnd) return null
return strongestSignificantPeak(tgcSg, searchStart, searchEnd)
}
// ── find_back_reflection_near_idx ──
fun findBackReflectionNearIdx(tgcSg: DoubleArray, centerIdx: Int, postIdx: Int, postMaxIdx: Int, radius: Int = RADIUS, extend: Int = EXTEND): Int? {
val n = tgcSg.size
val lo = max(postIdx + 1, centerIdx - radius)
val hi = min(n, min(postMaxIdx + extend, centerIdx + radius + 1))
if (lo >= hi) return null
return strongestSignificantPeak(tgcSg, lo, hi)
}
// ── build_back_reflection_candidate ──
fun buildCandidate(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, postMatchTol: Int = POST_MATCH_TOL): Int? {
val globalBr = findBackReflectionIdx(tgcSg, postMaxIdx)
if (abs(globalBr - postIdx) <= postMatchTol) return null // 후위벽≈전체BR → 별도 BR 없음
return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx)
}
// ── consensus_back_reflection_idx ──
private const val MIN_VOTES = 3
private const val AMP_RATIO = 1.5
fun consensusIdx(candidates: List<Int?>, outlierTol: Int = OUTLIER_TOL, minVotes: Int = MIN_VOTES): Int? {
val vals = candidates.filterNotNull()
if (vals.size < minVotes) return null
val sorted = vals.sorted()
val med = sorted[sorted.size / 2].toDouble()
val inliers = vals.filter { abs(it - med) <= outlierTol }
val finalVals = inliers.ifEmpty { vals }
val finalSorted = finalVals.sorted()
return finalSorted[finalSorted.size / 2]
}
// ── is_suspicious_post_peak ──
fun isSuspiciousPostPeak(tgcSg: DoubleArray, postIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, sharedTol: Int = 2, ampRatio: Double = AMP_RATIO): Boolean {
val n = tgcSg.size
if (postIdx < 0 || postIdx >= n) return false
var suspicious = false
if (antIdx != null && antIdx in 0 until n && tgcSg[postIdx] > tgcSg[antIdx] * ampRatio) suspicious = true
if (sharedIdx != null && abs(postIdx - sharedIdx) <= sharedTol) suspicious = true
return suspicious
}
// ── select_back_reflection_idx ──
fun selectIdx(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): Int? {
val suspiciousPost = isSuspiciousPostPeak(tgcSg, postIdx, antIdx = antIdx, sharedIdx = sharedIdx)
var local: Int? = null
if (sharedIdx != null) {
local = findBackReflectionNearIdx(tgcSg, sharedIdx, postIdx, postMaxIdx)
}
if (suspiciousPost && sharedIdx != null) {
val postDist = abs(postIdx - sharedIdx)
val localDist = local?.let { abs(it - sharedIdx) }
val candDist = channelCandidate?.let { abs(it - sharedIdx) }
var nearestOther: Int? = localDist
if (candDist != null) nearestOther = if (nearestOther == null) candDist else min(nearestOther, candDist)
if (nearestOther == null || postDist < nearestOther) return postIdx
}
if (local != null) return local
if (channelCandidate != null) return channelCandidate
if (suspiciousPost) return postIdx
return null
}
// ── resolve_back_reflection_indices ──
data class ResolveResult(val brIdx: Int?, val suppressIdx: Int?)
fun resolveIndices(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): ResolveResult {
val br = selectIdx(tgcSg, postIdx, postMaxIdx, antIdx = antIdx, sharedIdx = sharedIdx, channelCandidate = channelCandidate)
?: return ResolveResult(null, null)
val suppressIdx = sharedIdx ?: br
return ResolveResult(br, suppressIdx)
}
// ── is_reflection_only_multichannel ──
fun isReflectionOnlyMultichannel(
tgcSignals: List<DoubleArray>,
wallPairs: List<Pair<Int?, Int?>>,
sharedIdx: Int? = null,
sharedTol: Int = SHARED_TOL,
minChannels: Int = MIN_CHANNELS,
ampRatio: Double = AMP_RATIO
): Boolean {
data class Valid(val i: Int, val ant: Int, val post: Int)
val valid = wallPairs.mapIndexedNotNull { i, (ant, post) ->
if (ant != null && post != null) Valid(i, ant, post) else null
}
if (valid.size < minChannels) return false
val ratio = ampRatio
// 조건 A: shared_idx 기반
var effectiveShared = sharedIdx
if (effectiveShared == null) {
val postVals = valid.map { it.post }.sorted()
val med = postVals[postVals.size / 2].toDouble()
val inliers = valid.filter { abs(it.post - med) <= sharedTol }
if (inliers.size >= minChannels) effectiveShared = med.roundToInt()
}
if (effectiveShared != null) {
if (valid.all { abs(it.post - effectiveShared!!) <= sharedTol && tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
}
// 조건 B: post 클러스터
val postVals = valid.map { it.post }.sorted()
val medPost = postVals[postVals.size / 2].toDouble()
val inlierTriples = valid.filter { abs(it.post - medPost) <= sharedTol }
if (inlierTriples.size >= minChannels) {
if (inlierTriples.all { tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
}
return false
}
// ── choose_rerun_or_initial_result ──
fun chooseResult(
initial: LowEchoResult?, rerun: LowEchoResult?,
brIdx: Int?, suppressIdx: Int?,
minGap: Int, minBrSep: Int = 0, minInitialUrineLen: Int = 0
): LowEchoResult? {
if (rerun != null) return rerun
if (canFallbackToInitial(initial, brIdx, suppressIdx, minGap, minBrSep, minInitialUrineLen)) return initial
return null
}
private fun canFallbackToInitial(initial: LowEchoResult?, brIdx: Int?, suppressIdx: Int?, minGap: Int, minBrSep: Int, minInitialUrineLen: Int): Boolean {
if (initial == null || brIdx == null || suppressIdx == null) return false
if (abs(brIdx - initial.post) < minBrSep) return false
if ((suppressIdx - initial.post) < minGap) return false
if (initial.urineLen < minInitialUrineLen) return false
return true
}
// ── suppress_back_reflection (valley early-stop 50.0) ──
fun suppress(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray {
val cleaned = sg.copyOf()
val n = sg.size
if (backRefIdx >= n) return cleaned
val lo = max(0, backRefIdx - min(searchMargin, 3))
val hi = min(n, backRefIdx + searchMargin + 1)
var actualPeak = lo
for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i
// Left valley with 50.0 rise early-stop
var leftValley = actualPeak
var leftBest = sg[actualPeak]
for (i in (actualPeak - 1) downTo max(0, actualPeak - searchMargin)) {
val cur = sg[i]
if (cur < leftBest) { leftBest = cur; leftValley = i }
else if (cur > leftBest + 50.0) break
}
// Right valley with 50.0 rise early-stop
var rightValley = actualPeak
var rightBest = sg[actualPeak]
for (i in (actualPeak + 1) until min(n, actualPeak + searchMargin + 1)) {
val cur = sg[i]
if (cur < rightBest) { rightBest = cur; rightValley = i }
else if (cur > rightBest + 50.0) break
}
if (rightValley > leftValley) {
val leftVal = sg[leftValley]
val rightVal = sg[rightValley]
val length = rightValley - leftValley
for (i in 0..length) {
cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length
}
}
return cleaned
}
}
@@ -0,0 +1,72 @@
package com.medithings.vesiscan.managers
import kotlin.math.abs
/**
* R-fit trend tracker — watches per-frame sphere-fit radius and classifies:
* RISING — sliding toward bladder centre (R increasing)
* PEAK — at / near session maximum R
* FALLING — sliding past centre (R decreasing)
* NOISE — fluctuating without clear trend
* IDLE — no valid fit (too few channels)
*
* Ported from CKLaw BladderSeekRTracker.kt.
*/
class BladderSeekRTracker(
private val historySize: Int = 8,
private val peakToleranceMm: Double = 1.5,
private val noiseDeltaMm: Double = 0.4,
) {
enum class Trend { RISING, PEAK, FALLING, NOISE, IDLE }
data class Status(
val currentR: Double?,
val bestR: Double,
val trend: Trend,
val improvementMm: Double,
val hintEn: String,
)
private val history = ArrayDeque<Double>()
var bestR: Double = 0.0
private set
fun reset() {
history.clear()
bestR = 0.0
}
fun submit(r: Double?): Status {
if (r == null || r.isNaN() || r <= 0.0) {
return Status(null, bestR, Trend.IDLE, 0.0,
"Not enough channels — check signal")
}
history.addLast(r)
while (history.size > historySize) history.removeFirst()
if (r > bestR) bestR = r
val trend = computeTrend(r)
val gap = r - bestR
val en = when (trend) {
Trend.RISING -> "Getting closer (R=${"%.0f".format(r)}mm)"
Trend.PEAK -> "At peak (R=${"%.0f".format(r)}mm)"
Trend.FALLING -> "Past peak — reverse (best ${"%.0f".format(bestR)}mm)"
Trend.NOISE -> "Searching (R=${"%.0f".format(r)}mm)"
Trend.IDLE -> ""
}
return Status(r, bestR, trend, gap, en)
}
private fun computeTrend(currentR: Double): Trend {
if (history.size < 3) return Trend.NOISE
if (bestR - currentR < peakToleranceMm) return Trend.PEAK
val prev = history.toList().dropLast(1).takeLast(3)
val prevMean = prev.average()
val delta = currentR - prevMean
return when {
abs(delta) < noiseDeltaMm -> Trend.NOISE
delta > 0 -> Trend.RISING
else -> Trend.FALLING
}
}
}
@@ -0,0 +1,190 @@
package com.medithings.vesiscan.managers
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Sagittal-plane circle fit + coronal lateral offset + argmax.
*
* Ported from CKLaw BladderSphereSeek.kt, simplified to use PiezoHW
* geometry directly (no SphereFit2Step / Geometry dependencies).
*
* Ch0..Ch3 wall points are projected to the YZ plane (sagittal slice)
* and fitted with a 2D algebraic circle (Kasa method). The fit radius
* serves as real-time alignment quality — maximised when probe is
* centred over the bladder.
*/
object BladderSphereSeek {
data class FitResult(
val radiusMm: Double,
val centerY: Double, // along body axis (cranial+, caudal-)
val centerZ: Double, // depth from probe surface
val residualStdMm: Double,
val nPoints: Int, // 3..8 wall points
val confidence: Float, // 0..1
)
data class CoronalResult(
val lateralOffsetMm: Double,
val nChannels: Int,
val ch4MidXMm: Double?,
val ch5MidXMm: Double?,
val confidence: Float,
)
/**
* Convert wall sample index to YZ coordinates (mm) for a given channel.
* Y = sensor_z + depth * sin(theta) (along body, cranial+)
* Z = depth * cos(theta) (perpendicular depth into body)
*/
private fun wallIdxToYZ(ch: Int, sampleIdx: Int): DoubleArray {
val hw = PiezoHW
val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm
val theta = hw.degreeAll[ch] * Math.PI / 180.0
val y = hw.sensorZMmAll[ch] + depth * sin(theta)
val z = depth * cos(theta)
return doubleArrayOf(y, z)
}
/**
* Convert wall sample index to X coordinate (mm) for lateral channels.
* X = sensor_x + depth * sin(theta_lr)
*/
private fun wallIdxToX(ch: Int, sampleIdx: Int): Double {
val hw = PiezoHW
val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm
val thetaLR = hw.degreeLRAll[ch] * Math.PI / 180.0
return hw.sensorXMmAll[ch] + depth * sin(thetaLR)
}
/**
* 2D circle fit (Kasa algebraic method) on YZ wall points from Ch0..Ch3.
*
* @param walls list of 6 Pair(antIdx, postIdx)?, null for undetected channels
* @param detected list of 6 booleans
*/
fun fitSagittalCircle(
walls: List<Pair<Int, Int>?>,
detected: List<Boolean>,
): FitResult? {
val pts = mutableListOf<DoubleArray>()
for (ch in 0..3) {
if (!detected.getOrElse(ch) { false }) continue
val w = walls.getOrNull(ch) ?: continue
pts.add(wallIdxToYZ(ch, w.first)) // ant
pts.add(wallIdxToYZ(ch, w.second)) // post
}
if (pts.size < 3) return null
// Kasa circle fit: minimize algebraic distance
// Solve [y^2+z^2, y, z, 1] * [1, -2cy, -2cz, cy^2+cz^2-r^2]^T = 0
val n = pts.size
var syy = 0.0; var sy = 0.0; var sz = 0.0
var syz = 0.0; var szz = 0.0; var s1 = n.toDouble()
var syyy = 0.0; var syzz = 0.0; var syyz = 0.0; var szzz = 0.0
var sd = 0.0; var sdy = 0.0; var sdz = 0.0
for (p in pts) {
val y = p[0]; val z = p[1]
val d = y * y + z * z
sy += y; sz += z; syy += y * y; szz += z * z; syz += y * z
sd += d; sdy += d * y; sdz += d * z
}
// Normal equations for Kasa fit
val a11 = syy; val a12 = syz; val a13 = sy
val a21 = syz; val a22 = szz; val a23 = sz
val a31 = sy; val a32 = sz; val a33 = s1
val b1 = sdy; val b2 = sdz; val b3 = sd
// Solve 3x3 system via Cramer's rule
val det = a11 * (a22 * a33 - a23 * a32) -
a12 * (a21 * a33 - a23 * a31) +
a13 * (a21 * a32 - a22 * a31)
if (kotlin.math.abs(det) < 1e-12) return null
val cy = (b1 * (a22 * a33 - a23 * a32) -
a12 * (b2 * a33 - a23 * b3) +
a13 * (b2 * a32 - a22 * b3)) / det / 2.0
val cz = (a11 * (b2 * a33 - a23 * b3) -
b1 * (a21 * a33 - a23 * a31) +
a13 * (a21 * b3 - b2 * a31)) / det / 2.0
val c3 = (a11 * (a22 * b3 - b2 * a32) -
a12 * (a21 * b3 - b2 * a31) +
b1 * (a21 * a32 - a22 * a31)) / det
val rSq = cy * cy + cz * cz + c3
if (rSq <= 0) return null
val r = sqrt(rSq)
// Residual: std of (|p - center| - r)
var sumResidSq = 0.0
for (p in pts) {
val dist = sqrt((p[0] - cy) * (p[0] - cy) + (p[1] - cz) * (p[1] - cz))
val resid = dist - r
sumResidSq += resid * resid
}
val residStd = sqrt(sumResidSq / n)
// Sanity: anatomically possible range
if (r < 15.0 || r > 120.0) return null
if (residStd > 5.0) return null
val nFactor = ((n - 3).coerceAtLeast(0).toFloat() / 5f).coerceIn(0f, 1f)
val residFactor = (1.0 - (residStd / 5.0).coerceIn(0.0, 1.0)).toFloat()
val conf = (0.4f * nFactor + 0.6f * residFactor).coerceIn(0f, 1f)
return FitResult(r, cy, cz, residStd, n, conf)
}
/**
* Among Ch0..Ch3, returns the index of the channel with the longest chord.
* 0 = centred over bladder, 1-3 = increasingly off-centre.
* Returns null if any of Ch0..Ch3 is not detected.
*/
fun sagittalArgMaxCh(
walls: List<Pair<Int, Int>?>,
detected: List<Boolean>,
): Int? {
var bestCh = -1
var bestLen = -1
for (ch in 0..3) {
if (!detected.getOrElse(ch) { false }) return null
val w = walls.getOrNull(ch) ?: return null
val len = w.second - w.first
if (len > bestLen) { bestLen = len; bestCh = ch }
}
return if (bestCh >= 0) bestCh else null
}
/**
* Coronal lateral offset from Ch4/Ch5 chord midpoints.
* Returns null if neither Ch4 nor Ch5 detected.
*/
fun computeCoronalLateral(
walls: List<Pair<Int, Int>?>,
detected: List<Boolean>,
): CoronalResult? {
var ch4Mid: Double? = null
var ch5Mid: Double? = null
for (ch in 4..5) {
if (!detected.getOrElse(ch) { false }) continue
val w = walls.getOrNull(ch) ?: continue
val xAnt = wallIdxToX(ch, w.first)
val xPost = wallIdxToX(ch, w.second)
val midX = (xAnt + xPost) / 2.0
if (ch == 4) ch4Mid = midX else ch5Mid = midX
}
val xs = listOfNotNull(ch4Mid, ch5Mid)
if (xs.isEmpty()) return null
return CoronalResult(
lateralOffsetMm = xs.average(),
nChannels = xs.size,
ch4MidXMm = ch4Mid,
ch5MidXMm = ch5Mid,
confidence = if (xs.size == 2) 1.0f else 0.5f,
)
}
}
@@ -0,0 +1,93 @@
package com.medithings.vesiscan.managers
import kotlin.math.abs
import kotlin.math.sqrt
/**
* 센서 미부착 감지 — detachment_detection.py 1:1 포팅
*
* ADC 신호의 [20:80] 구간에서 baseline([80:100] median)을 빼고,
* mean(|y|)과 std(y)가 모두 threshold 미만이면 미부착으로 판정.
*
* 미부착 시 LED state 4 (DETACH_WARNING) 전송.
*/
object DetachmentDetection {
const val WINDOW_START = 20
const val WINDOW_END = 80
const val BASELINE_WINDOW_START = 80
const val BASELINE_WINDOW_END = 100
const val THR_MEAN = 30.0
const val THR_STD = 30.0
const val N_CENTER_CH = 4
data class DetachmentResult(
val isDetached: Boolean,
val meanAbs: Double,
val stdVal: Double,
val meanAbsPerCh: DoubleArray,
val stdPerCh: DoubleArray
)
/**
* 4채널(CH0~CH3) ADC 신호에서 미부착 여부 판정.
*
* @param signals 6채널 ADC 데이터 (List<List<UShort>>)
* @return DetachmentResult
*/
fun detect(signals: List<List<UShort>>): DetachmentResult {
if (signals.size < N_CENTER_CH) {
return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH))
}
val nSample = signals[0].size
val ws = WINDOW_START.coerceAtMost(nSample)
val we = WINDOW_END.coerceAtMost(nSample)
val bs = BASELINE_WINDOW_START.coerceAtMost(nSample)
val be = BASELINE_WINDOW_END.coerceAtMost(nSample)
if (we <= ws || be <= bs) {
return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH))
}
val meanAbsPerCh = DoubleArray(N_CENTER_CH)
val stdPerCh = DoubleArray(N_CENTER_CH)
for (ch in 0 until N_CENTER_CH) {
val raw = signals[ch]
if (raw.size < be) {
meanAbsPerCh[ch] = 0.0
stdPerCh[ch] = 0.0
continue
}
// Baseline = median of [bs:be]
val baselineSlice = (bs until be).map { raw[it].toDouble() }.sorted()
val baseline = baselineSlice[baselineSlice.size / 2]
// y = signal[ws:we] - baseline
val y = DoubleArray(we - ws) { raw[ws + it].toDouble() - baseline }
// mean(|y|)
meanAbsPerCh[ch] = y.map { abs(it) }.average()
// std(y)
val mean = y.average()
stdPerCh[ch] = sqrt(y.map { (it - mean) * (it - mean) }.average())
}
val meanAbs = meanAbsPerCh.average()
val stdVal = stdPerCh.average()
// "and" rule: 둘 다 threshold 미만이면 미부착
val isDetached = meanAbs < THR_MEAN && stdVal < THR_STD
return DetachmentResult(
isDetached = isDetached,
meanAbs = meanAbs,
stdVal = stdVal,
meanAbsPerCh = meanAbsPerCh,
stdPerCh = stdPerCh
)
}
}
@@ -0,0 +1,203 @@
package com.medithings.vesiscan.managers
/**
* Green Zone 전체 파이프라인의 공유 상수 — Single Source of Truth.
*
* UrinAI, PeakDetection, Scoring 모두 이 상수를 참조.
* 파일 간 임계값 불일치 방지.
*
* 원본: xBench/GreenZoneConstants.kt (Charles KWON)
* 포팅일: 2026-04-17
*
* ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨.
* 각 상수의 의미와 영향 범위를 아래 주석 참고.
*/
enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C, METHOD_D }
enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP }
/**
* BV 계산 방법.
* - FRUSTUM : legacy analyzer walls + estimateBladderVolume6ch (Tanaka frustum)
* - V41 : Method C 의 SphereFit (methodCBvMl 직접 사용)
* - METHOD_D : Method D walls (cross-channel ant tie-break + OS-CFAR) + estimateBladderVolume6ch
* → Python `for_app_share` 임상 검증 pipeline 와 1:1 정렬 (2026-06-30).
*
* 사용 지침 (phantom 측정 vs 임상):
* - 구형 phantom (QC/calibration) : V41 (SphereFit) 권장. METHOD_D 는 lr_ratio < 1.0
* 영역에서 BV underestimate 가능 (대칭 phantom 검증 시 -38% 오차 사례).
* - 임상 인체 측정 : METHOD_D 권장. 인체 방광은 비대칭이므로 lr_ratio
* [0.5, 1.5] 자유 범위가 anatomically 올바름. Python algorithm = appshare 팀
* reference 동작과 정확 일치 (lr floor / shrinkage 의도적으로 없음).
* - METHOD_D 에 phantom-style lr floor (1.0) 를 추가하지 말 것. 사용자 결정
* 2026-06-30: "phantom QC 는 V41 로, 인체는 Python 1:1".
*/
enum class BvMethod { FRUSTUM, V41, METHOD_D }
/** Sensor alignment 알고리즘 — V1=기존 (computePlacementGuide), V2=신규 (alignment.py 포팅). */
enum class AlignmentAlgo { V1, V2 }
object GreenZoneConstants {
@Volatile var postMaxIdx: Int = 80
@Volatile var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
@Volatile var bvMethod: BvMethod = BvMethod.V41
/**
* lr_ratio 강제 override (algorithm 팀 최신 표준: piezophantomtest 4830e7c).
* - null : computed (기존 Python `compute_lr_ratio` 알고리즘)
* - Double : 강제 값 (algorithm 팀 default = 1.0)
*
* Phantom 실측 검증: computed lr=0.68 → BV -38%. lr=1.0 fixed → -1.6% (APP 일치).
* 6채널 hardware LR 분해능 한계로 computed lr 신뢰도 낮음. Dev toggle 전환 가능.
*/
@Volatile var lrRatioOverride: Double? = 1.0
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // default V1 (일반 사용자). V2 (Method D) 는 clinical alignment session 전용.
/** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */
@Volatile var pitchLyingThreshold: Float = 25f
/** Posture acc magnitude gate min (g). 이 값 미만이면 classifier UNKNOWN. */
@Volatile var postureAccMagMin: Float = 0.8f
/** Posture acc magnitude gate max (g). 이 값 초과면 classifier UNKNOWN. */
@Volatile var postureAccMagMax: Float = 1.2f
/**
* Posture polling 간격 (ms). PiezoMonitoring 에서 msp 보내는 주기.
* 6/30 변경: mtb (1Hz × 15sample) → msp (5Hz × 1sample) 로 전환. piezo burst 가청음 제거.
* Walking detector 의 1.5초 window 안에 7-8 sample 확보 위해 200ms 주기.
* dev 모드에서 조정 가능.
*/
@Volatile var posturePollIntervalMs: Long = 200L
// ── Walking detector 임계값 (flowchart 4-2 / 5-2) ─────────────────
/** Walking 인정 최소 pitch (deg). |pitch| > 이 값 이라야 walking 후보. */
@Volatile var walkingMinPitchDeg: Float = 45f
/** Walking 시작 — gyro 이동평균 (wmean) 임계 (dps). 실측: stand-up + 자세조정
* motion 이 5~10 dps 지속. 진짜 보행은 50+ dps. 10 으로 완화하여 짧은 보행도 잡음. */
@Volatile var walkingStartWmean: Float = 10f
/** Walking 시작 — gyro 이동표준편차 (wstd) 임계 (dps). */
@Volatile var walkingStartWstd: Float = 2f
/** Walking 시작 — 두 임계 모두 만족한 채 지속해야 하는 최소 시간 (ms). 실측: stand-up
* motion 이 3-5초 → 6초 로 차단. 실제 보행은 무한 지속이라 6초 이상 자동 만족. */
@Volatile var walkingStartHoldMs: Long = 6000L
/** Walking 유지 — gyro wmean 임계 (dps). 시작보다 낮음. */
@Volatile var walkingKeepWmean: Float = 2f
/** Walking 유지 — wmean 임계 미달 후 종료까지 grace 시간 (ms). 미만 지속이 이 값 넘으면 walking 종료. */
@Volatile var walkingKeepHoldMs: Long = 2000L
/** Walking detector sliding window (ms). 이 시간 분량 sample 평균/표준편차. */
@Volatile var walkingWindowMs: Long = 1500L
// ═══════════════════════════════════════════════════════════
// 신호 범위
// ═══════════════════════════════════════════════════════════
/** raw ADC 중 사용할 샘플 수.
* Kotlin 원본: 90. VesiScan maa 응답은 100 samples 전송하지만 끝부분 10개는
* CRC/noise 가능성 → 앞 90개만 사용.
* 영향: UrinAI 탐색 범위, PeakDetection 탐색 범위 */
const val useSamples: Int = 90
/** 초기 ringdown 스킵 (센서 근접 반사 무시).
* idx 0~2는 피에조 소자 직접 반사 → 항상 높은 값 → 액체로 오판 방지.
* 영향: UrinAI.fw 탐색 시작점, PeakDetection.fw 탐색 시작점 */
const val ringSkip: Int = 3
// ═══════════════════════════════════════════════════════════
// 액체(소변) 판정 임계 — 12-bit ADC (0~4095)
// ═══════════════════════════════════════════════════════════
/** Low-echo detection 임계값 (denoised 신호 기준).
* denoised ≤ 이 값 → low-echo (소변 후보).
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
@Volatile var lowEchoAmp: Float = 1250f
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
* Kotlin 원본: 1400.
* 영향: UrinAI.binarize → fw/bw 탐색의 기반 */
const val liquidThrLoose: Float = 1400f
/** 순수 액체 확인용 엄격 임계값: raw < 이 값 → "확실한 소변".
* liquidThrLoose보다 낮아야 함.
* 이 기준으로 연속 액체 구간(liquidRun) 측정.
* Kotlin 원본: 1100. VesiScan 실측에서 팬텀 low-echo가 1100~1400 범위.
* 1100→run=0, 1300→run=2 (미달). 1400으로 상향.
* liquidThrLoose(1400)와 동일하게 설정 — 팬텀에서는 loose/strict 구분 불필요.
* ⚠ 인체 측정 시 loose > strict 으로 재분리 필요할 수 있음
* 영향: UrinAI.maxRun 계산 → 최종 판정의 핵심 */
const val liquidThrStrict: Float = 1400f
/** 최소 연속 순수 액체 길이.
* maxRun ≥ 이 값이어야 "소변 있음" 판정.
* 소방광(50mL) 대응: chord≈24 중 순수 액체 5+ 필요.
* Kotlin 원본: 5.
* 영향: UrinAI 최종 판정 (detected 조건 1/3) */
const val minLiquidRun: Int = 5
// ═══════════════════════════════════════════════════════════
// 벽 검출 범위
// ═══════════════════════════════════════════════════════════
/** bw - fw 최소 (chord 최소 길이).
* 소방광 한계: 15 → 30mL(chord≈20) 이상 검출 가능.
* 50mL(chord≈24): margin 9 ✓
* 30mL(chord≈20): margin 5 ⚠
* 25mL 이하: 검출 불가 (임상적으로 PVR 50mL+ 의미 있는 범위)
* Kotlin 원본: 15.
* 영향: UrinAI 최종 판정 (detected 조건 2/3), PeakDetection bw 탐색 하한 */
const val minChord: Int = 15
/** bw - fw 최대 (이론적 상한).
* USE_SAMPLES(90)가 실질 제한.
* Kotlin 원본: 80.
* 영향: PeakDetection bw 탐색 상한 */
const val maxChord: Int = 80
// ═══════════════════════════════════════════════════════════
// 품질
// ═══════════════════════════════════════════════════════════
/** (wallPeak - lumenFloor) / wallPeak 최소.
* 벽과 소변 사이 대비가 이 값 이상이어야 유효.
* 높일수록 엄격 (선명한 벽 요구), 낮출수록 관대.
* Kotlin 원본: 0.15.
* 영향: UrinAI 최종 판정 (detected 조건 3/3) */
const val contrastMin: Float = 0.15f
// ═══════════════════════════════════════════════════════════
// Green Zone Finder
// ═══════════════════════════════════════════════════════════
/** score ≥ 이 값 → center lock (Green Zone 확정).
* Scoring.THR_EXCELLENT(75)보다 낮아 빠른 UX 확보.
* Kotlin 원본: 70.
* 영향: Placement Guide에서 Good 판정 기준 */
const val lockThreshold: Int = 70
/** 최대 기억 측정 수 (히스토리).
* Kotlin 원본: 20. */
const val maxHistory: Int = 20
// ═══════════════════════════════════════════════════════════
// 물리 상수 (교정용)
// ═══════════════════════════════════════════════════════════
/** ADC 샘플링 레이트 (Hz). VesiScan HW 사양. */
const val fs: Double = 400_000.0
/** 기준 조직 음속 (m/s). */
const val cRef: Double = 1540.0
/** 교정용 팬텀 직경 (mm). V=530mL 구. */
const val dPhantomMm: Double = 100.406
/** 팬텀 용적 (mL). */
const val vPhantomMl: Double = 530.0
/** 팬텀 반지름 (mm). */
const val rPhantomMm: Double = 50.203
}
@@ -0,0 +1,91 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.ble.ImuSample
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.sqrt
/**
* IMU 샘플 묶음(한 mtb 응답 = 15샘플)에서 자세/안정성 지표 추출.
*
* 가정: VBT 센서의 Accel z축이 환자 몸 바깥 방향(센서 면 → 위).
* 평평하게 누운 환자(Supine)에 부착 시 Az ≈ -1g (또는 +1g, 부착 면에 따라).
*
* 향후 임상 데이터로 calibration 필요.
*/
object ImuAnalysis {
/** 한 응답 안의 모든 샘플 평균 Accel vector */
fun meanAccel(samples: List<ImuSample>): Triple<Float, Float, Float>? {
if (samples.isEmpty()) return null
var sx = 0f; var sy = 0f; var sz = 0f
for (s in samples) { sx += s.ax; sy += s.ay; sz += s.az }
val n = samples.size
return Triple(sx / n, sy / n, sz / n)
}
/** 한 응답 안의 모든 샘플 평균 Gyro vector */
fun meanGyro(samples: List<ImuSample>): Triple<Float, Float, Float>? {
if (samples.isEmpty()) return null
var sx = 0f; var sy = 0f; var sz = 0f
for (s in samples) { sx += s.gx; sy += s.gy; sz += s.gz }
val n = samples.size
return Triple(sx / n, sy / n, sz / n)
}
/**
* 흔들림 강도 — Gyro 표준편차의 RMS. 정지 시 ≈ 0~5 dps, 흔드는 중 ≈ 50+ dps.
*/
fun shakeRmsDps(samples: List<ImuSample>): Float {
if (samples.size < 2) return 0f
val mean = meanGyro(samples) ?: return 0f
var sum = 0f
for (s in samples) {
val dx = s.gx - mean.first
val dy = s.gy - mean.second
val dz = s.gz - mean.third
sum += dx * dx + dy * dy + dz * dz
}
return sqrt(sum / samples.size)
}
/**
* 자세 추정 (간이 — Accel만 사용).
* 평균 Accel vector의 방향 → 어느 축이 중력에 가까운지 판정.
*
* 반환: "Supine" / "Sitting" / "Standing" / "Tilted" / "Moving"
*
* 임시 기준 (임상 데이터로 보정 필요):
* - |Az| 가장 큼 + Az > 0.7g: 환자 몸 누움 (Supine, 센서면 위 향함)
* - |Az| 가장 큼 + Az < -0.7g: 센서 뒤집힘
* - |Ay| 가장 큼: Sitting (몸 세움)
* - |Ax| 가장 큼: Standing 또는 옆 기울기
* - 기타: Tilted
* - shakeRmsDps > 30: Moving (자세 판정 불가)
*/
fun estimatePosture(samples: List<ImuSample>): String {
if (samples.isEmpty()) return "—"
if (shakeRmsDps(samples) > 30f) return "Moving"
val (ax, ay, az) = meanAccel(samples) ?: return "—"
val axAbs = abs(ax); val ayAbs = abs(ay); val azAbs = abs(az)
return when {
azAbs >= axAbs && azAbs >= ayAbs && azAbs > 0.7f ->
if (az > 0) "Supine" else "Supine (flip)"
ayAbs >= axAbs && ayAbs > 0.6f -> "Sitting"
axAbs > 0.6f -> "Standing"
else -> "Tilted"
}
}
/**
* Tilt 각도 (도) — 센서 z축이 중력에 대해 얼마나 기울었는지.
* 0° = z축이 정확히 중력 방향, 90° = z축이 수평.
*/
fun tiltDegrees(samples: List<ImuSample>): Float {
val (ax, ay, az) = meanAccel(samples) ?: return 0f
val mag = sqrt(ax * ax + ay * ay + az * az)
if (mag < 0.1f) return 0f // 자유낙하/이상치
val tiltRad = atan2(sqrt(ax * ax + ay * ay), abs(az))
return (tiltRad * 180.0 / Math.PI).toFloat()
}
}
@@ -0,0 +1,78 @@
/*
* Posture classifier — flowchart `Posture classification` 기반 (2026-06-22).
*
* 단순화: 3 state (SUPINE/SITTING/STANDING) → **2 state (LYING / NON_LYING)**.
* - 낙상 예방 목적상 sitting/standing 구분 불필요. "누웠나, 일어났나" 만 중요.
*
* 단계:
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
* 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
* 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
* 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold):
* > threshold → LYING (누움)
* ≤ threshold → NON_LYING (앉음/섬)
* threshold default = 25°, dev 모드 설정에서 변경 가능.
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.ble.ImuSample
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.sqrt
enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING }
object ImuPostureClassifier {
// Acc gate 임계값은 GreenZoneConstants.postureAccMagMin/Max 사용 (dev 조절 가능).
// Gyro gate 비활성화 — 일부 디바이스의 gyro calibration bias (-25 dps 등) 가
// flowchart 임계(9.16) 보다 커서 정지 상태에서도 fail → 분류 영구 불가.
// accel magnitude gate 가 이미 큰 모션 거름. gyro 게이트 제거.
// 단 dev 모드 디버그 위해 magnitude 는 계속 계산해 로그/Result 에 노출.
/** 자세 + 진단 정보 (UI/log용). */
data class Result(
val posture: Posture,
val pitchDeg: Float,
val accMag: Float,
val gyroMagDps: Float,
val reason: String,
)
fun classify(samples: List<ImuSample>): Result {
if (samples.isEmpty()) {
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
}
var sumAx = 0f; var sumAy = 0f; var sumAz = 0f
var sumGx = 0f; var sumGy = 0f; var sumGz = 0f
for (s in samples) {
sumAx += s.ax; sumAy += s.ay; sumAz += s.az
sumGx += s.gx; sumGy += s.gy; sumGz += s.gz
}
val n = samples.size
val ax = sumAx / n; val ay = sumAy / n; val az = sumAz / n
val gx = sumGx / n; val gy = sumGy / n; val gz = sumGz / n
val accMag = sqrt(ax * ax + ay * ay + az * az)
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
// Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용).
val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
if (accMag < GreenZoneConstants.postureAccMagMin || accMag > GreenZoneConstants.postureAccMagMax) {
return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag,
"acc mag out of range (%.2f)".format(accMag))
}
// Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조)
val absPitch = abs(pitchPre)
val thr = GreenZoneConstants.pitchLyingThreshold
// Pitch 의미: AX 가 중력과 일치할수록 큰 값 (≈90°), AZ 가 중력과 일치하면 작은 값 (≈0°).
// 사용자 직관: 센서 평평하게 둠 (AZ up) = LYING, 세움 (AX up) = NON_LYING (upright).
// → 작은 pitch → LYING, 큰 pitch → NON_LYING
val posture = if (absPitch <= thr) Posture.LYING else Posture.NON_LYING
return Result(posture, pitchPre, accMag, gyroMag, "ok")
}
}
@@ -0,0 +1,206 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.models.NirsConstants
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.models.NirsOxyResult
import kotlin.math.abs
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.pow
class NirsCalcEngine {
// Gain Calibration
private var gainValues: IntArray = intArrayOf()
val hasGain: Boolean get() = gainValues.size >= 24
fun setGainFromMag(response: NirsMagResponse) {
gainValues = response.gainValues
}
fun clearGain() {
gainValues = intArrayOf()
}
// AGC DAC -> Software Amplification
private fun agcGain(dacValue: Int): Double {
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
}
// DeGain Calculation
fun degain(rawLed: IntArray): DoubleArray {
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
val result = DoubleArray(24)
for (i in 0 until 24) {
val raw = rawLed[i].toDouble()
val sag = gainValues[i]
val pkg = i / 6
val wl = i % 6
val softAmp = agcGain(sag)
val hAmp = NirsConstants.hardwareAmp[pkg]
val spResponse = NirsConstants.spectralResponse[wl]
val dpf = NirsConstants.dpf[wl]
val denominator = softAmp * hAmp
if (denominator > 0) {
var dg = raw / denominator
dg /= spResponse
dg *= dpf
result[i] = dg
}
}
return result
}
// Butterworth IIR Filter
private var filterState: Array<DoubleArray> = emptyArray()
private var filterInitialized = false
var isFilterEnabled = true
fun resetFilter() {
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
filterInitialized = false
}
private fun applyFilter(x: Double, state: DoubleArray): Double {
val b = NirsConstants.filterB
val a = NirsConstants.filterA
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
state[1] = state[0]
state[0] = x
state[3] = state[2]
state[2] = y
return y
}
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
if (degainValues.size < 24) return degainValues
if (filterState.isEmpty()) resetFilter()
if (!filterInitialized) {
for (i in 0 until 24) {
val v = degainValues[i]
filterState[i] = doubleArrayOf(v, v, v, v)
}
filterInitialized = true
}
val filtered = DoubleArray(24)
for (i in 0 until 24) {
filtered[i] = applyFilter(degainValues[i], filterState[i])
}
return filtered
}
// Oxygenation Calculation
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
if (degainValues.size < 24) return NirsOxyResult.zero
val c = NirsConstants
val muA = DoubleArray(c.numWavelengths)
for (i in 0 until c.numWavelengths) {
val i2cm = degainValues[i]
val i5cm = degainValues[18 + i]
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
if (mix > 0) {
val slope = -ln(mix) / c.srsPathLength
muA[i] = -slope
}
}
val solution = solveNormalEq6x3(muA)
var hbO2 = max(0.0, solution.first) * c.oxyScale
var hb = max(0.0, solution.second) * c.oxyScale
val tHb = hbO2 + hb
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
}
// Least Squares Solver (6x3)
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
val eps = arrayOf(
NirsConstants.extinctionHbO2,
NirsConstants.extinctionHb,
NirsConstants.extinctionH2O
)
val n = Array(3) { DoubleArray(3) }
for (i in 0 until 3) {
for (j in 0 until 3) {
var sum = 0.0
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
n[i][j] = sum
}
}
val b = DoubleArray(3)
for (i in 0 until 3) {
var sum = 0.0
for (k in 0 until 6) sum += eps[i][k] * muA[k]
b[i] = sum
}
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
val inv = Array(3) { DoubleArray(3) }
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
val c = DoubleArray(3) { i ->
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
}
return Triple(c[0], c[1], c[2])
}
// Full Processing Pipeline
data class ProcessResult(
val degain: DoubleArray,
val degainFiltered: DoubleArray,
val oxy: NirsOxyResult,
val oxyFiltered: NirsOxyResult
)
fun process(rawLed: IntArray): ProcessResult {
val dg = degain(rawLed)
val oxyRaw = calcOxygenation(dg)
val dgf: DoubleArray
val oxyF: NirsOxyResult
if (isFilterEnabled) {
dgf = filterDeGain(dg)
oxyF = calcOxygenation(dgf)
} else {
dgf = dg
oxyF = oxyRaw
}
return ProcessResult(dg, dgf, oxyRaw, oxyF)
}
}
@@ -0,0 +1,94 @@
package com.medithings.vesiscan.managers
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.models.NirsMcjPacket
import com.medithings.vesiscan.models.NirsConstants
import com.medithings.vesiscan.BuildConfig
import com.medithings.vesiscan.models.NirsOxyResult
class NirsManager private constructor() {
companion object {
val instance: NirsManager by lazy { NirsManager() }
}
// State
val isStreaming = mutableStateOf(false)
val isCalibrated = mutableStateOf(false)
val isFilterEnabled = mutableStateOf(true)
// Current Values
val currentOxy = mutableStateOf(NirsOxyResult.zero)
val sampleCount = mutableIntStateOf(0)
// Chart History
val oxyHbO2History = mutableStateListOf<Double>()
val oxyHbHistory = mutableStateListOf<Double>()
// Internal
val calcEngine = NirsCalcEngine()
private val maxPoints = NirsConstants.maxHistoryPoints
// Calibration
fun setCalibrationGain(response: NirsMagResponse) {
calcEngine.setGainFromMag(response)
isCalibrated.value = true
}
// Process mcj Packet
fun processMcjPacket(packet: NirsMcjPacket) {
val result = calcEngine.process(packet.ledValues)
currentOxy.value = result.oxyFiltered
sampleCount.intValue++
if (BuildConfig.DEBUG && (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0)) {
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
"hasGain=${calcEngine.hasGain}, " +
"led[0..5]=${packet.ledValues.take(6)}, " +
"degain[0..2]=${result.degain.take(3).map { "%.2f".format(it) }}, " +
"oxy=StO2:${"%.1f".format(result.oxyFiltered.stO2)} HbO2:${"%.1f".format(result.oxyFiltered.hbO2)} HHb:${"%.1f".format(result.oxyFiltered.hb)}")
}
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
}
// Controls
fun toggleFilter() {
isFilterEnabled.value = !isFilterEnabled.value
calcEngine.isFilterEnabled = isFilterEnabled.value
if (isFilterEnabled.value) calcEngine.resetFilter()
}
fun clearData() {
oxyHbO2History.clear()
oxyHbHistory.clear()
currentOxy.value = NirsOxyResult.zero
sampleCount.intValue = 0
calcEngine.resetFilter()
}
fun startStreaming() {
isStreaming.value = true
calcEngine.resetFilter()
}
fun stopStreaming() {
isStreaming.value = false
}
fun reset() {
stopStreaming()
clearData()
isCalibrated.value = false
calcEngine.clearGain()
}
// Helpers
private fun appendTrimming(list: MutableList<Double>, value: Double) {
list.add(value)
while (list.size > maxPoints) list.removeAt(0)
}
}
@@ -0,0 +1,855 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.core.WdConfig
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.min
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt
// ==========================================================================
// Bladder Volume Estimation — Multi-Channel Frustum + Cap (5ch vertical)
//
// 1:1 port of PiezoBVEstimator.swift / bv_estimation.py (algo-test branch)
// Only "traditional" mode is used (coord/hybrid disabled in Python too)
// ==========================================================================
// ── Hardware Parameters (5ch vertical array) ──
/**
* Probe geometry — probe model별 고정
*/
object PiezoHW {
// ═══════════════════════════════════════════════════════════
// Device Presets — 6ch 기기 3가지 버전 (Snell's law 굴절 보정 적용)
// config_6ch.py 1:1 포팅
// ═══════════════════════════════════════════════════════════
enum class DevicePreset {
LEGACY_5CH,
V0, // All 0° (BLE 테스트용)
V1, // Max 20° housing → Snell's law refraction
V2 // Max 30° housing → Snell's law refraction
}
var activePreset: DevicePreset = DevicePreset.V0
fun autoDetectPreset(deviceName: String) {
if (deviceName.startsWith("VBT") && deviceName.length >= 4) {
// VBT...n0x → 뒤에서 3번째 글자(n)가 각도 타입
// n=0 → V0, n=2 → V1, n=3 → V2
val n = deviceName[deviceName.length - 3]
activePreset = when (n) {
'0' -> DevicePreset.V0
'2' -> DevicePreset.V1
'3' -> DevicePreset.V2
else -> DevicePreset.V1 // 알 수 없으면 V1 기본
}
} else if (deviceName.startsWith("2025MEDIP")) {
activePreset = DevicePreset.LEGACY_5CH
}
android.util.Log.d("PiezoHW", "autoDetectPreset: '$deviceName' → ${activePreset.name}")
}
val centerCh = intArrayOf(0, 1, 2, 3)
val lateralCh = intArrayOf(4, 5)
val lateralNeighbors = intArrayOf(1, 2)
// 6채널 전체 z 좌표 (CH0~CH5)
val sensorZMmAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
DevicePreset.V0 -> doubleArrayOf(21.0, 14.0, 7.0, 0.0, 10.5, 10.5)
DevicePreset.V1 -> doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9)
DevicePreset.V2 -> doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85)
}
// 6채널 전체 x 좌표
val sensorXMmAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
else -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0)
}
// 6채널 전체 SI 빔 각도 (Snell's law 굴절 후)
val degreeAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8, 0.0)
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V1 -> doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87)
DevicePreset.V2 -> doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87)
}
// 6채널 전체 LR 빔 각도 (Snell's law 굴절 후)
val degreeLRAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
DevicePreset.V2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
}
// BV용 center 채널만 (CH0~CH3)
val sensorZMm: DoubleArray get() = centerCh.map { sensorZMmAll[it] }.toDoubleArray()
val sensorXMm: DoubleArray get() = centerCh.map { sensorXMmAll[it] }.toDoubleArray()
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
/** Acoustic calibration (런타임 조절 가능).
* Setter는 walldetect.core.WdConfig.DPS_DEFAULT 도 함께 동기화한다.
* V41Detector / Geometry / AnatomicalGate 가 WdConfig 측 상수를 사용하기
* 때문에 한 곳만 바꾸면 BV 두 경로 (6ch estimate vs V41 dispatch / gate) 가
* 서로 다른 dps 로 계산되어 측정이 어긋난다. */
// `.also` 로 초기값을 walldetect.core.WdConfig 측에도 동시에 반영 — 시작
// 시점부터 두 경로의 dps 가 일치한다.
// 02bed02 (config_6ch.py): DISTANCE_PER_SAMPLE 1.981 → 1.936 (HW 샘플링레이트 변경).
// 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준).
@Volatile private var _distancePerSample: Double = 1.936.also { WdConfig.DPS_DEFAULT = it }
var distancePerSample: Double
get() = _distancePerSample
set(value) {
_distancePerSample = value
WdConfig.DPS_DEFAULT = value
}
const val delayOffsetMm: Double = 6.85
/** Volume model */
val areaK: Double = Math.PI / 4.0
const val defaultLrRatio: Double = 1.0
// lr_ratio fallbacks
const val lrRatioNoDetection: Double = 1.0
const val lrRatioInvalid: Double = 1.2
const val lrPrior: Double = 1.2
val presetName: String
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
DevicePreset.V0 -> "V0 (all 0°)"
DevicePreset.V1 -> "V1 (max 20°, Snell)"
DevicePreset.V2 -> "V2 (max 30°, Snell)"
}
}
// ── Center Wall Repair (6ch) ──
/**
* 6ch center 채널(CH0~CH3) 패턴을 BV 계산용으로 보정.
* - gap 1개: 선형 보간
* - gap 2개+: 위쪽 그룹 버리고 아래쪽만 사용
* Port of bv_estimation.py _repair_center_walls_for_6ch (#21 merge)
*/
private fun repairCenterWallsFor6ch(
centerWalls: List<Pair<Int, Int>?>
): List<Pair<Int, Int>?> {
val result = centerWalls.toMutableList()
val valid = result.indices.filter { i ->
val w = result[i]; w != null
}
if (valid.size < 2) return result
for (k in 0 until valid.size - 1) {
val prevIdx = valid[k]
val nextIdx = valid[k + 1]
val gap = nextIdx - prevIdx - 1
if (gap <= 0) continue
if (gap == 1) {
val pw = result[prevIdx]!!
val nw = result[nextIdx]!!
val ant = ((pw.first + nw.first) / 2.0 + 0.5).toInt()
val post = ((pw.second + nw.second) / 2.0 + 0.5).toInt()
result[prevIdx + 1] = Pair(ant, post)
continue
}
// gap >= 2: drop top group
for (i in 0 until nextIdx) result[i] = null
return result
}
return result
}
// ── LR Ratio Computation (x-z ellipse fitting, #21 merge) ──
/**
* x-z 평면 타원 피팅 기반 LR/AP ratio.
* CH4/CH5의 SI 높이에서 center(CH1,CH2) 벽 좌표를 보간하고,
* lateral 벽 좌표와 합쳐 6개 경계점으로 x-z 평면 타원 피팅.
* lr_ratio = LR 반축 / AP 반축.
*/
fun computeLrRatio(
centerWalls: List<Pair<Int, Int>?>,
lateralWalls: List<Pair<Int, Int>?>,
maxRatio: Double = 1.0,
sensorZMm: DoubleArray = PiezoHW.sensorZMmAll
): Double {
val hw = PiezoHW
val neighbors = hw.lateralNeighbors
val availableNbrs = neighbors.filter { ni ->
ni < centerWalls.size && centerWalls[ni] != null
}
if (availableNbrs.size < 2) return hw.lrRatioNoDetection
// Lateral wall points (x-z plane)
val latPtsX = mutableListOf<Double>()
val latPtsZ = mutableListOf<Double>()
val latSiPositions = mutableListOf<Double>()
val latChords = mutableListOf<Double>()
for ((li, lCh) in hw.lateralCh.withIndex()) {
if (li >= lateralWalls.size) continue
val w = lateralWalls[li] ?: continue
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
val alpha = hw.degreeAll[lCh] * Math.PI / 180.0
val beta = hw.degreeLRAll[lCh] * Math.PI / 180.0
val sx = hw.sensorXMmAll[lCh]
for (d in listOf(dNear, dFar)) {
latPtsX.add(sx + d * cos(alpha) * sin(beta))
latPtsZ.add(d * cos(alpha) * cos(beta))
}
latSiPositions.add(sensorZMm[lCh])
latChords.add(abs(dFar - dNear))
}
if (latPtsX.isEmpty()) return hw.lrRatioNoDetection
val targetSi = latSiPositions.average()
// Center wall z-coords at neighbor channels, interpolated to lateral SI height
val nbrSi = mutableListOf<Double>()
val nbrZAnt = mutableListOf<Double>()
val nbrZPost = mutableListOf<Double>()
for (ni in availableNbrs) {
val w = centerWalls[ni]!!
val ch = hw.centerCh[ni]
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
val th = hw.degreeAll[ch] * Math.PI / 180.0
nbrSi.add(sensorZMm[ch])
nbrZAnt.add(dNear * cos(th))
nbrZPost.add(dFar * cos(th))
}
val zAntCenter: Double
val zPostCenter: Double
if (nbrSi.size >= 2) {
val w = if (abs(nbrSi[0] - nbrSi[1]) > 1e-6)
((targetSi - nbrSi[1]) / (nbrSi[0] - nbrSi[1])).coerceIn(0.0, 1.0) else 0.5
zAntCenter = nbrZAnt[1] + w * (nbrZAnt[0] - nbrZAnt[1])
zPostCenter = nbrZPost[1] + w * (nbrZPost[0] - nbrZPost[1])
} else {
zAntCenter = nbrZAnt[0]
zPostCenter = nbrZPost[0]
}
// 6 boundary points: 2 center (x=0) + 4 lateral
val xw = (listOf(0.0, 0.0) + latPtsX).toDoubleArray()
val zw = (listOf(zAntCenter, zPostCenter) + latPtsZ).toDoubleArray()
// Normalized ellipse fit: α·x̂² + β·ẑ² + γ·x̂ + δ·ẑ = 1
val xm = xw.average(); val xs = std(xw) + 1e-12
val zm = zw.average(); val zs = std(zw) + 1e-12
val xn = DoubleArray(xw.size) { (xw[it] - xm) / xs }
val zn = DoubleArray(zw.size) { (zw[it] - zm) / zs }
val sol = solveEllipseLSQ(xn, zn, xw.size) ?: return hw.lrRatioInvalid
val (alphaF, betaF, _, _) = sol.let { Triple(it[0], it[1], Pair(it[2], it[3])) }
.let { doubleArrayOf(sol[0], sol[1], sol[2], sol[3]) }
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return hw.lrRatioInvalid
val rVal = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
if (rVal <= 0) return hw.lrRatioInvalid
val bLr = sqrt(rVal / sol[0]) * xs // LR 반축 (mm)
val aAp = sqrt(rVal / sol[1]) * zs // AP 반축 (mm)
if (aAp <= 0) return hw.lrRatioInvalid
val lrRaw = max(bLr / aAp, 1.0)
// Shrinkage toward prior
val dCenterMean = abs(zPostCenter - zAntCenter)
if (dCenterMean <= 0) return hw.lrRatioInvalid
val avgRatio = if (latChords.isNotEmpty()) latChords.average() / dCenterMean else 1.0
val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0)
val result = hw.lrPrior + (lrRaw - hw.lrPrior) * confidence
return result.coerceAtLeast(1.0)
}
// ── Result ──
data class BVResult(
val volumeMl: Double,
val volumeMm3: Double,
// Per-channel (입력 순서, valid만)
val validChannels: List<Int>,
val dAntMm: DoubleArray,
val dPostMm: DoubleArray,
val dMm: DoubleArray, // 단면 직경 (cos 보정)
val sMm2: DoubleArray, // 단면적
// Sorted by y (bottom → top)
val sortedChannels: List<Int>,
val sortedYMm: DoubleArray,
val sortedSMm2: DoubleArray,
// Frustum / caps
val vFrustumMm3: DoubleArray,
val vCoreMm3: Double,
val vBottomMm3: Double,
val vTopMm3: Double,
val bottomHMm: Double,
val topHMm: Double,
val bottomKind: String,
val topKind: String,
// Parameters used
val lrRatio: Double,
val distancePerSample: Double,
val delayOffsetMm: Double
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is BVResult) return false
return volumeMl == other.volumeMl && volumeMm3 == other.volumeMm3
&& validChannels == other.validChannels
}
override fun hashCode(): Int = 31 * volumeMl.hashCode() + volumeMm3.hashCode()
}
// ── Helpers ──
/** Sample index → distance (mm) */
private fun sampleToMm(
idx: Double,
dps: Double = PiezoHW.distancePerSample,
offset: Double = PiezoHW.delayOffsetMm
): Double = offset + idx * dps
/** (ant, post) sample indices → (d_near, d_far) mm */
private fun segmentToDistancesMm(
ant: Int, post: Int,
dps: Double = PiezoHW.distancePerSample,
offset: Double = PiezoHW.delayOffsetMm
): Pair<Double, Double> {
val d1 = sampleToMm(ant.toDouble(), dps, offset)
val d2 = sampleToMm(post.toDouble(), dps, offset)
return Pair(min(d1, d2), max(d1, d2))
}
/** 4×4 least squares: M^T M x = M^T 1 */
private fun solveEllipseLSQ(px: DoubleArray, py: DoubleArray, n: Int): DoubleArray? {
// Build 4×4 normal equations: (M^T M) params = M^T ones
// M columns: [x², y², x, y]
val mtm = Array(4) { DoubleArray(4) }
val mtb = DoubleArray(4)
for (k in 0 until n) {
val x = px[k]; val y = py[k]
val row = doubleArrayOf(x * x, y * y, x, y)
for (i in 0 until 4) {
for (j in 0 until 4) mtm[i][j] += row[i] * row[j]
mtb[i] += row[i] // RHS = 1
}
}
return solve4x4(mtm, mtb)
}
private fun solve4x4(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
val a = Array(4) { A[it].copyOf() }
val bb = b.copyOf()
for (col in 0 until 4) {
var maxRow = col; var maxVal = abs(a[col][col])
for (row in (col + 1) until 4) {
if (abs(a[row][col]) > maxVal) { maxVal = abs(a[row][col]); maxRow = row }
}
if (maxVal < 1e-12) return null
if (maxRow != col) {
val tmpA = a[col]; a[col] = a[maxRow]; a[maxRow] = tmpA
val tmpB = bb[col]; bb[col] = bb[maxRow]; bb[maxRow] = tmpB
}
for (row in (col + 1) until 4) {
val factor = a[row][col] / a[col][col]
for (j in col until 4) a[row][j] -= factor * a[col][j]
bb[row] -= factor * bb[col]
}
}
val x = DoubleArray(4)
for (i in 3 downTo 0) {
var sum = bb[i]
for (j in (i + 1) until 4) sum -= a[i][j] * x[j]
if (abs(a[i][i]) < 1e-12) return null
x[i] = sum / a[i][i]
}
return x
}
// ── NEW BV lib (piezophantomtest 4830e7c) helpers ─────────────────────────
/** posterior arc chord-대비 최대 수직 이탈 (sagitta, mm). Python `_sagitta_mm`. */
private fun sagittaMm(yp: DoubleArray, zp: DoubleArray): Double {
if (yp.size < 3) return 0.0
val order = (0 until yp.size).sortedBy { yp[it] }
val y = DoubleArray(yp.size) { yp[order[it]] }
val z = DoubleArray(zp.size) { zp[order[it]] }
val p0y = y[0]; val p0z = z[0]
val dY = y[y.size - 1] - p0y
val dZ = z[z.size - 1] - p0z
val ln = hypot(dY, dZ)
if (ln < 1e-6) return 0.0
val nY = -dZ / ln; val nZ = dY / ln
var m = 0.0
for (i in 1 until y.size - 1) {
val d = abs((y[i] - p0y) * nY + (z[i] - p0z) * nZ)
if (d > m) m = d
}
return m
}
/**
* posterior arc 원 fit (Kåsa algebraic) + sagitta shrinkage → R_eff.
* Python `_shrink_si_radius`. 실패 시 null.
*/
private fun shrinkSiRadius(
yWallPost: DoubleArray, zWallPost: DoubleArray,
cApPrior: Double?,
sNoise: Double = BOTTOM_CAP_SAGITTA_NOISE_MM,
): Double? {
val n = yWallPost.size
if (n < 3 || cApPrior == null || cApPrior <= 0) return null
val ata = Array(3) { DoubleArray(3) }
val atb = DoubleArray(3)
for (i in 0 until n) {
val row = doubleArrayOf(yWallPost[i], zWallPost[i], 1.0)
val r = yWallPost[i] * yWallPost[i] + zWallPost[i] * zWallPost[i]
for (a in 0 until 3) {
for (b in 0 until 3) ata[a][b] += row[a] * row[b]
atb[a] += row[a] * r
}
}
val sol = solve3x3(ata, atb) ?: return null
val yc = sol[0] / 2.0
val zc = sol[1] / 2.0
val rFit = sqrt(max(sol[2] + yc * yc + zc * zc, 1e-9))
val s = sagittaMm(yWallPost, zWallPost)
val w = (s * s) / (s * s + sNoise * sNoise)
val kappa = w / rFit + (1.0 - w) / cApPrior
return if (kappa > 1e-9) 1.0 / kappa else cApPrior
}
/** R 구에서 base a 인 minor 구면 캡 높이. Python `_minor_cap_height`. */
private fun minorCapHeight(rEff: Double, aBase: Double): Double {
val a = min(aBase, rEff)
return rEff - sqrt(max(rEff * rEff - a * a, 0.0))
}
/** 구면 캡 부피 (lr 보정). V = π·h²·(3R−h)/3 · lr. Python `_spherical_cap_volume`. */
private fun sphericalCapVolume(rEff: Double, h: Double, lrRatio: Double): Double =
Math.PI * h * h * (3.0 * rEff - h) / 3.0 * lrRatio
/**
* 벽 인셋: (ant + f·(ls-ant), post - f·(post-le)) → 벽을 lumen 내부로 살짝 밀어 넣기.
* Python `_apply_lumen_inset_one`. frac=0 이면 (ant, post) 그대로.
* ls, le 없으면 (ant, post) 그대로.
*/
fun applyLumenInsetOne(
ant: Int, post: Int, ls: Int?, le: Int?, frac: Double = LUMEN_INSET_FRAC,
): Pair<Int, Int> {
if (frac <= 0 || ls == null || le == null) return Pair(ant, post)
val ai = kotlin.math.round(ant + frac * (ls - ant)).toInt()
val pi = kotlin.math.round(post - frac * (post - le)).toInt()
return Pair(ai, pi)
}
/** Python `BOTTOM_CAP_SAGITTA_NOISE_MM` (bv_estimation.py:895). */
const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0
/** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */
const val LUMEN_INSET_FRAC: Double = 0.15
// ── Core BV Computation ──
/**
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
* 1:1 port of _bv_core(mode="traditional")
*/
/**
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
*/
fun estimateBladderVolume6ch(
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
): BVResult? {
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
centerWalls = repairCenterWallsFor6ch(centerWalls)
val lateralWalls = PiezoHW.lateralCh.map { if (it < allWalls.size) allWalls[it] else null }
// 2026-07-01: algorithm 팀 최신 표준 (piezophantomtest 4830e7c) — lr_ratio_override=1.0
// Phantom 검증: computed lr 은 6채널 HW 한계로 -38% 오차. Dev panel 로 전환 가능.
val lrRatio = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride
?: computeLrRatio(centerWalls, lateralWalls, sensorZMm = PiezoHW.sensorZMmAll)
return estimateBladderVolume(
walls = centerWalls,
lrRatio = lrRatio,
edgeCh = 3,
edgeRefChannels = listOf(1, 2)
)
}
fun estimateBladderVolume(
walls: List<Pair<Int, Int>?>,
distancePerSample: Double = PiezoHW.distancePerSample,
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
degreeDeg: DoubleArray = PiezoHW.degree,
areaK: Double = PiezoHW.areaK,
lrRatio: Double = PiezoHW.defaultLrRatio,
applyAngleCorrection: Boolean = true,
edgeCh: Int = walls.size - 1,
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
): BVResult? {
// 1) Edge channel filter — cap 방식 결정용 플래그
var edgeIsShort = false
if (edgeCh < walls.size) {
val wEdge = walls[edgeCh]
if (wEdge != null) {
val lEdge = wEdge.second - wEdge.first
val refLens = mutableListOf<Int>()
for (ci in edgeRefChannels) {
if (ci in walls.indices) {
val w = walls[ci]
if (w != null) refLens.add(w.second - w.first)
}
}
val minRef = refLens.minOrNull()
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
edgeIsShort = true
}
}
}
// 2) 유효 채널 추출 + sample → mm
val validChannels = mutableListOf<Int>()
val dAnt = mutableListOf<Double>()
val dPost = mutableListOf<Double>()
for ((i, w) in walls.withIndex()) {
if (w == null) continue
validChannels.add(i)
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second, distancePerSample, delayOffsetMm)
dAnt.add(dNear)
dPost.add(dFar)
}
// 1채널 케이스: 구(sphere) 부피 (Python _single_channel_bv)
if (validChannels.size < 2) {
if (validChannels.size == 1) {
val ch = validChannels[0]
val theta = degreeDeg[ch] * Math.PI / 180.0
val dRaw = dPost[0] - dAnt[0]
val D = if (applyAngleCorrection) dRaw * cos(theta) else dRaw
val R = D / 2.0
val bvMm3 = (4.0 / 3.0) * Math.PI * R * R * R
val S = areaK * D * D * lrRatio
val yMid = sensorZMm[ch] + (dAnt[0] + dPost[0]) / 2.0 * sin(theta)
return BVResult(
volumeMl = bvMm3 / 1000.0, volumeMm3 = bvMm3,
validChannels = validChannels, dAntMm = dAnt.toDoubleArray(), dPostMm = dPost.toDoubleArray(),
dMm = doubleArrayOf(D), sMm2 = doubleArrayOf(S),
sortedChannels = listOf(ch), sortedYMm = doubleArrayOf(yMid), sortedSMm2 = doubleArrayOf(S),
vFrustumMm3 = doubleArrayOf(), vCoreMm3 = 0.0,
vBottomMm3 = bvMm3, vTopMm3 = 0.0,
bottomHMm = R, topHMm = 0.0,
bottomKind = "sphere", topKind = "sphere",
lrRatio = lrRatio, distancePerSample = distancePerSample, delayOffsetMm = delayOffsetMm
)
}
return null
}
var n = validChannels.size
// 2-1) Post median outlier 제거 (#21 merge)
if (n >= 3) {
val posts = dPost.toDoubleArray()
val med = posts.sorted()[posts.size / 2]
val postTol = max(med * 0.25, 5.0 * distancePerSample)
val keep = (0 until n).filter { abs(posts[it] - med) <= postTol }
if (keep.size >= 2 && keep.size < n) {
val newValid = keep.map { validChannels[it] }.toMutableList()
val newDAnt = keep.map { dAnt[it] }.toMutableList()
val newDPost = keep.map { dPost[it] }.toMutableList()
validChannels.clear(); validChannels.addAll(newValid)
dAnt.clear(); dAnt.addAll(newDAnt)
dPost.clear(); dPost.addAll(newDPost)
n = validChannels.size
}
}
// theta, sensor_z for valid channels
val theta = DoubleArray(n) { degreeDeg[validChannels[it]] * Math.PI / 180.0 }
val sensZ = DoubleArray(n) { sensorZMm[validChannels[it]] }
// 3) 단면 직경 (traditional: cos 보정)
val lRaw = DoubleArray(n) { dPost[it] - dAnt[it] }
val D = DoubleArray(n) { if (applyAngleCorrection) lRaw[it] * cos(theta[it]) else lRaw[it] }
// 4) 단면적 + AP 반지름
val S = DoubleArray(n) { areaK * D[it] * D[it] * lrRatio }
val aCap = DoubleArray(n) { D[it] / 2.0 }
// 5) Midpoint + 벽 좌표 (y-z 평면, 타원 피팅용)
val dMid = DoubleArray(n) { (dAnt[it] + dPost[it]) / 2.0 }
val y = DoubleArray(n) { sensZ[it] + dMid[it] * sin(theta[it]) }
val yWallAnt = DoubleArray(n) { sensZ[it] + dAnt[it] * sin(theta[it]) }
val zWallAnt = DoubleArray(n) { dAnt[it] * cos(theta[it]) }
val yWallPost = DoubleArray(n) { sensZ[it] + dPost[it] * sin(theta[it]) }
val zWallPost = DoubleArray(n) { dPost[it] * cos(theta[it]) }
// 6) y 오름차순 정렬
val order = (0 until n).sortedBy { y[it] }
val yS = DoubleArray(order.size) { y[order[it]] }
val sS = DoubleArray(order.size) { S[order[it]] }
val aCapS = DoubleArray(order.size) { aCap[order[it]] }
val sortedCh = order.map { validChannels[it] }
// 7) y-z 평면 타원 피팅 + 반복 outlier 제거 (#22 merge)
val allYw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
val allZw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
val nPts = allYw.size
var capKind = "fallback"
var z0Ellipse: Double? = null
var cApPrior: Double? = null // shrink_bottom_cap 용 AP 반축
var hCapBot = aCapS[0]
var hCapTop = aCapS[n - 1]
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
// 타원 피팅 helper: 성공 시 (y0, z0, bSi, aAp, residuals) 반환
fun fitEllipsePts(ywF: DoubleArray, zwF: DoubleArray): Array<Any>? {
val cnt = ywF.size
val ym = ywF.average(); val ysS = std(ywF) + 1e-12
val zm = zwF.average(); val zsS = std(zwF) + 1e-12
val yn = DoubleArray(cnt) { (ywF[it] - ym) / ysS }
val zn = DoubleArray(cnt) { (zwF[it] - zm) / zsS }
val sol = solveEllipseLSQ(yn, zn, cnt) ?: return null
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return null
val rr = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
if (rr <= 0) return null
val y0 = (-sol[2] / (2.0 * sol[0])) * ysS + ym
val z0 = (-sol[3] / (2.0 * sol[1])) * zsS + zm
val bSi = sqrt(rr / sol[0]) * ysS
val aAp = sqrt(rr / sol[1]) * zsS
val resid = DoubleArray(cnt) {
abs(((ywF[it] - y0) / bSi) * ((ywF[it] - y0) / bSi) +
((zwF[it] - z0) / aAp) * ((zwF[it] - z0) / aAp) - 1.0)
}
return arrayOf(y0, z0, bSi, aAp, resid)
}
if (nPts >= 5) {
val keep = BooleanArray(nPts) { true }
var fit = fitEllipsePts(allYw, allZw)
if (fit != null) {
var y0 = fit[0] as Double; var z0 = fit[1] as Double
var bSi = fit[2] as Double; var aAp = fit[3] as Double
var resid = fit[4] as DoubleArray
var meanRes = resid.average()
// 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지
while (meanRes > ellipseCostThr && keep.count { it } > 5) {
val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break
val activeIndices = keep.indices.filter { keep[it] }
keep[activeIndices[worstLocal]] = false
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
val meanRes2 = (fit2[4] as DoubleArray).average()
if (meanRes2 < meanRes) {
y0 = fit2[0] as Double; z0 = fit2[1] as Double
bSi = fit2[2] as Double; aAp = fit2[3] as Double
resid = fit2[4] as DoubleArray; meanRes = meanRes2
} else break
}
// 품질 판정: 평균 잔차 ≤ threshold
if (meanRes <= ellipseCostThr) {
z0Ellipse = z0
// b_si 상한: a_ap × 1.3 (해부학적 SI/AP 비율 제한)
if (bSi > aAp * 1.3) bSi = aAp * 1.3
hCapBot = max(0.0, yS[0] - (y0 - bSi))
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
hCapBot = min(hCapBot, aCapS[0])
hCapTop = min(hCapTop, aCapS[n - 1])
cApPrior = aAp
capKind = "ellipse"
}
}
}
// shrink_bottom_cap=true (Python default) — posterior arc 원 fit + sagitta shrink 로
// R_eff 공유 곡률 산출. 성공 시 both caps 높이를 정규화 (자유 b_si 제거).
val rEffCap: Double? =
if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior) else null
if (rEffCap != null) {
hCapBot = minorCapHeight(rEffCap, aCapS[0])
hCapTop = minorCapHeight(rEffCap, aCapS[n - 1])
}
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한
val nTotalCh = sensorZMm.size
val topSortedCh = sortedCh.last()
if (n < nTotalCh && topSortedCh > 0) {
val upperCh = topSortedCh - 1
val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0
val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) {
val dAtZ0 = z0Ellipse!! / cos(upperTheta)
sensorZMm[upperCh] + dAtZ0 * sin(upperTheta)
} else {
sensorZMm[upperCh].toDouble()
}
val hTopLimit = yBeamUpper - yS[n - 1]
if (hTopLimit > 0 && hCapTop > hTopLimit) hCapTop = hTopLimit
}
// 8) Core frustum (traditional: h = dy)
val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] }
val vFrustum = DoubleArray(n - 1) {
(dy[it] / 3.0) * (sS[it] + sS[it + 1] + sqrt(sS[it] * sS[it + 1]))
}
val vCore = vFrustum.sum()
// 9) Caps — Bottom: spherical cap (shrink 시 R_eff 공유), Top: cone
val vBottom: Double
val vTop: Double
val bottomKind: String
val topKind: String
if (rEffCap != null) {
vBottom = sphericalCapVolume(rEffCap, hCapBot, lrRatio)
vTop = sS[n - 1] * hCapTop / 3.0
bottomKind = "shrink sphere"
topKind = "shrink cone"
} else {
// fallback (ellipse fit 실패): 기존 hemisphere 공식.
vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
vTop = sS[n - 1] * hCapTop / 3.0
bottomKind = "$capKind sphere"
topKind = "$capKind cone"
}
// 10) 합산
val bvMm3 = vCore + vBottom + vTop
return BVResult(
volumeMl = bvMm3 / 1000.0,
volumeMm3 = bvMm3,
validChannels = validChannels,
dAntMm = dAnt.toDoubleArray(),
dPostMm = dPost.toDoubleArray(),
dMm = D,
sMm2 = S,
sortedChannels = sortedCh,
sortedYMm = yS,
sortedSMm2 = sS,
vFrustumMm3 = vFrustum,
vCoreMm3 = vCore,
vBottomMm3 = vBottom,
vTopMm3 = vTop,
bottomHMm = hCapBot,
topHMm = hCapTop,
bottomKind = bottomKind,
topKind = topKind,
lrRatio = lrRatio,
distancePerSample = distancePerSample,
delayOffsetMm = delayOffsetMm
)
}
// ── Math Utilities ──
/**
* Degree 2 polynomial fit: returns Triple(c2, c1, c0) where f(x) = c2*x² + c1*x + c0
* Least squares via normal equations (Vandermonde)
*/
private fun polyfit2(x: DoubleArray, y: DoubleArray): Triple<Double, Double, Double> {
val n = x.size
if (n < 3) return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
// Build normal equations for Ax = b where A is Vandermonde [x^0, x^1, x^2]
val sx = DoubleArray(5)
val sy = DoubleArray(3)
for (i in 0 until n) {
val xi = x[i]
val yi = y[i]
var xp = 1.0
for (k in 0 until 5) {
sx[k] += xp
xp *= xi
}
sy[0] += yi
sy[1] += yi * xi
sy[2] += yi * xi * xi
}
// 3x3 system
val a = arrayOf(
doubleArrayOf(sx[0], sx[1], sx[2]),
doubleArrayOf(sx[1], sx[2], sx[3]),
doubleArrayOf(sx[2], sx[3], sx[4])
)
val b = doubleArrayOf(sy[0], sy[1], sy[2])
val sol = solve3x3(a, b) ?: return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
return Triple(sol[2], sol[1], sol[0]) // (c2, c1, c0)
}
/** Evaluate degree 2 polynomial: c2*x² + c1*x + c0 */
private fun polyval2(c: Triple<Double, Double, Double>, x: Double): Double =
c.first * x * x + c.second * x + c.third
/** Solve 3x3 linear system via Gaussian elimination with partial pivoting */
private fun solve3x3(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
val a = Array(3) { A[it].copyOf() }
val bb = b.copyOf()
for (col in 0 until 3) {
// Partial pivoting
var maxRow = col
var maxVal = abs(a[col][col])
for (row in (col + 1) until 3) {
if (abs(a[row][col]) > maxVal) {
maxVal = abs(a[row][col])
maxRow = row
}
}
if (maxVal < 1e-12) return null
if (maxRow != col) {
val tmpA = a[col]; a[col] = a[maxRow]; a[maxRow] = tmpA
val tmpB = bb[col]; bb[col] = bb[maxRow]; bb[maxRow] = tmpB
}
// Eliminate
for (row in (col + 1) until 3) {
val factor = a[row][col] / a[col][col]
for (j in col until 3) a[row][j] -= factor * a[col][j]
bb[row] -= factor * bb[col]
}
}
// Back substitution
val x = DoubleArray(3)
for (i in 2 downTo 0) {
var sum = bb[i]
for (j in (i + 1) until 3) sum -= a[i][j] * x[j]
if (abs(a[i][i]) < 1e-12) return null
x[i] = sum / a[i][i]
}
return x
}
/** Standard deviation */
private fun std(arr: DoubleArray): Double {
val n = arr.size.toDouble()
if (n <= 0) return 0.0
val mean = arr.sum() / n
val variance = arr.sumOf { (it - mean).let { d -> d * d } } / n
return sqrt(variance)
}
@@ -0,0 +1,916 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.ble.PiezoChannelData
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
// ── Result Types ──
/**
* Low-echo 기반 urine region 탐지 결과 (원은지 B방식 포팅)
*/
data class LowEchoResult(
val ant: Int, // 전벽 index
val post: Int, // 후벽 index
val lowStart: Int, // low-echo span 시작
val lowEnd: Int, // low-echo span 끝 (inclusive)
val lowMean: Double, // low-echo 구간 평균 진폭
val urineLen: Int, // post - ant
val score: Double, // low_depth * urine_len
val innerPeaks: List<Int> // (ant, post) 내부 local peak indices
) {
/** 방광 직경 (mm) = urine_len × distancePerSample */
val diameterMm: Double
get() = urineLen.toDouble() * PiezoConstants.distancePerSample
/** 방광 용적 (ml) = K × (D_cm)³ (DrBench 공식, 팬텀 캘리브레이션) */
val volumeMl: Double
get() = PiezoConstants.volumeMl(diameterMm)
/** 전벽 깊이 (mm) */
val antDepthMm: Double
get() = PiezoConstants.distanceMm(ant)
/** 후벽 깊이 (mm) */
val postDepthMm: Double
get() = PiezoConstants.distanceMm(post)
}
/**
* 채널별 분석 결과
*/
data class ChannelAnalysisResult(
val channel: Int,
val result: LowEchoResult?,
val rawSignal: DoubleArray,
val denoisedSignal: DoubleArray
) {
val isValid: Boolean get() = result != null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChannelAnalysisResult) return false
return channel == other.channel && result == other.result
}
override fun hashCode(): Int = 31 * channel + (result?.hashCode() ?: 0)
}
/**
* 전체 측정 분석 결과
*/
data class PiezoAnalysisResult(
val channels: List<ChannelAnalysisResult>,
val volumeMl: Double,
val confidence: Double, // 0~100
val validChannelCount: Int,
val method: String // "single", "multi_avg", "multi_weighted"
) {
val bestChannel: ChannelAnalysisResult?
get() = channels.filter { it.isValid }
.maxByOrNull { it.result?.score ?: 0.0 }
}
// ── PiezoConstants (referenced by LowEchoResult) ──
/**
* Physical constants for Piezo ultrasound echo measurement.
* Mirrors iOS PiezoConstants, delegates distancePerSample/delayOffsetMm to PiezoHW.
*/
object PiezoConstants {
const val adcMaxValue: Int = 4095
const val adcVrefMv: Double = 3300.0
const val sampleRateMhz: Double = 0.5375
const val sampleIntervalUs: Double = 1.86
const val soundSpeedMmUs: Double = 1.54
val distancePerSample: Double get() = PiezoHW.distancePerSample
val delayOffsetMm: Double get() = PiezoHW.delayOffsetMm
const val volumeK: Double = 1.76
fun distanceMm(index: Int): Double =
index.toDouble() * distancePerSample + delayOffsetMm
fun voltageMv(adcValue: Int): Double =
adcValue.toDouble() / adcMaxValue.toDouble() * adcVrefMv
fun volumeMl(diameterMm: Double): Double {
val dCm = diameterMm / 10.0
return volumeK * dCm * dCm * dCm
}
}
// ── Analyzer ──
/**
* Piezo 초음파 에코 분석기 — 원은지 연구원 B방식 알고리즘 포팅
* Pipeline: Raw ADC → TVD Denoise → SG Smooth → Low-Echo Detection → Volume
*/
class PiezoEchoAnalyzer private constructor() {
companion object {
val shared = PiezoEchoAnalyzer()
}
// ── Parameters (노트북 검증값) ──
// TVD
val tvdWeight: Double = 0.06
val tvdMaxIter: Int = 200
val tvdTol: Double = 2e-4
val tvdTau: Double = 0.25
// Savitzky-Golay (window=5, poly=2 → 고정 계수)
val sgCoeffs: DoubleArray = doubleArrayOf(-3.0/35.0, 12.0/35.0, 17.0/35.0, 12.0/35.0, -3.0/35.0)
// Low Echo Detection
val lowEchoAmpDefault: Double get() = GreenZoneConstants.lowEchoAmp.toDouble()
val lowMinLen: Int = 3
val mergeGapMax: Int = 3
val peakSearchWin: Int = 20
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
val minPeakMargin: Double = 30.0
val minUrineLen: Int = 3
val postRefineTolRatio: Double = 0.05
val maxRealisticVolumeMl: Double = 1500.0
val valleyStopRise: Double = 50.0
val edgeDistDecay: Double = 0.12
// ── Public API ──
/** 단일 채널 raw ADC → 용적 분석 (crash-safe) */
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
if (raw.size < 10) {
return ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
}
return try {
val denoised = denoise(raw)
val result = detectLowEcho(raw = raw, denoised = denoised)
ChannelAnalysisResult(channel = channel, result = result, rawSignal = raw, denoisedSignal = denoised)
} catch (_: Exception) {
ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
}
}
/** 다채널 파이프라인 — Python pipeline.py detect_walls_multichannel 1:1 포팅
* denoise → TGC(전체 채널 통합) → initial detect → BR consensus → resolve → suppress → rerun → choose
*/
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
val denoised = raws.map { denoise(it) }
// TGC 전체 채널 통합 (Python preprocess_multichannel)
val preprocessed = applyTgcMultichannel(denoised)
// 채널별 각도 보정 (Python _make_angle_kwargs_b)
val beamAngles = PiezoHW.degreeAll
fun channelOtsuRatio(chIdx: Int): Double {
if (chIdx >= beamAngles.size) return otsuRatio
return otsuRatio * kotlin.math.cos(beamAngles[chIdx] * Math.PI / 180.0)
}
// 1) Initial detect + BR candidate
val initialResults = mutableListOf<LowEchoResult?>()
val brCandidates = mutableListOf<Int?>()
for (i in channelData.indices) {
val r = detectLowEcho(raw = raws[i], denoised = preprocessed[i], otsuRatioOverride = channelOtsuRatio(channelData[i].channel))
initialResults.add(r)
brCandidates.add(if (r != null) BackReflection.buildCandidate(preprocessed[i], r.post, postMaxIdx) else null)
}
// 2) Consensus + reflection_only
val sharedBrIdx = BackReflection.consensusIdx(brCandidates)
val wallPairs = initialResults.map { r -> if (r != null) Pair<Int?, Int?>(r.ant, r.post) else Pair(null, null) }
val reflectionOnly = BackReflection.isReflectionOnlyMultichannel(preprocessed, wallPairs, sharedIdx = sharedBrIdx)
// 3) Per-channel: resolve → suppress → rerun → choose
return channelData.mapIndexed { i, ch ->
try {
val r = initialResults[i]
var suppressIdx: Int? = null
if (r == null) {
if (reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
else return@mapIndexed ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
} else {
val resolved = BackReflection.resolveIndices(preprocessed[i], r.post, postMaxIdx, antIdx = r.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i])
suppressIdx = resolved.suppressIdx
if (suppressIdx == null && reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
}
if (suppressIdx == null) {
return@mapIndexed ChannelAnalysisResult(ch.channel, r, raws[i], preprocessed[i])
}
val cleaned = BackReflection.suppress(preprocessed[i], suppressIdx)
val r2 = detectLowEcho(raw = raws[i], denoised = cleaned, otsuSignal = preprocessed[i], dynamicPostMax = suppressIdx, otsuRatioOverride = channelOtsuRatio(ch.channel))
val chosen = BackReflection.chooseResult(r, r2, brIdx = BackReflection.selectIdx(preprocessed[i], r?.post ?: 0, postMaxIdx, antIdx = r?.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i]), suppressIdx = suppressIdx, minGap = 8)
ChannelAnalysisResult(ch.channel, chosen, raws[i], cleaned)
} catch (_: Exception) {
ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
}
}
}
/** TGC 전체 채널 통합 처리 (Python preprocess_multichannel) */
fun applyTgcMultichannel(denoisedChannels: List<DoubleArray>): List<DoubleArray> {
if (denoisedChannels.isEmpty()) return denoisedChannels
val n = denoisedChannels[0].size
val nCh = denoisedChannels.size
// Log 압축 → 채널별 slope 계산
val slopes = DoubleArray(nCh)
for (ch in 0 until nCh) {
val logSig = DoubleArray(n) { kotlin.math.ln(max(denoisedChannels[ch][it], 1.0)) }
val x = DoubleArray(n) { it.toDouble() }
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
for (i in 0 until n) { sumX += x[i]; sumY += logSig[i]; sumXY += x[i] * logSig[i]; sumX2 += x[i] * x[i] }
slopes[ch] = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
}
// 채널별 보상 적용
return denoisedChannels.mapIndexed { ch, signal ->
val slope = slopes[ch]
if (slope >= 0) return@mapIndexed signal.copyOf()
val absSlope = abs(slope)
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
val ratio = if (absSlope < slopeThresh) 1.0
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
val targetSlope = slope * ratio
val x = DoubleArray(n) { it.toDouble() }
DoubleArray(n) { signal[it] + (targetSlope - slope) * x[it] }
}
}
/** 다채널 분석 → 최종 용적 */
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
val results = channelData.map { ch ->
analyzeChannel(ch.buffer, ch.channel)
}
// Step 1: Get valid results (algorithm found urine region)
val validResults = results.filter { it.isValid }
// Step 2: Filter out unrealistic volumes (container wall artifacts)
val realisticResults = validResults.filter { (it.result?.volumeMl ?: 0.0) <= maxRealisticVolumeMl }
// Step 3: Outlier removal — if 3+ results, remove > 2× median
var filtered = realisticResults
if (filtered.size >= 3) {
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
val median = vols[vols.size / 2]
filtered = filtered.filter { cr ->
val v = cr.result?.volumeMl ?: return@filter false
v <= median * 2.5 && v >= median * 0.3
}
}
val usedCount = filtered.size
val volumeMl: Double
val method: String
val confidence: Double
if (usedCount == 0) {
volumeMl = 0.0
method = "none"
confidence = 0.0
} else if (usedCount == 1) {
volumeMl = filtered[0].result!!.volumeMl
method = "single"
confidence = min(100.0, filtered[0].result!!.score / 100.0)
} else {
// Median-based: use median volume (robust against remaining outliers)
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
val median = vols[vols.size / 2]
volumeMl = median
method = "median_${usedCount}ch"
confidence = min(100.0, usedCount.toDouble() / results.size.toDouble() * 100.0)
}
return PiezoAnalysisResult(
channels = results,
volumeMl = volumeMl,
confidence = confidence,
validChannelCount = usedCount,
method = method
)
}
// ── Denoising Pipeline ──
var useSgFilter: Boolean = true
fun denoise(signal: DoubleArray): DoubleArray {
return if (useSgFilter) sgSmooth(signal) else signal.copyOf()
}
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
fun tvDenoiseChambolle1D(signal: DoubleArray): DoubleArray {
val n = signal.size
if (n < 2 || tvdWeight <= 0) return signal.copyOf()
// Skip if signal is flat (all same values)
val mn = signal.min()
val mx = signal.max()
if (mx - mn < 1.0) return signal.copyOf()
val p = DoubleArray(n - 1)
var uPrev = signal.copyOf()
val tauW = tvdTau / tvdWeight
for (iter in 0 until tvdMaxIter) {
// u = x + weight * div(p)
val divP = DoubleArray(n)
divP[0] = p[0]
for (i in 1 until (n - 1)) {
divP[i] = p[i] - p[i - 1]
}
divP[n - 1] = -p[n - 2]
val u = DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
// grad(u) = diff(u) and dual update
var maxDiff = 0.0
for (i in 0 until (n - 1)) {
val gradU = u[i + 1] - u[i]
p[i] = (p[i] + tauW * gradU) / (1.0 + tauW * abs(gradU))
maxDiff = max(maxDiff, abs(u[i] - uPrev[i]))
}
maxDiff = max(maxDiff, abs(u[n - 1] - uPrev[n - 1]))
if (maxDiff < tvdTol) break
uPrev = u
}
// Final reconstruction
val divP = DoubleArray(n)
divP[0] = p[0]
for (i in 1 until (n - 1)) {
divP[i] = p[i] - p[i - 1]
}
divP[n - 1] = -p[n - 2]
return DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
}
/** Savitzky-Golay 5-tap FIR smoothing (window=5, poly=2) */
fun sgSmooth(signal: DoubleArray): DoubleArray {
val n = signal.size
if (n < 5) return signal.copyOf()
val out = DoubleArray(n)
// Inner: convolution with fixed coefficients [-3, 12, 17, 12, -3]/35
for (i in 2 until (n - 2)) {
out[i] = sgCoeffs[0] * signal[i - 2] +
sgCoeffs[1] * signal[i - 1] +
sgCoeffs[2] * signal[i] +
sgCoeffs[3] * signal[i + 1] +
sgCoeffs[4] * signal[i + 2]
}
// Edge: polynomial fit for first/last 2 samples
// Left edge: fit poly2 to signal[0..4], evaluate at 0,1
val leftCoeffs = polyFit2(signal.sliceArray(0 until 5))
out[0] = evalPoly2(leftCoeffs, -2.0)
out[1] = evalPoly2(leftCoeffs, -1.0)
// Right edge: fit poly2 to signal[n-5..n-1], evaluate at n-2,n-1
val rightCoeffs = polyFit2(signal.sliceArray((n - 5) until n))
out[n - 2] = evalPoly2(rightCoeffs, 1.0)
out[n - 1] = evalPoly2(rightCoeffs, 2.0)
return out
}
// Quadratic LS fit to 5 points centered at 0: x = [-2,-1,0,1,2]
// Returns Triple(c0, c1, c2) where f(t) = c0 + c1*t + c2*t²
private fun polyFit2(y: DoubleArray): Triple<Double, Double, Double> {
val c0 = (-3*y[0] + 12*y[1] + 17*y[2] + 12*y[3] - 3*y[4]) / 35.0
val c1 = (-2*y[0] - y[1] + y[3] + 2*y[4]) / 10.0
val c2 = (2*y[0] - y[1] - 2*y[2] - y[3] + 2*y[4]) / 14.0
return Triple(c0, c1, c2)
}
private fun evalPoly2(c: Triple<Double, Double, Double>, t: Double): Double =
c.first + c.second * t + c.third * t * t
// ── Low Echo Detection ──
var useAdaptiveThreshold: Boolean = true
var lastOtsuThreshold: Double = 0.0; private set
var lastOtsuSeparability: Double = 0.0; private set
val otsuRatio: Double = 0.9
val minScore: Double = 3000.0
/** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null, otsuRatioOverride: Double? = null): LowEchoResult? {
val sg = denoised
if (sg.size < 10) return null
val effectivePostMax = dynamicPostMax ?: postMaxIdx
if (lowEchoAmpOverride != null) {
lastOtsuThreshold = lowEchoAmpOverride
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpOverride, effectivePostMax = effectivePostMax)
}
if (!useAdaptiveThreshold) {
lastOtsuThreshold = lowEchoAmpDefault
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
}
// Otsu-only: 단일 시도 (Python detect_low_echo_method_b 동일)
val effectiveRatio = otsuRatioOverride ?: otsuRatio
val otsuInput = otsuSignal ?: sg
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
lastOtsuThreshold = thr * effectiveRatio
lastOtsuSeparability = sep
return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax)
}
// ── TGC (Time Gain Compensation) ──
fun applyTgc(signal: DoubleArray): DoubleArray {
val n = signal.size
if (n < 5) return signal.copyOf()
// Log 압축 후 linear fit (Python fit_attenuation_lines 동일)
val logSignal = DoubleArray(n) { kotlin.math.ln(max(signal[it], 1.0)) }
val x = DoubleArray(n) { it.toDouble() }
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
for (i in 0 until n) { sumX += x[i]; sumY += logSignal[i]; sumXY += x[i] * logSignal[i]; sumX2 += x[i] * x[i] }
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
if (slope >= 0) return signal.copyOf()
val absSlope = abs(slope)
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
val ratio = if (absSlope < slopeThresh) 1.0
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
val targetSlope = slope * ratio
val compensation = DoubleArray(n) { (targetSlope - slope) * x[it] }
return DoubleArray(n) { signal[it] + compensation[it] }
}
// ── Back Reflection Detection & Suppression ──
fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = 20): Int {
val n = tgcSg.size
val searchEnd = min(n, postMaxIdx + extend)
if (minSearchIdx >= searchEnd) return postMaxIdx
val seg = tgcSg.sliceArray(minSearchIdx until searchEnd)
val peaks = findPeaks1D(seg)
if (peaks.isEmpty()) return postMaxIdx
val globalPeaks = peaks.map { it + minSearchIdx }
return globalPeaks.maxByOrNull { tgcSg[it] } ?: postMaxIdx
}
fun suppressBackReflection(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray {
val cleaned = sg.copyOf()
val n = sg.size
if (backRefIdx >= n) return cleaned
val lo = max(0, backRefIdx - min(searchMargin, 3))
val hi = min(n, backRefIdx + searchMargin + 1)
var actualPeak = lo
for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i
// left valley
val valleyLoStart = max(0, actualPeak - searchMargin)
var leftValley = valleyLoStart
for (i in valleyLoStart..actualPeak) if (sg[i] < sg[leftValley]) leftValley = i
// right valley
val valleyHiEnd = min(n, actualPeak + searchMargin + 1)
var rightValley = actualPeak
for (i in actualPeak until valleyHiEnd) if (sg[i] < sg[rightValley]) rightValley = i
if (rightValley > leftValley) {
val leftVal = sg[leftValley]
val rightVal = sg[rightValley]
val length = rightValley - leftValley
for (i in 0..length) {
cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length
}
}
return cleaned
}
/** 다채널 후면 반사 위치 합의 (median) */
fun findBackReflectionMultichannel(tgcSignals: List<DoubleArray>, postMaxIdx: Int): Int {
val detected = mutableListOf<Int>()
for (tgc in tgcSignals) {
val idx = findBackReflectionIdx(tgc, postMaxIdx)
if (idx < postMaxIdx) detected.add(idx)
}
if (detected.isEmpty()) return postMaxIdx
detected.sort()
return detected[detected.size / 2]
}
/**
* 1D Otsu threshold + separability
*/
data class OtsuResult(val threshold: Double, val separability: Double)
fun otsu1dWithSeparability(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): OtsuResult {
val n = min(values.size, limit)
if (n == 0) return OtsuResult(0.0, 0.0)
var vMin = values[0]; var vMax = values[0]
for (i in 1 until n) {
if (values[i] < vMin) vMin = values[i]
if (values[i] > vMax) vMax = values[i]
}
if (vMax == vMin) return OtsuResult(vMin, 0.0)
val binWidth = (vMax - vMin) / nBins
val hist = IntArray(nBins)
for (i in 0 until n) {
val bin = ((values[i] - vMin) / binWidth).toInt().coerceIn(0, nBins - 1)
hist[bin]++
}
val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth }
val total = n.toDouble()
val p = DoubleArray(nBins) { hist[it] / total }
val cumP = DoubleArray(nBins)
val cumMP = DoubleArray(nBins)
cumP[0] = p[0]; cumMP[0] = p[0] * centers[0]
for (i in 1 until nBins) {
cumP[i] = cumP[i - 1] + p[i]
cumMP[i] = cumMP[i - 1] + p[i] * centers[i]
}
val totalM = cumMP[nBins - 1]
var bestSigma = -1.0
var bestIdx = 0
for (t in 0 until nBins - 1) {
val w0 = cumP[t]; val w1 = 1.0 - w0
if (w0 < 1e-6 || w1 < 1e-6) continue
val m0 = cumMP[t] / w0
val m1 = (totalM - cumMP[t]) / w1
val sigmaB = w0 * w1 * (m0 - m1) * (m0 - m1)
if (sigmaB > bestSigma) {
bestSigma = sigmaB
bestIdx = t
}
}
// Separability: σ_b² / σ_total²
var totalVar = 0.0
for (i in 0 until nBins) totalVar += p[i] * (centers[i] - totalM) * (centers[i] - totalM)
val separability = if (totalVar > 1e-12) bestSigma / totalVar else 0.0
return OtsuResult(centers[bestIdx], separability)
}
fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double {
return otsu1dWithSeparability(values, limit, nBins).threshold
}
/**
* Low-echo 탐지 핵심 — Python _detect_core 1:1 포팅 (비대칭 score + FP 필터)
*/
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
if (sg.size < 10) return null
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
val gapPeakThr = threshold + 5.0
val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr)
val firstSpan = spans.firstOrNull() ?: return null
// refine_right_edge
val (s0, e0) = refineRightEdge(rawSpans, firstSpan, lowMinLen)
val s = max(0, s0)
val e = min(sg.size - 1, e0)
if (s > e) return null
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
if (lowSlice.isEmpty()) return null
val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
val peakMin = max(lowMean + minPeakMargin, threshold)
// POST 먼저, ANT 나중 (Python 순서)
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.ANT, otherEdge = e)
// ant fallback: low-echo 오른쪽 경계와 post 사이 거리만큼 왼쪽 반사
if (ant == null && post != null) ant = max(0, s - (post - e))
if (ant == null && s > 0) ant = s - 1
if (ant == null) return null
// sg[ant] < threshold 또는 sg[post] < threshold → reject
if (sg[ant] < threshold || sg[post] < threshold) return null
if (post > effectivePostMax) {
val backHalfEdge = (s + e) / 2
post = selectWallByProminence(
sg = sg, edge = backHalfEdge,
searchWin = effectivePostMax - backHalfEdge,
peakMin = peakMin, side = WallSide.POST, otherEdge = null
) ?: return null
}
val antH = sg[ant]
val postH = sg[post]
val antProm = antH - findRightValley(sg, ant)
val postProm = postH - findLeftValley(sg, post)
val urineLen = post - ant - 1
if (urineLen < minUrineLen) return null
// FP 필터: post prominence 최소
if (postProm < minPeakMargin) return null
// 비대칭 score (ant_reliability)
val antDepth = max(0.0, antH - lowMean)
val postDepth = max(0.0, postH - lowMean)
val antReliability = (antProm / max(postProm, 1e-6)).coerceIn(0.25, 1.0)
val lowDepth = ((antReliability * antDepth) + postDepth) / (1.0 + antReliability)
val score = lowDepth * urineLen.toDouble()
if (score < minScore) return null
return LowEchoResult(
ant = ant, post = post,
lowStart = s, lowEnd = e, lowMean = lowMean,
urineLen = urineLen, score = score,
innerPeaks = findInnerPeaks(sg, left = ant, right = post)
)
}
/** 병합 span 우측 트리밍 (Python _refine_right_edge_from_merged_components) */
private fun refineRightEdge(rawSpans: List<Pair<Int, Int>>, mergedSpan: Pair<Int, Int>, lowMinLen: Int): Pair<Int, Int> {
val (s, e) = mergedSpan
val components = rawSpans.filter { it.first >= s && it.second <= e }
if (components.size <= 1) return mergedSpan
val (firstS, firstE) = components[0]
val (secondS, _) = components[1]
val spanLen = e - s + 1
if ((secondS - s) >= (spanLen / 2) && (firstE - s + 1) >= lowMinLen) return Pair(firstS, firstE)
return mergedSpan
}
// ── Prominence-based Wall Selection (Python 동기화: ANT=nearest, POST=edge_score) ──
private val maxPeakCandidates = 3
private enum class WallSide { ANT, POST }
/** valley with descending-first logic (Python _find_right_valley) */
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
val n = sg.size
var v = sg[peakIdx]
var descending = false
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist + 1)) {
val cur = sg[i]
if (cur <= v) { v = cur; descending = true; continue }
if (descending) break
if (cur > v + valleyStopRise) break
}
return v
}
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx]
var descending = false
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
val cur = sg[i]
if (cur <= v) { v = cur; descending = true; continue }
if (descending) break
if (cur > v + valleyStopRise) break
}
return v
}
/**
* Wall peak 선택 — Python _select_wall_by_prominence 1:1 포팅
* ANT: 가장 가까운 outer peak (prominence는 tie-breaker)
* POST: edge_score = prom / (1 + decay * dist)
*/
private fun selectWallByProminence(
sg: DoubleArray, edge: Int, searchWin: Int,
peakMin: Double, side: WallSide, otherEdge: Int?
): Int? {
val n = sg.size
if (n <= 0 || edge < 0 || edge >= n) return null
val leftLo: Int
val rightHi: Int
if (side == WallSide.ANT) {
leftLo = max(0, edge - searchWin)
var rh = min(n - 1, edge + searchWin)
if (otherEdge != null) rh = min(rh, otherEdge)
rightHi = rh
} else {
var ll = max(0, edge - searchWin)
if (otherEdge != null) ll = max(ll, otherEdge)
leftLo = ll
rightHi = min(n - 1, edge + searchWin)
}
var leftCandidates = listOf<Int>()
if (edge > leftLo) {
val leftEnd = min(edge + 1, n)
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
if (seg != null) {
val pks = findPeaks1D(seg)
leftCandidates = pks.map { it + leftLo }.filter { it < edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
}
}
var rightCandidates = listOf<Int>()
if (rightHi >= edge) {
val rightStart = max(edge - 1, 0)
val seg = safeSlice(sg, from = rightStart, to = rightHi)
if (seg != null) {
val pks = findPeaks1D(seg)
rightCandidates = pks.map { it + rightStart }.filter { it >= edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
}
}
// Score candidates
data class Scored(val p: Int, val prom: Double, val dist: Int, val sidePref: Int, val edgeScore: Double)
fun scoreGroup(group: List<Int>, groupName: String): List<Scored> {
return group.mapNotNull { p ->
val valley = if (side == WallSide.ANT) findRightValley(sg, p) else findLeftValley(sg, p)
val prom = sg[p] - valley
if (sg[p] < peakMin) return@mapNotNull null
val dist = abs(p - edge)
val sidePref = if (groupName == "outer") 0 else 1
val es = prom / (1.0 + edgeDistDecay * dist)
Scored(p, prom, dist, sidePref, es)
}
}
val (outerCands, innerCands) = if (side == WallSide.ANT)
Pair(leftCandidates, rightCandidates) else Pair(rightCandidates, leftCandidates)
val outerScored = scoreGroup(outerCands, "outer")
val innerScored = scoreGroup(innerCands, "inner")
// ANT: 가장 가까운 outer peak (distance 우선, prominence tie-break)
if (side == WallSide.ANT) {
if (outerScored.isNotEmpty()) {
return outerScored.sortedWith(compareBy<Scored> { it.dist }.thenByDescending { it.prom }.thenBy { it.p }).first().p
}
return null
}
// POST: edge_score 최대
val allScored = outerScored + innerScored
if (allScored.isEmpty()) return null
return allScored.sortedWith(compareByDescending<Scored> { it.edgeScore }.thenByDescending { it.prom }.thenBy { it.dist }.thenBy { it.sidePref }.thenBy { it.p }).first().p
}
// ── Span Utilities ──
fun findPeaks1D(x: DoubleArray, height: Double? = null): List<Int> {
val n = x.size
if (n < 3) return emptyList()
val peaks = mutableListOf<Int>()
var i = 1
while (i < n - 1) {
if (x[i - 1] < x[i]) {
var j = i
while (j < n - 1 && x[j + 1] == x[j]) j++
if (j < n - 1 && x[j + 1] < x[j]) {
peaks.add((i + j) / 2)
}
i = j + 1
} else {
i++
}
}
return if (height != null) {
peaks.filter { x[it] >= height }
} else {
peaks
}
}
fun contiguousTrueSpans(mask: BooleanArray): List<Pair<Int, Int>> {
val spans = mutableListOf<Pair<Int, Int>>()
var start: Int? = null
for ((i, m) in mask.withIndex()) {
if (m && start == null) {
start = i
} else if (!m && start != null) {
spans.add(Pair(start, i - 1))
start = null
}
}
if (start != null) {
spans.add(Pair(start, mask.size - 1))
}
return spans
}
fun mergeCloseSpans(spans: List<Pair<Int, Int>>, maxGap: Int): List<Pair<Int, Int>> {
return mergeCloseSpansWithWallCheck(spans, maxGap = maxGap, signal = null, gapPeakThr = 0.0)
}
/** 병합 시 gap 내부에 벽 후보(gapPeakThr 초과 peak)가 있으면 병합하지 않음 */
fun mergeCloseSpansWithWallCheck(
spans: List<Pair<Int, Int>>, maxGap: Int,
signal: DoubleArray?, gapPeakThr: Double
): List<Pair<Int, Int>> {
if (spans.isEmpty()) return emptyList()
val ordered = spans.sortedBy { it.first }
val merged = mutableListOf(ordered[0])
for (i in 1 until ordered.size) {
val (s, e) = ordered[i]
val prevEnd = merged[merged.size - 1].second
val gap = s - prevEnd - 1
if (gap <= maxGap) {
// gap < 3은 무조건 병합 (Python peak_check_min_gap=3)
var hasWallPeak = false
if (gap >= 3 && signal != null && gapPeakThr > 0) {
val gapStart = prevEnd + 1
val gapEnd = s - 1
if (gapStart <= gapEnd) {
val gapSlice = safeSlice(signal, from = gapStart, to = gapEnd)
if (gapSlice != null) {
val peaks = findPeaks1D(gapSlice, height = gapPeakThr)
if (peaks.isNotEmpty()) hasWallPeak = true
// Also check endpoints
if (!hasWallPeak) {
if ((gapSlice.firstOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
if ((gapSlice.lastOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
}
}
}
}
if (hasWallPeak) {
// Wall candidate in gap — don't merge
merged.add(Pair(s, e))
} else {
merged[merged.size - 1] = Pair(merged[merged.size - 1].first, max(prevEnd, e))
}
} else {
merged.add(Pair(s, e))
}
}
return merged
}
fun nearestPeak(signal: DoubleArray, center: Int, left: Int, right: Int, minHeight: Double): Int? {
if (signal.isEmpty()) return null
val l = max(0, left)
val r = min(signal.size - 1, right)
val local = safeSlice(signal, from = l, to = r) ?: return null
val pkLocal = findPeaks1D(local, height = minHeight)
val candidates = (pkLocal.map { it + l }).toMutableSet()
// Endpoint correction
if (l == r) {
if (signal[l] >= minHeight) candidates.add(l)
} else {
if (signal[l] >= minHeight && signal[l] >= signal[l + 1]) candidates.add(l)
if (signal[r] >= minHeight && signal[r] >= signal[r - 1]) candidates.add(r)
}
if (candidates.isEmpty()) return null
val sorted = candidates.sorted()
// Find nearest to center, tie-break by amplitude
var bestIdx = sorted[0]
var bestDist = abs(sorted[0] - center)
for (c in sorted) {
val dist = abs(c - center)
if (dist < bestDist || (dist == bestDist && signal[c] > signal[bestIdx])) {
bestIdx = c
bestDist = dist
}
}
return bestIdx
}
fun findInnerPeaks(signal: DoubleArray, left: Int, right: Int): List<Int> {
val l = left + 1
val r = right - 1
val local = safeSlice(signal, from = l, to = r) ?: return emptyList()
return findPeaks1D(local).map { it + l }
}
// ── Safe Array Slicing ──
/** Safe inclusive range slice — returns null if bounds are invalid */
private fun safeSlice(arr: DoubleArray, from: Int, to: Int): DoubleArray? {
val l = max(0, from)
val r = min(arr.size - 1, to)
if (l > r || arr.isEmpty()) return null
return arr.sliceArray(l..r)
}
}
@@ -0,0 +1,329 @@
package com.medithings.vesiscan.managers
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* Method A — TVD → SG → Derivative + Plateau-first wall expansion.
* 1:1 port of low_echo_detection_method_a.py (원은지 연구원)
*
* Pipeline:
* 1. Condat(2013) 1D TVD
* 2. SG smooth
* 3. Composite signal y = sg + α|d1| + β|d2|
* 4. Sliding plateau score
* 5. Plateau span detection + merge
* 6. Prominence-based wall selection from plateau edges
*/
class PiezoEchoAnalyzerA private constructor() {
companion object {
val shared = PiezoEchoAnalyzerA()
}
// ── Parameters (notebook 실측 튜닝값) ──
val tvLambda: Double = 3.0
val alpha: Double = 1.0
val beta: Double = 1.0
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
val plateauMinLen: Int = 3
val plateauMergeGap: Int = 5
val scoreWin: Int = 7
val plateauQ: Double = 0.5
val edgeDistDecay: Double = 0.15
val peakSearchWin: Int = 20
val minWallProminence: Double = 3.0
val minWallContrastRatio: Double = 1.2
val minUrineLen: Int = 3
val minEffectiveDist: Int = 3
val valleyStopRise: Double = 5.0
var lastOtsuThreshold: Double = 0.0; private set
// ── Public API ──
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
if (raw.size < 10) {
return ChannelAnalysisResult(channel, null, raw, raw.copyOf())
}
return try {
val sg = PiezoEchoAnalyzer.shared.denoise(raw)
// 각도 보정: min_wall_contrast_ratio × cos(θ)
val beamAngles = PiezoHW.degreeAll
val angleFactor = if (channel < beamAngles.size) kotlin.math.cos(beamAngles[channel] * Math.PI / 180.0) else 1.0
val result = detectMethodA(raw, sg, contrastRatioOverride = minWallContrastRatio * angleFactor)
ChannelAnalysisResult(channel, result, raw, sg)
} catch (_: Exception) {
ChannelAnalysisResult(channel, null, raw, raw.copyOf())
}
}
fun detectMethodA(raw: DoubleArray, sg: DoubleArray, contrastRatioOverride: Double? = null): LowEchoResult? {
val effectiveContrastRatio = contrastRatioOverride ?: minWallContrastRatio
if (sg.size < 10) return null
// 1) Composite signal y = sg + α|d1| + β|d2|
val d1 = DoubleArray(sg.size - 1) { sg[it + 1] - sg[it] }
val d2 = DoubleArray(sg.size - 2) { sg[it + 2] - 2 * sg[it + 1] + sg[it] }
val L = d2.size
if (L < 10) return null
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
// 2) Plateau score — threshold from full array (np.quantile 보간)
val platScore = slidingScores1d(sg, scoreWin)
val scoreThr = npQuantile(platScore, plateauQ)
val limit = min(platScore.size, postMaxIdx + 1)
// 3) Plateau spans + merge (postMaxIdx 이내만)
var plateauSpans = findPlateauSpans(platScore, limit, scoreThr, plateauMinLen)
if (plateauSpans.size > 1) {
val merged = mutableListOf(plateauSpans[0])
for (i in 1 until plateauSpans.size) {
val (s, e) = plateauSpans[i]
val (ps, pe) = merged.last()
if (s - pe <= plateauMergeGap) {
merged[merged.size - 1] = Pair(ps, e)
} else {
merged.add(Pair(s, e))
}
}
plateauSpans = merged
}
// 4) Plateau-first matching
for ((spanStart, spanEnd) in plateauSpans) {
val leftLo = max(0, spanStart - peakSearchWin)
val rightHi = min(sg.size, spanEnd + peakSearchWin + 1)
val platMean = if (spanEnd >= spanStart) {
var sum = 0.0
for (i in spanStart..spanEnd) sum += sg[i]
sum / (spanEnd - spanStart + 1)
} else continue
if (platMean <= 0) continue
// Contrast check
var leftMax = 0.0
for (i in leftLo..min(spanStart, sg.size - 1)) if (sg[i] > leftMax) leftMax = sg[i]
var rightMax = 0.0
for (i in spanEnd until rightHi) if (sg[i] > rightMax) rightMax = sg[i]
if (leftMax / platMean < effectiveContrastRatio || rightMax / platMean < effectiveContrastRatio) continue
// Find peaks
val leftPks = findPeaksInRange(sg, leftLo, spanStart).filter { it < spanStart }
val rightPks = findPeaksInRange(sg, spanEnd, rightHi - 1).filter { it > spanEnd && it <= postMaxIdx }
if (leftPks.isEmpty() || rightPks.isEmpty()) continue
// Score left candidates
var bestLeftScore = -1.0; var bestLeft = -1
for (p in leftPks) {
val prom = sg[p] - findRightValley(sg, p)
if (prom < minWallProminence) continue
val dist = spanStart - p
val effDist = max(dist, minEffectiveDist)
val score = prom / (1.0 + edgeDistDecay * effDist)
if (score > bestLeftScore) { bestLeftScore = score; bestLeft = p }
}
// Score right candidates
var bestRightScore = -1.0; var bestRight = -1
for (p in rightPks) {
val prom = sg[p] - findLeftValley(sg, p)
if (prom < minWallProminence) continue
val dist = p - spanEnd
val effDist = max(dist, minEffectiveDist)
val score = prom / (1.0 + edgeDistDecay * effDist)
if (score > bestRightScore) { bestRightScore = score; bestRight = p }
}
if (bestLeft < 0 || bestRight < 0 || bestRight <= bestLeft) continue
val urineLen = bestRight - bestLeft - 1
if (urineLen < minUrineLen) continue
val lowSlice = sg.sliceArray(spanStart..spanEnd)
val lowMean = lowSlice.average()
return LowEchoResult(
ant = bestLeft, post = bestRight,
lowStart = spanStart, lowEnd = spanEnd,
lowMean = lowMean, urineLen = urineLen,
score = (sg[bestLeft] + sg[bestRight]).toDouble(),
innerPeaks = PiezoEchoAnalyzer.shared.findInnerPeaks(sg, bestLeft, bestRight)
)
}
return null
}
// ── Sliding plateau score ──
private fun slidingScores1d(x: DoubleArray, win: Int): DoubleArray {
val w = if (win % 2 == 0) win + 1 else win
val half = w / 2
val T = x.size
val flat = DoubleArray(T)
val slope = DoubleArray(T)
val low = DoubleArray(T)
// Edge padding (mode="edge") — Python np.pad 동일
val xp = DoubleArray(T + 2 * half)
for (i in 0 until half) xp[i] = x[0]
for (i in 0 until T) xp[i + half] = x[i]
for (i in 0 until half) xp[T + half + i] = x[T - 1]
val tt = DoubleArray(w) { (it - half).toDouble() }
var ttSqSum = 0.0
for (t in tt) ttSqSum += t * t
ttSqSum += 1e-12
for (i in 0 until T) {
val ww = DoubleArray(w) { xp[i + it] }
var sum = 0.0; var sqSum = 0.0
for (v in ww) { sum += v; sqSum += v * v }
val mean = sum / w
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
val sorted = ww.sorted()
low[i] = npQuantile(sorted, 0.2)
var slopeNum = 0.0
for (j in 0 until w) {
slopeNum += tt[j] * (ww[j] - mean)
}
slope[i] = abs(slopeNum / ttSqSum)
}
val rzLow = robustZ(low)
val rzFlat = robustZ(flat)
val rzSlope = robustZ(slope)
return DoubleArray(T) { rzLow[it] + rzFlat[it] + rzSlope[it] }
}
private fun robustZ(a: DoubleArray): DoubleArray {
val sorted = a.sorted().toDoubleArray()
val n = sorted.size
val med = if (n % 2 == 0) (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 else sorted[n / 2]
val deviations = DoubleArray(n) { abs(a[it] - med) }
val devSorted = deviations.sorted().toDoubleArray()
val madMed = if (n % 2 == 0) (devSorted[n / 2 - 1] + devSorted[n / 2]) / 2.0 else devSorted[n / 2]
val mad = madMed + 1e-12
return DoubleArray(n) { (a[it] - med) / (1.4826 * mad) }
}
// ── Plateau span detection ──
private fun findPlateauSpans(score: DoubleArray, limit: Int, thr: Double, minLen: Int): MutableList<Pair<Int, Int>> {
val spans = mutableListOf<Pair<Int, Int>>()
var runStart: Int? = null
for (i in 0 until limit) {
if (score[i] < thr) {
if (runStart == null) runStart = i
} else {
if (runStart != null && (i - runStart) >= minLen) {
spans.add(Pair(runStart, i - 1))
}
runStart = null
}
}
if (runStart != null && (limit - runStart) >= minLen) {
spans.add(Pair(runStart, limit - 1))
}
return spans
}
// ── Cross-validation (채널간 벽 보정) ──
// 1:1 port of _cross_validate_walls()
private val crossValMaxDev = 5
private val crossValMaxGradient = 3
fun crossValidateWalls(
walls: List<Pair<Int, Int>?>,
centerCh: List<Int> = listOf(0, 1, 2, 3)
): List<Pair<Int, Int>?> {
val corrected = walls.toMutableList()
// 1단계: 글로벌 중앙값 기반
val ants = corrected.mapNotNull { it?.first }
val posts = corrected.mapNotNull { it?.second }
if (ants.size >= 3) {
val medAnt = ants.sorted()[ants.size / 2]
val medPost = posts.sorted()[posts.size / 2]
for (i in corrected.indices) {
val w = corrected[i] ?: continue
val a = if (abs(w.first - medAnt) >= crossValMaxDev) medAnt else w.first
val p = if (abs(w.second - medPost) >= crossValMaxDev) medPost else w.second
corrected[i] = Pair(a, p)
}
}
// 2단계: 인접 center 채널 smoothness
val validCenter = centerCh.filter { it < corrected.size && corrected[it] != null }
if (validCenter.size < 3) return corrected
for (field in listOf("ant", "post")) {
val vals = validCenter.map { if (field == "ant") corrected[it]!!.first else corrected[it]!!.second }
for (j in vals.indices) {
val neighbors = mutableListOf<Int>()
if (j > 0) neighbors.add(vals[j - 1])
if (j < vals.size - 1) neighbors.add(vals[j + 1])
if (neighbors.isEmpty()) continue
val neighborMean = neighbors.average()
if (abs(vals[j] - neighborMean) > crossValMaxGradient) {
val newVal = Math.round(neighborMean).toInt()
val chIdx = validCenter[j]
val w = corrected[chIdx]!!
corrected[chIdx] = if (field == "ant") Pair(newVal, w.second) else Pair(w.first, newVal)
}
}
}
return corrected
}
// ── np.quantile 보간 (linear interpolation, numpy default) ──
private fun npQuantile(data: DoubleArray, q: Double): Double {
if (data.isEmpty()) return 0.0
val sorted = data.sorted().toDoubleArray()
return npQuantile(sorted.toList(), q)
}
private fun npQuantile(sorted: List<Double>, q: Double): Double {
if (sorted.isEmpty()) return 0.0
val n = sorted.size
val idx = q * (n - 1)
val lo = idx.toInt().coerceIn(0, n - 1)
val hi = (lo + 1).coerceAtMost(n - 1)
val frac = idx - lo
return sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
// ── Peak / Valley helpers ──
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
val lo = max(0, from); val hi = min(sg.size - 1, to)
if (hi - lo < 2) return emptyList()
val seg = sg.sliceArray(lo..hi)
return PiezoEchoAnalyzer.shared.findPeaks1D(seg).map { it + lo }
}
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx]
for (i in (peakIdx + 1) until min(sg.size, peakIdx + maxDist)) {
if (sg[i] < v) v = sg[i]
else if (sg[i] > v + valleyStopRise) break
}
return v
}
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx]
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
if (sg[i] < v) v = sg[i]
else if (sg[i] > v + valleyStopRise) break
}
return v
}
}
@@ -0,0 +1,197 @@
package com.medithings.vesiscan.managers
/**
* UrinAI (UrinGateKeeper) — 소변(액체) 존재 판정 엔진.
*
* 역할: raw ADC 신호에서 "방광(액체)이 있는가?" → true/false 반환.
* true일 때만 벽 검출 엔진(PiezoEchoAnalyzer)이 실행됨.
*
* 원리: 이진 임계 기법
* raw < LIQUID_THR_LOOSE → 액체 / raw ≥ LIQUID_THR_LOOSE → 조직
* 연속 액체 구간 + contrast 검증 → 생물학적 판정
*
* 1:1 port of UrinAI.swift / UrinAI.kt (Charles KWON)
*/
object UrinAI {
// ── Result ──
data class Result(
/** 핵심: 소변 존재 여부 */
val detected: Boolean,
/** lumen 시작점 (액체 진입) */
val fw: Int,
/** posterior wall peak */
val bw: Int,
/** 연속 순수 액체 최대 길이 */
val liquidRun: Int,
/** wall-lumen 대비 (0~1) */
val contrast: Float,
/** 원본 raw (그래프 표시용) */
val rawSignal: List<UShort>,
/** 이진 마스크 (true=액체) */
val binaryMask: List<Boolean>,
/** 순수 액체 구간들 */
val urineZones: List<IntRange>,
/** anterior wall 평가 가능 여부 */
val antWallAvailable: Boolean
)
// ── Detection ──
/** 소변 존재 판정 */
fun detect(rawAdc: List<UShort>): Result {
if (rawAdc.isEmpty()) {
return emptyResult(rawCopy = emptyList(), binary = emptyList())
}
val n = minOf(rawAdc.size, GreenZoneConstants.useSamples)
val raw = FloatArray(n) { rawAdc[it].toFloat() }
val rawCopy = rawAdc.take(n)
val binarizeThr = GreenZoneConstants.liquidThrLoose
val inUrineThr = GreenZoneConstants.liquidThrStrict
val minLiquidRun = GreenZoneConstants.minLiquidRun
val minDepth = GreenZoneConstants.minChord
val contrastMin = GreenZoneConstants.contrastMin
val ringSkip = GreenZoneConstants.ringSkip
// 1. Binarize (LIQUID_THR_LOOSE 기준)
val binary = List(n) { raw[it] < binarizeThr }
// 2. fw = 첫 번째 연속 액체 (3샘플 이상, ringdown skip)
var fw = -1
for (i in ringSkip until (n - 2)) {
if (binary[i] && binary[i + 1] && binary[i + 2]) {
fw = i
break
}
}
if (fw < 0) {
return emptyResult(rawCopy = rawCopy, binary = binary)
}
// 3. bw = fw 이후 가장 강한 조직 그룹의 peak
// Two-pass: 엄격(3샘플) → 실패 시 완화(2샘플, 소방광 대응)
var bw = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 3)
if (bw < 0 || bw - fw < minDepth) {
val bw2 = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 2)
if (bw2 >= 0 && bw2 - fw >= minDepth) {
bw = bw2
}
}
if (bw < 0) {
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw)
}
if (bw - fw < minDepth) {
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw, bw = bw)
}
// 4. 순수 액체 연속 구간 (strict threshold)
var maxRun = 0
var run = 0
var zoneStart = -1
val zones = mutableListOf<IntRange>()
for (j in fw until bw) {
if (raw[j] < inUrineThr) {
if (zoneStart < 0) zoneStart = j
run += 1
} else {
if (run > 0) {
if (run > maxRun) maxRun = run
zones.add(zoneStart until j)
}
run = 0
zoneStart = -1
}
}
if (run > 0 && zoneStart >= 0) {
if (run > maxRun) maxRun = run
zones.add(zoneStart until bw)
}
// 5. Contrast
val antWallAvailable = fw > ringSkip + 2
var antWallPeak = raw[fw]
for (j in maxOf(ringSkip, fw - 5) until fw) {
if (raw[j] > antWallPeak) antWallPeak = raw[j]
}
val wallPeak = maxOf(antWallPeak, raw[bw])
var lumenFloor = raw[fw + 1]
for (j in (fw + 2) until bw) {
if (raw[j] < lumenFloor) lumenFloor = raw[j]
}
val contrast: Float = if (wallPeak > 0) (wallPeak - lumenFloor) / wallPeak else 0f
// 6. 판정
val detected = maxRun >= minLiquidRun
&& (bw - fw) >= minDepth
&& contrast >= contrastMin
return Result(
detected = detected,
fw = fw, bw = bw,
liquidRun = maxRun, contrast = contrast,
rawSignal = rawCopy, binaryMask = binary,
urineZones = zones,
antWallAvailable = antWallAvailable
)
}
// ── Private ──
/** fw 이후 가장 강한 조직 그룹의 peak index */
private fun findBw(fw: Int, binary: List<Boolean>, raw: FloatArray, minGroupLen: Int): Int {
val n = raw.size
var bw = -1
var maxGroupSum = 0f
var i = fw + 1
// fw 이후 liquid 구간 건너뛰기
while (i < n && binary[i]) i++
while (i < n) {
if (!binary[i]) {
val gs = i
var gSum = 0f
while (i < n && !binary[i]) {
gSum += raw[i]
i++
}
val ge = i - 1
if (ge - gs + 1 >= minGroupLen && gSum > maxGroupSum) {
maxGroupSum = gSum
// peak index within group
var peakIdx = gs
for (k in (gs + 1)..ge) {
if (raw[k] > raw[peakIdx]) peakIdx = k
}
bw = peakIdx
}
} else {
i++
}
}
return bw
}
/** 실패 시 빈 결과 */
private fun emptyResult(
rawCopy: List<UShort>, binary: List<Boolean>,
fw: Int = 0, bw: Int = 0
): Result = Result(
detected = false,
fw = fw, bw = bw,
liquidRun = 0, contrast = 0f,
rawSignal = rawCopy, binaryMask = binary,
urineZones = emptyList(),
antWallAvailable = false
)
}
@@ -0,0 +1,67 @@
/*
* WakeupDetector — LYING → NON_LYING 전이 감지 (낙상 예방용 기상 알람).
*
* 로직:
* - 사용자가 LYING 상태로 들어가면 startTimestamp 기록
* - LYING 으로 lyingDurationSec(default 180) 이상 유지 후 NON_LYING 으로 전환 → 기상 인정
* - 기상 인정 시 onWakeup(elapsedSec) 콜백 호출 (한 번)
* - 알람 발동 후 다시 LYING 으로 돌아가야 재무장 (재발동 가능)
*
* UNKNOWN 상태는 무시 (직전 상태 유지). 자세 분류 게이트 실패 시 잠깐 ?_ 떠도 detector 영향 X.
*
* 사용:
* val detector = WakeupDetector(lyingDurationSec = 180)
* detector.onWakeup = { elapsedSec -> showAlarm() }
* // 매 posture 갱신 시:
* detector.update(currentPosture, System.currentTimeMillis())
*/
package com.medithings.vesiscan.managers
class WakeupDetector(
/** 알람 발동을 위해 직전 LYING 상태로 유지되어야 하는 최소 시간 (초). */
var lyingDurationSec: Int = 180,
) {
/** 일어남 감지 시 호출 — argument 는 직전 LYING 유지 시간 (초). */
var onWakeup: ((elapsedSec: Long) -> Unit)? = null
private var lastPosture: Posture = Posture.UNKNOWN
private var lyingStartMs: Long = 0L // 현재 LYING streak 시작 시각 (0 = LYING 아님)
private var armed: Boolean = true // false 면 알람 1회 발동 후 LYING 복귀 대기
/** 외부 reset (Settings 토글 변경 등). */
fun reset() {
lastPosture = Posture.UNKNOWN
lyingStartMs = 0L
armed = true
}
/** 현재 자세 + 현재 시각으로 갱신. UNKNOWN 은 직전 상태 유지. */
fun update(posture: Posture, nowMs: Long = System.currentTimeMillis()) {
if (posture == Posture.UNKNOWN) return
val prev = lastPosture
lastPosture = posture
when {
// LYING 진입 — start timestamp 기록 + 재무장
posture == Posture.LYING && prev != Posture.LYING -> {
lyingStartMs = nowMs
armed = true
}
// LYING 유지 — 그대로
posture == Posture.LYING -> { /* keep streak */ }
// LYING → NON_LYING 전이 — 알람 판정
posture == Posture.NON_LYING && prev == Posture.LYING -> {
val elapsedMs = nowMs - lyingStartMs
val elapsedSec = elapsedMs / 1000
if (armed && lyingStartMs > 0 && elapsedSec >= lyingDurationSec) {
armed = false
onWakeup?.invoke(elapsedSec)
}
lyingStartMs = 0L
}
// NON_LYING 유지 — 그대로
else -> { /* nothing */ }
}
}
}
@@ -0,0 +1,152 @@
/*
* WalkingDetector — flowchart Path B (4-2 / 5-2) 구현.
*
* 알고리즘:
* 1. Sliding window (walkingWindowMs, default 1.5초) 동안 IMU gyro sample 누적
* 2. window 의 gyro magnitude 평균 (wmean) 과 표준편차 (wstd) 계산
* 3. 상태 머신:
* IDLE (not walking):
* 조건: |pitch| > walkingMinPitchDeg
* AND wmean > walkingStartWmean (default 4)
* AND wstd > walkingStartWstd (default 2)
* → 조건 만족이 walkingStartHoldMs (default 1초) 이상 지속 → WALKING
* WALKING:
* 조건: |pitch| > walkingMinPitchDeg
* AND wmean > walkingKeepWmean (default 2)
* → 조건 미달이 walkingKeepHoldMs (default 2초) 이상 지속 → IDLE
*
* 입력: 매 mtb cycle (≈1초 주기) 마다 IMU sample 리스트 + 현재 pitch + 시각
* 출력: 현재 walking 상태 (Boolean) + 진단 정보
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.ble.ImuSample
import kotlin.math.abs
import kotlin.math.sqrt
class WalkingDetector {
/** 진단 결과. UI/log 용. */
data class Snapshot(
val isWalking: Boolean,
val wmean: Float,
val wstd: Float,
val pitchOk: Boolean,
val samplesInWindow: Int,
val reason: String,
)
private data class TsGyro(val tsMs: Long, val magDps: Float)
private val buffer = ArrayDeque<TsGyro>()
private var walking = false
/** IDLE 에서 시작 조건 처음 만족된 시각. null 이면 미만족 / 끊김. */
private var startCondSinceMs: Long? = null
/** WALKING 에서 유지 조건 마지막 만족 시각. */
private var keepCondLastOkMs: Long? = null
/** 직전 cycle 의 pitchOk — false→true 전이 시 buffer reset (sit-to-stand burst 차단). */
private var lastPitchOk: Boolean = false
val isWalking: Boolean get() = walking
fun reset() {
buffer.clear()
walking = false
startCondSinceMs = null
keepCondLastOkMs = null
lastPitchOk = false
}
/**
* 매 cycle 마다 호출. samples = 이번 cycle 에 들어온 IMU sample 리스트.
* pitchDeg = 같은 cycle 의 pitch (ImuPostureClassifier 가 계산한 값).
*/
fun update(samples: List<ImuSample>, pitchDeg: Float, nowMs: Long): Snapshot {
val minPitch = GreenZoneConstants.walkingMinPitchDeg
val pitchOk = abs(pitchDeg) > minPitch
// Pitch false→true 전이 (sit-to-stand 등) — buffer 비움.
// 이유: 일어남 burst 가 pitch<45° 일 때 발생 → window 에 누적 → pitch 가 통과한
// 순간 wmean 이 이미 큰 값 → 즉시 start cond 만족 → 가짜 walking trigger.
// pitch 가 true 가 된 직후엔 새 데이터만으로 measure 하도록.
if (pitchOk && !lastPitchOk && !walking) {
buffer.clear()
startCondSinceMs = null
}
lastPitchOk = pitchOk
// 1) sample buffer 에 추가 (timestamp 단순 분배 — cycle 내 균등 간격 가정)
val windowMs = GreenZoneConstants.walkingWindowMs
if (samples.isNotEmpty()) {
// cycle 내 각 sample 의 timestamp 를 nowMs 기준으로 역산 (≈ ICM ODR 50Hz → 20ms 간격)
val sampleGapMs = 20L
val startTs = nowMs - (samples.size - 1) * sampleGapMs
for ((i, s) in samples.withIndex()) {
val mag = sqrt(s.gx * s.gx + s.gy * s.gy + s.gz * s.gz)
buffer.addLast(TsGyro(startTs + i * sampleGapMs, mag))
}
}
// 2) window 밖 sample 제거
val cutoff = nowMs - windowMs
while (buffer.isNotEmpty() && buffer.first().tsMs < cutoff) buffer.removeFirst()
// 3) wmean, wstd 계산
val n = buffer.size
if (n < 2) {
return Snapshot(walking, 0f, 0f, false, n, "buffer too small")
}
var sum = 0f
for (b in buffer) sum += b.magDps
val wmean = sum / n
var sqSum = 0f
for (b in buffer) {
val d = b.magDps - wmean
sqSum += d * d
}
val wstd = sqrt(sqSum / n)
// 4) state machine (pitchOk 은 위에서 이미 계산됨)
if (!walking) {
// IDLE → WALKING 검사 (4-2)
val cond = pitchOk &&
wmean > GreenZoneConstants.walkingStartWmean &&
wstd > GreenZoneConstants.walkingStartWstd
if (cond) {
if (startCondSinceMs == null) startCondSinceMs = nowMs
val held = nowMs - startCondSinceMs!!
if (held >= GreenZoneConstants.walkingStartHoldMs) {
walking = true
keepCondLastOkMs = nowMs
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING start (held ${held}ms)")
}
return Snapshot(false, wmean, wstd, pitchOk, n,
"walking start cond met for ${held}ms")
} else {
startCondSinceMs = null
return Snapshot(false, wmean, wstd, pitchOk, n,
"idle (pitchOk=$pitchOk wmean=${"%.1f".format(wmean)} wstd=${"%.1f".format(wstd)})")
}
} else {
// WALKING → IDLE 검사 (5-2)
val cond = pitchOk && wmean > GreenZoneConstants.walkingKeepWmean
if (cond) {
keepCondLastOkMs = nowMs
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING keep (wmean=${"%.1f".format(wmean)})")
} else {
// 조건 미달 — grace 시간 체크
val sinceOk = nowMs - (keepCondLastOkMs ?: nowMs)
if (sinceOk >= GreenZoneConstants.walkingKeepHoldMs) {
walking = false
startCondSinceMs = null
keepCondLastOkMs = null
return Snapshot(false, wmean, wstd, pitchOk, n,
"WALKING end (grace ${sinceOk}ms exceeded)")
}
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING grace (${sinceOk}ms / ${GreenZoneConstants.walkingKeepHoldMs}ms)")
}
}
}
}
@@ -0,0 +1,8 @@
package com.medithings.vesiscan.measure
data class CCPosition(
val cc: Int,
val x: Int,
val y: Int,
val predicted: Boolean = false
)
@@ -0,0 +1,287 @@
package com.medithings.vesiscan.measure
import android.graphics.Bitmap
import android.graphics.Color
import android.util.Log
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import com.medithings.vesiscan.measure.CCPosition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.math.roundToInt
/**
* V2 측정 서비스 — 순수 Kotlin, C++/OpenCV 불필요.
* iOS 3-zone Center 2-peak 방식.
*
* YOLO crop → OCR → 3-zone gradient (Center 2-peak) → yToCC
*/
object SimpleMeasureService {
private const val TAG = "SimpleMeasure"
data class MeasureResult(
val volume: Int?,
val isSmallVolume: Boolean = false,
val timeMs: Long = 0,
val log: String = ""
)
suspend fun measure(bitmap: Bitmap): MeasureResult = withContext(Dispatchers.Default) {
val log = StringBuilder()
val t0 = System.currentTimeMillis()
// 밝기 체크
val brightness = checkBrightness(bitmap)
log.appendLine("밝기: ${"%.0f".format(brightness)}/255")
if (brightness < 80) {
log.appendLine("⚠ 이미지가 너무 어둡습니다")
return@withContext MeasureResult(null, log = log.toString(), timeMs = System.currentTimeMillis() - t0)
}
// OCR
val rawOcr = runOCR(bitmap)
val t1 = System.currentTimeMillis()
val ocrPositions = filterOcrPositions(rawOcr)
log.appendLine("OCR: ${t1 - t0}ms → ${ocrPositions.size}개 눈금")
if (ocrPositions.size < 2) {
return@withContext MeasureResult(null, log = log.toString() + "눈금 부족", timeMs = System.currentTimeMillis() - t0)
}
// 스캔 영역: 0~500cc 전체 범위로 외삽
val sorted = ocrPositions.sortedBy { it.y }
val avgGap = (sorted.last().y - sorted.first().y).toFloat() / (sorted.size - 1)
val pxPer50cc = avgGap
val topOcrCC = sorted.first().cc.toFloat() // e.g., 450
val botOcrCC = sorted.last().cc.toFloat() // e.g., 100
val gapsAbove = (500f - topOcrCC) / 50f
val gapsBelow = botOcrCC / 50f
val scanTop = (sorted.first().y - pxPer50cc * gapsAbove - 10).toInt().coerceAtLeast(0)
val scanBot = (sorted.last().y + pxPer50cc * gapsBelow + 10).toInt().coerceAtMost(bitmap.height - 1)
val width = bitmap.width
// OCR 좌표 기반 zone 설정
val ocrMaxX = ocrPositions.maxOf { it.x }
val ocrOnLeft = ocrPositions.map { it.x }.average() < width / 2.0
val zoneALeft: Int; val zoneARight: Int
val zoneBLeft: Int; val zoneBRight: Int
val zoneCLeft: Int; val zoneCRight: Int
if (ocrOnLeft) {
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.33).toInt()
zoneBLeft = (width * 0.35).toInt(); zoneBRight = (width * 0.55).toInt()
zoneCLeft = (width * 0.60).toInt(); zoneCRight = (width * 0.85).toInt()
} else {
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.40).toInt()
zoneBLeft = (width * 0.45).toInt(); zoneBRight = (width * 0.65).toInt()
zoneCLeft = (width * 0.67).toInt(); zoneCRight = (width * 0.85).toInt()
}
// 3-zone: Center(B) primary + Left(A)/Right(C) cross-zone 확인
val peaksB = computeZonePeaks(bitmap, scanTop, scanBot, zoneBLeft, zoneBRight)
val peaksA = computeZonePeaks(bitmap, scanTop, scanBot, zoneALeft, zoneARight)
val peaksC = computeZonePeaks(bitmap, scanTop, scanBot, zoneCLeft, zoneCRight)
val t2 = System.currentTimeMillis()
val surfaceY: Int
if (peaksB.isEmpty()) {
val allSide = peaksA + peaksC
if (allSide.isEmpty()) {
return@withContext MeasureResult(null, log = log.toString() + "감지 실패", timeMs = System.currentTimeMillis() - t0)
}
surfaceY = allSide.maxByOrNull { it.second }!!.first
} else {
val maxVal = peaksB.maxOf { it.second }
val rawSigPeaks = peaksB.filter { it.second >= maxVal * 0.3f }.sortedByDescending { it.second }
// 근접 peak 병합: 0.3*avgGap 이내면 건너뜀
val minPeakDist = (avgGap * 0.3f).toInt()
val sigPeaks = mutableListOf<Pair<Int, Float>>()
for (sp in rawSigPeaks) {
val tooClose = sigPeaks.any { kotlin.math.abs(it.first - sp.first) < minPeakDist }
if (!tooClose) sigPeaks.add(sp)
}
// Cross-zone 확인: Left(A)/Right(C)에서도 비슷한 y에 peak가 있는지 확인
// Center peak가 OCR 텍스트 피처일 수 있으므로, 확인되는 peak를 우선 승격
val confirmRadius = (avgGap * 0.5f).toInt()
fun countConfirming(peakY: Int): Int {
var count = 0
if (peaksA.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
if (peaksC.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
return count
}
// 상위 5개 중 첫 번째로 확인되는 peak를 승격
val topCandidates = sigPeaks.take(5)
val confirmedPeak = topCandidates.firstOrNull { countConfirming(it.first) >= 1 }
val orderedPeaks = if (confirmedPeak != null && confirmedPeak != topCandidates.firstOrNull()) {
log.appendLine("승격: y=${confirmedPeak.first} (확인됨) → 최강 y=${sigPeaks[0].first} 대체")
val reordered = mutableListOf(confirmedPeak)
reordered.addAll(sigPeaks.filter { it != confirmedPeak })
reordered
} else {
sigPeaks
}
if (orderedPeaks.size >= 2) {
val ellipseTop = minOf(orderedPeaks[0].first, orderedPeaks[1].first)
val ellipseBot = maxOf(orderedPeaks[0].first, orderedPeaks[1].first)
val peakDistance = ellipseBot - ellipseTop
val maxEllipseSpan = (avgGap * 1.0f).toInt()
if (peakDistance > maxEllipseSpan) {
surfaceY = orderedPeaks[0].first
log.appendLine("타원 거부: 거리 ${peakDistance}px > 한계 ${maxEllipseSpan}px → peak y=$surfaceY")
} else {
surfaceY = ellipseBot
log.appendLine("타원: top=$ellipseTop, bot=$ellipseBot, 거리=${peakDistance}px")
}
} else {
surfaceY = orderedPeaks[0].first
}
}
// yToCC 보간
val volume = interpolate(surfaceY, sorted)
val totalMs = System.currentTimeMillis() - t0
log.appendLine("수면: y=$surfaceY → ${volume}ml (${totalMs}ms)")
Log.d(TAG, "V2: ${volume}ml, ${totalMs}ms")
MeasureResult(volume = volume, timeMs = totalMs, log = log.toString())
}
private fun computeZonePeaks(bitmap: Bitmap, scanTop: Int, scanBot: Int, leftX: Int, rightX: Int): List<Pair<Int, Float>> {
val height = bitmap.height
val width = bitmap.width
val satProfile = FloatArray(height)
for (y in scanTop..scanBot) {
var satSum = 0f; var count = 0
for (x in leftX until rightX) {
if (x < 0 || x >= width) continue
val pixel = bitmap.getPixel(x, y)
val r = Color.red(pixel) / 255f; val g = Color.green(pixel) / 255f; val b = Color.blue(pixel) / 255f
val max = maxOf(r, g, b); val min = minOf(r, g, b)
satSum += if (max > 0) (max - min) / max else 0f
count++
}
satProfile[y] = if (count > 0) satSum / count else 0f
}
val smoothed = FloatArray(height)
for (y in scanTop..scanBot) {
var sum = 0f; var cnt = 0
for (k in -10..10) { val idx = y + k; if (idx in scanTop..scanBot) { sum += satProfile[idx]; cnt++ } }
smoothed[y] = if (cnt > 0) sum / cnt else 0f
}
val step = 15
val grad = FloatArray(height)
for (y in (scanTop + step)..(scanBot - step)) { grad[y] = smoothed[y + step] - smoothed[y] }
val gradSmooth = FloatArray(height)
for (y in scanTop..scanBot) {
var sum = 0f; var cnt = 0
for (k in -5..5) { val idx = y + k; if (idx in scanTop..scanBot) { sum += grad[idx]; cnt++ } }
gradSmooth[y] = if (cnt > 0) sum / cnt else 0f
}
val peaks = mutableListOf<Pair<Int, Float>>()
for (y in (scanTop + 1)..(scanBot - 1)) {
if (gradSmooth[y] > 0 && gradSmooth[y] >= gradSmooth[y - 1] && gradSmooth[y] >= gradSmooth[y + 1]) {
peaks.add(Pair(y, gradSmooth[y]))
}
}
return peaks
}
private fun interpolate(surfaceY: Int, sorted: List<CCPosition>): Int? {
if (sorted.size < 2) return null
// 외삽: OCR 최상단 위 (450cc 이상 → 500cc 방향)
if (surfaceY <= sorted.first().y) {
val pxPerCC = (sorted[1].y - sorted[0].y).toFloat() / (sorted[1].cc - sorted[0].cc)
val extrapolated = sorted.first().cc + (surfaceY - sorted.first().y) / pxPerCC
return extrapolated.coerceIn(0f, 500f).roundToInt()
}
// 외삽: OCR 최하단 아래 (100cc 이하 → 0cc 방향)
if (surfaceY >= sorted.last().y) {
val n = sorted.size
val pxPerCC = (sorted[n - 1].y - sorted[n - 2].y).toFloat() / (sorted[n - 1].cc - sorted[n - 2].cc)
val extrapolated = sorted.last().cc + (surfaceY - sorted.last().y) / pxPerCC
return extrapolated.coerceIn(0f, 500f).roundToInt()
}
// 보간: 인접한 두 마크 사이
for (i in 0 until sorted.size - 1) {
val upper = sorted[i]; val lower = sorted[i + 1]
if (surfaceY in upper.y..lower.y) {
val t = (surfaceY - upper.y).toFloat() / (lower.y - upper.y).toFloat()
return (upper.cc + t * (lower.cc - upper.cc)).roundToInt()
}
}
return null
}
private fun checkBrightness(bitmap: Bitmap): Float {
val w = bitmap.width; val h = bitmap.height
var sum = 0L; var count = 0
var y = h / 6
while (y < h / 3) {
var x = w / 4
while (x < w * 3 / 4) {
val p = bitmap.getPixel(x, y)
sum += (Color.red(p) * 299 + Color.green(p) * 587 + Color.blue(p) * 114) / 1000
count++; x += 4
}; y += 4
}
return if (count > 0) sum.toFloat() / count else 0f
}
private suspend fun runOCR(bitmap: Bitmap): List<CCPosition> =
suspendCancellableCoroutine { cont ->
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
recognizer.process(InputImage.fromBitmap(bitmap, 0))
.addOnSuccessListener { visionText ->
val positions = mutableListOf<CCPosition>()
val regex = Regex("""(\d{2,3})\s*(?:cc|ml|CC|ML|Cc|mL)?""")
for (block in visionText.textBlocks) {
for (line in block.lines) {
val match = regex.find(line.text) ?: continue
val ccVal = match.groupValues[1].toIntOrNull() ?: continue
if (ccVal !in 50..500 || ccVal % 50 != 0) continue
val bbox = line.boundingBox ?: continue
positions.add(CCPosition(cc = ccVal, x = bbox.centerX(), y = bbox.centerY()))
}
}
cont.resume(positions.distinctBy { it.cc }.sortedBy { it.y })
}
.addOnFailureListener { cont.resume(emptyList()) }
}
private fun filterOcrPositions(raw: List<CCPosition>): List<CCPosition> {
if (raw.size < 2) return raw
val sortedByY = raw.sortedBy { it.y }
val valid = mutableListOf<CCPosition>()
for (pos in sortedByY) {
if (valid.isEmpty() || pos.cc < valid.last().cc) valid.add(pos)
}
if (valid.size >= 3) {
val gaps = (0 until valid.size - 1).map { valid[it + 1].y - valid[it].y }
val medianGap = gaps.sorted()[gaps.size / 2]
val filtered = mutableListOf(valid.first())
for (i in 1 until valid.size) {
val gap = valid[i].y - filtered.last().y
if (gap > medianGap * 0.3 && gap < medianGap * 3.0) filtered.add(valid[i])
}
return filtered
}
return valid
}
}
@@ -0,0 +1,91 @@
package com.medithings.vesiscan.measure
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
/**
* 배뇨 기록 데이터
*/
data class VoidingRecord(
val id: String = UUID.randomUUID().toString(),
val volume: Int?, // ml (null이면 기상/취침)
val activity: String = "none", // "none", "awake", "sleep"
val method: String = "manual", // "manual", "voice", "camera"
val timestamp: Long = System.currentTimeMillis()
) {
companion object {
private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
}
val timeString: String
get() = timeFormat.format(Date(timestamp))
val dateString: String
get() = dateFormat.format(Date(timestamp))
val displayText: String
get() = when {
volume != null -> "$timeString 소변량 $volume ml"
activity == "awake" -> "$timeString 기상"
activity == "sleep" -> "$timeString 취침"
else -> "$timeString 기록"
}
}
/**
* 로컬 배뇨 기록 저장소 (SharedPreferences + JSON)
*/
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
object VoidingRecordStore {
private const val PREFS_NAME = "voiding_records"
private const val KEY_RECORDS = "records"
private val gson = Gson()
private fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getAll(context: Context): List<VoidingRecord> {
val json = prefs(context).getString(KEY_RECORDS, null) ?: return emptyList()
val type = object : TypeToken<List<VoidingRecord>>() {}.type
return try { gson.fromJson(json, type) } catch (_: Exception) { emptyList() }
}
fun add(context: Context, record: VoidingRecord) {
val records = getAll(context).toMutableList()
records.add(record)
save(context, records)
}
fun delete(context: Context, id: String) {
val records = getAll(context).filter { it.id != id }
save(context, records)
}
fun clear(context: Context) {
GlobalScope.launch(Dispatchers.IO) {
prefs(context).edit().remove(KEY_RECORDS).apply()
}
}
private fun save(context: Context, records: List<VoidingRecord>) {
GlobalScope.launch(Dispatchers.IO) {
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
}
}
fun getToday(context: Context): List<VoidingRecord> {
val todayStart = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
return getAll(context).filter { it.timestamp >= todayStart }.sortedBy { it.timestamp }
}
}
@@ -0,0 +1,130 @@
package com.medithings.vesiscan.measure
import android.content.Context
import android.graphics.Bitmap
import android.graphics.RectF
import android.util.Log
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import java.nio.FloatBuffer
class YoloDetector(private val context: Context) {
private var session: OrtSession? = null
private var ortEnv: OrtEnvironment? = null
private val inputSize = 640
private var isLoaded = false
data class DetectionResult(
val boundingBox: RectF, // Normalized [0,1], top-left origin
val confidence: Float,
val label: String
)
fun loadModel(): Boolean {
return try {
ortEnv = OrtEnvironment.getEnvironment()
val modelBytes = context.assets.open("urinecup_best.onnx").readBytes()
session = ortEnv!!.createSession(modelBytes)
isLoaded = true
Log.d(TAG, "ONNX YOLO model loaded successfully")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to load ONNX model", e)
isLoaded = false
false
}
}
fun isModelLoaded(): Boolean = isLoaded
fun detect(bitmap: Bitmap): DetectionResult? {
val session = this.session ?: return null
val env = this.ortEnv ?: return null
try {
val resized = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true)
// Bitmap → float array [1, 3, 640, 640] NCHW, normalized 0-1
val floatBuffer = FloatBuffer.allocate(1 * 3 * inputSize * inputSize)
val pixels = IntArray(inputSize * inputSize)
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
for (c in 0 until 3) {
for (i in pixels.indices) {
val pixel = pixels[i]
val value = when (c) {
0 -> ((pixel shr 16) and 0xFF) / 255f // R
1 -> ((pixel shr 8) and 0xFF) / 255f // G
2 -> (pixel and 0xFF) / 255f // B
else -> 0f
}
floatBuffer.put(value)
}
}
floatBuffer.rewind()
val shape = longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong())
val inputTensor = OnnxTensor.createTensor(env, floatBuffer, shape)
val inputName = session.inputNames.first()
val results = session.run(mapOf(inputName to inputTensor))
// YOLOv8 output: [1, 5, 8400]
val outputTensor = results[0] as OnnxTensor
@Suppress("UNCHECKED_CAST")
val output = (outputTensor.value as Array<Array<FloatArray>>)[0]
inputTensor.close()
results.close()
return parseYoloOutput(output)
} catch (e: Exception) {
Log.e(TAG, "YOLO detection failed", e)
return null
}
}
private fun parseYoloOutput(output: Array<FloatArray>): DetectionResult? {
// output: [5, 8400] → rows = cx, cy, w, h, conf
val numDetections = output[0].size
var bestConf = 0.25f
var bestBox: FloatArray? = null
for (i in 0 until numDetections) {
val conf = output[4][i]
if (conf > bestConf) {
bestConf = conf
val cx = output[0][i] / inputSize
val cy = output[1][i] / inputSize
val w = output[2][i] / inputSize
val h = output[3][i] / inputSize
bestBox = floatArrayOf(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
}
}
return bestBox?.let {
DetectionResult(
boundingBox = RectF(
it[0].coerceIn(0f, 1f),
it[1].coerceIn(0f, 1f),
it[2].coerceIn(0f, 1f),
it[3].coerceIn(0f, 1f)
),
confidence = bestConf,
label = "urinecup"
)
}
}
fun close() {
session?.close()
session = null
isLoaded = false
}
companion object {
private const val TAG = "YoloDetector"
}
}
@@ -0,0 +1,28 @@
package com.medithings.vesiscan.models
data class BladderLevel(
var currentLevel: Int = 0,
var catheterizationThreshold: Int = 8
) {
val urgency: UrgencyLevel
get() = when {
currentLevel >= catheterizationThreshold -> UrgencyLevel.URGENT
currentLevel == catheterizationThreshold - 1 -> UrgencyLevel.WARNING
else -> UrgencyLevel.SAFE
}
val progress: Double
get() = currentLevel.toDouble() / MAX_LEVEL.toDouble()
fun increment() {
if (currentLevel < MAX_LEVEL) currentLevel++
}
fun reset() {
currentLevel = 0
}
companion object {
const val MAX_LEVEL = 10
}
}
@@ -0,0 +1,89 @@
package com.medithings.vesiscan.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 임상 R&D 측정 — "한 측정 단위" 모델.
*
* 한 ClinicalSession = 한 라벨 = 한 폴더 = 한 측정 사이클.
* 사용자가 BLE 연결 후 매번 자세/위치를 입력하고 측정 진행 → Back 시 폴더 finalize.
*
* 디바이스 식별자는 BLE 연결된 기기 이름(예: VBT26050202)을 자동으로 사용.
*
* 데이터 구조 (한 측정당):
* Downloads/VesiScan_Sessions/{folderName}/
* ├─ meta.json
* ├─ adc.csv
* ├─ placements.jsonl (alignment) or events.jsonl (BV)
* └─ ble.log
*/
enum class ClinicalPosture(val label: String) {
SUPINE("Supine"), SITTING("Sitting"), STANDING("Standing")
}
/** 측정 대상 분류 — 회의 결정 (subject ID 자유 입력 대체). */
enum class SubjectType(val label: String) {
PHANTOM("Phantom"), HUMAN("Human"), ETC("ETC")
}
/**
* 측정 단계 — 회의 결정으로 BV 제거. 정렬모드 알고리즘 개발용 5단계만.
* - ALIGN_0CM ~ ALIGN_4CM: 기기 아래 끝 기준 치골 위 0~4cm
*/
enum class ClinicalStep(val label: String, val description: String, val isAlignment: Boolean) {
ALIGN_0CM("0 cm", "기기 아래 끝이 치골 바로 위", true),
ALIGN_1CM("1 cm", "치골 위로 1 cm", true),
ALIGN_2CM("2 cm", "치골 위로 2 cm", true),
ALIGN_3CM("3 cm", "치골 위로 3 cm", true),
ALIGN_4CM("4 cm", "치골 위로 4 cm", true),
}
/**
* 한 ClinicalSession 의 데이터 수집 모드.
* - ALIGNMENT : PlacementGuide(Sensor Alignment) 에서 V1 advisor 프레임 + 라벨 누적
* → labdb 업로드 (dataType="002")
* - MEASUREMENT: ClinicalLive 에서 6ch 실시간 측정 누적 (기존 동작)
* → labdb 업로드 (dataType="001")
*/
enum class SessionMode { ALIGNMENT, MEASUREMENT }
data class ClinicalSession(
val deviceName: String, // BLE 기기 이름 (예: "VBT26050202")
val posture: ClinicalPosture,
val step: ClinicalStep,
val examiner: String = "",
val notes: String = "",
val subjectType: SubjectType = SubjectType.PHANTOM,
val subject: String = "",
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
val mode: SessionMode = SessionMode.MEASUREMENT, // 신규
val alignmentAlgo: String = "", // demo-final: V1 only
val startedAt: Long = System.currentTimeMillis(),
var endedAt: Long? = null
) {
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
/** mode == ALIGNMENT 시 "_ALIGNMODE_" 접미사 (Live 6ch 세션과 폴더 분리). */
val folderName: String
get() = buildString {
append(subjectType.name)
if (subject.isNotBlank()) {
append('-')
append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-"))
}
append('_')
append(deviceName); append('_')
append(posture.name); append('_')
append(step.name); append('_')
if (mode == SessionMode.ALIGNMENT) append("ALIGNMODE_")
append(folderFmt.format(Date(startedAt)))
}
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
val label: String
get() = "${deviceName}_${posture.name}_${step.name}"
}
@@ -0,0 +1,35 @@
package com.medithings.vesiscan.models
import java.util.Date
import java.util.UUID
enum class MeasurementEventType {
MEASUREMENT, CATHETERIZATION
}
data class MeasurementRecord(
val id: String = UUID.randomUUID().toString(),
val timestamp: Long = System.currentTimeMillis(),
val eventType: MeasurementEventType,
val level: Int,
val estimatedVolume: Int,
val urgency: String,
val fillPercentage: Double
) {
constructor(
eventType: MeasurementEventType,
level: Int,
estimatedVolume: Int,
urgencyLevel: UrgencyLevel,
fillPercentage: Double
) : this(
eventType = eventType,
level = level,
estimatedVolume = estimatedVolume,
urgency = urgencyLevel.rawStringValue,
fillPercentage = fillPercentage
)
val urgencyLevel: UrgencyLevel
get() = UrgencyLevel.fromString(urgency)
}
@@ -0,0 +1,49 @@
package com.medithings.vesiscan.models
import androidx.compose.ui.graphics.Color
object NirsConstants {
// Hardware Parameters
val hardwareAmp = doubleArrayOf(1.2, 5.008, 11.22, 37.072)
val ledPower = doubleArrayOf(5.0, 10.0, 15.0, 20.0)
val spectralResponse = doubleArrayOf(0.457, 0.561, 0.602, 0.653, 0.71, 0.725)
val dpf = doubleArrayOf(7.0, 5.8, 5.5, 5.2, 4.9, 4.7)
// Extinction Coefficients
val extinctionHbO2 = doubleArrayOf(0.303, 0.586, 0.816, 1.058, 1.224, 1.162)
val extinctionHb = doubleArrayOf(2.968, 1.549, 0.762, 0.691, 0.777, 0.429)
val extinctionH2O = doubleArrayOf(0.003, 0.026, 0.020, 0.044, 0.267, 0.448)
// Butterworth IIR Filter (2nd order, Fc=1.5Hz, Fs=4Hz)
val filterB = doubleArrayOf(0.4208, 0.8416, 0.4208)
val filterA = doubleArrayOf(1.0, 0.3533, 0.3300)
// SRS Parameters
const val srsMixNear = 0.8
const val srsMixFar = 0.2
const val srsPathLength = 2.6
const val referencePower = 20.0
const val oxyScale = 22.0
// Dimensions
const val numPackages = 4
const val numWavelengths = 6
const val numChannels = numPackages * numWavelengths
// Wavelength Definitions
val wavelengths = intArrayOf(665, 760, 800, 850, 920, 970)
val wavelengthNames = arrayOf("665nm", "760nm", "800nm", "850nm", "920nm", "970nm")
val wavelengthColors = listOf(
Color.Red,
Color.Blue,
Color(0xFF33CC33),
Color(0xFFFF9500),
Color(0xFF9966FF),
Color(0xFF666666)
)
// Chart History
const val maxHistoryPoints = 240
const val streamingIntervalMs = 250L
}
@@ -0,0 +1,106 @@
package com.medithings.vesiscan.models
data class NirsMcjPacket(
val battery: Int,
val temperature: Int,
val accX: Short,
val accY: Short,
val accZ: Short,
val gyrX: Short,
val gyrY: Short,
val gyrZ: Short,
val ledValues: IntArray
) {
companion object {
private const val TAG = "NirsMcjPacket"
fun parse(data: ByteArray): NirsMcjPacket? {
if (data.size < 72) {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
}
return null
}
fun u16(offset: Int): Int =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
fun s16(offset: Int): Short =
u16(offset).toShort()
val base = 4
val battery = u16(base)
val temperature = u16(base + 2)
val accX = s16(base + 4)
val accY = s16(base + 6)
val accZ = s16(base + 8)
val gyrX = s16(base + 10)
val gyrY = s16(base + 12)
val gyrZ = s16(base + 14)
val ledValues = IntArray(24)
for (i in 0 until 24) {
val offset = base + 20 + (i * 2)
if (offset + 1 >= data.size) {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
}
return null
}
ledValues[i] = u16(offset)
}
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
}
return NirsMcjPacket(
battery, temperature,
accX, accY, accZ,
gyrX, gyrY, gyrZ,
ledValues
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NirsMcjPacket) return false
return battery == other.battery && ledValues.contentEquals(other.ledValues)
}
override fun hashCode(): Int = battery * 31 + ledValues.contentHashCode()
}
data class NirsMagResponse(val gainValues: IntArray) {
companion object {
fun parse(data: ByteArray): NirsMagResponse? {
if (data.size < 52) return null
val base = 4
val values = IntArray(24) { i ->
val offset = base + (i * 2)
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
return NirsMagResponse(values)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NirsMagResponse) return false
return gainValues.contentEquals(other.gainValues)
}
override fun hashCode(): Int = gainValues.contentHashCode()
}
data class NirsOxyResult(
val hbO2: Double = 0.0,
val hb: Double = 0.0,
val tHb: Double = 0.0,
val stO2: Double = 0.0
) {
companion object {
val zero = NirsOxyResult()
}
}
@@ -0,0 +1,23 @@
package com.medithings.vesiscan.models
enum class Posture(val displayName: String) {
STANDING("Standing"),
SITTING("Sitting"),
LYING("Lying Down")
}
enum class SensorPosition(val displayName: String, val description: String) {
LOWER_ABDOMEN("Lower Abdomen", "Below the navel"),
SUPRAPUBIC("Suprapubic", "Above the pubic bone")
}
data class PiezoSettings(
var posture: Posture = Posture.SITTING,
var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN,
var maxVolume: Int = 500,
// Charles 833c110: BV calculation tweak — anterior-wall pull-in magnitude in millimetres.
var antOffsetMm: Int = 0,
// 우리 4ae25d9: Auto Scan 측정 사이클 목표 간격 (ms).
// BleManager.canSendMaa() time gate와 동일 값이 기본 (500ms).
var autoScanIntervalMs: Int = 500
)
@@ -0,0 +1,6 @@
package com.medithings.vesiscan.models
enum class SensorMode(val displayName: String) {
NIRS("NIRS"),
PIEZO("Piezo")
}
@@ -0,0 +1,50 @@
package com.medithings.vesiscan.models
import androidx.compose.ui.graphics.Color
enum class UrgencyLevel {
SAFE, WARNING, URGENT;
val backgroundColor: Color
get() = when (this) {
SAFE -> Color(0xFFEDF8FF)
WARNING -> Color(0xFFFFFAE6)
URGENT -> Color(0xFFFFEBEB)
}
val accentColor: Color
get() = when (this) {
SAFE -> Color(0xFF008FFF)
WARNING -> Color(0xFFF5A623)
URGENT -> Color(0xFFF04523)
}
val gradientColors: List<Color>
get() = when (this) {
SAFE -> listOf(Color(0xFF00DBA1), Color(0xFF008FFF))
WARNING -> listOf(Color(0xFFFECC2E), Color(0xFFF04A23))
URGENT -> listOf(Color(0xFFF04A23), Color(0xFFCC1A1A))
}
val message: String
get() = when (this) {
SAFE -> "Enjoy your day!"
WARNING -> "Prepare for catheterization"
URGENT -> "Please perform catheterization"
}
val rawStringValue: String
get() = when (this) {
SAFE -> "safe"
WARNING -> "warning"
URGENT -> "urgent"
}
companion object {
fun fromString(value: String): UrgencyLevel = when (value) {
"warning" -> WARNING
"urgent" -> URGENT
else -> SAFE
}
}
}
@@ -0,0 +1,12 @@
package com.medithings.vesiscan.models
enum class Sex { MALE, FEMALE }
data class User(
var firstName: String = "",
var lastName: String = "",
var age: Int = 0,
var sex: Sex? = null,
var heightCm: Double = 0.0,
var weightKg: Double = 0.0
)
@@ -0,0 +1,102 @@
package com.medithings.vesiscan.services
import android.os.Environment
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* ADC 데이터를 세션별 CSV 파일로 저장.
*
* 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv
* newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능)
*/
object AdcCsvLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
var lastScanId: Int = 0
private set
private var scanCounter = 0
private var sessionFile: File? = null
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_ADC"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
fun newSession() {
scanCounter = 0
lastScanId = 0
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
}
/**
* 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용.
* 일반 모드: 기존 세션 파일 사용.
*/
private fun getFile(): File {
val clinicalDir = ClinicalSessionStore.currentLogDir()
if (clinicalDir != null) {
clinicalDir.mkdirs()
return File(clinicalDir, "adc.csv")
}
if (sessionFile == null) newSession()
return sessionFile!!
}
fun log(
channels: List<List<UShort>>,
volumeMl: Double? = null,
lrRatio: Double = 1.0
) {
try {
val file = getFile()
val isNew = !file.exists() || file.length() == 0L
scanCounter++
lastScanId = scanCounter
val timestamp = timeFmt.format(Date())
val volStr = volumeMl?.let { "%.1f".format(it) } ?: ""
val lrStr = "%.2f".format(lrRatio)
val analyzer = com.medithings.vesiscan.managers.PiezoEchoAnalyzer.shared
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.medithings.vesiscan.managers.GreenZoneConstants.lowEchoAmp)
val dpsStr = "%.3f".format(com.medithings.vesiscan.managers.PiezoHW.distancePerSample)
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 ""
// BLE 펌웨어 버전 (연결 안 됐으면 "")
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
java.io.FileWriter(file, !isNew).buffered().use { writer ->
if (isNew) {
val header = buildString {
append("scan_id,timestamp,clinical_label,firmware_version,volume_ml,lr_ratio,threshold,dps,sg_filter,channel")
for (i in 0 until 100) append(",s$i")
}
writer.write(header)
writer.newLine()
}
for ((chIdx, buffer) in channels.withIndex()) {
val row = buildString {
append("$scanCounter,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx")
for (sample in buffer) {
append(",${sample.toInt()}")
}
for (i in buffer.size until 100) {
append(",")
}
}
writer.write(row)
writer.newLine()
}
}
} catch (_: Exception) {}
}
}
@@ -0,0 +1,349 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList
/**
* BLE 디버그 로거 — 펌웨어 디버깅용 상세 통신 로그.
*
* 모든 TX/RX 패킷, RSSI, 연결 상태, 에러를 기록하고
* 파일로 내보내기 가능.
*
* 파일 위치: vesiscan_ble_logs/YYYY-MM-DD_HHmmss.log
*/
class BleDebugLogger private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: BleDebugLogger? = null
fun getInstance(context: Context) = instance ?: synchronized(this) {
instance ?: BleDebugLogger(context.applicationContext).also { instance = it }
}
private const val MAX_MEMORY_ENTRIES = 2000
}
enum class Level { TX, RX, INFO, WARN, ERROR, RSSI, CONN }
data class Entry(
val timestamp: Long,
val level: Level,
val message: String,
val dataSize: Int = 0,
val rssi: Int? = null
) {
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
val timeStr: String get() = fmt.format(Date(timestamp))
fun toLogLine(): String {
val tag = level.name.padEnd(5)
val extra = when {
dataSize > 0 -> " [${dataSize}B]"
rssi != null -> " [${rssi}dBm]"
else -> ""
}
return "$timeStr $tag $message$extra"
}
fun toJson(): JSONObject = JSONObject().apply {
put("t", timestamp)
put("level", level.name)
put("msg", message)
if (dataSize > 0) put("bytes", dataSize)
if (rssi != null) put("rssi", rssi)
}
}
// In-memory ring buffer
private val entries = CopyOnWriteArrayList<Entry>()
// Session tracking
private var sessionStart: Long = System.currentTimeMillis()
var deviceName: String = ""
private set
private var txCount = 0
private var rxCount = 0
private var errorCount = 0
private var lastRssi: Int = 0
private val rssiHistory = mutableListOf<Pair<Long, Int>>()
private var packetTimeouts = 0
private var channelMissCount = 0
// File
private val logDir = File(context.filesDir, "vesiscan_ble_logs").also { it.mkdirs() }
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
private var liveLogFile: File? = null
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
private fun ensureLiveLogFile(): File {
val clinicalDir = ClinicalSessionStore.currentLogDir()
if (clinicalDir != null) {
clinicalDir.mkdirs()
val currentLabel = ClinicalSessionStore.currentLabel()
// 측정 단위가 바뀌었으면 새 파일로 전환
if (liveLogFile == null || liveLogLabel != currentLabel) {
liveLogFile = File(clinicalDir, "ble.log")
liveLogLabel = currentLabel
}
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
// 일반 모드 진입 또는 임상 모드 종료
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
liveLogLabel = ""
}
return liveLogFile!!
}
private fun appendToFile(line: String) {
try {
ensureLiveLogFile().appendText(line + "\n")
} catch (_: Exception) {}
}
// ── Logging Methods ──
fun tx(command: String, dataSize: Int = 0) {
txCount++
add(Entry(System.currentTimeMillis(), Level.TX, command, dataSize))
}
fun rx(prefix: String, dataSize: Int = 0, detail: String = "") {
rxCount++
val msg = if (detail.isNotEmpty()) "$prefix $detail" else prefix
add(Entry(System.currentTimeMillis(), Level.RX, msg, dataSize))
}
fun rssi(value: Int) {
lastRssi = value
rssiHistory.add(System.currentTimeMillis() to value)
if (rssiHistory.size > 500) rssiHistory.removeAt(0)
add(Entry(System.currentTimeMillis(), Level.RSSI, "RSSI=$value dBm", rssi = value))
}
/** mid?/rid: 응답 받은 후 호출 — 펌웨어 정보를 ble.log에 보기 좋게 헤더 형태로 기록 */
fun deviceInfo(fw: String, hw: String, sn: String) {
appendToFile("─── Device Info ───")
if (fw.isNotEmpty()) appendToFile("Firmware: $fw")
if (hw.isNotEmpty()) appendToFile("Hardware: $hw")
if (sn.isNotEmpty()) appendToFile("Serial: $sn")
appendToFile("───────────────────")
}
fun connected(name: String, address: String) {
deviceName = name
sessionStart = System.currentTimeMillis()
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
rssiHistory.clear()
liveLogFile = null // 새 세션 → 새 파일
appendToFile("=== VesiScan BLE Debug Log ===")
appendToFile("Device: $name")
appendToFile("Session: ${dateFmt.format(Date())}")
appendToFile("============================================================")
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
}
fun disconnected(reason: String = "", gattStatus: Int = -1) {
val msg = buildString {
append("DISCONNECTED")
if (reason.isNotEmpty()) append(" reason=$reason")
if (gattStatus >= 0) append(" gatt_status=$gattStatus")
append(" session=${sessionDurationSec()}s tx=$txCount rx=$rxCount err=$errorCount")
}
add(Entry(System.currentTimeMillis(), Level.CONN, msg))
}
fun reconnecting(attempt: Int, maxAttempt: Int, delayMs: Long) {
add(Entry(System.currentTimeMillis(), Level.CONN, "RECONNECTING attempt=$attempt/$maxAttempt delay=${delayMs}ms"))
}
fun reconnectFailed() {
errorCount++
add(Entry(System.currentTimeMillis(), Level.ERROR, "RECONNECT_FAILED after all attempts"))
}
fun info(message: String) {
add(Entry(System.currentTimeMillis(), Level.INFO, message))
}
fun warn(message: String) {
add(Entry(System.currentTimeMillis(), Level.WARN, message))
}
fun error(message: String) {
errorCount++
add(Entry(System.currentTimeMillis(), Level.ERROR, message))
}
fun packetTimeout(channel: Int, retryCount: Int) {
packetTimeouts++
error("PACKET_TIMEOUT ch=$channel retry=$retryCount")
}
fun channelMissing(expectedChannels: Int, receivedChannels: Int) {
channelMissCount++
warn("CHANNEL_MISS expected=$expectedChannels received=$receivedChannels")
}
fun endianDetected(isBigEndian: Boolean, firstSampleLE: Int, firstSampleBE: Int) {
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
val msg = buildString {
append("MEASUREMENT scan=$scanId")
append(" vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml")
append(" lr=${"%.2f".format(lrRatio)}")
append(" valid=$validChannels/6")
append(" urine=[${urineLines.joinToString(",")}]")
if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}")
}
add(Entry(System.currentTimeMillis(), Level.INFO, msg))
}
// ── Access ──
private fun add(entry: Entry) {
entries.add(entry)
if (entries.size > MAX_MEMORY_ENTRIES) {
entries.removeAt(0)
}
appendToFile(entry.toLogLine())
}
fun getEntries(): List<Entry> = entries.toList()
fun getRecentEntries(count: Int = 100): List<Entry> {
val all = entries.toList()
return if (all.size > count) all.subList(all.size - count, all.size) else all
}
fun clear() {
entries.clear()
}
// ── Stats ──
private fun sessionDurationSec(): Long = (System.currentTimeMillis() - sessionStart) / 1000
fun getStats(): JSONObject = JSONObject().apply {
put("session_duration_sec", sessionDurationSec())
put("device", deviceName)
put("tx_count", txCount)
put("rx_count", rxCount)
put("error_count", errorCount)
put("packet_timeouts", packetTimeouts)
put("channel_misses", channelMissCount)
put("last_rssi", lastRssi)
if (rssiHistory.isNotEmpty()) {
put("avg_rssi", rssiHistory.map { it.second }.average().toInt())
put("min_rssi", rssiHistory.minOf { it.second })
put("max_rssi", rssiHistory.maxOf { it.second })
}
}
// ── File Export ──
private fun writeLogContent(writer: java.io.BufferedWriter) {
writer.write("=== VesiScan BLE Debug Log ===\n")
writer.write("Device: $deviceName\n")
writer.write("Session: ${dateFmt.format(Date(sessionStart))}\n")
writer.write("Duration: ${sessionDurationSec()}s\n")
writer.write("TX: $txCount RX: $rxCount Errors: $errorCount Timeouts: $packetTimeouts\n")
if (rssiHistory.isNotEmpty()) {
writer.write("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} ")
writer.write("min=${rssiHistory.minOf { it.second }} max=${rssiHistory.maxOf { it.second }}\n")
}
writer.write("=".repeat(60) + "\n\n")
for (entry in entries) {
writer.write(entry.toLogLine() + "\n")
}
}
fun exportToFile(): File {
val fileName = "ble_debug_${dateFmt.format(Date())}.log"
val file = File(logDir, fileName)
file.bufferedWriter().use { writeLogContent(it) }
return file
}
/** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */
fun exportToDownloads(): File? {
return try {
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
)
val fileName = "VesiScan_BLE_${dateFmt.format(Date())}.log"
val file = File(downloadsDir, fileName)
file.bufferedWriter().use { writeLogContent(it) }
file
} catch (e: Exception) {
null
}
}
fun exportToJsonFile(): File {
val fileName = "ble_debug_${dateFmt.format(Date())}.jsonl"
val file = File(logDir, fileName)
file.bufferedWriter().use { writer ->
val header = JSONObject().apply {
put("type", "header")
put("device", deviceName)
put("session_start", sessionStart)
put("stats", getStats())
}
writer.write(header.toString() + "\n")
for (entry in entries) {
writer.write(entry.toJson().toString() + "\n")
}
}
return file
}
fun getShareIntent(): Intent {
val file = exportToFile()
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
return Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "VesiScan BLE Debug Log — $deviceName")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
fun getLogText(): String {
val sb = StringBuilder()
sb.appendLine("=== VesiScan BLE Debug ===")
sb.appendLine("Device: $deviceName | TX:$txCount RX:$rxCount Err:$errorCount Timeout:$packetTimeouts")
if (rssiHistory.isNotEmpty()) {
sb.appendLine("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} min=${rssiHistory.minOf { it.second }}")
}
sb.appendLine("---")
for (entry in entries.takeLast(200)) {
sb.appendLine(entry.toLogLine())
}
return sb.toString()
}
/** 7일 이상 된 로그 삭제 */
fun purgeOldLogs(keepDays: Int = 7) {
val cutoff = System.currentTimeMillis() - keepDays * 24 * 60 * 60 * 1000L
logDir.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.delete() }
}
}
@@ -0,0 +1,155 @@
package com.medithings.vesiscan.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.medithings.vesiscan.MainActivity
import com.medithings.vesiscan.R
/**
* BLE 연결 유지를 위한 Foreground Service.
*
* 화면이 꺼져도 Android Doze 모드에서 앱을 유지하여
* BLE 통신(TX/RX), watchdog, Auto 측정이 중단되지 않도록 한다.
*/
class BleForegroundService : Service() {
companion object {
const val CHANNEL_ID = "vesiscan_ble_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_START = "com.medithings.vesiscan.BLE_START"
const val ACTION_STOP = "com.medithings.vesiscan.BLE_STOP"
fun start(context: Context) {
// 배터리 최적화 제외 요청 제거 — 다른 페이지로 이동되는 UX 문제
// Foreground Service + PARTIAL_WAKE_LOCK만으로 Doze 대응
val intent = Intent(context, BleForegroundService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
@android.annotation.SuppressLint("BatteryLife")
private fun requestBatteryOptimizationExemption(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(context.packageName)) {
try {
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
}
}
fun stop(context: Context) {
val intent = Intent(context, BleForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
else -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
startForeground(NOTIFICATION_ID, buildNotification())
}
acquireWakeLock()
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"VesiScan BLE Connection",
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.ble_service_channel_desc)
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
private fun buildNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VesiScan")
.setContentText(getString(R.string.ble_service_connecting))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.setContentIntent(pendingIntent)
.build()
}
private fun acquireWakeLock() {
if (wakeLock == null) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"VesiScan::BleWakeLock"
).apply {
acquire(4 * 60 * 60 * 1000L) // 최대 4시간
}
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
}
@@ -0,0 +1,338 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Environment
import com.medithings.vesiscan.ble.ImuSample
import com.medithings.vesiscan.ble.PiezoChannelData
import com.medithings.vesiscan.models.ClinicalSession
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플.
* alignment 모드일 때 alignXxx 필드들이 채워짐 (V1 advisor 출력). */
data class MeasurementCycle(
val cycle: Int,
val timestampMs: Long,
val piezoChannels: Map<Int, List<Int>>, // channel idx → samples
val imuSamples: List<ImuSample>,
// Alignment mode metadata (V1 placementPhase, detected channels, urineLens)
val alignPhase: String = "", // VERTICAL / LATERAL / GREEN / ""
val alignScore: Int = 0, // placementScore
val alignDetected: List<Boolean> = emptyList(), // 6개 채널 검출 여부
val alignUrineLens: List<Int> = emptyList(), // 6개 채널 urineLen
val alignHint: String = "", // directionHint
val alignIcon: String = "", // directionIcon
)
/**
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
*
* 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면
* Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨.
* 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가
* 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨.
*
* zip은 사용하지 않음 — 폴더 자체가 분석 단위.
*/
object ClinicalSessionStore {
/** labdb 자료 구분 코드 — "001" = 내부 임상 R&D (외부 일반 VesiScan "000"과 구분). */
const val DATA_TYPE: String = "001"
/** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */
@Volatile
var inClinicalFlow: Boolean = false
@Volatile
var currentSession: ClinicalSession? = null
private set
/** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */
@Volatile
var lastFinalizedFolder: File? = null
private set
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
var lastExaminer: String = ""
var lastNotes: String = ""
var lastSubjectType: com.medithings.vesiscan.models.SubjectType =
com.medithings.vesiscan.models.SubjectType.PHANTOM
var lastSubject: String = "" // 카테고리 내 식별자 (예: "500mL", "S001")
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
var lastAbdomenThicknessMm: String = ""
// 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거
var lastPosture: com.medithings.vesiscan.models.ClinicalPosture =
com.medithings.vesiscan.models.ClinicalPosture.SUPINE
var lastStep: com.medithings.vesiscan.models.ClinicalStep =
com.medithings.vesiscan.models.ClinicalStep.ALIGN_0CM
/** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */
private val cyclesBuffer = mutableListOf<MeasurementCycle>()
fun startMeasurement(context: Context, session: ClinicalSession) {
currentSession = session
lastExaminer = session.examiner
lastNotes = session.notes
lastSubjectType = session.subjectType
lastSubject = session.subject
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
lastPosture = session.posture
lastStep = session.step
cyclesBuffer.clear()
currentLogDir()?.mkdirs()
writeMeta(context)
}
/** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
if (currentSession == null) return
cyclesBuffer.add(MeasurementCycle(
cycle = cyclesBuffer.size,
timestampMs = System.currentTimeMillis(),
piezoChannels = piezo.sortedBy { it.channel }
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
imuSamples = imu.toList(),
))
}
/** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */
fun addAlignmentFrame(
piezo: List<PiezoChannelData>,
imu: List<ImuSample>,
alignPhase: String,
alignScore: Int,
alignDetected: List<Boolean>,
alignUrineLens: List<Int>,
alignHint: String,
alignIcon: String,
) {
if (currentSession == null) return
cyclesBuffer.add(MeasurementCycle(
cycle = cyclesBuffer.size,
timestampMs = System.currentTimeMillis(),
piezoChannels = piezo.sortedBy { it.channel }
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
imuSamples = imu.toList(),
alignPhase = alignPhase,
alignScore = alignScore,
alignDetected = alignDetected,
alignUrineLens = alignUrineLens,
alignHint = alignHint,
alignIcon = alignIcon,
))
}
val cycleCount: Int get() = cyclesBuffer.size
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
fun currentLogDir(): File? {
val s = currentSession ?: return null
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
return File(File(downloads, "VesiScan_Sessions"), s.folderName)
}
/** clinical_label 컬럼/필드용. 비활성 시 "" */
fun currentLabel(): String = currentSession?.label ?: ""
/**
* 폴더명 정보를 파일명에 포함시켜 분석 PC로 옮긴 후에도 식별 가능하게.
* 예: meta_PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-06-09_15-30-00.json
*/
private fun metaJsonFile(dir: File): File = File(dir, "meta_${dir.name}.json")
private fun measurementJsonFile(dir: File): File = File(dir, "measurement_${dir.name}.json")
fun writeMeta(context: Context) {
val s = currentSession ?: return
val dir = currentLogDir() ?: return
dir.mkdirs()
val meta = JSONObject().apply {
put("data_type", DATA_TYPE)
put("device_name", s.deviceName)
put("posture", s.posture.name)
put("step", s.step.name)
put("is_alignment", s.step.isAlignment)
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
put("subject_type", s.subjectType.name)
if (s.subject.isNotBlank()) put("subject", s.subject)
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
s.endedAt?.let {
put("ended_at", it)
put("ended_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
put("duration_ms", it - s.startedAt)
}
put("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
put("app_version_code", try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 })
put("device_model", android.os.Build.MODEL)
put("device_manufacturer", android.os.Build.MANUFACTURER)
put("android_release", android.os.Build.VERSION.RELEASE)
put("android_sdk", android.os.Build.VERSION.SDK_INT)
// VBT 디바이스 정보 (mid?/rid: 응답 파싱값) — 분석 시 펌웨어별 데이터 grouping용
val ble = com.medithings.vesiscan.ble.BleManager.getInstance(context)
val fw = ble.firmwareVersion.value
val hw = ble.hardwareVersion.value
val sn = ble.serialNumber.value
if (fw.isNotEmpty()) put("firmware_version", fw)
if (hw.isNotEmpty()) put("hardware_version", hw)
if (sn.isNotEmpty()) put("serial_number", sn)
}
metaJsonFile(dir).writeText(meta.toString(2))
}
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null.
* labdb 업로드는 자동 X — Clinical Home 의 'Last saved' 카드의 Upload 버튼으로 수동 트리거. */
fun endMeasurement(context: Context): File? {
val s = currentSession ?: return null
s.endedAt = System.currentTimeMillis()
writeMeta(context)
writeMeasurementJson(context)
val dir = currentLogDir()
lastFinalizedFolder = dir
currentSession = null
cyclesBuffer.clear()
return dir
}
/**
* End Session 시점에 모든 사이클을 단일 measurement.json으로 통합.
* 분석 PC에서 한 줄 로드: data = json.load(open('measurement.json'))
*/
private fun writeMeasurementJson(context: Context) {
val s = currentSession ?: return
val dir = currentLogDir() ?: return
dir.mkdirs()
try {
val root = JSONObject()
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능)
// alignment 모드 → dataType=002, 그 외 = 001 (기존 measurement)
val effectiveDataType = if (s.mode == com.medithings.vesiscan.models.SessionMode.ALIGNMENT)
"002" else DATA_TYPE
val meta = JSONObject().apply {
put("data_type", effectiveDataType)
put("session_mode", s.mode.name)
if (s.alignmentAlgo.isNotBlank()) put("alignment_algo", s.alignmentAlgo)
put("subject_type", s.subjectType.name)
if (s.subject.isNotBlank()) put("subject", s.subject)
put("device_name", s.deviceName)
put("posture", s.posture.name)
put("step", s.step.name)
put("is_alignment", s.step.isAlignment)
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
s.endedAt?.let {
put("ended_at", it)
put("ended_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
put("duration_ms", it - s.startedAt)
}
put("captured_cycles", cyclesBuffer.size)
put("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
val ble = com.medithings.vesiscan.ble.BleManager.current
ble?.firmwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("firmware_version", it) }
ble?.hardwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("hardware_version", it) }
ble?.serialNumber?.value?.takeIf { it.isNotEmpty() }?.let { put("serial_number", it) }
}
root.put("meta", meta)
// cycles: 모든 사이클을 piezo + imu 묶음으로
val cyclesArr = JSONArray()
for (c in cyclesBuffer) {
val piezo = JSONObject()
c.piezoChannels.forEach { (ch, samples) ->
piezo.put("CH$ch", JSONArray(samples))
}
val imuArr = JSONArray()
for (sample in c.imuSamples) {
imuArr.put(JSONObject().apply {
put("ax", sample.ax.toDouble())
put("ay", sample.ay.toDouble())
put("az", sample.az.toDouble())
put("gx", sample.gx.toDouble())
put("gy", sample.gy.toDouble())
put("gz", sample.gz.toDouble())
})
}
cyclesArr.put(JSONObject().apply {
put("cycle", c.cycle)
put("timestamp_ms", c.timestampMs)
put("piezo", piezo)
put("imu", imuArr)
// Alignment metadata (alignment mode 일 때만 채워짐)
if (c.alignPhase.isNotBlank()) {
put("align_phase", c.alignPhase)
put("align_score", c.alignScore)
put("align_hint", c.alignHint)
put("align_icon", c.alignIcon)
put("align_detected", JSONArray(c.alignDetected))
put("align_urine_lens", JSONArray(c.alignUrineLens))
}
})
}
root.put("cycles", cyclesArr)
measurementJsonFile(dir).writeText(root.toString(2))
} catch (_: Exception) {
// 저장 실패해도 측정 자체는 막지 않음
}
}
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
fun discardMeasurement() {
currentSession = null
}
/**
* 측정 중인 세션을 즉시 폐기 + 폴더 완전 삭제 (잘못 측정 인지 시).
* endMeasurement()와 달리 meta.json/measurement.json/labdb 업로드 전부 skip.
* 반환: 삭제된 폴더 경로 (없으면 null).
*/
fun discardAndDeleteFolder(): File? {
val dir = currentLogDir()
currentSession = null
cyclesBuffer.clear()
if (dir != null && dir.exists()) {
dir.deleteRecursively()
}
return dir
}
/**
* 이미 finalize된 측정 폴더(lastFinalizedFolder)를 삭제.
* labdb 서버 데이터까지 제거하려면 호출자가 LabdbClient.deleteSession()도 별도 호출.
*/
fun deleteFinalizedFolder(): File? {
val dir = lastFinalizedFolder
if (dir != null && dir.exists()) {
dir.deleteRecursively()
}
lastFinalizedFolder = null
return dir
}
}
@@ -0,0 +1,66 @@
package com.medithings.vesiscan.services
import com.medithings.vesiscan.ble.ImuSample
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* IMU 샘플을 CSV로 저장. 임상 R&D 모드 한정.
*
* 파일 위치 (임상 모드일 때):
* Downloads/VesiScan_Sessions/{folder}/imu.csv
*
* 한 번의 mtb? 응답에서 15 샘플 → CSV 15행. scan_id로 같은 측정의 piezo adc.csv와 매칭 가능.
*
* Sensitivity:
* ax/ay/az: g (Accel ±4g, 1 LSB = 1/8192 g)
* gx/gy/gz: dps (Gyro ±500 dps, 1 LSB = 1/65.536 dps)
*
* Sample 순서: 한 응답 안에서 oldest→newest. sample_idx 컬럼으로 명시.
*/
object ImuCsvLogger {
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
/**
* 임상 모드 한정. ClinicalSessionStore.currentLogDir()가 있을 때만 저장.
* samples를 oldest→newest 순서로 그대로 기록.
*/
fun log(samples: List<ImuSample>) {
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와 매칭용
val timestamp = timeFmt.format(Date())
val clinicalLabel = ClinicalSessionStore.currentLabel()
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
file.bufferedWriter().use { _ -> } // ensure exists
java.io.FileWriter(file, !isNew).buffered().use { writer ->
if (isNew) {
writer.write("scan_id,timestamp,clinical_label,firmware_version,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
writer.newLine()
}
for ((idx, s) in samples.withIndex()) {
writer.write(buildString {
append("$scanId,$timestamp,$clinicalLabel,$fwVersion,$idx,")
append("%.4f".format(s.ax)); append(",")
append("%.4f".format(s.ay)); append(",")
append("%.4f".format(s.az)); append(",")
append("%.4f".format(s.gx)); append(",")
append("%.4f".format(s.gy)); append(",")
append("%.4f".format(s.gz))
})
writer.newLine()
}
}
} catch (_: Exception) {
// 로그는 측정 자체를 막으면 안 됨 — silent fail
}
}
}
@@ -0,0 +1,35 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object KeychainService {
private const val FILE_NAME = "medilightv2_secure_prefs"
private var prefs: SharedPreferences? = null
fun init(context: Context) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun save(key: String, value: String) {
prefs?.edit()?.putString(key, value)?.apply()
}
fun load(key: String): String? = prefs?.getString(key, null)
fun delete(key: String) {
prefs?.edit()?.remove(key)?.apply()
}
}
@@ -0,0 +1,91 @@
package com.medithings.vesiscan.services
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
/**
* 페어링했던 BLE 기기 다수 영속화.
* SharedPreferences "medilight_prefs" 의 "known_devices" 키에 JSON array.
*
* 기존 단일 키(last_device_address/last_device_name) 는 마이그레이션 위해 1회 읽어 합치고
* BleManager 가 새로 add() 호출 시 같이 정리.
*/
data class KnownDevice(
val address: String,
val name: String,
val lastConnectedAt: Long,
)
object KnownDeviceStore {
private const val PREFS = "medilight_prefs"
private const val KEY = "known_devices"
fun getAll(context: Context): List<KnownDevice> {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY, null)
val list = mutableListOf<KnownDevice>()
if (!raw.isNullOrBlank()) {
runCatching {
val arr = JSONArray(raw)
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val addr = o.optString("address").takeIf { it.isNotBlank() } ?: continue
val name = o.optString("name", "Unknown")
val ts = o.optLong("lastConnectedAt", 0L)
list.add(KnownDevice(addr, name, ts))
}
}
}
// 구 키 마이그레이션 — 한 번이라도 들어와 보면 list 에 흡수
val legacyAddr = prefs.getString("last_device_address", null)
val legacyName = prefs.getString("last_device_name", null)
if (!legacyAddr.isNullOrBlank() && list.none { it.address == legacyAddr }) {
list.add(KnownDevice(legacyAddr, legacyName ?: "Unknown", System.currentTimeMillis()))
saveAll(context, list)
}
return list.sortedByDescending { it.lastConnectedAt }
}
/** 새 연결 성공 시 호출 — 있으면 lastConnectedAt 갱신, 없으면 추가. 구 단일 키도 같이 채움. */
fun add(context: Context, address: String, name: String) {
val current = getAll(context).toMutableList()
val idx = current.indexOfFirst { it.address == address }
val now = System.currentTimeMillis()
if (idx >= 0) current[idx] = current[idx].copy(name = name, lastConnectedAt = now)
else current.add(KnownDevice(address, name, now))
saveAll(context, current)
// 구 키도 호환성 유지
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("last_device_address", address)
.putString("last_device_name", name)
.apply()
}
/** 페어링 해제 시 호출 — known list 에서 제거. */
fun remove(context: Context, address: String) {
val current = getAll(context).filter { it.address != address }
saveAll(context, current)
// 구 키 정리 — 제거한 게 마지막 기기였다면
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString("last_device_address", null) == address) {
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
}
}
fun mostRecent(context: Context): KnownDevice? = getAll(context).firstOrNull()
private fun saveAll(context: Context, list: List<KnownDevice>) {
val arr = JSONArray()
list.forEach { d ->
arr.put(JSONObject().apply {
put("address", d.address)
put("name", d.name)
put("lastConnectedAt", d.lastConnectedAt)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY, arr.toString())
.apply()
}
}
@@ -0,0 +1,73 @@
/*
* LocalePref — 앱 언어 설정 영속화 + 컨텍스트 wrapping.
*
* 모드:
* - SYSTEM (default): 폰 시스템 언어 따라감 (Korean → 한국어, 그 외 → English)
* - KO : 강제 한국어
* - EN : 강제 영어
*
* 적용:
* 1. MainActivity.attachBaseContext(base) → wrap(base) 호출
* 2. 언어 변경 시 setMode(...) 호출 후 activity recreate (또는 process restart)
*
* Android 13+ 의 per-app locale API (AppCompatDelegate.setApplicationLocales) 와
* 무관하게 Configuration override 방식으로 모든 SDK 에서 동작.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import java.util.Locale
object LocalePref {
enum class Mode { SYSTEM, KO, EN }
private const val PREF = "locale_pref"
private const val KEY_MODE = "mode"
fun getMode(context: Context): Mode {
val s = context.applicationContext
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.getString(KEY_MODE, Mode.SYSTEM.name) ?: Mode.SYSTEM.name
return runCatching { Mode.valueOf(s) }.getOrDefault(Mode.SYSTEM)
}
fun setMode(context: Context, mode: Mode) {
context.applicationContext
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.edit().putString(KEY_MODE, mode.name).apply()
}
/** 선택된 모드에 맞춰 Locale 적용된 새 Context 반환. attachBaseContext 에서 사용. */
fun wrap(base: Context): Context {
val mode = getMode(base)
if (mode == Mode.SYSTEM) return base // 시스템 기본 — 손대지 않음
val locale = when (mode) {
Mode.KO -> Locale.KOREAN
Mode.EN -> Locale.ENGLISH
else -> return base
}
Locale.setDefault(locale)
val config = Configuration(base.resources.configuration)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val list = android.os.LocaleList(locale)
android.os.LocaleList.setDefault(list)
config.setLocales(list)
} else {
@Suppress("DEPRECATION")
config.locale = locale
}
return base.createConfigurationContext(config)
}
/** 현재 적용된 locale 의 사용자 표시용 라벨. */
fun currentLabel(context: Context): String = when (getMode(context)) {
Mode.SYSTEM -> "시스템 기본 / System"
Mode.KO -> "한국어"
Mode.EN -> "English"
}
}
@@ -0,0 +1,47 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import com.medithings.vesiscan.models.MeasurementRecord
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class MeasurementHistoryStore private constructor(context: Context) {
companion object {
@Volatile
private var instance: MeasurementHistoryStore? = null
fun getInstance(context: Context): MeasurementHistoryStore =
instance ?: synchronized(this) {
instance ?: MeasurementHistoryStore(context.applicationContext).also { instance = it }
}
private const val MAX_RECORDS = 100
}
private val prefs: SharedPreferences = context.getSharedPreferences("measurement_history", Context.MODE_PRIVATE)
private val gson = Gson()
private val key = "measurement_history"
var records: List<MeasurementRecord>
get() {
val json = prefs.getString(key, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<MeasurementRecord>>() {}.type
gson.fromJson(json, type)
} catch (_: Exception) { emptyList() }
}
private set(value) {
val trimmed = if (value.size > MAX_RECORDS) value.take(MAX_RECORDS) else value
prefs.edit().putString(key, gson.toJson(trimmed)).apply()
}
fun addRecord(record: MeasurementRecord) {
val current = records.toMutableList()
current.add(0, record)
records = current
}
fun clearAll() {
prefs.edit().remove(key).apply()
}
}
@@ -0,0 +1,246 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Build
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Measurement log auto-save service.
*
* All measurement results are saved in JSON Lines format to internal storage.
* Medical audit trail + QA verification.
*
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
* Each line = one measurement event (JSON)
*/
class MeasurementLogService private constructor(private val context: Context) {
private val logDir: File
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
private val appVersionName: String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
private val appVersionCode: Int = try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 }
init {
// Downloads/VesiScan_Events/ 로 이동 — 앱 삭제 시 데이터 손실 방지.
// 내부 저장소(filesDir)는 앱 uninstall 시 같이 사라지지만, 외부 Downloads는 유지됨.
val downloads = android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
)
logDir = File(downloads, "VesiScan_Events")
if (!logDir.exists()) logDir.mkdirs()
}
companion object {
@Volatile
private var instance: MeasurementLogService? = null
fun getInstance(context: Context): MeasurementLogService {
return instance ?: synchronized(this) {
instance ?: MeasurementLogService(context.applicationContext).also { instance = it }
}
}
}
// -- Log Events --
/**
* Log a spot measurement result.
*/
fun logMeasurement(
volumeMl: Double?,
channels: List<ChannelLogEntry>,
rawADC: List<List<UShort>>
) {
val entry = JSONObject().apply {
put("event", "measurement")
put("timestamp", timestampFormatter.format(Date()))
put("device", Build.MODEL)
put("app_version", appVersionName)
put("app_version_code", appVersionCode)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
// BLE 펌웨어 버전 (연결 시 mid?/rid: 응답에서 받음)
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
}
if (volumeMl != null) {
entry.put("volume_ml", Math.round(volumeMl * 10.0) / 10.0)
}
// Per-channel detection
val chArray = JSONArray()
for (ch in channels) {
val chObj = JSONObject().apply {
put("channel", ch.channel)
ch.ant?.let { put("ant", it) }
ch.post?.let { put("post", it) }
ch.urineLen?.let { put("urine_len", it) }
ch.score?.let { put("score", Math.round(it * 10.0) / 10.0) }
}
chArray.put(chObj)
}
entry.put("channels", chArray)
// Raw ADC (compact: comma-separated per channel)
val adcObj = JSONObject()
for ((i, adc) in rawADC.withIndex()) {
adcObj.put("CH$i", adc.joinToString(",") { it.toString() })
}
entry.put("adc", adcObj)
appendLog(entry)
}
/**
* Log Placement Guide result.
*
* 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅.
* 일반 모드에서는 기존 events JSONL 묶음에 그대로.
*/
fun logPlacement(
channelDetected: List<Boolean>,
direction: String,
urineLens: List<Int> = emptyList(),
cv: Double = Double.NaN,
lrDev: Double = Double.NaN,
phase: String = "",
score: Int = -1
) {
val entry = JSONObject().apply {
put("event", "placement")
put("timestamp", timestampFormatter.format(Date()))
val arr = JSONArray()
for (d in channelDetected) arr.put(d)
put("detected", arr)
put("detected_count", channelDetected.count { it })
put("direction", direction)
if (urineLens.isNotEmpty()) {
put("urine_lens", JSONArray().apply { urineLens.forEach { put(it) } })
put("urine_sum", urineLens.sum())
}
if (!cv.isNaN()) put("cv", cv)
if (!lrDev.isNaN()) put("lr_dev", lrDev)
if (phase.isNotEmpty()) put("phase", phase)
if (score >= 0) put("score", score)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
// BLE 펌웨어 버전
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
}
appendLog(entry, "placements.jsonl")
}
/**
* Log BLE connection/disconnection.
*/
fun logConnection(event: String, deviceName: String = "") {
val entry = JSONObject().apply {
put("event", "ble_$event")
put("timestamp", timestampFormatter.format(Date()))
put("device_name", deviceName)
}
appendLog(entry)
}
/**
* Log an error.
*/
fun logError(message: String) {
val entry = JSONObject().apply {
put("event", "error")
put("timestamp", timestampFormatter.format(Date()))
put("message", message)
}
appendLog(entry)
}
// -- File Operations --
/**
* 임상 모드면 세션 step 폴더의 지정 파일에 추가.
* 일반 모드면 Downloads/VesiScan_Events/YYYY-MM-DD.jsonl 에 추가.
*/
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
try {
val line = entry.toString() + "\n"
val clinicalDir = ClinicalSessionStore.currentLogDir()
val file = if (clinicalDir != null) {
clinicalDir.mkdirs()
File(clinicalDir, clinicalFileName)
} else {
File(logDir, dateFormatter.format(Date()) + ".jsonl")
}
file.appendText(line)
} catch (e: Exception) {
// Silently fail — logging should never crash the app
}
}
/** Today's log file */
val todayLogFile: File
get() = File(logDir, dateFormatter.format(Date()) + ".jsonl")
/** All log files, newest first */
fun listLogFiles(): List<File> {
return logDir.listFiles { f -> f.extension == "jsonl" }
?.sortedByDescending { it.name }
?: emptyList()
}
/** Read a log file as lines */
fun readLog(file: File): List<String> {
return try {
file.readLines().filter { it.isNotBlank() }
} catch (e: Exception) {
emptyList()
}
}
/** Today's log as text */
fun todayLogText(): String {
return try {
todayLogFile.readText()
} catch (e: Exception) {
"(no logs today)"
}
}
/** Total log size in bytes */
val totalLogSize: Long
get() = listLogFiles().sumOf { it.length() }
/** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */
fun purgeOldLogs(keepDays: Int = 365) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
val cutoffStr = dateFormatter.format(calendar.time)
for (file in listLogFiles()) {
val name = file.nameWithoutExtension
if (name < cutoffStr) {
file.delete()
}
}
}
}
/** Data class for channel log entries */
data class ChannelLogEntry(
val channel: Int,
val ant: Int? = null,
val post: Int? = null,
val urineLen: Int? = null,
val score: Double? = null
)
@@ -0,0 +1,50 @@
package com.medithings.vesiscan.services
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.SensorMode
class MeasurementService private constructor(context: Context) {
companion object {
@Volatile
private var instance: MeasurementService? = null
fun getInstance(context: Context): MeasurementService =
instance ?: synchronized(this) {
instance ?: MeasurementService(context.applicationContext).also { instance = it }
}
}
private val bleManager = BleManager.getInstance(context)
val isMeasuring = mutableStateOf(false)
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
isMeasuring.value = true
when (mode) {
SensorMode.NIRS -> performNirsMeasurement(completion)
SensorMode.PIEZO -> performPiezoMeasurement(completion)
}
}
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
bleManager.onNirsMcjReceived = { _ ->
isMeasuring.value = false
completion(true)
}
bleManager.sendMcjQuery()
}
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
bleManager.onPiezoDataReceived = { _ ->
isMeasuring.value = false
completion(true)
}
bleManager.sendBurst(freqOption = 2, cycles = 5, delayUs = 100)
}
fun stop() {
isMeasuring.value = false
bleManager.sendPiezoStop()
}
}
@@ -0,0 +1,77 @@
package com.medithings.vesiscan.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.UrgencyLevel
class NotificationService private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: NotificationService? = null
fun getInstance(context: Context): NotificationService =
instance ?: synchronized(this) {
instance ?: NotificationService(context.applicationContext).also { instance = it }
}
private const val CHANNEL_ID = "bladder_urgency"
private const val CHANNEL_NAME = "Bladder Monitoring"
}
private var lastNotifiedUrgency: String? = null
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Bladder level urgency notifications"
enableVibration(true)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
fun sendUrgencyNotification(urgency: UrgencyLevel, volume: Int) {
val urgencyKey = urgency.rawStringValue
if (urgencyKey == "safe" || urgencyKey == lastNotifiedUrgency) return
lastNotifiedUrgency = urgencyKey
val (title, body) = when (urgency) {
UrgencyLevel.WARNING -> "Bladder Level Rising" to "Estimated $volume ml. Prepare for catheterization."
UrgencyLevel.URGENT -> "Catheterization Needed" to "Estimated $volume ml. Please catheterize now."
UrgencyLevel.SAFE -> return
}
val priority = if (urgency == UrgencyLevel.URGENT)
NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(context).notify(urgencyKey.hashCode(), notification)
} catch (_: SecurityException) { }
}
fun cancelAll() {
lastNotifiedUrgency = null
NotificationManagerCompat.from(context).cancelAll()
}
}
@@ -0,0 +1,109 @@
/*
* PostureEventLogger — 자세/walking 알고리즘 검증용 CSV logger.
*
* 매 cycle 마다 분류 결과 + walking 상태 + 신호값 기록 +
* 사용자가 dev settings 의 annotation 버튼 누를 때 ground-truth marker 기록.
*
* 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
* 컬럼:
* timestamp_ms, type, posture, isWalking, pitch, wmean, wstd, accMag, gyroMag,
* alarm_event, annotation
*
* Row type:
* CYCLE — 매 cycle 의 자세/walking detector 결과
* ALARM — 알람 발동 (wakeup / walking)
* ANNOTATION — 사용자가 "지금 OOO" 표시 버튼 누름 (ground truth)
*
* 사용:
* 1. logSession(context) 한 번 호출 (PiezoMonitoring 진입 시) — 파일 생성
* 2. logCycle(...) 매 cycle 마다 호출
* 3. logAlarm(...) 알람 발동 시
* 4. annotate("WALK_START") 사용자가 버튼 누를 때
* 5. closeSession() 종료 시 (선택)
*
* 분석: CSV → Excel / python pandas → ANNOTATION 과 CYCLE 의 walking 전이 대조.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Environment
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object PostureEventLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
@Volatile private var sessionFile: File? = null
val currentFile: File? get() = sessionFile
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_Posture"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
/** 새 세션 파일 생성 + 헤더 작성. */
fun startSession(@Suppress("UNUSED_PARAMETER") context: Context): File {
val f = File(getDir(), "${sessionFmt.format(Date())}.csv")
try {
FileWriter(f, false).buffered().use { w ->
w.write("timestamp_ms,type,posture,isWalking,pitch,wmean,wstd,accMag,gyroMag,alarm_event,annotation")
w.newLine()
}
} catch (_: Exception) {}
sessionFile = f
return f
}
/** Cycle 의 자세 분류 결과 기록. */
fun logCycle(
nowMs: Long,
posture: String,
isWalking: Boolean,
pitch: Float,
wmean: Float,
wstd: Float,
accMag: Float,
gyroMag: Float,
) {
appendRow(
nowMs, "CYCLE", posture, isWalking.toString(),
pitch, wmean, wstd, accMag, gyroMag, "", ""
)
}
/** 알람 발동 (wakeup / walking) 기록. */
fun logAlarm(nowMs: Long, alarmEvent: String) {
appendRow(nowMs, "ALARM", "", "", 0f, 0f, 0f, 0f, 0f, alarmEvent, "")
}
/** 사용자 ground-truth annotation 기록 (예: "WALK_START"). */
fun annotate(label: String, nowMs: Long = System.currentTimeMillis()) {
appendRow(nowMs, "ANNOTATION", "", "", 0f, 0f, 0f, 0f, 0f, "", label)
}
fun closeSession() {
sessionFile = null
}
private fun appendRow(
ts: Long, type: String, posture: String, isWalking: String,
pitch: Float, wmean: Float, wstd: Float, accMag: Float, gyroMag: Float,
alarm: String, annotation: String
) {
val f = sessionFile ?: return
try {
FileWriter(f, true).buffered().use { w ->
w.write("$ts,$type,$posture,$isWalking,${"%.2f".format(pitch)},${"%.2f".format(wmean)},${"%.2f".format(wstd)},${"%.3f".format(accMag)},${"%.2f".format(gyroMag)},$alarm,$annotation")
w.newLine()
}
} catch (_: Exception) {}
}
}
@@ -0,0 +1,80 @@
/*
* PostureTuningPrefs — Posture / Walking detector 임계값 SharedPreferences 영속화.
*
* GreenZoneConstants 의 임계값들은 @Volatile var (메모리) 라 앱 재시작 시 default 리셋.
* 사용자가 dev slider 로 튜닝한 값을 유지하려면 prefs 에 저장 + 시작 시 로드.
*
* 사용:
* loadAll(context) — 앱/화면 시작 시 한 번 호출 → SharedPreferences 값 → GreenZoneConstants
* saveAll(context) — slider 변경 시 호출 → 현재 GreenZoneConstants → SharedPreferences
* resetAll(context) — 사용자가 reset 누름 → defaults 로 복원
*/
package com.medithings.vesiscan.services
import android.content.Context
import com.medithings.vesiscan.managers.GreenZoneConstants
object PostureTuningPrefs {
private const val PREF = "posture_tuning_prefs"
// Defaults — GreenZoneConstants 의 초기값과 일치 유지
private const val D_PITCH_LYING = 25f
private const val D_ACC_MIN = 0.8f
private const val D_ACC_MAX = 1.2f
private const val D_POLL_MS = 1000L
private const val D_WALK_PITCH = 45f
private const val D_WALK_START_WMEAN = 10f
private const val D_WALK_START_WSTD = 2f
private const val D_WALK_KEEP_WMEAN = 2f
private const val D_WALK_START_HOLD_MS = 6000L
private const val D_WALK_KEEP_HOLD_MS = 2000L
private const val D_WALK_WINDOW_MS = 1500L
fun loadAll(context: Context) {
val p = context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE)
GreenZoneConstants.pitchLyingThreshold = p.getFloat("pitchLying", D_PITCH_LYING)
GreenZoneConstants.postureAccMagMin = p.getFloat("accMin", D_ACC_MIN)
GreenZoneConstants.postureAccMagMax = p.getFloat("accMax", D_ACC_MAX)
GreenZoneConstants.posturePollIntervalMs = p.getLong("pollMs", D_POLL_MS)
GreenZoneConstants.walkingMinPitchDeg = p.getFloat("wPitch", D_WALK_PITCH)
GreenZoneConstants.walkingStartWmean = p.getFloat("wStartWmean", D_WALK_START_WMEAN)
GreenZoneConstants.walkingStartWstd = p.getFloat("wStartWstd", D_WALK_START_WSTD)
GreenZoneConstants.walkingKeepWmean = p.getFloat("wKeepWmean", D_WALK_KEEP_WMEAN)
GreenZoneConstants.walkingStartHoldMs = p.getLong("wStartHoldMs", D_WALK_START_HOLD_MS)
GreenZoneConstants.walkingKeepHoldMs = p.getLong("wKeepHoldMs", D_WALK_KEEP_HOLD_MS)
GreenZoneConstants.walkingWindowMs = p.getLong("wWindowMs", D_WALK_WINDOW_MS)
}
fun saveAll(context: Context) {
context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit().apply {
putFloat("pitchLying", GreenZoneConstants.pitchLyingThreshold)
putFloat("accMin", GreenZoneConstants.postureAccMagMin)
putFloat("accMax", GreenZoneConstants.postureAccMagMax)
putLong("pollMs", GreenZoneConstants.posturePollIntervalMs)
putFloat("wPitch", GreenZoneConstants.walkingMinPitchDeg)
putFloat("wStartWmean", GreenZoneConstants.walkingStartWmean)
putFloat("wStartWstd", GreenZoneConstants.walkingStartWstd)
putFloat("wKeepWmean", GreenZoneConstants.walkingKeepWmean)
putLong("wStartHoldMs", GreenZoneConstants.walkingStartHoldMs)
putLong("wKeepHoldMs", GreenZoneConstants.walkingKeepHoldMs)
putLong("wWindowMs", GreenZoneConstants.walkingWindowMs)
apply()
}
}
fun resetAll(context: Context) {
GreenZoneConstants.pitchLyingThreshold = D_PITCH_LYING
GreenZoneConstants.postureAccMagMin = D_ACC_MIN
GreenZoneConstants.postureAccMagMax = D_ACC_MAX
GreenZoneConstants.posturePollIntervalMs = D_POLL_MS
GreenZoneConstants.walkingMinPitchDeg = D_WALK_PITCH
GreenZoneConstants.walkingStartWmean = D_WALK_START_WMEAN
GreenZoneConstants.walkingStartWstd = D_WALK_START_WSTD
GreenZoneConstants.walkingKeepWmean = D_WALK_KEEP_WMEAN
GreenZoneConstants.walkingStartHoldMs = D_WALK_START_HOLD_MS
GreenZoneConstants.walkingKeepHoldMs = D_WALK_KEEP_HOLD_MS
GreenZoneConstants.walkingWindowMs = D_WALK_WINDOW_MS
saveAll(context)
}
}
@@ -0,0 +1,94 @@
/*
* RawBleLogger — 모든 BLE notification 의 raw bytes 를 시간순으로 파일로 dump.
*
* 용도: IMU FSR / packet 구조 / loss 진단. JSON 가공 거치기 전 원본 그대로 저장.
*
* 파일: Downloads/VesiScan_RawBLE/yyyy-MM-dd_HHmmss_raw.log
* 포맷 (한 줄당 한 notification):
* HH:mm:ss.SSS dir=RX prefix=rim len=188 hex=72 69 6D 3A 00 0F ...
*
* 사용: startCapture() / stopCapture() 토글. 활성화 시 BleManager.processReceivedData
* 가 매 notification 마다 write() 호출.
*/
package com.medithings.vesiscan.services
import android.os.Environment
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
object RawBleLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val capturing = AtomicBoolean(false)
@Volatile private var sessionFile: File? = null
@Volatile private var packetCount: Int = 0
val isCapturing: Boolean get() = capturing.get()
val currentFile: File? get() = sessionFile
val count: Int get() = packetCount
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_RawBLE"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
/** 새 파일 만들고 capture 시작. 이미 capturing 중이면 stop 후 새로 시작. */
fun startCapture(): File {
if (capturing.get()) stopCapture()
val f = File(getDir(), "${sessionFmt.format(Date())}_raw.log")
try {
FileWriter(f, false).buffered().use { w ->
w.write("# RawBleLogger session — started ${timeFmt.format(Date())}")
w.newLine()
w.write("# Format: HH:mm:ss.SSS dir=RX prefix=XXX len=N hex=AA BB ...")
w.newLine()
}
} catch (_: Exception) {}
sessionFile = f
packetCount = 0
capturing.set(true)
return f
}
fun stopCapture(): File? {
if (!capturing.get()) return sessionFile
val f = sessionFile
try {
if (f != null) {
FileWriter(f, true).buffered().use { w ->
w.write("# stopped ${timeFmt.format(Date())} — $packetCount packets")
w.newLine()
}
}
} catch (_: Exception) {}
capturing.set(false)
return f
}
/** BleManager 가 매 notification 받을 때마다 호출. capturing 아니면 즉시 return. */
fun write(data: ByteArray) {
if (!capturing.get()) return
val f = sessionFile ?: return
try {
val ts = timeFmt.format(Date())
val prefix = if (data.size >= 4)
String(data, 0, 4, Charsets.US_ASCII).replace(Regex("[^\\x20-\\x7E]"), "?")
else "?"
val hex = data.joinToString(" ") { "%02X".format(it) }
FileWriter(f, true).buffered().use { w ->
w.write("$ts dir=RX prefix=$prefix len=${data.size} hex=$hex")
w.newLine()
}
packetCount++
} catch (_: Exception) {}
}
}
@@ -0,0 +1,215 @@
package com.medithings.vesiscan.services
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.medithings.vesiscan.R
import java.util.Calendar
class ReminderService private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: ReminderService? = null
fun getInstance(context: Context): ReminderService =
instance ?: synchronized(this) {
instance ?: ReminderService(context.applicationContext).also { instance = it }
}
private const val CHANNEL_ID = "catheter_reminder"
private const val NOTIFICATION_ID = 2001
private const val REQUEST_CODE = 3001
private const val PREFS_NAME = "reminder_prefs"
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
init {
createNotificationChannel()
}
// Settings
var isEnabled: Boolean
get() = prefs.getBoolean("enabled", false)
set(value) {
prefs.edit().putBoolean("enabled", value).apply()
if (value) scheduleNext() else cancelAll()
}
var intervalMinutes: Int
get() = prefs.getInt("interval_minutes", 120)
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
var startHour: Int
get() = prefs.getInt("start_hour", 8)
set(value) { prefs.edit().putInt("start_hour", value).apply() }
var endHour: Int
get() = prefs.getInt("end_hour", 22)
set(value) { prefs.edit().putInt("end_hour", value).apply() }
var useSound: Boolean
get() = prefs.getBoolean("use_sound", true)
set(value) { prefs.edit().putBoolean("use_sound", value).apply() }
var useVibration: Boolean
get() = prefs.getBoolean("use_vibration", true)
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
var usePopup: Boolean
get() = prefs.getBoolean("use_popup", true)
set(value) { prefs.edit().putBoolean("use_popup", value).apply() }
var isFlexible: Boolean
get() = prefs.getBoolean("is_flexible", true)
set(value) { prefs.edit().putBoolean("is_flexible", value).apply() }
val permissionGranted: Boolean
get() = NotificationManagerCompat.from(context).areNotificationsEnabled()
fun scheduleNext() {
if (!isEnabled) return
val now = Calendar.getInstance()
val nextTime = Calendar.getInstance().apply {
timeInMillis = now.timeInMillis + intervalMinutes * 60 * 1000L
}
// 시간대 체크: endHour 이후면 다음날 startHour로
if (nextTime.get(Calendar.HOUR_OF_DAY) >= endHour ||
nextTime.get(Calendar.HOUR_OF_DAY) < startHour) {
nextTime.apply {
if (get(Calendar.HOUR_OF_DAY) >= endHour) {
add(Calendar.DAY_OF_YEAR, 1)
}
set(Calendar.HOUR_OF_DAY, startHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
}
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} else {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} catch (_: SecurityException) {
// Fallback to inexact alarm
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
}
fun reschedule() {
if (isEnabled) {
cancelAll()
scheduleNext()
}
}
fun cancelAll() {
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
}
private val reminderMessageIds = listOf(
R.string.reminder_msg_1,
R.string.reminder_msg_2,
R.string.reminder_msg_3,
R.string.reminder_msg_4,
R.string.reminder_msg_5,
)
fun showNotification() {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(context.getString(reminderMessageIds.random()))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
if (useSound) {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
} else {
builder.setSilent(true)
}
if (!usePopup) {
builder.setPriority(NotificationCompat.PRIORITY_LOW)
}
try {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
} catch (_: SecurityException) { }
if (useVibration) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
}
} catch (_: Exception) { }
}
// Chain: schedule next alarm
scheduleNext()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, context.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_desc)
enableVibration(true)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
}
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
ReminderService.getInstance(context).showNotification()
}
}
@@ -0,0 +1,76 @@
package com.medithings.vesiscan.services
import com.medithings.vesiscan.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())
}
}
@@ -0,0 +1,122 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import com.medithings.vesiscan.models.*
import com.google.gson.Gson
class UserStorage private constructor(context: Context) {
companion object {
@Volatile
private var instance: UserStorage? = null
fun getInstance(context: Context): UserStorage =
instance ?: synchronized(this) {
instance ?: UserStorage(context.applicationContext).also { instance = it }
}
}
private val prefs: SharedPreferences = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
private val gson = Gson()
init {
migratePinToKeychain()
}
var user: User
get() {
val json = prefs.getString("user_data", null) ?: return User()
return try { gson.fromJson(json, User::class.java) } catch (_: Exception) { User() }
}
set(value) { prefs.edit().putString("user_data", gson.toJson(value)).apply() }
var isRegistered: Boolean
get() = prefs.getBoolean("is_registered", false)
set(value) { prefs.edit().putBoolean("is_registered", value).apply() }
var pin: String
get() {
KeychainService.load("user_pin")?.let { return it }
return prefs.getString("user_pin", "") ?: ""
}
set(value) {
KeychainService.save("user_pin", value)
prefs.edit().remove("user_pin").apply()
}
var hasPin: Boolean
get() = prefs.getBoolean("has_pin", false)
set(value) { prefs.edit().putBoolean("has_pin", value).apply() }
var sensorMode: SensorMode
get() {
val raw = prefs.getString("sensor_mode", null) ?: return SensorMode.PIEZO
return try { SensorMode.valueOf(raw) } catch (_: Exception) { SensorMode.PIEZO }
}
set(value) { prefs.edit().putString("sensor_mode", value.name).apply() }
var lastDeviceAddress: String?
get() = prefs.getString("last_device_address", null)
set(value) { prefs.edit().putString("last_device_address", value).apply() }
var lastDeviceName: String?
get() = prefs.getString("last_device_name", null)
set(value) { prefs.edit().putString("last_device_name", value).apply() }
var catheterizationLevel: Int
get() {
val v = prefs.getInt("catheterization_level", 0)
return if (v > 0) v else 8
}
set(value) { prefs.edit().putInt("catheterization_level", value).apply() }
var bladderLevel: Int
get() = prefs.getInt("bladder_level", 0)
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
var catheterCount: Int
get() = prefs.getInt("catheter_count", 15)
set(value) { prefs.edit().putInt("catheter_count", value).apply() }
var hasCompletedOnboarding: Boolean
get() = prefs.getBoolean("has_completed_onboarding", false)
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
var hasConsented: Boolean
get() = prefs.getBoolean("has_consented", false)
set(value) { prefs.edit().putBoolean("has_consented", value).apply() }
var consentTimestamp: Long
get() = prefs.getLong("consent_timestamp", 0L)
set(value) { prefs.edit().putLong("consent_timestamp", value).apply() }
var consentPersonalInfo: Boolean
get() = prefs.getBoolean("consent_personal_info", false)
set(value) { prefs.edit().putBoolean("consent_personal_info", value).apply() }
var consentMedicalInfo: Boolean
get() = prefs.getBoolean("consent_medical_info", false)
set(value) { prefs.edit().putBoolean("consent_medical_info", value).apply() }
var consentDataCollection: Boolean
get() = prefs.getBoolean("consent_data_collection", false)
set(value) { prefs.edit().putBoolean("consent_data_collection", value).apply() }
var piezoSettings: PiezoSettings
get() {
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
return try { gson.fromJson(json, PiezoSettings::class.java) } catch (_: Exception) { PiezoSettings() }
}
set(value) { prefs.edit().putString("piezo_settings", gson.toJson(value)).apply() }
private fun migratePinToKeychain() {
val existingPin = prefs.getString("user_pin", null)
if (existingPin.isNullOrEmpty()) return
val keychainPin = KeychainService.load("user_pin")
if (!keychainPin.isNullOrEmpty()) {
prefs.edit().remove("user_pin").apply()
return
}
KeychainService.save("user_pin", existingPin)
prefs.edit().remove("user_pin").apply()
}
}
@@ -0,0 +1,42 @@
/*
* WakeupAlarmPrefs — 기상 알람 설정 persistence (SharedPreferences).
*
* 사용자가 "설정 탭" 에서 toggle on/off → 일어남 감지 시 빨간 팝업.
* LYING 유지 시간 임계 (default 3 min) 도 함께.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
object WakeupAlarmPrefs {
private const val PREF_NAME = "wakeup_alarm_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LYING_DURATION_SEC = "lying_duration_sec"
private var prefs: SharedPreferences? = null
private fun ensure(context: Context): SharedPreferences {
val p = prefs
if (p != null) return p
val newP = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs = newP
return newP
}
fun isEnabled(context: Context): Boolean =
ensure(context).getBoolean(KEY_ENABLED, false)
fun setEnabled(context: Context, enabled: Boolean) {
ensure(context).edit().putBoolean(KEY_ENABLED, enabled).apply()
}
/** 일어남으로 인정하기 위한 직전 LYING 유지 시간 (초). default 180 = 3분. */
fun lyingDurationSec(context: Context): Int =
ensure(context).getInt(KEY_LYING_DURATION_SEC, 180)
fun setLyingDurationSec(context: Context, sec: Int) {
ensure(context).edit().putInt(KEY_LYING_DURATION_SEC, sec).apply()
}
}
@@ -0,0 +1,237 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.os.Build
import android.util.Log
import com.medithings.vesiscan.ble.BleManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class LabdbException(message: String) : IOException(message)
class PendingApprovalException : LabdbException("Device is awaiting administrator approval")
class ReregisterRequiredException(message: String) : LabdbException(message)
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
data class UploadResult(
val ok: Boolean,
val sessionId: String,
val created: Boolean,
val totalRecords: Int,
val inserted: Int,
val duplicates: Int,
val errors: Int,
)
/**
* https://labdb.medithings.net REST 클라이언트.
*
* 사용 순서:
* 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회
* 2) checkStatus() — 임상 화면 진입 시 매번
* 3) uploadJson(payload) — End Session 후 measurement.json 업로드
*
* 모든 메서드는 suspend, IO 디스패처에서 실행.
*/
object LabdbClient {
private const val TAG = "LabdbClient"
private const val BASE = "https://labdb.medithings.net/api/v1"
private const val APP_NAME = "VesiScan"
private val JSON = "application/json; charset=utf-8".toMediaType()
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
/** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */
suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) {
val ble = BleManager.getInstance(context)
val body = JSONObject().apply {
put("appName", APP_NAME)
put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}")
ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) }
ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) }
ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) }
put("appVersion", appVersion)
}
sanitizeForJsonb(body)
Log.d(TAG, ">> POST /devices/register payload=$body")
val req = Request.Builder()
.url("$BASE/devices/register")
.post(body.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> {
val deviceId = json.optString("deviceId")
val apiKey = json.optString("apiKey")
val status = json.optString("status", "pending")
val message = json.optString("message", "")
if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey")
LabdbCredentials.apiKey = apiKey
LabdbCredentials.deviceId = deviceId
LabdbCredentials.lastStatus = status
RegisterResult(deviceId, apiKey, status, message)
}
else -> throw apiError(code, json)
}
}
/** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */
suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: return@withContext StatusResult(0, "not_registered", null, null)
val req = Request.Builder()
.url("$BASE/devices/status")
.header("X-API-Key", key)
.get()
.build()
val (code, json) = exec(req)
when (code) {
200 -> {
val status = json.optString("status", "unknown")
LabdbCredentials.lastStatus = status
StatusResult(
httpStatus = 200,
status = status,
deviceName = json.optString("deviceName").ifBlank { null },
sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null,
)
}
401, 404 -> {
LabdbCredentials.clear()
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED"
throw ReregisterRequiredException("$errCode: server requires re-registration")
}
else -> throw apiError(code, json)
}
}
/** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */
suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
// PostgreSQL JSONB rejects \u0000 — strip NULL/control chars from payload
sanitizeForJsonb(payload)
val req = Request.Builder()
.url("$BASE/upload/json")
.header("X-API-Key", key)
.post(payload.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> UploadResult(
ok = json.optBoolean("ok", true),
sessionId = json.optString("sessionId"),
created = json.optBoolean("created", code == 201),
totalRecords = json.optInt("totalRecords"),
inserted = json.optInt("inserted"),
duplicates = json.optInt("duplicates"),
errors = json.optInt("errors"),
)
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") }
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* 세션 삭제 — DELETE /api/v1/sessions/{testId}. Cascade로 모든 records 함께 제거.
* 204(No Content) 또는 200/404(이미 없음) = 성공. 401/403/등은 throw.
*/
suspend fun deleteSession(testId: String): Boolean = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
Log.d(TAG, ">> DELETE /sessions/$testId")
val req = Request.Builder()
.url("$BASE/sessions/$testId")
.header("X-API-Key", key)
.delete()
.build()
val (code, json) = exec(req)
when (code) {
200, 204, 404 -> true // 404 = 이미 없음, 멱등 처리
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* Recursively strip NULL (0x00) and other C0 control chars from every string value.
* PostgreSQL JSONB rejects escapes with "unsupported Unicode escape sequence"
* (HTTP 500). BLE responses occasionally carry trailing 0x00 padding that survives
* String(US_ASCII) decoding + .trim().
*/
internal fun sanitizeForJsonb(root: JSONObject) {
val keys = root.keys().asSequence().toList()
for (k in keys) {
when (val v = root.opt(k)) {
is String -> root.put(k, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun sanitizeArray(arr: JSONArray) {
for (i in 0 until arr.length()) {
when (val v = arr.opt(i)) {
is String -> arr.put(i, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun stripControl(s: String): String {
if (s.none { it.code == 0 || it.code == 127 || (it.code in 1..31 && it.code != 9 && it.code != 10 && it.code != 13) }) return s
val sb = StringBuilder(s.length)
for (c in s) {
val code = c.code
if (code == 0 || code == 127 || (code in 1..31 && code != 9 && code != 10 && code != 13)) continue
sb.append(c)
}
return sb.toString()
}
private fun apiError(code: Int, json: JSONObject): LabdbApiError {
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: "HTTP_$code"
val msg = err?.optString("message") ?: json.toString().take(200)
return LabdbApiError(code, errCode, msg)
}
private fun exec(req: Request): Pair<Int, JSONObject> {
return try {
client.newCall(req).execute().use { res ->
val raw = res.body?.string().orEmpty()
Log.d(TAG, "<< ${req.method} ${req.url.encodedPath} → HTTP ${res.code} body=${raw.take(500)}")
val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() }
res.code to obj
}
} catch (t: IOException) {
Log.w(TAG, "exec failed: ${req.method} ${req.url}", t)
throw LabdbNetworkError(t)
}
}
}
@@ -0,0 +1,46 @@
package com.medithings.vesiscan.services.labdb
import com.medithings.vesiscan.services.KeychainService
/**
* labdb (https://labdb.medithings.net) 자격 증명 저장소.
*
* apiKey/deviceId는 KeychainService(EncryptedSharedPreferences)에 안전 저장.
* 등록은 앱 첫 임상 모드 진입 시 1회만 — 재등록 시 pending device 누적되므로
* 401 INVALID_API_KEY / 404 DEVICE_DELETED 응답을 받았을 때만 clear() 후 재등록.
*/
object LabdbCredentials {
private const val KEY_API_KEY = "labdb.apiKey"
private const val KEY_DEVICE_ID = "labdb.deviceId"
private const val KEY_LAST_STATUS = "labdb.lastStatus"
var apiKey: String?
get() = KeychainService.load(KEY_API_KEY)
set(value) {
if (value == null) KeychainService.delete(KEY_API_KEY)
else KeychainService.save(KEY_API_KEY, value)
}
var deviceId: String?
get() = KeychainService.load(KEY_DEVICE_ID)
set(value) {
if (value == null) KeychainService.delete(KEY_DEVICE_ID)
else KeychainService.save(KEY_DEVICE_ID, value)
}
/** 마지막으로 GET /devices/status 응답에서 받은 상태 ("active"/"pending"/"revoked") */
var lastStatus: String?
get() = KeychainService.load(KEY_LAST_STATUS)
set(value) {
if (value == null) KeychainService.delete(KEY_LAST_STATUS)
else KeychainService.save(KEY_LAST_STATUS, value)
}
val isRegistered: Boolean get() = !apiKey.isNullOrBlank()
fun clear() {
apiKey = null
deviceId = null
lastStatus = null
}
}
@@ -0,0 +1,235 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 측정 폴더(measurement.json 포함) → labdb /upload/json 자동 업로드.
*
* 정책:
* - End Session 시 ClinicalSessionStore가 uploadAsync(folder)를 호출
* - 성공: folder/labdb_upload.json (응답 본문)
* - 실패: folder/labdb_upload_error.json (에러 본문) — 폴더는 그대로 유지되어 수동 재시도 가능
*
* dataType = "001" (내부 임상 R&D — 외부 일반 VesiScan "000"과 구분).
* measurement.json의 meta.data_type을 우선 사용, 없으면 DEFAULT_DATA_TYPE fallback.
*
* testId:
* - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에
* 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지).
*/
object LabdbUploader {
private const val TAG = "LabdbUploader"
private const val DEFAULT_DATA_TYPE = "001"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val isoFmt: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
/** 비동기 업로드 — fire-and-forget. 결과는 폴더 내 마커 파일로 확인. */
fun uploadAsync(context: Context, folder: File, onResult: ((Boolean, String) -> Unit)? = null) {
scope.launch {
val ok = runCatching { uploadBlocking(folder) }
.onFailure { Log.w(TAG, "upload failed: ${folder.name}", it) }
.getOrElse { e ->
writeError(folder, e)
onResult?.invoke(false, e.message ?: e.javaClass.simpleName)
return@launch
}
onResult?.invoke(true, "uploaded: ${ok.inserted}/${ok.totalRecords} (dup=${ok.duplicates})")
}
}
/** 동기 업로드 — 재시도 버튼/자동 호출 양쪽 사용. */
suspend fun uploadBlocking(folder: File): UploadResult {
// 새 파일명 (measurement_<folderName>.json) 우선, 구 버전 (measurement.json) fallback
val measurementFile = File(folder, "measurement_${folder.name}.json").takeIf { it.exists() }
?: File(folder, "measurement.json").takeIf { it.exists() }
?: folder.listFiles()?.firstOrNull { it.name.startsWith("measurement_") && it.name.endsWith(".json") }
?: throw IllegalStateException("measurement json not found in ${folder.name}")
val measurement = JSONObject(measurementFile.readText())
val payload = buildPayload(folder.name, measurement)
val result = LabdbClient.uploadJson(payload)
writeSuccess(folder, result)
return result
}
fun isUploaded(folder: File): Boolean = File(folder, "labdb_upload.json").exists()
fun lastError(folder: File): String? {
val f = File(folder, "labdb_upload_error.json")
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
}
/**
* 폴더에 해당하는 labdb 서버 세션 삭제.
* labdb_upload.json에 sessionId가 있으면 그것을 사용, 없으면 folderName → sanitizeTestId 변환.
* 미등록/미업로드 폴더의 testId도 그대로 호출 → labdb가 404로 응답 → 성공 처리.
*/
suspend fun deleteSessionForFolder(folder: File): Boolean {
val uploadMarker = File(folder, "labdb_upload.json")
val testId = if (uploadMarker.exists()) {
runCatching { JSONObject(uploadMarker.readText()).optString("sessionId") }
.getOrNull()?.takeIf { it.isNotBlank() } ?: sanitizeTestId(folder.name)
} else {
sanitizeTestId(folder.name)
}
return if (testId.isNotBlank()) LabdbClient.deleteSession(testId) else true
}
/** measurement.json → labdb payload. 각 cycle = 1 record. */
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
val meta = measurement.optJSONObject("meta") ?: JSONObject()
val cycles = measurement.optJSONArray("cycles") ?: JSONArray()
val testId = sanitizeTestId(folderName)
val dataType = meta.optString("data_type").ifBlank { DEFAULT_DATA_TYPE }
val records = JSONArray()
for (i in 0 until cycles.length()) {
val cycle = cycles.optJSONObject(i) ?: continue
records.put(cycleToRecord(cycle, meta))
}
val params = JSONObject().apply {
// 측정 컨텍스트 — admin 콘솔에서 한눈에 보이도록
put("posture", meta.optString("posture"))
put("step", meta.optString("step"))
put("is_alignment", meta.optBoolean("is_alignment"))
put("examiner", meta.optString("examiner"))
meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) }
meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) }
if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml"))
if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm"))
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
meta.optString("hardware_version").takeIf { it.isNotBlank() }?.let { put("hardware_version", it) }
meta.optString("serial_number").takeIf { it.isNotBlank() }?.let { put("serial_number", it) }
put("app_version", meta.optString("app_version_name"))
put("captured_cycles", cycles.length())
}
return JSONObject().apply {
put("testId", testId)
put("dataType", dataType)
// 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임).
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
put("sessionName", folderName)
put("memo", meta.optString("notes"))
meta.optLong("ended_at", 0L).takeIf { it > 0 }?.let {
put("savedAt", isoFmt.format(Date(it)))
}
put("params", params)
put("recordCount", records.length())
put("records", records)
}
}
private fun cycleToRecord(cycle: JSONObject, meta: JSONObject): JSONObject {
val rowIndex = cycle.optInt("cycle")
val tsMs = cycle.optLong("timestamp_ms")
val piezo = cycle.optJSONObject("piezo") ?: JSONObject()
val imuArr = cycle.optJSONArray("imu") ?: JSONArray()
// labdb 표준 sensor.imu는 단일 샘플. 우리는 cycle당 다수 샘플(FIFO 순)이라
// 마지막(=가장 최근, piezo 캡처 직전) 샘플을 sensor.imu에 넣고
// 전체를 sensor.imu_samples에 free field로 보존.
val latestImu = if (imuArr.length() > 0) imuArr.optJSONObject(imuArr.length() - 1) else null
val sensor = JSONObject().apply {
latestImu?.let {
put("imu", JSONObject().apply {
put("ax", it.optDouble("ax"))
put("ay", it.optDouble("ay"))
put("az", it.optDouble("az"))
put("gx", it.optDouble("gx"))
put("gy", it.optDouble("gy"))
put("gz", it.optDouble("gz"))
})
}
put("imu_samples", imuArr)
put("imu_sample_count", imuArr.length())
}
val channelsArr = JSONArray()
for (ch in 0..5) {
val data = piezo.optJSONArray("CH$ch") ?: continue
var peak = 0
var peakIdx = 0
for (i in 0 until data.length()) {
val v = data.optInt(i)
if (v > peak) { peak = v; peakIdx = i }
}
channelsArr.put(JSONObject().apply {
put("ch", ch)
put("peak", peak)
put("peakIdx", peakIdx)
put("data", data)
})
}
return JSONObject().apply {
put("rowIndex", rowIndex)
put("datetime", isoFmt.format(Date(tsMs)))
put("commandType", "MTB")
put("sensor", sensor)
put("channels", channelsArr)
// Alignment metadata (alignment session 의 cycle 일 때만 채워짐) — labdb 서버가
// raw.json export 시 함께 내려줘 분석 PC 에서 phase 별 frame 분석 가능.
if (cycle.has("align_phase")) {
put("align_phase", cycle.optString("align_phase"))
put("align_score", cycle.optInt("align_score"))
cycle.optString("align_hint").takeIf { it.isNotBlank() }?.let { put("align_hint", it) }
cycle.optString("align_icon").takeIf { it.isNotBlank() }?.let { put("align_icon", it) }
cycle.optJSONArray("align_detected")?.let { put("align_detected", it) }
cycle.optJSONArray("align_urine_lens")?.let { put("align_urine_lens", it) }
}
}
}
/** ^[A-Za-z0-9_-]{1,40}$ 강제. 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix. */
private fun sanitizeTestId(raw: String): String {
// - 와 _ 이외 특수문자 제거
val cleaned = raw.replace(Regex("[^A-Za-z0-9_-]"), "_")
if (cleaned.length <= 40) return cleaned
val hash = MessageDigest.getInstance("SHA-1").digest(cleaned.toByteArray())
.joinToString("") { "%02x".format(it) }.take(8)
return cleaned.take(31) + "_" + hash
}
private fun writeSuccess(folder: File, r: UploadResult) {
val body = JSONObject().apply {
put("ok", r.ok)
put("sessionId", r.sessionId)
put("created", r.created)
put("totalRecords", r.totalRecords)
put("inserted", r.inserted)
put("duplicates", r.duplicates)
put("errors", r.errors)
put("uploadedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload.json").writeText(body.toString(2))
// 이전 실패 마커가 있었다면 정리
File(folder, "labdb_upload_error.json").delete()
}
private fun writeError(folder: File, t: Throwable) {
val body = JSONObject().apply {
put("error", t.javaClass.simpleName)
put("message", t.message ?: "")
if (t is LabdbApiError) {
put("httpStatus", t.httpStatus)
put("code", t.code)
}
put("failedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload_error.json").writeText(body.toString(2))
}
}
@@ -0,0 +1,298 @@
/*
* Copyright 2026 Charles KWON (KWON Ohjun)
* charleskwon@medithings.co.kr / charleskwonohjun@gmail.com
* MEDiThings Inc.
* All rights reserved.
*/
package com.medithings.vesiscan.speech
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Extracts a milliliter volume from speech-recognition text via the
* Hugging Face Inference API (Qwen 2.5-72B model).
*
* Selects a language-specific system prompt to maximize extraction accuracy.
* Falls back to [VolumeParser] when the API key is missing or the request fails.
*
* @param apiKey Hugging Face bearer token.
*/
class HfVolumeExtractor(private val apiKey: String) {
/** Successful extraction result. */
data class ExtractResult(
val volumeMl: Int,
val rawText: String,
val explanation: String,
)
private val client = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
/**
* Send [speechText] to the AI model using a prompt tailored to [language],
* and return the extracted volume, or `null` when extraction fails.
*/
suspend fun extract(
speechText: String,
language: SpeechLanguage = SpeechLanguage.KOREAN,
): ExtractResult? = withContext(Dispatchers.IO) {
Log.d(TAG, "Starting extraction | lang=${language.code} | input: $speechText")
if (apiKey.isBlank()) {
Log.w(TAG, "API key is blank; falling back to local parser")
return@withContext fallbackParse(speechText)
}
try {
val prompt = getSystemPrompt(language)
val requestBody = buildRequestBody(speechText, prompt)
val request = Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", JSON_MEDIA_TYPE)
.post(requestBody.toRequestBody(JSON_MEDIA_TYPE.toMediaType()))
.build()
val response = client.newCall(request).execute()
val body = response.body?.string() ?: return@withContext null
Log.d(TAG, "Response code: ${response.code}")
if (!response.isSuccessful) {
Log.e(TAG, "API failed (${response.code}); falling back to local parser")
return@withContext fallbackParse(speechText)
}
parseApiResponse(body, speechText)
} catch (e: Exception) {
Log.e(TAG, "API error: ${e.message}", e)
fallbackParse(speechText)
}
}
// ── Private helpers ─────────────────────────────────────────────────
private fun buildRequestBody(speechText: String, systemPrompt: String): String =
JSONObject().apply {
put("model", MODEL_ID)
put("max_tokens", MAX_TOKENS)
put("stream", false)
put("messages", JSONArray().apply {
put(JSONObject().apply {
put("role", "system")
put("content", systemPrompt)
})
put(JSONObject().apply {
put("role", "user")
put("content", speechText)
})
})
}.toString()
private fun parseApiResponse(body: String, speechText: String): ExtractResult? {
val content = JSONObject(body)
.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content")
Log.d(TAG, "Model response: $content")
val jsonStr = extractJsonObject(content)
val resultJson = JSONObject(jsonStr)
val ml = resultJson.getInt("ml")
val explanation = resultJson.optString("explanation", "")
Log.d(TAG, "Parsed result: ${ml}ml - $explanation")
return if (ml in VALID_RANGE) {
ExtractResult(ml, speechText, explanation)
} else {
Log.w(TAG, "Volume out of range: ${ml}ml")
null
}
}
private fun extractJsonObject(text: String): String {
val start = text.indexOf('{')
val end = text.lastIndexOf('}')
return if (start >= 0 && end > start) text.substring(start, end + 1) else text
}
private fun fallbackParse(text: String): ExtractResult? {
val result = VolumeParser.parse(text) ?: return null
Log.d(TAG, "Local parse result: ${result.volumeMl}ml")
return ExtractResult(result.volumeMl, result.rawText, "Local parse")
}
/** Return a system prompt optimized for the given [language]. */
private fun getSystemPrompt(language: SpeechLanguage): String = when (language) {
SpeechLanguage.KOREAN -> PROMPT_KOREAN
SpeechLanguage.ENGLISH -> PROMPT_ENGLISH
SpeechLanguage.CHINESE -> PROMPT_CHINESE
SpeechLanguage.JAPANESE -> PROMPT_JAPANESE
}
companion object {
private const val TAG = "HfVolumeExtractor"
private const val API_URL = "https://router.huggingface.co/v1/chat/completions"
private const val MODEL_ID = "Qwen/Qwen2.5-72B-Instruct"
private const val JSON_MEDIA_TYPE = "application/json"
private const val MAX_TOKENS = 100
private const val CONNECT_TIMEOUT_SECONDS = 15L
private const val READ_TIMEOUT_SECONDS = 30L
private val VALID_RANGE = 1..1000
// ── Language-specific prompts ───────────────────────────────────
private val PROMPT_BASE = """
## Your Mission
You are specialized in extracting MILLILITER (ml) volumes from voice input.
The text you receive is raw speech-to-text output from a medical/lab device.
Your ONLY job: find the ml number the user intended to say.
## Rules
- Extract a single integer in ml, range 1~1000
- mm and ml are the SAME in this context (user ALWAYS means ml, never millimeters)
- cc and ml are the SAME (1cc = 1ml)
- If ambiguous, prefer the most likely medical volume
- Look at ALL candidates (separated by " / ") to find consensus on the number
- If no valid volume found, return -1
- Respond ONLY in this JSON format:
""".trimIndent()
private val PROMPT_KOREAN = """
You are a milliliter (ml) volume extraction AI.
## Context
This is a KOREAN speech-to-text result from a medical/lab voice input device.
The user spoke in KOREAN to input a liquid volume in milliliters (ml).
Your task: understand Korean speech and extract the ml value accurately.
## Input Format
Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm").
STT often confuses mm/ml/미리/밀리 — the user ALWAYS means milliliters (ml).
## Korean-Specific Patterns
- Pure Korean numerals: "삼백 밀리", "백오십 미리"
- Numbers with units: "300ml", "150 미리"
- Korean-English mix: "삼백 ml", "원헌드레드 미리"
- Misheard Korean that is actually English: "쓰리헌드레드", "투헌드레드 피프티"
- Phonetic Korean of English: "파이브 헌드레드" = 500, "투 피프티" = 250
- Casual/slang: "반 리터" = 500, "한 컵" = 250, "반" = 500, "쿼터" = 250
- Unit variations: 밀리리터, 밀리미터, 미리, 밀리, ml, cc, 시시
## Critical: Korean Phonetic Analysis
Korean STT may transcribe English words as Korean phonetics.
SOUND THEM OUT to decode:
- 쓰리 = three, 투 = two, 원 = one, 포 = four, 파이브 = five
- 식스 = six, 세븐 = seven, 에잇 = eight, 나인 = nine, 텐 = ten
- 헌드레드 = hundred, 피프티 = fifty, 트웬티 = twenty, 서티 = thirty
- 지로/제로 = zero, 오 = five OR "o" (context dependent)
- 더블 = double, 트리플 = triple
$PROMPT_BASE
{"ml": <integer>, "explanation": "<한국어로 간단한 설명>"}
""".trimIndent()
private val PROMPT_ENGLISH = """
You are a milliliter (ml) volume extraction AI.
## Context
This is an ENGLISH speech-to-text result from a medical/lab voice input device.
The user spoke in ENGLISH to input a liquid volume in milliliters (ml).
Your task: understand English speech and extract the ml value accurately.
## Input Format
Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm").
STT often confuses mm/ml — the user ALWAYS means milliliters (ml).
## English-Specific Patterns
- Standard: "three hundred milliliters", "one fifty ml"
- Digit-by-digit: "three zero zero" = 300, "one five o" = 150, "two o five" = 205
- Casual: "half a liter" = 500, "a quarter liter" = 250, "half" = 500
- Compound: "one hundred and fifty", "two fifty"
- With modifiers: "double zero" = 00, "triple zero" = 000
- Unit variations: ml, milliliters, millimeters, cc
$PROMPT_BASE
{"ml": <integer>, "explanation": "<brief reason in English>"}
""".trimIndent()
private val PROMPT_CHINESE = """
你是一个毫升(ml)容量提取AI。
## 背景
这是来自医疗/实验室语音输入设备的中文语音识别结果。
用户用中文说出了一个液体容量(毫升/ml)。
你的任务:准确理解中文语音并提取毫升值。
## 输入格式
多个语音识别候选项用 " / " 分隔(例如 "95毫升 / 95ml / 九十五")。
语音识别经常混淆毫米/毫升——用户始终指的是毫升(ml)。
## 中文特有模式
- 中文数字: "三百毫升", "一百五十", "二百五"
- 阿拉伯数字+中文单位: "300毫升", "150ml"
- 口语: "半升" = 500, "一杯" = 250, "半" = 500
- 口语简称: "二百五" = 250(此处不是骂人), "三百" = 300
- 单位变体: 毫升, 毫米, ml, cc, 西西
## 关键:中文数字规则
- 一百五 = 150(一百五十的简称)
- 二百五 = 250(二百五十的简称)
- 两百 = 200 (两 = 2 in spoken Chinese)
- 半升 = 500ml, 四分之一升 = 250ml
- 一千 = 1000
$PROMPT_BASE
{"ml": <integer>, "explanation": "<用中文简要说明>"}
""".trimIndent()
private val PROMPT_JAPANESE = """
あなたはミリリットル(ml)容量抽出AIです。
## 背景
これは医療・実験室の音声入力デバイスからの日本語音声認識結果です。
ユーザーは日本語で液体の容量(ミリリットル/ml)を発話しました。
あなたのタスク:日本語の音声を正確に理解し、ml値を抽出すること。
## 入力形式
複数の音声認識候補が " / " で区切られています(例:"95ミリ / 95ml / きゅうじゅうご")。
音声認識はミリメートル/ミリリットルを混同することが多い — ユーザーは常にミリリットル(ml)を意味します。
## 日本語特有のパターン
- 日本語数字: "さんびゃくミリ", "ひゃくごじゅう"
- 漢数字: "三百ミリリットル", "百五十"
- 数字+単位: "300ml", "150ミリ"
- カジュアル: "半リットル" = 500, "コップ一杯" = 250
- 省略形: "にひゃくごじゅう" = 250, "さんびゃく" = 300
- 単位バリエーション: ミリリットル, ミリ, ml, cc, シーシー
## 重要:日本語数字ルール
- ひゃくごじゅう = 150
- にひゃく = 200
- にひゃくご = 250(にひゃくごじゅうの略)
- さんびゃく = 300
- ごひゃく = 500
- せん = 1000
- 半分 = half (context: 半リットル = 500ml)
$PROMPT_BASE
{"ml": <integer>, "explanation": "<日本語で簡単な説明>"}
""".trimIndent()
}
}
@@ -0,0 +1,103 @@
/*
* Copyright 2026 Charles KWON (KWON Ohjun)
* charleskwon@medithings.co.kr / charleskwonohjun@gmail.com
* MEDiThings Inc.
* All rights reserved.
*/
package com.medithings.vesiscan.speech
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
/**
* Supported speech-recognition locales.
*
* @property code BCP-47 language tag used by [RecognizerIntent].
* @property label Human-readable name shown in the UI language toggle.
*/
enum class SpeechLanguage(val code: String, val label: String) {
KOREAN("ko-KR", "한국어"),
ENGLISH("en-US", "English"),
CHINESE("zh-CN", "中文"),
JAPANESE("ja-JP", "日本語"),
}
/**
* Thin wrapper around Android's [SpeechRecognizer] that manages the recognizer lifecycle
* and exposes results through simple callbacks.
*
* @param context Application or Activity context.
* @param language Target speech-recognition locale.
* @param onResult Invoked with the ranked list of recognition candidates.
* @param onError Invoked with a [SpeechRecognizer] error code on failure.
* @param onReady Invoked when the recognizer is ready to accept speech.
*/
class SpeechRecognizerManager(
private val context: Context,
private val language: SpeechLanguage = SpeechLanguage.KOREAN,
private val onResult: (List<String>) -> Unit,
private val onError: (Int) -> Unit,
private val onReady: () -> Unit,
) {
private var speechRecognizer: SpeechRecognizer? = null
private val recognitionIntent: Intent by lazy {
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, language.code)
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, language.code)
putExtra(RecognizerIntent.EXTRA_ONLY_RETURN_LANGUAGE_PREFERENCE, false)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
}
}
/** Create a fresh [SpeechRecognizer] and begin listening. */
fun startListening() {
destroy()
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
setRecognitionListener(createListener())
startListening(recognitionIntent)
}
}
/** Ask the recognizer to stop capturing audio. */
fun stopListening() {
speechRecognizer?.stopListening()
}
/** Release the underlying [SpeechRecognizer] resources. */
fun destroy() {
speechRecognizer?.destroy()
speechRecognizer = null
}
private fun createListener(): RecognitionListener = object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) { onReady() }
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onError(error: Int) { this@SpeechRecognizerManager.onError(error) }
override fun onResults(results: Bundle?) {
val matches = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
.orEmpty()
onResult(matches)
}
override fun onPartialResults(partialResults: Bundle?) {}
override fun onEvent(eventType: Int, params: Bundle?) {}
}
companion object {
private const val MAX_RESULTS = 5
}
}
@@ -0,0 +1,260 @@
/*
* Copyright 2026 Charles KWON (KWON Ohjun)
* charleskwon@medithings.co.kr / charleskwonohjun@gmail.com
* MEDiThings Inc.
* All rights reserved.
*/
package com.medithings.vesiscan.speech
/**
* Local (offline) parser that extracts a volume in milliliters from speech-recognition text.
*
* Supported patterns:
* - Arabic digits with unit: "150ml", "200 미리"
* - Korean numerals with unit: "백오십 밀리리터", "이백 미리"
* - English numerals with unit: "one hundred fifty ml", "two hundred milliliters"
* - English digit-by-digit: "three zero zero" -> 300, "one five o" -> 150, "two o five" -> 205
* - Bare numbers (no unit): "삼백", "250", "three hundred"
*/
object VolumeParser {
/** Result of a successful volume parse. */
data class ParseResult(
val volumeMl: Int,
val rawText: String,
)
// ── Korean number mappings (DO NOT translate these Korean keys) ──────
private val DIGIT_MAP = mapOf(
"영" to 0, "공" to 0,
"일" to 1, "하나" to 1, "한" to 1,
"이" to 2, "둘" to 2, "두" to 2,
"삼" to 3, "셋" to 3, "세" to 3,
"사" to 4, "넷" to 4, "네" to 4,
"오" to 5, "다섯" to 5,
"육" to 6, "여섯" to 6,
"칠" to 7, "일곱" to 7,
"팔" to 8, "여덟" to 8,
"구" to 9, "아홉" to 9,
)
private val PLACE_MAP = mapOf(
"십" to 10,
"백" to 100,
"천" to 1000,
)
private val UNIT_KEYWORDS = listOf(
"milliliters", "milliliter", "millimeters", "millimeter",
"밀리리터", "밀리미터", "미리리터", "미리미터",
"미리", "밀리", "ml", "ML", "mL",
)
// ── English number mappings ─────────────────────────────────────────
private val EN_ONES = mapOf(
"zero" to 0, "one" to 1, "two" to 2, "three" to 3, "four" to 4,
"five" to 5, "six" to 6, "seven" to 7, "eight" to 8, "nine" to 9,
"ten" to 10, "eleven" to 11, "twelve" to 12, "thirteen" to 13,
"fourteen" to 14, "fifteen" to 15, "sixteen" to 16, "seventeen" to 17,
"eighteen" to 18, "nineteen" to 19,
)
private val EN_TENS = mapOf(
"twenty" to 20, "thirty" to 30, "forty" to 40, "fifty" to 50,
"sixty" to 60, "seventy" to 70, "eighty" to 80, "ninety" to 90,
)
/** Single-digit mapping for digit-by-digit reading (includes "o"/"oh" as zero). */
private val EN_SINGLE_DIGIT = mapOf(
"zero" to 0, "o" to 0, "oh" to 0,
"one" to 1, "two" to 2, "three" to 3, "four" to 4,
"five" to 5, "six" to 6, "seven" to 7, "eight" to 8, "nine" to 9,
)
// ── Public API ──────────────────────────────────────────────────────
/**
* Attempt to extract a volume (1..1000 ml) from [text].
*
* @return [ParseResult] when a valid volume is found, `null` otherwise.
*/
fun parse(text: String): ParseResult? {
val cleaned = text.trim()
if (cleaned.isEmpty()) return null
// 1) Arabic digits (most reliable)
extractArabicNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let {
return ParseResult(it, cleaned)
}
// 2) Korean numerals (checked before English to avoid "오" / "o" confusion)
extractKoreanNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let {
return ParseResult(it, cleaned)
}
// 3) English compound numerals ("one hundred fifty")
extractEnglishNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let {
return ParseResult(it, cleaned)
}
// 4) English digit-by-digit ("three zero zero") -- last because "o" maps to 0
extractDigitByDigitEnglish(cleaned)?.takeIf { it in VALID_RANGE }?.let {
return ParseResult(it, cleaned)
}
return null
}
/**
* Try each [candidates] in order and return the first successful parse.
*
* Useful because [android.speech.SpeechRecognizer] may return multiple hypotheses.
*/
fun parseBest(candidates: List<String>): ParseResult? =
candidates.firstNotNullOfOrNull { parse(it) }
// ── Extraction strategies ───────────────────────────────────────────
private fun extractArabicNumber(text: String): Int? {
val stripped = removeUnits(text).replace(Regex("[^0-9]"), "")
return stripped.toIntOrNull()
}
private fun extractKoreanNumber(text: String): Int? {
val stripped = removeUnits(text)
.replace(Regex("[0-9mlML\\s]", RegexOption.IGNORE_CASE), "")
.trim()
if (stripped.isEmpty()) return null
return parseKoreanNumberString(stripped)
}
/**
* Convert a Korean numeral string to an integer.
* Example: "백오십" -> 150, "이백삼" -> 203, "삼백" -> 300
*/
private fun parseKoreanNumberString(text: String): Int? {
var result = 0
var current = 0
var i = 0
val sortedDigits = DIGIT_MAP.entries.sortedByDescending { it.key.length }
while (i < text.length) {
// Try place-value keywords first (십, 백, 천)
val placeMatch = PLACE_MAP.entries.firstOrNull { text.startsWith(it.key, i) }
if (placeMatch != null) {
if (current == 0) current = 1
result += current * placeMatch.value
current = 0
i += placeMatch.key.length
continue
}
// Try digit words (longest match first: "다섯" before "다")
val digitMatch = sortedDigits.firstOrNull { text.startsWith(it.key, i) }
if (digitMatch != null) {
current = digitMatch.value
i += digitMatch.key.length
continue
}
// Unrecognized character -- skip
i++
}
result += current
return result.takeIf { it > 0 }
}
/**
* Convert an English numeral phrase to an integer.
* Example: "one hundred fifty" -> 150, "two hundred" -> 200
*/
private fun extractEnglishNumber(text: String): Int? {
val words = tokenize(text)
if (words.isEmpty()) return null
var result = 0
var current = 0
for (word in words) {
when {
word == "thousand" -> {
if (current == 0) current = 1
current *= 1_000
}
word == "hundred" -> {
if (current == 0) current = 1
current *= 100
}
word == "and" -> continue
word in EN_ONES -> current += EN_ONES.getValue(word)
word in EN_TENS -> current += EN_TENS.getValue(word)
else -> {
// Handle hyphenated forms: "twenty-five"
val parts = word.split("-")
if (parts.size == 2) {
val tens = EN_TENS[parts[0]]
val ones = EN_ONES[parts[1]]
if (tens != null && ones != null) current += tens + ones
}
}
}
}
result += current
return result.takeIf { it > 0 }
}
/**
* Convert digit-by-digit English reading to an integer.
* Example: "three zero zero" -> 300, "one five o" -> 150
* Supports "double" / "triple" modifiers.
*/
private fun extractDigitByDigitEnglish(text: String): Int? {
val words = tokenize(text)
if (words.isEmpty()) return null
// Expand "double"/"triple" modifiers
val expanded = buildList {
var i = 0
while (i < words.size) {
when {
words[i] == "double" && i + 1 < words.size -> {
repeat(2) { add(words[i + 1]) }
i += 2
}
words[i] == "triple" && i + 1 < words.size -> {
repeat(3) { add(words[i + 1]) }
i += 2
}
else -> {
add(words[i])
i++
}
}
}
}
// Every token must be a single digit
val digits = expanded.map { EN_SINGLE_DIGIT[it] ?: return null }
if (digits.size !in 2..4) return null
val result = digits.fold(0) { acc, d -> acc * 10 + d }
return result.takeIf { it > 0 }
}
// ── Utilities ───────────────────────────────────────────────────────
private fun removeUnits(text: String): String =
UNIT_KEYWORDS.fold(text) { acc, unit -> acc.replace(unit, "", ignoreCase = true) }.trim()
/** Lowercase, strip units and non-alpha characters, then split on whitespace. */
private fun tokenize(text: String): List<String> {
val stripped = removeUnits(text).lowercase().replace(Regex("[^a-z\\s-]"), "").trim()
return stripped.split(Regex("\\s+")).filter { it.isNotEmpty() }
}
private val VALID_RANGE = 1..1000
}
@@ -0,0 +1,71 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
/**
* 배터리 잔량 경고 배너 — PlacementGuide / PiezoMonitoring 상단.
*
* 임계:
* - 15% 이하 → 주황 경고 ("기기 배터리 부족 — 곧 충전하세요")
* - 5% 이하 → 빨강 위급 ("기기 배터리 위급 — 측정이 갑자기 중단될 수 있음")
* - 그 외 / 미연결 / 0% (아직 미수신) → 숨김
*/
@Composable
fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
val isConnected by bleManager.isConnected
val batt by bleManager.batteryLevel
if (!isConnected) return
if (batt <= 0) return // 응답 받기 전엔 0 → false positive 방지
if (batt > LOW_BATTERY_THRESHOLD) return
val critical = batt <= CRITICAL_BATTERY_THRESHOLD
val bgColor = if (critical) Color(0xFFD32F2F) else Color(0xFFFF9500)
val title = if (critical) stringResource(R.string.battery_critical_title) else stringResource(R.string.battery_low_title)
val subtitle = if (critical)
stringResource(R.string.battery_critical_desc, batt)
else
stringResource(R.string.battery_low_desc, batt)
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.BatteryAlert, "Battery", tint = Color.White)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
}
}
}
const val LOW_BATTERY_THRESHOLD = 15
const val CRITICAL_BATTERY_THRESHOLD = 5
@@ -0,0 +1,121 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.rive.runtime.kotlin.RiveAnimationView
import app.rive.runtime.kotlin.core.Fit
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.UrgencyLevel
import kotlinx.coroutines.delay
import kotlin.random.Random
/**
* Bladdy Rive animation that responds to urgency level changes.
* Used in monitoring views where bladder level is tracked.
*
* State machine inputs:
* 0 = idle, 1 = wave, 3 = urgent, 4 = warning, 5 = eyes
*/
@Composable
fun BladdyRiveView(
urgency: UrgencyLevel,
size: Dp = 100.dp,
modifier: Modifier = Modifier
) {
val riveView = remember { mutableListOf<RiveAnimationView>() }
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { context ->
RiveAnimationView(context).apply {
setRiveResource(
R.raw.bladdy_final12,
stateMachineName = "State Machine 1",
fit = Fit.CONTAIN
)
riveView.clear()
riveView.add(this)
}
},
modifier = Modifier.size(size)
)
}
// Respond to urgency changes
LaunchedEffect(urgency) {
val view = riveView.firstOrNull() ?: return@LaunchedEffect
when (urgency) {
UrgencyLevel.SAFE -> {
view.setNumberState("State Machine 1", "state", 0f)
// Random idle animations every 4 seconds
while (true) {
delay(4000)
val randomState = listOf(0f, 1f, 5f).random()
view.setNumberState("State Machine 1", "state", randomState)
}
}
UrgencyLevel.WARNING -> {
view.setNumberState("State Machine 1", "state", 4f)
}
UrgencyLevel.URGENT -> {
view.setNumberState("State Machine 1", "state", 3f)
}
}
}
}
/**
* Bladdy Rive animation for idle/decorative use.
* Plays random animations (idle, wave, eyes) every 5 seconds.
* Used on home, onboarding, registration, sensor select, pin, scan screens.
*/
@Composable
fun BladdyIdleRiveView(
size: Dp = 220.dp,
modifier: Modifier = Modifier
) {
val riveView = remember { mutableListOf<RiveAnimationView>() }
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { context ->
RiveAnimationView(context).apply {
setRiveResource(
R.raw.bladdy_final12,
stateMachineName = "State Machine 1",
fit = Fit.CONTAIN
)
setNumberState("State Machine 1", "state", 0f)
riveView.clear()
riveView.add(this)
}
},
modifier = Modifier.size(size)
)
}
// Random idle animations every 5 seconds
LaunchedEffect(Unit) {
while (true) {
delay(5000)
val view = riveView.firstOrNull() ?: continue
val randomState = listOf(0f, 1f, 5f).random()
view.setNumberState("State Machine 1", "state", randomState)
}
}
}
@@ -0,0 +1,82 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.theme.MlPrimary
enum class BleReconnectionState {
DISCONNECTED, RECONNECTING, FAILED
}
@Composable
fun BleDisconnectionBanner(
state: BleReconnectionState,
onReconnect: () -> Unit
) {
val bannerColor = when (state) {
BleReconnectionState.DISCONNECTED -> Color(0xFFFF9500)
BleReconnectionState.RECONNECTING -> MlPrimary
BleReconnectionState.FAILED -> Color.Red
}
Row(
modifier = Modifier
.fillMaxWidth()
.shadow(8.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.1f))
.clip(RoundedCornerShape(14.dp))
.background(bannerColor)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
when (state) {
BleReconnectionState.RECONNECTING -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
}
else -> {
Text("!", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (state == BleReconnectionState.RECONNECTING) "Reconnecting..." else "Device Disconnected",
fontWeight = FontWeight.Bold,
color = Color.White,
fontSize = 14.sp
)
Text(
text = if (state == BleReconnectionState.FAILED) "Could not reconnect" else "Connection lost",
color = Color.White.copy(alpha = 0.8f),
fontSize = 12.sp
)
}
if (state != BleReconnectionState.RECONNECTING) {
Button(
onClick = onReconnect,
colors = ButtonDefaults.buttonColors(containerColor = Color.White),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(8.dp)
) {
Text("Reconnect", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = bannerColor)
}
}
}
}
@@ -0,0 +1,72 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.medithings.vesiscan.models.UrgencyLevel
@Composable
fun DonutChartView(
progress: Double,
urgency: UrgencyLevel,
lineWidth: Dp = 24.dp,
size: Dp = 240.dp
) {
val animatedProgress by animateFloatAsState(
targetValue = progress.toFloat().coerceIn(0f, 1f),
animationSpec = tween(600),
label = "progress"
)
val gradientColors = urgency.gradientColors
val pct = (progress * 100).toInt()
Canvas(
modifier = Modifier
.size(size)
.semantics { contentDescription = "Bladder fill $pct percent, ${urgency.message}" }
) {
val strokeWidth = lineWidth.toPx()
// Stroke 외곽이 Canvas 경계 밖으로 튀어나오는 걸 막기 위해 radius를 inner로 명시.
// 회의 #5: 게이지가 도넛 외곽선을 이탈해 보이던 문제 해결.
val outerR = this.size.minDimension / 2f
val innerR = outerR - strokeWidth / 2f
val center = androidx.compose.ui.geometry.Offset(this.size.width / 2f, this.size.height / 2f)
// Background track
drawCircle(
color = Color.Gray.copy(alpha = 0.12f),
radius = innerR,
center = center,
style = Stroke(width = strokeWidth)
)
// Progress arc — same radius
if (animatedProgress > 0f) {
val arcSize = androidx.compose.ui.geometry.Size(innerR * 2, innerR * 2)
val arcOffset = androidx.compose.ui.geometry.Offset(
center.x - innerR, center.y - innerR
)
drawArc(
color = gradientColors.first(),
startAngle = -90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
topLeft = arcOffset,
size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
}
@@ -0,0 +1,107 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import kotlinx.coroutines.delay
/**
* 펌웨어 상태 배너 — PlacementGuide / PiezoMonitoring 상단에 노출.
*
* 표시 케이스:
* (1) 펌웨어 구버전 (build < MIN_FIRMWARE_BUILD) — 주황 경고
* (2) 연결 후 8초 넘게 mid? 응답 미수신 — 빨강 경고 (펌웨어가 mid 미지원)
* (3) 정상 또는 미연결 — 안 보임
*/
@Composable
fun FirmwareWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
val fw by bleManager.firmwareVersion
val isConnected by bleManager.isConnected
val lastRxAt by bleManager.lastBleRxAt
// 미연결 상태에선 펌웨어 배너 일절 노출 X. dev bypass / demo mode / 이전 세션
// 잔여 firmwareVersion 값으로 인한 false positive 방지.
if (!isConnected) return
// (2) 연결됐는데 일정 시간 동안 펌웨어 미수신 추적
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(isConnected, fw) {
timedOut = false
if (isConnected && fw.isEmpty()) {
delay(8_000)
if (bleManager.isConnected.value && bleManager.firmwareVersion.value.isEmpty()) {
timedOut = true
}
}
}
// deviceAlive 재계산용 매초 tick
var tick by remember { mutableStateOf(0) }
LaunchedEffect(isConnected) {
while (isConnected) {
delay(1000)
tick++
}
}
@Suppress("UNUSED_EXPRESSION") tick
// 최근 5초 내 BLE 데이터 수신 있으면 기기 alive — "기기 응답 없음" false positive 차단.
val deviceAlive = lastRxAt > 0 && (System.currentTimeMillis() - lastRxAt) < 5_000
val outdated = bleManager.isFirmwareOutdated
val showTimedOut = timedOut && !deviceAlive
if (!outdated && !showTimedOut) return
val bgColor = if (outdated) Color(0xFFFF9500) else Color(0xFFD32F2F)
val title = if (outdated) stringResource(R.string.firmware_outdated_title) else stringResource(R.string.firmware_no_response_title)
// timedOut(mid? 미수신) 케이스는 사용자에게 mid?/펌웨어 호환 같은 기술 용어가 무의미.
// outdated 일 때만 상세 안내, timedOut 은 타이틀만 노출.
val subtitle: String? = if (outdated) {
stringResource(R.string.firmware_outdated_desc, fw, BleManager.MIN_FIRMWARE_BUILD)
} else {
null
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, "Warning", tint = Color.White)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (subtitle != null) {
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
}
}
}
}
@@ -0,0 +1,79 @@
/*
* LockableSlider — 표준 Material3 Slider 를 잠금 아이콘으로 감싼 wrapper.
*
* 사용 의도:
* 설정 패널 등 ScrollView 안의 슬라이더가 위아래 스크롤 시 손가락 끌림에 의해
* 값이 의도치 않게 바뀌는 문제 차단. 사용자가 잠금 아이콘을 1회 탭하여 활성화한
* 뒤에만 슬라이더가 반응.
*
* 동작:
* - 기본 상태: 잠김 (회색 lock 아이콘 + slider disabled)
* - 잠금 아이콘 tap → 활성화 (녹색 lock_open 아이콘 + slider enabled)
* - 다시 tap → 재잠금
* - 잠금 상태에서는 slider drag 무시 → 위아래 스크롤 안전
*
* UX 톤:
* "1회 터치 후 조정" 패턴 — 의도된 조작만 허용.
*/
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.medithings.vesiscan.R
@Composable
fun LockableSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
) {
var unlocked by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
IconButton(
onClick = { unlocked = !unlocked },
modifier = Modifier.size(32.dp)
) {
Icon(
if (unlocked) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = if (unlocked) stringResource(R.string.slider_unlocked_desc) else stringResource(R.string.slider_locked_desc),
tint = if (unlocked) Color(0xFF4CAF50) else Color.Gray.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Slider(
value = value,
onValueChange = if (unlocked) onValueChange else { _ -> },
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
steps = steps,
colors = colors,
enabled = unlocked,
modifier = Modifier.weight(1f)
)
}
}
@@ -0,0 +1,173 @@
/*
* Scan-log diagnostic dialog. Format and interface mirror the xbench
* MbbLogDialog (CharlesKWONsLaw / wallcompare) so the same downstream
* clipboard / spreadsheet workflow applies: each entry is one Single Scan
* measurement, newest at the bottom, monospace, long-press to select spans
* across entries. Copy puts the full log on the system clipboard.
*/
package com.medithings.vesiscan.ui.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun ScanLogDialog(
log: List<String>,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val ctx = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val listState = rememberLazyListState()
LaunchedEffect(log.size) {
if (log.isNotEmpty()) listState.scrollToItem(log.size - 1)
}
Box(Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF182338), RoundedCornerShape(12.dp))
.border(1.dp, Color(0xFF2D394F), RoundedCornerShape(12.dp))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.0001f)) { }
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.fillMaxWidth(0.7f)) {
Text(
text = "scan diagnostic log",
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${log.size} entries (newest at bottom)",
color = Color.White.copy(0.5f),
fontSize = 11.sp,
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF0F1F3D), RoundedCornerShape(6.dp))
.border(1.dp, Color(0xFF1F2C44), RoundedCornerShape(6.dp))
.padding(8.dp),
) {
if (log.isEmpty()) {
Text(
text = "no scan attempts yet — press Single Scan",
color = Color.White.copy(0.4f),
fontSize = 12.sp,
modifier = Modifier.align(Alignment.Center),
)
} else {
SelectionContainer {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(log) { entry ->
val isFail = entry.contains(" ✗ ")
Text(
text = entry,
color = if (isFail) Color(0xFFA6192E)
else Color(0xFFD9D9D6),
fontSize = 10.sp,
lineHeight = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
val text = log.joinToString(separator = "\n────\n")
copyToClipboard(ctx, text)
Toast.makeText(
ctx,
"Copied ${log.size} entries to clipboard",
Toast.LENGTH_SHORT,
).show()
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFF00A7B5),
),
enabled = log.isNotEmpty(),
) { Text("Copy") }
OutlinedButton(
onClick = onClear,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White.copy(0.7f),
),
) { Text("Clear") }
Button(
onClick = onDismiss,
modifier = Modifier.weight(1.2f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073CF)),
) { Text("Close") }
}
}
}
}
}
private fun copyToClipboard(ctx: Context, text: String) {
val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
cm?.setPrimaryClip(ClipData.newPlainText("VesiScan scan log", text))
}
@@ -0,0 +1,20 @@
package com.medithings.vesiscan.ui.theme
import androidx.compose.ui.graphics.Color
// Material theme colors
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// MediLight theme colors
val MlPrimary = Color(0xFF008FFF)
val MlTeal = Color(0xFF00DB9F)
val MlBackground = Color(0xFFF5F8FF)
val MlCardBackground = Color.White
val MlSecondaryText = Color(0xFF80808C)
val MlInputBackground = Color(0xFFF5F8FF)
val MlInputBorder = Color(0xFF00DB9F)
@@ -0,0 +1,54 @@
package com.medithings.vesiscan.ui.theme
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
fun Modifier.pressScale(onClick: () -> Unit): Modifier = composed {
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, label = "pressScale")
val alpha by animateFloatAsState(if (pressed) 0.85f else 1f, label = "pressAlpha")
this
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.pointerInput(Unit) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
},
onTap = { onClick() }
)
}
}
object DesignTokens {
// Button heights (iOS: buttonPrimary=56, buttonSecondary=48)
val ButtonHeight = 56.dp
val ButtonHeightSecondary = 48.dp
// Touch targets (iOS: minTouchTarget=44)
val MinTouchTarget = 44.dp
// Corner radius (iOS: small=8, medium=12, large=16)
val CornerRadiusSmall = 8.dp
val CornerRadiusMedium = 12.dp
val CornerRadiusLarge = 16.dp
// Spacing (iOS: xs=4, sm=8, md=16, lg=24, xl=32)
val SpacingXs = 4.dp
val SpacingSm = 8.dp
val SpacingMd = 16.dp
val SpacingLg = 24.dp
val SpacingXl = 32.dp
// Icon sizes (iOS: small=14, medium=18, large=24)
val IconSmall = 14.dp
val IconMedium = 18.dp
val IconLarge = 24.dp
}
@@ -0,0 +1,35 @@
package com.medithings.vesiscan.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun Medilightv2androidTheme(
content: @Composable () -> Unit
) {
// Always use light color scheme — app is designed for light mode only.
// Dynamic color and dark theme disabled to prevent Samsung Force Dark Mode
// and Material You from overriding custom MediLight colors.
MaterialTheme(
colorScheme = LightColorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.medithings.vesiscan.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,216 @@
package com.medithings.vesiscan.ui.views.calibration
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun CalibrationView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val nirsManager = remember { NirsManager.instance }
val handler = remember { Handler(Looper.getMainLooper()) }
val initMsg = stringResource(R.string.calibration_initializing)
val completeMsg = stringResource(R.string.calibration_complete_status)
val noResponseMsg = stringResource(R.string.calibration_no_response)
val poweringMsg = stringResource(R.string.calibration_powering_on)
val activatingMsg = stringResource(R.string.calibration_activating)
val readingGainMsg = stringResource(R.string.calibration_reading_gain)
var statusMessage by remember { mutableStateOf(initMsg) }
var isComplete by remember { mutableStateOf(false) }
var isFailed by remember { mutableStateOf(false) }
var didReceiveResponse by remember { mutableStateOf(false) }
val isServiceReady by bleManager.isServiceReady
fun completeCalibration() {
if (isComplete) return
statusMessage = completeMsg
isComplete = true
}
fun failCalibration() {
if (isComplete || isFailed) return
statusMessage = noResponseMsg
isFailed = true
}
fun startCalibration() {
statusMessage = poweringMsg
bleManager.onNirsPowerOnReceived = {
didReceiveResponse = true
statusMessage = activatingMsg
handler.postDelayed({ bleManager.sendMqqQuery() }, 2000)
}
bleManager.onNirsSensorActivated = {
statusMessage = readingGainMsg
handler.postDelayed({ bleManager.sendMagQuery() }, 300)
}
bleManager.onNirsMagReceived = { data ->
NirsMagResponse.parse(data)?.let { response ->
nirsManager.setCalibrationGain(response)
}
completeCalibration()
}
bleManager.sendNirsPowerOn()
// Timeout
handler.postDelayed({
if (didReceiveResponse) completeCalibration() else failCalibration()
}, 6000)
}
fun retryCalibration() {
isFailed = false
isComplete = false
didReceiveResponse = false
statusMessage = initMsg
startCalibration()
}
LaunchedEffect(isServiceReady) {
if (isServiceReady) startCalibration()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
// Bladdy with checkmark
Box {
BladdyIdleRiveView(size = 160.dp)
if (isComplete) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MlTeal,
modifier = Modifier
.size(40.dp)
.align(Alignment.BottomEnd)
.offset(x = (-10).dp, y = (-10).dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = when {
isFailed -> stringResource(R.string.calibration_failed)
isComplete -> stringResource(R.string.calibration_complete)
else -> stringResource(R.string.calibration_in_progress)
},
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = if (isFailed) Color.Red else Color.Unspecified
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = statusMessage,
fontSize = 16.sp,
color = MlSecondaryText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.weight(1f))
if (isFailed) {
Column(
modifier = Modifier.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { retryCalibration() },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Refresh, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.retry), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
TextButton(
onClick = { appState.calibrationComplete() },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(stringResource(R.string.calibration_skip), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
}
}
} else if (isComplete) {
Button(
onClick = { appState.calibrationComplete() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
@@ -0,0 +1,643 @@
package com.medithings.vesiscan.ui.views.clinical
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.ClinicalSessionStore
import com.medithings.vesiscan.services.labdb.LabdbClient
import com.medithings.vesiscan.services.labdb.LabdbCredentials
import com.medithings.vesiscan.services.labdb.LabdbUploader
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.launch
/**
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
*
* 흐름:
* 1. BLE 연결 (미연결 시 Connect 버튼 → DeviceScan)
* 2. 측정 라벨 입력 (Subject/Device/Posture/Step/Examiner/Notes)
* 3. Start Measurement → PlacementGuide(alignment) or PiezoMonitoring(BV)
* 4. 측정 후 Back → 자동 endMeasurement → 폴더 finalize
* 5. 다음 측정 라벨 입력 또는 종료
*/
@Composable
fun ClinicalHomeView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val isConnected by bleManager.isConnected
val deviceName = bleManager.connectedDeviceName.value
// 진입 시 flag set, 화면 떠날 때 unset (DEVICE_SCAN/측정화면 라우팅용)
DisposableEffect(Unit) {
ClinicalSessionStore.inClinicalFlow = true
onDispose {
// 측정 진행을 위해 다른 화면(DEVICE_SCAN/PLACEMENT_GUIDE/PIEZO_MONITORING)으로 갈 땐 flag 유지.
// HOME으로 복귀 시에만 unset → MainActivity의 HOME 진입 시 처리.
}
}
// 라벨 입력 state — 마지막 입력값 기억 (Store에서 자동 채움)
// 마지막 선택값 유지 — 매 측정마다 0cm/SUPINE으로 리셋되는 불편 제거 (회의 결정)
var posture by remember { mutableStateOf(ClinicalSessionStore.lastPosture) }
var step by remember { mutableStateOf(ClinicalSessionStore.lastStep) }
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼)
var subjectType by remember { mutableStateOf(ClinicalSessionStore.lastSubjectType) }
// 카테고리 내 추가 식별자 (예: "500mL", "S001") — optional
var subject by remember { mutableStateOf(ClinicalSessionStore.lastSubject) }
var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
val scope = rememberCoroutineScope()
// labdb 상태 — 화면 진입 시 + 사용자 액션 후 refresh
var labdbApiKey by remember { mutableStateOf(LabdbCredentials.apiKey) }
var labdbStatus by remember { mutableStateOf(LabdbCredentials.lastStatus ?: "unknown") }
var labdbBusy by remember { mutableStateOf(false) }
var labdbMessage by remember { mutableStateOf<String?>(null) }
// 마지막 측정 업로드 상태 — Last saved 카드용
var lastUploaded by remember(lastFolder) {
mutableStateOf(lastFolder?.let { LabdbUploader.isUploaded(it) } ?: false)
}
var lastUploadError by remember(lastFolder) {
mutableStateOf(lastFolder?.let { LabdbUploader.lastError(it) })
}
var uploadBusy by remember { mutableStateOf(false) }
var showUnpairConfirm by remember { mutableStateOf(false) }
var showDeleteSavedConfirm by remember { mutableStateOf(false) }
var deleteBusy by remember { mutableStateOf(false) }
// 진입 시 status 자동 확인 (apiKey 있을 때만)
LaunchedEffect(Unit) {
if (LabdbCredentials.isRegistered) {
labdbBusy = true
runCatching { LabdbClient.checkStatus() }
.onSuccess {
labdbStatus = it.status
labdbApiKey = LabdbCredentials.apiKey
}
.onFailure {
labdbMessage = it.message
labdbApiKey = LabdbCredentials.apiKey
}
labdbBusy = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Top bar
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = {
ClinicalSessionStore.inClinicalFlow = false
appState.currentScreen = AppScreen.HOME
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
}
Spacer(modifier = Modifier.width(8.dp))
Text("Clinical Measurement", fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(16.dp))
// BLE 연결 상태
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (isConnected) Color(0xFF4CAF50).copy(alpha = 0.15f)
else Color(0xFFFF9800).copy(alpha = 0.15f)
)
) {
// 재연결/새 페어링 진행 중 — connectGatt 호출부터 STATE_CONNECTED까지 1~5초 걸리는 동안
// "BLE Not Connected"가 보이는 깜박임 방지.
val isConnecting by bleManager.isConnecting
val isReconnecting by bleManager.isReconnecting
val showConnecting = !isConnected && (isConnecting || isReconnecting)
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (isConnected) {
Icon(Icons.Default.CheckCircle, "Connected",
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text("BLE Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text(bleManager.connectedDeviceName.value,
fontSize = 11.sp, color = MlSecondaryText)
// 펌웨어 버전 (mid? → rid: 응답 파싱, 연결 직후 자동 채워짐)
val fwVersion by bleManager.firmwareVersion
if (fwVersion.isNotEmpty()) {
Text("FW: $fwVersion",
fontSize = 11.sp, color = MlSecondaryText,
fontWeight = FontWeight.SemiBold)
}
}
Column(horizontalAlignment = Alignment.End) {
OutlinedButton(
onClick = {
bleManager.disconnect()
appState.isDeviceConnected = false
},
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF555555)),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
modifier = Modifier.height(32.dp)
) { Text("Disconnect", fontSize = 11.sp) }
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { showUnpairConfirm = true },
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935)),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
modifier = Modifier.height(32.dp)
) { Text("Unpair", fontSize = 11.sp) }
}
} else if (showConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MlPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("BLE Connecting…", fontWeight = FontWeight.Bold, fontSize = 14.sp,
color = MlPrimary, modifier = Modifier.weight(1f))
} else {
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
Button(
onClick = { appState.currentScreen = AppScreen.DEVICE_SCAN },
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { Text("Connect", color = Color.White) }
}
}
}
// labdb 서버 상태/등록 카드
Spacer(modifier = Modifier.height(12.dp))
val labdbBg = when {
!LabdbCredentials.isRegistered -> Color(0xFF9E9E9E).copy(alpha = 0.15f)
labdbStatus == "active" -> Color(0xFF2196F3).copy(alpha = 0.12f)
labdbStatus == "pending" -> Color(0xFFFF9800).copy(alpha = 0.15f)
else -> Color(0xFFE53935).copy(alpha = 0.12f)
}
Card(shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = labdbBg)) {
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("labdb.medithings.net", fontSize = 11.sp, color = MlSecondaryText)
Text(
when {
!LabdbCredentials.isRegistered -> "Not registered"
labdbBusy -> "Checking…"
else -> "Status: $labdbStatus"
},
fontSize = 14.sp, fontWeight = FontWeight.Bold
)
LabdbCredentials.deviceId?.let {
Text("deviceId: $it", fontSize = 10.sp, color = MlSecondaryText)
}
}
if (!LabdbCredentials.isRegistered) {
Button(
onClick = {
if (labdbBusy) return@Button
labdbBusy = true
labdbMessage = null
scope.launch {
runCatching {
LabdbClient.register(
context,
appVersion = try {
context.packageManager
.getPackageInfo(context.packageName, 0)
.versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
)
}.onSuccess {
labdbApiKey = it.apiKey
labdbStatus = it.status
labdbMessage = it.message
Toast.makeText(context,
"Registered. Awaiting approval.",
Toast.LENGTH_LONG).show()
}.onFailure {
labdbMessage = it.message
}
labdbBusy = false
}
},
enabled = !labdbBusy,
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { Text("Register", color = Color.White, fontSize = 12.sp) }
} else {
OutlinedButton(
onClick = {
if (labdbBusy) return@OutlinedButton
labdbBusy = true
labdbMessage = null
scope.launch {
runCatching { LabdbClient.checkStatus() }
.onSuccess {
labdbStatus = it.status
labdbApiKey = LabdbCredentials.apiKey
}
.onFailure {
labdbMessage = it.message
labdbApiKey = LabdbCredentials.apiKey
}
labdbBusy = false
}
},
enabled = !labdbBusy
) { Text("Refresh", fontSize = 12.sp) }
}
}
labdbMessage?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(it, fontSize = 11.sp, color = Color(0xFFE53935))
}
if (LabdbCredentials.isRegistered && labdbStatus == "pending") {
Spacer(modifier = Modifier.height(4.dp))
Text("admin@medithings.net 승인 후 자동 업로드됩니다.",
fontSize = 11.sp, color = MlSecondaryText)
}
}
}
// 직전 측정 결과 + 업로드 상태/재시도
if (lastFolder != null) {
Spacer(modifier = Modifier.height(12.dp))
val savedBg = when {
lastUploaded -> Color(0xFF4CAF50).copy(alpha = 0.10f)
lastUploadError != null -> Color(0xFFE53935).copy(alpha = 0.10f)
else -> Color(0xFF4CAF50).copy(alpha = 0.06f)
}
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = savedBg)
) {
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.CheckCircle, null,
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(
when {
lastUploaded -> "✓ labdb 업로드 완료"
lastUploadError != null -> "✗ 업로드 실패: $lastUploadError"
uploadBusy -> "업로드 중…"
else -> "로컬 저장만 (서버 미업로드)"
},
fontSize = 11.sp,
color = when {
lastUploaded -> Color(0xFF4CAF50)
lastUploadError != null -> Color(0xFFE53935)
else -> MlSecondaryText
}
)
}
Column(horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!lastUploaded && LabdbCredentials.isRegistered) {
Button(
onClick = {
if (uploadBusy) return@Button
uploadBusy = true
scope.launch {
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
.onSuccess {
lastUploaded = true
lastUploadError = null
Toast.makeText(context,
"Uploaded: ${it.inserted}/${it.totalRecords}",
Toast.LENGTH_SHORT).show()
}
.onFailure {
lastUploadError = it.message ?: it.javaClass.simpleName
// status도 갱신 (401/403/404 케이스 반영)
labdbApiKey = LabdbCredentials.apiKey
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
}
uploadBusy = false
}
},
enabled = !uploadBusy,
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
}
// Delete — 잘못 측정 시 폐기 (로컬 + 서버)
OutlinedButton(
onClick = { showDeleteSavedConfirm = true },
enabled = !deleteBusy && !uploadBusy,
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
) { Text("Delete", fontSize = 12.sp) }
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// 라벨 입력 폼
Text("Measurement Label", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(8.dp))
LabeledSegmented("Posture", ClinicalPosture.values().toList(), posture, { posture = it }) { it.label }
Spacer(modifier = Modifier.height(12.dp))
LabeledSegmented("Step", ClinicalStep.values().toList(), step, { step = it }) { it.label }
Spacer(modifier = Modifier.height(4.dp))
// 선택된 Step의 부가 설명 (치골 위로 N cm 등)
Text(
"→ ${step.description}",
fontSize = 12.sp,
color = MlPrimary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp)
)
Spacer(modifier = Modifier.height(12.dp))
// 측정 대상 분류 — Phantom/Human/ETC 버튼 (회의 결정)
LabeledSegmented(
label = "Subject Type",
options = com.medithings.vesiscan.models.SubjectType.values().toList(),
selected = subjectType,
onSelect = { subjectType = it }
) { it.label }
Spacer(modifier = Modifier.height(8.dp))
// 카테고리 내 추가 식별자 — 예: "500mL", "S001"
val subjectFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
OutlinedTextField(
value = subject,
onValueChange = { subject = it },
label = { Text("Subject (선택) — 예: 500mL, S001") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onDone = { subjectFocus?.hide() }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = trueVolText,
onValueChange = { trueVolText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text("True Volume (mL)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = abdomenText,
onValueChange = { abdomenText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text("Abdomen (mm)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = examiner,
onValueChange = { examiner = it },
label = { Text("Examiner") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Notes — Examiner처럼 키보드 우하단 ✓(Done)으로 완료 가능
val notesFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (선택)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onDone = { notesFocus?.hide() }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
// 라벨 preview — BLE 기기 이름이 device 식별자 역할
val previewLabel = if (isConnected)
"${deviceName}_${posture.name}_${step.name}"
else "(BLE 연결 후 활성화)"
Text("Label: $previewLabel",
fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// 두 가지 진입점 — Live 6ch (기존) + Sensor Alignment (신규)
val labdbOk = LabdbCredentials.isRegistered && labdbStatus == "active"
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Start Live Measurement (기존)
Button(
onClick = {
ClinicalSessionStore.startMeasurement(
context,
ClinicalSession(
deviceName = deviceName, posture = posture, step = step,
examiner = examiner.trim(), notes = notes.trim(),
subjectType = subjectType, subject = subject.trim(),
trueVolumeMl = trueVolText.toDoubleOrNull(),
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
mode = com.medithings.vesiscan.models.SessionMode.MEASUREMENT,
)
)
appState.currentScreen = AppScreen.CLINICAL_LIVE
},
enabled = isConnected,
modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) {
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
Spacer(modifier = Modifier.width(6.dp))
Text("Live 6ch", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
// Sensor Alignment — 신규. BLE + labdb 둘 다 OK 일 때만 활성.
Button(
onClick = {
ClinicalSessionStore.startMeasurement(
context,
ClinicalSession(
deviceName = deviceName, posture = posture, step = step,
examiner = examiner.trim(), notes = notes.trim(),
subjectType = subjectType, subject = subject.trim(),
trueVolumeMl = trueVolText.toDoubleOrNull(),
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
mode = com.medithings.vesiscan.models.SessionMode.ALIGNMENT,
// 현재 dev 토글된 algo 그대로 — V1 (default) 또는 V2.
alignmentAlgo = com.medithings.vesiscan.managers.GreenZoneConstants.alignmentAlgo.name,
)
)
appState.enterAlignmentSession()
},
enabled = isConnected && labdbOk,
modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
) {
Icon(Icons.Default.MyLocation, null, tint = Color.White)
Spacer(modifier = Modifier.width(6.dp))
Text("Sensor Alignment", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
if (!isConnected) {
Spacer(modifier = Modifier.height(4.dp))
Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800))
} else if (!labdbOk) {
Spacer(modifier = Modifier.height(4.dp))
Text("→ Sensor Alignment 는 labdb 활성 필요", fontSize = 11.sp, color = Color(0xFFFF9800))
}
Spacer(modifier = Modifier.height(24.dp))
Text("저장 위치: Downloads/VesiScan_Sessions/",
fontSize = 10.sp, color = MlSecondaryText)
}
// 잘못 측정한 폴더 삭제 confirm — 로컬 + labdb 서버 둘 다 (회의 결정: Yes 서버도)
if (showDeleteSavedConfirm && lastFolder != null) {
val targetFolder = lastFolder
AlertDialog(
onDismissRequest = { showDeleteSavedConfirm = false },
title = { Text("Delete measurement?") },
text = { Text("폴더 + labdb 서버 데이터를 모두 삭제합니다. 복구 불가.\n\n${targetFolder.name}") },
confirmButton = {
TextButton(onClick = {
if (deleteBusy) return@TextButton
showDeleteSavedConfirm = false
deleteBusy = true
scope.launch {
// 1) labdb 서버 삭제 시도 (실패해도 로컬은 진행)
val serverResult = runCatching {
LabdbUploader.deleteSessionForFolder(targetFolder)
}
// 2) 로컬 폴더 삭제
ClinicalSessionStore.deleteFinalizedFolder()
lastUploaded = false
lastUploadError = null
val msg = if (serverResult.isSuccess) "Deleted (local + server)"
else "Local deleted, server failed: ${serverResult.exceptionOrNull()?.message}"
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
deleteBusy = false
}
}) { Text("Delete", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showDeleteSavedConfirm = false }) { Text("Cancel") }
}
)
}
if (showUnpairConfirm) {
AlertDialog(
onDismissRequest = { showUnpairConfirm = false },
title = { Text("Delete pairing?") },
text = { Text("기기 본딩을 삭제하고 연결을 끊습니다. 다음 측정 시 다시 페어링이 필요합니다.") },
confirmButton = {
TextButton(onClick = {
showUnpairConfirm = false
bleManager.disconnectAndUnbond()
// BleManager.isConnected.value는 disconnectAndUnbond 내부에서 false로 reset되지만
// appState.isDeviceConnected는 별도 — 명시적으로 동기화하지 않으면 stale true 상태가
// 유지되어 HOME→Start 시 DeviceScan 건너뛰고 Personalization으로 직진 → 도넛차트
// 진입 시 BLE 미연결 상태로 도착하는 버그 발생.
appState.isDeviceConnected = false
Toast.makeText(context, "Unpaired", Toast.LENGTH_SHORT).show()
}) { Text("Delete", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showUnpairConfirm = false }) { Text("Cancel") }
}
)
}
}
@Composable
private fun <T> LabeledSegmented(
label: String,
options: List<T>,
selected: T,
onSelect: (T) -> Unit,
labelOf: (T) -> String
) {
Column {
Text(label, fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
for (opt in options) {
val isSel = opt == selected
Box(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSel) MlPrimary else Color.Gray.copy(alpha = 0.12f))
.clickable { onSelect(opt) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
labelOf(opt),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = if (isSel) Color.White else Color.Black
)
}
}
}
}
}
@@ -0,0 +1,422 @@
package com.medithings.vesiscan.ui.views.clinical
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ble.ImuSample
import com.medithings.vesiscan.ble.PiezoChannelData
import com.medithings.vesiscan.managers.ImuAnalysis
import com.medithings.vesiscan.services.AdcCsvLogger
import com.medithings.vesiscan.services.ClinicalSessionStore
import com.medithings.vesiscan.services.ImuCsvLogger
import com.medithings.vesiscan.services.MeasurementLogService
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.delay
import kotlin.math.max
import kotlin.math.min
/**
* Clinical Live 6채널 시각화 화면.
*
* vesiscan_test 의 run_stream 패턴을 안드로이드 앱 내에서 직접 구현:
* - 600ms 간격으로 mtb? 송신 → 6채널 + IMU 자동 수집
* - 2×3 grid 로 채널별 실시간 raw ADC 그래프
* - 상단: 세션 정보 (Subject ID / Posture / Step / True Vol / Abdomen)
* - 측정 상태 (cycle 번호, 6/6 채널 도착률, IMU 자세/흔들림)
* - 하단 컨트롤: Pause/Resume / Discard / End
* - 측정 중 매 사이클 자동 저장 (autoCapture=true 고정, 회의 결정으로 토글 제거)
* - End: 세션 finalize → ClinicalHome 복귀
*/
@Composable
fun ClinicalLiveView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val session = ClinicalSessionStore.currentSession
// 세션 없으면 즉시 복귀 (잘못된 진입)
if (session == null) {
LaunchedEffect(Unit) { appState.currentScreen = AppScreen.CLINICAL_HOME }
return
}
// 측정 state
var isMeasuring by remember { mutableStateOf(true) } // 시작 시 자동 측정
// autoCapture는 항상 true — 토글 UI 제거됨 (회의 결정)
val autoCapture = true
var cycleCount by remember { mutableIntStateOf(0) }
var capturedCount by remember { mutableIntStateOf(0) }
var lastChannels by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) }
var lastCycleMs by remember { mutableLongStateOf(0L) }
// BLE 콜백 — mtb 응답은 reb×6 + raa(piezo 완료) → rim(IMU 완료) 순서.
// 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push.
var pendingPiezo by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
// V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청)
val v41 = remember { com.medithings.vesiscan.walldetect.V41Detector() }
var channelWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
DisposableEffect(Unit) {
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
lastChannels = channels
pendingPiezo = channels
// V4.1로 채널별 ant/post 즉시 계산 (실패해도 측정 자체는 영향 없음)
runCatching {
val adcMatrix = (0 until 6).map { ch ->
channels.find { it.channel == ch }?.buffer?.map { it.toInt() } ?: List(100) { 0 }
}
val probe = com.medithings.vesiscan.walldetect.dto.ProbeProfileDto(
name = com.medithings.vesiscan.managers.PiezoHW.presetName,
rMm = 50.2, anteriorAnchorMm = 32.0,
fsHz = 537500, cMmPerUs = 1.54,
anglesDeg = com.medithings.vesiscan.managers.PiezoHW.degreeAll.toList()
)
val sweepInput = com.medithings.vesiscan.walldetect.dto.SweepInput(
requestId = "clinical_${System.currentTimeMillis()}",
timestampMs = System.currentTimeMillis(),
deviceId = bleManager.connectedDeviceName.value,
sweepSeq = 0, probe = probe, adc = adcMatrix
)
val result = v41.detect(sweepInput)
channelWalls = List(6) { ch ->
val r = result.perChannel.firstOrNull { it.ch == ch }
val a = r?.antIdx; val p = r?.postIdx
if (a != null && p != null) Pair(a, p) else null
}
}
}
bleManager.imuCollector.onComplete = { imuSamples ->
lastImu = imuSamples
cycleCount++
lastCycleMs = System.currentTimeMillis()
// 자동 저장: piezo + imu 묶어서 한 사이클로 누적 + 기존 CSV 저장 (재현용)
val piezo = pendingPiezo
if (autoCapture && piezo != null && ClinicalSessionStore.currentSession != null) {
ClinicalSessionStore.addCycle(piezo, imuSamples)
val rawADC = piezo.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null)
ImuCsvLogger.log(imuSamples)
capturedCount++
}
pendingPiezo = null
}
onDispose {
bleManager.piezoCollector.onMultiChannelComplete = null
bleManager.imuCollector.onComplete = null
}
}
// 측정 루프 — 600ms 간격
LaunchedEffect(isMeasuring) {
if (!isMeasuring) return@LaunchedEffect
while (isMeasuring) {
bleManager.sendMtb()
delay(600)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
.verticalScroll(rememberScrollState())
) {
// ── Top bar ──
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
}
Column(modifier = Modifier.weight(1f)) {
Text("Live · ${session.label}",
fontSize = 14.sp, fontWeight = FontWeight.Bold)
val subj = "${session.subjectType.label} · "
val tv = session.trueVolumeMl?.let { "TV=${it.toInt()}mL · " } ?: ""
val ab = session.abdomenThicknessMm?.let { "Ab=${"%.0f".format(it)}mm" } ?: ""
Text("$subj$tv$ab",
fontSize = 11.sp, color = MlSecondaryText)
}
// Cycle counter
Text("#$cycleCount ✓$capturedCount",
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
// ── 6ch grid (2 rows × 3 cols) ──
SixChannelGrid(
channels = lastChannels,
walls = channelWalls,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// ── IMU 정보 ──
if (lastImu.isNotEmpty()) {
val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) }
val tilt = remember(lastImu) { ImuAnalysis.tiltDegrees(lastImu) }
val shake = remember(lastImu) { ImuAnalysis.shakeRmsDps(lastImu) }
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (shake > 30f) Color(0xFFFFEBEE) else Color(0xFFE8F5E9)
)
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("IMU", fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.width(8.dp))
Text("$posture · tilt=${"%.0f".format(tilt)}° · shake=${"%.1f".format(shake)}dps",
fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (shake > 30f) Color.Red else Color(0xFF2E7D32))
Spacer(modifier = Modifier.weight(1f))
Text("${lastImu.size} samples", fontSize = 10.sp, color = MlSecondaryText)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// ── 컨트롤 영역 ──
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Pause/Resume
Button(
onClick = { isMeasuring = !isMeasuring },
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isMeasuring) Color(0xFFFF9800) else Color(0xFF4CAF50)
)
) {
Icon(if (isMeasuring) Icons.Default.Stop else Icons.Default.PlayArrow,
null, tint = Color.White)
Spacer(modifier = Modifier.width(4.dp))
Text(if (isMeasuring) "Pause" else "Resume", color = Color.White,
fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.height(8.dp))
// End Session / Discard 두 버튼 나란히
var showDiscardConfirm by remember { mutableStateOf(false) }
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Discard — 측정값 폐기 + 폴더 삭제 (저장 안 함)
OutlinedButton(
onClick = { showDiscardConfirm = true },
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
) {
Text("Discard", fontWeight = FontWeight.Bold)
}
// End Session — 저장 + 자동 업로드
Button(
onClick = {
isMeasuring = false
val folder = ClinicalSessionStore.endMeasurement(context)
folder?.let {
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
Toast.LENGTH_LONG).show()
}
appState.currentScreen = AppScreen.CLINICAL_HOME
},
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
) {
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
}
}
if (showDiscardConfirm) {
AlertDialog(
onDismissRequest = { showDiscardConfirm = false },
title = { Text("Discard measurement?") },
text = { Text("현재 사이클 ${capturedCount}개 + 폴더 전체 삭제. labdb 업로드 안 함. 복구 불가.") },
confirmButton = {
TextButton(onClick = {
showDiscardConfirm = false
isMeasuring = false
val deleted = ClinicalSessionStore.discardAndDeleteFolder()
deleted?.let {
Toast.makeText(context, "Discarded: ${it.name}",
Toast.LENGTH_SHORT).show()
}
appState.currentScreen = AppScreen.CLINICAL_HOME
}) { Text("Discard", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showDiscardConfirm = false }) { Text("Cancel") }
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Text("Cycle: ~600ms · Captured: $capturedCount · ${ClinicalSessionStore.currentLogDir()?.name ?: ""}",
modifier = Modifier.padding(horizontal = 12.dp),
fontSize = 10.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(16.dp))
}
}
/** 2×3 grid 6채널 라이브 차트 */
@Composable
private fun SixChannelGrid(
channels: List<PiezoChannelData>,
walls: List<Pair<Int, Int>?>,
modifier: Modifier = Modifier
) {
val channelColors = listOf(
Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19),
Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60),
)
// 회의 #1: 2열 3행 grid로 복귀 + 셀 height 더 키움 → y축 길이 확보로 기울기 보임.
// 화면 전체의 2/3 정도 차지 (3행 × 320dp ≈ 960dp).
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
for (row in 0 until 3) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
for (col in 0 until 2) {
val ch = row * 2 + col
val data = channels.find { it.channel == ch }
Box(modifier = Modifier.weight(1f)) {
ChannelChart(
channelIdx = ch,
buffer = data?.buffer ?: emptyList(),
color = channelColors[ch],
walls = walls.getOrNull(ch),
)
}
}
}
}
}
}
/** 한 채널 그래프 (raw ADC waveform + V4.1 ant/post 마커) */
@Composable
private fun ChannelChart(
channelIdx: Int,
buffer: List<UShort>,
color: Color,
walls: Pair<Int, Int>?,
) {
val antColor = Color(0xFF4CAF50) // green = anterior wall
val postColor = Color(0xFFFF9800) // orange = posterior wall
// 회의 #1: 2x3 grid에서 y축 더 늘림 → 기울기 잘 보임. 한 셀 height 240→320dp.
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
modifier = Modifier.fillMaxWidth().height(320.dp)
) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.clip(RoundedCornerShape(3.dp))
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text("CH$channelIdx", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (walls != null) {
Text("a${walls.first}", fontSize = 14.sp, color = antColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(6.dp))
Text("p${walls.second}", fontSize = 14.sp, color = postColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
}
if (buffer.isNotEmpty()) {
val peak = buffer.maxOrNull()?.toInt() ?: 0
Text("$peak", fontSize = 14.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.height(4.dp))
Canvas(modifier = Modifier.fillMaxSize()) {
if (buffer.isEmpty()) return@Canvas
val w = size.width
val h = size.height
val n = buffer.size
if (n < 2) return@Canvas
// y 범위: 0~4095 (12-bit ADC), 일부 여백
val yMin = 0f
val yMax = 4095f
val yScale = h / (yMax - yMin)
val xScale = w / (n - 1).toFloat()
// 가로 grid (4분할)
for (i in 1..3) {
val y = h * i / 4f
drawLine(
Color.LightGray.copy(alpha = 0.3f),
Offset(0f, y), Offset(w, y),
strokeWidth = 0.5f
)
}
// V4.1 ant/post 마커 (waveform 뒤에 그려서 가려지지 않게 alpha 조정)
if (walls != null) {
val (antIdx, postIdx) = walls
if (antIdx in 0 until n) {
val x = antIdx * xScale
drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
}
if (postIdx in 0 until n) {
val x = postIdx * xScale
drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
}
}
// waveform — 굵게 (1.5 → 2.5)
val path = Path()
for (i in buffer.indices) {
val x = i * xScale
val y = h - (buffer[i].toInt() - yMin) * yScale
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color, style = Stroke(width = 2.5f))
}
}
}
}
@@ -0,0 +1,562 @@
package com.medithings.vesiscan.ui.views.connection
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothConnected
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleDevice
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.SensorMode
import com.medithings.vesiscan.services.KnownDevice
import com.medithings.vesiscan.services.KnownDeviceStore
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun DeviceScanView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
var permissionsGranted by remember { mutableStateOf(false) }
// Clinical 흐름에서만 다중 기기 저장/표시 — 일반 사용자는 fresh scan & pair.
val isClinicalFlow = com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow
var knownDevices by remember {
mutableStateOf(if (isClinicalFlow) KnownDeviceStore.getAll(context) else emptyList())
}
var pendingDelete by remember { mutableStateOf<KnownDevice?>(null) }
val knownAddresses = knownDevices.map { it.address }.toSet()
val newDevices = bleManager.discoveredDevices
.filter { it.name.startsWith("2025") || it.name.startsWith("VBT") }
.filter { it.address !in knownAddresses }
.sortedBy { it.name }
val isConnected by bleManager.isConnected
val isServiceReady by bleManager.isServiceReady
val isScanning by bleManager.isScanning
val bluetoothEnabled by bleManager.bluetoothEnabled
val connectionError by bleManager.connectionError
// BLE permissions
val requiredPermissions = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
}
fun checkPermissions(): Boolean {
return requiredPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
permissionsGranted = results.values.all { it }
if (permissionsGranted) {
bleManager.startScan()
}
}
LaunchedEffect(Unit) {
if (checkPermissions()) {
permissionsGranted = true
bleManager.startScan()
} else {
permissionLauncher.launch(requiredPermissions)
}
}
// 서비스 준비 + 페어링(bonding) 완료 확인 후 다음 화면
var showPairingHint by remember { mutableStateOf(false) }
LaunchedEffect(isServiceReady) {
if (isServiceReady) {
connectingDeviceAddress = null
if (bleManager.isBonded) {
// 이미 페어링됨 → 바로 진행
appState.deviceConnected()
} else {
// 페어링 필요 → 안내 표시 + 대기
showPairingHint = true
var waited = 0
while (!bleManager.isBonded && waited < 30000) {
kotlinx.coroutines.delay(500)
waited += 500
}
showPairingHint = false
if (bleManager.isBonded) {
appState.deviceConnected()
} else {
bleManager.disconnect()
bleManager.connectionError.value = context.getString(R.string.device_scan_pairing_required_err)
}
}
}
}
LaunchedEffect(connectionError) {
if (connectionError != null) connectingDeviceAddress = null
}
DisposableEffect(Unit) { onDispose { bleManager.stopScan() } }
// 페어링 안내 다이얼로그
if (showPairingHint) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.device_scan_pairing_required_title), fontWeight = FontWeight.Bold) },
text = { Text(stringResource(R.string.device_scan_pairing_required_msg)) },
confirmButton = {}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
// Header
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { appState.backToSensorSelect() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.device_scan_back_desc), tint = MlPrimary)
}
Spacer(modifier = Modifier.weight(1f))
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.device_scan_title), fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(
if (knownDevices.isEmpty()) stringResource(R.string.device_scan_searching)
else stringResource(R.string.device_scan_tap_or_scan),
fontSize = 14.sp, color = MlSecondaryText
)
}
Spacer(modifier = Modifier.height(16.dp))
// Bluetooth off warning
if (!bluetoothEnabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFFFF9500))
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, stringResource(R.string.device_scan_warning_desc), tint = Color.White)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(stringResource(R.string.device_scan_bluetooth_off), fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Text("Enable Bluetooth in Settings", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Permission denied warning
if (!permissionsGranted) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFFFF9500))
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, stringResource(R.string.device_scan_warning_desc), tint = Color.White)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(stringResource(R.string.device_scan_permissions_required), fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Text("Bluetooth and Location permissions are needed to scan for devices", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Connection error
connectionError?.let { error ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Red)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_error_desc), tint = Color.White)
Spacer(modifier = Modifier.width(12.dp))
Text(error, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))
IconButton(onClick = { bleManager.connectionError.value = null }) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_dismiss_desc), tint = Color.White.copy(alpha = 0.8f))
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// 디바이스 목록 — Known + New 두 섹션
val emptyAll = knownDevices.isEmpty() && newDevices.isEmpty()
if (emptyAll && isScanning) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
BladdyIdleRiveView(size = 180.dp)
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.device_scan_scanning), color = MlSecondaryText)
}
} else if (emptyAll) {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.device_scan_no_devices), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
TroubleshootItem("1.", stringResource(R.string.device_scan_troubleshoot_1))
TroubleshootItem("2.", stringResource(R.string.device_scan_troubleshoot_2))
TroubleshootItem("3.", stringResource(R.string.device_scan_troubleshoot_3))
TroubleshootItem("4.", stringResource(R.string.device_scan_troubleshoot_4))
}
}
if (appState.isDevMode) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
appState.isDeviceConnected = true
appState.currentScreen = com.medithings.vesiscan.AppScreen.PIEZO_PERSONALIZATION
},
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.15f))
) {
Text(stringResource(R.string.device_scan_enter_measurement_dev), color = MlPrimary, fontWeight = FontWeight.SemiBold)
}
}
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (knownDevices.isNotEmpty()) {
item("known_header") {
SectionHeader(stringResource(R.string.device_scan_saved_devices), knownDevices.size)
}
items(knownDevices, key = { "k_${it.address}" }) { known ->
KnownDeviceRow(
device = known,
isConnecting = connectingDeviceAddress == known.address,
enabled = connectingDeviceAddress == null,
onConnect = {
connectingDeviceAddress = known.address
bleManager.connectByAddress(known.address, known.name)
},
onDelete = { pendingDelete = known }
)
}
}
if (newDevices.isNotEmpty()) {
item("new_header") {
Spacer(modifier = Modifier.height(8.dp))
SectionHeader(stringResource(R.string.device_scan_new_devices), newDevices.size)
}
items(newDevices, key = { "n_${it.address}" }) { device ->
DeviceRow(
device = device,
isConnecting = connectingDeviceAddress == device.address,
enabled = connectingDeviceAddress == null,
onClick = {
connectingDeviceAddress = device.address
bleManager.connect(device)
}
)
}
}
}
}
// Delete Known Device 확인 다이얼로그
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.unpair_title)) },
text = { Text(stringResource(R.string.unpair_message, target.name)) },
confirmButton = {
TextButton(onClick = {
bleManager.unbondAndRemoveAddress(target.address)
knownDevices = KnownDeviceStore.getAll(context)
pendingDelete = null
}) { Text(stringResource(R.string.unpair_confirm), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
}
)
}
// Scan button
Button(
onClick = {
if (!permissionsGranted) {
permissionLauncher.launch(requiredPermissions)
} else if (isScanning) {
bleManager.stopScan()
} else {
bleManager.startScan()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
.height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
shape = RoundedCornerShape(14.dp)
),
contentAlignment = Alignment.Center
) {
Text(
when {
!permissionsGranted -> stringResource(R.string.device_scan_grant_permissions)
isScanning -> stringResource(R.string.device_scan_stop_scan)
else -> stringResource(R.string.device_scan_start_scan)
},
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
}
@Composable
private fun SectionHeader(label: String, count: Int) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.width(6.dp))
Text("$count", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.6f))
}
}
@Composable
private fun KnownDeviceRow(
device: KnownDevice,
isConnecting: Boolean,
enabled: Boolean,
onConnect: () -> Unit,
onDelete: () -> Unit
) {
val lastConnectedLabel = remember(device.lastConnectedAt) {
if (device.lastConnectedAt <= 0) "—"
else SimpleDateFormat("M/d HH:mm", Locale.getDefault()).format(Date(device.lastConnectedAt))
}
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
.clickable(enabled = enabled, onClick = onConnect),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.06f))
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MlPrimary.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.BluetoothConnected, null, tint = MlPrimary, modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(device.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.device_scan_last_label, lastConnectedLabel), fontSize = 11.sp, color = MlSecondaryText)
}
if (isConnecting) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} else {
IconButton(
onClick = onDelete,
enabled = enabled,
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Delete, stringResource(R.string.device_scan_delete_pairing_desc),
tint = Color.Red.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp))
}
}
}
}
}
@Composable
private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
.clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(MlPrimary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Text("BLE", color = MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(device.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically) {
SignalBars(rssi = device.rssi)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(signalLabelRes(device.rssi)), fontSize = 12.sp, color = MlSecondaryText)
}
}
if (isConnecting) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MlSecondaryText)
}
}
}
}
@Composable
private fun SignalBars(rssi: Int) {
val strength = when {
rssi >= -80 -> 4 // Good
rssi >= -90 -> 2 // Fair
else -> 1 // Weak
}
val color = when {
strength >= 4 -> Color.Green
strength == 2 -> Color(0xFFFF9500)
else -> Color.Red
}
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
repeat(4) { i ->
Box(
modifier = Modifier
.width(4.dp)
.height((6 + i * 3).dp)
.clip(RoundedCornerShape(1.dp))
.background(if (i < strength) color else Color.Gray.copy(alpha = 0.3f))
)
}
}
}
private fun signalLabel(rssi: Int): String = when {
rssi >= -80 -> "Good"
rssi >= -90 -> "Fair"
else -> "Weak"
}
/** UI 용 — Locale 적용된 라벨. signalLabel(rssi) 는 dev/log 용으로 유지. */
private fun signalLabelRes(rssi: Int): Int = when {
rssi >= -80 -> R.string.device_scan_signal_good
rssi >= -90 -> R.string.device_scan_signal_fair
else -> R.string.device_scan_signal_weak
}
@Composable
private fun TroubleshootItem(number: String, text: String) {
Row(modifier = Modifier.padding(vertical = 4.dp)) {
Text(number, fontSize = 13.sp, color = MlPrimary, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.width(8.dp))
Text(text, fontSize = 13.sp, color = MlSecondaryText)
}
}
@@ -0,0 +1,194 @@
package com.medithings.vesiscan.ui.views.consent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.medithings.vesiscan.R
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.services.UserStorage
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun ConsentView(appState: AppState) {
val context = LocalContext.current
val storage = remember { UserStorage.getInstance(context) }
var consentPersonal by remember { mutableStateOf(false) }
var consentMedical by remember { mutableStateOf(false) }
var consentData by remember { mutableStateOf(false) }
val allAgreed = consentPersonal && consentMedical && consentData
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
Spacer(modifier = Modifier.height(48.dp))
BladdyIdleRiveView(
size = 100.dp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.consent_title),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 32.dp)
)
Text(
stringResource(R.string.consent_intro),
fontSize = 14.sp,
color = MlSecondaryText,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 전체 동의
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newVal = !allAgreed
consentPersonal = newVal
consentMedical = newVal
consentData = newVal
}
.padding(vertical = 8.dp)
) {
Checkbox(
checked = allAgreed,
onCheckedChange = {
consentPersonal = it
consentMedical = it
consentData = it
},
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.consent_agree_all), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.2f))
ConsentItem(
title = stringResource(R.string.consent_personal_title),
description = stringResource(R.string.consent_personal_desc),
checked = consentPersonal,
onCheckedChange = { consentPersonal = it }
)
ConsentItem(
title = stringResource(R.string.consent_medical_title),
description = stringResource(R.string.consent_medical_desc),
checked = consentMedical,
onCheckedChange = { consentMedical = it }
)
ConsentItem(
title = stringResource(R.string.consent_data_title),
description = stringResource(R.string.consent_data_desc),
checked = consentData,
onCheckedChange = { consentData = it }
)
Spacer(modifier = Modifier.height(16.dp))
}
// 동의 버튼
Button(
onClick = {
storage.consentPersonalInfo = consentPersonal
storage.consentMedicalInfo = consentMedical
storage.consentDataCollection = consentData
appState.completeConsent()
},
enabled = allAgreed,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = if (allAgreed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary))
else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))),
shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.consent_continue),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
}
@Composable
private fun ConsentItem(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MlCardBackground, RoundedCornerShape(12.dp))
.clickable { onCheckedChange(!checked) }
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
)
Spacer(modifier = Modifier.width(8.dp))
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
Text(
description,
fontSize = 12.sp,
color = MlSecondaryText,
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
)
}
}
@@ -0,0 +1,456 @@
package com.medithings.vesiscan.ui.views.history
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.MeasurementHistoryStore
import com.medithings.vesiscan.ui.theme.*
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun MeasurementHistoryView(appState: AppState) {
val context = LocalContext.current
val records = remember { MeasurementHistoryStore.getInstance(context).records }
var selectedTab by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
// Top bar
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { appState.backToMonitoring() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), tint = MlPrimary)
}
Text(stringResource(R.string.history_title), fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.history_records_count, records.size), fontSize = 13.sp, color = MlSecondaryText)
}
// Tab picker
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = 24.dp)) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text(stringResource(R.string.history_tab_history), modifier = Modifier.padding(12.dp)) }
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text(stringResource(R.string.history_tab_trends), modifier = Modifier.padding(12.dp)) }
}
if (records.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.history_no_measurements), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.history_tap_spot_hint), fontSize = 14.sp, color = MlSecondaryText.copy(alpha = 0.7f))
}
} else if (selectedTab == 0) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(records) { record ->
RecordCard(record)
}
}
} else {
// Trends tab
val measurements = records.filter { it.eventType == MeasurementEventType.MEASUREMENT }.reversed()
val cathEvents = records.filter { it.eventType == MeasurementEventType.CATHETERIZATION }
LazyColumn(
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Level chart
item {
InteractiveChartCard(
title = stringResource(R.string.history_chart_bladder_level),
measurements = measurements,
valueExtractor = { it.level.toFloat() },
maxValue = BladderLevel.MAX_LEVEL.toFloat(),
lineColor = MlPrimary.copy(alpha = 0.8f),
pointColorExtractor = { it.urgencyLevel.accentColor },
thresholdValue = appState.bladderLevel.catheterizationThreshold.toFloat(),
unit = "Lv"
)
}
// Volume chart
if (measurements.isNotEmpty()) {
item {
InteractiveChartCard(
title = stringResource(R.string.history_chart_estimated_volume),
measurements = measurements,
valueExtractor = { it.estimatedVolume.toFloat() },
maxValue = measurements.maxOf { it.estimatedVolume }.toFloat().coerceAtLeast(1f),
lineColor = MlTeal,
pointColorExtractor = { MlTeal },
unit = "ml"
)
}
}
// Catheterization log
if (cathEvents.isNotEmpty()) {
item {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.history_catheterization_log), fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
cathEvents.forEach { event ->
val yesterdayLabel = stringResource(R.string.history_yesterday)
Row(modifier = Modifier.padding(vertical = 4.dp)) {
Text(formatDate(event.timestamp, yesterdayLabel), fontSize = 13.sp)
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.history_at_level, event.level), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun InteractiveChartCard(
title: String,
measurements: List<MeasurementRecord>,
valueExtractor: (MeasurementRecord) -> Float,
maxValue: Float,
lineColor: Color,
pointColorExtractor: (MeasurementRecord) -> Color,
thresholdValue: Float? = null,
unit: String
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var selectedIndex by remember { mutableIntStateOf(-1) }
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
if (scale > 1.05f) {
Text(
String.format("%.0f%%", scale * 100),
fontSize = 10.sp,
color = MlPrimary,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Tooltip
if (selectedIndex in measurements.indices) {
val record = measurements[selectedIndex]
val value = valueExtractor(record)
val todayLabel = stringResource(R.string.history_today)
val yesterdayLabel = stringResource(R.string.history_yesterday)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MlPrimary.copy(alpha = 0.08f))
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
formatDateTime(record.timestamp, todayLabel, yesterdayLabel),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = MlPrimary
)
Spacer(modifier = Modifier.weight(1f))
Text(
"${if (unit == "Lv") value.toInt().toString() else String.format("%.0f", value)} $unit",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = MlPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
record.urgencyLevel.rawStringValue,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = record.urgencyLevel.accentColor
)
}
Spacer(modifier = Modifier.height(4.dp))
}
if (measurements.isEmpty()) {
Text(stringResource(R.string.history_no_data), fontSize = 12.sp, color = MlSecondaryText)
} else {
val density = LocalDensity.current
val textPaint = remember {
android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#80808C")
textSize = with(density) { 9.sp.toPx() }
isAntiAlias = true
textAlign = android.graphics.Paint.Align.CENTER
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clipToBounds()
.pointerInput(measurements.size) {
detectTransformGestures { _, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(1f, 5f)
scale = newScale
val totalWidth = size.width * scale
val maxOff = (totalWidth - size.width).coerceAtLeast(0f)
offsetX = (offsetX + pan.x).coerceIn(-maxOff, 0f)
}
}
.pointerInput(measurements.size, scale, offsetX) {
detectTapGestures { tapOffset ->
if (measurements.size < 2) {
selectedIndex = if (selectedIndex == 0) -1 else 0
return@detectTapGestures
}
val totalWidth = size.width * scale
val stepX = totalWidth / (measurements.size - 1).toFloat()
val dataX = tapOffset.x - offsetX
val idx = (dataX / stepX + 0.5f).toInt().coerceIn(0, measurements.size - 1)
selectedIndex = if (selectedIndex == idx) -1 else idx
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
if (measurements.size < 2) {
val value = valueExtractor(measurements[0])
val y = size.height * 0.5f
drawCircle(pointColorExtractor(measurements[0]), radius = 8f, center = Offset(size.width / 2, y))
return@Canvas
}
val totalWidth = size.width * scale
val stepX = totalWidth / (measurements.size - 1).toFloat()
val chartHeight = size.height - 24f
val topPadding = 10f
// Threshold line (dashed)
thresholdValue?.let { threshold ->
val thresholdY = topPadding + chartHeight - (threshold / maxValue * chartHeight)
drawLine(
Color.Red.copy(alpha = 0.4f),
Offset(0f, thresholdY),
Offset(size.width, thresholdY),
strokeWidth = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
)
}
// Draw line path
val path = Path()
measurements.forEachIndexed { i, r ->
val x = i * stepX + offsetX
val value = valueExtractor(r)
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color = lineColor, style = Stroke(width = 3f))
// Draw points and labels
val labelInterval = when {
measurements.size <= 6 -> 1
measurements.size <= 15 -> 2
measurements.size <= 30 -> 5
else -> 10
}
measurements.forEachIndexed { i, r ->
val x = i * stepX + offsetX
if (x < -20f || x > size.width + 20f) return@forEachIndexed
val value = valueExtractor(r)
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
val isSelected = i == selectedIndex
val pointColor = pointColorExtractor(r)
if (isSelected) {
// Vertical guide
drawLine(
pointColor.copy(alpha = 0.3f),
Offset(x, topPadding),
Offset(x, topPadding + chartHeight),
strokeWidth = 1f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f))
)
// Selection ring
drawCircle(pointColor.copy(alpha = 0.2f), radius = 16f, center = Offset(x, y))
drawCircle(pointColor, radius = 8f, center = Offset(x, y))
drawCircle(Color.White, radius = 4f, center = Offset(x, y))
} else {
drawCircle(pointColor, radius = 5f, center = Offset(x, y))
}
// Time labels
if (i % labelInterval == 0 || i == measurements.size - 1) {
val label = formatShortTime(r.timestamp)
drawContext.canvas.nativeCanvas.drawText(
label, x, size.height, textPaint
)
}
}
}
}
// Hint text
if (measurements.size > 3 && scale < 1.05f && selectedIndex < 0) {
Text(
stringResource(R.string.history_pinch_hint),
fontSize = 10.sp,
color = MlSecondaryText.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
@Composable
private fun RecordCard(record: MeasurementRecord) {
val isCath = record.eventType == MeasurementEventType.CATHETERIZATION
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.fillMaxWidth().shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (isCath) MlPrimary.copy(alpha = 0.12f)
else record.urgencyLevel.accentColor.copy(alpha = 0.12f)
),
contentAlignment = Alignment.Center
) {
Text(
if (isCath) "C" else "M",
fontWeight = FontWeight.SemiBold,
color = if (isCath) MlPrimary else record.urgencyLevel.accentColor
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (isCath) stringResource(R.string.history_catheterization) else stringResource(R.string.history_measurement),
fontSize = 14.sp, fontWeight = FontWeight.SemiBold
)
Text(formatDate(record.timestamp, stringResource(R.string.history_yesterday)), fontSize = 12.sp, color = MlSecondaryText)
}
Column(horizontalAlignment = Alignment.End) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Lv.${record.level}", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.width(4.dp))
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(record.urgencyLevel.accentColor))
}
Text("${record.estimatedVolume} ml", fontSize = 12.sp, color = MlSecondaryText)
}
}
}
}
private fun formatDate(timestamp: Long, yesterdayLabel: String = "Yesterday"): String {
val cal = Calendar.getInstance()
val now = Calendar.getInstance()
cal.timeInMillis = timestamp
return when {
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp))
}
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
"$yesterdayLabel " + SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp))
}
else -> {
SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()).format(Date(timestamp))
}
}
}
private fun formatDateTime(timestamp: Long, todayLabel: String = "Today", yesterdayLabel: String = "Yesterday"): String {
val cal = Calendar.getInstance()
val now = Calendar.getInstance()
cal.timeInMillis = timestamp
return when {
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
"$todayLabel " + SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
}
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
"$yesterdayLabel " + SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
}
else -> {
SimpleDateFormat("MMM d, h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
}
}
}
private fun formatShortTime(timestamp: Long): String {
return SimpleDateFormat("HH:mm", Locale.US).format(Date(timestamp))
}
@@ -0,0 +1,211 @@
package com.medithings.vesiscan.ui.views.home
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.min
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.medithings.vesiscan.R
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun HomeView(appState: AppState) {
val context = LocalContext.current
var tapCount by remember { mutableIntStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
var showLanguageDialog by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(MlBackground)) {
// 우상단 — 언어 설정 진입 아이콘
IconButton(
onClick = { showLanguageDialog = true },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(Icons.Default.Language, contentDescription = stringResource(R.string.home_language_icon_desc),
tint = MlSecondaryText.copy(alpha = 0.7f))
}
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
// Bladdy hero — 3× 확대(660dp 목표), 화면 작을 땐 가용 영역의 90%까지 캡
BoxWithConstraints(contentAlignment = Alignment.Center) {
val target = 660.dp
val cap = min(maxWidth, maxHeight) * 0.90f
val bladdySize = if (target < cap) target else cap
BladdyIdleRiveView(size = bladdySize)
// 투명 오버레이 — Rive AndroidView가 터치를 가로채므로 위에 덮어씀
Box(
modifier = Modifier
.size(bladdySize)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
val now = System.currentTimeMillis()
if (now - lastTapTime > 1500) tapCount = 0
lastTapTime = now
tapCount++
if (tapCount >= 3) {
tapCount = 0
appState.isDevMode = !appState.isDevMode
val msg = if (appState.isDevMode) context.getString(R.string.home_dev_mode_on)
else context.getString(R.string.home_dev_mode_off)
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
)
}
Spacer(modifier = Modifier.height(24.dp))
Text("VesiScan-Basic", fontSize = 32.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Text(stringResource(R.string.home_subtitle), fontSize = 16.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(4.dp))
val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
Text(versionName ?: "", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.5f))
Spacer(modifier = Modifier.height(8.dp))
// BLE 연결 상태 chip — 연결 유지 정책에서 사용자가 인지 가능하도록
if (appState.isDeviceConnected) {
Text(
stringResource(R.string.home_connected_resume),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF4CAF50),
modifier = Modifier
.background(Color(0xFF4CAF50).copy(alpha = 0.12f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { appState.startFromHome() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 12.dp)
.height(60.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
shape = RoundedCornerShape(18.dp)
),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.home_start), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
// Dev 모드 한정 — Clinical R&D Mode 진입
if (appState.isDevMode) {
Button(
onClick = { appState.currentScreen = com.medithings.vesiscan.AppScreen.CLINICAL_HOME },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp)
.height(48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
) {
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
}
} else {
Spacer(modifier = Modifier.height(48.dp))
}
}
} // Box (top-bar gear overlay) close
// 언어 선택 다이얼로그 — 시스템 / 한국어 / English. 변경 시 Activity recreate.
if (showLanguageDialog) {
val curMode = com.medithings.vesiscan.services.LocalePref.getMode(context)
var selected by remember { mutableStateOf(curMode) }
AlertDialog(
onDismissRequest = { showLanguageDialog = false },
title = { Text(stringResource(R.string.language_dialog_title)) },
text = {
Column {
listOf(
com.medithings.vesiscan.services.LocalePref.Mode.SYSTEM to stringResource(R.string.language_system_default),
com.medithings.vesiscan.services.LocalePref.Mode.KO to stringResource(R.string.language_korean),
com.medithings.vesiscan.services.LocalePref.Mode.EN to stringResource(R.string.language_english),
).forEach { (mode, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { selected = mode }
.padding(vertical = 6.dp)
) {
RadioButton(
selected = selected == mode,
onClick = { selected = mode }
)
Spacer(Modifier.width(4.dp))
Text(label)
}
}
}
},
confirmButton = {
TextButton(onClick = {
com.medithings.vesiscan.services.LocalePref.setMode(context, selected)
showLanguageDialog = false
// Activity recreate → attachBaseContext 다시 호출 → 새 locale 적용
(context as? android.app.Activity)?.recreate()
}) { Text(stringResource(R.string.confirm)) }
},
dismissButton = {
TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
}
@@ -0,0 +1,226 @@
package com.medithings.vesiscan.ui.views.monitoring
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.medithings.vesiscan.R
import com.medithings.vesiscan.measure.VoidingRecord
import com.medithings.vesiscan.measure.VoidingRecordStore
import com.medithings.vesiscan.speech.SpeechRecognizerManager
import com.medithings.vesiscan.speech.VolumeParser
/**
* Catheterize 바텀시트 — 수기/음성/카메라 3가지 입력
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CatheterizeSheet(
onDismiss: () -> Unit,
onCameraRequested: () -> Unit,
onCatheterized: () -> Unit // 레벨 리셋 콜백
) {
val context = LocalContext.current
var selectedTab by remember { mutableIntStateOf(0) } // 0=수기, 1=음성, 2=카메라
var volumeText by remember { mutableStateOf("") }
var isSaved by remember { mutableStateOf(false) }
// 음성 인식
var isListening by remember { mutableStateOf(false) }
var voiceStatus by remember { mutableStateOf("") }
var speechManager by remember { mutableStateOf<SpeechRecognizerManager?>(null) }
var hasAudioPerm by remember {
mutableStateOf(ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) ==
android.content.pm.PackageManager.PERMISSION_GRANTED)
}
val audioPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasAudioPerm = it }
DisposableEffect(Unit) { onDispose { speechManager?.destroy() } }
fun saveRecord(volume: Int?, method: String) {
VoidingRecordStore.add(context, VoidingRecord(volume = volume, method = method))
onCatheterized()
isSaved = true
Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
}
fun startVoice() {
if (!hasAudioPerm) { audioPermLauncher.launch(android.Manifest.permission.RECORD_AUDIO); return }
speechManager?.destroy()
isListening = true; voiceStatus = context.getString(R.string.listening)
speechManager = SpeechRecognizerManager(
context = context,
onResult = { candidates ->
isListening = false
// 범위 표현
val rangeRegex = Regex("""(\d{2,3})\s*(?:에서|~|부터|-)\s*(\d{2,3})\s*(?:사이|까지)?""")
for (c in candidates) {
rangeRegex.find(c)?.let { m ->
val lo = m.groupValues[1].toIntOrNull(); val hi = m.groupValues[2].toIntOrNull()
if (lo != null && hi != null && lo in 1..1000 && hi in 1..1000) {
val avg = (lo + hi) / 2
volumeText = avg.toString()
voiceStatus = "\"$lo~$hi\" → ${avg} ml"
saveRecord(avg, "voice")
return@SpeechRecognizerManager
}
}
}
VolumeParser.parseBest(candidates)?.let {
volumeText = it.volumeMl.toString()
voiceStatus = "\"${it.rawText}\" → ${it.volumeMl} ml"
saveRecord(it.volumeMl, "voice")
} ?: run { voiceStatus = context.getString(R.string.recognition_fail, candidates.firstOrNull() ?: "") }
},
onError = { code -> isListening = false; voiceStatus = when(code) {
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> context.getString(R.string.recognition_fail, "")
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> context.getString(R.string.error_code, code)
else -> context.getString(R.string.error_code, code)
}},
onReady = { voiceStatus = context.getString(R.string.speak_now) }
)
speechManager?.startListening()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isSaved) {
// 저장 완료 화면
Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = Color(0xFF4CAF50))
Spacer(Modifier.height(16.dp))
Text(stringResource(R.string.registered_diary), fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
if (volumeText.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("$volumeText ml", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onDismiss,
Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp)
) { Text(stringResource(R.string.confirm)) }
} else {
// 입력 모드 선택
Text(stringResource(R.string.voiding_record), fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
// 3 탭 버튼
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// 수기
Button(
onClick = { selectedTab = 0 },
modifier = Modifier.weight(1f).height(64.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selectedTab == 0) Color(0xFFFF9800) else Color(0xFFFF9800).copy(alpha = 0.1f)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Edit, null, Modifier.size(20.dp), tint = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
Text(stringResource(R.string.manual_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
}
}
// 음성
Button(
onClick = { selectedTab = 1 },
modifier = Modifier.weight(1f).height(64.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selectedTab == 1) Color(0xFF007AFF) else Color(0xFF007AFF).copy(alpha = 0.1f)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.SettingsVoice, null, Modifier.size(20.dp), tint = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
Text(stringResource(R.string.voice_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
}
}
// 카메라
Button(
onClick = { onCameraRequested(); onDismiss() },
modifier = Modifier.weight(1f).height(64.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.1f))
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CameraAlt, null, Modifier.size(20.dp), tint = Color(0xFF4CAF50))
Text(stringResource(R.string.camera_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF4CAF50))
}
}
}
Spacer(Modifier.height(20.dp))
when (selectedTab) {
0 -> {
// 수기 입력
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
OutlinedTextField(
value = volumeText,
onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) },
placeholder = { Text(stringResource(R.string.volume_placeholder), color = Color.Gray) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
modifier = Modifier.width(160.dp),
shape = RoundedCornerShape(12.dp)
)
Spacer(Modifier.width(8.dp))
Text("ml", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Color.Gray)
}
Spacer(Modifier.height(20.dp))
val vol = volumeText.toIntOrNull()
Button(
onClick = { if (vol != null && vol in 1..1000) saveRecord(vol, "manual") },
enabled = vol != null && vol in 1..1000,
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF9800))
) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
}
1 -> {
// 음성 입력
Button(
onClick = { startVoice() },
modifier = Modifier.size(80.dp),
shape = RoundedCornerShape(40.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isListening) Color(0xFFE53935) else Color(0xFF007AFF)
)
) {
Icon(Icons.Default.SettingsVoice, null, Modifier.size(36.dp), tint = Color.White)
}
Spacer(Modifier.height(12.dp))
Text(
if (isListening) stringResource(R.string.listening) else stringResource(R.string.tap_to_speak),
color = Color.Gray, fontSize = 14.sp
)
if (voiceStatus.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(voiceStatus, color = Color.Gray, fontSize = 12.sp)
}
}
}
}
}
}
}
@@ -0,0 +1,279 @@
package com.medithings.vesiscan.ui.views.monitoring
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Matrix
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.medithings.vesiscan.R
import com.medithings.vesiscan.measure.SimpleMeasureService
import com.medithings.vesiscan.measure.VoidingRecord
import com.medithings.vesiscan.measure.VoidingRecordStore
import com.medithings.vesiscan.measure.YoloDetector
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
/**
* 소변컵 카메라 촬영 + V2 측정 화면 (medilightv2용 간소화 버전)
* YOLO 자동감지 → 2초 자동촬영 → V2 측정 → 로컬 저장
*/
@Composable
fun UrineCameraScreen(
onDone: (Int?) -> Unit, // 측정 완료 콜백 (volume)
onCancel: () -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
var hasCameraPerm by remember {
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
}
val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCameraPerm = it }
LaunchedEffect(Unit) { if (!hasCameraPerm) permLauncher.launch(Manifest.permission.CAMERA) }
val yoloDetector = remember { YoloDetector(context) }
var isModelLoaded by remember { mutableStateOf(false) }
var currentDetection by remember { mutableStateOf<YoloDetector.DetectionResult?>(null) }
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { isModelLoaded = yoloDetector.loadModel() } }
DisposableEffect(Unit) { onDispose { yoloDetector.close() } }
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isAnalyzing by remember { mutableStateOf(false) }
var resultVolume by remember { mutableStateOf<Int?>(null) }
var analysisComplete by remember { mutableStateOf(false) }
// 자동 촬영
var detectionStartTime by remember { mutableStateOf<Long?>(null) }
var autoCaptureProgress by remember { mutableFloatStateOf(0f) }
var isAutoCapturing by remember { mutableStateOf(false) }
val autoCaptureDelay = 2000L
val imageCapture = remember {
ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build()
}
fun doCapture() {
isAutoCapturing = true
imageCapture.takePicture(ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
val bmp = image.toBitmap()
val rotation = image.imageInfo.rotationDegrees.toFloat()
image.close()
val rotated = if (rotation != 0f) {
val m = Matrix().apply { postRotate(rotation) }
Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
} else bmp
originalBitmap = rotated
val det = currentDetection
if (det != null) {
val box = det.boundingBox
val padH = 0.10f; val padTop = 0.10f; val padBot = 0.05f
val cropX = ((box.left - box.width() * padH).coerceAtLeast(0f) * rotated.width).toInt()
val cropY = ((box.top - box.height() * padTop).coerceAtLeast(0f) * rotated.height).toInt()
val cropR = ((box.right + box.width() * padH).coerceAtMost(1f) * rotated.width).toInt()
val cropB = ((box.bottom + box.height() * padBot).coerceAtMost(1f) * rotated.height).toInt()
val cropW = (cropR - cropX).coerceAtMost(rotated.width - cropX)
val cropH = (cropB - cropY).coerceAtMost(rotated.height - cropY)
capturedBitmap = if (cropW > 50 && cropH > 50) Bitmap.createBitmap(rotated, cropX, cropY, cropW, cropH) else rotated
} else {
capturedBitmap = rotated
}
}
})
}
// 자동 촬영 로직
LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) {
if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) {
if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis()
while (currentDetection != null && currentDetection!!.confidence >= 0.90f && !isAutoCapturing && capturedBitmap == null) {
val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis())
autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f)
if (elapsed >= autoCaptureDelay) { doCapture(); break }
delay(50)
}
} else { detectionStartTime = null; autoCaptureProgress = 0f }
}
// 자동 분석
LaunchedEffect(capturedBitmap) {
val bmp = capturedBitmap ?: return@LaunchedEffect
isAnalyzing = true
try {
val result = SimpleMeasureService.measure(bmp)
resultVolume = result.volume
} catch (_: Exception) { resultVolume = null }
isAnalyzing = false
analysisComplete = true
}
val borderColor by animateColorAsState(
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) Color.Green else Color.Transparent,
tween(300), label = "border"
)
if (!hasCameraPerm) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.camera_permission_needed))
Spacer(Modifier.height(16.dp))
Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.grant_permission)) }
}
}
} else if (capturedBitmap != null) {
// === 분석 결과 화면 ===
Column(
Modifier.fillMaxSize().background(Color.White).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) }
Text(stringResource(R.string.urine_measurement), fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(48.dp))
}
Spacer(Modifier.height(16.dp))
Image(bitmap = capturedBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.height(16.dp))
if (isAnalyzing) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.analyzing), color = Color.Gray)
}
} else if (analysisComplete) {
if (resultVolume != null && resultVolume!! > 0) {
Text(stringResource(R.string.measurement_result), color = Color.Gray, fontSize = 14.sp)
Row(verticalAlignment = Alignment.Bottom) {
Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp))
}
} else {
Text(stringResource(R.string.measurement_fail), fontSize = 20.sp, color = Color.Red)
Text(stringResource(R.string.retry_in_bright), color = Color.Gray, fontSize = 14.sp)
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f },
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp)
) { Text(stringResource(R.string.retake_photo)) }
Button(
onClick = {
val vol = resultVolume
if (vol != null && vol > 0) {
VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera"))
Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
}
onDone(vol)
},
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
}
}
Spacer(Modifier.height(16.dp))
}
} else {
// === 카메라 프리뷰 ===
Box(
Modifier.fillMaxSize().border(
width = if (borderColor == Color.Transparent) 0.dp else 4.dp, color = borderColor
)
) {
val previewView = remember { PreviewView(context) }
val executor = remember { Executors.newSingleThreadExecutor() }
LaunchedEffect(Unit) {
val future = ProcessCameraProvider.getInstance(context)
future.addListener({
val provider = future.get()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { a ->
a.setAnalyzer(executor) { proxy ->
if (isModelLoaded) {
val bmp = proxy.toBitmap()
val rot = proxy.imageInfo.rotationDegrees.toFloat()
val rotated = if (rot != 0f) { val m = Matrix().apply { postRotate(rot) }; Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) } else bmp
currentDetection = yoloDetector.detect(rotated)
}
proxy.close()
}
}
try { provider.unbindAll(); provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, analysis) } catch (_: Exception) {}
}, ContextCompat.getMainExecutor(context))
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
// 상단
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, stringResource(R.string.close), tint = Color.White) }
Spacer(Modifier.weight(1f))
val statusText = when {
!isModelLoaded -> stringResource(R.string.model_loading)
isAutoCapturing -> stringResource(R.string.auto_capturing)
autoCaptureProgress > 0 -> stringResource(R.string.hold_still, (autoCaptureProgress * 100).toInt())
currentDetection != null -> stringResource(R.string.cup_detected)
else -> stringResource(R.string.searching_cup)
}
Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp))
Spacer(Modifier.weight(1f)); Spacer(Modifier.size(48.dp))
}
// 가이드
Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.camera_guide_1), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.camera_guide_2), color = Color(0xFFFFD54F), fontSize = 12.sp)
if (autoCaptureProgress > 0) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f))
}
}
// 수동 촬영 버튼
Box(Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp)) {
Button(onClick = { if (!isAutoCapturing) doCapture() }, Modifier.size(80.dp), shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = Color.White), contentPadding = PaddingValues(0.dp)
) { Box(Modifier.size(64.dp).background(Color.White, CircleShape)) }
}
}
}
}
@@ -0,0 +1,497 @@
package com.medithings.vesiscan.ui.views.monitoring
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.NirsMcjPacket
import com.medithings.vesiscan.models.NirsOxyResult
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
import kotlin.random.Random
@Composable
fun VivamyoMonitoringView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val nirsManager = remember { NirsManager.instance }
val handler = remember { Handler(Looper.getMainLooper()) }
val screenWidth = LocalConfiguration.current.screenWidthDp
val isCompact = screenWidth < 400
var selectedTab by remember { mutableIntStateOf(0) }
var showClearConfirm by remember { mutableStateOf(false) }
var isToggling by remember { mutableStateOf(false) }
val batteryLvl by bleManager.batteryLevel
val isConnected by bleManager.isConnected
val isStreaming by nirsManager.isStreaming
val currentOxy by nirsManager.currentOxy
val sampleCount by nirsManager.sampleCount
val isFilterEnabled by nirsManager.isFilterEnabled
val isDemoMode = appState.isDemoMode
var demoRunnable by remember { mutableStateOf<Runnable?>(null) }
var demoBaseHbO2 by remember { mutableStateOf(12.0) }
var demoBaseHb by remember { mutableStateOf(8.0) }
var mcjRetryCount = remember { mutableIntStateOf(0) }
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
fun cancelMcjRetry() {
mcjRetryRunnable?.let { handler.removeCallbacks(it) }
mcjRetryRunnable = null
mcjRetryCount.intValue = 0
}
fun startNirsStream() {
cancelMcjRetry()
bleManager.onNirsMcjReceived = { data ->
cancelMcjRetry() // Got response, cancel retry
if (nirsManager.isStreaming.value) {
val packet = NirsMcjPacket.parse(data)
if (packet != null) {
nirsManager.processMcjPacket(packet)
} else {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
}
}
handler.postDelayed({
if (nirsManager.isStreaming.value) {
bleManager.sendMcjQuery()
// Retry if no response within 2s
val retry = Runnable {
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 3) {
mcjRetryCount.intValue++
bleManager.sendMcjQuery()
}
}
mcjRetryRunnable = retry
handler.postDelayed(retry, 2000)
}
}, 100)
}
}
// Start mcj chain directly (same as iOS)
bleManager.sendMcjQuery()
// Retry if first mcj gets no response within 2s
val initialRetry = Runnable {
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 5) {
mcjRetryCount.intValue++
bleManager.sendMcjQuery()
}
}
mcjRetryRunnable = initialRetry
handler.postDelayed(initialRetry, 2000)
}
fun startDemoStream() {
val tick = object : Runnable {
override fun run() {
if (!nirsManager.isStreaming.value) return
demoBaseHbO2 += Random.nextDouble(-0.3, 0.3)
demoBaseHb += Random.nextDouble(-0.2, 0.2)
val hbO2 = demoBaseHbO2.coerceIn(5.0, 25.0)
val hb = demoBaseHb.coerceIn(3.0, 15.0)
demoBaseHbO2 = hbO2
demoBaseHb = hb
val tHb = hbO2 + hb
val stO2 = if (tHb > 0) (hbO2 / tHb * 100.0) else 0.0
nirsManager.currentOxy.value = NirsOxyResult(hbO2, hb, tHb, stO2)
nirsManager.sampleCount.intValue++
nirsManager.oxyHbO2History.add(hbO2)
nirsManager.oxyHbHistory.add(hb)
if (nirsManager.oxyHbO2History.size > 240) nirsManager.oxyHbO2History.removeAt(0)
if (nirsManager.oxyHbHistory.size > 240) nirsManager.oxyHbHistory.removeAt(0)
handler.postDelayed(this, 250)
}
}
demoRunnable = tick
handler.post(tick)
}
fun stopDemoStream() {
demoRunnable?.let { handler.removeCallbacks(it) }
demoRunnable = null
nirsManager.stopStreaming()
}
fun stopNirsStream() {
// 1. Stop polling chain & clear callback first
nirsManager.stopStreaming()
cancelMcjRetry()
bleManager.onNirsMcjReceived = null
// 2. Resume battery polling after in-flight mcj response has time to arrive
handler.postDelayed({
bleManager.startBatteryPolling()
}, 500)
}
fun toggleStreaming() {
if (isToggling) return
isToggling = true
if (isStreaming) {
if (isDemoMode) stopDemoStream() else stopNirsStream()
} else {
if (isDemoMode) {
nirsManager.startStreaming()
startDemoStream()
} else {
bleManager.stopBatteryPolling()
nirsManager.startStreaming()
startNirsStream()
}
}
handler.postDelayed({ isToggling = false }, 1000)
}
// BLE 끊김 banner는 사내 테스트 피드백으로 제거됨 — BleManager 측 자동 재연결만 동작.
LaunchedEffect(Unit) {
bleManager.onUnexpectedDisconnect = { /* no-op: silent reconnect */ }
}
DisposableEffect(Unit) { onDispose {
if (isDemoMode) { stopDemoStream() }
else if (nirsManager.isStreaming.value) stopNirsStream()
else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null }
} }
// Clear confirm dialog
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text(stringResource(R.string.vivamyo_clear_title)) },
text = { Text(stringResource(R.string.vivamyo_clear_message)) },
confirmButton = {
TextButton(onClick = {
nirsManager.clearData()
showClearConfirm = false
}) { Text(stringResource(R.string.vivamyo_clear_button), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
val stO2Color = when {
currentOxy.stO2 < 50 -> Color.Red
currentOxy.stO2 < 60 -> Color(0xFFFF9500)
else -> Color.Green
}
val stO2StatusText = when {
currentOxy.stO2 < 50 -> context.getString(R.string.vivamyo_status_low)
currentOxy.stO2 < 60 -> context.getString(R.string.vivamyo_status_caution)
currentOxy.stO2 <= 85 -> context.getString(R.string.vivamyo_status_normal)
else -> context.getString(R.string.vivamyo_status_check)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// Top bar
val hPad = if (isCompact) 12.dp else 24.dp
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
nirsManager.reset()
appState.goHome()
}, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Home, stringResource(R.string.vivamyo_home_desc), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
Text(stringResource(R.string.vivamyo_title), fontSize = if (isCompact) 16.sp else 20.sp, fontWeight = FontWeight.Bold)
if (!isCompact) Text(stringResource(R.string.vivamyo_subtitle), fontSize = 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.weight(1f))
if (isDemoMode) {
Text("DEMO", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 4.dp, vertical = 2.dp))
} else {
Text("$batteryLvl%", fontSize = if (isCompact) 11.sp else 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(if (isCompact) 4.dp else 8.dp))
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isDemoMode || isConnected) Color.Green else Color.Red))
}
// Tab picker
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = hPad)) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("SpO2", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Oxy/Deoxy", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalArrangement = Arrangement.spacedBy(if (isCompact) 10.dp else 16.dp)
) {
if (selectedTab == 0) {
// SpO2 tab
BladdyIdleRiveView(
size = if (isCompact) 80.dp else 120.dp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(String.format("%.1f", currentOxy.stO2), fontSize = if (isCompact) 40.sp else 56.sp, fontWeight = FontWeight.Bold, color = stO2Color)
Text("% StO2", fontSize = 18.sp, color = MlSecondaryText)
Text(stO2StatusText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = stO2Color,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(stO2Color.copy(alpha = 0.15f)).padding(horizontal = 12.dp, vertical = 4.dp))
}
NirsMetricsCard(nirsManager)
Row(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
}
} else {
// Oxy/Deoxy tab
LineChartCard("HbO2 (Oxy)", nirsManager.oxyHbO2History, Color.Red, "uM")
LineChartCard("HHb (DeOxy)", nirsManager.oxyHbHistory, Color.Blue, "uM")
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
if (isFilterEnabled) {
Text(stringResource(R.string.vivamyo_filtered), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFF9966FF)).padding(horizontal = 6.dp, vertical = 2.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
}
NirsMetricsCard(nirsManager)
}
}
// Control bar
Row(
modifier = Modifier.padding(horizontal = hPad).padding(bottom = if (isCompact) 16.dp else 32.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = { showClearConfirm = true },
modifier = Modifier.weight(1f).height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.Red)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.vivamyo_clear), fontWeight = FontWeight.SemiBold, color = Color.Red)
}
}
Button(
onClick = { toggleStreaming() },
modifier = Modifier.weight(1f).height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues(),
enabled = !isToggling
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(14.dp)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
null, tint = Color.White
)
Spacer(modifier = Modifier.width(4.dp))
Text(if (isStreaming) stringResource(R.string.vivamyo_stop) else stringResource(R.string.vivamyo_start), fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
}
}
@Composable
private fun NirsMetricsCard(nirsManager: NirsManager) {
val oxy by nirsManager.currentOxy
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricBox("HbO2", oxy.hbO2, "uM", Color.Red, Modifier.weight(1f))
MetricBox("HHb", oxy.hb, "uM", Color.Blue, Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricBox("tHb", oxy.tHb, "uM", Color(0xFF9966FF), Modifier.weight(1f))
MetricBox("StO2", oxy.stO2, "%", Color.Green, Modifier.weight(1f))
}
}
}
}
@Composable
private fun MetricBox(title: String, value: Double, unit: String, color: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MlBackground)
.padding(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(4.dp))
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
}
Text(String.format("%.1f", value), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F), modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
Text(unit, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
}
}
@Composable
private fun LineChartCard(label: String, data: List<Double>, color: Color, unit: String) {
val axisColor = MlSecondaryText.copy(alpha = 0.4f)
val gridColor = MlSecondaryText.copy(alpha = 0.1f)
val sampleRate = 4f // Hz
val yLabelWidth = 36.dp
val xLabelHeight = 18.dp
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(4.dp))
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Text(
String.format("%.1f %s", data.lastOrNull() ?: 0.0, unit),
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = color
)
}
Spacer(modifier = Modifier.height(8.dp))
if (data.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(140.dp),
contentAlignment = Alignment.Center
) { Text(stringResource(R.string.vivamyo_no_data), fontSize = 11.sp, color = MlSecondaryText) }
} else {
val minVal = data.min()
val maxVal = data.max()
val range = if (maxVal - minVal > 0.001) maxVal - minVal else 1.0
val yMin = minVal - range * 0.05
val yMax = maxVal + range * 0.05
val yRange = yMax - yMin
// Y-axis tick values (5 ticks)
val yTicks = (0..4).map { yMin + yRange * it / 4.0 }
Row(modifier = Modifier.fillMaxWidth().height(140.dp + xLabelHeight)) {
// Y-axis labels
Column(
modifier = Modifier.width(yLabelWidth).height(140.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
yTicks.reversed().forEach { tick ->
Text(
String.format("%.1f", tick),
fontSize = 8.sp,
color = MlSecondaryText,
maxLines = 1
)
}
}
Column {
// Chart area
Canvas(modifier = Modifier.fillMaxWidth().height(140.dp)) {
// Grid lines
for (i in 0..4) {
val y = size.height * i / 4f
drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
}
// Data line
if (data.size >= 2) {
val stepX = size.width / (data.size - 1).toFloat()
val path = Path()
data.forEachIndexed { index, value ->
val x = index * stepX
val y = size.height - ((value - yMin) / yRange * size.height).toFloat()
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color = color, style = Stroke(width = 3f))
}
}
// X-axis labels
val totalSeconds = data.size / sampleRate
Row(
modifier = Modifier.fillMaxWidth().height(xLabelHeight),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(String.format("%.0fs", 0f), fontSize = 8.sp, color = MlSecondaryText)
Text(String.format("%.0fs", totalSeconds / 2), fontSize = 8.sp, color = MlSecondaryText)
Text(String.format("%.0fs", totalSeconds), fontSize = 8.sp, color = MlSecondaryText)
}
}
}
// Unit label
Text(unit, fontSize = 8.sp, color = MlSecondaryText, modifier = Modifier.padding(start = yLabelWidth))
}
}
}
}
@@ -0,0 +1,294 @@
package com.medithings.vesiscan.ui.views.monitoring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.R
import com.medithings.vesiscan.measure.VoidingRecord
import com.medithings.vesiscan.measure.VoidingRecordStore
import com.medithings.vesiscan.ui.theme.MlCardBackground
import com.medithings.vesiscan.ui.theme.MlPrimary
import com.medithings.vesiscan.ui.theme.MlSecondaryText
import java.text.SimpleDateFormat
import java.util.*
/**
* 배뇨일지 목록 화면 — 로컬 기록 조회 + 의료 지표
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VoidingDiaryView(onBack: () -> Unit) {
val context = LocalContext.current
var records by remember { mutableStateOf(VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }) }
var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
val grouped = remember(records) {
records.groupBy { dateFormat.format(Date(it.timestamp)) }
.toSortedMap(compareByDescending { it })
}
// 의료 지표 계산 (최근 24시간 기준)
val metrics = remember(records) { calculateMedicalMetrics(records) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.voiding_diary), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }
}
)
}
) { padding ->
if (records.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Description, contentDescription = null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp))
Text(stringResource(R.string.no_records), color = Color.Gray)
}
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 요약 바 (iOS summaryBar: 건수 + 총량 + 평균)
item { SummaryBar(records) }
// 의료 지표 카드
item { MedicalMetricsCard(metrics) }
grouped.forEach { (date, dayRecords) ->
item {
val displayDate = try {
val d = dateFormat.parse(date)
SimpleDateFormat("M/d (E)", Locale.getDefault()).format(d!!)
} catch (_: Exception) { date }
Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp))
}
items(dayRecords, key = { it.id }) { record ->
Row(
modifier = Modifier.fillMaxWidth()
.background(Color.Gray.copy(alpha = 0.08f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(record.timeString, fontFamily = FontFamily.Monospace, color = Color.Gray,
modifier = Modifier.width(50.dp))
Spacer(Modifier.width(8.dp))
when {
record.volume != null -> {
Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(16.dp), Color(0xFF007AFF))
Spacer(Modifier.width(4.dp))
Text("${record.volume} ml", fontWeight = FontWeight.SemiBold)
}
record.activity == "awake" -> {
Icon(Icons.Default.WbSunny, contentDescription = null, Modifier.size(16.dp), Color(0xFFFF9800))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.awake))
}
record.activity == "sleep" -> {
Icon(Icons.Default.DarkMode, contentDescription = null, Modifier.size(16.dp), Color(0xFF9C27B0))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.sleep))
}
}
Spacer(Modifier.weight(1f))
val methodLabel = when (record.method) {
"voice" -> stringResource(R.string.method_voice)
"camera" -> stringResource(R.string.method_camera)
else -> stringResource(R.string.method_manual)
}
Text(methodLabel, fontSize = 10.sp, color = Color.Gray,
modifier = Modifier.background(Color.Gray.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp))
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { showDeleteConfirm = record.id },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), Modifier.size(20.dp), Color.Gray)
}
}
}
}
item {
Spacer(Modifier.height(16.dp))
Text(stringResource(R.string.total_records, records.size), color = Color.Gray, fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
}
}
// 삭제 확인
showDeleteConfirm?.let { id ->
AlertDialog(
onDismissRequest = { showDeleteConfirm = null },
title = { Text(stringResource(R.string.delete_record)) },
text = { Text(stringResource(R.string.delete_confirm_msg)) },
confirmButton = {
TextButton(onClick = {
VoidingRecordStore.delete(context, id)
records = VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }
showDeleteConfirm = null
}) { Text(stringResource(R.string.delete), color = Color.Red) }
},
dismissButton = { TextButton(onClick = { showDeleteConfirm = null }) { Text(stringResource(R.string.cancel)) } }
)
}
}
data class DiaryMetrics(
val totalVolume24h: Int, // 24hUV (24-hour urine volume)
val nocturnalVolume: Int, // NUV (nocturnal urine volume)
val npi: Double, // Nocturnal Polyuria Index (NUV / 24hUV)
val maxCapacity: Int, // Max single voiding volume
val totalCount: Int, // 총 횟수
val daytimeCount: Int, // 주간 횟수
val nighttimeCount: Int // 야간 횟수
)
private fun calculateMedicalMetrics(records: List<VoidingRecord>): DiaryMetrics {
val now = System.currentTimeMillis()
val h24ago = now - 24 * 60 * 60 * 1000L
val recent = records.filter { it.timestamp >= h24ago }.sortedBy { it.timestamp }
val voidingRecords = recent.filter { it.volume != null }
// awake/sleep 경계 찾기
val lastAwake = recent.lastOrNull { it.activity == "awake" }
val lastSleep = recent.lastOrNull { it.activity == "sleep" }
// 야간 = sleep 이후 ~ awake 이전의 기록
val nocturnalRecords = if (lastSleep != null && lastAwake != null) {
if (lastSleep.timestamp < lastAwake.timestamp) {
// sleep → awake 순서: sleep~awake 사이가 야간
voidingRecords.filter { it.timestamp in lastSleep.timestamp..lastAwake.timestamp }
} else {
// awake → sleep 순서: sleep 이후가 현재 야간
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
}
} else if (lastSleep != null) {
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
} else {
emptyList()
}
val totalVolume = voidingRecords.sumOf { it.volume ?: 0 }
val nocturnalVolume = nocturnalRecords.sumOf { it.volume ?: 0 }
val npi = if (totalVolume > 0) nocturnalVolume.toDouble() / totalVolume else 0.0
val maxCapacity = voidingRecords.maxOfOrNull { it.volume ?: 0 } ?: 0
val nightCount = nocturnalRecords.size
val dayCount = voidingRecords.size - nightCount
return DiaryMetrics(
totalVolume24h = totalVolume,
nocturnalVolume = nocturnalVolume,
npi = npi,
maxCapacity = maxCapacity,
totalCount = voidingRecords.size,
daytimeCount = dayCount,
nighttimeCount = nightCount
)
}
@Composable
private fun SummaryBar(records: List<VoidingRecord>) {
val volumes = records.mapNotNull { it.volume }
val count = volumes.size
val total = volumes.sum()
val avg = if (count > 0) total / count else 0
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(14.dp), Color(0xFF2196F3))
Text(stringResource(R.string.count_times, count), fontSize = 12.sp, color = Color(0xFF2196F3))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.Science, contentDescription = null, Modifier.size(14.dp), Color(0xFF00BCD4))
Text(stringResource(R.string.volume_ml_short, total), fontSize = 12.sp, color = Color(0xFF00BCD4))
}
if (count > 0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.BarChart, contentDescription = null, Modifier.size(14.dp), Color(0xFF4CAF50))
Text(stringResource(R.string.average_ml, avg), fontSize = 12.sp, color = Color(0xFF4CAF50))
}
}
}
}
@Composable
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.medical_metrics_24h), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.15f))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell("24hUV", stringResource(R.string.volume_ml_short, metrics.totalVolume24h), Modifier.weight(1f))
MetricCell("NUV", stringResource(R.string.volume_ml_short, metrics.nocturnalVolume), Modifier.weight(1f))
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
highlight = metrics.npi > 0.33)
MetricCell("Max", stringResource(R.string.volume_ml_short, metrics.maxCapacity), Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell(stringResource(R.string.metric_total_count), stringResource(R.string.count_times, metrics.totalCount), Modifier.weight(1f))
MetricCell(stringResource(R.string.metric_daytime), stringResource(R.string.count_times, metrics.daytimeCount), Modifier.weight(1f))
MetricCell(stringResource(R.string.metric_nighttime), stringResource(R.string.count_times, metrics.nighttimeCount), Modifier.weight(1f),
highlight = metrics.nighttimeCount >= 2)
}
}
}
}
@Composable
private fun MetricCell(label: String, value: String, modifier: Modifier = Modifier, highlight: Boolean = false) {
Column(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(if (highlight) Color.Red.copy(alpha = 0.08f) else Color.Gray.copy(alpha = 0.06f))
.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(value, fontSize = 16.sp, fontWeight = FontWeight.Bold,
color = if (highlight) Color.Red else Color(0xFF1C1B1F))
Text(label, fontSize = 10.sp, color = MlSecondaryText)
}
}
@@ -0,0 +1,80 @@
package com.medithings.vesiscan.ui.views.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun OnboardingView(appState: AppState) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
BladdyIdleRiveView(size = 180.dp)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.onboarding_welcome),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_subtitle),
fontSize = 16.sp,
color = MlSecondaryText,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(2f))
Button(
onClick = { appState.completeOnboarding() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.onboarding_get_started), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
@@ -0,0 +1,381 @@
package com.medithings.vesiscan.ui.views.personalization
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
import kotlin.math.roundToInt
/**
* 회의 #8 통합: MaxCapacity + Catheter Threshold + Auto Scan Interval 3카드 한 화면.
* 캐릭터는 작게(상단), 카드 영역은 verticalScroll로 모두 노출.
*/
@Composable
fun PiezoPersonalizationView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val handler = remember { Handler(Looper.getMainLooper()) }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { appState.backToSensorSelect() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.personalization_back_desc), tint = MlPrimary)
}
}
// Bladdy hero — 회의 #8: 작게 (이전 220 → 120dp)
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
BladdyIdleRiveView(size = 120.dp)
}
// 3카드 영역 — verticalScroll
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(4.dp))
MaxVolumeCard(appState)
CatheterThresholdCard(appState)
AutoScanIntervalCard(appState)
WakeupAlarmCard()
Spacer(modifier = Modifier.height(4.dp))
}
// Continue button
Button(
onClick = { appState.personalizationComplete() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 32.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
@Composable
private fun MaxVolumeCard(appState: AppState) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.06f))
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.personalization_max_capacity), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text("${appState.piezoSettings.maxVolume}", fontSize = 48.sp,
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
Spacer(modifier = Modifier.width(6.dp))
Text("mL", fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 8.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
StepperButton(icon = Icons.Default.Remove) {
appState.piezoSettings = appState.piezoSettings.copy(
maxVolume = (appState.piezoSettings.maxVolume - 50).coerceIn(200, 800)
)
}
StepperButton(icon = Icons.Default.Add) {
appState.piezoSettings = appState.piezoSettings.copy(
maxVolume = (appState.piezoSettings.maxVolume + 50).coerceIn(200, 800)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = appState.piezoSettings.maxVolume.toFloat(),
onValueChange = {
appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.roundToInt())
},
valueRange = 200f..800f,
steps = 11,
modifier = Modifier.padding(horizontal = 24.dp),
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
}
@Composable
private fun CatheterThresholdCard(appState: AppState) {
val threshold = appState.bladderLevel.catheterizationThreshold
val maxVol = appState.piezoSettings.maxVolume
val pct = threshold * 10 // level 1~10 → 10~100%
val ml = (maxVol * pct / 100) // 자동 환산
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MlTeal.copy(alpha = 0.06f))
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.personalization_voiding_reminder_title), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
Text(stringResource(R.string.voiding_reminder_threshold_desc),
fontSize = 12.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 2.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text("$pct", fontSize = 48.sp,
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
Spacer(modifier = Modifier.width(4.dp))
Text("%", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MlPrimary,
modifier = Modifier.padding(bottom = 6.dp))
}
Text("≈ $ml mL (of $maxVol mL)",
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
StepperButton(icon = Icons.Default.Remove) {
appState.updateCatheterizationThreshold((threshold - 1).coerceIn(1, 10))
}
StepperButton(icon = Icons.Default.Add) {
appState.updateCatheterizationThreshold((threshold + 1).coerceIn(1, 10))
}
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = threshold.toFloat(),
onValueChange = {
appState.updateCatheterizationThreshold(it.roundToInt().coerceIn(1, 10))
},
valueRange = 1f..10f,
steps = 8,
modifier = Modifier.padding(horizontal = 24.dp),
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
}
/**
* 회의 #9: Auto Scan Interval 카드.
* 범위 0.5 ~ 10, 0.5 단위. sec/min 토글이 숫자 우측에 위치.
* - SEC 모드: ms = value * 1000 (예: 1.5sec = 1500ms)
* - MIN 모드: ms = value * 60_000 (예: 1.5min = 90000ms)
*/
@Composable
private fun AutoScanIntervalCard(appState: AppState) {
// 저장된 ms 값에서 현재 단위와 표시값 추론
var unitMode by remember {
mutableStateOf(if (appState.piezoSettings.autoScanIntervalMs < 30_000) "sec" else "min")
}
val currentMs = appState.piezoSettings.autoScanIntervalMs
val displayValue = if (unitMode == "sec") currentMs / 1000.0 else currentMs / 60_000.0
val valueClamped = displayValue.coerceIn(0.5, 10.0)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.04f))
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.piezo_settings_auto_scan_interval), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
Spacer(modifier = Modifier.height(12.dp))
// 숫자 + sec/min 토글 (우측)
Row(verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Center) {
Text("%.1f".format(valueClamped), fontSize = 48.sp,
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
Spacer(modifier = Modifier.width(10.dp))
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(bottom = 6.dp)
) {
UnitToggle(label = "sec", selected = unitMode == "sec") {
if (unitMode != "sec") {
// 단위 전환 시 같은 숫자 값 유지 (0.5 → 0.5sec)
val newMs = (valueClamped * 1000).toInt()
unitMode = "sec"
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
}
}
Spacer(modifier = Modifier.height(4.dp))
UnitToggle(label = "min", selected = unitMode == "min") {
if (unitMode != "min") {
val newMs = (valueClamped * 60_000).toInt()
unitMode = "min"
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// 스테퍼 ±0.5
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
StepperButton(icon = Icons.Default.Remove) {
val next = (valueClamped - 0.5).coerceIn(0.5, 10.0)
val newMs = if (unitMode == "sec") (next * 1000).toInt() else (next * 60_000).toInt()
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
}
StepperButton(icon = Icons.Default.Add) {
val next = (valueClamped + 0.5).coerceIn(0.5, 10.0)
val newMs = if (unitMode == "sec") (next * 1000).toInt() else (next * 60_000).toInt()
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 슬라이더 0.5 ~ 10, 0.5 step (20 ticks → steps=19)
Slider(
value = valueClamped.toFloat(),
onValueChange = {
val snapped = ((it * 2).roundToInt() / 2.0).coerceIn(0.5, 10.0)
val newMs = if (unitMode == "sec") (snapped * 1000).toInt() else (snapped * 60_000).toInt()
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
},
valueRange = 0.5f..10f,
steps = 19,
modifier = Modifier.padding(horizontal = 24.dp),
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
}
@Composable
private fun UnitToggle(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(if (selected) MlPrimary else Color.Gray.copy(alpha = 0.15f))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (selected) Color.White else Color.Black)
}
}
@Composable
private fun StepperButton(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MlPrimary)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(icon, null, tint = Color.White, modifier = Modifier.size(28.dp))
}
}
@androidx.compose.runtime.Composable
private fun WakeupAlarmCard() {
val context = LocalContext.current
var enabled by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(
com.medithings.vesiscan.services.WakeupAlarmPrefs.isEnabled(context)
)
}
Card(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFD32F2F).copy(alpha = 0.06f))
) {
androidx.compose.foundation.layout.Row(
modifier = androidx.compose.ui.Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
androidx.compose.foundation.layout.Column(
modifier = androidx.compose.ui.Modifier.weight(1f)
) {
androidx.compose.material3.Text(
stringResource(R.string.wakeup_alarm_title), fontSize = 24.sp, fontWeight = FontWeight.ExtraBold
)
androidx.compose.foundation.layout.Spacer(
modifier = androidx.compose.ui.Modifier.height(4.dp)
)
androidx.compose.material3.Text(
stringResource(R.string.wakeup_alarm_desc),
fontSize = 12.sp, color = MlSecondaryText
)
}
androidx.compose.material3.Switch(
checked = enabled,
onCheckedChange = {
enabled = it
com.medithings.vesiscan.services.WakeupAlarmPrefs.setEnabled(context, it)
},
colors = androidx.compose.material3.SwitchDefaults.colors(
checkedTrackColor = Color(0xFFD32F2F)
)
)
}
}
}
@@ -0,0 +1,135 @@
package com.medithings.vesiscan.ui.views.pin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.services.UserStorage
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
enum class PinMode { SETUP, ENTER }
@Composable
fun PinView(appState: AppState, mode: PinMode, context: android.content.Context) {
// DEMO: 자동 통과 — PIN 저장 없이 즉시 verified 처리
LaunchedEffect(Unit) { appState.pinVerified() }
// DEMO: UI 본체 비표시 — 빈 배경만 노출, 원본은 if(false) 안에 보존
Box(modifier = Modifier.fillMaxSize().background(MlBackground))
if (false) {
var enteredPin by remember { mutableStateOf("") }
var confirmPin by remember { mutableStateOf("") }
var isConfirming by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
val pinLength = 4
val currentPin = if (isConfirming) confirmPin else enteredPin
val title = when (mode) {
PinMode.SETUP -> if (isConfirming) "Re-enter PIN" else "Set PIN"
PinMode.ENTER -> "Enter PIN"
}
fun addDigit(digit: Int) {
errorMessage = ""
if (isConfirming) {
if (confirmPin.length >= pinLength) return
confirmPin += digit.toString()
if (confirmPin.length == pinLength) {
// Verify setup
if (confirmPin == enteredPin) {
appState.pinSet(enteredPin)
} else {
errorMessage = "PINs don't match. Try again."
enteredPin = ""
confirmPin = ""
isConfirming = false
}
}
} else {
if (enteredPin.length >= pinLength) return
enteredPin += digit.toString()
if (enteredPin.length == pinLength) {
when (mode) {
PinMode.SETUP -> isConfirming = true
PinMode.ENTER -> {
val stored = UserStorage.getInstance(context).pin
if (enteredPin == stored) {
appState.pinVerified()
} else {
errorMessage = "Incorrect PIN"
enteredPin = ""
}
}
}
}
}
}
fun deleteDigit() {
if (isConfirming) {
if (confirmPin.isNotEmpty()) confirmPin = confirmPin.dropLast(1)
} else {
if (enteredPin.isNotEmpty()) enteredPin = enteredPin.dropLast(1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
BladdyIdleRiveView(size = 100.dp)
Spacer(modifier = Modifier.height(32.dp))
Text(title, fontSize = 24.sp, fontWeight = FontWeight.Bold)
if (errorMessage.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(errorMessage, fontSize = 14.sp, color = Color.Red)
}
Spacer(modifier = Modifier.height(24.dp))
// Dot indicators
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
repeat(pinLength) { index ->
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(
if (index < currentPin.length) MlPrimary
else Color.Gray.copy(alpha = 0.3f)
)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Shuffled keypad
ShuffledKeypad(
onDigitTap = { addDigit(it) },
onDelete = { deleteDigit() }
)
Spacer(modifier = Modifier.weight(1f))
}
} // end if (false) — DEMO 본체 비표시
}
@@ -0,0 +1,85 @@
package com.medithings.vesiscan.ui.views.pin
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ui.theme.MlCardBackground
@Composable
fun ShuffledKeypad(
onDigitTap: (Int) -> Unit,
onDelete: () -> Unit
) {
val digits = (1..9).toList() + listOf(0)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.padding(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = false
) {
// Digits 1-9
items(9) { index ->
KeyButton(digit = digits[index], onClick = { onDigitTap(digits[index]) })
}
// Empty space
item { Spacer(modifier = Modifier.height(60.dp)) }
// Digit 0
item { KeyButton(digit = 0, onClick = { onDigitTap(0) }) }
// Delete
item {
Box(
modifier = Modifier
.height(60.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.Gray.copy(alpha = 0.1f))
.clickable(onClick = onDelete),
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.Backspace,
contentDescription = stringResource(R.string.pin_delete),
modifier = Modifier.size(24.dp)
)
}
}
}
}
@Composable
private fun KeyButton(digit: Int, onClick: () -> Unit) {
Box(
modifier = Modifier
.height(60.dp)
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.06f))
.clip(RoundedCornerShape(14.dp))
.background(MlCardBackground)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = "$digit",
fontSize = 28.sp,
fontWeight = FontWeight.Medium
)
}
}
@@ -0,0 +1,181 @@
package com.medithings.vesiscan.ui.views.registration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.Sex
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun RegistrationView(appState: AppState) {
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var ageText by remember { mutableStateOf("") }
var selectedSex by remember { mutableStateOf<Sex?>(null) }
var heightText by remember { mutableStateOf("") }
var weightText by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
BladdyIdleRiveView(size = 100.dp)
Spacer(modifier = Modifier.height(16.dp))
Text("User Registration", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Text("Enter your information", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp))
// Name
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("First Name", firstName, { firstName = it })
}
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Last Name", lastName, { lastName = it })
}
}
Spacer(modifier = Modifier.height(12.dp))
// Age + Sex
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
}
listOf(Sex.MALE to stringResource(R.string.sex_male), Sex.FEMALE to stringResource(R.string.sex_female)).forEach { (sex, label) ->
val selected = selectedSex == sex
OutlinedButton(
onClick = { selectedSex = sex },
modifier = Modifier.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp, if (selected) MlPrimary else MlInputBorder.copy(alpha = 0.3f)
)
) {
Text(label, color = if (selected) MlPrimary else MlSecondaryText, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Height + Weight
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Height", heightText, { heightText = it }, KeyboardType.Decimal, "cm")
}
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Weight", weightText, { weightText = it }, KeyboardType.Decimal, "kg")
}
}
Spacer(modifier = Modifier.height(24.dp))
}
// Complete button
Button(
onClick = {
appState.user = appState.user.copy(
firstName = firstName,
lastName = lastName,
age = ageText.toIntOrNull() ?: 0,
sex = selectedSex,
heightCm = heightText.toDoubleOrNull() ?: 0.0,
weightKg = weightText.toDoubleOrNull() ?: 0.0
)
appState.completeRegistration()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text("Complete", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
// Skip button
TextButton(
onClick = { appState.completeRegistration() },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text("Skip", fontSize = 14.sp, color = MlSecondaryText)
}
}
}
@Composable
private fun RegistrationTextField(
placeholder: String,
value: String,
onValueChange: (String) -> Unit,
keyboardType: KeyboardType = KeyboardType.Text,
suffix: String? = null
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text(placeholder) },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
suffix = suffix?.let { { Text(it, color = MlSecondaryText) } },
modifier = Modifier
.fillMaxWidth()
.background(MlInputBackground, RoundedCornerShape(12.dp))
.border(1.dp, MlInputBorder.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = MlInputBorder
),
singleLine = true
)
}
@@ -0,0 +1,320 @@
package com.medithings.vesiscan.ui.views.reminder
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.R
import com.medithings.vesiscan.services.ReminderService
import com.medithings.vesiscan.ui.components.LockableSlider
import com.medithings.vesiscan.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReminderSettingsView(onBack: () -> Unit) {
val context = LocalContext.current
val reminderService = remember { ReminderService.getInstance(context) }
var isEnabled by remember { mutableStateOf(reminderService.isEnabled) }
var intervalMinutes by remember { mutableIntStateOf(reminderService.intervalMinutes) }
var startHour by remember { mutableIntStateOf(reminderService.startHour) }
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
var useSound by remember { mutableStateOf(reminderService.useSound) }
var useVibration by remember { mutableStateOf(reminderService.useVibration) }
var usePopup by remember { mutableStateOf(reminderService.usePopup) }
var isFlexible by remember { mutableStateOf(reminderService.isFlexible) }
val intervalOptions = listOf(30, 60, 90, 120, 150, 180, 240)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.reminder_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// ON/OFF
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.reminder_enable), fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text(
if (isEnabled) stringResource(R.string.reminder_enabled_desc)
else stringResource(R.string.reminder_disabled_desc),
fontSize = 12.sp, color = MlSecondaryText
)
}
Switch(
checked = isEnabled,
onCheckedChange = {
isEnabled = it
reminderService.isEnabled = it
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
if (isEnabled) {
// Interval (list with checkmarks, matching iOS)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(R.string.reminder_interval_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
intervalOptions.forEach { minutes ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
intervalMinutes = minutes
reminderService.intervalMinutes = minutes
reminderService.reschedule()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
formatInterval(minutes, context),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
if (intervalMinutes == minutes) {
Text("✓", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary)
}
}
if (minutes != intervalOptions.last()) {
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f))
}
}
}
}
// Active hours
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(R.string.reminder_active_hours), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.reminder_start), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text(stringResource(R.string.reminder_hour_format, startHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
LockableSlider(
value = startHour.toFloat(),
onValueChange = {
startHour = it.toInt()
reminderService.startHour = startHour
if (endHour <= startHour) {
endHour = startHour + 1
reminderService.endHour = endHour
}
reminderService.reschedule()
},
valueRange = 5f..23f,
steps = 17,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.reminder_end), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text(stringResource(R.string.reminder_hour_format, endHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
LockableSlider(
value = endHour.toFloat(),
onValueChange = {
endHour = it.toInt()
reminderService.endHour = endHour
reminderService.reschedule()
},
valueRange = (startHour + 1).toFloat()..24f,
steps = (24 - startHour - 2).coerceAtLeast(0),
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
// Flexible (toggle, matching iOS)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.reminder_flexible_title), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.reminder_flexible_desc), fontSize = 12.sp, color = MlSecondaryText)
}
Switch(
checked = isFlexible,
onCheckedChange = {
isFlexible = it
reminderService.isFlexible = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
// Alert methods (sound, vibration, popup)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(R.string.reminder_method_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.reminder_sound), modifier = Modifier.weight(1f))
Switch(
checked = useSound,
onCheckedChange = {
useSound = it
reminderService.useSound = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.reminder_vibration), modifier = Modifier.weight(1f))
Switch(
checked = useVibration,
onCheckedChange = {
useVibration = it
reminderService.useVibration = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.reminder_popup), modifier = Modifier.weight(1f))
Switch(
checked = usePopup,
onCheckedChange = {
usePopup = it
reminderService.usePopup = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
}
// Summary
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.reminder_summary), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🕐", fontSize = 14.sp)
Text(stringResource(R.string.reminder_active_hours_summary, startHour, endHour), fontSize = 14.sp, color = MlPrimary)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🔁", fontSize = 14.sp)
Text(stringResource(R.string.reminder_interval_summary, formatInterval(intervalMinutes, context)), fontSize = 14.sp, color = Color(0xFFF5A623))
}
}
}
}
// Permission banner
if (!reminderService.permissionGranted) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("⚠️", fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.reminder_permission_needed), fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.reminder_permission_desc), fontSize = 12.sp, color = MlSecondaryText)
}
OutlinedButton(onClick = {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
}) {
Text(stringResource(R.string.settings))
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
private fun formatInterval(minutes: Int, context: android.content.Context): String {
val h = minutes / 60
val m = minutes % 60
return when {
h > 0 && m > 0 -> context.getString(R.string.format_hours_minutes, h, m)
h > 0 -> context.getString(R.string.format_hours, h)
else -> context.getString(R.string.format_minutes, m)
}
}
@@ -0,0 +1,126 @@
package com.medithings.vesiscan.ui.views.sensorselect
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.SensorMode
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun SensorSelectView(appState: AppState) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
BladdyIdleRiveView(size = 140.dp)
Spacer(modifier = Modifier.height(32.dp))
Text(stringResource(R.string.sensor_select_title), fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.sensor_select_subtitle), fontSize = 16.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier = Modifier.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SensorCard(
mode = SensorMode.NIRS,
title = "NIRS",
description = stringResource(R.string.sensor_nirs_desc),
gradientColors = listOf(Color(0xFF00DBA1), Color(0xFF008FFF)),
iconText = "NIRS",
onClick = { appState.selectSensor(SensorMode.NIRS) }
)
SensorCard(
mode = SensorMode.PIEZO,
title = "Piezo",
description = stringResource(R.string.sensor_piezo_desc),
gradientColors = listOf(Color(0xFF8F44FC), Color(0xFFF04A8F)),
iconText = "Piezo",
onClick = { appState.selectSensor(SensorMode.PIEZO) }
)
}
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
private fun SensorCard(
mode: SensorMode,
title: String,
description: String,
gradientColors: List<Color>,
iconText: String,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(8.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(16.dp))
.background(Brush.linearGradient(gradientColors)),
contentAlignment = Alignment.Center
) {
Text(iconText, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
Spacer(modifier = Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Text(description, fontSize = 14.sp, color = MlSecondaryText)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MlSecondaryText
)
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
*
* Stability check on V41 detection outputs. Complements SweepStabilizer:
* the stabilizer guards the INPUT, this guards the OUTPUT.
*
* Per-channel rules (relative to the previous live frame):
* • antIdx jump > maxIndexJump → unstable
* • postIdx jump > maxIndexJump → unstable
* • |chord change| / chord > maxChordRatio → unstable
*
* "unstable" is a flag for the diagnostic log, NOT a hard reject — V41's
* own anatomical gate already drops genuinely-bad detections. This layer
* just surfaces "this channel's reading just jumped, double-check".
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.walldetect.dto.ChannelResult
import kotlin.math.abs
class DetectionSanity(
private val maxIndexJump: Int = 8,
private val maxChordRatio: Double = 0.20,
private val channels: Int = 6,
) {
private val lastAnt = IntArray(channels) { -1 }
private val lastPost = IntArray(channels) { -1 }
data class ChannelReport(
val channel: Int,
val antJump: Int?,
val postJump: Int?,
val chordChangeRatio: Double?,
val unstable: Boolean,
)
fun reset() {
for (i in lastAnt.indices) { lastAnt[i] = -1; lastPost[i] = -1 }
}
fun check(perChannel: List<ChannelResult>): List<ChannelReport> {
val reports = mutableListOf<ChannelReport>()
for ((ch, cr) in perChannel.withIndex()) {
val ant = cr.antIdx
val post = cr.postIdx
val prevA = lastAnt.getOrElse(ch) { -1 }
val prevP = lastPost.getOrElse(ch) { -1 }
var antJump: Int? = null
var postJump: Int? = null
var chordRatio: Double? = null
var unstable = false
if (ant != null && prevA >= 0) {
val jump = abs(ant - prevA)
antJump = jump
if (jump > maxIndexJump) unstable = true
}
if (post != null && prevP >= 0) {
val jump = abs(post - prevP)
postJump = jump
if (jump > maxIndexJump) unstable = true
}
if (ant != null && post != null && prevA >= 0 && prevP >= 0) {
val curChord = (post - ant).toDouble()
val prevChord = (prevP - prevA).toDouble()
if (prevChord > 0.0) {
val r = abs(curChord - prevChord) / prevChord
chordRatio = r
if (r > maxChordRatio) unstable = true
}
}
reports += ChannelReport(
channel = ch,
antJump = antJump,
postJump = postJump,
chordChangeRatio = chordRatio,
unstable = unstable,
)
// Update state ONLY when we have a valid current detection,
// otherwise comparison against -1 sentinel resumes after gap.
if (ant != null) lastAnt[ch] = ant
if (post != null) lastPost[ch] = post
}
return reports
}
}
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
* All rights reserved.
*/
package com.medithings.vesiscan.walldetect
object DetectorIds {
const val V2 = "v2"
const val V4_1 = "v4_1"
}
@@ -0,0 +1,219 @@
/*
* Method D detector — port of method_d/detector.py.
*
* 단일 채널 raw → ant/post wall index + subsample refine + diagnostics.
*
* Stage 1 : SG heavy / light denoise (호출부에서 미리 줘도 됨)
* Stage 2 : span (heavy primary, light fallback)
* Stage 3 : ant/post wall peak (find_wall_peak_local)
* Stage 3.5: wall-lumen ratio gate + 최대 2회 recovery (outward stronger peak 탐색)
* Stage 4 : TGC-FP gate (raw post ratio ≥ min_post_raw_ratio)
* Stage 5 : subsample refine (parabolic for peak / d2 fit for shoulder)
*
* 인터페이스는 WallDetector 와 무관 — alignment.py 가 channels[i].urine_len 만
* 필요로 하므로 가벼운 단일-함수 API 로 유지.
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDPreprocessing
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDSpan
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.CType
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.Side
import kotlin.math.max
data class MethodDResult(
// ant / antRefined / urineLen 는 02bed02 의 cross-channel _apply_ant_tiebreak 가
// 사후에 갱신하므로 var. Python `r.ant = pick[0]`, `r.ant_refined = float(pick[0])`,
// `r.urine_len = int(r.post - pick[0] - 1)` 와 1:1 매칭.
var ant: Int,
// 2026-07-02: post, postRefined, postType 도 var — cross-channel `apply_neighbor_top_validate`
// 와 `apply_inward_post` (piezophantomtest 4830e7c / 5576a59) 가 사후 수정.
var post: Int,
var antRefined: Double,
var postRefined: Double,
val lowStart: Int,
val lowEnd: Int,
val lowAmp: Double,
val inwardWalkAnt: Int,
val inwardWalkPost: Int,
var urineLen: Int,
val antProm: Double,
val postProm: Double,
val antType: CType,
var postType: CType,
val sgHeavy: DoubleArray,
val sgLight: DoubleArray,
/** 초기 ant 후보 [(idx, score), ...] score 내림차순 — 02bed02 cross-channel tie-break 용.
* recovery 전 wall_select 결과를 그대로 노출 (Python: detector.py `ant_candidates`). */
val antCandidates: List<Pair<Int, Double>> = emptyList(),
)
object MethodDDetector {
private fun wallGateOk(antAmp: Double, postAmp: Double, lumenMin: Double, p: MethodDParams): Boolean {
val wallAmp = if (p.wallRatioUsePostOnly) postAmp else kotlin.math.min(antAmp, postAmp)
return wallAmp / max(lumenMin, 1.0) >= p.minWallLumenRatio
}
fun detect(
raw: DoubleArray,
denoisedHeavy: DoubleArray? = null,
denoisedLight: DoubleArray? = null,
otsuRatioOverride: Double? = null,
params: MethodDParams = MethodDParams.DEFAULT,
): MethodDResult? {
val sgHeavy = denoisedHeavy ?: MethodDPreprocessing.preprocessHeavy(raw, params)
val sgLight = denoisedLight ?: MethodDPreprocessing.preprocessLight(raw)
val ratio = otsuRatioOverride ?: params.otsuRatio
// Stage 2: span
val spanRes = MethodDSpan.extractLowEchoSpanWithFallback(sgHeavy, sgLight, ratio, params)
?: return null
val lowStart = spanRes.lowStart
val lowEnd = spanRes.lowEnd
val lowAmp = spanRes.lowAmp
val inwardAnt = spanRes.inwardWalkAnt
val inwardPost = spanRes.inwardWalkPost
// lumen_min — Stage 3.5 wall_lumen_ratio gate 에서 사용
var lumenMin = Double.POSITIVE_INFINITY
for (i in lowStart..lowEnd) if (sgHeavy[i] < lumenMin) lumenMin = sgHeavy[i]
if (!lumenMin.isFinite()) lumenMin = 1.0
// Stage 3: ant + post peak
val antRes = MethodDWallSelect.findWallPeakLocal(
sgLight, lowStart, lowEnd, Side.ANT, params,
dMaxOverride = params.antDMax, inwardWalk = inwardAnt,
)
val postRes = MethodDWallSelect.findWallPeakLocal(
sgLight, lowStart, lowEnd, Side.POST, params,
dMaxOverride = params.dMax, inwardWalk = inwardPost,
)
if (antRes.best == null || postRes.best == null) return null
// 02bed02: 초기 ant 후보 (recovery 전, score 내림차순) — cross-channel tie-break 용.
// Python: tuple((int(c[0]), float(c[4])) for c in sorted(ant_res['candidates'], key=-x[4]))
val antCandidates: List<Pair<Int, Double>> =
antRes.candidates.sortedByDescending { it.score }.map { it.idx to it.score }
var antIdx = antRes.best.idx
var antProm = antRes.best.prom
var antType = antRes.best.type
var postIdx = postRes.best.idx
var postProm = postRes.best.prom
var postType = postRes.best.type
if (postIdx <= antIdx) return null
var urineLen = postIdx - antIdx - 1
if (urineLen < params.minUrineLen) return null
var antAmp = sgLight[antIdx]
var postAmp = sgLight[postIdx]
// Stage 3.5: recovery (최대 2회, 각 side 1회씩)
repeat(2) {
if (wallGateOk(antAmp, postAmp, lumenMin, params)) return@repeat
val side: Side
val curIdx: Int
val curAmp: Double
val otherAmp: Double
val walkKw: Int
val extDMax: Int
if (postAmp <= antAmp) {
side = Side.POST
curIdx = postIdx
curAmp = postAmp
otherAmp = antAmp
walkKw = inwardPost
extDMax = if (params.recoveryExtendOutward)
max(params.dMax, params.postMaxIdx - lowEnd) else params.dMax
} else {
side = Side.ANT
curIdx = antIdx
curAmp = antAmp
otherAmp = postAmp
walkKw = inwardAnt
extDMax = if (params.recoveryExtendOutward)
max(params.antDMax, lowStart) else params.antDMax
}
val ext = MethodDWallSelect.findWallPeakLocal(
sgLight, lowStart, lowEnd, side, params,
dMaxOverride = extDMax, inwardWalk = walkKw,
)
// outward stronger peak 만 후보. closest 우선 (post=오름차순/ant=내림차순).
val sorted = if (side == Side.POST) ext.candidates.sortedBy { it.idx }
else ext.candidates.sortedByDescending { it.idx }
var passing: MethodDWallSelect.Candidate? = null // ratio 통과시키는 closest
var fallback: MethodDWallSelect.Candidate? = null // 통과 못해도 stronger 한 첫 후보
for (c in sorted) {
val outward = if (side == Side.POST) c.idx > curIdx else c.idx < curIdx
if (!outward) continue
val cAmp = sgLight[c.idx]
if (cAmp <= curAmp) continue
if (fallback == null) fallback = c
val minAmp = kotlin.math.min(cAmp, otherAmp)
if (minAmp / max(lumenMin, 1.0) >= params.minWallLumenRatio) {
passing = c
break
}
}
val recovered = passing ?: fallback ?: return null
if (side == Side.POST) {
postIdx = recovered.idx
postProm = recovered.prom
postType = recovered.type
postAmp = sgLight[postIdx]
} else {
antIdx = recovered.idx
antProm = recovered.prom
antType = recovered.type
antAmp = sgLight[antIdx]
}
}
if (!wallGateOk(antAmp, postAmp, lumenMin, params)) return null
// Stage 4: TGC-FP gate — raw post / raw lumen_min ≥ min_post_raw_ratio
val rawHeavy = MethodDPreprocessing.preprocessHeavy(raw, params)
val rawLight = MethodDPreprocessing.preprocessLight(raw)
var rawLumenMin = Double.POSITIVE_INFINITY
for (i in lowStart..lowEnd) if (rawHeavy[i] < rawLumenMin) rawLumenMin = rawHeavy[i]
if (!rawLumenMin.isFinite()) rawLumenMin = 1.0
val rawPostRatio = rawLight[postIdx] / max(rawLumenMin, 1.0)
if (rawPostRatio < params.minPostRawRatio) return null
urineLen = postIdx - antIdx - 1
if (urineLen < params.minUrineLen) return null
// Stage 5: subsample refine
val antRefined = if (antType == CType.PEAK)
MethodDWallSelect.refineParabolic(sgLight, antIdx, true)
else MethodDWallSelect.refineShoulder(sgLight, antIdx)
val postRefined = if (postType == CType.PEAK)
MethodDWallSelect.refineParabolic(sgLight, postIdx, true)
else MethodDWallSelect.refineShoulder(sgLight, postIdx)
return MethodDResult(
ant = antIdx,
post = postIdx,
antRefined = antRefined,
postRefined = postRefined,
lowStart = lowStart,
lowEnd = lowEnd,
lowAmp = lowAmp,
inwardWalkAnt = inwardAnt,
inwardWalkPost = inwardPost,
urineLen = urineLen,
antProm = antProm,
postProm = postProm,
antType = antType,
postType = postType,
sgHeavy = sgHeavy,
sgLight = sgLight,
antCandidates = antCandidates,
)
}
}
@@ -0,0 +1,434 @@
/*
* Method D multichannel runner + cross-channel corrections.
*
* 1:1 port of piezophantomtest `library/runners.py::method_d()` +
* `library/cross_channel.py` (2026-07-02 4830e7c + 5576a59)
*
* 파이프라인:
* 1) heavy = SG(7,3) + oscfar_median(win=9, iter=4) ← 02bed02 win 5→9
* light = SG(7,3)
* 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels
* 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll
* 4) MethodDDetector.detect()
* 5) cross-channel 후처리 (순서 고정):
* ① applyAntTiebreak — 전벽 교차보정 (4830e7c 신 버전)
* ② applyNeighborTopValidate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
* ③ applyInwardPost — 최상단 widest post 과확장 교정 (신규 5576a59)
*
* 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 →
* MethodDResult.urineLen 또는 null 의 List 로 충분.
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.managers.AlignmentConstants
import com.medithings.vesiscan.managers.PiezoHW
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDPreprocessing
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.CType
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.Side
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min as kmin
object MethodDRunner {
// ── cross-channel tuning constants (cross_channel.py:30-45) ────────────────
/** 깊은쪽 교정 시 "합의에 이 이상 더 가까운 후보" 요구 (곡률 보존). */
private const val ANT_OUTLIER_IMPROVE_MARGIN_MM = 5.0
/** 교정 대상이 합의에서 이 이상 떨어지면 보정 보류 (얕은쪽은 제외). */
private const val ANT_CONSENSUS_CLOSE_TOL_MM = 6.0
/** dominant-pick 보호: raw 픽 score >= 이 배수 × 차순위 → 합의 교정에서 보존. */
private const val ANT_DOMINANT_RATIO = 2.0
/** neighbor_top_validate: nb-1→nb slope 가 이 이하면 "비증가 추세". */
private const val NTV_SLOPE_EPS = 2.0
/** neighbor_top_validate: top 이 예측보다 이만큼 깊으면 위반 (FP). */
private const val NTV_DEV_DELTA = 6.0
/** cross-channel 3개 스위치 (Python 과 동일 default). */
private const val NEIGHBOR_TOP_VALIDATE = true
private const val INWARD_POST_SEARCH = true
/**
* 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
*/
fun detectMultichannel(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
): List<MethodDResult?> {
val angles = beamAnglesDeg ?: PiezoHW.degreeAll
// 1) per-channel SG denoise (heavy + light)
val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) }
val lightList = signals.map { MethodDPreprocessing.preprocessLight(it) }
// 2) TGC per channel (Python apply_tgc_pipeline default center_ch=None → all)
val heavyTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(heavyList) else heavyList
val lightTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(lightList) else lightList
// 3+4) per-channel cos-angle adjusted otsu_ratio + detect
val results = MutableList(signals.size) { ch ->
val angleDeg = if (ch < angles.size) angles[ch] else 0.0
val chRatio = params.otsuRatio * cos(Math.toRadians(angleDeg))
MethodDDetector.detect(
raw = signals[ch],
denoisedHeavy = heavyTgc[ch],
denoisedLight = lightTgc[ch],
otsuRatioOverride = chRatio,
params = params,
)
}
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정)
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params)
return results
}
/**
* Cross-channel 3-stage 후처리 (Python `apply_cross_channel`).
* ① ant_tiebreak → ② neighbor_top_validate → ③ inward_post
*/
private fun applyCrossChannel(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>,
params: MethodDParams,
) {
applyAntTiebreak(results, angles)
if (NEIGHBOR_TOP_VALIDATE) {
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params)
}
if (INWARD_POST_SEARCH) {
applyInwardPost(results, angles)
}
}
// ─────────────────────────────────────────────────────────────────────────
// ① ant_tiebreak — 전벽 교차보정 (2026-07-02: 4830e7c 신 버전)
// ─────────────────────────────────────────────────────────────────────────
/**
* 이웃 center 채널 전벽 z 합의 (LOO median) 기준 전벽 보정 (in-place).
*
* 단일 임계 `devTol=8mm` + 방향 분기 (구 nf_tol 8 / outlier_tol 9 통합):
* [최우선] dominant-pick 보호: raw 픽 score >= 2.0 × 차순위 → 보존
* (1) 동률(top1/top2 < tieRatio): 동률 band 중 합의 최근접 선택
* (2) 얕은쪽 (합의 - dev_tol 미만 = near-field 아티팩트): 무조건 재선택
* (3) 깊은쪽 (합의 + dev_tol 초과): improve_margin 이상 더 가까운 후보 있을 때만
*
* 얕은쪽 제외 후 합의 최근접 pick. 합의-근접 가드 (얕은쪽 교정은 항상, 그 외엔 pick 이
* 합의 근접일 때만).
*/
private fun applyAntTiebreak(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
tieRatio: Double = AlignmentConstants.ANT_TIEBREAK_RATIO,
devTol: Double = AlignmentConstants.ANT_NEARFIELD_TOL_MM,
improveMargin: Double = ANT_OUTLIER_IMPROVE_MARGIN_MM,
) {
if (results.isEmpty()) return
// 현재 center 채널 ant z 좌표 (LOO median 의 base)
val baseZ = HashMap<Int, Double>()
for (i in AlignmentConstants.CENTER_CH) {
if (i < results.size) {
val r = results[i] ?: continue
baseZ[i] = zOf(angles, i, r.ant)
}
}
for (i in AlignmentConstants.CENTER_CH) {
if (i >= results.size) continue
val r = results[i] ?: continue
val cands = r.antCandidates
if (cands.size < 2) continue
val cons = AlignmentConstants.CENTER_CH
.filter { it != i }
.mapNotNull { baseZ[it] }
if (cons.isEmpty()) continue
val cz = median(cons)
// dominant-pick 보호 (최우선)
if (cands[0].second >= ANT_DOMINANT_RATIO * cands[1].second
&& r.ant == cands[0].first) {
continue
}
val cur = zOf(angles, i, r.ant)
val curDev = abs(cur - cz)
val bestDev = cands.minOf { abs(zOf(angles, i, it.first) - cz) }
val isTie = cands[0].second / maxOf(cands[1].second, 1e-9) < tieRatio
val isShallow = cur < cz - devTol
val isDeep = cur > cz + devTol
// 진입 + 후보 pool (방향 분기)
val pool: List<Pair<Int, Double>> = when {
isTie -> {
val thr = cands[0].second / tieRatio
cands.filter { it.second >= thr }
}
isShallow -> cands.toList() // 얕은 아티팩트 → 무조건
isDeep && bestDev < curDev - improveMargin -> cands.toList()
else -> continue // 압승 & 정상 범위 → 유지
}
// 얕은(아티팩트) 후보 제외, 합의 최근접 선택
val eligPrelim = pool.filter { zOf(angles, i, it.first) >= cz - devTol }
val elig = if (eligPrelim.isEmpty()) pool else eligPrelim
val pick = elig.minBy { abs(zOf(angles, i, it.first) - cz) }
// 합의-근접 가드: 얕은쪽 교정은 항상, 그 외엔 pick 이 합의 근접일 때만
val pickDev = abs(zOf(angles, i, pick.first) - cz)
if (pickDev > ANT_CONSENSUS_CLOSE_TOL_MM && !isShallow) continue
if (pick.first != r.ant && r.post > pick.first) {
r.ant = pick.first
r.antRefined = pick.first.toDouble()
r.urineLen = r.post - pick.first - 1
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// ② neighbor_top_validate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
// ─────────────────────────────────────────────────────────────────────────
/**
* 이웃 후위벽 trend 로 최상단 채널 검증 (in-place).
*
* Rule B (trend-FP): 검출 top 후위벽 z 를 아래 두 채널 기울기로 예측. 비증가추세인데
* top 이 예측보다 깊게(>6mm) jump → nb urine span 시드 재탐색 → drop/교체.
* Rule A (FN): 미검출 top 을 바로 아래 검출 채널 span 시드로 재탐색 복원(gate 통과 시).
*/
private fun applyNeighborTopValidate(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>,
params: MethodDParams,
) {
val n = kmin(4, results.size)
val origNone = (0 until n).filter { results[it] == null }.toHashSet()
val det = (0 until n).filter { results[it] != null }.sorted()
// ---- Rule B ----
if (det.size >= 3) {
val top = det[0]; val nb1 = det[1]; val nb2 = det[2]
val rTop = results[top]!!
val rNb1 = results[nb1]!!
val rNb2 = results[nb2]!!
val zTop = zOf(angles, top, rTop.post)
val zNb1 = zOf(angles, nb1, rNb1.post)
val zNb2 = zOf(angles, nb2, rNb2.post)
val slope = zNb1 - zNb2
val pred = zNb1 + slope
if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) {
val rr = researchPostInSpan(
light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params
)
if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) {
results[top] = null
} else {
rTop.post = rr.post
rTop.postRefined = rr.post.toDouble()
rTop.postType = rr.postType
rTop.urineLen = rTop.post - rTop.ant - 1
}
}
}
// ---- Rule A ----
val first = (0 until n).firstOrNull { results[it] != null }
if (first != null && first >= 1 && (first - 1) in origNone) {
val top = first - 1
val nb = first
val rNb = results[nb]!!
val ls = rNb.lowStart
val le = rNb.lowEnd
val rr = researchPostInSpan(light[top], heavy[top], raw6ch[top], ls, le, params)
if (rr != null) {
val lowAmp = minInRange(heavy[top], ls, le)
results[top] = MethodDResult(
ant = rr.ant,
post = rr.post,
antRefined = rr.ant.toDouble(),
postRefined = rr.post.toDouble(),
lowStart = ls,
lowEnd = le,
lowAmp = lowAmp,
inwardWalkAnt = 0,
inwardWalkPost = 0,
urineLen = rr.post - rr.ant - 1,
antProm = 0.0,
postProm = 0.0,
antType = rr.antType,
postType = rr.postType,
sgHeavy = heavy[top],
sgLight = light[top],
antCandidates = emptyList(),
)
}
}
}
/** _research_post_in_span 결과. */
private data class ResearchResult(val ant: Int, val post: Int, val antType: CType, val postType: CType)
/**
* seed span(ls, le) 에서 ant / post 재탐색 + wall/raw gate. 실패 시 null.
* detector 와 동일한 gate (_wall_gate_ok + min_post_raw_ratio) 로 재검증.
*/
private fun researchPostInSpan(
lt: DoubleArray, hv: DoubleArray, raw: DoubleArray,
ls: Int, le: Int, params: MethodDParams,
): ResearchResult? {
if (le <= ls) return null
val antRes = MethodDWallSelect.findWallPeakLocal(
lt, ls, le, Side.ANT, params,
dMaxOverride = params.antDMax, inwardWalk = 0,
)
val postRes = MethodDWallSelect.findWallPeakLocal(
lt, ls, le, Side.POST, params,
dMaxOverride = params.dMax, inwardWalk = 0,
)
val antBest = antRes.best ?: return null
val postBest = postRes.best ?: return null
val ai = antBest.idx
val pi = postBest.idx
if (pi <= ai || (pi - ai - 1) < params.minUrineLen) return null
// wall gate
val lumenMin = minInRange(hv, ls, le)
val wallAmp = if (params.wallRatioUsePostOnly) lt[pi]
else kmin(lt[ai], lt[pi])
if (wallAmp / maxOf(lumenMin, 1.0) < params.minWallLumenRatio) return null
// raw ratio gate (SG-only 재계산)
val rawHv = MethodDPreprocessing.preprocessHeavy(raw, params)
val rawLt = MethodDPreprocessing.preprocessLight(raw)
val rawLumen = minInRange(rawHv, ls, le)
if (rawLt[pi] / maxOf(rawLumen, 1.0) < params.minPostRawRatio) return null
return ResearchResult(ai, pi, antBest.type, postBest.type)
}
// ─────────────────────────────────────────────────────────────────────────
// ③ inward_post — 최상단 widest 후벽 과확장 교정 (신규 5576a59)
// ─────────────────────────────────────────────────────────────────────────
/**
* 최상단 검출 center 채널이 widest (적도가 fan 위) 면, 그 채널 후벽을
* [post-win..post] 범위의 더 inward 한 후보(urine-wall ratio ≥ gate)로 교체.
* (a) 분리형 peak, (b) shoulder.
*/
private fun applyInwardPost(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
win: Int = 16,
ratioGate: Double = 1.15,
frac: Double = 0.75,
) {
val det = (0 until kmin(4, results.size))
.filter { results[it] != null }
.sorted()
if (det.size < 2) return
fun dOf(i: Int): Double {
val r = results[i]!!
val ang = if (i < angles.size) angles[i] else 0.0
return (r.post - r.ant) * cos(Math.toRadians(ang))
}
val s = det.associateWith { dOf(it) * dOf(it) }
val top = det[0]
val topS = s[top]!!
val maxS = s.values.max()
val secondS = s[det[1]]!!
if (topS < maxS || topS < secondS) return
val r = results[top]!!
val p = r.post
val ls = r.lowStart
val le = r.lowEnd
val lt = r.sgLight
val hv = r.sgHeavy
if (le <= ls || p - 2 <= ls + 3) return
val base = minInRange(hv, ls, le)
if (base <= 0 || base.isNaN() || base.isInfinite()) return
val postRatio = if (p in lt.indices) lt[p] / base else 0.0
val lo = maxOf(ls + 3, p - win)
val cands = mutableListOf<Int>()
for (i in lo until p - 2) {
if (i !in lt.indices) continue
if (lt[i] / base < ratioGate) continue
// (a) 분리형 peak: light local max, i~post 사이 valley, 깊은 peak 강도 frac 이상
val isPeak = i - 1 in lt.indices && i + 1 in lt.indices
&& lt[i] >= lt[i - 1] && lt[i] > lt[i + 1]
if (isPeak) {
var minLtIP = lt[i]
for (j in i..p) if (j in lt.indices && lt[j] < minLtIP) minLtIP = lt[j]
if (minLtIP < lt[i] * 0.97 && lt[i] >= postRatio * base * frac) {
cands.add(i)
continue
}
}
// (b) shoulder: 3-샘플 plateau + 앞쪽 상승 + 뒤에 더 깊은 peak
if (i + 3 <= p && i - 2 in lt.indices) {
var maxWin = lt[i]; var minWin = lt[i]
for (j in i until i + 3) {
if (j in lt.indices) {
if (lt[j] > maxWin) maxWin = lt[j]
if (lt[j] < minWin) minWin = lt[j]
}
}
val flat = (maxWin - minWin) < 0.02 * lt[i]
val risingBefore = lt[i] > lt[i - 2] + 0.03 * base
var maxAfter = lt[i + 3]
for (j in i + 3..p) if (j in lt.indices && lt[j] > maxAfter) maxAfter = lt[j]
val higherAfter = maxAfter > lt[i] * 1.03
if (flat && risingBefore && higherAfter) {
cands.add(i)
}
}
}
if (cands.isEmpty()) return
val newPost = cands.min()
if (newPost < p - 3) {
r.post = newPost
r.postRefined = newPost.toDouble()
r.urineLen = r.post - r.ant - 1
}
}
// ─────────────────────────────────────────────────────────────────────────
// 공통 helpers
// ─────────────────────────────────────────────────────────────────────────
/** z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i) — sample_to_ap_depth. */
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double {
val ang = if (ch < angles.size) angles[ch] else 0.0
return (PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(ang))
}
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
private fun median(xs: List<Double>): Double {
if (xs.isEmpty()) return 0.0
val sorted = xs.sorted()
val n = sorted.size
return if (n % 2 == 1) sorted[n / 2]
else (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
}
/** arr[from..to] 최소 (numpy min 매칭). 유효 범위 밖은 skip. 없으면 0.0. */
private fun minInRange(arr: DoubleArray, from: Int, to: Int): Double {
var m = Double.POSITIVE_INFINITY
val lo = maxOf(0, from)
val hi = kmin(arr.size - 1, to)
for (i in lo..hi) if (arr[i] < m) m = arr[i]
return if (m.isFinite()) m else 0.0
}
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
*
* Per-channel sweep stabilization layer.
*
* ── Why ─────────────────────────────────────────────────────
* Firmware is observed to occasionally emit ONE channel's ADC
* stream at ~88-90 % of the normal amplitude (TGC gain register
* glitch / Vref sag / ADC trigger miss; observed 25 % of frames
* in the 2026-04-30 11:47 capture). Each anomaly mis-locates
* ant/post for that channel and corrupts the sphere fit.
*
* ── How ─────────────────────────────────────────────────────
* 1) Maintain a ring buffer of the last `historySize` raw ADC
* sweeps (per channel × 100 samples).
* 2) For each new sweep, per channel:
* head_mean(now) / median(head_mean over history) → ratio
* if |ratio − 1| > tolerance ⇒ ANOMALY
* replace channel samples with element-wise median
* across the history → V41 sees a robust value
* else ⇒ pass through
* 3) Expose the anomalous channel list so the caller (VM) can
* surface it in the mbb diagnostic log.
*
* ── What this does NOT do ───────────────────────────────────
* • Does NOT re-scale the bad channel (no artificial correction).
* • Does NOT touch detection logic — V41 stays bit-identical.
* • Does NOT cross-talk between channels.
*
* The contract: "I either pass the live sample through unchanged,
* or replace it with the temporal median of recent good values."
*/
package com.medithings.vesiscan.walldetect
import kotlin.math.abs
class SweepStabilizer(
private val historySize: Int = 3,
/** Allowed band for current head-mean / running median. Outside → anomaly. */
private val tolerance: Double = 0.08, // ±8 %
/** Number of leading samples used to estimate per-channel "amplitude". */
private val headSize: Int = 8,
private val channels: Int = 6,
) {
/** Snapshot of one full sweep's ADC matrix (channel × samples). */
private val history = ArrayDeque<Array<IntArray>>()
data class Anomaly(
val channel: Int,
/** current head-mean ÷ running-median head-mean. ~1.0 is normal. */
val ratio: Double,
/** true if temporal median was substituted; false if first-frame
* (no history yet → cannot replace, passed through unchanged). */
val replaced: Boolean,
)
data class Result(
val adc: List<List<Int>>,
val anomalies: List<Anomaly>,
)
/** Forget all history — call on disconnect / probe re-positioning so the
* stabilizer doesn't compare new captures against stale data. */
fun reset() = history.clear()
fun submit(adc: List<List<Int>>): Result {
require(adc.size >= channels) { "expected $channels channels, got ${adc.size}" }
// Materialise current sweep into IntArrays for efficient median work.
val current = Array(channels) { ch ->
val src = adc[ch]
IntArray(src.size) { src[it] }
}
// No history yet → pass through. Seed the buffer.
if (history.isEmpty()) {
history.addLast(current.deepCopy())
return Result(adc, emptyList())
}
val anomalies = mutableListOf<Anomaly>()
val outChannels = Array(channels) { ch ->
val curHead = headMean(current[ch])
val histHeads = history.map { headMean(it[ch]) }.sorted()
val medHead = histHeads[histHeads.size / 2]
if (medHead <= 0.0) {
current[ch] // degenerate baseline; can't judge
} else {
val ratio = curHead / medHead
if (abs(ratio - 1.0) > tolerance) {
// Anomaly — replace with element-wise median across history
// (NOT including the suspect current frame).
anomalies += Anomaly(channel = ch, ratio = ratio, replaced = true)
elementWiseMedian(history.map { it[ch] }, current[ch].size)
} else {
current[ch]
}
}
}
// Push into history. We push the STABILIZED version so a single
// anomaly can't pollute the median for the next 3 frames.
history.addLast(outChannels.deepCopy())
while (history.size > historySize) history.removeFirst()
return Result(
adc = outChannels.map { it.toList() },
anomalies = anomalies,
)
}
private fun headMean(samples: IntArray): Double {
val n = minOf(headSize, samples.size)
if (n == 0) return 0.0
var sum = 0L
for (i in 0 until n) sum += samples[i]
return sum.toDouble() / n
}
/** Element-wise median across the given snapshots, all assumed length `len`. */
private fun elementWiseMedian(snapshots: List<IntArray>, len: Int): IntArray {
if (snapshots.isEmpty()) return IntArray(len)
val out = IntArray(len)
val tmp = IntArray(snapshots.size)
for (i in 0 until len) {
for (j in snapshots.indices) tmp[j] = snapshots[j][i]
tmp.sort()
out[i] = tmp[tmp.size / 2]
}
return out
}
private fun Array<IntArray>.deepCopy(): Array<IntArray> =
Array(this.size) { this[it].copyOf() }
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* V2Detector v2.2.0 — aligned to py2/for_app_share/low_echo_detection_method_b.
* Replaces both the JS-V2 lumen-first (PR-2) and the plateau-based v2.1.0
* (PR-11) with the actual SSOT mirrored by JS detect_lumen_first.
*
* Pipeline (method_b):
* 1. SG denoise (window=5, polyorder=2)
* 2. Adaptive low-echo threshold via 1-D Otsu on sg[0..POST_MAX_IDX]
* 3. low_mask = sg ≤ T → contiguous spans (≥ LOW_MIN_LEN=3) → merge gaps
* 4. peak_min = max(low_mean + 30, T) — both criteria required
* 5. wall selection by prominence × edge-distance decay (EDGE_DIST_DECAY=0.12)
* with valley-walk stop rise=50
* 6. post > POST_MAX_IDX → back-half retry
* 7. urine_len ≥ 3 required
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.walldetect.algo.DetectLumenFirst
import com.medithings.vesiscan.walldetect.algo.Geometry
import com.medithings.vesiscan.walldetect.algo.SubsampleRefine
import com.medithings.vesiscan.walldetect.dto.ChannelResult
import com.medithings.vesiscan.walldetect.dto.DetectionResult
import com.medithings.vesiscan.walldetect.dto.DetectionSummary
import com.medithings.vesiscan.walldetect.dto.SweepInput
import kotlin.math.sqrt
class V2Detector : WallDetector {
override val algorithmId: String = DetectorIds.V2
/** v2.0.0 (JS lumen-first) → v2.1.0 (plateau, scrapped) → v2.2.0 (py2 method_b). */
override val algorithmVersion: String = "v2.2.0"
override fun detect(input: SweepInput): DetectionResult {
val t0 = System.nanoTime()
val perCh: List<ChannelResult> = (0..5).map { ch -> detectChannel(ch, input) }
val chordsMm: List<Float> = perCh.mapNotNull { it.chordMm }
val (chordMmMean, chordMmStd) = if (chordsMm.isEmpty()) null to null
else {
val mu = chordsMm.average()
val v = chordsMm.map { (it - mu) * (it - mu) }.sum() / chordsMm.size
mu.toFloat() to sqrt(v).toFloat()
}
return DetectionResult(
requestId = input.requestId,
timestampMs = input.timestampMs,
algorithm = algorithmId,
algorithmVersion = algorithmVersion,
processingMs = (System.nanoTime() - t0) / 1_000_000.0,
perChannel = perCh,
summary = DetectionSummary(
matchCount = perCh.count { it.antIdx != null && it.postIdx != null },
chordMmMean = chordMmMean,
chordMmStd = chordMmStd,
),
)
}
private fun detectChannel(ch: Int, input: SweepInput): ChannelResult {
val rawList = input.adc[ch]
val rawArr = IntArray(rawList.size) { rawList[it] }
// py2 method_b: Mode.Otsu — adaptive threshold per channel
val det = DetectLumenFirst.detect(rawArr, DetectLumenFirst.Mode.Otsu)
val antRefined = det.ant?.let {
SubsampleRefine.refineParabolic(det.sg, it, SubsampleRefine.Kind.PEAK)
}
val postRefined = det.post?.let {
SubsampleRefine.refineParabolic(det.sg, it, SubsampleRefine.Kind.PEAK)
}
val antMm = antRefined?.let { Geometry.sampleToMm(it).toFloat() }
?: det.ant?.let { Geometry.sampleToMm(it.toDouble()).toFloat() }
val postMm = postRefined?.let { Geometry.sampleToMm(it).toFloat() }
?: det.post?.let { Geometry.sampleToMm(it.toDouble()).toFloat() }
val chordMm = if (antMm != null && postMm != null) postMm - antMm else null
// method_b uses an adaptive scalar T (Otsu); fill the threshold trace
// with that constant so the chart still renders a horizontal reference.
val thrT = det.adaptiveT.toFloat()
val thrTrace = List(det.sg.size) { thrT }
return ChannelResult(
ch = ch,
sg = det.sg.map { it.toFloat() },
threshold = thrTrace,
antIdx = det.ant,
postIdx = det.post,
antRefined = antRefined?.toFloat(),
postRefined = postRefined?.toFloat(),
antMm = antMm,
postMm = postMm,
lumenStart = det.lowStart,
lumenEnd = det.lowEnd,
chordMm = chordMm,
clipping = null,
v41Diag = null,
)
}
}
@@ -0,0 +1,409 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
*
* ═════════════════════════════════════════════════════════════════════════
* V41Detector — Charles V4.1 algorithm (PUBLIC API for team members)
* ═════════════════════════════════════════════════════════════════════════
*
* ▶ USAGE (one-liner)
* ──────────────────
* val result = V41Detector().detect(sweepInput)
*
* ▶ TYPICAL FLOW
* ─────────────
* val mbb = measureCommands.measureFull().getOrThrow() // BLE mbb command
* val sweep = mbb.toSweepInput(probe = SampleSweeps.defaultProbe())
* val result = V41Detector().detect(sweep)
* val bv = result.summary.bvDispatch?.bvMl // mL
* val gated = result.perChannel.count { it.v41Diag?.gated == true }
*
* ▶ INPUT · `dto.SweepInput`
* ──────
* adc : List<List<Int>> // 6 × 100 ADC matrix
* probe : ProbeProfileDto // metadata only — runtime uses WdProbe
* gainDb : Int // V4.1 ignores; V2 fixed-thr scaling
*
* ▶ OUTPUT · `dto.DetectionResult`
* ───────
* algorithm = "v4_1"
* algorithmVersion = "v4.1.1"
* processingMs : Double
* perChannel[6] : ChannelResult
* ├─ sg[100] sg-smoothed envelope
* ├─ threshold[100] OS-CFAR per-sample T
* ├─ antIdx / postIdx wall indices (null when gated out)
* ├─ antRefined / postRefined parabolic sub-sample refine
* ├─ antMm / postMm converted via Geometry.sampleToMm
* ├─ lumenStart / End low-echo span on sg
* ├─ chordMm postMm − antMm
* ├─ clipping ADC saturation flag
* └─ v41Diag ★ V4.1-only diagnostics (see V41Diagnostics.kt)
* summary : DetectionSummary
* ├─ matchCount / chordMmMean / chordMmStd
* ├─ tier / scoreMean / scoreMin / scoreMax / gatedCount
* ├─ v41Sphere Kasa→LM (≥ 4 gated channels)
* └─ bvDispatch ★ multi-method BV (see BvEstimation.kt)
*
* ▶ ALGORITHM PIPELINE (per channel unless marked sweep)
* ─────────────────────────────────────────────────────
* 1.a Denoising.sgSmooth — Savitzky-Golay (5,2)
* 1.b WaveletDenoise.denoise — Sun 2024 db4 3-level adaptive
* 1.c WaveletDenoise.diagnose — energy ratio L1/L2/L3
* 1.d WaveletDenoise.dwt — coefficients (scalogram)
* 2.a ThresholdOsCfar.perSample — Rohling 1983 (k=0.7, scale=1.05)
* 2.b Clipping.isClipped — ADC saturation flag
* 2.c PeakDetection.findPeaks1D — strict local maxima
* 2.d SpanUtils — boolean span merging
* 3.a DetectLumenFirst (Mode.Adaptive) — lumen → wall-pair selection
* 3.b WallSelect.selectWallByProminence— prominence × edge-distance decay
* 3.c SubsampleRefine.refineParabolic — Cespedes 1995
* 4.a ContrastAux.farPostContrast — far-post recovery (s_contrast)
* 4.b BModeScore.score — composite [0,1] (RAW envelope!)
* 4.c AnatomicalGate.apply — PHANTOM_530 [22,60] mm depth
* 6.b BvFromSphere.bvFromChord — single-channel chord-as-D BV
* ─── sweep level ───
* 5.a Geometry.buildWallPointsVisual — visual coords (≤ 12 wall pts)
* 5.d SphereFit2Step.fit2Step — Kasa → LM (auto sphere/circle)
* 6.a BvFromSphere.bvFromSphere — 4/3 π R³ / 1000
* ★ BvEstimation.estimate — multi-method BV dispatch
*
* ▶ KEY CONFIG (`core.WdConfig`)
* ────────────
* DPS_DEFAULT = 1.9309 mm/sample (amode_simulator V4 SSOT)
* DELAY_MM_DEFAULT = 6.85 mm
* LOW_ECHO_AMP = 1250 ADC (V2 fixed; V4.1 uses Otsu)
* PHANTOM_530 = R 50.20 mm, BV 530 mL, ant 32 mm
* WdProbe = v2 (30° device, Snell-refracted angles) by default
*
* ▶ DEPENDENCIES (do not remove)
* ─────────────
* data/protocol/ — packet build/parse + CRC16
* data/command/MeasureCommands.measureFull() → mbb call
* domain/walldetect/algo/ — 17+ algorithm modules (see Pipeline above)
* domain/walldetect/dto/ — kotlinx.serialization DTOs
* data/ble/BleConnector — BLE GATT connection
*
* ▶ NOTES for new team members
* ───────────────────────────
* • V4.1 == "Adaptive" (OS-CFAR) mode. V2 == "Otsu" mode (py2 method_b).
* • Both detectors implement `WallDetector` so the same SweepInput drives both
* in parallel (drift-zero pairing).
* • BV is now from `BvEstimation.estimate()` (multi-method dispatch);
* sphere fit BV is kept as a cross-check value inside that result.
* • `gainDb` only affects V2; V4.1 ignores it on purpose.
* • All algorithms are pure functions — no side effects beyond logging.
* • For team docs see `docs/CHARLES-V41-API.md`,
* `docs/BV-CALCULATION-DESIGN.md`.
* ═════════════════════════════════════════════════════════════════════════
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.walldetect.algo.AnatomicalGate
import com.medithings.vesiscan.walldetect.algo.BModeScore
import com.medithings.vesiscan.walldetect.algo.BvEstimation
import com.medithings.vesiscan.walldetect.algo.BvFromSphere
import com.medithings.vesiscan.walldetect.algo.ChordConsensus
import com.medithings.vesiscan.walldetect.algo.Clipping
import com.medithings.vesiscan.walldetect.algo.ContrastAux
import com.medithings.vesiscan.walldetect.algo.DetectLumenFirst
import com.medithings.vesiscan.walldetect.algo.Geometry
import com.medithings.vesiscan.walldetect.algo.ImpulseReject
import com.medithings.vesiscan.walldetect.algo.PeakDetection
import com.medithings.vesiscan.walldetect.algo.SphereFit2Step
import com.medithings.vesiscan.walldetect.algo.SubsampleRefine
import com.medithings.vesiscan.walldetect.algo.WaveletDenoise
import com.medithings.vesiscan.walldetect.core.WdConfig
import com.medithings.vesiscan.walldetect.dto.ChannelResult
import com.medithings.vesiscan.walldetect.dto.DetectionResult
import com.medithings.vesiscan.walldetect.dto.DetectionSummary
import com.medithings.vesiscan.walldetect.dto.IntRange2
import com.medithings.vesiscan.walldetect.dto.ScoreSubscores
import com.medithings.vesiscan.walldetect.dto.SweepInput
import com.medithings.vesiscan.walldetect.dto.V41Diagnostics
import com.medithings.vesiscan.walldetect.dto.V41SphereFit
import com.medithings.vesiscan.walldetect.dto.Vec3
import com.medithings.vesiscan.walldetect.dto.WaveletCoefs
import com.medithings.vesiscan.walldetect.dto.WaveletEnergyRatio
import kotlin.math.sqrt
class V41Detector(
private val gatePreset: AnatomicalGate.Preset = AnatomicalGate.PHANTOM_530,
private val useLrTilt: Boolean = WdConfig.USE_LR_TILT
) : WallDetector {
override val algorithmId: String = DetectorIds.V4_1
// v4.1.1 (2026-04-29): three-regime validation upgrade.
// • Two-pass Hampel impulse rejection (ImpulseReject.detectWithLumenClean).
// • V41_PARAMS in DetectLumenFirst (mergeGapMax=5, gapPeakMargin=50,
// maxCandidatesPost=64) — phantom-on-rigid-floor speckle clusters
// and far-but-dominant wall+floor merged peaks now correctly handled.
// • V41_WEIGHTS in BModeScore (adds u_wamp + u_stl, Allen 1978 STA/LTA;
// phantom-mode trust no longer collapses with u_far→0).
// • PHANTOM_530 gate band [10, 60] mm — Neyman-Pearson loose prior.
// • New algo modules: StaLta, ChordConsensus (Tukey 1977 +
// Fischler-Bolles 1981), ImpulseReject, MorphClose (kept disabled).
// • Validated on 3-capture set: Center 1.5%, Corner 18%, 500 mL
// Phantom on Floor 0.3% BV error.
override val algorithmVersion: String = "v4.1.1"
override fun detect(input: SweepInput): DetectionResult {
val t0 = System.nanoTime()
val perCh: List<ChannelResult> = (0..5).map { ch -> detectChannel(ch, input) }
// ── 5/6. sweep-level sphere fit (Mode A only — Q5: gated < 4 → null) ──
val gatedDetections: List<Geometry.Detection> = perCh.map { cr ->
if (cr.v41Diag?.gated == true && cr.antIdx != null && cr.postIdx != null)
Geometry.Detection(cr.antIdx, cr.postIdx)
else
Geometry.Detection(null, null)
}
val gatedCount = gatedDetections.count { it.ant != null && it.post != null }
val v41Sphere: V41SphereFit? = if (gatedCount >= 4) {
val wallPts = Geometry.buildWallPointsVisual(gatedDetections, useLr = useLrTilt)
val fit = SphereFit2Step.fit2Step(
wallPts.map { it.xyz },
SphereFit2Step.Mode.AUTO
)
if (fit != null) buildSphereDto(fit, wallPts) else null
} else null
// ── BV dispatch (PR-13 — see docs/BV-CALCULATION-DESIGN.md) ──
// Use the gated ant/post pairs (post anatomical gate) as input. Sphere
// fit BV is passed as cross-check.
val bvDispatch = run {
// v4.1.1 — pass the B-mode score per channel so BvEstimation can
// run ChordConsensus (score-trust + Tukey/Fischler-Bolles).
// Score is only meaningful for gated detections; ungated channels
// get score=0 so the consensus filter excludes them anyway.
val dets = perCh.map { cr ->
val isGated = cr.v41Diag?.gated == true
BvEstimation.Detection(
ant = if (isGated) cr.antIdx else null,
post = if (isGated) cr.postIdx else null,
score = if (isGated) (cr.v41Diag?.score?.toDouble() ?: 0.0) else 0.0,
)
}
BvEstimation.estimate(
walls = dets,
sphereCrossCheckBvMl = v41Sphere?.bvMl,
)
}
// ── summary ──
val scores = perCh.mapNotNull { it.v41Diag?.score }
val chords = perCh.mapNotNull { it.chordMm }
val gated = perCh.count { it.v41Diag?.gated == true }
val chordMean: Float? = chords.takeIf { it.isNotEmpty() }?.average()?.toFloat()
val chordStd: Float? = stdF(chords)
val scoreMean: Float? = scores.takeIf { it.isNotEmpty() }?.let { it.average().toFloat() }
val scoreMin: Float? = scores.minOrNull()
val scoreMax: Float? = scores.maxOrNull()
val sweepTier: String? = scoreMean?.let { BModeScore.classify(it.toDouble()).tier }
return DetectionResult(
requestId = input.requestId,
timestampMs = input.timestampMs,
algorithm = algorithmId,
algorithmVersion = algorithmVersion,
processingMs = (System.nanoTime() - t0) / 1_000_000.0,
perChannel = perCh,
summary = DetectionSummary(
matchCount = perCh.count { it.antIdx != null && it.postIdx != null },
chordMmMean = chordMean,
chordMmStd = chordStd,
tier = sweepTier,
scoreMean = scoreMean,
scoreMin = scoreMin,
scoreMax = scoreMax,
gatedCount = gated,
v41Sphere = v41Sphere,
bvDispatch = bvDispatch,
)
)
}
private fun detectChannel(ch: Int, input: SweepInput): ChannelResult {
val rawIntList = input.adc[ch]
val rawInt = IntArray(rawIntList.size) { rawIntList[it] }
val raw = DoubleArray(rawInt.size) { rawInt[it].toDouble() }
// 1.b/c/d Wavelet (raw envelope)
val wlDenoised = WaveletDenoise.denoise(raw, levels = 3)
val wlDiag = WaveletDenoise.diagnose(raw, levels = 3)
val wlDecomp = WaveletDenoise.dwt(raw, levels = 3)
// 1.a + 2.a + 2.d + 3.a + 3.b — V4.1 two-pass detection.
// • Pass 1: DetectLumenFirst with V41_PARAMS (Hampel-aware
// mergeGapMax=5, gapPeakMargin=50, maxCandidatesPost=64).
// • Hampel-in-range: Hampel impulse rejection (Hampel 1974)
// restricted to the coarse lumen [ant+1, post-1] — handles
// 1-3 sample isolated impulses without touching wall samples.
// • Pass 2: re-detect on cleaned envelope. The two-pass
// structure is the architectural strength described in §6.5b.
val twoPass = ImpulseReject.detectWithLumenClean(
rawInt, DetectLumenFirst.Mode.Adaptive,
params = DetectLumenFirst.V41_PARAMS
)
val det = twoPass.refined
val sg = det.sg
val cfarThr = det.cfarThr ?: DoubleArray(sg.size) { det.adaptiveT } // safety
// 2.b clipping (raw)
val clipping = Clipping.isClipped(rawInt)
// 2.c peaks (sg, all candidates)
val peaksAll = PeakDetection.findPeaks1D(sg).toList()
// 3.c subsample refine (parabolic, peak kind) — operates on RAW envelope.
// SG smoothing slightly biases the parabola vertex, so refine uses the
// raw amplitude. The two-pass detector's `cleanedEnvelope` is used so
// that intra-lumen impulses (already removed in pass 2) don't pull the
// parabola during refinement.
val refineSubstrate = twoPass.cleanedEnvelope
val antRefined = det.ant?.let { SubsampleRefine.refineParabolic(refineSubstrate, it, SubsampleRefine.Kind.PEAK) }
val postRefined = det.post?.let { SubsampleRefine.refineParabolic(refineSubstrate, it, SubsampleRefine.Kind.PEAK) }
val antMmRaw: Float? = antRefined?.let { Geometry.sampleToMm(it).toFloat() }
?: det.ant?.let { Geometry.sampleToMm(it.toDouble()).toFloat() }
val postMmRaw: Float? = postRefined?.let { Geometry.sampleToMm(it).toFloat() }
?: det.post?.let { Geometry.sampleToMm(it.toDouble()).toFloat() }
// 4.a far-post contrast (sg)
val cR = ContrastAux.farPostContrast(sg, det.ant, det.post)
val sContrast: Float = (cR?.contrast ?: 0.0).toFloat()
val sContrastTier: String = ContrastAux.tierBand(cR?.contrast)
// 4.b B-mode composite score (RAW envelope) — V4.1 weighting.
// far 0.15 + dark 0.10 + ant 0.05 + post 0.05
// + wamp 0.45 (wall-peak amplitude vs lumen baseline)
// + stalta 0.20 (Allen-1978 STA/LTA impulse purity)
// The wamp + stalta pair handles the phantom-on-rigid-floor
// regime where there is no tissue echo behind the wall and the
// legacy s_contrast / u_far drops to 0.
val sR = BModeScore.score(
raw, det.ant, det.post,
weights = BModeScore.V41_WEIGHTS
)
val score: Float = (sR?.total ?: 0.0).toFloat()
val scoreSub: ScoreSubscores = if (sR != null) ScoreSubscores(
uFarPost = sR.sub.far.toFloat(),
uLumDark = sR.sub.dark.toFloat(),
uAntGrad = sR.sub.antGrad.toFloat(),
uPostGrad = sR.sub.postGrad.toFloat()
) else ScoreSubscores(0f, 0f, 0f, 0f)
val chTier: String = sR?.tier ?: "—"
// 4.c anatomical gate
val gate = AnatomicalGate.apply(
AnatomicalGate.Detection(det.ant, det.post),
channelIndex = ch,
preset = gatePreset
)
val gated = gate.passed
// 6.b single-channel BV (gated only)
val singleBv: Float? = if (gated && det.ant != null && det.post != null) {
BvFromSphere.bvFromChord((det.post - det.ant).toDouble()).toFloat()
} else null
// spans (DetectLumenFirst.detect already merged them)
val spansForDto: List<IntRange2> = det.spans.map { IntRange2(it.start, it.end) }
val chordMmFinal: Float? =
if (gated && antMmRaw != null && postMmRaw != null) postMmRaw - antMmRaw else null
// Wavelet diagnostic — diagnose() returns null for n < 8; sg.size = 100 → safe
val (rL1, rL2, rL3) = if (wlDiag != null) Triple(
wlDiag.ratio[0].toFloat(),
wlDiag.ratio[1].toFloat(),
wlDiag.ratio[2].toFloat()
) else Triple(0f, 0f, 0f)
return ChannelResult(
ch = ch,
sg = sg.map { it.toFloat() },
threshold = cfarThr.map { it.toFloat() },
antIdx = if (gated) det.ant else null,
postIdx = if (gated) det.post else null,
antRefined = if (gated) antRefined?.toFloat() else null,
postRefined = if (gated) postRefined?.toFloat() else null,
antMm = if (gated) antMmRaw else null,
postMm = if (gated) postMmRaw else null,
lumenStart = if (gated) det.lowStart else null,
lumenEnd = if (gated) det.lowEnd else null,
chordMm = chordMmFinal,
clipping = clipping,
v41Diag = V41Diagnostics(
waveletDenoised = wlDenoised.map { it.toFloat() },
waveletEnergyRatio = WaveletEnergyRatio(rL1, rL2, rL3),
waveletCoefs = WaveletCoefs(
l1 = wlDecomp.details[0].map { it.toFloat() },
l2 = wlDecomp.details[1].map { it.toFloat() },
l3 = wlDecomp.details[2].map { it.toFloat() },
a3 = wlDecomp.approx.map { it.toFloat() }
),
peaksAll = peaksAll,
spans = spansForDto,
score = score,
scoreSub = scoreSub,
gated = gated,
tier = chTier,
sContrast = sContrast,
sContrastTier = sContrastTier,
singleChannelBvMl = singleBv
)
)
}
/**
* Build V41SphereFit DTO from the SphereFit2Step result.
* Circle mode (constY/X/Z): inflate 2D centre back to 3D using the const-axis value.
* Sphere mode: 3D centre directly.
*/
private fun buildSphereDto(
fit: SphereFit2Step.FitResult,
wallPts: List<Geometry.WallPoint>
): V41SphereFit {
val center3 = if (fit.mode == "circle") {
val axes = fit.axes!!
val dropAxis = fit.dropAxis!!
val constVal = if (wallPts.isNotEmpty()) wallPts[0].xyz[dropAxis] else 0.0
val c = DoubleArray(3)
c[axes[0]] = fit.lmCenter[0]
c[axes[1]] = fit.lmCenter[1]
c[dropAxis] = constVal
c
} else {
fit.lmCenter
}
val rMm = fit.lmR
val bvMl = BvFromSphere.bvFromSphere(rMm)
return V41SphereFit(
mode = "A",
center = Vec3(center3[0].toFloat(), center3[1].toFloat(), center3[2].toFloat()),
radiusMm = rMm.toFloat(),
bvMl = bvMl.toFloat(),
residualStdMm = fit.residualStd.toFloat(),
wallPoints = wallPts.map {
Vec3(it.xyz[0].toFloat(), it.xyz[1].toFloat(), it.xyz[2].toFloat())
},
deltaRMm = (rMm - WdConfig.PHANTOM_530_R_MM).toFloat(),
deltaBvMl = (bvMl - WdConfig.PHANTOM_530_BV_ML).toFloat(),
nPoints = wallPts.size
)
}
private fun stdF(values: List<Float>): Float? {
if (values.isEmpty()) return null
if (values.size == 1) return 0f
val mu = values.average()
var sq = 0.0
for (v in values) { val d = v - mu; sq += d * d }
return sqrt(sq / values.size).toFloat()
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
*
* ─────────────────────────────────────────────────────────────────────────
* WallDetector — common contract for V2 and V4.1 detectors
* ─────────────────────────────────────────────────────────────────────────
*
* Two implementations exist:
* • V2Detector — py2 method_b (Otsu adaptive threshold + lumen-first)
* • V41Detector — Charles V4.1 (OS-CFAR + B-mode score + sphere fit)
*
* Both consume the same `SweepInput` and emit the same `DetectionResult`
* shape so a single sweep can drive both detectors in parallel
* (drift-zero pairing). Only the `algorithm` field differentiates output.
*
* Invariants (must hold for every implementation)
* ───────────
* • Single method: `detect(SweepInput): DetectionResult`
* • Idempotent: same input → same output (modulo `processingMs`)
* • No side effects beyond logging (no IO / no mutation of input)
* • Thread-safe: a single instance can be called from multiple coroutines
* • `gainDb`: V2 scales LOW_ECHO_AMP by 10^(-gainDb/20); V4.1 ignores it (Q4)
*/
package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.walldetect.dto.DetectionResult
import com.medithings.vesiscan.walldetect.dto.SweepInput
interface WallDetector {
/** Stable algorithm identifier. `"v2"` | `"v4_1"`. */
val algorithmId: String
/** Algorithm semantic version (bump when the numeric definition changes). */
val algorithmVersion: String
/**
* Run the detector on a single sweep. Returns a complete `DetectionResult`
* including per-channel diagnostics and a sweep-level summary.
*
* @param input 6 × 100 ADC matrix + probe metadata
* @return DetectionResult with `algorithm = algorithmId`
*/
fun detect(input: SweepInput): DetectionResult
}
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2026 Medithings Co., Ltd.
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* Project: CharlesKWONsLaw — wall-detect live compare
*
* Mirror of study/wall_detect_verify/js/algo/anatomical_gate.js (1:1).
*
* Biological-plausibility gate. Two priors:
* 1. Anterior-wall vertical depth band [antDepthMin, antDepthMax]
* probe-to-anterior depth, projected by cos(LR)·cos(SI), in band.
* PHANTOM_530 (BP2 530 mL): [22, 60] mm ← V41Detector default (Q6)
* CLINICAL (free bladder): [20, 70] mm
* 2. Wall-to-wall chord band (along beam) [chordMin, 2·R_max + chordSlack]
* PHANTOM_530: [5, 110.4] mm
* CLINICAL: [5, 130] mm
*
* Beam-angle correction uses Snell-refracted angles (PROBE.DEGREE / DEGREE_LR),
* NOT the mechanical CAD angles (MECH_DEGREE / MECH_DEGREE_LR).
*/
package com.medithings.vesiscan.walldetect.algo
import com.medithings.vesiscan.walldetect.core.WdConfig
import com.medithings.vesiscan.walldetect.core.WdProbe
import kotlin.math.cos
object AnatomicalGate {
private const val DEG = Math.PI / 180.0
data class Preset(
val antDepthMin: Double,
val antDepthMax: Double,
val rMax: Double,
val chordMin: Double,
val chordSlack: Double
)
/**
* Legacy in-vivo preset (kept for backwards compatibility).
* Tight ant-depth band — appropriate for human captures with the
* standard 12-25 mm abdominal-wall layer between probe and bladder.
*/
val LEGACY_INVIVO = Preset(
antDepthMin = 22.0, antDepthMax = 60.0,
rMax = 50.20, chordMin = 5.0, chordSlack = 10.0
)
/**
* V4.1 default — Neyman-Pearson "loose prior" for the 530 mL BP2
* phantom AND phantom-on-rigid-floor AND corner geometries. Tight
* discrimination (in-vivo vs reverberation) is delegated to the
* likelihood-ratio test (B-mode score, trust threshold 0.50). The
* gate only rejects what no acquisition geometry could ever produce.
* ant_vd > 10 mm — minimum probe near-field + coupling layer.
* ant_vd < 60 mm — extreme corner/off-axis still intersects bladder.
* chord ∈ [5, 110.4] — geometric chord of a sphere R ≤ 50.2 mm.
* Reference: Neyman J, Pearson ES. "On the problem of the most
* efficient tests of statistical hypotheses." Phil Trans R Soc A
* 231:289-337, 1933. Lehmann EL "Testing Statistical Hypotheses"
* 1986 §3 (loose prior + sharp likelihood for nuisance-parameter
* problems).
*/
val PHANTOM_530 = Preset(
antDepthMin = 10.0, antDepthMax = 60.0,
rMax = 50.20, chordMin = 5.0, chordSlack = 10.0
)
/** Free-bladder clinical preset. */
val CLINICAL = Preset(
antDepthMin = 20.0, antDepthMax = 70.0,
rMax = 65.0, chordMin = 5.0, chordSlack = 0.0
)
/** Per-channel detection input — only the indices matter for gating. */
data class Detection(val ant: Int?, val post: Int?)
data class BeforeGate(
val ant: Int,
val post: Int,
val antVdMm: Double,
val chordMm: Double
)
/**
* Gate decision. On pass: `ant`/`post` retained, `gateRejected = null`.
* On reject: `ant`/`post` set to null, `gateRejected` carries reason,
* `beforeGate` retains original indices + diagnostics.
*/
data class Result(
val ant: Int?,
val post: Int?,
val gateRejected: String?,
val antVdMm: Double?,
val chordMm: Double?,
val beforeGate: BeforeGate?
) {
val passed: Boolean get() = gateRejected == null && ant != null && post != null
}
fun apply(
detection: Detection,
channelIndex: Int,
preset: Preset = PHANTOM_530,
dps: Double = WdConfig.DPS_DEFAULT,
delay: Double = WdConfig.DELAY_MM_DEFAULT,
siAngles: DoubleArray = WdProbe.DEGREE,
lrAngles: DoubleArray = WdProbe.DEGREE_LR
): Result {
val ant = detection.ant
val post = detection.post
if (ant == null || post == null) {
return Result(
ant = null, post = null,
gateRejected = null,
antVdMm = null, chordMm = null, beforeGate = null
)
}
val si = siAngles.getOrElse(channelIndex) { 0.0 }
val lr = lrAngles.getOrElse(channelIndex) { 0.0 }
val cosBeamY = cos(lr * DEG) * cos(si * DEG)
val antPath = ant * dps + delay
val antVd = antPath * cosBeamY // vertical depth (mm)
val chord = (post - ant) * dps // along-beam (mm)
val chordMax = 2.0 * preset.rMax + preset.chordSlack
val rejected: String? = when {
antVd < preset.antDepthMin ->
"ant depth ${"%.1f".format(antVd)} mm < ${"%.0f".format(preset.antDepthMin)} mm"
antVd > preset.antDepthMax ->
"ant depth ${"%.1f".format(antVd)} mm > ${"%.0f".format(preset.antDepthMax)} mm"
chord < preset.chordMin ->
"chord ${"%.1f".format(chord)} mm < ${"%.0f".format(preset.chordMin)} mm"
chord > chordMax ->
"chord ${"%.1f".format(chord)} mm > ${"%.0f".format(chordMax)} mm " +
"(2·R_max + ${"%.0f".format(preset.chordSlack)} mm)"
else -> null
}
return if (rejected != null) {
Result(
ant = null, post = null,
gateRejected = rejected,
antVdMm = null, chordMm = null,
beforeGate = BeforeGate(ant, post, antVd, chord)
)
} else {
Result(
ant = ant, post = post,
gateRejected = null,
antVdMm = antVd, chordMm = chord, beforeGate = null
)
}
}
}

Some files were not shown because too many files have changed in this diff Show More