mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-06 04:27:29 +00:00
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:
1 parent
83ce7229f6
commit
5d792a0fc8
2 files changed
+125
-25
No files matched your search
+59
-24
@@ -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. */
|
||||
|
||||
+66
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user