From bccc2ccfccc517d10e72f2a10360f13404924625 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 8 Aug 2026 15:04:09 +0800 Subject: [PATCH] fix: broaden linear transponder matching and deduplicate same-range entries - isNamedLinearTransponder: match ' Lin' prefix (AO-7 style) and add fallback branch (hasLinearName && hasLinearMode) for entries with 'Linear'/'Lin' but no 'transponder' word (AO-7, AO-73) - add deduplicateTransponders: merge entries sharing the same uplink/downlink frequency range, preferring non-CW entry (AO-7 Mode A had separate SSB and CW entries; JO-97 had separate CW and SSB Transponder entries) - apply dedup in CalculatorPage filter chain - 6 new tests, 23 total, all passing --- .../utility/DopplerFrequencyCalculator.kt | 23 ++++- .../domain/DopplerFrequencyCalculatorTest.kt | 85 ++++++++++++++++++- .../feature/radar/TransceiversPage.kt | 1 + 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt index 6b5e9575..1bd9037c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -112,11 +112,30 @@ object DopplerFrequencyCalculator { val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) .joinToString(separator = " ") .lowercase(Locale.ENGLISH) - val hasLinearName = info.contains("linear") + val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin") val hasTransponderName = info.contains("transponder") || info.contains("transp") || info.contains("xponder") || info.contains("xpdr") val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } - return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) + return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) || + (hasLinearName && hasLinearMode) + } + + /** + * Removes duplicate transponder entries that describe the same physical + * transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A + * as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both + * "CW Transponder" and "SSB Transponder"). + * + * Entries sharing the same uplink/downlink frequency range are considered + * the same transponder. The non-CW entry is preferred because its invert + * flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false). + */ + fun deduplicateTransponders(radios: List): List { + return radios.groupBy { radio -> + listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh) + }.values.map { group -> + group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first() + } } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt index 49e8ebb6..dd832672 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -13,6 +13,7 @@ import org.junit.Test class DopplerFrequencyCalculatorTest { private fun linearTransponder( + uuid: String = "linear", upLow: Long = 145_000_000L, upHigh: Long = 145_500_000L, downLow: Long = 435_000_000L, @@ -22,7 +23,7 @@ class DopplerFrequencyCalculatorTest { downlinkMode: String? = "USB", uplinkMode: String? = "LSB" ) = SatRadio( - uuid = "linear", info = info, isAlive = true, + uuid = uuid, info = info, isAlive = true, downlinkLow = downLow, downlinkHigh = downHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 @@ -72,11 +73,93 @@ class DopplerFrequencyCalculatorTest { assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) } + @Test + fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() { + // AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word + val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry)) + val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry)) + val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry)) + } + + @Test + fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() { + // AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder" + val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() { + // "Downlink" contains "lin" but is not a linear-transponder name + val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM") + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry)) + } + @Test fun isNamedLinearTransponder_returnsFalseForFmRepeater() { assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) } + @Test + fun deduplicateTransponders_mergesSameFrequencyRange() { + // AO-7's Mode A: same range, SSB and CW entries + val ssb = linearTransponder( + uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB", + downlinkMode = "USB", uplinkMode = "USB" + ) + val cw = linearTransponder( + uuid = "cw-uuid", info = "Mode V/A (A) Lin CW", + downlinkMode = "CW", uplinkMode = "CW" + ) + val modeB = linearTransponder( + uuid = "modeb-uuid", info = "Mode U/V (B) Lin", + upLow = 432_125_000L, upHigh = 432_175_000L, + downLow = 145_925_000L, downHigh = 145_975_000L, + downlinkMode = "USB", uplinkMode = "LSB" + ) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB)) + assertEquals(2, result.size) + // SSB entry should be preferred over CW (same range) + assertEquals("ssb-uuid", result[0].uuid) + assertEquals("modeb-uuid", result[1].uuid) + } + + @Test + fun deduplicateTransponders_prefersNonCwEntry() { + // JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct) + val cw = linearTransponder( + uuid = "cw-uuid", info = "U/V CW Transponder", + downlinkMode = "CW", uplinkMode = "CW", + upLow = 435_100_000L, upHigh = 435_120_000L, + downLow = 145_855_000L, downHigh = 145_875_000L + ) + val ssb = linearTransponder( + uuid = "ssb-uuid", info = "U/V SSB Transponder", + downlinkMode = "USB", uplinkMode = "LSB", + upLow = 435_100_000L, upHigh = 435_120_000L, + downLow = 145_855_000L, downHigh = 145_875_000L, + inverted = true + ) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb)) + assertEquals(1, result.size) + assertEquals("ssb-uuid", result[0].uuid) + // Verify the correct invert flag is preserved + assertTrue(result[0].isInverted) + } + + @Test + fun deduplicateTransponders_preservesUniqueEntries() { + val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L, + downLow = 435_000_000L, downHigh = 435_500_000L) + val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L, + downLow = 145_000_000L, downHigh = 145_500_000L) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2)) + assertEquals(2, result.size) + } + @Test fun computeUplinkFromDownlink_linear_noDoppler() { val xpdr = linearTransponder() diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index 1a4d2e10..2d03bc58 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -122,6 +122,7 @@ fun CalculatorPage( ) { val calculatorTransceivers = remember(transceivers) { transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) + .let(DopplerFrequencyCalculator::deduplicateTransponders) } val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }