diff --git a/README.md b/README.md index 0e0f11a..2be5027 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,18 @@ Implemented and tested: - `core/sensors` — sensor selection (game rotation vector → gravity → low-passed accelerometer), device-frame gravity stream, and the pure measurement math: - orientation mapping, two-sample per-mode calibration, EMA smoothing, - lock hysteresis/dwell/debounce, display deadband. All unit-tested. + orientation mapping, two-sample per-mode calibration (derived in angle space, + **applied in vector space** so it stays correct away from zero), EMA smoothing, + lock hysteresis/dwell/debounce, display deadband, placement validity, and the + sensor-vector contract (all sources emit device-frame world-up; flat = +9.81 on Z, + pinned by `SensorContractTest`). All unit-tested. - `core/settings` — DataStore preferences and per-mode calibration persistence. - `core/billing` — entitlement interface with a stub (free-tier) implementation. - `feature/level`, `feature/angle` — live numeric scaffolds wired to the real - sensor pipeline, with manual Surface|Edge selection, lock state, hold-to-zero. + sensor pipeline, with manual Surface|Edge selection, placement-validity hints, + lock state (locked label states the tolerance so it can never contradict the + rounded readout), and directional hold-to-zero (vector reference, not magnitude + subtraction). - `feature/ruler`, `feature/tools` — static placeholders. Not yet implemented (deliberately — see TODO markers): diff --git a/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt b/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt index 96602ad..d3d1c55 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt @@ -49,12 +49,13 @@ class AndroidSensorSource(context: Context) : SensorSource { val sample = when (sensorKind) { SensorSource.Kind.GAME_ROTATION_VECTOR -> { SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) - // R maps device → world; world-up expressed in the device frame - // is R's third row. Scale to standard gravity. + // Converted to the GravitySample contract (device-frame + // world-up); see RotationVectorMath and SensorContractTest. + val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(rotationMatrix) GravitySample( - x = rotationMatrix[6] * STANDARD_GRAVITY, - y = rotationMatrix[7] * STANDARD_GRAVITY, - z = rotationMatrix[8] * STANDARD_GRAVITY, + x = x * STANDARD_GRAVITY, + y = y * STANDARD_GRAVITY, + z = z * STANDARD_GRAVITY, timestampNanos = event.timestamp, ) } diff --git a/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt b/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt index 5b0b833..2258a57 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt @@ -1,9 +1,16 @@ package com.onthelevel.core.sensors /** - * Gravity (reaction) vector in the DEVICE coordinate frame, in m/s². + * THE SENSOR-VECTOR CONTRACT: world-up (the gravity REACTION, not the gravity force) + * expressed in the DEVICE coordinate frame, in m/s². * Android convention: x → right edge, y → top edge, z → out of the screen. - * A phone lying screen-up on a level surface reads approximately (0, 0, +9.81). + * + * A phone lying flat screen-up on a level surface reads approximately (0, 0, +9.81). + * This is what Android's TYPE_ACCELEROMETER and TYPE_GRAVITY natively report at rest — + * per the SensorEvent docs, a stationary flat device reads +9.81 on Z ("acceleration of + * the device (0 m/s²) minus the force of gravity (−9.81 m/s²)"). The rotation-vector + * path is converted to the same convention via [RotationVectorMath]. All three sources + * therefore agree without sign adjustment; SensorContractTest pins this. */ data class GravitySample( val x: Double, @@ -12,5 +19,20 @@ data class GravitySample( val timestampNanos: Long, ) -/** The two physical orientations v1 supports (BRIEF.md). Selected manually — never auto-switched. */ +/** + * The two physical orientations v1 supports (BRIEF.md). Selected manually — never + * auto-switched. + * + * SURFACE: device lying flat, screen up, on the surface being measured. Gravity + * predominantly along +Z. + * + * EDGE: device standing upright on either LONG edge (the edges parallel to the device + * Y axis) against the surface being measured — like a torpedo level. Screen plane + * roughly vertical; gravity predominantly along ±X. The level reading is the tilt of + * the resting edge from horizontal; plumb lean (screen tilted from vertical) is a + * separate, secondary reading. + * + * Placement is validated with [OrientationMath.isPlacementValid]; readings and lock + * are suppressed when the device is not in the selected mode's geometry. + */ enum class LevelMode { SURFACE, EDGE } diff --git a/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt index e0f3d83..6b52d77 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt @@ -11,11 +11,21 @@ import kotlin.math.abs * Time is injected (callers pass `nowMillis`) so transitions are unit-testable. */ class LockDetector( - private val enterThresholdDegrees: Double = 0.2, - private val exitThresholdDegrees: Double = 0.35, - private val dwellMillis: Long = 400, - private val feedbackDebounceMillis: Long = 3_000, + private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES, + private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES, + private val dwellMillis: Long = DEFAULT_DWELL_MILLIS, + private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS, ) { + companion object { + // BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit + // threshold doubles as the tolerance stated next to the locked label, so the + // rounded readout and "level" claim can never contradict (Codex review). + const val DEFAULT_ENTER_DEGREES = 0.2 + const val DEFAULT_EXIT_DEGREES = 0.35 + const val DEFAULT_DWELL_MILLIS = 400L + const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L + } + init { require(exitThresholdDegrees > enterThresholdDegrees) { "Hysteresis requires exit > enter threshold" diff --git a/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt index bbc9363..e3ed031 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt @@ -4,6 +4,7 @@ import kotlin.math.abs import kotlin.math.acos import kotlin.math.asin import kotlin.math.atan2 +import kotlin.math.cos import kotlin.math.sqrt import kotlin.math.tan @@ -62,5 +63,37 @@ 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 + * accounts for the axis of movement: zeroing at 10° pitch and moving to 10° + * roll reports the true ~14° orientation change, not 0°. + */ + fun angleBetweenDegrees(a: GravitySample, b: GravitySample): Double { + val na = norm(a) + val nb = norm(b) + if (na == 0.0 || nb == 0.0) return 0.0 + val cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (na * nb) + return Math.toDegrees(acos(cosine.coerceIn(-1.0, 1.0))) + } + + /** + * True when the device is physically in the selected mode's geometry (within + * [PLACEMENT_TOLERANCE_DEGREES] of it). Guards against, e.g., Edge mode reading + * "level" for a phone lying flat on a table — asin(gy) is near zero there too, + * but the measurement is meaningless and must not lock. + */ + fun isPlacementValid(g: GravitySample, mode: LevelMode): Boolean { + val n = norm(g) + if (n == 0.0) return false + return when (mode) { + LevelMode.SURFACE -> g.z / n >= PLACEMENT_MIN_COS + LevelMode.EDGE -> abs(g.x) / n >= PLACEMENT_MIN_COS + } + } + + const val PLACEMENT_TOLERANCE_DEGREES = 45.0 // TODO(tune) against real handling + private val PLACEMENT_MIN_COS = cos(Math.toRadians(PLACEMENT_TOLERANCE_DEGREES)) + private fun norm(g: GravitySample): Double = sqrt(g.x * g.x + g.y * g.y + g.z * g.z) } diff --git a/app/src/main/java/com/onthelevel/core/sensors/RotationVectorMath.kt b/app/src/main/java/com/onthelevel/core/sensors/RotationVectorMath.kt new file mode 100644 index 0000000..fbbeea3 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/RotationVectorMath.kt @@ -0,0 +1,26 @@ +package com.onthelevel.core.sensors + +/** + * Pure conversion from a rotation matrix to the device-frame world-up direction, + * split out of AndroidSensorSource so the sensor-contract tests can prove the + * rotation-vector path matches the gravity/accelerometer convention. + */ +object RotationVectorMath { + + /** + * [rotationMatrix] is the row-major 3×3 device→world matrix produced by + * SensorManager.getRotationMatrixFromVector. World-up expressed in the device + * frame is Rᵀ·(0,0,1) — the matrix's third row. Multiplied by standard gravity + * this matches what TYPE_GRAVITY reports for the same orientation. + */ + fun worldUpDeviceFrame(rotationMatrix: FloatArray): Triple { + require(rotationMatrix.size >= 9) { "Expected a 3x3 rotation matrix" } + return Triple( + rotationMatrix[6].toDouble(), + rotationMatrix[7].toDouble(), + rotationMatrix[8].toDouble(), + ) + } + + const val STANDARD_GRAVITY = 9.80665 +} diff --git a/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt index f22b4d2..7d7578e 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt @@ -1,20 +1,34 @@ package com.onthelevel.core.sensors +import com.onthelevel.core.sensors.Vec3Math.Vec3 +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan + /** * Two-sample (180° flip) calibration, per mode (BRIEF.md §Measurement modes and calibration). * - * Math: the surface's true tilt is fixed in the world frame; the device's own bias is fixed - * in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the - * same, unmoved surface — the true tilt appears negated in device readings while the bias - * does not move: + * DERIVATION — the surface's true tilt is fixed in the world frame; the device's own bias + * is fixed in the device frame. After rotating the device 180° about the CONTACT-PLANE + * NORMAL — on the same, unmoved surface — the true tilt appears negated in device + * readings while the bias does not move: * * reading₁ = tilt + bias * reading₂ = -tilt + bias * ⇒ bias = (reading₁ + reading₂) / 2 * - * This holds per axis for the small near-level angles calibration is used at. It is only - * valid if (a) the rotation is about the contact-plane normal and (b) the surface does not - * move between samples — the calibration UI must instruct exactly that. + * Derivation happens in angle space near level, where the flip identity is exact to + * first order. Preconditions the calibration UI must enforce: (a) rotation about the + * contact-plane normal, (b) surface unmoved between samples, (c) surface within a few + * degrees of level. + * + * APPLICATION — the stored bias angles parameterize a per-mode REFERENCE ORIENTATION: + * the device-frame direction gravity reads when the device is truly level in that mode. + * Correction is applied in vector space — a fixed rotation of every gravity sample that + * maps the reference onto the mode's ideal axis — BEFORE any display angle is derived. + * This stays correct away from zero (see tests at 30°), unlike subtracting scalar + * offsets from derived angles. * * Biases are stored per mode and applied only to that mode's readings; a Surface * calibration must never touch Edge readings (AUDIT.md finding 3). @@ -33,19 +47,68 @@ object TwoSampleCalibration { rollBiasDegrees = deriveBiasDegrees(roll1, roll2), ) - fun deriveEdge(level1: Double, level2: Double): EdgeCalibration = - EdgeCalibration(levelBiasDegrees = deriveBiasDegrees(level1, level2)) + fun deriveEdge( + level1: Double, + level2: Double, + calibratedOnPositiveXEdge: Boolean = true, + ): EdgeCalibration = EdgeCalibration( + levelBiasDegrees = deriveBiasDegrees(level1, level2), + calibratedOnPositiveXEdge = calibratedOnPositiveXEdge, + ) } +/** + * Surface-mode reference orientation, parameterized by the bias angles measured when the + * device is truly flat. [apply] rotates each gravity sample by the fixed rotation that + * aligns the reference direction with the screen normal (+Z). + */ data class SurfaceCalibration(val pitchBiasDegrees: Double, val rollBiasDegrees: Double) { - fun applyToPitch(rawPitchDegrees: Double): Double = rawPitchDegrees - pitchBiasDegrees - fun applyToRoll(rawRollDegrees: Double): Double = rawRollDegrees - rollBiasDegrees + + fun apply(g: GravitySample): GravitySample { + if (pitchBiasDegrees == 0.0 && rollBiasDegrees == 0.0) return g + // Reference: the direction gravity reads on a truly level surface — + // atan2(y, z) = pitchBias and atan2(x, z) = rollBias by construction. + val reference = Vec3Math.normalize( + Vec3( + tan(Math.toRadians(rollBiasDegrees)), + tan(Math.toRadians(pitchBiasDegrees)), + 1.0, + ), + ) + val axis = Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT) + val axisNorm = Vec3Math.norm(axis) + if (axisNorm < 1e-12) return g + val unitAxis = Vec3(axis.x / axisNorm, axis.y / axisNorm, axis.z / axisNorm) + val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0)) + val corrected = Vec3Math.rotate(Vec3(g.x, g.y, g.z), unitAxis, angle) + return g.copy(x = corrected.x, y = corrected.y, z = corrected.z) + } companion object { val NONE = SurfaceCalibration(0.0, 0.0) } } -data class EdgeCalibration(val levelBiasDegrees: Double) { - fun applyToLevel(rawLevelDegrees: Double): Double = rawLevelDegrees - levelBiasDegrees +/** + * Edge-mode reference orientation: a fixed rotation about the device Z axis that zeroes + * the level reading for the calibrated placement, leaving plumb lean untouched (lean is + * not part of "edge level" and must not be silently "calibrated" from a leaned placement). + * + * Valid for the long edge the calibration was performed on; [calibratedOnPositiveXEdge] + * records which (true = the device's right edge down, gravity along +X). The calibration + * flow must capture this from the placement it instructed. + */ +data class EdgeCalibration( + val levelBiasDegrees: Double, + val calibratedOnPositiveXEdge: Boolean = true, +) { + fun apply(g: GravitySample): GravitySample { + if (levelBiasDegrees == 0.0) return g + val gamma = Math.toRadians( + if (calibratedOnPositiveXEdge) -levelBiasDegrees else levelBiasDegrees, + ) + val c = cos(gamma) + val s = sin(gamma) + return g.copy(x = g.x * c - g.y * s, y = g.x * s + g.y * c) + } companion object { val NONE = EdgeCalibration(0.0) } } diff --git a/app/src/main/java/com/onthelevel/core/sensors/Vec3Math.kt b/app/src/main/java/com/onthelevel/core/sensors/Vec3Math.kt new file mode 100644 index 0000000..cd013c9 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/Vec3Math.kt @@ -0,0 +1,41 @@ +package com.onthelevel.core.sensors + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** Minimal 3-vector helpers for calibration rotations. Pure Kotlin, module-internal. */ +internal object Vec3Math { + + data class Vec3(val x: Double, val y: Double, val z: Double) + + val WORLD_UP_FLAT = Vec3(0.0, 0.0, 1.0) + + fun norm(v: Vec3): Double = sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + + fun normalize(v: Vec3): Vec3 { + val n = norm(v) + return if (n == 0.0) v else Vec3(v.x / n, v.y / n, v.z / n) + } + + fun dot(a: Vec3, b: Vec3): Double = a.x * b.x + a.y * b.y + a.z * b.z + + fun cross(a: Vec3, b: Vec3): Vec3 = Vec3( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + ) + + /** Rodrigues rotation of [v] by [angleRadians] about the UNIT axis [axis]. */ + fun rotate(v: Vec3, axis: Vec3, angleRadians: Double): Vec3 { + val c = cos(angleRadians) + val s = sin(angleRadians) + val kxv = cross(axis, v) + val kdv = dot(axis, v) + return Vec3( + v.x * c + kxv.x * s + axis.x * kdv * (1 - c), + v.y * c + kxv.y * s + axis.y * kdv * (1 - c), + v.z * c + kxv.z * s + axis.z * kdv * (1 - c), + ) + } +} diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt index 95dd7c6..08d03f1 100644 --- a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt +++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt @@ -30,7 +30,10 @@ class SettingsRepository(context: Context) { } val edgeCalibration: Flow = store.data.map { prefs -> - EdgeCalibration(levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0) + EdgeCalibration( + levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0, + calibratedOnPositiveXEdge = prefs[Keys.EDGE_CAL_POSITIVE_X] ?: true, + ) } val hapticsEnabled: Flow = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true } @@ -47,7 +50,10 @@ class SettingsRepository(context: Context) { } suspend fun setEdgeCalibration(calibration: EdgeCalibration) { - store.edit { it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees } + store.edit { + it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees + it[Keys.EDGE_CAL_POSITIVE_X] = calibration.calibratedOnPositiveXEdge + } } suspend fun setHapticsEnabled(enabled: Boolean) { @@ -68,6 +74,7 @@ class SettingsRepository(context: Context) { val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg") val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg") val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_bias_deg") + val EDGE_CAL_POSITIVE_X = booleanPreferencesKey("edge_cal_positive_x") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled") val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") diff --git a/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt b/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt index 4abed2a..7065495 100644 --- a/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt @@ -1,5 +1,6 @@ package com.onthelevel.feature.angle +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,12 +22,12 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.foundation.gestures.detectTapGestures import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.onthelevel.AppContainer import com.onthelevel.core.design.KeepScreenOn import com.onthelevel.core.design.LevelColors import com.onthelevel.core.sensors.Ema +import com.onthelevel.core.sensors.GravitySample import com.onthelevel.core.sensors.OrientationMath import com.onthelevel.feature.level.formatDegrees import com.onthelevel.feature.level.performConfirmHaptic @@ -34,6 +35,13 @@ import kotlinx.coroutines.flow.map /** * Scaffold Angle screen: live absolute/relative angle with hold-to-zero (always free). + * + * Relative zero stores the full gravity DIRECTION at the moment of zeroing, and the + * relative reading is the angle between the current and stored directions. Subtracting + * tilt magnitudes would lose the axis: zeroed at 10° pitch and moved to 10° roll, the + * device has genuinely rotated ~14°, and that is what this reports (Codex review). + * The relative reading is therefore unsigned. + * * TODO(pro): target-angle alerts and saved named references behind the entitlement. */ @Composable @@ -48,29 +56,43 @@ fun AngleScreen(container: AppContainer) { return } - data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double) - val readingFlow = remember { - val tiltEma = Ema(0.15) - val pitchEma = Ema(0.15) - val rollEma = Ema(0.15) + // Smooth the vector components, then derive angles — keeps the smoothed + // gravity direction available for the vector-based zero reference. + val xEma = Ema(0.15) + val yEma = Ema(0.15) + val zEma = Ema(0.15) var lastNanos: Long? = null sensorSource.gravity.map { g -> val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0 lastNanos = g.timestampNanos - AngleReading( - tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt), - pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt), - roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), dt), + GravitySample( + x = xEma.update(g.x, dt), + y = yEma.update(g.y, dt), + z = zEma.update(g.z, dt), + timestampNanos = g.timestampNanos, ) } } - val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null) + val smoothed by readingFlow.collectAsStateWithLifecycle(initialValue = null) - // Relative reference: "hold to zero" (BRIEF.md §Angle — always free). - var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) } + // Zero reference: the smoothed gravity direction captured on long-press. + // Empty array = no reference set. DoubleArray keeps rememberSaveable happy. + var zeroReference by rememberSaveable { mutableStateOf(doubleArrayOf()) } val view = LocalView.current + val primaryDegrees = smoothed?.let { s -> + if (zeroReference.size == 3) { + OrientationMath.angleBetweenDegrees( + s, + GravitySample(zeroReference[0], zeroReference[1], zeroReference[2], 0), + ) + } else { + OrientationMath.surfaceTiltMagnitudeDegrees(s) + } + } + val isRelative = zeroReference.size == 3 + Column( modifier = Modifier .fillMaxSize() @@ -82,7 +104,7 @@ fun AngleScreen(container: AppContainer) { ) { Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) Text( - text = if (zeroReferenceDegrees != 0.0) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO", + text = if (isRelative) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextFaint, ) @@ -92,27 +114,28 @@ fun AngleScreen(container: AppContainer) { modifier = Modifier .weight(1f) .fillMaxWidth() - .pointerInput(reading != null) { + .pointerInput(Unit) { detectTapGestures( onLongPress = { - reading?.let { - zeroReferenceDegrees = it.tilt + smoothed?.let { + zeroReference = doubleArrayOf(it.x, it.y, it.z) view.performConfirmHaptic() } }, + onDoubleTap = { zeroReference = doubleArrayOf() }, ) }, contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "—", + text = primaryDegrees?.let(::formatDegrees) ?: "—", style = MaterialTheme.typography.displayLarge, color = LevelColors.TextPrimary, ) - if (zeroReferenceDegrees != 0.0) { + if (isRelative) { Text( - text = "zeroed at " + formatDegrees(zeroReferenceDegrees), + text = "from saved orientation · double-tap to clear", style = MaterialTheme.typography.labelSmall, color = LevelColors.Amber, textAlign = TextAlign.Center, @@ -125,9 +148,18 @@ fun AngleScreen(container: AppContainer) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f)) - SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f)) - SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, Modifier.weight(1f)) + val pitch = smoothed?.let(OrientationMath::surfacePitchDegrees) + SecondaryValue("PITCH", pitch?.let(::formatDegrees), Modifier.weight(1f)) + SecondaryValue( + "ROLL", + smoothed?.let(OrientationMath::surfaceRollDegrees)?.let(::formatDegrees), + Modifier.weight(1f), + ) + SecondaryValue( + "GRADE", + pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, + Modifier.weight(1f), + ) } } } diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index 2becd59..7048fd9 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -8,7 +8,6 @@ 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 kotlin.math.hypot /** * One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3). @@ -23,14 +22,23 @@ data class LevelReading( val secondaryADegrees: Double, /** Surface: roll. Edge: plumb lean. Deadbanded. */ val secondaryBDegrees: Double, + /** False when the device is not physically in the selected mode's geometry. */ + val placementOk: Boolean, val isLocked: Boolean, val fireFeedback: Boolean, ) /** - * Stateful per-collection pipeline: calibration → EMA smoothing → lock detection → - * display deadband. Created fresh when mode or calibration changes; the LockDetector - * is shared across recreations so the haptic debounce survives mode switches. + * Stateful per-collection pipeline: vector-space calibration → angle derivation → + * EMA smoothing → lock detection → display deadband. Calibration is applied to the + * gravity VECTOR before any display angle is derived, so it stays a reference + * orientation rather than a scalar offset. Created fresh when mode or calibration + * 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 + * lock detection — asin(gy) reads near zero there too, and locking on it would be + * a lie. */ class LevelPipeline( private val mode: LevelMode, @@ -38,6 +46,7 @@ class LevelPipeline( private val edgeCalibration: EdgeCalibration, private val lockDetector: LockDetector, ) { + private val emaPrimary = Ema(SMOOTHING_TAU_SECONDS) private val emaA = Ema(SMOOTHING_TAU_SECONDS) private val emaB = Ema(SMOOTHING_TAU_SECONDS) private val primaryDeadband = DisplayDeadband() @@ -51,42 +60,52 @@ class LevelPipeline( // Sensor timestamps are monotonic; using them (not wall clock) keeps the // lock state machine deterministic under recorded traces. val nowMillis = g.timestampNanos / 1_000_000 + val placementOk = OrientationMath.isPlacementValid(g, mode) return when (mode) { LevelMode.SURFACE -> { - val pitch = emaA.update( - surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(g)), + val corrected = surfaceCalibration.apply(g) + val pitch = emaA.update(OrientationMath.surfacePitchDegrees(corrected), dtSeconds) + val roll = emaB.update(OrientationMath.surfaceRollDegrees(corrected), dtSeconds) + val magnitude = emaPrimary.update( + OrientationMath.surfaceTiltMagnitudeDegrees(corrected), dtSeconds, ) - val roll = emaB.update( - surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)), - dtSeconds, + val lock = lockDetector.update( + if (placementOk) magnitude else Double.MAX_VALUE, + nowMillis, ) - // Near level, calibrated tilt magnitude ≈ hypot of the two calibrated - // axis angles — keeps lock detection consistent with the displayed axes. - val magnitude = hypot(pitch, roll) - val lock = lockDetector.update(magnitude, nowMillis) LevelReading( mode = mode, displayPrimaryDegrees = primaryDeadband.update(magnitude), secondaryADegrees = aDeadband.update(pitch), secondaryBDegrees = bDeadband.update(roll), + placementOk = placementOk, isLocked = lock.isLocked, fireFeedback = lock.fireFeedback, ) } LevelMode.EDGE -> { - val level = emaA.update( - edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)), + val corrected = edgeCalibration.apply(g) + val level = emaPrimary.update( + OrientationMath.edgeLevelDegrees(corrected), dtSeconds, ) - val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds) - val lock = lockDetector.update(level, nowMillis) + val lean = emaB.update( + OrientationMath.edgePlumbLeanDegrees(corrected), + dtSeconds, + ) + val lock = lockDetector.update( + if (placementOk) level else Double.MAX_VALUE, + nowMillis, + ) + val displayLevel = primaryDeadband.update(level) LevelReading( mode = mode, - displayPrimaryDegrees = primaryDeadband.update(level), - secondaryADegrees = aDeadband.update(level), + displayPrimaryDegrees = displayLevel, + secondaryADegrees = displayLevel, secondaryBDegrees = bDeadband.update(lean), + placementOk = placementOk, isLocked = lock.isLocked, fireFeedback = lock.fireFeedback, ) diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index f982e85..97fa5dd 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -133,21 +133,26 @@ fun LevelScreen(container: AppContainer) { .fillMaxWidth(), contentAlignment = Alignment.Center, ) { + val placementOk = reading?.placementOk ?: true Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", + text = if (!placementOk) "—" else reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", style = MaterialTheme.typography.displayLarge, color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary, ) // Lock state is announced with a label, never color alone (BRIEF.md). + // The label states the tolerance (the lock's exit threshold), so the + // rounded readout and the "level" claim can never contradict. Text( text = when { - isLocked && mode == LevelMode.SURFACE -> "Surface is flat" - isLocked -> "Edge is level" + !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()}" + isLocked -> "Level within ${formatTolerance()}" else -> "" }, style = MaterialTheme.typography.headlineMedium, - color = LevelColors.LimeLockText, + color = if (placementOk) LevelColors.LimeLockText else LevelColors.TextDim, textAlign = TextAlign.Center, ) } @@ -213,6 +218,9 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String { internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value) +private fun formatTolerance(): String = + String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES) + internal fun View.performConfirmHaptic() { val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { HapticFeedbackConstants.CONFIRM diff --git a/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt index 1558ef1..d3c7fde 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt @@ -76,6 +76,35 @@ class OrientationMathTest { assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY) } + @Test + fun `angle between identical directions is zero`() { + assertEquals(0.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(10.0)), 1e-9) + } + + @Test + fun `angle between same-axis tilts is their difference`() { + assertEquals(15.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(25.0)), 1e-9) + } + + @Test + fun `angle between cross-axis tilts is directional, not a magnitude difference`() { + // Zeroed at 10° pitch, moved to 10° roll: magnitudes are equal (difference 0), + // but the device genuinely rotated acos(cos²10°) ≈ 14.1°. + val expected = Math.toDegrees( + kotlin.math.acos(cos(Math.toRadians(10.0)) * cos(Math.toRadians(10.0))), + ) + assertEquals(expected, OrientationMath.angleBetweenDegrees(pitched(10.0), rolled(10.0)), 1e-9) + assertTrue(expected > 14.0) + } + + @Test + fun `placement validity matches the selected mode's geometry`() { + assertTrue(OrientationMath.isPlacementValid(flat(), LevelMode.SURFACE)) + assertTrue(!OrientationMath.isPlacementValid(flat(), LevelMode.EDGE)) + assertTrue(OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.EDGE)) + assertTrue(!OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.SURFACE)) + } + @Test fun `zero vector does not produce NaN`() { val zero = GravitySample(0.0, 0.0, 0.0, 0) diff --git a/app/src/test/java/com/onthelevel/core/sensors/SensorContractTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SensorContractTest.kt new file mode 100644 index 0000000..5b48517 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/SensorContractTest.kt @@ -0,0 +1,64 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.math.cos +import kotlin.math.sin + +/** + * Pins the GravitySample contract: every sensor path yields device-frame WORLD-UP, + * with flat-screen-up = (0, 0, +g). + * + * Per the Android sensor docs (TYPE_ACCELEROMETER), a device stationary flat on a + * table reads +9.81 on Z: "the acceleration of the device (0 m/s²) minus the force + * of gravity (−9.81 m/s²)". TYPE_GRAVITY shares that convention (it is the isolated + * gravity component of the same signal), so both pass-through paths already match. + * These tests prove the rotation-vector conversion produces the identical vector, + * so NO sign adjustment is applied to any path. + */ +class SensorContractTest { + + private val g = RotationVectorMath.STANDARD_GRAVITY + + /** Device→world rotation matrix for a device pitched up by [degrees] about X. */ + private fun deviceToWorldPitch(degrees: Double): FloatArray { + val r = Math.toRadians(degrees) + return floatArrayOf( + 1f, 0f, 0f, + 0f, cos(r).toFloat(), (-sin(r)).toFloat(), + 0f, sin(r).toFloat(), cos(r).toFloat(), + ) + } + + @Test + fun `flat device - rotation path matches the documented gravity sensor output`() { + val identity = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f) + val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(identity) + // Documented TYPE_GRAVITY / TYPE_ACCELEROMETER flat output: (0, 0, +9.81). + assertEquals(0.0, x * g, 1e-6) + assertEquals(0.0, y * g, 1e-6) + assertEquals(g, z * g, 1e-6) + } + + @Test + fun `pitched device - rotation path matches the analytic gravity vector`() { + val theta = 10.0 + val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(deviceToWorldPitch(theta)) + val fromRotation = GravitySample(x * g, y * g, z * g, 0) + + // The gravity-sensor path for the same physical orientation: + val r = Math.toRadians(theta) + val fromGravitySensor = GravitySample(0.0, g * sin(r), g * cos(r), 0) + + assertEquals(fromGravitySensor.x, fromRotation.x, 1e-6) + assertEquals(fromGravitySensor.y, fromRotation.y, 1e-6) + assertEquals(fromGravitySensor.z, fromRotation.z, 1e-6) + + // And both derive the same pitch through the measurement math: + assertEquals( + OrientationMath.surfacePitchDegrees(fromGravitySensor), + OrientationMath.surfacePitchDegrees(fromRotation), + 1e-6, + ) + } +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt index 0632caa..3a23b88 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt @@ -1,10 +1,24 @@ package com.onthelevel.core.sensors +import com.onthelevel.core.sensors.Vec3Math.Vec3 import org.junit.Assert.assertEquals import org.junit.Test +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan class TwoSampleCalibrationTest { + private val g = 9.80665 + + private fun sample(v: Vec3) = GravitySample(v.x, v.y, v.z, 0) + + private fun pitched(degrees: Double): GravitySample { + val r = Math.toRadians(degrees) + return GravitySample(0.0, g * sin(r), g * cos(r), 0) + } + @Test fun `flip cancels true tilt and isolates device bias`() { // Surface truly tilted 0.5°, device bias +0.3°: @@ -12,8 +26,6 @@ class TwoSampleCalibrationTest { val reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2) assertEquals(0.3, bias, 1e-9) - // Applying the bias recovers the true tilt from the original reading: - assertEquals(0.5, reading1 - bias, 1e-9) } @Test @@ -24,24 +36,113 @@ class TwoSampleCalibrationTest { ) assertEquals(0.3, cal.pitchBiasDegrees, 1e-9) assertEquals(0.2, cal.rollBiasDegrees, 1e-9) - assertEquals(0.5, cal.applyToPitch(0.8), 1e-9) - assertEquals(-0.3, cal.applyToRoll(-0.1), 1e-9) } @Test - fun `unbiased device on a level surface derives zero bias`() { - val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0) - assertEquals(0.0, cal.levelBiasDegrees, 1e-9) - assertEquals(1.2, cal.applyToLevel(1.2), 1e-9) + fun `surface correction zeroes a biased level placement - vector space`() { + val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = -0.3) + // The reference direction: what a biased device measures on a TRULY level surface. + val measuredAtLevel = sample( + Vec3Math.normalize( + Vec3( + tan(Math.toRadians(cal.rollBiasDegrees)), + tan(Math.toRadians(cal.pitchBiasDegrees)), + 1.0, + ), + ).let { Vec3(it.x * g, it.y * g, it.z * g) }, + ) + val corrected = cal.apply(measuredAtLevel) + assertEquals(0.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9) + assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9) + assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-6) } @Test - fun `surface and edge calibrations are independent types applied per mode`() { + fun `surface correction is exact away from zero - same axis`() { + // Misalignment purely about X by 0.5°: a true pitch of 30° measures as 30.5°. + val cal = SurfaceCalibration(pitchBiasDegrees = 0.5, rollBiasDegrees = 0.0) + val measured = pitched(30.5) + val corrected = cal.apply(measured) + assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9) + assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9) + } + + @Test + fun `surface correction is exact away from zero - cross axis`() { + // General misalignment: pitch bias 0.4°, roll bias 0.3°. Build the measured + // sample by applying the INVERSE of the correction rotation to the true + // 30°-pitched gravity, then verify the correction recovers it exactly. + val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = 0.3) + val reference = Vec3Math.normalize( + Vec3( + tan(Math.toRadians(cal.rollBiasDegrees)), + tan(Math.toRadians(cal.pitchBiasDegrees)), + 1.0, + ), + ) + val axis = Vec3Math.normalize(Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT)) + val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0)) + val true30 = pitched(30.0) + val measured = sample(Vec3Math.rotate(Vec3(true30.x, true30.y, true30.z), axis, -angle)) + + val corrected = cal.apply(measured) + assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9) + assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9) + assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9) + } + + @Test + fun `edge correction is exact away from zero and leaves lean untouched`() { + // Misalignment about Z by 0.3° on the positive-X edge; true in-plane tip 10°. + val bias = 0.3 + val cal = EdgeCalibration(levelBiasDegrees = bias, calibratedOnPositiveXEdge = true) + fun onEdgeMeasured(tipDegrees: Double): GravitySample { + val r = Math.toRadians(tipDegrees + bias) // Rz misalignment adds directly in-plane + return GravitySample(g * cos(r), g * sin(r), 0.0, 0) + } + + assertEquals( + 0.0, + OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(0.0))), + 1e-9, + ) + assertEquals( + 10.0, + OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(10.0))), + 1e-9, + ) + // Lean (device Z component) is untouched by the Z-rotation correction. + val leaned = GravitySample(g * 0.99, 0.05, g * 0.1, 0) + assertEquals( + OrientationMath.edgePlumbLeanDegrees(leaned), + OrientationMath.edgePlumbLeanDegrees(cal.apply(leaned)), + 1e-9, + ) + } + + @Test + fun `edge polarity flips the correction direction`() { + val positive = EdgeCalibration(0.3, calibratedOnPositiveXEdge = true) + val negative = EdgeCalibration(0.3, calibratedOnPositiveXEdge = false) + val s = GravitySample(g, 0.1, 0.0, 0) + val correctedPositive = positive.apply(s) + val correctedNegative = negative.apply(s) + // Opposite rotation directions about Z: + assertEquals( + OrientationMath.edgeLevelDegrees(correctedPositive) - OrientationMath.edgeLevelDegrees(s), + -(OrientationMath.edgeLevelDegrees(correctedNegative) - OrientationMath.edgeLevelDegrees(s)), + 1e-6, + ) + } + + @Test + fun `surface and edge calibrations are independent and applied per mode`() { val surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0) val edge = EdgeCalibration.NONE - // An edge reading passed through the untouched edge calibration is unchanged, + // An edge sample passed through the untouched edge calibration is unchanged, // regardless of surface calibration state (AUDIT.md finding 3). - assertEquals(0.7, edge.applyToLevel(0.7), 1e-9) + val edgeSample = GravitySample(g, 0.12, 0.0, 0) + assertEquals(edgeSample, edge.apply(edgeSample)) assertEquals(0.5, surface.pitchBiasDegrees, 1e-9) } } diff --git a/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt new file mode 100644 index 0000000..7c76ed6 --- /dev/null +++ b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt @@ -0,0 +1,109 @@ +package com.onthelevel.feature.level + +import com.onthelevel.core.sensors.EdgeCalibration +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.cos +import kotlin.math.sin + +/** + * Acceptance tests for lock/readout coherence (Codex review): whenever the pipeline + * reports a lock, the deadbanded readout must stay within the tolerance the locked + * label states (the lock's exit threshold). Also pins placement gating. + */ +class LevelPipelineTest { + + private val g = 9.80665 + + private fun pitched(degrees: Double, tMillis: Long): GravitySample { + val r = Math.toRadians(degrees) + return GravitySample(0.0, g * sin(r), g * cos(r), tMillis * 1_000_000) + } + + private fun flat(tMillis: Long) = pitched(0.0, tMillis) + + private fun surfacePipeline() = LevelPipeline( + mode = LevelMode.SURFACE, + surfaceCalibration = SurfaceCalibration.NONE, + edgeCalibration = EdgeCalibration.NONE, + lockDetector = LockDetector(), + ) + + /** Feed [degrees] steadily from t=[fromMillis] to t=[untilMillis] at 20 ms. */ + private fun run( + pipeline: LevelPipeline, + degrees: Double, + fromMillis: Long, + untilMillis: Long, + ): List = + (fromMillis..untilMillis step 20).map { pipeline.process(pitched(degrees, it)) } + + @Test + fun `steady near-level tilt locks and readout stays within stated tolerance`() { + val pipeline = surfacePipeline() + val readings = run(pipeline, degrees = 0.18, fromMillis = 0, untilMillis = 2_000) + + assertTrue("expected a lock after dwell", readings.last().isLocked) + assertEquals(1, readings.count { it.fireFeedback }) + readings.filter { it.isLocked }.forEach { + assertTrue( + "locked reading displayed ${it.displayPrimaryDegrees}° above tolerance", + it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES, + ) + } + } + + @Test + fun `coherence invariant holds while drifting inside hysteresis, then unlocks`() { + val pipeline = surfacePipeline() + run(pipeline, degrees = 0.1, fromMillis = 0, untilMillis = 1_000) // acquire lock + // Drift to 0.30° — inside hysteresis (exit is 0.35°), so the lock holds and + // the displayed value (0.3°) must still be within the stated tolerance. + val drifted = run(pipeline, degrees = 0.30, fromMillis = 1_020, untilMillis = 3_000) + assertTrue(drifted.last().isLocked) + drifted.filter { it.isLocked }.forEach { + assertTrue(it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES) + } + // Past the exit threshold the lock must release. + val tilted = run(pipeline, degrees = 0.6, fromMillis = 3_020, untilMillis = 5_000) + assertFalse(tilted.last().isLocked) + } + + @Test + fun `edge mode never locks while the phone lies flat on a table`() { + val pipeline = LevelPipeline( + mode = LevelMode.EDGE, + surfaceCalibration = SurfaceCalibration.NONE, + edgeCalibration = EdgeCalibration.NONE, + lockDetector = LockDetector(), + ) + // Flat on a table, Edge mode selected: edgeLevelDegrees(g) is ~0 — without + // placement gating this would lock on a meaningless reading. + val readings = (0L..2_000L step 20).map { pipeline.process(flat(it)) } + readings.forEach { + assertFalse(it.placementOk) + assertFalse(it.isLocked) + } + } + + @Test + fun `edge mode locks on a genuinely level edge placement`() { + val pipeline = LevelPipeline( + mode = LevelMode.EDGE, + surfaceCalibration = SurfaceCalibration.NONE, + edgeCalibration = EdgeCalibration.NONE, + lockDetector = LockDetector(), + ) + val readings = (0L..2_000L step 20).map { + pipeline.process(GravitySample(g, 0.0, 0.0, it * 1_000_000)) + } + assertTrue(readings.last().placementOk) + assertTrue(readings.last().isLocked) + } +}