From 5d792a0fc8fc48ceac3c356459e4de08e5ed7b69 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 20:00:16 +0800 Subject: [PATCH] fix: apply both uplink and downlink Doppler in linear calculator mapping The passband mapping happens on the satellite, so both legs of the trip must carry their own Doppler shift: the uplink shift before the mapping and the downlink shift after it. The previous implementation mapped the ground frequency first and applied a single shift, so TX to RX missed the uplink Doppler and the reverse computation missed the downlink one. Neither function was the inverse of the other - switching the TX/RX anchor in the calculator jumped the frequencies by (uplink + downlink) Doppler, 13.5 kHz at 7 km/s for a U/V transponder. Adds exact-value and round-trip regressions at non-zero range rates. --- .../utility/DopplerFrequencyCalculator.kt | 83 +++++++++++++------ .../domain/DopplerFrequencyCalculatorTest.kt | 67 ++++++++++++++- 2 files changed, 125 insertions(+), 25 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt index 1bd9037c..a7e009bc 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -16,18 +16,31 @@ import java.util.Locale /** * Computes Doppler-corrected reciprocal frequencies for linear transponders. * - * For a linear (passband) transponder, uplink and downlink frequencies are - * related by a fixed passband offset. When the satellite moves, both are - * Doppler-shifted. Given one, this computes the other: + * The full physical path: * - * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) - * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) + * TX to RX (uplink -> downlink): + * 1. Ground transmits f_tx + * 2. Satellite receives f_tx * (c - v) / c (uplink Doppler) + * 3. Satellite transmits the passband mapping of (2) (mapping happens on board) + * 4. Ground hears (3) * (c - v) / c (downlink Doppler) + * + * RX to TX (downlink -> uplink), the same chain in reverse: + * 4. Ground hears f_rx + * 3. Satellite transmits f_rx * (c + v) / c (undo downlink Doppler) + * 2. Satellite receives the inverse passband mapping of (3) + * 1. Ground must transmit (2) * (c + v) / c (undo uplink Doppler) + * + * Both legs of the trip are Doppler shifted and by different amounts (uplink + * and downlink frequencies differ), so the mapping must happen between the two + * shifts on satellite-received frequencies. Addresses GitHub issue #91 + * (Custom frequency Doppler correction). */ object DopplerFrequencyCalculator { /** - * Given a downlink frequency, compute the Doppler-corrected uplink frequency. - * Returns null if the transponder is not a linear passband type. + * Given a downlink frequency (what the user hears), compute the + * uplink frequency the user should transmit. + * Full path: 4 -> 3 -> 2 -> 1 */ fun computeUplinkFromDownlink( downlinkHz: Long, @@ -35,17 +48,23 @@ object DopplerFrequencyCalculator { orbitalPos: OrbitalPos ): Long? { if (!isLinearTransponder(transponder)) return null - val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null - return orbitalPos.getUplinkFreq(baseUplink) + // 4 -> 3 undo the downlink Doppler: the frequency the satellite transmits + val satTx = orbitalPos.getUplinkFreq(downlinkHz) + // 3 -> 2 inverse passband mapping + val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null + // 2 -> 1 undo the uplink Doppler: the frequency the ground station transmits + return orbitalPos.getUplinkFreq(satRx) } /** - * Given a downlink frequency, compute the Doppler-corrected uplink frequency - * with an offset applied to the downlink (in Hz). - * Returns null if the transponder is not a linear passband type. + * Given a downlink frequency (what the user hears), compute the + * uplink frequency the user should transmit, with an offset applied + * to the downlink (in Hz). + * Full path: 4 -> 3 -> 2 -> 1 * * The user-entered downlink frequency already includes the offset, so subtract - * it before mapping the downlink passband position back to the uplink. + * it before the inverse passband mapping. The offset lives in the satellite + * frequency domain, hence it is removed after undoing the downlink Doppler. */ fun computeUplinkFromDownlinkWithOffset( downlinkHz: Long, @@ -54,13 +73,20 @@ object DopplerFrequencyCalculator { offsetHz: Long ): Long? { if (!isLinearTransponder(transponder)) return null - val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null - return orbitalPos.getUplinkFreq(baseUplink) + // 4 -> 3 undo the downlink Doppler (the offset travels with it) + val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz) + // 3 remove the offset (it lives in the satellite frequency domain) + val satTx = satTxWithOffset - offsetHz + // 3 -> 2 inverse passband mapping + val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null + // 2 -> 1 undo the uplink Doppler + return orbitalPos.getUplinkFreq(satRx) } /** - * Given an uplink frequency, compute the Doppler-corrected downlink frequency. - * Returns null if the transponder is not a linear passband type. + * Given an uplink frequency (what the user transmits), compute the + * downlink frequency the user will hear. + * Full path: 1 -> 2 -> 3 -> 4 */ fun computeDownlinkFromUplink( uplinkHz: Long, @@ -68,14 +94,19 @@ object DopplerFrequencyCalculator { orbitalPos: OrbitalPos ): Long? { if (!isLinearTransponder(transponder)) return null - val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null - return orbitalPos.getDownlinkFreq(baseDownlink) + // 1 -> 2 uplink Doppler: the frequency the satellite receives + val satRx = orbitalPos.getDownlinkFreq(uplinkHz) + // 2 -> 3 passband mapping + val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null + // 3 -> 4 downlink Doppler: what the ground station hears + return orbitalPos.getDownlinkFreq(satTx) } /** - * Given an uplink frequency, compute the Doppler-corrected downlink frequency - * with an offset applied to the downlink (in Hz). - * Returns null if the transponder is not a linear passband type. + * Given an uplink frequency (what the user transmits), compute the + * downlink frequency the user will hear, with an offset applied + * to the downlink (in Hz). + * Full path: 1 -> 2 -> 3 -> 4 */ fun computeDownlinkFromUplinkWithOffset( uplinkHz: Long, @@ -84,8 +115,12 @@ object DopplerFrequencyCalculator { offsetHz: Long ): Long? { if (!isLinearTransponder(transponder)) return null - val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null - return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) + // 1 -> 2 uplink Doppler: the frequency the satellite receives + val satRx = orbitalPos.getDownlinkFreq(uplinkHz) + // 2 -> 3 passband mapping + val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null + // 3 apply the offset (satellite frequency domain), 3 -> 4 downlink Doppler + return orbitalPos.getDownlinkFreq(satTx + offsetHz) } /** True if this transponder supports linear passband mapping. */ diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt index dd832672..38de7a46 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -268,6 +268,71 @@ class DopplerFrequencyCalculatorTest { val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) assertNotNull(roundTripDownlink) val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink) - assertTrue("Round-trip error too large: $error", error < 10000) + // Both legs of the trip carry their own Doppler shift, so the reverse + // computation must undo both. A single-shift implementation leaves an + // error of roughly (uplink + downlink) Doppler, ~6.8 kHz at 3.5 km/s. + assertTrue("Round-trip error too large: $error", error < 100) + } + + @Test + fun computeDownlinkFromUplink_appliesUplinkAndDownlinkDoppler() { + // 7 km/s range rate: the uplink is Doppler shifted before the passband + // mapping and the downlink after it. Both shifts must be applied - the + // mapping happens on the satellite-received frequency, not the ground one. + val xpdr = linearTransponder() + val orbitalPos = pos(7.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos) + assertNotNull(downlink) + // A single-shift implementation maps the ground frequency first and + // yields 435_189_838 here, missing the uplink shift of 3391 Hz. + assertEquals(435_186_447L, downlink) + } + + @Test + fun computeDownlinkFromUplink_inverted_appliesUplinkAndDownlinkDoppler() { + val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L) + val orbitalPos = pos(7.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos) + assertNotNull(downlink) + assertEquals(435_293_226L, downlink) + } + + @Test + fun computeUplinkFromDownlink_roundTripAtHighRangeRate() { + val xpdr = linearTransponder() + val orbitalPos = pos(7.0) + val originalUplink = 145_200_000L + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(originalUplink, xpdr, orbitalPos) + assertNotNull(downlink) + val roundTripUplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(downlink!!, xpdr, orbitalPos) + assertNotNull(roundTripUplink) + val error = kotlin.math.abs(roundTripUplink!! - originalUplink) + // A single-shift implementation is not its own inverse: the round trip + // drifts by ~(uplink + downlink) Doppler, 13.5 kHz at 7 km/s. + assertTrue("Round-trip error too large: $error", error < 100) + } + + @Test + fun computeOffsetRoundTrip_atHighRangeRate() { + val xpdr = linearTransponder() + val orbitalPos = pos(7.0) + val originalUplink = 145_200_000L + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = originalUplink, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertNotNull(downlink) + assertEquals(435_188_947L, downlink) + val roundTripUplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = downlink!!, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertNotNull(roundTripUplink) + val error = kotlin.math.abs(roundTripUplink!! - originalUplink) + assertTrue("Round-trip error too large: $error", error < 100) } }