fix(wavelog): resolve the LoTW satellite name from the catalogue number

LoTW refuses a QSO whose SAT_NAME is not spelled as its accepted list has it - its
own help page gives AO7 against AO-7 as a rejection - so the name we upload decides
whether a contact can ever be confirmed. The old code derived it with
substringBefore('('), which returns the descriptive half of a TLE name rather than
the OSCAR designator: measured against live Celestrak amateur data, 0 of 96
satellites resolved to something LoTW accepts. ASRTU-1 went up as ASRTU-1 where
LoTW wants AO-123.

Keying on the name cannot be made to work, because the sources disagree. Of the 49
satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
differently - 43017 is RADFXSAT (FOX-1B) in one and AO-91 in the other, 43700 is
ES'HAIL 2 against QO-100 - so which name a QSO got depended on where the operator
fetched their TLE. The catalogue number is identical everywhere, so the table is
keyed on it and OrbitalPass.catNum is now threaded through to the QSO and persisted.

The name path stays as a fallback for contacts logged before the number was
recorded, and got two fixes of its own: it tries either side of the parentheses
rather than assuming the designator is on the left, and tolerates a differing
separator so RADIO ROSTO (RS15) reaches RS-15. Resolution now returns the list's own
spelling, so Arsene is not uploaded as ARSENE and rejected the same way AO7 would be.

Measured on the same data: 3 names resolved before, 20 by name alone now, 30 with
the catalogue number, and no satellite that used to resolve stopped resolving.

The table gained the nine TEVEL-2 satellites after an audit found them missing.
Every source writes those TEVEL2-N while LoTW has TEV2-N, which stripping separators
does not bridge - TEVEL21 is not TEV21 - so they resolved to nothing at all. They
launched in 2025 and are workable now. Their numbering is not sequential: 63217 is
TEVEL2-1 while 63213 is TEVEL2-4.

All 38 entries were cross-checked two independent ways: every catalogue number
appears in the app's own configured sources under a name consistent with the LoTW
spelling, and ARRL's startDate for each name agrees with the launch year in the
TLE international designator - which is what would catch a number pointing at the
wrong object, since a name can match by luck. Nothing here was typed from memory;
an early hand-written draft had AO-123 as 62690 when it is 61781.
This commit is contained in:
mckero committed 2026-08-25 08:38:45 +00:00
1 parent bdc6db5aff
commit 38f939bd49
7 files changed
+217 -23

No files matched your search

@@ -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. */
@@ -151,27 +151,62 @@ 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<String> {
if (raw.isEmpty()) return emptyList()
val parts = mutableListOf(raw)
val open = raw.indexOf('(')
val close = raw.lastIndexOf(')')
if (open in 0..<close) {
parts += raw.substring(open + 1, close).trim()
parts += raw.substring(0, open).trim()
}
// Formation launches are catalogued as "RS-44 & BREEZE-KM R/B".
if ('&' in raw) parts += raw.substringBefore('&').trim()
return parts.filter { it.isNotEmpty() }.distinct()
}
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH)
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
@@ -184,7 +219,7 @@ object WaveLogApi {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
@@ -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)
@@ -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
)
}
@@ -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))
}
}
@@ -92,6 +92,7 @@ fun LogTab(
transceivers: List<SatRadio>,
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)
)
)
@@ -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,