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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user