mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-06 20:47:22 +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.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
*
|
*
|
||||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
* The full physical path:
|
||||||
* related by a fixed passband offset. When the satellite moves, both are
|
|
||||||
* Doppler-shifted. Given one, this computes the other:
|
|
||||||
*
|
*
|
||||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
* TX to RX (uplink -> downlink):
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
* 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 {
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* uplink frequency the user should transmit.
|
||||||
|
* Full path: 4 -> 3 -> 2 -> 1
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlink(
|
fun computeUplinkFromDownlink(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -35,17 +48,23 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
// 4 -> 3 undo the downlink Doppler: the frequency the satellite transmits
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
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
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* uplink frequency the user should transmit, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: 4 -> 3 -> 2 -> 1
|
||||||
*
|
*
|
||||||
* The user-entered downlink frequency already includes the offset, so subtract
|
* 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(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -54,13 +73,20 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
// 4 -> 3 undo the downlink Doppler (the offset travels with it)
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
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.
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* downlink frequency the user will hear.
|
||||||
|
* Full path: 1 -> 2 -> 3 -> 4
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplink(
|
fun computeDownlinkFromUplink(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -68,14 +94,19 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// 1 -> 2 uplink Doppler: the frequency the satellite receives
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
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
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* downlink frequency the user will hear, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: 1 -> 2 -> 3 -> 4
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplinkWithOffset(
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -84,8 +115,12 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// 1 -> 2 uplink Doppler: the frequency the satellite receives
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
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. */
|
/** True if this transponder supports linear passband mapping. */
|
||||||
|
|||||||
+66
-1
@@ -268,6 +268,71 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
assertNotNull(roundTripDownlink)
|
assertNotNull(roundTripDownlink)
|
||||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
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