From 083ecf426603d0273a82d60691e9feb47c3c175d Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 26 Sep 2026 10:01:13 -0500 Subject: [PATCH 1/3] fix(ui): even out the collapsed controls drawer's spacing The collapsed peek looked cramped and lopsided. The 120.dp peek height was a flat constant that happened to sit 6.dp past the bottom of the 72.dp Call CQ / Hunt row, while the row itself butted straight against the 42.dp handle strip: 0.dp of padding above the buttons, 6.dp below, and the buttons effectively jammed onto the tab bar. The status line above had no gap either, so the sheet's rounded top edge read as clipping it. - Derive OperateDrawerPeekHeight from its parts (handle + top gap + the shared OperatePrimaryRowHeight + bottom padding) instead of hard-coding it, so the peek can neither clip the primary row nor reveal the row below it when any of those change. Row spacing is now larger than the bottom padding, which is what keeps the next row hidden; both invariants are unit-tested. - 10.dp above the primary row, 14.dp below it (was 0 / 6), and a 10.dp gap between the docked status line and the sheet's top edge. - Shrink the handle strip 42 -> 36.dp and stop the "More controls" hint bobbing. A caption drifting up and down directly above fixed buttons reads as uneven spacing, not as an affordance; the up-chevron already says "pulls up". Measured on an emulator (1080x2400, xhdpi): clearance under the Call CQ row goes from 6.1.dp to 14.1.dp. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lbd9brnZDMCzipgsu9n8Xg --- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 9 ++- .../ui/components/OperateControlsDrawer.kt | 69 ++++++++++++------- .../ks3ckc/ft8af/ui/components/TxStrip.kt | 11 ++- .../components/OperateControlsDrawerTest.kt | 38 +++++++++- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 877b736d1..4a144f206 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -602,7 +602,14 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showCatChip = showCatChip, catState = catState, onReconnectCat = onReconnectCatAction, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + // Bottom padding separates the status line from the sheet's rounded top edge + // below it — butted together, the rounding read as if the line were clipped. + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 10.dp, + ), ) Spacer( modifier = Modifier diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt index 3117e27fd..450f6d975 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -1,10 +1,6 @@ package radio.ks3ckc.ft8af.ui.components import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -73,11 +69,37 @@ internal fun drawerTargetExpanded( /** The vertical drag distance on the handle that commits to toggling the drawer. */ internal val DrawerDragThreshold = 48.dp +/** Height of the grab handle strip (grabber bar + the collapsed "More controls" hint). */ +internal val DrawerHandleHeight = 36.dp + +/** Gap between the handle strip and the first control row. */ +internal val DrawerContentTopGap = 10.dp + +/** + * Padding below the last visible control row. In the collapsed peek this is the breathing room + * between the Call CQ / Hunt row and the tab bar the sheet sits on, so it must be comfortably + * bigger than nothing and comfortably smaller than [DrawerRowSpacing] (see below). + */ +internal val DrawerContentBottomPad = 14.dp + /** - * The collapsed height of the drawer — the handle (with its "More controls" hint) plus the primary - * Call CQ + Hunt row, stopping before the next row so nothing peeks through when collapsed. + * Vertical gap between control rows. Deliberately larger than [DrawerContentBottomPad]: the + * collapsed sheet is clipped [DrawerContentBottomPad] past the primary row's bottom edge, so the + * next row only stays fully hidden while the gap exceeds that padding. */ -internal val OperateDrawerPeekHeight = 120.dp +internal val DrawerRowSpacing = 18.dp + +/** + * The collapsed height of the drawer — the handle (with its "More controls" hint), the primary + * Call CQ + Hunt row, and equal-feeling padding above and below it, stopping short of the next row + * so nothing peeks through when collapsed. + * + * Derived from the parts rather than hard-coded (it was a flat 120.dp, which left the 72.dp button + * row butted against the handle with 6.dp under it — the row read as jammed onto the tab bar) so + * the peek can never clip the primary row or reveal the row below it. + */ +internal val OperateDrawerPeekHeight = + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + DrawerContentBottomPad /** * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. @@ -199,8 +221,13 @@ fun OperateControlsDrawer( Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding( + start = 16.dp, + end = 16.dp, + top = DrawerContentTopGap, + bottom = DrawerContentBottomPad, + ), + verticalArrangement = Arrangement.spacedBy(DrawerRowSpacing), ) { // ---- Primary row (visible in the peek): Call CQ / Stop + Hunt ---- CqHuntRow( @@ -246,9 +273,13 @@ fun OperateControlsDrawer( /** * The drawer's grab handle: a centered grabber bar plus — while collapsed — an up-chevron and - * "More controls" caption that gently bob to advertise that the sheet pulls up. A tap toggles the - * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] - * with the total drag distance. + * "More controls" caption advertising that the sheet pulls up. A tap toggles the drawer; a + * vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] with the + * total drag distance. + * + * The hint used to bob up and down on an infinite animation. It sat directly above the primary + * action row, where a caption drifting against fixed buttons read as uneven spacing rather than as + * an affordance, so it is static now — the chevron carries the message. */ @Composable private fun DrawerHandle( @@ -263,19 +294,10 @@ private fun DrawerHandle( val collapseLabel = stringResource(R.string.controls_drawer_collapse) val label = if (expanded) collapseLabel else expandLabel - // Gentle up-and-down bob on the hint, only while collapsed, to draw the eye to the affordance. - val bobTransition = rememberInfiniteTransition(label = "drawer-hint-bob") - val bob by bobTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), - label = "drawer-hint-bob-offset", - ) - Column( modifier = modifier .fillMaxWidth() - .height(42.dp) + .height(DrawerHandleHeight) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -293,7 +315,7 @@ private fun DrawerHandle( ) }, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), ) { Box( modifier = Modifier @@ -307,7 +329,6 @@ private fun DrawerHandle( FT8AFIcons.ChevronDown(size = 14.dp, color = TextFaint, strokeWidth = 2.2f) } else { Row( - modifier = Modifier.offset(y = (bob * -3f).dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), ) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 5d67e1b1a..c88862b6a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -63,6 +63,13 @@ internal data class TxStripActionState( val cqIsStop: Boolean, ) +/** + * Height of the primary action row — the Call CQ button and the Hunt tile beside it. Shared so + * the collapsing drawer can derive its peek height from the real button height instead of + * repeating the number (see OperateDrawerPeekHeight). + */ +internal val OperatePrimaryRowHeight = 72.dp + /** * Clamp a volume value after a +/- step to the 0–100 range. * Extracted so it can be unit-tested without Compose. @@ -528,7 +535,7 @@ internal fun CallCqButton( Row( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(background) .combinedClickable( @@ -629,7 +636,7 @@ internal fun HuntTile( val contentAlpha = if (huntDisabled) 0.4f else 1f Column( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(if (huntDisabled) BgSurface3.copy(alpha = 0.4f) else BgSurface3) .border(1.dp, Border, RoundedCornerShape(14.dp)) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt index 39a036eb8..91e1fafea 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt @@ -1,12 +1,14 @@ package radio.ks3ckc.ft8af.ui.components +import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import org.junit.Test /** * Unit tests for [drawerTargetExpanded] — the pure gesture rule deciding whether the - * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle. - * No Android/Compose runtime needed. Sign convention: negative dy = upward drag. + * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle — + * plus the collapsed-peek geometry invariants. No Android/Compose runtime needed. Sign convention: + * negative dy = upward drag. */ class OperateControlsDrawerTest { @@ -67,4 +69,36 @@ class OperateControlsDrawerTest { assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 0f, thresholdPx = threshold)) .isTrue() } + + // ---- Collapsed-peek geometry (see OperateDrawerPeekHeight) ---- + + @Test + fun `peek height is the handle, the primary row and its padding`() { + assertThat(OperateDrawerPeekHeight).isEqualTo( + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + + DrawerContentBottomPad + ) + } + + @Test + fun `peek height clears the whole primary row`() { + // Anything less and the collapsed sheet clips the Call CQ / Hunt buttons. + assertThat(OperateDrawerPeekHeight) + .isGreaterThan(DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight) + } + + @Test + fun `row gap exceeds the bottom padding so the next row cannot peek through`() { + // The sheet is clipped DrawerContentBottomPad past the primary row's bottom edge; the + // following row starts DrawerRowSpacing past it, so the gap has to be the larger of the two. + assertThat(DrawerRowSpacing).isGreaterThan(DrawerContentBottomPad) + } + + @Test + fun `primary row has breathing room above and below it`() { + // The 120.dp peek this replaced left 0.dp above the buttons and 6.dp below, which is what + // made the collapsed drawer look jammed onto the tab bar. + assertThat(DrawerContentTopGap).isAtLeast(8.dp) + assertThat(DrawerContentBottomPad).isAtLeast(8.dp) + } } From 50285a9661a118fa739075ce813e01a5e817573c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 26 Sep 2026 10:01:46 -0500 Subject: [PATCH 2/3] feat(map): draw US state borders on the standard map view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Map redesign already loads us_states.json and strokes it, but behind a `scale >= 3` zoom gate. That gate hides the layer in exactly the view a US operator sees: with a grid set and no decodes yet, the map auto-frames the operator at scale ~2.9 — a hair under the threshold — so the state lines never appear. Zoom was the wrong measure anyway. The map COVERs its card, so the same `scale` means very different detail in the two projections: the standard map spends ~6.3 px per degree of longitude at zoom 1 (the lower 48 are ~370 px across — their borders read cleanly), while the azimuthal disc squeezes the whole globe into the card at ~2.8 px per degree, where state outlines really are noise. Gate on that density instead, and fade rather than snap so a pinch doesn't pop a whole layer into existence: stateBorderAlpha() ramps 0 -> 1 between 3 and 6 longitude px per degree, fed by EquirectViewport.pxPerLonDegree for the standard map and azimuthalPxPerLonDegree() for the disc. Net effect: state borders are on throughout the standard projection, still off on the azimuthal world view, and the stroke stays a shade under the national borders so the basemap hierarchy holds. Verified on an emulator: state lines visible at the standard map's default zoom (screenshot in the PR). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lbd9brnZDMCzipgsu9n8Xg --- .../radio/ks3ckc/ft8af/ui/map/MapScreen.kt | 71 +++++++++++++++---- .../radio/ks3ckc/ft8af/ui/map/MapViewport.kt | 9 +++ .../ks3ckc/ft8af/ui/map/MapRedesignTest.kt | 54 ++++++++++++-- 3 files changed, 115 insertions(+), 19 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt index b1771ac8d..9bcf2e980 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt @@ -1325,13 +1325,17 @@ private fun AzimuthalMapCanvas( // Land — Natural Earth 110m vector outlines projected via azProject landRings?.let { rings -> drawAzimuthalLand(rings, opLat, opLon, cx, cy, r, scale, panX, panY) } - // National borders over the land; US state borders once zoomed in. + // National borders over the land; US state borders fade in with the basemap's density. countryRings?.let { rings -> drawAzimuthalRings(rings, opLat, opLon, cx, cy, r, scale, panX, panY, CountryBorderColor, 0.7f) } - if (showStateBorders(scale)) { + val stateAlpha = stateBorderAlpha(azimuthalPxPerLonDegree(r, scale)) + if (stateAlpha > 0f) { stateRings?.let { rings -> - drawAzimuthalRings(rings, opLat, opLon, cx, cy, r, scale, panX, panY, StateBorderColor, 0.5f) + drawAzimuthalRings( + rings, opLat, opLon, cx, cy, r, scale, panX, panY, + StateBorderColor.copy(alpha = StateBorderColor.alpha * stateAlpha), 0.5f, + ) } } @@ -1537,10 +1541,15 @@ private fun StandardMapCanvas( // Land — Natural Earth 110m vector outlines (drawn once polygons are loaded) landRings?.let { rings -> drawWorldLand(rings, vp) } - // National borders over the land; US state borders once zoomed in. + // National borders over the land; US state borders fade in with the basemap's density. countryRings?.let { rings -> drawEquirectRings(rings, vp, CountryBorderColor, 0.7f) } - if (showStateBorders(scale)) { - stateRings?.let { rings -> drawEquirectRings(rings, vp, StateBorderColor, 0.5f) } + val stateAlpha = stateBorderAlpha(vp.pxPerLonDegree) + if (stateAlpha > 0f) { + stateRings?.let { rings -> + drawEquirectRings(rings, vp, StateBorderColor.copy( + alpha = StateBorderColor.alpha * stateAlpha, + ), 0.5f) + } } // Lat/lon grid (over land so it stays visible across continents) @@ -1825,14 +1834,52 @@ private fun DrawScope.drawWorldLand(rings: List, vp: EquirectViewpor } // Border strokes drawn over the dissolved land layer so the basemap reads as a -// real bordered map. National borders are always on; US state borders only when -// zoomed in enough to make sense of them (they'd be noise at world scale). +// real bordered map. National borders are always on; US state borders fade in with the map's +// detail budget (see stateBorderAlpha) so a US operator sees their state lines at the default +// view instead of only after pinching past a hard zoom gate. private val CountryBorderColor = Color(0x7B94A3B8) -private val StateBorderColor = Color(0x4694A3B8) -internal const val STATE_BORDER_ZOOM = 3f +private val StateBorderColor = Color(0x6094A3B8) + +/** At/below this many longitude px per degree the state layer would be mush, so it is hidden. */ +internal const val STATE_BORDER_MIN_PX_PER_DEG = 3f + +/** + * At/above this many longitude px per degree the state layer is drawn at full strength. Tuned on + * the real map card, which is ~1000x1140 px on a 1080p phone: the standard projection's default + * "whole world" view lands at ~6.3 px per degree, where the lower 48 are ~370 px across and their + * borders read cleanly, so the whole standard projection is at full strength. + */ +internal const val STATE_BORDER_FULL_PX_PER_DEG = 6f -/** State borders are decluttered until the map is zoomed past [STATE_BORDER_ZOOM]. */ -internal fun showStateBorders(scale: Float): Boolean = scale >= STATE_BORDER_ZOOM +/** + * Opacity multiplier (0..1) for the US state border layer at a basemap density of + * [pxPerLonDegree] longitude pixels per degree — 0 below [STATE_BORDER_MIN_PX_PER_DEG], 1 at/above + * [STATE_BORDER_FULL_PX_PER_DEG], linear between. + * + * Replaces a hard `scale >= 3` gate that kept the layer off in exactly the view a US operator + * looks at: with a grid set and no decodes yet the map auto-frames the operator at scale ~2.9, + * a hair under the gate, so the state lines never appeared. + * + * Zoom is the wrong measure anyway. The map COVERs its card, so at the same `scale` the standard + * projection spends far more pixels per degree than the azimuthal disc, which squeezes the whole + * globe into the card width (~3 px per degree — where state outlines really are noise). Density is + * what decides whether the layer helps, and fading rather than snapping keeps a pinch from popping + * a whole layer into existence. + */ +internal fun stateBorderAlpha(pxPerLonDegree: Float): Float = when { + pxPerLonDegree <= STATE_BORDER_MIN_PX_PER_DEG -> 0f + pxPerLonDegree >= STATE_BORDER_FULL_PX_PER_DEG -> 1f + else -> (pxPerLonDegree - STATE_BORDER_MIN_PX_PER_DEG) / + (STATE_BORDER_FULL_PX_PER_DEG - STATE_BORDER_MIN_PX_PER_DEG) +} + +/** + * Basemap density (longitude px per degree) of the azimuthal disc: the disc's radius spans a + * quarter of the globe's circumference — [azProject] puts the antipode (180° away) at the rim — + * so `r * scale` pixels cover 180°. + */ +internal fun azimuthalPxPerLonDegree(discRadiusPx: Float, scale: Float): Float = + discRadiusPx * scale / 180f /** Stroke a set of lon/lat rings through the equirectangular viewport (borders, no fill). */ private fun DrawScope.drawEquirectRings( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt index db57c039b..99365fd9c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt @@ -47,6 +47,15 @@ internal class EquirectViewport( val worldPxW: Float = baseUniform * 2f * userScale val worldPxH: Float = baseUniform * 1f * userScale + /** + * Longitude pixels per degree at the current zoom — how much screen the map spends on a + * degree of longitude. The world is [worldPxW] px wide and spans 360°, so this is the honest + * measure of how much detail the basemap can carry (the raw [userScale] is not: COVER already + * blows the world up past the canvas at zoom 1, and by different amounts on a tall phone and a + * wide tablet). Drives the state-border fade — see `stateBorderAlpha`. + */ + val pxPerLonDegree: Float get() = worldPxW / 360f + /** Project a normalized point (from [equirectProject]) to canvas pixels. */ fun project(p: ProjectedPoint): Offset { val cx = canvasW / 2f diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt index cd5e92753..af73e5810 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt @@ -8,7 +8,7 @@ import kotlin.math.abs /** * Pure unit tests for the Map redesign (concept 4c) logic: layer-chip - * visibility, state-border zoom gating, the gray-line info pill countdown, and + * visibility, state-border fade, the gray-line info pill countdown, and * the "Me" recenter pan. No Android/Compose runtime is touched. */ class MapRedesignTest { @@ -39,14 +39,54 @@ class MapRedesignTest { assertThat(isStationVisible(isWorked = true, decodesOn = false, workedOn = false)).isFalse() } - // ---- state-border zoom gate ---------------------------------------------- + // ---- state-border fade --------------------------------------------------- @Test - fun stateBorders_hiddenUntilZoomThreshold() { - assertThat(showStateBorders(1f)).isFalse() - assertThat(showStateBorders(STATE_BORDER_ZOOM - 0.01f)).isFalse() - assertThat(showStateBorders(STATE_BORDER_ZOOM)).isTrue() - assertThat(showStateBorders(8f)).isTrue() + fun stateBorders_hiddenWhenBasemapIsTooCoarse() { + assertThat(stateBorderAlpha(0f)).isEqualTo(0f) + assertThat(stateBorderAlpha(STATE_BORDER_MIN_PX_PER_DEG)).isEqualTo(0f) + } + + @Test + fun stateBorders_fullyDrawnOnceDenseEnough() { + assertThat(stateBorderAlpha(STATE_BORDER_FULL_PX_PER_DEG)).isEqualTo(1f) + assertThat(stateBorderAlpha(40f)).isEqualTo(1f) + } + + @Test + fun stateBorders_rampLinearlyBetweenTheThresholds() { + val mid = (STATE_BORDER_MIN_PX_PER_DEG + STATE_BORDER_FULL_PX_PER_DEG) / 2f + assertThat(stateBorderAlpha(mid)).isWithin(1e-4f).of(0.5f) + // Monotonic across the ramp — a pinch never dims the layer. + var previous = 0f + var pxPerDeg = STATE_BORDER_MIN_PX_PER_DEG + while (pxPerDeg <= STATE_BORDER_FULL_PX_PER_DEG) { + val alpha = stateBorderAlpha(pxPerDeg) + assertThat(alpha).isAtLeast(previous) + previous = alpha + pxPerDeg += 0.5f + } + } + + @Test + fun stateBorders_drawnOnTheStandardMapCardAtDefaultZoom() { + // The map card measures ~996x1140 px on a 1080p phone. At zoom 1 (whole world, no grid + // set) and at the auto-framed zoom a US operator with a grid actually gets (~2.9 — a hair + // under the old `scale >= 3` gate, which is why the lines never showed), the layer is on. + val world = EquirectViewport(canvasW = 996f, canvasH = 1140f, userScale = 1f) + assertThat(stateBorderAlpha(world.pxPerLonDegree)).isEqualTo(1f) + val framed = EquirectViewport(canvasW = 996f, canvasH = 1140f, userScale = 2.9f) + assertThat(stateBorderAlpha(framed.pxPerLonDegree)).isEqualTo(1f) + } + + @Test + fun stateBorders_stayHiddenOnTheAzimuthalWorldDisc() { + // The whole globe inside a ~1000 px disc is ~2.8 px per degree: real mush, still hidden. + assertThat(stateBorderAlpha(azimuthalPxPerLonDegree(discRadiusPx = 500f, scale = 1f))) + .isEqualTo(0f) + // Pinched in, they fade up. + assertThat(stateBorderAlpha(azimuthalPxPerLonDegree(discRadiusPx = 500f, scale = 3f))) + .isEqualTo(1f) } // ---- gray-line countdown formatting -------------------------------------- From d75b212512900a9706f6755a029ef549465d8725 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 26 Sep 2026 10:04:57 -0500 Subject: [PATCH 3/3] fix(clock): stop the DT indicator jumping on a well-synced rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock-sync pill showed the raw arithmetic MEAN of a slot's decode DTs, re-posted on every decode pass. On a rig working stations perfectly it would announce "+2.5 s" and turn red, which reads as an unstable app. Three separate reasons it did that: - A mean has no defence against one bad decode. DT is per-station: a station transmitting late, or a marginal decode placed a couple of seconds off, carries its own large DT. Three good decodes at +0.1 s plus one at +7 s average to +1.8 s — a red "clock off" verdict produced entirely by somebody else's clock. - Every pass re-posted. A slot is decoded several times (fast, then deep and late passes that dig out the weakest signals — exactly the ones with the sloppiest DT), so the number visibly jumped several times per slot. - No slot-to-slot smoothing at all. Meanwhile the estimator that actually corrects the clock (ClockSelfSync) has always been robust — median, MAD rejection, multi-slot confirmation — so the display and the correction openly disagreed: the pill flashed "clock off" while auto-sync, reading the same decodes, correctly did nothing. New DecodeDtDisplay puts the display on the same footing: MAD-rejected median per slot, passes accumulating into their slot instead of replacing the reading, the median of the last 3 slots, and a 0.05 s publish deadband so the last digit stops twitching. A deliberate clock change (manual, GPS/NTP or self-sync) drops the window so the reading re-acquires instead of averaging across a correction. A real, sustained error is still followed within ~2 slots. Also logs "DT: raw=... shown=... n=..." once per slot, so a debug.log shows directly whether a wandering raw mean is being smoothed or a clock is genuinely drifting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lbd9brnZDMCzipgsu9n8Xg --- .../java/com/k1af/ft8af/MainViewModel.java | 48 ++++- .../com/k1af/ft8af/timer/DecodeDtDisplay.java | 171 ++++++++++++++++++ .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 5 +- .../ks3ckc/ft8af/ui/components/ClockSync.kt | 12 +- .../ft8af/ui/settings/TimeCorrection.kt | 2 +- .../ft8af/ui/settings/TimeSyncSettings.kt | 4 +- .../k1af/ft8af/timer/DecodeDtDisplayTest.java | 153 ++++++++++++++++ 7 files changed, 376 insertions(+), 19 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index ee3b3da94..282ae7b0a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -128,6 +128,7 @@ import com.k1af.ft8af.rigs.YaesuDX10Rig; import com.k1af.ft8af.spectrum.SpectrumListener; import com.k1af.ft8af.timer.ClockSelfSync; +import com.k1af.ft8af.timer.DecodeDtDisplay; import com.k1af.ft8af.timer.OnUtcTimer; import com.k1af.ft8af.timer.UtcTimer; import com.k1af.ft8af.ui.ToastMessage; @@ -185,6 +186,12 @@ private void fileLog(String msg) { // TimeSyncSettings can reset it when the operator flips the toggles. public final ClockSelfSync clockSelfSync = new ClockSelfSync(); + // Robust, smoothed source for the DT figure the UI shows (clock-sync pill + Time Sync + // readout). Always on — unlike clockSelfSync it never touches the clock, it only decides + // what number is honest to display. See DecodeDtDisplay for why the raw per-slot mean + // this replaced made a perfectly-synced rig look 2 s out. + public final DecodeDtDisplay decodeDtDisplay = new DecodeDtDisplay(); + //public CallsignDatabase callsignDatabase = null;//callsign information database public DatabaseOpr databaseOpr;//configuration info and related data database @@ -705,6 +712,14 @@ public void afterDecode(long utc, float time_sec, int sequential // already ignore own-callsign messages. OwnTxEchoFilter filtered = OwnTxEchoFilter.filter(decoded); ArrayList messages = filtered.kept; + // This pass's per-decode DTs (seconds). Feeds both the self-syncing clock + // (fast pass only, below) and the displayed DT (every pass), so the number + // on screen and the correction the estimator makes are measured from + // exactly the same samples. + final float[] dtSamples = new float[messages.size()]; + for (int i = 0; i < messages.size(); i++) { + dtSamples[i] = messages.get(i).time_sec; + } // Full duplex (satellite operating): our own downlink coming back // IS the measurement, so put the echoes the filter just dropped // back on screen. Strictly display — `display` feeds only the @@ -734,10 +749,6 @@ public void afterDecode(long utc, float time_sec, int sequential if (ClockSelfSync.mayRun(GeneralVariables.autoSyncClockFromDecodes, GeneralVariables.disciplineClockFromGPS, GeneralVariables.disciplineClockFromNtp)) { - float[] dtSamples = new float[messages.size()]; - for (int i = 0; i < messages.size(); i++) { - dtSamples[i] = messages.get(i).time_sec; - } // onSlot is atomic: slot dedup/ordering + streak update under // one lock, so concurrent adjacent-slot deliveries can't // interleave (out-of-order slots are rejected inside). @@ -804,12 +815,29 @@ public void afterDecode(long utc, float time_sec, int sequential appendToMessageList(display); publishFt8MessageList();//post an immutable snapshot so the UI recomposes immediately - // Slot-wide mean DT with own-TX echoes already excluded (see - // OwnTxEchoFilter.meanTimeOffsetSec). NaN can't happen here — messages - // is non-empty, so at least one decode survived — but never post it: - // the pill would render it as "unknown". - if (!Float.isNaN(time_sec)) { - mutableTimerOffset.postValue(time_sec);//this cycle's time offset + // Displayed DT. NOT the slot's raw mean (the `time_sec` argument, kept for + // the diagnostic below): a mean is at the mercy of one late station's DT, and + // it was re-posted on every decode pass, so the pill flashed "2.5 s off" on a + // rig that was working stations perfectly. DecodeDtDisplay applies the same + // robust measurement the self-syncing clock uses — MAD-rejected median per + // slot, accumulated across that slot's passes, then the median of the last + // few slots — and returns null when the reading hasn't meaningfully moved, + // which is what makes the indicator sit still when the clock is right. + Float shownDt = decodeDtDisplay.onDecodes(utc, dtSamples, UtcTimer.delay); + if (shownDt != null && !Float.isNaN(shownDt)) { + mutableTimerOffset.postValue(shownDt); + } + if (!isDeep) { + // Both numbers, once per slot: a debug.log where `raw` swings while + // `shown` holds is the smoothing doing its job, not a drifting clock. + fileLog(String.format(java.util.Locale.US, + "DT: raw=%+.2f shown=%s n=%d", + time_sec, + decodeDtDisplay.lastPublished() == null + ? "--" + : String.format(java.util.Locale.US, "%+.2f", + decodeDtDisplay.lastPublished()), + dtSamples.length)); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java new file mode 100644 index 000000000..a1118f8b5 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java @@ -0,0 +1,171 @@ +package com.k1af.ft8af.timer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Stabilises the decode-DT figure the operating screen shows (the clock-sync pill on the slot + * timer bar, and the "measured DT" readout in Settings → Time Sync). + * + *

