fix: mutual pass search now matches main radar pass definition

Root cause: mutual search used the min-elevation threshold (default 10°)
as the AOS/LOS boundary, while the main radar uses the 0° horizon
(getLeoPass). With identical stations the windows therefore never
matched. Also, an in-progress pass was counted as a new one.

- AOS/LOS boundaries now at the 0° horizon (refined to 1s)
- Skip in-progress mutual windows at search start (like getLeoPass)
- Filter requires BOTH stations to reach their min elevation
- Default min elevation follows the main radar passes setting
This commit is contained in:
atsunatsu committed 2026-08-03 02:04:13 +08:00
1 parent 1a3f1bb34c
commit 43788ba7a7
1 file changed
+29 -20
@@ -61,10 +61,12 @@ class MutualViewModel(
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow() val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
init { init {
// Pre-fill station A with the user's current station position (as grid) // Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes
val pos = settingsRepo.stationPosition.value val pos = settingsRepo.stationPosition.value
val grid = latLonToGrid(pos.latitude, pos.longitude) val grid = latLonToGrid(pos.latitude, pos.longitude)
_uiState.update { it.copy(stationAGrid = grid) } val minElev = settingsRepo.passesSettings.value.minElevation
_uiState.update { it.copy(stationAGrid = grid, stationAMinElev = minElev, stationBMinElev = minElev) }
} }
fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) } fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) }
@@ -107,7 +109,6 @@ class MutualViewModel(
val results = withContext(Dispatchers.Default) { val results = withContext(Dispatchers.Default) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours) findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
mutualPasses = results.sortedBy { mp -> mp.startTime }, mutualPasses = results.sortedBy { mp -> mp.startTime },
@@ -214,14 +215,14 @@ class MutualViewModel(
var searchStart = time var searchStart = time
// Find ALL mutual passes for this satellite // Find ALL mutual passes for this satellite
while (true) { while (true) {
val t = findNextMutualPass(sat, posA, posB, minElevADeg, minElevBDeg, searchStart, endTime) val t = findNextMutualPass(sat, posA, posB, searchStart, endTime)
if (t == null) break if (t == null) break
val (aos, los) = t val (aos, los) = t
// Refine AOS: finer search near the edge // Refine AOS: finer search near the edge (0° horizon, like the main radar)
val refinedAos = refineEdge(sat, posA, posB, minElevADeg, minElevBDeg, aos, step = 1_000L, goingUp = true) val refinedAos = refineEdge(sat, posA, posB, aos, step = 1_000L, goingUp = true)
// Refine LOS: finer search near the edge // Refine LOS: finer search near the edge
val refinedLos = refineEdge(sat, posA, posB, minElevADeg, minElevBDeg, los, step = 1_000L, goingUp = false) val refinedLos = refineEdge(sat, posA, posB, los, step = 1_000L, goingUp = false)
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>() val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
val tracks = mutableListOf<TrackSample>() val tracks = mutableListOf<TrackSample>()
@@ -249,7 +250,8 @@ class MutualViewModel(
tSample += sampleInterval tSample += sampleInterval
} }
if (maxElevA > minElevADeg || maxElevB > minElevBDeg) { // Both stations must reach their own min elevation for a usable mutual pass
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
results.add( results.add(
MutualPass( MutualPass(
catNum = sat.data.catnum, catNum = sat.data.catnum,
@@ -270,20 +272,19 @@ class MutualViewModel(
return results return results
} }
/** Refine the AOS (goingUp=true) or LOS (goingUp=false) with fine steps. */ /** Refine the AOS (goingUp=true) or LOS (goingUp=false) to ~1s precision at the 0° horizon. */
private fun refineEdge( private fun refineEdge(
sat: OrbitalObject, posA: GeoPos, posB: GeoPos, sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
approxTime: Long, step: Long, goingUp: Boolean approxTime: Long, step: Long, goingUp: Boolean
): Long { ): Long {
if (goingUp) { if (goingUp) {
// AOS: walk backward from approxTime to find the last sample where both are below, // AOS: walk backward from approxTime to find the last sample where either is below
// then AOS is the next step after that. // the horizon, then AOS is the next step after that.
var t = approxTime var t = approxTime
while (t > approxTime - 70_000L) { while (t > approxTime - 70_000L) {
val eA = elevationDeg(sat, posA, t) val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t) val eB = elevationDeg(sat, posB, t)
if (eA > minElevADeg && eB > minElevBDeg) { if (eA > 0.0 && eB > 0.0) {
t -= step t -= step
} else { } else {
return t + step return t + step
@@ -291,13 +292,13 @@ class MutualViewModel(
} }
return approxTime - 70_000L + step return approxTime - 70_000L + step
} else { } else {
// LOS: walk forward from approxTime to find the first sample where either drops below, // LOS: walk forward from approxTime to find the first sample where either drops
// then LOS is the step before that. // below the horizon, then LOS is the step before that.
var t = approxTime var t = approxTime
while (t < approxTime + 70_000L) { while (t < approxTime + 70_000L) {
val eA = elevationDeg(sat, posA, t) val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t) val eB = elevationDeg(sat, posB, t)
if (eA > minElevADeg && eB > minElevBDeg) { if (eA > 0.0 && eB > 0.0) {
t += step t += step
} else { } else {
return t - step return t - step
@@ -314,23 +315,31 @@ class MutualViewModel(
private fun findNextMutualPass( private fun findNextMutualPass(
sat: OrbitalObject, sat: OrbitalObject,
posA: GeoPos, posB: GeoPos, posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
startTime: Long, endTime: Long startTime: Long, endTime: Long
): Pair<Long, Long>? { ): Pair<Long, Long>? {
var t = startTime var t = startTime
val step = 60_000L val step = 60_000L
// Skip an in-progress mutual window at the search start (same as getLeoPass):
// walk forward until either station drops below the horizon, then keep searching.
if (elevationDeg(sat, posA, t) > 0.0 && elevationDeg(sat, posB, t) > 0.0) {
while (t < endTime) {
if (elevationDeg(sat, posA, t) <= 0.0 || elevationDeg(sat, posB, t) <= 0.0) break
t += step
}
}
while (t < endTime) { while (t < endTime) {
val elevA = elevationDeg(sat, posA, t) val elevA = elevationDeg(sat, posA, t)
val elevB = elevationDeg(sat, posB, t) val elevB = elevationDeg(sat, posB, t)
if (elevA > minElevADeg && elevB > minElevBDeg) { if (elevA > 0.0 && elevB > 0.0) {
var aos = t var aos = t
var rew = t var rew = t
while (rew > startTime - 600_000L) { while (rew > startTime - 600_000L) {
val eA = elevationDeg(sat, posA, rew) val eA = elevationDeg(sat, posA, rew)
val eB = elevationDeg(sat, posB, rew) val eB = elevationDeg(sat, posB, rew)
if (eA < minElevADeg || eB < minElevBDeg) { if (eA <= 0.0 || eB <= 0.0) {
aos = rew + step aos = rew + step
break break
} }
@@ -342,7 +351,7 @@ class MutualViewModel(
while (fwd < endTime + 600_000L) { while (fwd < endTime + 600_000L) {
val eA = elevationDeg(sat, posA, fwd) val eA = elevationDeg(sat, posA, fwd)
val eB = elevationDeg(sat, posB, fwd) val eB = elevationDeg(sat, posB, fwd)
if (eA < minElevADeg || eB < minElevBDeg) { if (eA <= 0.0 || eB <= 0.0) {
los = fwd los = fwd
break break
} }