Slice 1: honest full-range Surface readings, self-gating lock (Codex)
- Surface tilt magnitude stays visible 0-180 degrees; never blanked by placement. Lock is self-gating (only near-zero magnitude can enter), so the placement gate now drives hints only. Edge gating unchanged. - Pitch/Roll suppressed as ambiguous at >=80 degrees; explicit screen-down state past 90 degrees with the true magnitude retained. - SurfacePresentationDetector classifies from the same deadbanded stable magnitude the UI displays, with 1-degree hysteresis at both boundaries and a direct face-up -> screen-down transition. - Tests: angle sweep (30/80/near-vertical/screen-down), hysteretic boundary tests, and a sustained 30-degree run proving a steep phone never locks or fires feedback. 43 tests passing. Audited-by: Claude (2 findings raised and resolved) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,6 @@ object OrientationMath {
|
||||
}
|
||||
|
||||
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
|
||||
|
||||
/**
|
||||
* Unsigned angle between two gravity directions — the directional basis for
|
||||
* relative zero in the angle meter. Unlike subtracting tilt magnitudes, this
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
/**
|
||||
* Surface-only presentation state. Tilt magnitude remains authoritative from 0–180°;
|
||||
* Pitch and roll become ill-conditioned near vertical and are deliberately suppressed.
|
||||
*/
|
||||
enum class SurfacePresentation {
|
||||
FACE_UP,
|
||||
NEAR_VERTICAL,
|
||||
SCREEN_DOWN,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hysteretic presentation classifier driven by the same deadbanded stable magnitude
|
||||
* the UI displays. This prevents Pitch/Roll panels and placement copy from flapping
|
||||
* around the 80° and 90° boundaries due to sensor noise.
|
||||
*/
|
||||
class SurfacePresentationDetector {
|
||||
private var presentation = SurfacePresentation.FACE_UP
|
||||
|
||||
fun update(displayedTiltDegrees: Double): SurfacePresentation {
|
||||
presentation = when (presentation) {
|
||||
SurfacePresentation.FACE_UP -> {
|
||||
if (displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES) {
|
||||
SurfacePresentation.SCREEN_DOWN
|
||||
} else if (displayedTiltDegrees >= AXIS_SUPPRESSION_ENTER_DEGREES) {
|
||||
SurfacePresentation.NEAR_VERTICAL
|
||||
} else {
|
||||
SurfacePresentation.FACE_UP
|
||||
}
|
||||
}
|
||||
SurfacePresentation.NEAR_VERTICAL -> when {
|
||||
displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES -> SurfacePresentation.SCREEN_DOWN
|
||||
displayedTiltDegrees < AXIS_SUPPRESSION_EXIT_DEGREES -> SurfacePresentation.FACE_UP
|
||||
else -> SurfacePresentation.NEAR_VERTICAL
|
||||
}
|
||||
SurfacePresentation.SCREEN_DOWN -> {
|
||||
if (displayedTiltDegrees <= SCREEN_DOWN_EXIT_DEGREES) {
|
||||
SurfacePresentation.NEAR_VERTICAL
|
||||
} else {
|
||||
SurfacePresentation.SCREEN_DOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
return presentation
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
presentation = SurfacePresentation.FACE_UP
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AXIS_SUPPRESSION_ENTER_DEGREES = 80.0
|
||||
const val AXIS_SUPPRESSION_EXIT_DEGREES = 79.0
|
||||
const val SCREEN_DOWN_ENTER_DEGREES = 90.0
|
||||
const val SCREEN_DOWN_EXIT_DEGREES = 89.0
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import com.onthelevel.core.sensors.LevelMode
|
||||
import com.onthelevel.core.sensors.LockDetector
|
||||
import com.onthelevel.core.sensors.OrientationMath
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import com.onthelevel.core.sensors.SurfacePresentation
|
||||
import com.onthelevel.core.sensors.SurfacePresentationDetector
|
||||
|
||||
/**
|
||||
* One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3).
|
||||
@@ -18,10 +20,12 @@ data class LevelReading(
|
||||
val mode: LevelMode,
|
||||
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
||||
val displayPrimaryDegrees: Double,
|
||||
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */
|
||||
val secondaryADegrees: Double,
|
||||
/** Surface: roll. Edge: plumb lean. Deadbanded. */
|
||||
val secondaryBDegrees: Double,
|
||||
/** Surface: pitch when it is meaningful. Edge: signed level deviation. */
|
||||
val secondaryADegrees: Double?,
|
||||
/** Surface: roll when it is meaningful. Edge: plumb lean. */
|
||||
val secondaryBDegrees: Double?,
|
||||
/** Surface-only presentation state; null for Edge mode. */
|
||||
val surfacePresentation: SurfacePresentation? = null,
|
||||
/** False when the device is not physically in the selected mode's geometry. */
|
||||
val placementOk: Boolean,
|
||||
val isLocked: Boolean,
|
||||
@@ -36,9 +40,10 @@ data class LevelReading(
|
||||
* changes; the LockDetector is shared across recreations so the haptic debounce
|
||||
* survives mode switches.
|
||||
*
|
||||
* Invalid placement (e.g. Edge mode selected but the phone lying flat) suppresses
|
||||
* Invalid Edge placement (e.g. Edge selected but the phone lying flat) suppresses
|
||||
* lock detection — asin(gy) reads near zero there too, and locking on it would be
|
||||
* a lie.
|
||||
* a lie. Surface lock is self-gating: only a near-zero surface tilt can acquire it,
|
||||
* while its magnitude remains visible at every orientation.
|
||||
*/
|
||||
class LevelPipeline(
|
||||
private val mode: LevelMode,
|
||||
@@ -52,6 +57,7 @@ class LevelPipeline(
|
||||
private val primaryDeadband = DisplayDeadband()
|
||||
private val aDeadband = DisplayDeadband()
|
||||
private val bDeadband = DisplayDeadband()
|
||||
private val surfacePresentationDetector = SurfacePresentationDetector()
|
||||
private var lastTimestampNanos: Long? = null
|
||||
|
||||
fun process(g: GravitySample): LevelReading {
|
||||
@@ -71,15 +77,23 @@ class LevelPipeline(
|
||||
OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
|
||||
dtSeconds,
|
||||
)
|
||||
val lock = lockDetector.update(
|
||||
if (placementOk) magnitude else Double.MAX_VALUE,
|
||||
nowMillis,
|
||||
)
|
||||
val displayMagnitude = primaryDeadband.update(magnitude)
|
||||
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
||||
val lock = lockDetector.update(magnitude, nowMillis)
|
||||
LevelReading(
|
||||
mode = mode,
|
||||
displayPrimaryDegrees = primaryDeadband.update(magnitude),
|
||||
secondaryADegrees = aDeadband.update(pitch),
|
||||
secondaryBDegrees = bDeadband.update(roll),
|
||||
displayPrimaryDegrees = displayMagnitude,
|
||||
secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
|
||||
aDeadband.update(pitch)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) {
|
||||
bDeadband.update(roll)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
surfacePresentation = presentation,
|
||||
placementOk = placementOk,
|
||||
isLocked = lock.isLocked,
|
||||
fireFeedback = lock.fireFeedback,
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.onthelevel.core.sensors.LevelMode
|
||||
import com.onthelevel.core.sensors.LockDetector
|
||||
import com.onthelevel.core.sensors.SensorSource
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import com.onthelevel.core.sensors.SurfacePresentation
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.Locale
|
||||
@@ -134,9 +135,10 @@ fun LevelScreen(container: AppContainer) {
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val placementOk = reading?.placementOk ?: true
|
||||
val surfacePresentation = reading?.surfacePresentation
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = if (!placementOk) "—" else reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
||||
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary,
|
||||
)
|
||||
@@ -145,6 +147,10 @@ fun LevelScreen(container: AppContainer) {
|
||||
// rounded readout and the "level" claim can never contradict.
|
||||
Text(
|
||||
text = when {
|
||||
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.SCREEN_DOWN ->
|
||||
"Screen facing down · turn phone screen up"
|
||||
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.NEAR_VERTICAL ->
|
||||
"Near vertical · pitch and roll unavailable"
|
||||
!placementOk && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
|
||||
!placementOk -> "Stand the phone upright on a long edge"
|
||||
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
|
||||
|
||||
@@ -56,6 +56,11 @@ class OrientationMathTest {
|
||||
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surface magnitude remains authoritative through screen down`() {
|
||||
assertEquals(120.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(120.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge mode reads zero when the long edge is horizontal`() {
|
||||
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SurfacePresentationDetectorTest {
|
||||
|
||||
@Test
|
||||
fun `axis suppression boundary is hysteretic`() {
|
||||
val detector = SurfacePresentationDetector()
|
||||
|
||||
assertEquals(SurfacePresentation.FACE_UP, detector.update(79.9))
|
||||
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(80.0))
|
||||
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(79.2))
|
||||
assertEquals(SurfacePresentation.FACE_UP, detector.update(78.9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen down boundary is hysteretic`() {
|
||||
val detector = SurfacePresentationDetector()
|
||||
|
||||
detector.update(80.0)
|
||||
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(90.0))
|
||||
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(90.1))
|
||||
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(89.2))
|
||||
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(89.0))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.onthelevel.core.sensors.GravitySample
|
||||
import com.onthelevel.core.sensors.LevelMode
|
||||
import com.onthelevel.core.sensors.LockDetector
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import com.onthelevel.core.sensors.SurfacePresentation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -75,6 +76,41 @@ class LevelPipelineTest {
|
||||
assertFalse(tilted.last().isLocked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surface magnitude remains available through near vertical and screen down`() {
|
||||
fun readingAt(degrees: Double): LevelReading =
|
||||
surfacePipeline().process(pitched(degrees, 0))
|
||||
|
||||
val atThirty = readingAt(30.0)
|
||||
assertEquals(30.0, atThirty.displayPrimaryDegrees, 1e-9)
|
||||
assertTrue(atThirty.secondaryADegrees != null)
|
||||
assertTrue(atThirty.secondaryBDegrees != null)
|
||||
assertEquals(SurfacePresentation.FACE_UP, atThirty.surfacePresentation)
|
||||
assertFalse(atThirty.isLocked)
|
||||
|
||||
val atEighty = readingAt(80.0)
|
||||
assertEquals(80.0, atEighty.displayPrimaryDegrees, 1e-9)
|
||||
assertEquals(null, atEighty.secondaryADegrees)
|
||||
assertEquals(null, atEighty.secondaryBDegrees)
|
||||
assertEquals(SurfacePresentation.NEAR_VERTICAL, atEighty.surfacePresentation)
|
||||
assertFalse(atEighty.isLocked)
|
||||
|
||||
val screenDown = readingAt(120.0)
|
||||
assertEquals(120.0, screenDown.displayPrimaryDegrees, 1e-9)
|
||||
assertEquals(null, screenDown.secondaryADegrees)
|
||||
assertEquals(null, screenDown.secondaryBDegrees)
|
||||
assertEquals(SurfacePresentation.SCREEN_DOWN, screenDown.surfacePresentation)
|
||||
assertFalse(screenDown.isLocked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sustained steep Surface reading never locks or fires feedback`() {
|
||||
val readings = run(surfacePipeline(), degrees = 30.0, fromMillis = 0, untilMillis = 2_000)
|
||||
|
||||
assertTrue(readings.all { !it.isLocked })
|
||||
assertTrue(readings.none { it.fireFeedback })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge mode never locks while the phone lies flat on a table`() {
|
||||
val pipeline = LevelPipeline(
|
||||
|
||||
Reference in New Issue
Block a user