Compare commits

...
Author SHA1 Message Date
atsunatsu 1813e1bfa4 fix: refine mutual pass matching and radar overlay 2026-08-03 13:04:42 +08:00
9 changed files with 311 additions and 161 deletions

No files matched your search

@@ -72,6 +72,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -200,6 +201,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
// navigateToRadar() // navigateToRadar()
@@ -216,7 +218,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
viewModel = mutualViewModel, viewModel = mutualViewModel,
navigateUp = navigateBack, navigateUp = navigateBack,
navigateToRadar = { catNum, aosTime, pass -> navigateToRadar = { catNum, aosTime, pass ->
pass?.let { container.setMutualPassData(it) } container.setMutualPassData(pass ?: MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
} }
@@ -245,6 +247,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
.clickable { .clickable {
val pass = trackingState.currentPass val pass = trackingState.currentPass
if (pass != null) { if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
} }
@@ -36,7 +36,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable @Serializable
data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual) data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
@Serializable @Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs) data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
</vector>
@@ -9,7 +9,7 @@
<string name="nav_sat">卫星</string> <string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string> <string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string> <string name="nav_radar">雷达</string>
<string name="nav_mutual">对台</string> <string name="nav_mutual">匹配</string>
<string name="nav_map">地图</string> <string name="nav_map">地图</string>
<string name="nav_prefs">设置</string> <string name="nav_prefs">设置</string>
@@ -10,7 +10,7 @@
<string name="nav_sat">Satellites</string> <string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string> <string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string> <string name="nav_radar">Radar</string>
<string name="nav_mutual">Mutual</string> <string name="nav_mutual">Match</string>
<string name="nav_map">Map</string> <string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string> <string name="nav_prefs">Settings</string>
@@ -88,7 +88,7 @@ fun MutualScreen(
}, },
topInfo = { topInfo = {
Text( Text(
text = "对台过境", text = "过境匹配",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -96,7 +96,7 @@ fun MutualScreen(
bottomInfo = { bottomInfo = {
if (state.mutualPasses.isNotEmpty()) { if (state.mutualPasses.isNotEmpty()) {
Text( Text(
text = "找到 ${state.mutualPasses.size} 个过境", text = "找到 ${state.mutualPasses.size} 个匹配",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -183,153 +183,91 @@ private fun MutualContent(
// Input form // Input form
item { item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(
Column( modifier = Modifier.fillMaxWidth(),
modifier = Modifier verticalArrangement = Arrangement.spacedBy(6.dp)
.fillMaxWidth() ) {
.padding(12.dp), MatchIntroCard()
verticalArrangement = Arrangement.spacedBy(8.dp)
) { if (isVertical) {
Text( StationInputCard(
text = "你的位置", title = "你的位置",
style = MaterialTheme.typography.titleSmall, titleColor = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium lat = state.stationALat,
lon = state.stationALon,
grid = state.stationAGrid,
minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition
) )
StationInputCard(
title = "友台位置",
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
grid = state.stationBGrid,
minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev
)
} else {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
OutlinedTextField( StationInputCard(
value = state.stationALat, title = "你的位置",
onValueChange = onStationALat, titleColor = MaterialTheme.colorScheme.primary,
label = { Text("纬度") }, lat = state.stationALat,
placeholder = { Text("39.9042") }, lon = state.stationALon,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), grid = state.stationAGrid,
singleLine = true, minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
OutlinedTextField( StationInputCard(
value = state.stationALon, title = "友台位置",
onValueChange = onStationALon, titleColor = MaterialTheme.colorScheme.tertiary,
label = { Text("经度") }, lat = state.stationBLat,
placeholder = { Text("116.4074") }, lon = state.stationBLon,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), grid = state.stationBGrid,
singleLine = true, minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
OutlinedTextField(
value = state.stationAGrid,
onValueChange = onStationAGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("ON79uj") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onUseCurrentPosition,
modifier = Modifier.align(Alignment.End)
) {
Text("当前精确位置", style = MaterialTheme.typography.bodySmall)
}
Text(
text = "最小仰角:${state.stationAMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationAMinElev.toFloat(),
onValueChange = { onStationAMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "友台位置",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationBLat,
onValueChange = onStationBLat,
label = { Text("纬度") },
placeholder = { Text("34.0522") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationBLon,
onValueChange = onStationBLon,
label = { Text("经度") },
placeholder = { Text("-118.2437") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = state.stationBGrid,
onValueChange = onStationBGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("PM01tv") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${state.stationBMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationBMinElev.toFloat(),
onValueChange = { onStationBMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = state.hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !state.isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isCalculating) "计算中..." else "查询过境")
}
} }
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
} }
} }
@@ -365,6 +303,169 @@ private fun MutualContent(
} }
} }
@Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "位置匹配",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StationInputCard(
title: String,
titleColor: androidx.compose.ui.graphics.Color,
lat: String,
lon: String,
grid: String,
minElev: Double,
latPlaceholder: String,
lonPlaceholder: String,
gridPlaceholder: String,
onLatChange: (String) -> Unit,
onLonChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onMinElevChange: (Double) -> Unit,
modifier: Modifier = Modifier,
currentPositionAction: (() -> Unit)? = null
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = titleColor
)
if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lat,
onValueChange = onLatChange,
label = { Text("纬度") },
placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = lon,
onValueChange = onLonChange,
label = { Text("经度") },
placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = grid,
onValueChange = onGridChange,
label = { Text("网格") },
placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${minElev.toInt()}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = minElev.toFloat(),
onValueChange = { onMinElevChange(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
}
}
}
@Composable
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCalculating) "计算中..." else "查询匹配")
}
}
}
}
@Composable @Composable
private fun MutualPassCard( private fun MutualPassCard(
pass: MutualPass, pass: MutualPass,
@@ -380,7 +481,6 @@ private fun MutualPassCard(
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) } var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
// Filter to only show the portion where both stations are above their minElev // Filter to only show the portion where both stations are above their minElev
// (satlover.de style — only the usable common window)
val visibleSamples = remember(pass, minElevA, minElevB) { val visibleSamples = remember(pass, minElevA, minElevB) {
pass.elevationSamples.filter { (_, elev) -> pass.elevationSamples.filter { (_, elev) ->
elev.first >= minElevA && elev.second >= minElevB elev.first >= minElevA && elev.second >= minElevB
@@ -447,7 +547,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot) // Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
Text( Text(
text = "双方轨迹(雷达图)", text = "双方轨迹",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -352,20 +352,23 @@ class MutualViewModel(
tSample += sampleInterval tSample += sampleInterval
} }
// The pass list already filters by maxElev > minElevation, so we trust // Search uses the 0° horizon as the pass boundary, but the user sliders are
// the pass is valid. The mutual pass just needs both stations' curves. // a final visibility filter. Both stations must exceed their own threshold;
results.add( // otherwise the result would expand to an empty filtered chart.
MutualPass( if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
catNum = pass.catNum, results.add(
name = pass.orbitalObject.data.name, MutualPass(
startTime = refinedAos, catNum = pass.catNum,
endTime = refinedLos, name = pass.orbitalObject.data.name,
maxElevationA = (maxElevA * 10).roundToInt() / 10.0, startTime = refinedAos,
maxElevationB = (maxElevB * 10).roundToInt() / 10.0, endTime = refinedLos,
elevationSamples = samples, maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
trackSamples = tracks maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
elevationSamples = samples,
trackSamples = tracks
)
) )
) }
} }
return results return results
} }
@@ -413,10 +416,12 @@ class MutualViewModel(
var tSample = refinedAos var tSample = refinedAos
while (tSample <= refinedLos) { while (tSample <= refinedLos) {
// Use getElevation for elevation, matching the main pass predictor; use
// getFullPosition only for azimuth needed by the radar plot.
val elevA = elevationDeg(sat, posA, tSample)
val elevB = elevationDeg(sat, posB, tSample)
val fullA = sat.getFullPosition(posA, tSample) val fullA = sat.getFullPosition(posA, tSample)
val fullB = sat.getFullPosition(posB, tSample) val fullB = sat.getFullPosition(posB, tSample)
val elevA = fullA.elevation * 180.0 / PI
val elevB = fullB.elevation * 180.0 / PI
if (elevA > maxElevA) maxElevA = elevA if (elevA > maxElevA) maxElevA = elevA
if (elevB > maxElevB) maxElevB = elevB if (elevB > maxElevB) maxElevB = elevB
samples.add(tSample to (elevA to elevB)) samples.add(tSample to (elevA to elevB))
@@ -73,6 +73,7 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlin.math.PI import kotlin.math.PI
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
@@ -87,6 +88,23 @@ fun RadarDestination(navigateUp: () -> Unit) {
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle() val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateUp()
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
val remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) {
navigateUpAndClearMutual()
return@LaunchedEffect
}
delay(remainingMs.coerceAtMost(1000L))
}
}
// Sync actual permission state on every recomposition so it survives screen re-entry // Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission( val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO context, Manifest.permission.RECORD_AUDIO
@@ -101,7 +119,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
RadarScreen(uiState, viewModel::onAction, navigateUp, mutualData, requestMicPermission = { RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) })
} }
@@ -139,6 +139,8 @@ class RadarViewModel(
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value val (catNum, aosTime) = satelliteRepo.selectedPass.value
return passes.find { it.catNum == catNum && it.aosTime == aosTime } return passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
?: passes.find { it.catNum == catNum }
?: passes.firstOrNull() ?: passes.firstOrNull()
} }