diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetector.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetector.kt index 14da8ef2d..bcf01f0ca 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetector.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetector.kt @@ -35,6 +35,15 @@ class HandleStateDetector( companion object { /** Position threshold (mm) for simple left/right handle detection booleans */ private const val SIMPLE_DETECTION_THRESHOLD = 50.0f + + /** [minPositionSeen] before any sample. */ + internal const val UNSET_MIN_POSITION_MM = Double.MAX_VALUE + + /** + * [maxPositionSeen] before any sample. + * [Double.MIN_VALUE] is the smallest positive double, so it is not an unset maximum. + */ + internal const val UNSET_MAX_POSITION_MM = -Double.MAX_VALUE } // State flows @@ -65,15 +74,16 @@ class HandleStateDetector( private var restBaselinePosA: Double? = null private var restBaselinePosB: Double? = null - // Position range diagnostics - var minPositionSeen: Double = Double.MAX_VALUE + // Position range diagnostics. Unset values are UNSET_MIN_POSITION_MM and UNSET_MAX_POSITION_MM. + var minPositionSeen: Double = UNSET_MIN_POSITION_MM private set - // F091: Double.MIN_VALUE is the smallest POSITIVE double, not the most - // negative, so an all-negative position stream would never update this and - // report a bogus ~0 maximum. Use -Double.MAX_VALUE as the sentinel. - var maxPositionSeen: Double = -Double.MAX_VALUE + var maxPositionSeen: Double = UNSET_MAX_POSITION_MM private set + /** Both extremes have moved off [UNSET_MIN_POSITION_MM] and [UNSET_MAX_POSITION_MM]. */ + internal fun hasObservedPositionRange(): Boolean = + minPositionSeen != UNSET_MIN_POSITION_MM && maxPositionSeen != UNSET_MAX_POSITION_MM + // Periodic logging counter private var logCounter = 0L @@ -159,8 +169,8 @@ class HandleStateDetector( * Reset all internal state variables (timers, baselines, diagnostics). */ private fun resetInternalState() { - minPositionSeen = Double.MAX_VALUE - maxPositionSeen = -Double.MAX_VALUE // F091: most-negative sentinel (see field decl) + minPositionSeen = UNSET_MIN_POSITION_MM + maxPositionSeen = UNSET_MAX_POSITION_MM pendingGrabbedStartTime = null pendingReleasedStartTime = null activeHandlesMask = 0 diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MetricPollingEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MetricPollingEngine.kt index 5599ebf7c..148f9c6e3 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MetricPollingEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MetricPollingEngine.kt @@ -23,8 +23,8 @@ import kotlinx.coroutines.withTimeoutOrNull * Manages the 4 BLE polling loops and their lifecycle for MetricPollingEngine extraction (Phase 11). * * Polling loops: - * - Monitor: 10-20Hz (no delay on success, BLE response time rate-limits) - * - Diagnostic: 1Hz (500ms interval) for keep-alive and fault detection + * - Monitor: no fixed delay on success; BLE response time rate-limits (~10-20ms) + * - Diagnostic: 2Hz (500ms interval) for keep-alive and fault detection * - Heuristic: 4Hz (250ms interval) for force telemetry * - Heartbeat: 0.5Hz (2000ms interval) for connection keep-alive * @@ -444,10 +444,8 @@ class MetricPollingEngine( "heartbeat=${heartbeatJob?.isActive}" } - // Log workout analysis (position range from HandleStateDetector) - if (handleDetector.minPositionSeen != Double.MAX_VALUE && - handleDetector.maxPositionSeen != Double.MIN_VALUE - ) { + // Log workout analysis once both extremes have left their unset sentinels. + if (handleDetector.hasObservedPositionRange()) { log.i { "========== WORKOUT ANALYSIS ==========" } log.i { "Position range: min=${handleDetector.minPositionSeen}, max=${handleDetector.maxPositionSeen}" } log.i { "Detection thresholds (auto-start mode uses lower velocity):" } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetectorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetectorTest.kt index bd7f5a964..967648c6e 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetectorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/HandleStateDetectorTest.kt @@ -6,6 +6,7 @@ import com.devil.phoenixproject.domain.model.WorkoutMetric import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class HandleStateDetectorTest { @@ -803,4 +804,26 @@ class HandleStateDetectorTest { assertEquals(2.0, detector.minPositionSeen) assertEquals(20.0, detector.maxPositionSeen) } + + @Test + fun `position range stays unobserved until a sample leaves both sentinels`() { + val detector = createDetector() + assertEquals(HandleStateDetector.UNSET_MIN_POSITION_MM, detector.minPositionSeen) + assertEquals(-Double.MAX_VALUE, detector.maxPositionSeen) + assertEquals(HandleStateDetector.UNSET_MAX_POSITION_MM, detector.maxPositionSeen) + assertNotEquals(Double.MIN_VALUE, detector.maxPositionSeen) + assertFalse(detector.hasObservedPositionRange()) + + detector.enable(autoStart = false) + assertFalse(detector.hasObservedPositionRange()) + + detector.processMetric(metric(posA = -30.0f, posB = -8.0f)) + assertEquals(-30.0, detector.minPositionSeen) + assertEquals(-8.0, detector.maxPositionSeen) + assertTrue(detector.hasObservedPositionRange()) + + detector.reset() + assertEquals(HandleStateDetector.UNSET_MAX_POSITION_MM, detector.maxPositionSeen) + assertFalse(detector.hasObservedPositionRange()) + } }