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 {