Added linear transponder Doppler calculator (#232)

Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
This commit is contained in:
atsunatsuandatsunatsu authored and GitHub committed 2026-08-04 18:45:12 +02:00
1 parent a9dd3e6e55
commit bd0881fb4b
6 files changed
+525 -8

No files matched your search

@@ -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)
}
}
@@ -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)
}
}
@@ -60,6 +60,9 @@
<string name="radar_string_no">否</string> <string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string> <string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string> <string name="radar_visible">日照中</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">上一个</string> <string name="map_prev">上一个</string>
@@ -84,6 +84,9 @@
<string name="radar_string_no">No</string> <string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string> <string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string> <string name="radar_visible">Visible</string>
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Prev</string> <string name="map_prev">Prev</string>
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider 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.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
@@ -74,6 +76,7 @@ import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
Transceivers("Transceivers"), Transceivers("Transceivers"),
Calculator("Calculator"),
Sstv("SSTV") Sstv("SSTV")
} }
@@ -150,16 +153,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
modifier: Modifier = Modifier 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 pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page -> pages.forEachIndexed { index, page ->
Tab( Tab(
selected = pagerState.currentPage == index, selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) } text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
) )
@@ -176,6 +194,12 @@ private fun PagerCard(
radioControl = uiState.radioControl, radioControl = uiState.radioControl,
onAction = onAction onAction = onAction
) )
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
onAction = onAction
)
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -39,14 +39,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio 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.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -104,6 +112,69 @@ fun TransceiversPage(
} }
} }
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
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 @Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -172,12 +243,8 @@ private fun TransceiverItem(
.rotate(if (isExpanded) 270f else 90f) .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(
text = fullTitle, text = transceiverTitle(radio),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, 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 @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let { 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 = 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") listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")