mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 19:47:22 +00:00
feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化
This commit is contained in:
1 parent
1871c05002
commit
5acf71b2a6
26 files changed
+2145
-98
No files matched your search
@@ -22,4 +22,12 @@ interface IQsoRepository {
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
/**
|
||||
* Folds LoTW confirmations that were imported as their own rows back into the local
|
||||
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
||||
* Confirmations merged before the tracker-name/band identity fix split every contact in
|
||||
* two; this repairs the existing logbook. Returns the number of merged rows.
|
||||
*/
|
||||
suspend fun consolidateConfirmations(): Int
|
||||
}
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
|
||||
*
|
||||
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
|
||||
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
|
||||
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
|
||||
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
|
||||
* satellite.
|
||||
*
|
||||
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
|
||||
* exact config.tq6 satellite names. Generated 2026-09-27 from:
|
||||
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
|
||||
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
||||
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
|
||||
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
|
||||
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
||||
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||
* here is a real config.tq6 name.
|
||||
*/
|
||||
object LoTWSatelliteAliases {
|
||||
|
||||
val table: Map<String, String> = mapOf(
|
||||
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
|
||||
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
|
||||
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
|
||||
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
|
||||
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
|
||||
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
|
||||
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
|
||||
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
|
||||
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
|
||||
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
|
||||
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
|
||||
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
|
||||
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
|
||||
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
|
||||
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
|
||||
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
|
||||
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
|
||||
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
|
||||
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
|
||||
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
|
||||
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
|
||||
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
|
||||
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
|
||||
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
|
||||
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
|
||||
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
|
||||
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
|
||||
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
|
||||
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
|
||||
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
|
||||
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
|
||||
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
|
||||
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
|
||||
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
|
||||
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
|
||||
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
||||
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
||||
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
|
||||
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
|
||||
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
|
||||
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
|
||||
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
|
||||
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
|
||||
"ZARYA" to "ARISS",
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
|
||||
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
|
||||
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
|
||||
*/
|
||||
fun lookup(name: String): String? {
|
||||
val normalized = normalize(name)
|
||||
table[normalized]?.let { return it }
|
||||
return table.entries
|
||||
.filter { it.key.length >= 5 && normalized.contains(it.key) }
|
||||
.maxByOrNull { it.key.length }
|
||||
?.value
|
||||
}
|
||||
|
||||
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
|
||||
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
|
||||
|
||||
/**
|
||||
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
|
||||
* from [table], then an official name contained in the tracker name. Returns the official
|
||||
* name, or null when the tracker name is not an ARRL satellite at all.
|
||||
*/
|
||||
fun resolve(name: String, officialNames: Collection<String>): String? {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = trimmed.uppercase(Locale.US)
|
||||
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
|
||||
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
|
||||
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
|
||||
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
|
||||
* when the two share no characters.
|
||||
*/
|
||||
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
|
||||
val normalized = normalize(query)
|
||||
if (normalized.isEmpty()) return emptyList()
|
||||
val fromAliases = table.entries
|
||||
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
|
||||
.map { it.value }
|
||||
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
|
||||
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
|
||||
.distinct()
|
||||
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
|
||||
/**
|
||||
* Wording for the reasons a QSO cannot be signed for LoTW.
|
||||
*
|
||||
* The logbook used to report every one of these as "invalid call/date", which sent the operator
|
||||
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
|
||||
* a satellite name ARRL does not know.
|
||||
*/
|
||||
fun LoTWProblem.label(): String = when (this) {
|
||||
LoTWProblem.BAND -> "missing frequency/band"
|
||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||
LoTWProblem.STATION_REGION -> "station region missing"
|
||||
LoTWProblem.STATION_ZONE -> "station zones missing"
|
||||
LoTWProblem.STATION_IOTA -> "station IOTA missing"
|
||||
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
|
||||
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
|
||||
else -> name.lowercase().replace('_', ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line explanation of why records were left out of an upload, e.g.
|
||||
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
|
||||
* Returns an empty string when nothing was skipped.
|
||||
*/
|
||||
fun unavailableUploadSummary(
|
||||
total: Int,
|
||||
reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
details: Map<LoTWProblem, String> = emptyMap(),
|
||||
duplicates: Int = 0,
|
||||
incomplete: Int = 0
|
||||
): String {
|
||||
if (total <= 0) return ""
|
||||
val parts = reasons.entries
|
||||
.sortedByDescending { it.value }
|
||||
.map { (problem, count) ->
|
||||
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
|
||||
"$count× ${problem.label()}$detail"
|
||||
}
|
||||
.toMutableList()
|
||||
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
|
||||
if (incomplete > 0) parts += "$incomplete× record not completed"
|
||||
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
|
||||
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||
return "$subject — $detail"
|
||||
}
|
||||
+118
-10
@@ -1,26 +1,134 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||
/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix. Kept as the last-resort identity for names the alias
|
||||
* table does not know (see [satelliteIdentity]). */
|
||||
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||
|
||||
/**
|
||||
* The satellite identity both sides of a match resolve to.
|
||||
*
|
||||
* A local record keeps whatever the tracking source published ("SAUDISAT 1C",
|
||||
* "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
|
||||
* name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
|
||||
* sides of the SAME contact unequal, so a downloaded confirmation was stored as a
|
||||
* second, separate QSO instead of confirming the uploaded one.
|
||||
*
|
||||
* Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
|
||||
* tracker names onto the ARRL names and every ARRL name onto itself. Names the table
|
||||
* does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
|
||||
* parenthetical-free prefix, i.e. they simply never match anything else.
|
||||
*/
|
||||
fun satelliteIdentity(name: String): String {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
val prefix = satMatchKey(trimmed)
|
||||
return LoTWSatelliteAliases.lookup(trimmed)
|
||||
?: LoTWSatelliteAliases.lookup(prefix)
|
||||
?: prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
|
||||
* one, otherwise the name as published.
|
||||
*
|
||||
* Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
|
||||
* every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
|
||||
* one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
|
||||
* for the logbook, the ADIF export and the signature. Names the alias table does not know are
|
||||
* left exactly as they are: satellites ARRL does not list have no official name to store, and the
|
||||
* recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
|
||||
*/
|
||||
fun officialSatelliteName(name: String, officialNames: Collection<String> = emptyList()): String {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
|
||||
val prefix = satMatchKey(trimmed)
|
||||
return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything about a contact except its bands: the same opposite station, the same
|
||||
* satellite, the same mode and less than a minute apart. LoTW reports the minute, so
|
||||
* the time window is what makes a match unambiguous.
|
||||
*/
|
||||
fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
|
||||
val localSat = satelliteIdentity(local.satelliteName)
|
||||
return localSat.isNotBlank() &&
|
||||
localSat == satelliteIdentity(remote.satelliteName) &&
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
}
|
||||
|
||||
/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
|
||||
private fun sameBandOrBlank(one: String, other: String): Boolean =
|
||||
one.isBlank() || other.isBlank() || one.equals(other, true)
|
||||
|
||||
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
|
||||
|
||||
/**
|
||||
* Rows that a confirmation should have been folded into but was not, so they can be
|
||||
* consolidated back into one.
|
||||
*
|
||||
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
||||
* name vs ARRL name comparison failed, and the band direction of the report was read
|
||||
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
|
||||
* were stored twice. The pair is only accepted when the contact identity matches and
|
||||
* the bands are either identical or exactly mirrored — the signature of that second
|
||||
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
||||
*/
|
||||
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
val taken = mutableSetOf<Int>()
|
||||
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
|
||||
val confirmation = records[confirmationIndex]
|
||||
val localIndex = pendingIndices.firstOrNull { index ->
|
||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||
bandsMatchOrMirror(records[index], confirmation)
|
||||
} ?: return@mapNotNull null
|
||||
taken += localIndex
|
||||
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
|
||||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
||||
|
||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||
|
||||
+35
-2
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
@@ -11,6 +29,9 @@ interface ILoTWUploadRepository {
|
||||
suspend fun removeCertificate()
|
||||
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
|
||||
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
|
||||
/** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
|
||||
* signed, so the logbook UI validates/picks satellite names from this list. */
|
||||
suspend fun satelliteCatalog(): List<String>
|
||||
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||
suspend fun upload(previewId: String): LoTWUploadResult
|
||||
@@ -69,18 +90,30 @@ data class LoTWUploadPreview(
|
||||
val unknownSkipped: Int = 0,
|
||||
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
|
||||
val unavailableSkipped: Int = 0,
|
||||
/** Why those records were un-signable, so the operator sees the real cause. */
|
||||
val unavailableReasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
/** Records dropped because the same contact already appears earlier in the batch. */
|
||||
val duplicateSkipped: Int = 0,
|
||||
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
||||
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
|
||||
val submittedIds: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||
data class LoTWUploadAudit(
|
||||
val total: Int,
|
||||
val pending: Int,
|
||||
val uploaded: Int,
|
||||
val unknown: Int,
|
||||
val unavailable: Int
|
||||
val unavailable: Int,
|
||||
/** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
|
||||
val reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
|
||||
val details: Map<LoTWProblem, String> = emptyMap(),
|
||||
/** Records dropped as duplicates of an earlier record in the same selection. */
|
||||
val duplicates: Int = 0,
|
||||
/** Records that are not marked complete, so they are not uploadable at all. */
|
||||
val incomplete: Int = 0
|
||||
)
|
||||
|
||||
sealed interface LoTWUploadResult {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Nominal uplink frequency: the middle of the passband when one is published. */
|
||||
fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||
|
||||
/** Nominal downlink frequency: the middle of the passband when one is published. */
|
||||
fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||
|
||||
/**
|
||||
* The FM voice repeater of a satellite, if it has one.
|
||||
*
|
||||
* A voice repeater is the single-frequency FM transceiver the operator actually
|
||||
* talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
|
||||
* CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
|
||||
* uplink and a downlink already filters them out; the FM mode check removes
|
||||
* AFSK/APRS and linear entries.
|
||||
*
|
||||
* Ranking when several FM duplex entries exist (ISS is the awkward case, it also
|
||||
* lists crew V/V channels):
|
||||
* 1. entries described as a repeater / voice channel,
|
||||
* 2. amateur service entries,
|
||||
* 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
|
||||
*/
|
||||
fun List<SatRadio>.voiceRepeater(): SatRadio? = asSequence()
|
||||
.filter { it.isAlive }
|
||||
.filter { it.uplinkHz() != null && it.downlinkHz() != null }
|
||||
.filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
|
||||
.sortedWith(
|
||||
compareByDescending<SatRadio> { it.info.contains("repeater", true) || it.info.contains("voice", true) }
|
||||
.thenByDescending { it.service.equals("Amateur", true) }
|
||||
.thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
|
||||
)
|
||||
.firstOrNull()
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
|
||||
class LoTWSatelliteAliasesTest {
|
||||
|
||||
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
|
||||
|
||||
@Test
|
||||
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
|
||||
assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
|
||||
assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
||||
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
||||
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize strips separators and case`() {
|
||||
assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
|
||||
assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lookup ignores unknown and placeholder names`() {
|
||||
assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
|
||||
// Recycled temporary designations must never resolve: they would sign the wrong object.
|
||||
assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
||||
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve rejects names that are not ARRL satellites`() {
|
||||
assertNull(LoTWSatelliteAliases.resolve("", catalogue))
|
||||
assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
|
||||
assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggestions find the ARRL name from a tracker spelling`() {
|
||||
assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
|
||||
assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
|
||||
assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
|
||||
assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
|
||||
* satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
|
||||
class VoiceRepeaterTest {
|
||||
|
||||
private fun radio(
|
||||
uuid: String,
|
||||
info: String,
|
||||
up: Long? = null,
|
||||
upHigh: Long? = null,
|
||||
down: Long? = null,
|
||||
downHigh: Long? = null,
|
||||
upMode: String? = null,
|
||||
downMode: String? = null,
|
||||
alive: Boolean = true,
|
||||
service: String? = null,
|
||||
catnum: Int = 12345
|
||||
) = SatRadio(
|
||||
uuid = uuid, info = info, isAlive = alive,
|
||||
downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
|
||||
uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
|
||||
isInverted = false, catnum = catnum, service = service
|
||||
)
|
||||
|
||||
// SO-50 (27607)
|
||||
private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
|
||||
private val so50Voice = radio(
|
||||
"so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
|
||||
up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
|
||||
)
|
||||
|
||||
// ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
|
||||
private val issCrew2 = radio(
|
||||
"iss-crew2", "Mode V/V FM (crew R2+3)",
|
||||
up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||
)
|
||||
private val issCrew1 = radio(
|
||||
"iss-crew1", "Mode V/V FM (crew R1)",
|
||||
up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||
)
|
||||
private val issVoice = radio(
|
||||
"iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
|
||||
up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
|
||||
service = "Amateur", catnum = 25544
|
||||
)
|
||||
private val issAprs = radio(
|
||||
"iss-aprs", "Mode V APRS",
|
||||
up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
|
||||
)
|
||||
|
||||
// AO-123 / ASRTU-1 (61781)
|
||||
private val ao123Voice = radio(
|
||||
"ao123-voice", "Mode V/U - FM Transceiver",
|
||||
up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
|
||||
service = "Amateur", catnum = 61781
|
||||
)
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_picksSo50VoicePair() {
|
||||
val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
|
||||
assertEquals("so50-voice", repeater?.uuid)
|
||||
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||
assertEquals(436_795_000L, repeater?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
|
||||
val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
|
||||
// Order must not matter - ranking decides.
|
||||
assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
|
||||
assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_picksAo123Transceiver() {
|
||||
val repeater = listOf(ao123Voice).voiceRepeater()
|
||||
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||
assertEquals(435_400_000L, repeater?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresBeaconsWithoutUplink() {
|
||||
assertNull(listOf(so50Tlm).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresDeadEntries() {
|
||||
val dead = so50Voice.copy(uuid = "dead", isAlive = false)
|
||||
assertNull(listOf(dead).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresLinearTransponders() {
|
||||
val linear = radio(
|
||||
"lin", "Linear Transponder",
|
||||
up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
|
||||
upMode = "LSB", downMode = "USB"
|
||||
)
|
||||
assertNull(listOf(linear).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uplinkHz_usesPassbandMiddleWhenPresent() {
|
||||
val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
|
||||
assertEquals(145_850_000L, wide.uplinkHz())
|
||||
assertEquals(145_850_000L, so50Voice.uplinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downlinkHz_isNullWhenNoDownlink() {
|
||||
assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
|
||||
}
|
||||
}
|
||||
+137
@@ -12,6 +12,7 @@ package com.rtbishop.look4sat.core.domain.logbook
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -133,4 +134,140 @@ class LogbookMergeTest {
|
||||
assertEquals("FM", staleFm.displayMode)
|
||||
assertEquals("CW", staleCw.displayMode)
|
||||
}
|
||||
|
||||
// region logbook split (tracker name / band direction, 2026-09-28)
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
|
||||
// Local records keep the tracking-source name, LoTW reports the ARRL name; both
|
||||
// must land on one identity or every confirmation becomes a second row.
|
||||
assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
|
||||
assertEquals("SO-50", satelliteIdentity("SO-50"))
|
||||
assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
|
||||
assertEquals("ARISS", satelliteIdentity("ARISS"))
|
||||
assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
|
||||
assertEquals("FO-29", satelliteIdentity("JAS 2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
|
||||
// Guards the whole table: an entry whose target does not resolve back to the same
|
||||
// identity would split that satellite's contacts again.
|
||||
LoTWSatelliteAliases.table.forEach { (tracker, official) ->
|
||||
assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
|
||||
// "OBJECT xx" names are reused between objects and deliberately unmapped, so they
|
||||
// must never resolve onto a real ARRL satellite.
|
||||
assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
|
||||
assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
|
||||
// The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
|
||||
// picked up the confirmation LoTW reported for it.
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||
val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
assertTrue(sameConfirmedContact(uploaded, reported))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||
val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
|
||||
val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
.copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
|
||||
assertFalse(sameConfirmedContact(uploaded, otherOperator))
|
||||
assertFalse(sameConfirmedContact(uploaded, anotherMinute))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
|
||||
// Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
|
||||
// mirrored band direction the old parser produced (band=downlink).
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 1L, lotwUploaded = true)
|
||||
val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
|
||||
val pairs = splitConfirmationPairs(listOf(uploaded, imported))
|
||||
assertEquals(1, pairs.size)
|
||||
assertEquals(0, pairs.first().localIndex)
|
||||
assertEquals(1, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 1L, lotwUploaded = true)
|
||||
// Another operator on another satellite in the same minute must not be folded in.
|
||||
val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
|
||||
.copy(id = 2L, theirCallsign = "BG5JVM")
|
||||
// A contact on a satellite the local record does not name at all stays separate.
|
||||
val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
|
||||
assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
|
||||
}
|
||||
|
||||
/** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
|
||||
@Test
|
||||
fun officialSatelliteName_storesTheArlName() {
|
||||
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
|
||||
assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
|
||||
assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
|
||||
assertEquals("FO-29", officialSatelliteName("JAS 2"))
|
||||
// Already-ARRL names are left alone (the rewrite is idempotent).
|
||||
assertEquals("SO-50", officialSatelliteName("SO-50"))
|
||||
assertEquals("AO-123", officialSatelliteName("AO-123"))
|
||||
// Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
|
||||
assertEquals("FOO-1", officialSatelliteName("FOO-1"))
|
||||
assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
|
||||
assertEquals("", officialSatelliteName(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
|
||||
val catalogue = listOf("BO-102", "SO-50")
|
||||
// "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
|
||||
assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
|
||||
assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
|
||||
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
|
||||
}
|
||||
|
||||
private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
|
||||
startUtcMillis = REPORTED_START,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = uplinkHz,
|
||||
rxFrequencyHz = downlinkHz,
|
||||
band = frequencyBand(uplinkHz),
|
||||
rxBand = frequencyBand(downlinkHz),
|
||||
mode = "FM",
|
||||
satelliteName = satelliteName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwUploaded = true
|
||||
)
|
||||
|
||||
/** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
|
||||
private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
|
||||
startUtcMillis = REPORTED_START,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
band = band,
|
||||
rxBand = rxBand,
|
||||
mode = "FM",
|
||||
satelliteName = satelliteName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
|
||||
const val REPORTED_START = 1_789_544_700_000L
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
Reference in new issue
Block a user