Address Codex measurement-core review

- Calibration: still derived in angle space via the 180-degree flip, but now
  APPLIED in vector space as a reference-orientation rotation (Rodrigues
  alignment for Surface, Z-rotation for Edge), exact away from zero; tests
  at 30 degrees, cross-axis, and edge-polarity cases.
- Angle relative zero: stores the gravity direction vector; relative reading
  is the angle between directions, so cross-axis movement is honest.
- Edge mode: precise geometry documented (either long edge down, gravity
  along +/-X), placement-validity guard so e.g. Edge mode never locks on a
  phone lying flat; UI shows repositioning hints.
- Lock/readout coherence: locked label states the tolerance (exit threshold)
  so the rounded readout can never contradict it; pipeline acceptance tests
  pin the invariant.
- Sensor-vector contract: documented and pinned by SensorContractTest. The
  suggested negation of gravity/accelerometer fallbacks is NOT applied: per
  Android SensorEvent docs, a stationary flat device reads +9.81 on Z (the
  gravity reaction), matching the rotation-vector path as-is. The contract
  tests prove all paths agree.

38 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-11 19:45:53 -04:00
parent 2a48d79c77
commit 99c2b12192
16 changed files with 658 additions and 87 deletions
@@ -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,
)
}
@@ -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 }
@@ -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"
@@ -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)
}
@@ -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<Double, Double, Double> {
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
}
@@ -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) }
}
@@ -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),
)
}
}
@@ -30,7 +30,10 @@ class SettingsRepository(context: Context) {
}
val edgeCalibration: Flow<EdgeCalibration> = 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<Boolean> = 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")
@@ -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),
)
}
}
}
@@ -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,
)
@@ -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
@@ -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)
@@ -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,
)
}
}
@@ -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)
}
}
@@ -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<LevelReading> =
(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)
}
}