diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt index ae503bb8..1941d252 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt @@ -63,7 +63,20 @@ object LotwSatelliteIds { 44909 to "RS-44", // DOSAAF-85 (RS-44) 50466 to "HO-113", // CAMSAT XW-3 (CAS-9) 53109 to "IO-117", // GREENCUBE (IO-117) - 61781 to "AO-123" // AO-123 + 61781 to "AO-123", // AO-123 + // The TEVEL-2 constellation. Every source writes these TEVEL2-N while LoTW has TEV2-N, + // and no amount of separator-stripping bridges that - TEVEL21 is not TEV21 - so without + // these nine rows their QSOs upload under a name LoTW refuses. Note the numbering is not + // sequential: 63217 is TEVEL2-1 and 63213 is TEVEL2-4. + 63213 to "TEV2-4", + 63214 to "TEV2-5", + 63215 to "TEV2-6", + 63217 to "TEV2-1", + 63218 to "TEV2-3", + 63219 to "TEV2-2", + 63237 to "TEV2-9", + 63238 to "TEV2-7", + 63239 to "TEV2-8" ) /** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */ diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt index 5586fb08..de6c20a4 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt @@ -151,28 +151,63 @@ object WaveLogApi { return if (up == down) "" else "$up/$down" } - /** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */ - fun normalizeSatName(raw: String): String { - val main = raw.substringBefore('(').trim() - .ifBlank { raw.trim() } - .uppercase(Locale.ENGLISH) - // Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list): - // 1. Already in LoTW list (common TLE name == common name) -> return as-is - // 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list - // 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved) - val commonName = mapOf( - "ZARYA" to "ARISS", - "ARISS" to "ARISS", - "FUNCUBE-1" to "AO-73", - "DIWATA-2B" to "PO-101", - "SAUDISAT-1C" to "SO-50", - "SAUDISAT 1C" to "SO-50", - "DIWATA-2A" to "PO-101" - ) - val candidate = commonName[main] ?: main - return if (candidate in LotwSatellites.names) candidate else main + /** + * The name LoTW accepts for this satellite, resolved from its catalogue number when known. + * + * LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help + * page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or + * nothing useful at all. + * + * [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and + * of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named + * differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is + * "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96 + * satellites to something LoTW accepts, because the descriptive part of a TLE name is never + * the OSCAR designator. + * + * The name path remains as a fallback for QSOs logged before the catalogue number was + * recorded. It tries the whole name, then either side of the parentheses, since which side + * carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not. + */ + fun normalizeSatName(raw: String, catnum: Int? = null): String { + catnum?.let { LotwSatelliteIds.nameFor(it) }?.let { return it } + val trimmed = raw.trim() + for (candidate in nameCandidates(trimmed)) { + LotwSatellites.names.firstOrNull { it.equals(candidate, ignoreCase = true) } + ?.let { return it } + } + // Tolerate a missing or extra hyphen: sources write RS15 where LoTW has RS-15. + for (candidate in nameCandidates(trimmed)) { + val squashed = candidate.squashSeparators() + LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed } + ?.let { return it } + } + return trimmed.uppercase(Locale.ENGLISH) } + /** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */ + fun isLotwSatellite(name: String, catnum: Int? = null): Boolean { + val resolved = normalizeSatName(name, catnum) + return LotwSatellites.names.any { it.equals(resolved, ignoreCase = true) } + } + + /** The whole name plus either side of the parentheses, longest first. */ + private fun nameCandidates(raw: String): List { + if (raw.isEmpty()) return emptyList() + val parts = mutableListOf(raw) + val open = raw.indexOf('(') + val close = raw.lastIndexOf(')') + if (open in 0.. 0 }) // v2: POST /index.php/api/v2/qso (JSON fields) val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt index da5b149d..8caa6862 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt @@ -25,6 +25,14 @@ data class WavelogQso( val freqTxHz: Long, // 上行(回车那一秒多普勒修正) val freqRxHz: Long, // 下行 val satName: String, + /** + * NORAD catalogue number of the satellite, or 0 when it was not recorded. + * + * Carried because the name alone cannot decide the LoTW spelling - sources disagree, and + * the same satellite named two ways would upload two ways. Zero means a QSO logged before + * this field existed; those fall back to resolving from the name. + */ + val catnum: Int = 0, val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据) val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到 val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾) @@ -48,6 +56,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) { freqTxHz = o.optLong("freqTxHz"), freqRxHz = o.optLong("freqRxHz"), satName = o.optString("satName"), + catnum = o.optInt("catnum", 0), sessionId = o.optString("sessionId"), gridsquare = o.optString("gridsquare"), uploaded = o.optBoolean("uploaded", false) @@ -99,6 +108,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) { put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call) put("mode", q.mode); put("freqTxHz", q.freqTxHz) put("freqRxHz", q.freqRxHz); put("satName", q.satName) + put("catnum", q.catnum) put("sessionId", q.sessionId) put("gridsquare", q.gridsquare) put("uploaded", q.uploaded) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt index ff77d9fa..30852c87 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt @@ -86,9 +86,30 @@ class LotwSatelliteIdsTest { assertEquals(catnums.size, LotwSatelliteIds.size) } + /** + * The TEVEL-2 constellation needs the table more than anything else does: every source + * writes these TEVEL2-N while LoTW has TEV2-N, and stripping separators does not bridge + * that - TEVEL21 is not TEV21 - so without these rows their QSOs upload under a name LoTW + * refuses. Nine satellites launched in 2025 and currently workable; they were missing from + * the first version of this table. + */ + @Test + fun `the TEVEL-2 constellation maps to the LoTW spelling`() { + assertEquals("TEV2-1", LotwSatelliteIds.nameFor(63217)) + assertEquals("TEV2-4", LotwSatelliteIds.nameFor(63213)) + assertEquals("TEV2-9", LotwSatelliteIds.nameFor(63237)) + // Nine numbers, nine distinct names. The numbering is deliberately not sequential - + // 63217 is TEVEL2-1 while 63213 is TEVEL2-4 - so an off-by-one logs the wrong bird. + val tevel = listOf(63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239) + val names = tevel.mapNotNull { LotwSatelliteIds.nameFor(it) } + assertEquals(tevel.size, names.size) + assertEquals(tevel.size, names.toSet().size) + } + private val catnums = listOf( 7530, 14129, 20439, 20442, 22825, 23439, 24278, 25544, 26609, 26931, 27607, 28650, 39444, 40025, 40074, 40908, 40931, 40967, 41847, 43017, - 43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781 + 43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781, + 63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239 ) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt new file mode 100644 index 00000000..6b71de26 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt @@ -0,0 +1,110 @@ +package com.rtbishop.look4sat.core.domain.wavelog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it, so these pin + * the exact spellings against the names real TLE sources actually publish. + */ +class SatNameResolutionTest { + + /** + * The catalogue number decides it whenever it is known, because sources disagree on the + * name for the same object and the descriptive part of a TLE name is never the designator. + */ + @Test + fun `the catalogue number wins over whatever the TLE calls it`() { + assertEquals("AO-123", WaveLogApi.normalizeSatName("ASRTU-1", 61781)) + assertEquals("AO-91", WaveLogApi.normalizeSatName("RADFXSAT (FOX-1B)", 43017)) + assertEquals("QO-100", WaveLogApi.normalizeSatName("ES'HAIL 2", 43700)) + assertEquals("TO-108", WaveLogApi.normalizeSatName("TIANYAN 01", 44881)) + assertEquals("CAS-3H", WaveLogApi.normalizeSatName("LILACSAT-2", 40908)) + assertEquals("ARISS", WaveLogApi.normalizeSatName("ISS (ZARYA)", 25544)) + } + + /** + * Two sources naming one satellite differently must still upload identically, or which + * source the operator fetched from would decide whether the QSO can be confirmed. + */ + @Test + fun `the same satellite resolves alike whichever source named it`() { + val fromCelestrak = WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 27607) + val fromAmsat = WaveLogApi.normalizeSatName("SO-50", 27607) + assertEquals(fromCelestrak, fromAmsat) + assertEquals("SO-50", fromCelestrak) + } + + /** Without a catalogue number - QSOs logged before it was recorded - the name is tried. */ + @Test + fun `falls back to the name when no catalogue number is given`() { + assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)")) + assertEquals("AO-7", WaveLogApi.normalizeSatName("OSCAR 7 (AO-7)")) + assertEquals("AO-73", WaveLogApi.normalizeSatName("FUNCUBE-1 (AO-73)")) + } + + /** + * Which side of the parentheses holds the designator varies, so both are tried: + * "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" has the useful part outside. + */ + @Test + fun `looks on both sides of the parentheses`() { + assertEquals("IO-117", WaveLogApi.normalizeSatName("GREENCUBE (IO-117)")) + assertEquals("ARISS", WaveLogApi.normalizeSatName("ARISS (ZARYA)")) + } + + /** Sources write RS15 where LoTW has RS-15; a hyphen must not decide confirmability. */ + @Test + fun `a differing hyphen still matches`() { + assertEquals("RS-15", WaveLogApi.normalizeSatName("RADIO ROSTO (RS15)")) + assertEquals("UKUBE1", WaveLogApi.normalizeSatName("UKUBE-1")) + } + + /** Formation launches are catalogued with a companion object appended. */ + @Test + fun `a shared launch entry resolves to the satellite`() { + assertEquals("CAS-2T", WaveLogApi.normalizeSatName("CAS-2T & KS-1Q")) + assertEquals("RS-44", WaveLogApi.normalizeSatName("RS-44 & BREEZE-KM R/B")) + } + + /** + * LoTW's list is not all upper case - Arsene is written that way - and uploading ARSENE + * would be rejected just as AO7 is. + */ + @Test + fun `the spelling LoTW uses is preserved rather than upper-cased`() { + assertEquals("Arsene", WaveLogApi.normalizeSatName("ARSENE")) + assertEquals("Arsene", WaveLogApi.normalizeSatName("arsene")) + assertEquals("Arsene", WaveLogApi.normalizeSatName("Arsene")) + } + + /** + * An unknown satellite is passed through rather than dropped: the QSO is still worth + * saving, and the caller uses [WaveLogApi.isLotwSatellite] to tell the operator that + * this one will not be confirmed. + */ + @Test + fun `an unknown satellite is passed through and reported as unknown`() { + assertEquals("CUTE-1 (CO-55)", WaveLogApi.normalizeSatName("CUTE-1 (CO-55)")) + assertFalse(WaveLogApi.isLotwSatellite("CUTE-1 (CO-55)")) + assertFalse(WaveLogApi.isLotwSatellite("CUBESAT XI-V")) + assertFalse(WaveLogApi.isLotwSatellite("SWISSCUBE")) + assertTrue(WaveLogApi.isLotwSatellite("SAUDISAT 1C (SO-50)")) + assertTrue(WaveLogApi.isLotwSatellite("ASRTU-1", 61781)) + } + + @Test + fun `an empty name does not crash`() { + assertEquals("", WaveLogApi.normalizeSatName("")) + assertEquals("", WaveLogApi.normalizeSatName(" ")) + assertFalse(WaveLogApi.isLotwSatellite("")) + } + + /** A catalogue number nobody mapped falls through to the name rather than returning null. */ + @Test + fun `an unmapped catalogue number falls back to the name`() { + assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 99999)) + } +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt index 31a94f41..57b07ad4 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt @@ -92,6 +92,7 @@ fun LogTab( transceivers: List, orbitalPos: OrbitalPos?, satelliteName: String, + satelliteCatnum: Int, queue: WavelogQueue, wavelogConfigured: Boolean, showToast: (String) -> Unit, @@ -167,6 +168,7 @@ fun LogTab( radio = radio, orbitalPos = orbitalPos, satelliteName = satelliteName, + satelliteCatnum = satelliteCatnum, queue = queue, showToast = showToast, txBaseFrequencyHz = txBaseFrequencyHz, @@ -253,6 +255,7 @@ private fun ExpandedLogInput( radio: SatRadio, orbitalPos: OrbitalPos?, satelliteName: String, + satelliteCatnum: Int, queue: WavelogQueue, showToast: (String) -> Unit, txBaseFrequencyHz: Long? = null, @@ -290,6 +293,7 @@ private fun ExpandedLogInput( freqTxHz = tx, freqRxHz = rx, satName = satelliteName, + catnum = satelliteCatnum, sessionId = buildSessionId(satelliteName, aosTimeMs) ) ) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 2266dcff..a0d3b389 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -297,6 +297,7 @@ private fun PagerCard( transceivers = uiState.transceivers.transmitters, orbitalPos = uiState.orbitalPos, satelliteName = uiState.currentPass?.name ?: "", + satelliteCatnum = uiState.currentPass?.catNum ?: 0, queue = wavelogQueue, wavelogConfigured = wavelogConfigured, showToast = showToast,