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
|
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsigned angle between two gravity directions — the directional basis for
|
* Unsigned angle between two gravity directions — the directional basis for
|
||||||
* relative zero in the angle meter. Unlike subtracting tilt magnitudes, this
|
* 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.LockDetector
|
||||||
import com.onthelevel.core.sensors.OrientationMath
|
import com.onthelevel.core.sensors.OrientationMath
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
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).
|
* 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,
|
val mode: LevelMode,
|
||||||
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
||||||
val displayPrimaryDegrees: Double,
|
val displayPrimaryDegrees: Double,
|
||||||
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */
|
/** Surface: pitch when it is meaningful. Edge: signed level deviation. */
|
||||||
val secondaryADegrees: Double,
|
val secondaryADegrees: Double?,
|
||||||
/** Surface: roll. Edge: plumb lean. Deadbanded. */
|
/** Surface: roll when it is meaningful. Edge: plumb lean. */
|
||||||
val secondaryBDegrees: Double,
|
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. */
|
/** False when the device is not physically in the selected mode's geometry. */
|
||||||
val placementOk: Boolean,
|
val placementOk: Boolean,
|
||||||
val isLocked: Boolean,
|
val isLocked: Boolean,
|
||||||
@@ -36,9 +40,10 @@ data class LevelReading(
|
|||||||
* changes; the LockDetector is shared across recreations so the haptic debounce
|
* changes; the LockDetector is shared across recreations so the haptic debounce
|
||||||
* survives mode switches.
|
* 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
|
* 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(
|
class LevelPipeline(
|
||||||
private val mode: LevelMode,
|
private val mode: LevelMode,
|
||||||
@@ -52,6 +57,7 @@ class LevelPipeline(
|
|||||||
private val primaryDeadband = DisplayDeadband()
|
private val primaryDeadband = DisplayDeadband()
|
||||||
private val aDeadband = DisplayDeadband()
|
private val aDeadband = DisplayDeadband()
|
||||||
private val bDeadband = DisplayDeadband()
|
private val bDeadband = DisplayDeadband()
|
||||||
|
private val surfacePresentationDetector = SurfacePresentationDetector()
|
||||||
private var lastTimestampNanos: Long? = null
|
private var lastTimestampNanos: Long? = null
|
||||||
|
|
||||||
fun process(g: GravitySample): LevelReading {
|
fun process(g: GravitySample): LevelReading {
|
||||||
@@ -71,15 +77,23 @@ class LevelPipeline(
|
|||||||
OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
|
OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
|
||||||
dtSeconds,
|
dtSeconds,
|
||||||
)
|
)
|
||||||
val lock = lockDetector.update(
|
val displayMagnitude = primaryDeadband.update(magnitude)
|
||||||
if (placementOk) magnitude else Double.MAX_VALUE,
|
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
||||||
nowMillis,
|
val lock = lockDetector.update(magnitude, nowMillis)
|
||||||
)
|
|
||||||
LevelReading(
|
LevelReading(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
displayPrimaryDegrees = primaryDeadband.update(magnitude),
|
displayPrimaryDegrees = displayMagnitude,
|
||||||
secondaryADegrees = aDeadband.update(pitch),
|
secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
|
||||||
secondaryBDegrees = bDeadband.update(roll),
|
aDeadband.update(pitch)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) {
|
||||||
|
bDeadband.update(roll)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
surfacePresentation = presentation,
|
||||||
placementOk = placementOk,
|
placementOk = placementOk,
|
||||||
isLocked = lock.isLocked,
|
isLocked = lock.isLocked,
|
||||||
fireFeedback = lock.fireFeedback,
|
fireFeedback = lock.fireFeedback,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.onthelevel.core.sensors.LevelMode
|
|||||||
import com.onthelevel.core.sensors.LockDetector
|
import com.onthelevel.core.sensors.LockDetector
|
||||||
import com.onthelevel.core.sensors.SensorSource
|
import com.onthelevel.core.sensors.SensorSource
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||||
|
import com.onthelevel.core.sensors.SurfacePresentation
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -134,9 +135,10 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val placementOk = reading?.placementOk ?: true
|
val placementOk = reading?.placementOk ?: true
|
||||||
|
val surfacePresentation = reading?.surfacePresentation
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = if (!placementOk) "—" else reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary,
|
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.
|
// rounded readout and the "level" claim can never contradict.
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
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 && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
|
||||||
!placementOk -> "Stand the phone upright on a long edge"
|
!placementOk -> "Stand the phone upright on a long edge"
|
||||||
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
|
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class OrientationMathTest {
|
|||||||
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
|
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
|
@Test
|
||||||
fun `edge mode reads zero when the long edge is horizontal`() {
|
fun `edge mode reads zero when the long edge is horizontal`() {
|
||||||
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
|
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.LevelMode
|
||||||
import com.onthelevel.core.sensors.LockDetector
|
import com.onthelevel.core.sensors.LockDetector
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||||
|
import com.onthelevel.core.sensors.SurfacePresentation
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -75,6 +76,41 @@ class LevelPipelineTest {
|
|||||||
assertFalse(tilted.last().isLocked)
|
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
|
@Test
|
||||||
fun `edge mode never locks while the phone lies flat on a table`() {
|
fun `edge mode never locks while the phone lies flat on a table`() {
|
||||||
val pipeline = LevelPipeline(
|
val pipeline = LevelPipeline(
|
||||||
|
|||||||
Reference in New Issue
Block a user