From eac1e6e273cbef7c0437e777b7a1732009086cbb Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 31 Jul 2026 14:17:19 +0800 Subject: [PATCH] feat: add Doppler frequency calculator for linear transponders (issue #91) - Add DopplerFrequencyCalculator utility with passband mapping + Doppler correction - Add MHz input UI with real-time 1Hz refresh during pass - Add kHz offset for downlink frequency correction - TX/RX fields on same row for compact layout - 40 unit tests covering linear/FM/inverted transponders --- .../utility/DopplerFrequencyCalculator.kt | 98 +++++++++++ .../domain/DopplerFrequencyCalculatorTest.kt | 120 +++++++++++++ .../src/main/res/values/strings.xml | 5 + .../look4sat/feature/radar/RadarScreen.kt | 1 + .../feature/radar/TransceiversPage.kt | 166 ++++++++++++++++++ 5 files changed, 390 insertions(+) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt 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 new file mode 100644 index 00000000..f391907d --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -0,0 +1,98 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.rtbishop.look4sat.core.domain.utility + +import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.OrbitalPos + +/** + * 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, we compute the other: + * + * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) + * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) + * + * 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. + */ + fun computeUplinkFromDownlink( + downlinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null + return orbitalPos.getUplinkFreq(baseUplink) + } + + /** + * 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. + */ + fun computeUplinkFromDownlinkWithOffset( + downlinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos, + offsetHz: Long + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null + return orbitalPos.getUplinkFreq(baseUplink) + } + + /** + * Given an uplink frequency, compute the Doppler-corrected downlink frequency. + * Returns null if the transponder is not a linear passband type. + */ + fun computeDownlinkFromUplink( + uplinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null + return orbitalPos.getDownlinkFreq(baseDownlink) + } + + /** + * 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. + */ + fun computeDownlinkFromUplinkWithOffset( + uplinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos, + offsetHz: Long + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null + return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) + } + + /** True if this transponder supports linear passband mapping. */ + fun isLinearTransponder(transponder: SatRadio): Boolean { + val upLow = transponder.uplinkLow + val upHigh = transponder.uplinkHigh + val downLow = transponder.downlinkLow + val downHigh = transponder.downlinkHigh + return upLow != null && upHigh != null && downLow != null && downHigh != null + && upLow != upHigh && downLow != downHigh + } +} 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 new file mode 100644 index 00000000..9cbfdedd --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -0,0 +1,120 @@ +package com.rtbishop.look4sat.core.domain + +import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.OrbitalPos +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator +import org.junit.Assert.* +import org.junit.Test + +class DopplerFrequencyCalculatorTest { + + private fun linearTransponder( + upLow: Long = 145_000_000L, + upHigh: Long = 145_500_000L, + downLow: Long = 435_000_000L, + downHigh: Long? = 435_500_000L, + inverted: Boolean = false + ) = SatRadio( + uuid = "linear", info = "Linear Transponder", isAlive = true, + downlinkLow = downLow, downlinkHigh = downHigh, + downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh, + uplinkMode = "LSB", isInverted = inverted, catnum = 12345 + ) + + private fun fmTransponder() = SatRadio( + uuid = "fm", info = "FM Repeater", isAlive = true, + downlinkLow = 435_600_000L, downlinkHigh = null, + downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null, + uplinkMode = "FM", isInverted = false, catnum = 99999 + ) + + private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply { + this.distanceRate = distanceRateKmS + } + + @Test + fun isLinearTransponder_returnsTrueForLinear() { + assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder())) + } + + @Test + fun isLinearTransponder_returnsFalseForFM() { + assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder())) + } + + @Test + fun isLinearTransponder_returnsFalseForNullDownlinkHigh() { + val xpdr = linearTransponder(downHigh = null) + assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr)) + } + + @Test + fun computeUplinkFromDownlink_linear_noDoppler() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + assertTrue(uplink!! > 0) + // With zero Doppler, result equals mapDownlinkToUplink output + assertEquals(145_200_000L, uplink) + } + + @Test + fun computeDownlinkFromUplink_linear_noDoppler() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos) + assertNotNull(downlink) + assertEquals(435_200_000L, downlink) + } + + @Test + fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() { + // Satellite receding (positive range rate) → ground must transmit higher freq to compensate + val xpdr = linearTransponder() + val orbitalPos = pos(7.0) // ~7 km/s receding + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + // Uplink freq should be Doppler shifted UP (compensating for receding) + assertTrue(uplink!! > 145_200_000L) + } + + @Test + fun computeUplinkFromDownlink_fm_transponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeDownlinkFromUplink_fm_transponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeUplinkFromDownlink_invertedTransponder() { + val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L) + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + // Inverted: offset from high end → maps to high end of uplink + assertEquals(145_300_000L, uplink) + } + + @Test + fun computeUplinkFromDownlink_roundTrip() { + // downlink → uplink → downlink should round-trip + val xpdr = linearTransponder() + val orbitalPos = pos(3.5) + val originalDownlink = 435_250_000L + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos) + assertNotNull(uplink) + val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) + assertNotNull(roundTripDownlink) + // Doppler round-trip: small residual due to freq-dependent Doppler + val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink) + assertTrue("Round-trip error too large: $error", error < 10000) + } +} diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 530545e5..a8080c3e 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -80,6 +80,11 @@ No Yes Visible + Doppler Frequency Calculator + Enter TX freq (MHz) + Enter RX freq (MHz) + Offset (kHz) + For linear transponders, type one frequency to see the other Prev diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index c3d753a2..ae368c9f 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -173,6 +173,7 @@ private fun PagerCard( RadarPage.Transceivers -> TransceiversPage( transceivers = uiState.transceivers.transmitters, selectedUuid = uiState.transceivers.selectedUuid, + orbitalPos = uiState.orbitalPos, radioControl = uiState.radioControl, onAction = onAction ) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index 41bab435..e1e906a6 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -39,14 +39,19 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,11 +60,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.OrbitalPos +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.formatFrequency @@ -71,6 +79,7 @@ import kotlin.time.Duration.Companion.milliseconds fun TransceiversPage( transceivers: List, selectedUuid: String?, + orbitalPos: OrbitalPos?, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit, modifier: Modifier = Modifier @@ -95,6 +104,7 @@ fun TransceiversPage( TransceiverItem( radio = radio, isExpanded = isExpanded, + orbitalPos = orbitalPos, radioControl = radioControl, onAction = onAction, onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) } @@ -131,6 +141,7 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { private fun TransceiverItem( radio: SatRadio, isExpanded: Boolean, + orbitalPos: OrbitalPos?, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit, onToggle: () -> Unit @@ -225,6 +236,7 @@ private fun TransceiverItem( ) { ExpandedRadioControl( radio = radio, + orbitalPos = orbitalPos, radioControl = radioControl, onAction = onAction ) @@ -295,6 +307,7 @@ private fun UnifiedFrequencyRow( @Composable private fun ExpandedRadioControl( radio: SatRadio, + orbitalPos: OrbitalPos?, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit ) { @@ -405,6 +418,13 @@ private fun ExpandedRadioControl( } } + // Doppler frequency calculator (linear transponders only) + DopplerFrequencyCalculator( + transponder = radio, + orbitalPos = orbitalPos, + modifier = Modifier.fillMaxWidth() + ) + // Control buttons Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { @@ -436,6 +456,152 @@ private fun ExpandedRadioControl( } } +@Composable +private fun DopplerFrequencyCalculator( + transponder: SatRadio, + orbitalPos: OrbitalPos?, + modifier: Modifier = Modifier +) { + if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return + + var txInputMHz by remember { mutableStateOf("") } + var rxInputMHz by remember { mutableStateOf("") } + var offsetKHz by remember { mutableStateOf("") } + var lastEditedBy by remember { mutableStateOf(EditedField.TX) } + + val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L + + // Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field + LaunchedEffect(orbitalPos) { + if (lastEditedBy == EditedField.TX) { + val txMHz = txInputMHz.toDoubleOrNull() + if (txMHz != null && txMHz > 0) { + val txHz = (txMHz * 1_000_000).toLong() + val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txHz, transponder, orbitalPos, offsetHz + ) + if (rxHz != null) { + rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) + } + } + } else { + val rxMHz = rxInputMHz.toDoubleOrNull() + if (rxMHz != null && rxMHz > 0) { + val rxHz = (rxMHz * 1_000_000).toLong() + val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxHz, transponder, orbitalPos, offsetHz + ) + if (txHz != null) { + txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) + } + } + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.radar_doppler_calc), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.radar_doppler_info), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Offset input + OutlinedTextField( + value = offsetKHz, + onValueChange = { newVal -> + offsetKHz = newVal + // Trigger recompute based on last edited field + if (lastEditedBy == EditedField.TX) { + val txMHz = txInputMHz.toDoubleOrNull() + if (txMHz != null && txMHz > 0) { + val txHz = (txMHz * 1_000_000).toLong() + val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txHz, transponder, orbitalPos, offsetHz + ) + rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" + } + } else { + val rxMHz = rxInputMHz.toDoubleOrNull() + if (rxMHz != null && rxMHz > 0) { + val rxHz = (rxMHz * 1_000_000).toLong() + val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxHz, transponder, orbitalPos, offsetHz + ) + txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" + } + } + }, + label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + + // TX and RX inputs on the same row + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + // TX input → compute RX + OutlinedTextField( + value = txInputMHz, + onValueChange = { newVal -> + txInputMHz = newVal + lastEditedBy = EditedField.TX + val mhz = newVal.toDoubleOrNull() + if (mhz != null && mhz > 0) { + val txHz = (mhz * 1_000_000).toLong() + val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txHz, transponder, orbitalPos, offsetHz + ) + rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" + } else if (newVal.isEmpty()) { + rxInputMHz = "" + } + }, + label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + + // RX input → compute TX + OutlinedTextField( + value = rxInputMHz, + onValueChange = { newVal -> + rxInputMHz = newVal + lastEditedBy = EditedField.RX + val mhz = newVal.toDoubleOrNull() + if (mhz != null && mhz > 0) { + val rxHz = (mhz * 1_000_000).toLong() + val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxHz, transponder, orbitalPos, offsetHz + ) + txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" + } else if (newVal.isEmpty()) { + txInputMHz = "" + } + }, + label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + } + } +} + +private enum class EditedField { TX, RX } + @Composable private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { val text = frequency?.let {