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