fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT"

Satellite QSOs uploaded with BAND=SAT, which is not a legal ADIF Band
enumeration value (the legal values are concrete bands: 160M/80M/.../
2M/70CM/23CM...). Loggers that fail to parse an unknown band fall back
to a default — observed as QSOs landing in 160m. SAT is only legal as
PROP_MODE (propagation mode), which is already sent for v1.

Changes (WaveLogApi):
- bandFromHz(): map TX frequency to the real ADIF band (2M for VHF,
  70CM for UHF, etc.)
- satModeFrom(): derive the ADIF SAT_MODE convention string from TX/RX
  bands ("V/U" = VHF up / UHF down, "U/V", "V/S", "U/S"...; empty for
  same-band links)
- v2 JSON: band=<real band>, add sat_mode when non-empty
- v1 ADIF: <band:> real band, add <sat_mode:> when non-empty;
  PROP_MODE=SAT kept

Verification:
- New tests: SO-50 (145.850 up / 436.795 down) -> band 2M, sat_mode V/U;
  AO-73 (435.150 up / 145.950 down) -> band 70CM, sat_mode U/V;
  same-band -> empty sat_mode; satellite freqs never map to 160M.
- All wavelog payload tests + full domain suite green.
This commit is contained in:
mckero committed 2026-08-09 07:48:15 +00:00
1 parent 09ebf1f39a
commit c42e1d7b4e
2 files changed
+143 -2

No files matched your search

@@ -106,6 +106,51 @@ object WaveLogApi {
WavelogResult.Success("")
}
/**
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
* that fails to parse an illegal band falls back to a default such as
* 160m. Satellite QSOs must carry the real band of the TX frequency.
*/
fun bandFromHz(freqHz: Long): String = when {
freqHz >= 1240_000_000 -> "23CM"
freqHz >= 902_000_000 -> "33CM"
freqHz >= 420_000_000 -> "70CM"
freqHz >= 222_000_000 -> "1.25M"
freqHz >= 144_000_000 -> "2M"
freqHz >= 50_000_000 -> "6M"
freqHz >= 28_000_000 -> "10M"
freqHz >= 24_890_000 -> "12M"
freqHz >= 21_000_000 -> "15M"
freqHz >= 18_068_000 -> "17M"
freqHz >= 14_000_000 -> "20M"
freqHz >= 10_000_000 -> "30M"
freqHz >= 7_000_000 -> "40M"
freqHz >= 5_102_000 -> "60M"
freqHz >= 3_500_000 -> "80M"
freqHz >= 1_800_000 -> "160M"
else -> "160M"
}
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
private fun bandLetter(freqHz: Long): String = when {
freqHz >= 1_240_000_000 -> "S"
freqHz >= 420_000_000 -> "U"
freqHz >= 144_000_000 -> "V"
else -> "V"
}
/**
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
*/
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
if (rxFreqHz <= 0) return ""
val up = bandLetter(txFreqHz)
val down = bandLetter(rxFreqHz)
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()
@@ -142,10 +187,11 @@ object WaveLogApi {
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
@@ -155,6 +201,7 @@ object WaveLogApi {
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
@@ -183,9 +230,10 @@ object WaveLogApi {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
return buildString {
append(field("call", qso.call))
append(field("band", "SAT"))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
@@ -198,6 +246,7 @@ object WaveLogApi {
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
append(field("prop_mode", "SAT"))
}
append("<eor>")
@@ -0,0 +1,92 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* Verifies the WaveLog upload payload frequency/band fields.
*
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
* value corrupts band derivation.
*/
class WaveLogApiPayloadTest {
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
private val uplinkHz = 145_850_000L
private val downlinkHz = 436_795_000L
@Test
fun v2_freq_usesMhzStringWithMSuffix() {
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
assertEquals("145.850000M", freq)
assertEquals("436.795000M", freqRx)
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
val parsedHz = parseLikeWaveLog(freq)
assertEquals(uplinkHz, parsedHz)
}
@Test
fun v1_adif_freq_isBareMhzNumber() {
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
assertEquals("145.850000", freq)
val adifFreq = freq.toDouble() * 1_000_000
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
}
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
private fun parseLikeWaveLog(raw: String): Long {
val s = raw.trim()
return if (s.endsWith("M", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000_000).toLong()
} else if (s.endsWith("k", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000).toLong()
} else {
s.toLong()
}
}
@Test
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
// Doppler-corrected values must remain near the base frequency.
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
val dopplerRate = 7.0 // km/s approaching
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
assertTrue(
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
kotlin.math.abs(corrected - uplinkHz) < 20_000
)
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
val mhz = corrected / 1_000_000.0
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
}
@Test
fun band_isRealBand_notSAT() {
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
// Same-band (e.g. simplex) -> empty sat mode
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
// Never 160m for satellite frequencies
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
}
@Test
fun adif_containsRealBandAndSatMode() {
// v2 payload fields (mirror postQso construction)
val band = WaveLogApi.bandFromHz(uplinkHz)
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
assertEquals("2M", band)
assertEquals("V/U", satMode)
}
}