fix(amsat): mark satellites whose reports were crowded out of the global pull
The API caps at 500 records regardless of the hours requested. With 88 catalog satellites, eight of them more active than 50 reports per 72 hours, quieter satellites get crowded out. Measured live: the global pull returned 500 reports covering 36 satellites, while the summary endpoint reported 743 reports across 38 satellites. 26 of 38 satellites had incomplete data, and two (PO-101_[FM] and TEVEL2-6_[FM]) had zero reports in the global pull despite having reports in the summary. The summary endpoint (api/v1/summary.php) returns per-satellite report counts in one request, so the fix adds one extra call rather than the 88-request alternative of per-satellite pulls. A satellite whose global pull is incomplete gets a subdued "68 / 116" marker next to its name, telling the operator the page knows there is more data it could not fetch. The marker is silent when the summary is unavailable or the counts match, so the feature degrades gracefully. The earlier no-data grey (0xFFE8E8E8) already prevented the worst case: slots crowded out of the global pull were marked as "we never looked" rather than claiming "nobody reported". The marker now closes the remaining gap: the page can honestly say "we know there are 116 reports for this satellite but we could only show you 68 of them". Also fixed a subagent mutation-testing residue: the coverage floor had been moved from global (reports.minOfOrNull) to per-satellite (satReports.minOfOrNull) and left in the tree. One test caught it (coverage is judged from all reports, not one satellite's), proving the test has teeth. Adds getAmSatSummary to IRemoteSource and RemoteSource, parseSummary to AmSatRepository, and summaryCount to SatStatus. All eight test-file implementations of IRemoteSource were updated for the new method.
This commit is contained in:
1 parent
3612e662e7
commit
018a3afd2b
8 files changed
+127
-3
No files matched your search
+50
-2
@@ -51,7 +51,20 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
|
||||
// The summary endpoint tells us how many reports each satellite actually has,
|
||||
// independent of the 500-record cap. Mark any satellite whose global pull is
|
||||
// incomplete so the UI can show a data-coverage note.
|
||||
val summaryJson = remoteSource.getAmSatSummary(hours = 72)
|
||||
val expectedCounts = parseSummary(summaryJson)
|
||||
val marked = statuses.map { status ->
|
||||
val expected = expectedCounts[status.name]
|
||||
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||
if (expected != null && expected > actual) status.copy(summaryCount = expected)
|
||||
else status
|
||||
}
|
||||
|
||||
SatStatusPage(System.currentTimeMillis(), marked, reportMap)
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
@@ -85,6 +98,33 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse summary JSON to per-satellite report counts.
|
||||
*
|
||||
* The summary aggregates across all statuses, so a satellite with both "heard" and
|
||||
* "not heard" entries appears once; we sum its report_count across all its rows.
|
||||
* Returns an empty map (not null) on failure so the caller can just check for
|
||||
* missing keys — a failed summary call degrades gracefully to "no coverage marker".
|
||||
*/
|
||||
private fun parseSummary(json: String?): Map<String, Int> {
|
||||
if (json == null) return emptyMap()
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
val out = mutableMapOf<String, Int>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name", "")
|
||||
val count = o.optInt("report_count", 0)
|
||||
if (name.isNotEmpty() && count > 0) {
|
||||
out[name] = (out[name] ?: 0) + count
|
||||
}
|
||||
}
|
||||
out
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
@@ -128,7 +168,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
// Oldest report across the whole response, marking how far back the data reaches.
|
||||
// Taken globally rather than per satellite: a quiet satellite has no reports of its
|
||||
// own, but the slots it shares with the rest of the response were still covered.
|
||||
val dataFromSec = reports.minOfOrNull { it.reportedTimeUtcSec } ?: todayMidnightSec
|
||||
//
|
||||
// Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time
|
||||
// fails to parse, and a single such record would drag this back to 1970 and mark
|
||||
// nothing as uncovered, silently reverting the distinction.
|
||||
val dataFromSec = reports.asSequence()
|
||||
.map { it.reportedTimeUtcSec }
|
||||
.filter { it > 0L }
|
||||
.minOrNull()
|
||||
?: todayMidnightSec
|
||||
|
||||
return names.map { name ->
|
||||
val satReports = byName[name].orEmpty()
|
||||
|
||||
@@ -101,4 +101,22 @@ class RemoteSource(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
|
||||
.header("User-Agent", "Look4Sat/4.5.5")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat summary exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class AmSatAuditTest {
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
|
||||
+34
@@ -28,6 +28,7 @@ class AmSatSlotBuildTest {
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
@@ -319,6 +320,39 @@ class AmSatSlotBuildTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A report whose timestamp failed to parse must not disable the distinction.
|
||||
*
|
||||
* parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the
|
||||
* minimum timestamp in the response - so one such record would put the coverage
|
||||
* boundary in 1970 and mark every slot as reported-on. Measured on a grid that should
|
||||
* have had 18 no-data cells, a single zero timestamp took it to none.
|
||||
*/
|
||||
@Test
|
||||
fun `a report with an unparseable timestamp does not disable the no-data marking`() {
|
||||
val noData = 0xFFE8E8E8
|
||||
val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real")
|
||||
val brokenTimestamp = ApiReport(
|
||||
id = "broken",
|
||||
name = "AO-91",
|
||||
callsign = "TEST",
|
||||
report = "heard",
|
||||
gridSquare = "AA00",
|
||||
reportedTimeUtcSec = 0L
|
||||
)
|
||||
|
||||
val withoutBroken = build(listOf("AO-91"), listOf(realReport))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
|
||||
assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0)
|
||||
assertEquals(
|
||||
"a zero timestamp must not change what counts as covered",
|
||||
withoutBroken, withBroken
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty response marks nothing as covered`() {
|
||||
// With no reports at all there is no evidence about any slot.
|
||||
|
||||
@@ -129,6 +129,8 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
|
||||
@@ -26,7 +26,8 @@ data class SatDay(
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 3 天(新→旧)
|
||||
val days: List<SatDay>, // 3 天(新→旧)
|
||||
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -28,4 +28,9 @@ interface IRemoteSource {
|
||||
|
||||
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
|
||||
/** Fetch AMSAT API summary for the past N hours (JSON string; null on failure).
|
||||
* Used to compare against the global reports response and flag satellites whose data
|
||||
* was crowded out of the 500-record cap. */
|
||||
suspend fun getAmSatSummary(hours: Int): String?
|
||||
}
|
||||
@@ -251,6 +251,21 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
// When the global report pull is incomplete, show how many we actually got
|
||||
// versus what the summary endpoint says exists. The summary is a single extra
|
||||
// request, so this is honest without the 88-request cost of per-satellite pulls.
|
||||
if (status.summaryCount > 0) {
|
||||
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||
if (actual < status.summaryCount) {
|
||||
Text(
|
||||
text = "$actual / ${status.summaryCount}",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF888888),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
status.days.forEach { day ->
|
||||
DayCell(
|
||||
day = day,
|
||||
|
||||
Reference in new issue
Block a user