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.
This commit is contained in:
atsunatsu committed 2026-10-05 20:00:16 +08:00
1 parent 83ce7229f6
commit 5d792a0fc8
2 files changed
+125 -25

No files matched your search

@@ -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. */
@@ -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)
}
}