Address Codex audit: Tools cards, 4-point validation, durable persistence
P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.
P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.
P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.
Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.
64 tests passing; assembleDebug clean; verified on device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,7 +96,10 @@ private fun AppRoot(container: AppContainer) {
|
|||||||
}
|
}
|
||||||
composable("angle") { AngleScreen(container) }
|
composable("angle") { AngleScreen(container) }
|
||||||
composable("tools") {
|
composable("tools") {
|
||||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
ToolsScreen(
|
||||||
|
onOpenRuler = { navController.navigate("ruler") },
|
||||||
|
onOpenSettings = { navController.navigate("settings") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("ruler") { RulerScreen(container) }
|
composable("ruler") { RulerScreen(container) }
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import com.onthelevel.core.billing.StubProEntitlementRepository
|
|||||||
import com.onthelevel.core.sensors.AndroidSensorSource
|
import com.onthelevel.core.sensors.AndroidSensorSource
|
||||||
import com.onthelevel.core.sensors.SensorSource
|
import com.onthelevel.core.sensors.SensorSource
|
||||||
import com.onthelevel.core.settings.SettingsRepository
|
import com.onthelevel.core.settings.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual application container — deliberately no DI framework for an app this size
|
* Manual application container — deliberately no DI framework for an app this size
|
||||||
@@ -15,6 +18,13 @@ import com.onthelevel.core.settings.SettingsRepository
|
|||||||
class AppContainer(context: Context) {
|
class AppContainer(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outlives any single screen. Used for durable fire-and-forget persistence
|
||||||
|
* (e.g. saving a calibration): a screen-scoped coroutine would be cancelled if
|
||||||
|
* the user leaves the moment the write is launched.
|
||||||
|
*/
|
||||||
|
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
|
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
|
||||||
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
||||||
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
|
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
|
||||||
|
|||||||
@@ -3,12 +3,34 @@ package com.onthelevel.core.sensors
|
|||||||
import kotlin.math.hypot
|
import kotlin.math.hypot
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guards calibration persistence against an inconsistent two-sample turn. The bound
|
* Guards calibration persistence against a bad calibration.
|
||||||
* applies to the combined two-axis correction, not each axis independently.
|
|
||||||
*/
|
*/
|
||||||
object SurfaceCalibrationValidation {
|
object SurfaceCalibrationValidation {
|
||||||
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
|
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
|
||||||
|
const val PAIR_AGREEMENT_TOLERANCE_DEGREES = 0.4
|
||||||
|
|
||||||
|
/** The combined two-axis correction must be small; a large mean means something moved. */
|
||||||
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
|
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
|
||||||
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
|
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 4-point set (0°/90°/180°/270°) contains TWO independent 180°-flip bias
|
||||||
|
* estimates: the (0,180) pair and the (90,270) pair. A clean turn makes them
|
||||||
|
* agree; a malformed rotation makes them diverge even when the overall mean stays
|
||||||
|
* plausibly small — which the magnitude guard alone cannot detect. Reject when the
|
||||||
|
* two estimates disagree beyond tolerance.
|
||||||
|
*
|
||||||
|
* The disagreement scales with the surface's true tilt, so this is naturally
|
||||||
|
* lenient near level (where a turn error barely matters) and strict on a steeper
|
||||||
|
* permitted surface (where it matters most). Sets that aren't the 4-point shape
|
||||||
|
* carry no cross-check and pass here; the magnitude guard still applies.
|
||||||
|
*/
|
||||||
|
fun isRotationConsistent(samples: List<CapturedSurfaceSample>): Boolean {
|
||||||
|
if (samples.size != 4) return true
|
||||||
|
val pitchA = (samples[0].pitchDegrees + samples[2].pitchDegrees) / 2.0
|
||||||
|
val rollA = (samples[0].rollDegrees + samples[2].rollDegrees) / 2.0
|
||||||
|
val pitchB = (samples[1].pitchDegrees + samples[3].pitchDegrees) / 2.0
|
||||||
|
val rollB = (samples[1].rollDegrees + samples[3].rollDegrees) / 2.0
|
||||||
|
return hypot(pitchA - pitchB, rollA - rollB) <= PAIR_AGREEMENT_TOLERANCE_DEGREES
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -62,7 +61,6 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
|
|
||||||
val sensorSource = container.sensorSource
|
val sensorSource = container.sensorSource
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val mode by container.settings.calibrationMode
|
val mode by container.settings.calibrationMode
|
||||||
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
|
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
|
||||||
val totalSteps = mode.captureCount
|
val totalSteps = mode.captureCount
|
||||||
@@ -112,7 +110,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
|
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
|
||||||
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
|
val consistent = SurfaceCalibrationValidation.isRotationConsistent(newCaptures)
|
||||||
|
if (!SurfaceCalibrationValidation.isAcceptable(calibration) || !consistent) {
|
||||||
captures = emptyList()
|
captures = emptyList()
|
||||||
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
|
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
|
||||||
phase = CapturePhase.ERROR
|
phase = CapturePhase.ERROR
|
||||||
@@ -121,7 +120,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
phase = CapturePhase.READY
|
phase = CapturePhase.READY
|
||||||
stage = Stage.OVERVIEW
|
stage = Stage.OVERVIEW
|
||||||
justSaved = true
|
justSaved = true
|
||||||
scope.launch { container.settings.setSurfaceCalibration(calibration) }
|
// App-owned scope: the write must survive an immediate Done/back.
|
||||||
|
container.applicationScope.launch { container.settings.setSurfaceCalibration(calibration) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||||
if (stage == Stage.CAPTURING) {
|
if (stage == Stage.CAPTURING) {
|
||||||
Text(
|
Text(
|
||||||
text = if (mode == CalibrationMode.THOROUGH) "THOROUGH" else "SIMPLE",
|
text = if (mode == CalibrationMode.THOROUGH) "DETAILED" else "STANDARD",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = LevelColors.Amber,
|
color = LevelColors.Amber,
|
||||||
)
|
)
|
||||||
@@ -165,7 +165,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
calibration = existingCalibration,
|
calibration = existingCalibration,
|
||||||
justSaved = justSaved,
|
justSaved = justSaved,
|
||||||
mode = mode,
|
mode = mode,
|
||||||
onModeChange = { scope.launch { container.settings.setCalibrationMode(it) } },
|
onModeChange = { container.applicationScope.launch { container.settings.setCalibrationMode(it) } },
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -176,7 +176,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
|||||||
text = "Reset to phone defaults",
|
text = "Reset to phone defaults",
|
||||||
onClick = {
|
onClick = {
|
||||||
justSaved = false
|
justSaved = false
|
||||||
scope.launch { container.settings.clearSurfaceCalibration() }
|
container.applicationScope.launch { container.settings.clearSurfaceCalibration() }
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -250,7 +250,12 @@ private fun OverviewContent(
|
|||||||
Text(
|
Text(
|
||||||
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
|
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = if (justSaved) LevelColors.LimeLockText else LevelColors.TextDim,
|
// Lime marks the confirmed/active state: just-saved, or an active calibration.
|
||||||
|
color = when {
|
||||||
|
justSaved -> LevelColors.LimeLockText
|
||||||
|
isCalibrated -> LevelColors.LimeLock
|
||||||
|
else -> LevelColors.TextDim
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
if (isCalibrated) {
|
if (isCalibrated) {
|
||||||
@@ -280,7 +285,7 @@ private fun OverviewContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
Text("THOROUGHNESS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
Text("METHOD", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
CalibrationMode.entries.forEachIndexed { index, entry ->
|
CalibrationMode.entries.forEachIndexed { index, entry ->
|
||||||
@@ -289,15 +294,15 @@ private fun OverviewContent(
|
|||||||
onClick = { onModeChange(entry) },
|
onClick = { onModeChange(entry) },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
|
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
|
||||||
) {
|
) {
|
||||||
Text(if (entry == CalibrationMode.SIMPLE) "Simple · 2 pt" else "Thorough · 4 pt")
|
Text(if (entry == CalibrationMode.SIMPLE) "Standard" else "Detailed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = if (mode == CalibrationMode.THOROUGH) {
|
text = if (mode == CalibrationMode.THOROUGH) {
|
||||||
"Four quarter-turns. Slower, but averages out more error."
|
"Four positions, a quarter-turn apart. Averages more sensor noise and cross-checks the turn."
|
||||||
} else {
|
} else {
|
||||||
"Two positions, half a turn apart. Quick and dependable."
|
"Two positions, a half-turn apart. Quick and dependable."
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = LevelColors.TextDim,
|
color = LevelColors.TextDim,
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ import androidx.compose.material3.SwitchDefaults
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.LevelColors
|
import com.onthelevel.core.design.LevelColors
|
||||||
import com.onthelevel.core.settings.MeasurementUnits
|
import com.onthelevel.core.settings.MeasurementUnits
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -45,9 +43,8 @@ fun SettingsScreen(
|
|||||||
onOpenSurfaceCalibration: () -> Unit,
|
onOpenSurfaceCalibration: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
KeepScreenOn()
|
// A preferences list should time out normally — no KeepScreenOn here (calibration
|
||||||
val scope = rememberCoroutineScope()
|
// and the live tool screens keep the screen awake themselves).
|
||||||
|
|
||||||
val units by container.settings.measurementUnits
|
val units by container.settings.measurementUnits
|
||||||
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
||||||
val reducedMotion by container.settings.reducedMotion
|
val reducedMotion by container.settings.reducedMotion
|
||||||
@@ -78,7 +75,7 @@ fun SettingsScreen(
|
|||||||
MeasurementUnits.entries.forEachIndexed { index, entry ->
|
MeasurementUnits.entries.forEachIndexed { index, entry ->
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
selected = units == entry,
|
selected = units == entry,
|
||||||
onClick = { scope.launch { container.settings.setMeasurementUnits(entry) } },
|
onClick = { container.applicationScope.launch { container.settings.setMeasurementUnits(entry) } },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
|
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
|
||||||
) {
|
) {
|
||||||
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
|
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
|
||||||
@@ -90,16 +87,16 @@ fun SettingsScreen(
|
|||||||
SettingsSection("Feedback & motion") {
|
SettingsSection("Feedback & motion") {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Haptic feedback",
|
label = "Haptic feedback",
|
||||||
description = "A gentle buzz when a surface locks level.",
|
description = "A gentle buzz when the level locks.",
|
||||||
checked = hapticsEnabled,
|
checked = hapticsEnabled,
|
||||||
onCheckedChange = { scope.launch { container.settings.setHapticsEnabled(it) } },
|
onCheckedChange = { container.applicationScope.launch { container.settings.setHapticsEnabled(it) } },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Reduce motion",
|
label = "Reduce motion",
|
||||||
description = "Snap the bubble instead of springing it.",
|
description = "Snap the bubble instead of springing it.",
|
||||||
checked = reducedMotion,
|
checked = reducedMotion,
|
||||||
onCheckedChange = { scope.launch { container.settings.setReducedMotion(it) } },
|
onCheckedChange = { container.applicationScope.launch { container.settings.setReducedMotion(it) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.onthelevel.core.design.LevelColors
|
import com.onthelevel.core.design.LevelColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ToolsScreen(onOpenRuler: () -> Unit) {
|
fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -33,14 +33,9 @@ fun ToolsScreen(onOpenRuler: () -> Unit) {
|
|||||||
onClick = onOpenRuler,
|
onClick = onOpenRuler,
|
||||||
)
|
)
|
||||||
ToolCard(
|
ToolCard(
|
||||||
title = "Calibration",
|
title = "Settings",
|
||||||
subtitle = "Two-sample level calibration — coming with the feature build",
|
subtitle = "Calibration, units, feedback, and motion",
|
||||||
onClick = { /* TODO(feature): guided calibration flow */ },
|
onClick = onOpenSettings,
|
||||||
)
|
|
||||||
ToolCard(
|
|
||||||
title = "Units & Feedback",
|
|
||||||
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
|
|
||||||
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.onthelevel.core.sensors
|
|||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
class SurfaceCalibrationValidationTest {
|
class SurfaceCalibrationValidationTest {
|
||||||
|
|
||||||
@@ -17,4 +19,37 @@ class SurfaceCalibrationValidationTest {
|
|||||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
|
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
|
||||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
|
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 5° surface is the worst permitted case (bias magnitude ≤ 5° is allowed to reach
|
||||||
|
// the mean). biasPitch/Roll are the fixed device bias; the true tilt rotates with
|
||||||
|
// the phone about the surface normal.
|
||||||
|
private val biasPitch = 0.3
|
||||||
|
private val biasRoll = -0.2
|
||||||
|
private val tilt = 5.0
|
||||||
|
|
||||||
|
private fun sampleAt(rotationDegrees: Double) = CapturedSurfaceSample(
|
||||||
|
pitchDegrees = biasPitch + tilt * cos(Math.toRadians(rotationDegrees)),
|
||||||
|
rollDegrees = biasRoll + tilt * sin(Math.toRadians(rotationDegrees)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clean four-point turn is consistent`() {
|
||||||
|
val clean = listOf(sampleAt(0.0), sampleAt(90.0), sampleAt(180.0), sampleAt(270.0))
|
||||||
|
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(clean))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sloppy four-point turn is rejected`() {
|
||||||
|
// Second position over-rotated 10° (100° instead of 90°). The (90,270) pair no
|
||||||
|
// longer cancels, so the two independent bias estimates diverge past tolerance —
|
||||||
|
// a malformed rotation the 3° magnitude guard alone cannot catch.
|
||||||
|
val sloppy = listOf(sampleAt(0.0), sampleAt(100.0), sampleAt(180.0), sampleAt(270.0))
|
||||||
|
assertFalse(SurfaceCalibrationValidation.isRotationConsistent(sloppy))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two-point set has no cross-check and is always consistent`() {
|
||||||
|
val twoPoint = listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5))
|
||||||
|
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(twoPoint))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user