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
+9 -3
View File
@@ -23,12 +23,18 @@ Implemented and tested:
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed - `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
accelerometer), device-frame gravity stream, and the pure measurement math: accelerometer), device-frame gravity stream, and the pure measurement math:
orientation mapping, two-sample per-mode calibration, EMA smoothing, orientation mapping, two-sample per-mode calibration (derived in angle space,
lock hysteresis/dwell/debounce, display deadband. All unit-tested. **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/settings` — DataStore preferences and per-mode calibration persistence.
- `core/billing` — entitlement interface with a stub (free-tier) implementation. - `core/billing` — entitlement interface with a stub (free-tier) implementation.
- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real - `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. - `feature/ruler`, `feature/tools` — static placeholders.
Not yet implemented (deliberately — see TODO markers): Not yet implemented (deliberately — see TODO markers):
@@ -49,12 +49,13 @@ class AndroidSensorSource(context: Context) : SensorSource {
val sample = when (sensorKind) { val sample = when (sensorKind) {
SensorSource.Kind.GAME_ROTATION_VECTOR -> { SensorSource.Kind.GAME_ROTATION_VECTOR -> {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// R maps device → world; world-up expressed in the device frame // Converted to the GravitySample contract (device-frame
// is R's third row. Scale to standard gravity. // world-up); see RotationVectorMath and SensorContractTest.
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(rotationMatrix)
GravitySample( GravitySample(
x = rotationMatrix[6] * STANDARD_GRAVITY, x = x * STANDARD_GRAVITY,
y = rotationMatrix[7] * STANDARD_GRAVITY, y = y * STANDARD_GRAVITY,
z = rotationMatrix[8] * STANDARD_GRAVITY, z = z * STANDARD_GRAVITY,
timestampNanos = event.timestamp, timestampNanos = event.timestamp,
) )
} }
@@ -1,9 +1,16 @@
package com.onthelevel.core.sensors 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. * 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( data class GravitySample(
val x: Double, val x: Double,
@@ -12,5 +19,20 @@ data class GravitySample(
val timestampNanos: Long, 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 } enum class LevelMode { SURFACE, EDGE }
@@ -11,11 +11,21 @@ import kotlin.math.abs
* Time is injected (callers pass `nowMillis`) so transitions are unit-testable. * Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
*/ */
class LockDetector( class LockDetector(
private val enterThresholdDegrees: Double = 0.2, private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
private val exitThresholdDegrees: Double = 0.35, private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
private val dwellMillis: Long = 400, private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
private val feedbackDebounceMillis: Long = 3_000, 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 { init {
require(exitThresholdDegrees > enterThresholdDegrees) { require(exitThresholdDegrees > enterThresholdDegrees) {
"Hysteresis requires exit > enter threshold" "Hysteresis requires exit > enter threshold"
@@ -4,6 +4,7 @@ import kotlin.math.abs
import kotlin.math.acos import kotlin.math.acos
import kotlin.math.asin import kotlin.math.asin
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.math.tan import kotlin.math.tan
@@ -62,5 +63,37 @@ 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
* 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) 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 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). * 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 * DERIVATION — the surface's true tilt is fixed in the world frame; the device's own bias
* in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the * is fixed in the device frame. After rotating the device 180° about the CONTACT-PLANE
* same, unmoved surface — the true tilt appears negated in device readings while the bias * NORMAL — on the same, unmoved surface — the true tilt appears negated in device
* does not move: * readings while the bias does not move:
* *
* reading₁ = tilt + bias * reading₁ = tilt + bias
* reading₂ = -tilt + bias * reading₂ = -tilt + bias
* ⇒ bias = (reading₁ + reading₂) / 2 * ⇒ bias = (reading₁ + reading₂) / 2
* *
* This holds per axis for the small near-level angles calibration is used at. It is only * Derivation happens in angle space near level, where the flip identity is exact to
* valid if (a) the rotation is about the contact-plane normal and (b) the surface does not * first order. Preconditions the calibration UI must enforce: (a) rotation about the
* move between samples — the calibration UI must instruct exactly that. * 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 * 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). * calibration must never touch Edge readings (AUDIT.md finding 3).
@@ -33,19 +47,68 @@ object TwoSampleCalibration {
rollBiasDegrees = deriveBiasDegrees(roll1, roll2), rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
) )
fun deriveEdge(level1: Double, level2: Double): EdgeCalibration = fun deriveEdge(
EdgeCalibration(levelBiasDegrees = deriveBiasDegrees(level1, level2)) 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) { 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) } 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) } 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 -> 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 } 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) { 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) { suspend fun setHapticsEnabled(enabled: Boolean) {
@@ -68,6 +74,7 @@ class SettingsRepository(context: Context) {
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg") val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg") val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_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 HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled") val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
@@ -1,5 +1,6 @@
package com.onthelevel.feature.angle package com.onthelevel.feature.angle
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.platform.LocalView
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.Ema import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.OrientationMath import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.feature.level.formatDegrees import com.onthelevel.feature.level.formatDegrees
import com.onthelevel.feature.level.performConfirmHaptic 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). * 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. * TODO(pro): target-angle alerts and saved named references behind the entitlement.
*/ */
@Composable @Composable
@@ -48,29 +56,43 @@ fun AngleScreen(container: AppContainer) {
return return
} }
data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double)
val readingFlow = remember { val readingFlow = remember {
val tiltEma = Ema(0.15) // Smooth the vector components, then derive angles — keeps the smoothed
val pitchEma = Ema(0.15) // gravity direction available for the vector-based zero reference.
val rollEma = Ema(0.15) val xEma = Ema(0.15)
val yEma = Ema(0.15)
val zEma = Ema(0.15)
var lastNanos: Long? = null var lastNanos: Long? = null
sensorSource.gravity.map { g -> sensorSource.gravity.map { g ->
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0 val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = g.timestampNanos lastNanos = g.timestampNanos
AngleReading( GravitySample(
tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt), x = xEma.update(g.x, dt),
pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt), y = yEma.update(g.y, dt),
roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), 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). // Zero reference: the smoothed gravity direction captured on long-press.
var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) } // Empty array = no reference set. DoubleArray keeps rememberSaveable happy.
var zeroReference by rememberSaveable { mutableStateOf(doubleArrayOf()) }
val view = LocalView.current 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -82,7 +104,7 @@ fun AngleScreen(container: AppContainer) {
) { ) {
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text( 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, style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextFaint, color = LevelColors.TextFaint,
) )
@@ -92,27 +114,28 @@ fun AngleScreen(container: AppContainer) {
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.pointerInput(reading != null) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onLongPress = { onLongPress = {
reading?.let { smoothed?.let {
zeroReferenceDegrees = it.tilt zeroReference = doubleArrayOf(it.x, it.y, it.z)
view.performConfirmHaptic() view.performConfirmHaptic()
} }
}, },
onDoubleTap = { zeroReference = doubleArrayOf() },
) )
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "", text = primaryDegrees?.let(::formatDegrees) ?: "",
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary, color = LevelColors.TextPrimary,
) )
if (zeroReferenceDegrees != 0.0) { if (isRelative) {
Text( Text(
text = "zeroed at " + formatDegrees(zeroReferenceDegrees), text = "from saved orientation · double-tap to clear",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber, color = LevelColors.Amber,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -125,9 +148,18 @@ fun AngleScreen(container: AppContainer) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f)) val pitch = smoothed?.let(OrientationMath::surfacePitchDegrees)
SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f)) SecondaryValue("PITCH", pitch?.let(::formatDegrees), Modifier.weight(1f))
SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, 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.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 kotlin.math.hypot
/** /**
* 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).
@@ -23,14 +22,23 @@ data class LevelReading(
val secondaryADegrees: Double, val secondaryADegrees: Double,
/** Surface: roll. Edge: plumb lean. Deadbanded. */ /** Surface: roll. Edge: plumb lean. Deadbanded. */
val secondaryBDegrees: Double, val secondaryBDegrees: Double,
/** False when the device is not physically in the selected mode's geometry. */
val placementOk: Boolean,
val isLocked: Boolean, val isLocked: Boolean,
val fireFeedback: Boolean, val fireFeedback: Boolean,
) )
/** /**
* Stateful per-collection pipeline: calibration → EMA smoothing → lock detection → * Stateful per-collection pipeline: vector-space calibration → angle derivation →
* display deadband. Created fresh when mode or calibration changes; the LockDetector * EMA smoothing → lock detection → display deadband. Calibration is applied to the
* is shared across recreations so the haptic debounce survives mode switches. * 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( class LevelPipeline(
private val mode: LevelMode, private val mode: LevelMode,
@@ -38,6 +46,7 @@ class LevelPipeline(
private val edgeCalibration: EdgeCalibration, private val edgeCalibration: EdgeCalibration,
private val lockDetector: LockDetector, private val lockDetector: LockDetector,
) { ) {
private val emaPrimary = Ema(SMOOTHING_TAU_SECONDS)
private val emaA = Ema(SMOOTHING_TAU_SECONDS) private val emaA = Ema(SMOOTHING_TAU_SECONDS)
private val emaB = Ema(SMOOTHING_TAU_SECONDS) private val emaB = Ema(SMOOTHING_TAU_SECONDS)
private val primaryDeadband = DisplayDeadband() private val primaryDeadband = DisplayDeadband()
@@ -51,42 +60,52 @@ class LevelPipeline(
// Sensor timestamps are monotonic; using them (not wall clock) keeps the // Sensor timestamps are monotonic; using them (not wall clock) keeps the
// lock state machine deterministic under recorded traces. // lock state machine deterministic under recorded traces.
val nowMillis = g.timestampNanos / 1_000_000 val nowMillis = g.timestampNanos / 1_000_000
val placementOk = OrientationMath.isPlacementValid(g, mode)
return when (mode) { return when (mode) {
LevelMode.SURFACE -> { LevelMode.SURFACE -> {
val pitch = emaA.update( val corrected = surfaceCalibration.apply(g)
surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(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, dtSeconds,
) )
val roll = emaB.update( val lock = lockDetector.update(
surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)), if (placementOk) magnitude else Double.MAX_VALUE,
dtSeconds, 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( LevelReading(
mode = mode, mode = mode,
displayPrimaryDegrees = primaryDeadband.update(magnitude), displayPrimaryDegrees = primaryDeadband.update(magnitude),
secondaryADegrees = aDeadband.update(pitch), secondaryADegrees = aDeadband.update(pitch),
secondaryBDegrees = bDeadband.update(roll), secondaryBDegrees = bDeadband.update(roll),
placementOk = placementOk,
isLocked = lock.isLocked, isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback, fireFeedback = lock.fireFeedback,
) )
} }
LevelMode.EDGE -> { LevelMode.EDGE -> {
val level = emaA.update( val corrected = edgeCalibration.apply(g)
edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)), val level = emaPrimary.update(
OrientationMath.edgeLevelDegrees(corrected),
dtSeconds, dtSeconds,
) )
val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds) val lean = emaB.update(
val lock = lockDetector.update(level, nowMillis) OrientationMath.edgePlumbLeanDegrees(corrected),
dtSeconds,
)
val lock = lockDetector.update(
if (placementOk) level else Double.MAX_VALUE,
nowMillis,
)
val displayLevel = primaryDeadband.update(level)
LevelReading( LevelReading(
mode = mode, mode = mode,
displayPrimaryDegrees = primaryDeadband.update(level), displayPrimaryDegrees = displayLevel,
secondaryADegrees = aDeadband.update(level), secondaryADegrees = displayLevel,
secondaryBDegrees = bDeadband.update(lean), secondaryBDegrees = bDeadband.update(lean),
placementOk = placementOk,
isLocked = lock.isLocked, isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback, fireFeedback = lock.fireFeedback,
) )
@@ -133,21 +133,26 @@ fun LevelScreen(container: AppContainer) {
.fillMaxWidth(), .fillMaxWidth(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val placementOk = reading?.placementOk ?: true
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "", text = if (!placementOk) "" else 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,
) )
// Lock state is announced with a label, never color alone (BRIEF.md). // 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(
text = when { text = when {
isLocked && mode == LevelMode.SURFACE -> "Surface is flat" !placementOk && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
isLocked -> "Edge is level" !placementOk -> "Stand the phone upright on a long edge"
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
isLocked -> "Level within ${formatTolerance()}"
else -> "" else -> ""
}, },
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = LevelColors.LimeLockText, color = if (placementOk) LevelColors.LimeLockText else LevelColors.TextDim,
textAlign = TextAlign.Center, 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) 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() { internal fun View.performConfirmHaptic() {
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.CONFIRM HapticFeedbackConstants.CONFIRM
@@ -76,6 +76,35 @@ class OrientationMathTest {
assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY) 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 @Test
fun `zero vector does not produce NaN`() { fun `zero vector does not produce NaN`() {
val zero = GravitySample(0.0, 0.0, 0.0, 0) 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 package com.onthelevel.core.sensors
import com.onthelevel.core.sensors.Vec3Math.Vec3
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tan
class TwoSampleCalibrationTest { 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 @Test
fun `flip cancels true tilt and isolates device bias`() { fun `flip cancels true tilt and isolates device bias`() {
// Surface truly tilted 0.5°, device bias +0.3°: // 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 reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2) val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
assertEquals(0.3, bias, 1e-9) 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 @Test
@@ -24,24 +36,113 @@ class TwoSampleCalibrationTest {
) )
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9) assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
assertEquals(0.2, cal.rollBiasDegrees, 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 @Test
fun `unbiased device on a level surface derives zero bias`() { fun `surface correction zeroes a biased level placement - vector space`() {
val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0) val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = -0.3)
assertEquals(0.0, cal.levelBiasDegrees, 1e-9) // The reference direction: what a biased device measures on a TRULY level surface.
assertEquals(1.2, cal.applyToLevel(1.2), 1e-9) 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 @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 surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
val edge = EdgeCalibration.NONE 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). // 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) 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)
}
}