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.
This commit is contained in:
mckero committed 2026-08-14 16:59:30 +00:00
1 parent 19ca5205fb
commit fed9fe188e
2 files changed
+41 -2

No files matched your search

@@ -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<String> {
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<String> {
// Ported lines 612-914: latitude -> (10 deg zone letter, 1 deg digit, 1' letter, 15" digit)
private fun encodeLat(inputLat: Double): List<String> {
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 {
@@ -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)