mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
fix: mutual pass elevation unit (radians->degrees) + grid input
Bug: OrbitalObject.getElevation() returns radians, but MutualViewModel was comparing it directly with degree values from the slider (10-90), causing no matches to be found. Fix: add elevationDeg() helper that converts to degrees. Also add Maidenhead grid square input support (4/6/8 chars) alongside existing lat/lon fields. Grid takes priority when filled.
This commit is contained in:
1 parent
1064dc739e
commit
65f61a5147
2 files changed
+129
-51
No files matched your search
@@ -42,11 +42,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -102,7 +100,7 @@ fun MutualScreen(
|
||||
value = state.stationALat,
|
||||
onValueChange = viewModel::onStationALat,
|
||||
label = { Text("纬度") },
|
||||
placeholder = { Text("例如: 39.9042") },
|
||||
placeholder = { Text("39.9042") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -111,12 +109,20 @@ fun MutualScreen(
|
||||
value = state.stationALon,
|
||||
onValueChange = viewModel::onStationALon,
|
||||
label = { Text("经度") },
|
||||
placeholder = { Text("例如: 116.4074") },
|
||||
placeholder = { Text("116.4074") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.stationAGrid,
|
||||
onValueChange = viewModel::onStationAGrid,
|
||||
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
|
||||
placeholder = { Text("ON79uj") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text("最小仰角:${state.stationAMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = state.stationAMinElev.toFloat(),
|
||||
@@ -138,7 +144,7 @@ fun MutualScreen(
|
||||
value = state.stationBLat,
|
||||
onValueChange = viewModel::onStationBLat,
|
||||
label = { Text("纬度") },
|
||||
placeholder = { Text("例如: 34.0522") },
|
||||
placeholder = { Text("34.0522") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -147,12 +153,20 @@ fun MutualScreen(
|
||||
value = state.stationBLon,
|
||||
onValueChange = viewModel::onStationBLon,
|
||||
label = { Text("经度") },
|
||||
placeholder = { Text("例如: -118.2437") },
|
||||
placeholder = { Text("-118.2437") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.stationBGrid,
|
||||
onValueChange = viewModel::onStationBGrid,
|
||||
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
|
||||
placeholder = { Text("PM01tv") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text("最小仰角:${state.stationBMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = state.stationBMinElev.toFloat(),
|
||||
@@ -234,17 +248,11 @@ private fun MutualPassCard(
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
+104
-34
@@ -22,8 +22,8 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -31,14 +31,17 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class MutualUiState(
|
||||
val stationALat: String = "",
|
||||
val stationALon: String = "",
|
||||
val stationAGrid: String = "",
|
||||
val stationAMinElev: Double = 10.0,
|
||||
val stationBLat: String = "",
|
||||
val stationBLon: String = "",
|
||||
val stationBGrid: String = "",
|
||||
val stationBMinElev: Double = 10.0,
|
||||
val hoursAhead: Int = 24,
|
||||
val mutualPasses: List<MutualPass> = emptyList(),
|
||||
@@ -56,22 +59,24 @@ class MutualViewModel(
|
||||
|
||||
fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) }
|
||||
fun onStationALon(value: String) = _uiState.update { it.copy(stationALon = value) }
|
||||
fun onStationAGrid(value: String) = _uiState.update { it.copy(stationAGrid = value) }
|
||||
fun onStationAMinElev(value: Double) = _uiState.update { it.copy(stationAMinElev = value) }
|
||||
fun onStationBLat(value: String) = _uiState.update { it.copy(stationBLat = value) }
|
||||
fun onStationBLon(value: String) = _uiState.update { it.copy(stationBLon = value) }
|
||||
fun onStationBGrid(value: String) = _uiState.update { it.copy(stationBGrid = value) }
|
||||
fun onStationBMinElev(value: Double) = _uiState.update { it.copy(stationBMinElev = value) }
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||
|
||||
fun queryMutualPasses() {
|
||||
val state = _uiState.value
|
||||
val latA = state.stationALat.toDoubleOrNull()
|
||||
val lonA = state.stationALon.toDoubleOrNull()
|
||||
val latB = state.stationBLat.toDoubleOrNull()
|
||||
val lonB = state.stationBLon.toDoubleOrNull()
|
||||
|
||||
if (latA == null || lonA == null || latB == null || lonB == null) {
|
||||
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标") }
|
||||
// Resolve positions from lat/lon or grid
|
||||
val posA = resolvePosition(state.stationALat, state.stationALon, state.stationAGrid)
|
||||
val posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
|
||||
|
||||
if (posA == null || posB == null) {
|
||||
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,8 +90,6 @@ class MutualViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
val posA = GeoPos(latA, lonA)
|
||||
val posB = GeoPos(latB, lonB)
|
||||
val minElevA = state.stationAMinElev
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
@@ -105,10 +108,70 @@ class MutualViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a position from lat/lon text or grid square text. */
|
||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||
// Try grid square first
|
||||
val trimmed = gridText.trim().uppercase()
|
||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||
return gridToLatLon(trimmed)
|
||||
}
|
||||
// Fall back to lat/lon
|
||||
val lat = latText.toDoubleOrNull()
|
||||
val lon = lonText.toDoubleOrNull()
|
||||
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||
}
|
||||
|
||||
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
||||
private fun gridToLatLon(grid: String): GeoPos? {
|
||||
val g = grid.uppercase()
|
||||
if (g.length < 4) return null
|
||||
// First pair: letters A-R
|
||||
val lonField = (g[0] - 'A').toDouble() * 20.0
|
||||
val latField = (g[1] - 'A').toDouble() * 10.0
|
||||
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
||||
|
||||
// Second pair: digits 0-9
|
||||
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
||||
val latSquare = (g[3] - '0').toDouble() * 1.0
|
||||
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
||||
|
||||
var lon = lonField + lonSquare
|
||||
var lat = latField + latSquare
|
||||
|
||||
if (g.length >= 6) {
|
||||
// Third pair: letters A-X
|
||||
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0 // 5 minutes
|
||||
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0 // 2.5 minutes
|
||||
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
||||
lon += lonSub
|
||||
lat += latSub
|
||||
|
||||
if (g.length >= 8) {
|
||||
// Fourth pair: digits 0-9
|
||||
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0 // 30 seconds
|
||||
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0 // 15 seconds
|
||||
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
||||
lon += lonExt + 15.0 / 3600.0 // center of the 30" square
|
||||
lat += latExt + 7.5 / 3600.0 // center of the 15" square
|
||||
} else {
|
||||
lon += 2.5 / 60.0 // center of the 5' square
|
||||
lat += 1.25 / 60.0 // center of the 2.5' square
|
||||
}
|
||||
} else {
|
||||
lon += 1.0 // center of the 2° square
|
||||
lat += 0.5 // center of the 1° square
|
||||
}
|
||||
|
||||
// Convert to signed lat/lon
|
||||
lon = (lon + 180.0) % 360.0 - 180.0
|
||||
lat = (lat + 90.0) % 180.0 - 90.0
|
||||
return GeoPos(lat, lon)
|
||||
}
|
||||
|
||||
private fun findMutualPasses(
|
||||
satellites: List<OrbitalObject>,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
minElevA: Double, minElevB: Double,
|
||||
minElevADeg: Double, minElevBDeg: Double,
|
||||
time: Long, hours: Int
|
||||
): List<MutualPass> {
|
||||
val results = mutableListOf<MutualPass>()
|
||||
@@ -117,7 +180,7 @@ class MutualViewModel(
|
||||
|
||||
for (sat in satellites) {
|
||||
if (sat.data.meanmo < 1e-8) continue
|
||||
val t = findNextMutualPass(sat, posA, posB, minElevA, minElevB, time, endTime)
|
||||
val t = findNextMutualPass(sat, posA, posB, minElevADeg, minElevBDeg, time, endTime)
|
||||
if (t != null) {
|
||||
val (start, end) = t
|
||||
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
|
||||
@@ -126,30 +189,37 @@ class MutualViewModel(
|
||||
|
||||
var tSample = start
|
||||
while (tSample <= end) {
|
||||
val elevA = sat.getElevation(posA, tSample)
|
||||
val elevB = sat.getElevation(posB, tSample)
|
||||
val elevA = elevationDeg(sat, posA, tSample)
|
||||
val elevB = elevationDeg(sat, posB, tSample)
|
||||
if (elevA > maxElevA) maxElevA = elevA
|
||||
if (elevB > maxElevB) maxElevB = elevB
|
||||
samples.add(tSample to (elevA to elevB))
|
||||
tSample += sampleInterval
|
||||
}
|
||||
|
||||
results.add(
|
||||
MutualPass(
|
||||
catNum = sat.data.catnum,
|
||||
name = sat.data.name,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||
elevationSamples = samples
|
||||
if (maxElevA > minElevADeg || maxElevB > minElevBDeg) {
|
||||
results.add(
|
||||
MutualPass(
|
||||
catNum = sat.data.catnum,
|
||||
name = sat.data.name,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||
elevationSamples = samples
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** Get elevation in degrees. */
|
||||
private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double {
|
||||
return sat.getElevation(pos, time) * 180.0 / PI
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first common time window where a satellite is above both stations'
|
||||
* minimum elevations. Uses 60-second coarse stepping.
|
||||
@@ -157,25 +227,25 @@ class MutualViewModel(
|
||||
private fun findNextMutualPass(
|
||||
sat: OrbitalObject,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
minElevA: Double, minElevB: Double,
|
||||
minElevADeg: Double, minElevBDeg: Double,
|
||||
startTime: Long, endTime: Long
|
||||
): Pair<Long, Long>? {
|
||||
var t = startTime
|
||||
val step = 60_000L // 60s coarse search step
|
||||
|
||||
while (t < endTime) {
|
||||
val elevA = sat.getElevation(posA, t)
|
||||
val elevB = sat.getElevation(posB, t)
|
||||
val elevA = elevationDeg(sat, posA, t)
|
||||
val elevB = elevationDeg(sat, posB, t)
|
||||
|
||||
if (elevA > minElevA && elevB > minElevB) {
|
||||
if (elevA > minElevADeg && elevB > minElevBDeg) {
|
||||
// Both above horizon — find the common pass window
|
||||
// Rewind to find AOS (both stations)
|
||||
// Rewind to find AOS (either station drops below)
|
||||
var aos = t
|
||||
var rew = t
|
||||
while (rew > startTime - 600_000L) {
|
||||
val eA = sat.getElevation(posA, rew)
|
||||
val eB = sat.getElevation(posB, rew)
|
||||
if (eA < minElevA || eB < minElevB) {
|
||||
val eA = elevationDeg(sat, posA, rew)
|
||||
val eB = elevationDeg(sat, posB, rew)
|
||||
if (eA < minElevADeg || eB < minElevBDeg) {
|
||||
aos = rew + step
|
||||
break
|
||||
}
|
||||
@@ -186,9 +256,9 @@ class MutualViewModel(
|
||||
var los = t
|
||||
var fwd = t
|
||||
while (fwd < endTime + 600_000L) {
|
||||
val eA = sat.getElevation(posA, fwd)
|
||||
val eB = sat.getElevation(posB, fwd)
|
||||
if (eA < minElevA || eB < minElevB) {
|
||||
val eA = elevationDeg(sat, posA, fwd)
|
||||
val eB = elevationDeg(sat, posB, fwd)
|
||||
if (eA < minElevADeg || eB < minElevBDeg) {
|
||||
los = fwd
|
||||
break
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user