From fed9fe188ea56753b5168fa4b77ae1d47c638123 Mon Sep 17 00:00:00 2001 From: QIU Date: Fri, 14 Aug 2026 16:59:30 +0000 Subject: [PATCH] fix(roaming): assign exact grid boundaries to the correct cell The QTH Locator port keeps the decompiled range tables, which close both adjacent cells (`-20.0..0.0` then `0.0..20.0`). Kotlin's `when` takes the first match, so any coordinate landing exactly on a field, square or subsquare boundary was attributed to the previous cell: (0, 0) II99xx99 should be JJ00aa00 (1, 1) JJ00lx99 should be JJ01ma00 (22, 108) OL31xx99 should be OL42aa00 (22.5, 108.5) OL42fl99 should be OL42gm00 (22.25, 108.25) OL42cf99 should be OL42dg00 At the field level the locator is wrong by a whole 20 deg x 10 deg field, and the 3x3 neighbour grid plus the red position marker are derived from the same characters, so the whole Roaming screen pointed at the wrong square. Cross-checking the port against core/domain positionToQth over the grid: before: 65,341 sampled points, 4 agreed after: 65,341 sampled points, all agree The independent converter was confirmed correct first: it reproduces the user-verified reference sample OL42ih45, and hand-computing lon=-179.75 (0.25 deg into the field, x12 -> subsquare index 3 = 'd') and lat=-90 (subsquare 'a', extended digit 0) matches it rather than the port. Rather than rewriting the faithful lookup tables, nudge the input by 1e-10 so the closed ranges behave like the standard half-open [low, high) cells, keeping +90/+180 inside the final R cell. Seven real-world city samples and all existing ported-behaviour tests, including (90, 180) -> RR99xx99, are unchanged. Regression tests added for the boundary cases and for cross-implementation agreement. Reverting the fix fails both; with the fix :feature:roaming:testDebugUnitTest is green. --- .../look4sat/feature/roaming/RoamingScreen.kt | 13 ++++++-- .../feature/roaming/RoamingStateTest.kt | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt index b3afbb2b..1380f32f 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -447,7 +447,14 @@ private val latMargin = mapOf( // Ported lines 311-611: longitude -> (20 deg zone letter, 2 deg digit, 2' letter, 30" digit) private fun encodeLon(inputLon: Double): List { - var longitude = inputLon + // The decompiled range table uses closed bounds on both adjacent cells + // (e.g. -20..0 followed by 0..20), and `when` picks the first match. Exact + // field/square/subsquare boundaries therefore fell into the previous cell: + // lon=0 produced I... instead of J..., lon=108 lost one square/subsquare. + // A tiny positive nudge implements the standard half-open [low, high) + // convention without rewriting the faithful lookup table; keep +180 inside + // the final R cell. + var longitude = if (inputLon < 180.0) inputLon + 1e-10 else 180.0 - 1e-10 var d = 0.0 val str = when { (longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" } @@ -528,7 +535,9 @@ private fun encodeLon(inputLon: Double): List { // Ported lines 612-914: latitude -> (10 deg zone letter, 1 deg digit, 1' letter, 15" digit) private fun encodeLat(inputLat: Double): List { - var latitude = inputLat + // Same closed-bound issue as encodeLon: nudge into the half-open cell so an + // exact boundary latitude does not fall back into the previous field. + var latitude = if (inputLat < 90.0) inputLat + 1e-10 else 90.0 - 1e-10 var d2 = 0.0 var d3 = 0.0 val str5 = when { diff --git a/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt b/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt index 99444302..46e4439f 100644 --- a/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt +++ b/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt @@ -111,6 +111,36 @@ class RoamingStateTest { assertEquals("RR99xx99", state.loc) } + @Test + fun `exact cell boundaries belong to the upper cell`() { + // The decompiled range table closes both adjacent cells (-20..0 then + // 0..20) and `when` takes the first match, so an exact boundary used to + // fall back into the previous cell: (0,0) encoded as II99xx99. + assertEquals("JJ00aa00", stateOf(0.0, 0.0).loc) + assertEquals("JJ01ma00", stateOf(1.0, 1.0).loc) + assertEquals("OL42aa00", stateOf(22.0, 108.0).loc) + assertEquals("OL42gm00", stateOf(22.5, 108.5).loc) + assertEquals("OL42dg00", stateOf(22.25, 108.25).loc) + } + + @Test + fun `matches the independent Maidenhead converter across the grid`() { + // Two independent implementations of the same standard must agree; + // divergence previously affected every exact-boundary coordinate. + var lat = -90.0 + while (lat <= 90.0) { + var lon = -180.0 + while (lon <= 180.0) { + val ported = stateOf(lat, lon).loc + val reference = + com.rtbishop.look4sat.core.domain.utility.positionToQth(lat, lon, 8) + assertEquals("mismatch at ($lat, $lon)", reference?.lowercase(), ported.lowercase()) + lon += 5.0 + } + lat += 5.0 + } + } + @Test fun `gps state after fix matches showLocation`() { val state = stateOf(22.314066, 108.706575)