From 92499b1cf1ba66f35db19d812f0c1b2bb8bdd574 Mon Sep 17 00:00:00 2001 From: QIU Date: Tue, 25 Aug 2026 06:16:08 +0000 Subject: [PATCH] feat(wavelog): map NORAD catalogue numbers to LoTW satellite names LoTW refuses a QSO whose SAT_NAME is not spelled as in its accepted list - its own help page gives AO7 against AO-7 as a rejection - so the name we upload has to match exactly. The existing code derives that name with substringBefore('('), which returns the descriptive part of a TLE name rather than the OSCAR designator: measured against live Celestrak amateur data, 0 of 96 satellites resolved to a name LoTW accepts. ASRTU-1 uploads as ASRTU-1 where LoTW wants AO-123. Keying on the name cannot be made to work, because 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 user gets depends on the source they happen to fetch from. The NORAD catalogue number is identical everywhere, so this table is keyed on it. Coverage is 29 entries, not the 112 names LoTW lists, because the rest are satellites no source still carries: they have re-entered, no user can track them, and a mapping for them would never be consulted. Every number was read out of live TLE data from the app's own configured sources rather than typed from memory - a first attempt at writing them by hand had AO-123 as 62690 when it is 61781. Three names matched more than one catalogued object and were settled by which object the amateur-specific sources carry. ARISS is 25544, the station; the full catalogue also lists ISS (UNITY), (ZVEZDA), (DESTINY) and (NAUKA), which are modules. IO-117 is 53109, named GREENCUBE (IO-117) by four sources against R4UAB alone calling it ROBUSTA 1F. TO-108 is 44881, in all three amateur sources, where 44879 is TIANQIN 1. Not yet wired into the upload path: WavelogQso carries only a satellite name, so the catalogue number has to be threaded through from the radar screen first. This commit adds the table and its tests only, leaving behaviour unchanged. --- .../core/domain/wavelog/LotwSatelliteIds.kt | 77 +++++++++++++++ .../domain/wavelog/LotwSatelliteIdsTest.kt | 94 +++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt 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 new file mode 100644 index 00000000..ae503bb8 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt @@ -0,0 +1,77 @@ +/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name. + * + * Why the catalogue number and not the name: the same satellite carries different names in + * different TLE sources, so a name-keyed table misses whenever the user switches source. + * Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in + * both 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". The catalogue number is identical in every + * source, so it is the only stable key. + * + * LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list + * (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7 + * instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling + * held in LotwSatellites rather than to whatever the TLE happens to say. + * + * Every number here was read out of live TLE data, never typed from memory. Entries cover the + * satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in + * the LoTW list; the rest of that list is satellites no source still carries, so no user can + * track them and no mapping is needed for them. + */ +package com.rtbishop.look4sat.core.domain.wavelog + +object LotwSatelliteIds { + + /** + * NORAD catalogue number to the LoTW spelling. The trailing comment is one name the + * satellite goes by in the sources, kept so a reader can recognise the entry. + * + * Three numbers had to be decided rather than derived, because one name matched several + * catalogued objects. Each was settled by which object the amateur-specific sources carry: + * - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY), + * (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work. + * - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls + * it ROBUSTA 1F, which is a different satellite. + * - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears + * only in the general catalogue. + */ + private val idToName: Map = mapOf( + 7530 to "AO-7", // AO-07 + 14129 to "AO-10", // PHASE 3B (AO-10) + 20439 to "AO-16", // OSCAR 16 (PACSAT) + 20442 to "LO-19", // LO-19 + 22825 to "AO-27", // AO-27 + 23439 to "RS-15", // RADIO ROSTO (RS-15) + 24278 to "FO-29", // FO-29 + 25544 to "ARISS", // ISS (ZARYA) + 26609 to "AO-40", // PHASE 3D (AO-40) + 26931 to "NO-44", // NO-44 + 27607 to "SO-50", // SAUDISAT 1C (SO-50) + 28650 to "VO-52", // HAMSAT (VO-52) + 39444 to "AO-73", // AO-73 + 40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1 + 40074 to "UKUBE1", // UKUBE-1 + 40908 to "CAS-3H", // LILACSAT-2 + 40931 to "IO-86", // IO-86 + 40967 to "AO-85", // FOX-1A (AO-85) + 41847 to "CAS-2T", // CAS-2T + 43017 to "AO-91", // AO-91 + 43678 to "PO-101", // DIWATA-2B + 43700 to "QO-100", // ES'HAIL 2 + 43803 to "JO-97", // JO-97 + 44530 to "TAURUS", // TAURUS-1 + 44881 to "TO-108", // CAS-6 (TO-108) + 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 + ) + + /** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */ + fun nameFor(catnum: Int): String? = idToName[catnum] + + /** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */ + fun isKnown(catnum: Int): Boolean = catnum in idToName + + /** Entry count, so a test can catch the table being emptied by a bad edit. */ + val size: Int get() = idToName.size +} 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 new file mode 100644 index 00000000..ff77d9fa --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt @@ -0,0 +1,94 @@ +package com.rtbishop.look4sat.core.domain.wavelog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LotwSatelliteIdsTest { + + /** + * Every name the table produces has to exist in the LoTW list verbatim, because LoTW + * rejects a QSO whose SAT_NAME is spelled differently - AO7 for AO-7 is refused. + */ + @Test + fun `every mapped name is spelled exactly as LoTW has it`() { + val known = LotwSatellites.names + for (catnum in catnums) { + val name = LotwSatelliteIds.nameFor(catnum) + assertTrue("$catnum maps to $name, which LoTW does not list", name in known) + } + } + + /** + * The catalogue number is the key precisely so that the source a user happens to fetch + * from cannot change the answer. These are the numbers whose names differ most between + * Celestrak and AMSAT, so they are the ones worth pinning. + */ + @Test + fun `names that differ between sources resolve to the LoTW spelling`() { + assertEquals("AO-91", LotwSatelliteIds.nameFor(43017)) // RADFXSAT (FOX-1B) / AO-91 + assertEquals("QO-100", LotwSatelliteIds.nameFor(43700)) // ES'HAIL 2 / QO-100 + assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) // TIANYAN 01 / CAS-6 (TO-108) + assertEquals("SO-50", LotwSatelliteIds.nameFor(27607)) // SAUDISAT 1C (SO-50) / SO-50 + assertEquals("AO-123", LotwSatelliteIds.nameFor(61781)) // ASRTU-1 (AO-123) / AO-123 + } + + /** + * ARISS is the station, not its modules. Celestrak's full catalogue lists ISS (UNITY), + * (ZVEZDA), (DESTINY) and (NAUKA) as separate objects; matching on the name "ISS" pulled + * all of them in, and a QSO cannot be worked through a module. + */ + @Test + fun `ARISS is the station and not one of its modules`() { + assertEquals("ARISS", LotwSatelliteIds.nameFor(25544)) + for (module in listOf(25575, 26400, 26700, 49044)) { + assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module)) + } + } + + /** + * One source (R4UAB) names 53109 as ROBUSTA 1F while four others call it GREENCUBE + * (IO-117), and 53106 appears in that one source alone. Trusting either would have put a + * second catalogue number on the same LoTW name. + */ + @Test + fun `IO-117 resolves to the number the amateur sources agree on`() { + assertEquals("IO-117", LotwSatelliteIds.nameFor(53109)) + assertNull(LotwSatelliteIds.nameFor(53106)) + } + + /** 44879 is TIANQIN 1, catalogued but not an amateur satellite carrying TO-108. */ + @Test + fun `TO-108 does not also match TIANQIN 1`() { + assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) + assertNull(LotwSatelliteIds.nameFor(44879)) + } + + /** No two numbers may share a name, or one of them is the wrong satellite. */ + @Test + fun `each LoTW name is claimed by a single catalogue number`() { + val names = catnums.mapNotNull { LotwSatelliteIds.nameFor(it) } + assertEquals(names.size, names.toSet().size) + } + + @Test + fun `isKnown agrees with nameFor`() { + assertTrue(LotwSatelliteIds.isKnown(27607)) + assertFalse(LotwSatelliteIds.isKnown(99999)) + assertNull(LotwSatelliteIds.nameFor(99999)) + } + + /** Guards against an edit that empties or truncates the table. */ + @Test + fun `table holds every entry`() { + assertEquals(catnums.size, LotwSatelliteIds.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 + ) +}