Slice 2: guided Surface calibration flow (Codex)
- SurfaceCalibrationScreen: two-sample guided flow from the Level header. Captures read SensorSource.gravity directly - stored calibration is never applied to either sample. Exact 180-degree same-plane guidance, clear-saved-calibration action, Surface persistence only. - SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s exit held 500 ms; mid-band movement resets the quiet dwell without leaving Settling (audit finding 1). - StableSurfaceCapture (core/sensors): continuous 1.5 s settled window with the 5-degree calibration-surface guard; restarts on motion. - SurfaceCalibrationValidation (core/sensors): 3-degree bound on the combined two-axis bias magnitude, unit-tested both sides of the boundary (audit finding 2). - Lock feedback suppression is structural: the calibration route removes LevelScreen from composition, cancelling its sensor collection. 50 tests passing. Audited-by: Claude (2 findings raised and resolved) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import com.onthelevel.core.design.OnTheLevelTheme
|
||||
import com.onthelevel.feature.angle.AngleScreen
|
||||
import com.onthelevel.feature.level.LevelScreen
|
||||
import com.onthelevel.feature.level.SurfaceCalibrationScreen
|
||||
import com.onthelevel.feature.ruler.RulerScreen
|
||||
import com.onthelevel.feature.tools.ToolsScreen
|
||||
|
||||
@@ -87,12 +88,19 @@ private fun AppRoot(container: AppContainer) {
|
||||
startDestination = "level",
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable("level") { LevelScreen(container) }
|
||||
composable("level") {
|
||||
LevelScreen(container, onOpenSurfaceCalibration = {
|
||||
navController.navigate("surface-calibration")
|
||||
})
|
||||
}
|
||||
composable("angle") { AngleScreen(container) }
|
||||
composable("tools") {
|
||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
||||
}
|
||||
composable("ruler") { RulerScreen(container) }
|
||||
composable("surface-calibration") {
|
||||
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.hypot
|
||||
|
||||
/** Shared two-axis motion gate for calibration, guidance, and Audio Assist. */
|
||||
class SettlingDetector(
|
||||
private val enterRateDegreesPerSecond: Double = ENTER_RATE_DEGREES_PER_SECOND,
|
||||
private val exitRateDegreesPerSecond: Double = EXIT_RATE_DEGREES_PER_SECOND,
|
||||
private val settledDwellMillis: Long = SETTLED_DWELL_MILLIS,
|
||||
) {
|
||||
data class Result(val isSettling: Boolean, val movementRateDegreesPerSecond: Double)
|
||||
|
||||
private var lastPitchDegrees: Double? = null
|
||||
private var lastRollDegrees: Double? = null
|
||||
private var lastTimestampNanos: Long? = null
|
||||
private var settledSinceMillis: Long? = null
|
||||
private var isSettling = true
|
||||
|
||||
fun update(pitchDegrees: Double, rollDegrees: Double, timestampNanos: Long): Result {
|
||||
val previousPitch = lastPitchDegrees
|
||||
val previousRoll = lastRollDegrees
|
||||
val previousTimestamp = lastTimestampNanos
|
||||
lastPitchDegrees = pitchDegrees
|
||||
lastRollDegrees = rollDegrees
|
||||
lastTimestampNanos = timestampNanos
|
||||
|
||||
if (previousPitch == null || previousRoll == null || previousTimestamp == null) {
|
||||
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
|
||||
}
|
||||
val dtSeconds = (timestampNanos - previousTimestamp) / 1e9
|
||||
if (dtSeconds <= 0.0) {
|
||||
reset()
|
||||
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
|
||||
}
|
||||
|
||||
val rate = hypot(pitchDegrees - previousPitch, rollDegrees - previousRoll) / dtSeconds
|
||||
val nowMillis = timestampNanos / 1_000_000
|
||||
when {
|
||||
rate > enterRateDegreesPerSecond -> {
|
||||
isSettling = true
|
||||
settledSinceMillis = null
|
||||
}
|
||||
isSettling && rate > exitRateDegreesPerSecond -> {
|
||||
// A mid-band hand nudge is not enough to leave Settling, but it is
|
||||
// enough to prove the signal has not been continuously quiet for
|
||||
// the required dwell window.
|
||||
settledSinceMillis = null
|
||||
}
|
||||
isSettling && rate <= exitRateDegreesPerSecond -> {
|
||||
val since = settledSinceMillis ?: nowMillis.also { settledSinceMillis = it }
|
||||
if (nowMillis - since >= settledDwellMillis) isSettling = false
|
||||
}
|
||||
}
|
||||
return Result(isSettling = isSettling, movementRateDegreesPerSecond = rate)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastPitchDegrees = null
|
||||
lastRollDegrees = null
|
||||
lastTimestampNanos = null
|
||||
settledSinceMillis = null
|
||||
isSettling = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ENTER_RATE_DEGREES_PER_SECOND = 1.0
|
||||
const val EXIT_RATE_DEGREES_PER_SECOND = 0.3
|
||||
const val SETTLED_DWELL_MILLIS = 500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
/** Uncalibrated, smoothed Surface sample used by guided calibration. */
|
||||
data class SurfaceCalibrationSample(
|
||||
val pitchDegrees: Double,
|
||||
val rollDegrees: Double,
|
||||
val tiltDegrees: Double,
|
||||
val timestampNanos: Long,
|
||||
val isSettling: Boolean,
|
||||
)
|
||||
|
||||
/** Mean of one settled 1.5-second calibration capture. */
|
||||
data class CapturedSurfaceSample(val pitchDegrees: Double, val rollDegrees: Double)
|
||||
|
||||
/** Collects a continuous settled capture on a roughly-level surface. */
|
||||
class StableSurfaceCapture(
|
||||
private val durationMillis: Long = CAPTURE_DURATION_MILLIS,
|
||||
private val maximumTiltDegrees: Double = MAXIMUM_CALIBRATION_TILT_DEGREES,
|
||||
) {
|
||||
private var startedAtNanos: Long? = null
|
||||
private var pitchTotal = 0.0
|
||||
private var rollTotal = 0.0
|
||||
private var count = 0
|
||||
|
||||
fun add(sample: SurfaceCalibrationSample): CapturedSurfaceSample? {
|
||||
if (sample.isSettling || sample.tiltDegrees > maximumTiltDegrees) {
|
||||
reset()
|
||||
return null
|
||||
}
|
||||
val startedAt = startedAtNanos ?: sample.timestampNanos.also { startedAtNanos = it }
|
||||
pitchTotal += sample.pitchDegrees
|
||||
rollTotal += sample.rollDegrees
|
||||
count += 1
|
||||
if ((sample.timestampNanos - startedAt) / 1_000_000 < durationMillis || count < 2) return null
|
||||
return CapturedSurfaceSample(pitchTotal / count, rollTotal / count)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
startedAtNanos = null
|
||||
pitchTotal = 0.0
|
||||
rollTotal = 0.0
|
||||
count = 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CAPTURE_DURATION_MILLIS = 1_500L
|
||||
const val MAXIMUM_CALIBRATION_TILT_DEGREES = 5.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.hypot
|
||||
|
||||
/**
|
||||
* Guards calibration persistence against an inconsistent two-sample turn. The bound
|
||||
* applies to the combined two-axis correction, not each axis independently.
|
||||
*/
|
||||
object SurfaceCalibrationValidation {
|
||||
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
|
||||
|
||||
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
|
||||
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
|
||||
}
|
||||
@@ -49,6 +49,8 @@ class SettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSurfaceCalibration() = setSurfaceCalibration(SurfaceCalibration.NONE)
|
||||
|
||||
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
|
||||
store.edit {
|
||||
it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees
|
||||
|
||||
@@ -54,7 +54,7 @@ import java.util.Locale
|
||||
* while the portrait-locked device stands on its long edge.
|
||||
*/
|
||||
@Composable
|
||||
fun LevelScreen(container: AppContainer) {
|
||||
fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
KeepScreenOn()
|
||||
|
||||
val sensorSource = container.sensorSource
|
||||
@@ -107,7 +107,7 @@ fun LevelScreen(container: AppContainer) {
|
||||
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
|
||||
IconButton(onClick = onOpenSurfaceCalibration) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = "Calibration and settings",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.onthelevel.feature.level
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.CapturedSurfaceSample
|
||||
import com.onthelevel.core.sensors.Ema
|
||||
import com.onthelevel.core.sensors.OrientationMath
|
||||
import com.onthelevel.core.sensors.SettlingDetector
|
||||
import com.onthelevel.core.sensors.StableSurfaceCapture
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import com.onthelevel.core.sensors.SurfaceCalibrationSample
|
||||
import com.onthelevel.core.sensors.SurfaceCalibrationValidation
|
||||
import com.onthelevel.core.sensors.TwoSampleCalibration
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class CalibrationPhase { FIRST_READY, FIRST_CAPTURING, SECOND_READY, SECOND_CAPTURING, COMPLETE, ERROR }
|
||||
|
||||
/**
|
||||
* Guided two-sample Surface calibration. Capture reads SensorSource.gravity directly
|
||||
* and never applies stored SurfaceCalibration to either sample.
|
||||
*/
|
||||
@Composable
|
||||
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
KeepScreenOn()
|
||||
|
||||
val sensorSource = container.sensorSource
|
||||
val scope = rememberCoroutineScope()
|
||||
var phase by remember { mutableStateOf(CalibrationPhase.FIRST_READY) }
|
||||
var firstCapture by remember { mutableStateOf<CapturedSurfaceSample?>(null) }
|
||||
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val existingCalibration by container.settings.surfaceCalibration
|
||||
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
|
||||
|
||||
val rawReadingFlow = remember(sensorSource) {
|
||||
val pitchEma = Ema(0.15)
|
||||
val rollEma = Ema(0.15)
|
||||
val tiltEma = Ema(0.15)
|
||||
val settling = SettlingDetector()
|
||||
var lastNanos: Long? = null
|
||||
sensorSource.gravity.map { raw ->
|
||||
val dtSeconds = lastNanos?.let { (raw.timestampNanos - it) / 1e9 } ?: 0.0
|
||||
lastNanos = raw.timestampNanos
|
||||
val pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(raw), dtSeconds)
|
||||
val roll = rollEma.update(OrientationMath.surfaceRollDegrees(raw), dtSeconds)
|
||||
val tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(raw), dtSeconds)
|
||||
val settlingResult = settling.update(pitch, roll, raw.timestampNanos)
|
||||
SurfaceCalibrationSample(pitch, roll, tilt, raw.timestampNanos, settlingResult.isSettling)
|
||||
}
|
||||
}
|
||||
val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
LaunchedEffect(reading, phase, capture, firstCapture) {
|
||||
val activeCapture = capture ?: return@LaunchedEffect
|
||||
val current = reading ?: return@LaunchedEffect
|
||||
val captured = activeCapture.add(current) ?: return@LaunchedEffect
|
||||
when (phase) {
|
||||
CalibrationPhase.FIRST_CAPTURING -> {
|
||||
firstCapture = captured
|
||||
capture = null
|
||||
phase = CalibrationPhase.SECOND_READY
|
||||
}
|
||||
CalibrationPhase.SECOND_CAPTURING -> {
|
||||
val first = firstCapture ?: return@LaunchedEffect
|
||||
val calibration = TwoSampleCalibration.deriveSurface(
|
||||
first.pitchDegrees,
|
||||
first.rollDegrees,
|
||||
captured.pitchDegrees,
|
||||
captured.rollDegrees,
|
||||
)
|
||||
capture = null
|
||||
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
|
||||
error = "The placements did not agree. Keep the phone on the same spot and rotate it exactly halfway around."
|
||||
phase = CalibrationPhase.ERROR
|
||||
} else {
|
||||
container.settings.setSurfaceCalibration(calibration)
|
||||
phase = CalibrationPhase.COMPLETE
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val canStartCapture = reading?.let {
|
||||
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES
|
||||
} ?: false
|
||||
val status = when {
|
||||
reading == null -> "Waiting for a sensor reading"
|
||||
reading!!.isSettling -> "Settling—keep the phone still"
|
||||
reading!!.tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
|
||||
"Find a flatter surface (under 5°)"
|
||||
else -> "Ready to capture"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
OutlinedButton(onClick = onBack) { Text("Done") }
|
||||
}
|
||||
Text(calibrationInstruction(phase), style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
|
||||
Text(
|
||||
"This corrects your phone and case setup. It does not make this surface level.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
reading?.let { formatDegrees(it.tiltDegrees) } ?: "—",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = LevelColors.TextPrimary,
|
||||
)
|
||||
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
|
||||
}
|
||||
}
|
||||
|
||||
when (phase) {
|
||||
CalibrationPhase.FIRST_READY, CalibrationPhase.SECOND_READY -> Button(
|
||||
onClick = {
|
||||
capture = StableSurfaceCapture()
|
||||
phase = if (phase == CalibrationPhase.FIRST_READY) CalibrationPhase.FIRST_CAPTURING else CalibrationPhase.SECOND_CAPTURING
|
||||
},
|
||||
enabled = canStartCapture,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (phase == CalibrationPhase.FIRST_READY) "Capture first position" else "Capture rotated position")
|
||||
}
|
||||
CalibrationPhase.FIRST_CAPTURING, CalibrationPhase.SECOND_CAPTURING -> Text(
|
||||
"Capturing 1.5 seconds of still readings…",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
CalibrationPhase.COMPLETE -> {
|
||||
Text(
|
||||
"Surface calibration saved for this phone/case setup.",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = LevelColors.LimeLockText,
|
||||
)
|
||||
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Back to level") }
|
||||
}
|
||||
CalibrationPhase.ERROR -> {
|
||||
Text(error ?: "Calibration could not be completed.", style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
|
||||
Button(
|
||||
onClick = {
|
||||
firstCapture = null
|
||||
error = null
|
||||
phase = CalibrationPhase.FIRST_READY
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
|
||||
if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) {
|
||||
OutlinedButton(
|
||||
onClick = { scope.launch { container.settings.clearSurfaceCalibration() } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Clear saved calibration") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calibrationInstruction(phase: CalibrationPhase): String = when (phase) {
|
||||
CalibrationPhase.FIRST_READY, CalibrationPhase.FIRST_CAPTURING ->
|
||||
"Lay the phone screen-up on a firm, roughly level surface. Keep it still."
|
||||
CalibrationPhase.SECOND_READY, CalibrationPhase.SECOND_CAPTURING ->
|
||||
"Rotate the phone exactly 180° in the same plane. Do not flip it over or move it."
|
||||
CalibrationPhase.COMPLETE -> "Calibration complete"
|
||||
CalibrationPhase.ERROR -> "Try Surface calibration again"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SettlingDetectorTest {
|
||||
|
||||
@Test
|
||||
fun `settles only after low movement has held for its dwell time`() {
|
||||
val detector = SettlingDetector()
|
||||
|
||||
assertTrue(detector.update(0.0, 0.0, 0).isSettling)
|
||||
assertTrue(detector.update(0.2, 0.0, 100_000_000).isSettling) // 2°/s: moving
|
||||
assertTrue(detector.update(0.2, 0.0, 200_000_000).isSettling)
|
||||
assertTrue(detector.update(0.2, 0.0, 600_000_000).isSettling)
|
||||
assertFalse(detector.update(0.2, 0.0, 700_000_000).isSettling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movement re-enters settling and resets the dwell`() {
|
||||
val detector = SettlingDetector()
|
||||
detector.update(0.0, 0.0, 0)
|
||||
detector.update(0.0, 0.0, 100_000_000)
|
||||
detector.update(0.0, 0.0, 600_000_000)
|
||||
assertFalse(detector.update(0.0, 0.0, 700_000_000).isSettling)
|
||||
|
||||
assertTrue(detector.update(0.2, 0.0, 800_000_000).isSettling)
|
||||
assertTrue(detector.update(0.2, 0.0, 1_200_000_000).isSettling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid band movement resets the quiet dwell without leaving settling`() {
|
||||
val detector = SettlingDetector()
|
||||
|
||||
detector.update(0.0, 0.0, 0)
|
||||
detector.update(0.0, 0.0, 100_000_000) // quiet dwell starts
|
||||
detector.update(0.08, 0.0, 300_000_000) // 0.4°/s: between exit and enter
|
||||
assertTrue(detector.update(0.08, 0.0, 600_000_000).isSettling)
|
||||
assertTrue(detector.update(0.08, 0.0, 1_000_000_000).isSettling)
|
||||
assertFalse(detector.update(0.08, 0.0, 1_100_000_000).isSettling)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class StableSurfaceCaptureTest {
|
||||
|
||||
private fun sample(
|
||||
pitch: Double,
|
||||
roll: Double,
|
||||
timestampMillis: Long,
|
||||
tilt: Double = 1.0,
|
||||
settling: Boolean = false,
|
||||
) = SurfaceCalibrationSample(pitch, roll, tilt, timestampMillis * 1_000_000, settling)
|
||||
|
||||
@Test
|
||||
fun `captures the mean of a continuous settled one point five second window`() {
|
||||
val capture = StableSurfaceCapture()
|
||||
|
||||
assertNull(capture.add(sample(0.2, -0.1, 0)))
|
||||
assertNull(capture.add(sample(0.4, -0.3, 750)))
|
||||
val result = capture.add(sample(0.6, -0.5, 1_500))
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(0.4, result.pitchDegrees, 1e-9)
|
||||
assertEquals(-0.3, result.rollDegrees, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `motion or excessive tilt resets the capture window`() {
|
||||
val capture = StableSurfaceCapture()
|
||||
|
||||
assertNull(capture.add(sample(0.2, 0.0, 0)))
|
||||
assertNull(capture.add(sample(0.2, 0.0, 750, settling = true)))
|
||||
assertNull(capture.add(sample(0.2, 0.0, 1_000)))
|
||||
assertNull(capture.add(sample(0.2, 0.0, 1_500, tilt = 5.1)))
|
||||
assertNull(capture.add(sample(0.2, 0.0, 1_750)))
|
||||
assertNull(capture.add(sample(0.2, 0.0, 2_500)))
|
||||
requireNotNull(capture.add(sample(0.2, 0.0, 3_250)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SurfaceCalibrationValidationTest {
|
||||
|
||||
@Test
|
||||
fun `accepts a combined bias at or below three degrees`() {
|
||||
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.0, 0.0)))
|
||||
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(1.8, 2.4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a combined bias above three degrees`() {
|
||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
|
||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user