From bd0881fb4bc1de494af72ec4d6cb9769a17cc7de Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Wed, 5 Aug 2026 00:45:12 +0800 Subject: [PATCH] Added linear transponder Doppler calculator (#232) Co-authored-by: atsunatsu --- .../utility/DopplerFrequencyCalculator.kt | 122 +++++++++++ .../domain/DopplerFrequencyCalculatorTest.kt | 190 ++++++++++++++++++ .../src/main/res/values-zh/strings.xml | 3 + .../src/main/res/values/strings.xml | 3 + .../look4sat/feature/radar/RadarScreen.kt | 30 ++- .../feature/radar/TransceiversPage.kt | 185 ++++++++++++++++- 6 files changed, 525 insertions(+), 8 deletions(-) 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..6b5e9575 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -0,0 +1,122 @@ +/* + * 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 +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: + * + * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) + * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) + */ +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. + * + * The user-entered downlink frequency already includes the offset, so subtract + * it before mapping the downlink passband position back to the uplink. + */ + 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 + } + + /** + * True for the radio entry that should drive the standalone Calculator page. + * + * A frequency range alone is not enough: some non-user-facing or drifting data entries + * can also have low/high frequencies. The calculator is meant for named linear + * transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder". + */ + fun isNamedLinearTransponder(transponder: SatRadio): Boolean { + if (!isLinearTransponder(transponder)) return false + + val info = transponder.info.lowercase(Locale.ENGLISH) + val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) + .joinToString(separator = " ") + .lowercase(Locale.ENGLISH) + val hasLinearName = info.contains("linear") + val hasTransponderName = info.contains("transponder") || info.contains("transp") || + info.contains("xponder") || info.contains("xpdr") + val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } + + return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) + } +} 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..49e8ebb6 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -0,0 +1,190 @@ +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.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +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, + info: String = "Linear Transponder", + downlinkMode: String? = "USB", + uplinkMode: String? = "LSB" + ) = SatRadio( + uuid = "linear", info = info, isAlive = true, + downlinkLow = downLow, downlinkHigh = downHigh, + downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, + uplinkMode = uplinkMode, 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 isNamedLinearTransponder_returnsTrueForLinearTransponderName() { + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder())) + } + + @Test + fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() { + val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() { + val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)") + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForFmRepeater() { + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) + } + + @Test + fun computeUplinkFromDownlink_linear_noDoppler() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + 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) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + assertTrue(uplink!! > 145_200_000L) + } + + @Test + fun computeUplinkFromDownlink_fmTransponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeDownlinkFromUplink_fmTransponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = 145_200_000L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertEquals(435_202_500L, downlink) + } + + @Test + fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = 435_202_500L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertEquals(145_200_000L, uplink) + } + + @Test + fun computeOffsetRoundTrip_handlesNegativeOffset() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = 145_200_000L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = -2_500L + ) + assertEquals(435_197_500L, downlink) + + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = downlink!!, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = -2_500L + ) + assertEquals(145_200_000L, uplink) + } + + @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) + assertEquals(145_300_000L, uplink) + } + + @Test + fun computeUplinkFromDownlink_roundTrip() { + 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) + 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-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index d5169543..42507759 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -60,6 +60,9 @@ 否 是 日照中 + 输入上行频率 (MHz) + 输入下行频率 (MHz) + 偏移 (kHz) 上一个 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index d571a14b..f88540cb 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ No Yes Visible + Enter TX freq (MHz) + Enter RX freq (MHz) + Offset (kHz) 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 aa164b32..b94227ca 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard @@ -74,6 +76,7 @@ import kotlinx.coroutines.launch private enum class RadarPage(val title: String) { Transceivers("Transceivers"), + Calculator("Calculator"), Sstv("SSTV") } @@ -150,16 +153,31 @@ private fun PagerCard( requestMicPermission: () -> Unit, modifier: Modifier = Modifier ) { - val pages = RadarPage.entries + val hasCalculatorPage = remember(uiState.transceivers.transmitters) { + uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder) + } + val pages = remember(hasCalculatorPage) { + buildList { + add(RadarPage.Transceivers) + if (hasCalculatorPage) add(RadarPage.Calculator) + add(RadarPage.Sstv) + } + } val pagerState = rememberPagerState(pageCount = { pages.size }) val coroutineScope = rememberCoroutineScope() + LaunchedEffect(pages.size) { + val lastPage = pages.lastIndex + if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage) + } + ElevatedCard(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex) + PrimaryTabRow(selectedTabIndex = selectedTabIndex) { pages.forEachIndexed { index, page -> Tab( - selected = pagerState.currentPage == index, + selected = selectedTabIndex == index, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) } ) @@ -176,6 +194,12 @@ private fun PagerCard( radioControl = uiState.radioControl, onAction = onAction ) + RadarPage.Calculator -> CalculatorPage( + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, + orbitalPos = uiState.orbitalPos, + onAction = onAction + ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, 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 c47f20e1..1a4d2e10 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 @@ -104,6 +112,69 @@ fun TransceiversPage( } } +@Composable +fun CalculatorPage( + transceivers: List, + selectedUuid: String?, + orbitalPos: OrbitalPos?, + onAction: (RadarAction) -> Unit, + modifier: Modifier = Modifier +) { + val calculatorTransceivers = remember(transceivers) { + transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) + } + val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { + calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } + ?: calculatorTransceivers.firstOrNull() + } + + if (selectedTransceiver == null) { + EmptyTransceiversContent(modifier) + return + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (calculatorTransceivers.size > 1) { + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + calculatorTransceivers.forEach { radio -> + FilterChip( + selected = radio.uuid == selectedTransceiver.uuid, + onClick = { + if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid)) + }, + label = { + Text( + text = transceiverTitle(radio), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + } + } + + item { + DopplerCalculatorPanel( + transponder = selectedTransceiver, + orbitalPos = orbitalPos, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + @Composable private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { @@ -172,12 +243,8 @@ private fun TransceiverItem( .rotate(if (isExpanded) 270f else 90f) ) } - // Title with mode - val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info - val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}" - val fullTitle = "$title ($mode)" Text( - text = fullTitle, + text = transceiverTitle(radio), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -437,6 +504,108 @@ private fun ExpandedRadioControl( } } +@Composable +private fun DopplerCalculatorPanel( + transponder: SatRadio, + orbitalPos: OrbitalPos?, + modifier: Modifier = Modifier +) { + if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return + + var txInputMHz by remember(transponder.uuid) { mutableStateOf("") } + var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") } + var offsetKHz by remember(transponder.uuid) { mutableStateOf("") } + var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) } + + fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L + fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0) + fun mhzToHz(text: String): Long? = text.toDoubleOrNull() + ?.takeIf { it > 0.0 } + ?.let { (it * 1_000_000).toLong() } + + fun calculateDownlink(txText: String, offsetText: String): String? { + val txHz = mhzToHz(txText) ?: return null + return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = txHz, + transponder = transponder, + orbitalPos = orbitalPos, + offsetHz = offsetHz(offsetText) + )?.let(::formatHz) + } + + fun calculateUplink(rxText: String, offsetText: String): String? { + val rxHz = mhzToHz(rxText) ?: return null + return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = rxHz, + transponder = transponder, + orbitalPos = orbitalPos, + offsetHz = offsetHz(offsetText) + )?.let(::formatHz) + } + + LaunchedEffect(orbitalPos, transponder.uuid) { + if (lastEditedBy == EditedField.TX) { + calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it } + } else { + calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it } + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = offsetKHz, + onValueChange = { newValue -> + offsetKHz = newValue + if (lastEditedBy == EditedField.TX) { + calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it } + } else { + calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it } + } + }, + label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth() + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = txInputMHz, + onValueChange = { newValue -> + txInputMHz = newValue + lastEditedBy = EditedField.TX + rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz + }, + label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + + OutlinedTextField( + value = rxInputMHz, + onValueChange = { newValue -> + rxInputMHz = newValue + lastEditedBy = EditedField.RX + txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else 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 { @@ -452,5 +621,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { ) } +private fun transceiverTitle(radio: SatRadio): String { + val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info + val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}" + return "$title ($mode)" +} + private val FREQ_ADJUSTMENTS = listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")