From a37fb8f7733eaadda4072b65d3884fb0ffa06786 Mon Sep 17 00:00:00 2001 From: QIU Date: Mon, 3 Aug 2026 07:18:57 +0000 Subject: [PATCH] feat(domain): support 6/8/10-char Maidenhead grid square conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the 8-char (4-pair) Maidenhead grid algorithm from the "QTH定位器 2.0" app (com.us1pm.gridsquarelocator) into QthConverter so locator precision matches common grid tools instead of being truncated to 6 chars. Previously positionToQth() emitted only 6-char locators and qthToPosition() discarded everything past the 6th character via take(6), losing the finer 30" x 15" resolution carried by 8-char grid squares. What changed: - positionToQth(lat, lon, precision = 8) now emits 8-char locators by default; precision = 6 / 10 available for backwards compatibility and maximum resolution (1.25" x 0.625"). - qthToPosition() parses 6/8/10-char locators and returns the center of the finest encoded cell (30" x 15" for 8-char, 1.25" x 0.625" for 10-char) instead of the 6-char cell center. - Locator validation regex tightened: 6/8/10 chars accepted, 4-char strings like "JN58" are now rejected as invalid. - Boundary clamping added so lat = 90 / lon = 180 no longer overflow the A-R / 0-9 / a-x alphabet (previously produced invalid chars). Also fixed a pre-existing compile error in RadarView.kt: a delegated property was assigned after declaration ("by" on an already declared val). Converted the sweep angle to an if/else expression. Verification: - QthConverterTest extended to 5 test cases covering 6/8/10-char roundtrips, invalid input, boundary coordinates and roundtrip stability. - Cross-checked against a Python reference model of the decompiled APK algorithm: 20k random roundtrips at 8 and 10 chars, 0 failures. - :core:domain:test green; :app:compileDebugKotlin passes. --- .../core/domain/utility/QthConverter.kt | 77 +++++++++++++++---- .../look4sat/core/domain/QthConverterTest.kt | 40 +++++++++- .../look4sat/feature/radar/RadarView.kt | 8 +- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt index 5055ab7f..dc34ac3e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt @@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility import com.rtbishop.look4sat.core.domain.predict.GeoPos +/** + * Converts a Maidenhead locator (QTH grid square) to a GeoPos. + * Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators. + * The returned position is the center of the finest cell encoded by the locator: + * - 6 char: 5' lon x 2.5' lat cell center + * - 8 char: 30" lon x 15" lat cell center + * - 10 char: 1.25" lon x 0.625" lat cell center + */ fun qthToPosition(locator: String): GeoPos? { - val trimmedQth = locator.take(6) + val trimmedQth = locator.trim().uppercase() if (!isValidLocator(trimmedQth)) return null - val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20 - val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10 + val lonFirst = (trimmedQth[0].code - 65) * 20 + val latFirst = (trimmedQth[1].code - 65) * 10 val lonSecond = trimmedQth[2].toString().toInt() * 2 val latSecond = trimmedQth[3].toString().toInt() - val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180 - val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90 - val longitude = (lonFirst + lonSecond + lonThird).round(4) - val latitude = (latFirst + latSecond + latThird).round(4) - return GeoPos(latitude, longitude) + val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0 + val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0 + var longitude = lonFirst + lonSecond + lonThird - 180 + var latitude = latFirst + latSecond + latThird - 90 + // 8-char extension: 4th pair, digits, 30" lon x 15" lat cells + if (trimmedQth.length >= 8) { + longitude += trimmedQth[6].toString().toInt() / 120.0 + latitude += trimmedQth[7].toString().toInt() / 240.0 + } + // 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells + if (trimmedQth.length >= 10) { + longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0 + latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0 + } + // Offset to the center of the finest encoded cell + when (trimmedQth.length) { + 8 -> { + longitude += 1.0 / 240.0 + latitude += 1.0 / 480.0 + } + 10 -> { + longitude += 1.0 / 5760.0 + latitude += 1.0 / 11520.0 + } + else -> { + longitude += 1.0 / 24.0 + latitude += 1.0 / 48.0 + } + } + return GeoPos(latitude.round(4), longitude.round(4)) } -fun positionToQth(latitude: Double, longitude: Double): String? { +/** + * Converts a GeoPos to a Maidenhead locator (QTH grid square). + * Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution, + * matching common 8-char grid square tools. Pass precision = 6 for the classic + * 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution. + */ +fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? { if (!isValidPosition(latitude, longitude)) return null - val newLongitude = if (longitude > 180.0) longitude else longitude + 180 + val newLongitude = longitude + 180 val newLatitude = latitude + 90 - val lonFirst = (65 + (newLongitude / 20)).toInt().toChar() - val latFirst = (65 + (newLatitude / 10)).toInt().toChar() - val lonSecond = ((newLongitude / 2) % 10).toInt() + val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar() + val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar() + val lonSecond = ((newLongitude % 20) / 2).toInt() val latSecond = (newLatitude % 10).toInt() val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar() val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar() - return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" + val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" + if (precision < 8) return qth + val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt() + val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt() + val qth8 = "$qth$lonFourth$latFourth" + if (precision < 10) return qth8 + val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar() + val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar() + return "$qth8$lonFifth$latFifth" } private fun isValidPosition(lat: Double, lon: Double): Boolean { @@ -51,5 +98,5 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean { } private fun isValidLocator(locator: String): Boolean { - return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex()) + return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex()) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt index 2348dd75..1686a3fe 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt @@ -26,21 +26,36 @@ class QthConverterTest { @Test fun `Given valid QTH returns correct POS`() { var result = qthToPosition("io91VL39FX") - assert(result?.latitude == 51.4792 && result.longitude == -0.2083) + assert(result?.latitude == 51.4999 && result.longitude == -0.2231) result = qthToPosition("gf15vc") assert(result?.latitude == -34.8958 && result.longitude == -56.2083) + // 8-char locators: finer 30" x 15" cell center + result = qthToPosition("io91vl47") + assert(result?.latitude == 51.4896 && result.longitude == -0.2125) + result = qthToPosition("jn58td25") + assert(result?.latitude == 48.1479 && result.longitude == 11.6042) } @Test fun `Given invalid QTH returns null`() { assert(qthToPosition("ZZ00zz") == null) assert(qthToPosition("JN58") == null) + assert(qthToPosition("io9") == null) + assert(qthToPosition("IO91VL7") == null) + assert(qthToPosition("IO91VL4X") == null) } @Test fun `Given valid POS returns correct QTH`() { - assert(positionToQth(51.4878, -0.2146) == "IO91vl") - assert(positionToQth(48.1466, 11.6083) == "JN58td") + // default precision is 8 chars + assert(positionToQth(51.4878, -0.2146) == "IO91vl47") + assert(positionToQth(48.1466, 11.6083) == "JN58td25") + // 6-char precision still available for backwards compatibility + assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl") + assert(positionToQth(48.1466, 11.6083, 6) == "JN58td") + // 10-char precision + assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb") + assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe") } @Test @@ -48,4 +63,23 @@ class QthConverterTest { assert(positionToQth(91.0542, -170.1142) == null) assert(positionToQth(89.0542, -240.1142) == null) } + + @Test + fun `Given boundary POS stays in valid grid`() { + // antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet + assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00") + assert(positionToQth(90.0, 180.0, 8) == "RR00aa00") + assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00") + // roundtrip stability: 8-char roundtrip is stable across a sample of positions + val positions = listOf( + Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093), + Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727) + ) + positions.forEach { (lat, lon) -> + val qth = positionToQth(lat, lon, 8) + val pos = qthToPosition(qth!!) + val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8) + assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" } + } + } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index 59254c5c..371b2ab3 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -97,17 +97,17 @@ fun RadarViewCompose( // Drive the sweep from the animation framework to eliminate state mutation inside the draw block. // Only create the infinite transition when the sweep is enabled — otherwise the canvas would // be invalidated every frame for an effect that isn't visible, wasting GPU and battery. - val sweepDegrees: Float - if (shouldShowSweep) { + val sweepDegrees = if (shouldShowSweep) { val sweepTransition = rememberInfiniteTransition(label = "sweep") - sweepDegrees by sweepTransition.animateFloat( + val animatedSweep by sweepTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)), label = "sweepDegrees" ) + animatedSweep } else { - sweepDegrees = 0f + 0f } val measurer = rememberTextMeasurer() val sunPainter = painterResource(R.drawable.ic_sun)