From c42e1d7b4e19208695c0b494c47d9674d0006731 Mon Sep 17 00:00:00 2001 From: QIU Date: Sun, 9 Aug 2026 07:48:15 +0000 Subject: [PATCH] fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=, add sat_mode when non-empty - v1 ADIF: real band, add 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. --- .../core/domain/wavelog/WaveLogApi.kt | 53 ++++++++++- .../domain/wavelog/WaveLogApiPayloadTest.kt | 92 +++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt index 36e56b11..f9491efe 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt @@ -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("") diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt new file mode 100644 index 00000000..3c639788 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt @@ -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 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) + } +}