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 + ) +}