Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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):" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
}
}
Loading