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.core.design.OnTheLevelTheme
|
||||||
import com.onthelevel.feature.angle.AngleScreen
|
import com.onthelevel.feature.angle.AngleScreen
|
||||||
import com.onthelevel.feature.level.LevelScreen
|
import com.onthelevel.feature.level.LevelScreen
|
||||||
|
import com.onthelevel.feature.level.SurfaceCalibrationScreen
|
||||||
import com.onthelevel.feature.ruler.RulerScreen
|
import com.onthelevel.feature.ruler.RulerScreen
|
||||||
import com.onthelevel.feature.tools.ToolsScreen
|
import com.onthelevel.feature.tools.ToolsScreen
|
||||||
|
|
||||||
@@ -87,12 +88,19 @@ private fun AppRoot(container: AppContainer) {
|
|||||||
startDestination = "level",
|
startDestination = "level",
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
) {
|
) {
|
||||||
composable("level") { LevelScreen(container) }
|
composable("level") {
|
||||||
|
LevelScreen(container, onOpenSurfaceCalibration = {
|
||||||
|
navController.navigate("surface-calibration")
|
||||||
|
})
|
||||||
|
}
|
||||||
composable("angle") { AngleScreen(container) }
|
composable("angle") { AngleScreen(container) }
|
||||||
composable("tools") {
|
composable("tools") {
|
||||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
||||||
}
|
}
|
||||||
composable("ruler") { RulerScreen(container) }
|
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) {
|
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
|
||||||
store.edit {
|
store.edit {
|
||||||
it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees
|
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.
|
* while the portrait-locked device stands on its long edge.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LevelScreen(container: AppContainer) {
|
fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
|
|
||||||
val sensorSource = container.sensorSource
|
val sensorSource = container.sensorSource
|
||||||
@@ -107,7 +107,7 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
|
IconButton(onClick = onOpenSurfaceCalibration) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Settings,
|
Icons.Outlined.Settings,
|
||||||
contentDescription = "Calibration and 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