Why this exists. The pill used to render the raw arithmetic mean of every + * decode in the slot, re-posted on every decode pass. Three things made that read as an unstable + * app on a rig that was in fact perfectly in time: + * + *

    + *
  • A mean has no defence against one bad decode. DT is per-station: a station + * transmitting late, or a marginal decode the searcher placed a couple of seconds off, + * carries its own big DT. Average three good decodes at +0.1 s with one at +7 s and the + * pill announces "+1.8 s" — a red "clock off" verdict produced entirely by somebody + * else's clock.
  • + *
  • Every pass re-posted. A slot is decoded several times (fast pass, then deep and + * late passes that dig out the weakest signals — exactly the decodes with the sloppiest + * DT), so the number visibly jumped several times within one 15 s slot.
  • + *
  • No slot-to-slot smoothing. Even with clean data, a slot with two decodes bounces + * against a slot with nine.
  • + *
+ * + *

The estimator that actually corrects the clock ({@link ClockSelfSync}) has always + * been robust — median, MAD outlier rejection, multi-slot confirmation — so the display and the + * correction disagreed: the pill flashed "2.5 s off" while auto-sync, looking at the same decodes, + * correctly concluded there was nothing to do. This class puts the display on the same footing: + * + *

    + *
  • Per slot: MAD-based outlier rejection then the median of the survivors, reusing + * {@link ClockSelfSync}'s helpers so both paths measure the same way.
  • + *
  • Passes accumulate into the slot they belong to instead of each replacing the reading, so a + * deep pass refines the slot's median rather than yanking the display.
  • + *
  • Across slots: the median of the last {@link #WINDOW_SLOTS} slots, so a single freak slot + * cannot move the needle at all.
  • + *
  • A {@link #PUBLISH_QUANTUM_SEC} deadband on the published value, so the last digit stops + * twitching between otherwise identical readings.
  • + *
+ * + *

A deliberate clock change resets the window. When {@link UtcTimer#delay} moves — the + * operator applied a correction, or GPS/NTP/self-sync did — every stored sample describes the old + * clock. Holding them would average the correction away and make the pill lie for half a minute, + * so the history is dropped and the reading re-acquires from the next slot. + * + *

Pure Java, no Android imports, so it is unit-testable without Robolectric. The entry point is + * synchronized: decode passes for adjacent slots can deliver concurrently. + */ +public class DecodeDtDisplay { + + /** + * How many slots the displayed value is the median of. Three is the smallest window that + * outvotes a single bad slot while still following a real change within ~2 slots (30 s of FT8). + */ + public static final int WINDOW_SLOTS = 3; + + /** + * Minimum change (seconds) before a new reading is published. The pill renders one decimal, so + * anything under half a displayed digit is invisible movement that only costs a recomposition. + */ + public static final float PUBLISH_QUANTUM_SEC = 0.05f; + + /** Cap on the DTs retained for one slot, so a pathological slot can't grow without bound. */ + static final int MAX_SLOT_SAMPLES = 64; + + // The slot currently accumulating, and every DT seen for it across passes. + private long currentUtc = Long.MIN_VALUE; + private float[] currentSamples = new float[0]; + + // Robust medians of the most recently finished slots, oldest first, at most WINDOW_SLOTS - 1 + // (the in-progress slot supplies the newest value). + private final List finishedMedians = new ArrayList<>(); + + // Last value handed to the UI, for the publish deadband. Null = nothing published yet. + private Float published = null; + + // The clock offset the stored samples were measured against. + private Integer sampledAtDelayMs = null; + + /** + * Feed one decode pass's per-decode DTs (seconds, own-TX echoes already filtered out) and get + * the value the UI should now show, or {@code null} to keep showing what it has. + * + * @param utc the slot the decodes belong to; stragglers for an older slot are ignored + * @param dtSec this pass's DTs; may be empty + * @param clockDelayMs the live {@link UtcTimer#delay} — a change resets the window + */ + public synchronized Float onDecodes(long utc, float[] dtSec, int clockDelayMs) { + if (sampledAtDelayMs == null || sampledAtDelayMs != clockDelayMs) { + // The clock moved under us: everything measured before it describes a different clock. + resetHistory(); + sampledAtDelayMs = clockDelayMs; + } + if (utc < currentUtc) { + // A late pass for an already-superseded slot — stale evidence, ignore it. + return null; + } + if (utc > currentUtc) { + finishCurrentSlot(); + currentUtc = utc; + currentSamples = new float[0]; + } + appendSamples(dtSec); + + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return null; + } + float slotMedian = ClockSelfSync.medianOf(survivors); + + float[] window = new float[finishedMedians.size() + 1]; + for (int i = 0; i < finishedMedians.size(); i++) { + window[i] = finishedMedians.get(i); + } + window[window.length - 1] = slotMedian; + float display = ClockSelfSync.medianOf(window); + + if (published != null && Math.abs(display - published) < PUBLISH_QUANTUM_SEC) { + return null; + } + published = display; + return display; + } + + /** The value last handed to the UI, or null if nothing has been published yet. */ + public synchronized Float lastPublished() { + return published; + } + + /** Drops all history — the samples, the window, and the published value. */ + public synchronized void reset() { + resetHistory(); + sampledAtDelayMs = null; + } + + private void resetHistory() { + finishedMedians.clear(); + currentSamples = new float[0]; + currentUtc = Long.MIN_VALUE; + published = null; + } + + /** Retire the in-progress slot into the window (dropping the oldest once it is full). */ + private void finishCurrentSlot() { + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return; + } + finishedMedians.add(ClockSelfSync.medianOf(survivors)); + while (finishedMedians.size() > WINDOW_SLOTS - 1) { + finishedMedians.remove(0); + } + } + + private void appendSamples(float[] dtSec) { + if (dtSec == null || dtSec.length == 0) { + return; + } + int room = MAX_SLOT_SAMPLES - currentSamples.length; + if (room <= 0) { + return; + } + int take = Math.min(room, dtSec.length); + float[] merged = Arrays.copyOf(currentSamples, currentSamples.length + take); + System.arraycopy(dtSec, 0, merged, currentSamples.length, take); + currentSamples = merged; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 877b736d1..b7bda4ec7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -319,8 +319,9 @@ fun FT8AFApp(mainViewModel: MainViewModel) { val operatingMode by mainViewModel.mutableOperatingMode.observeAsState(GeneralVariables.operatingMode) val modeName = ModeProfile.fromId(operatingMode).displayName - // Mean decode DT (seconds) for the slot-timer bar's live clock-sync pill; null until - // the first decode cycle reports one, so the bar renders unchanged before then. + // Decode DT (seconds) for the slot-timer bar's live clock-sync pill — the smoothed, + // outlier-rejected reading from DecodeDtDisplay, not a raw per-slot mean; null until the + // first decode cycle reports one, so the bar renders unchanged before then. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Observe SWR lockout state diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt index 3800f24e1..52ee333c0 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt @@ -29,15 +29,19 @@ import java.util.Locale import kotlin.math.abs /** - * Clock-sync health derived from the mean decode DT (time offset, in seconds). + * Clock-sync health derived from the smoothed decode DT (time offset, in seconds) — + * `DecodeDtDisplay`'s reading: an outlier-rejected median per slot, smoothed over the last few + * slots. It is deliberately not the slot's raw mean; see that class for why a mean made a rig + * that was working stations perfectly report itself seconds out of sync. * * FT8 tolerates only ~2 s of clock error before decoding collapses — both ways: a * mis-set clock stops you decoding others *and* pushes your transmissions to the edge * of everyone else's receive window so they can't decode you either. Yet the app never * surfaced this on the operating screen; the operator had to open Settings → Time Sync - * to notice. The mean DT of the stations you decode is a good proxy for your own clock + * to notice. The consensus DT of the stations you decode is a good proxy for your own clock * offset: most stations are themselves synced, so a consistent bias across all of them - * is almost certainly yours. + * is almost certainly yours — while any single station's DT is that station's own clock, which + * is why the displayed value is a median over several slots rather than one slot's average. */ internal enum class ClockSyncLevel { GOOD, FAIR, POOR, UNKNOWN } @@ -48,7 +52,7 @@ internal const val CLOCK_SYNC_GOOD_SEC = 0.3f internal const val CLOCK_SYNC_FAIR_SEC = 1.0f /** - * Classify the mean decode DT into a sync-health level. A `null` value means no decode + * Classify the smoothed decode DT into a sync-health level. A `null` value means no decode * cycle has reported yet this session; a non-finite value is treated the same way so a bad * reading can never mis-color the pill. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt index 301744a5e..9698ee7d7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt @@ -40,7 +40,7 @@ internal fun stepCorrectionMs(current: Int, deltaMs: Int): Int = clampCorrectionMs(current + deltaMs) /** - * Suggest a new correction from the average decode DT of the stations we're + * Suggest a new correction from the smoothed decode DT of the stations we're * hearing — the only time reference available offline. * * `avgDtSec` is WSJT-style DT (seconds): how far into our RX window the decoded diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt index 70db3e1dc..3a177928c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt @@ -96,8 +96,8 @@ fun TimeSyncSettings( GeneralVariables.mutableNtpClockSyncFailed.value ) - // Latest cycle's average decode DT (seconds), posted in MainViewModel.afterDecode. - // Null until the first decode this session. + // Smoothed decode DT (seconds) — DecodeDtDisplay's reading, posted in + // MainViewModel.afterDecode. Null until the first decode this session. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Last self-sync correction (timestamp), so the Suggestion card can show that diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java new file mode 100644 index 000000000..56ef8c79d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java @@ -0,0 +1,153 @@ +package com.k1af.ft8af.timer; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link DecodeDtDisplay} — the robust, smoothed source for the DT figure shown on + * the operating screen. Pure Java, no Robolectric needed. + * + *

The behaviour under test is the fix for "my DT indicator says 2.5 s off while the rig works + * stations perfectly": one station's DT, or one marginal deep-pass decode, must not be allowed to + * move the displayed number, while a real, sustained clock error still must. + */ +public class DecodeDtDisplayTest { + + private static final int DELAY = 0; + + /** Slot boundaries only have to be increasing; real ones are UTC ms. */ + private static long slot(int n) { + return 1_700_000_000_000L + n * 15_000L; + } + + @Test + public void firstSlotPublishesImmediately() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.4f, 0.5f, 0.6f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.5f); + } + + @Test + public void oneLateStationCannotDragTheReading() { + // The exact shape of the complaint: three in-time decodes and one station 7 s late. + // The old arithmetic mean of these is +1.85 s (a red "clock off" verdict). + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.1f, 0.0f, 0.2f, 7.0f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(Math.abs(shown)).isLessThan(0.3f); + } + + @Test + public void deepPassRefinesTheSlotInsteadOfReplacingIt() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float fast = dt.onDecodes(slot(1), new float[]{0.1f, 0.1f, 0.2f}, DELAY); + assertThat(fast).isNotNull(); + // A deep pass for the SAME slot digs out one marginal decode with a wild DT. It joins the + // slot's samples (where it is rejected) rather than becoming the new reading. + Float deep = dt.onDecodes(slot(1), new float[]{2.4f}, DELAY); + assertThat(deep).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(fast); + } + + @Test + public void oneFreakSlotIsOutvotedByTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.1f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A whole slot whose only decode is a late station: the median of the last three slots + // still reads the healthy clock. + Float shown = dt.onDecodes(slot(3), new float[]{3.0f}, DELAY); + assertThat(shown).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void sustainedErrorIsStillFollowed() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.0f, 0.0f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.0f, 0.0f}, DELAY); + // The clock genuinely drifts: every station now lands a second late. + dt.onDecodes(slot(3), new float[]{1.0f, 1.0f}, DELAY); + dt.onDecodes(slot(4), new float[]{1.0f, 1.0f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.0f); + } + + @Test + public void tinyChangesAreHeldSoTheLastDigitStopsTwitching() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[]{0.10f}, DELAY)).isNotNull(); + assertThat(dt.onDecodes(slot(2), new float[]{0.12f}, DELAY)).isNull(); + assertThat(dt.onDecodes(slot(3), new float[]{0.11f}, DELAY)).isNull(); + } + + @Test + public void aClockCorrectionResetsTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{1.2f}, DELAY); + dt.onDecodes(slot(2), new float[]{1.2f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.2f); + // Auto-sync (or the operator) trims the clock by 1.2 s: the stored samples describe the + // old clock, so the next slot re-acquires on its own instead of being averaged with them. + Float shown = dt.onDecodes(slot(3), new float[]{0.0f}, DELAY - 1200); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.0f); + } + + @Test + public void stragglerForAnOlderSlotIsIgnored() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A late pass for the previous slot arriving after its successor: stale evidence. + assertThat(dt.onDecodes(slot(1), new float[]{4.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void emptyPassPublishesNothing() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[0], DELAY)).isNull(); + assertThat(dt.onDecodes(slot(2), null, DELAY)).isNull(); + assertThat(dt.lastPublished()).isNull(); + } + + @Test + public void windowOnlyRemembersTheMostRecentSlots() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + // Three slots of a real +2 s error, then the clock is fixed and stays fixed. Once the + // window has rolled past the bad slots the reading must be the new truth, not a blend. + for (int i = 1; i <= 3; i++) { + dt.onDecodes(slot(i), new float[]{2.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(2.0f); + for (int i = 4; i <= 3 + DecodeDtDisplay.WINDOW_SLOTS; i++) { + dt.onDecodes(slot(i), new float[]{0.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.0f); + } + + @Test + public void slotSamplesAreCappedAcrossPasses() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + float[] batch = new float[DecodeDtDisplay.MAX_SLOT_SAMPLES]; + for (int i = 0; i < batch.length; i++) { + batch[i] = 0.2f; + } + assertThat(dt.onDecodes(slot(1), batch, DELAY)).isNotNull(); + // Past the cap, further passes for the same slot are dropped rather than growing forever; + // the reading stands on what it already has. + assertThat(dt.onDecodes(slot(1), new float[]{9.0f, 9.0f, 9.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.2f); + } + + @Test + public void resetClearsEverything() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.7f}, DELAY); + dt.reset(); + assertThat(dt.lastPublished()).isNull(); + Float shown = dt.onDecodes(slot(2), new float[]{-0.4f}, DELAY); + assertThat(shown).isWithin(1e-4f).of(-0.4f); + } +}