From 3612e662e747c7cb216afa3bbf23c23869e1b1a3 Mon Sep 17 00:00:00 2001 From: QIU Date: Sat, 22 Aug 2026 10:27:44 +0000 Subject: [PATCH] fix(amsat): distinguish slots we have no data for from slots nobody reported Grey meant two different things. The API caps at 500 records however many hours are requested: measured against the live endpoint, a 72-hour request returned 500 reports spanning only 49 hours, so the oldest 9.5 hours of the third day had no data at all. Those cells were painted the same grey as "nobody reported", which claimed knowledge we did not have - 352 of 3168 cells on a real page, a third of the third day's column. Slots entirely older than the earliest report in the response now use a lighter grey. Coverage is judged from all reports 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, so it must read as "not heard" rather than "unknown". The two greys are now in the legend, which previously listed only the four active states. That matters more than it sounds: on the live page 81% of cells are "nobody reported" and 11% are outside our data, so a user looking at a mostly-grey row had no way to tell a dead satellite from a gap in what we fetched. The legend chips use a solid dot, so the two greys stay distinguishable despite the 25% alpha background. Strings added to all nine locales. Three tests cover it: a day entirely before the data starts, a day straddling the boundary, and an empty response marking nothing as covered. --- .../core/data/repository/AmSatRepository.kt | 19 ++++- .../data/repository/AmSatSlotBuildTest.kt | 73 +++++++++++++++++++ .../src/main/res/values-es/strings.xml | 2 + .../src/main/res/values-id/strings.xml | 2 + .../src/main/res/values-in/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-si/strings.xml | 2 + .../src/main/res/values-tr/strings.xml | 2 + .../src/main/res/values-uk/strings.xml | 2 + .../src/main/res/values-zh/strings.xml | 2 + .../src/main/res/values/strings.xml | 2 + .../feature/status/SatStatusScreen.kt | 7 +- 12 files changed, 115 insertions(+), 2 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 98504e38..768cad88 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -125,6 +125,11 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000 "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" } + // 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 + return names.map { name -> val satReports = byName[name].orEmpty() val days = (0 until 3).map { dayIdx -> @@ -135,7 +140,9 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor val slotEnd = slotStart + 7200L val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd } if (inSlot.isEmpty()) { - SatSlot(statusColor = NO_REPORT_GRAY, count = 0) + // A slot entirely before the data starts is unknown, not silent. + val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY + SatSlot(statusColor = colour, count = 0) } else { val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! SatSlot( @@ -184,5 +191,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor private const val NOT_HEARD_PINK = 0xFFDC267F private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100 private const val NO_REPORT_GRAY = 0xFFC0C0C0 + + /** + * Slots older than the data we actually received. + * + * The API caps at 500 records however many hours are requested. Measured live: a + * 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest + * 9.5 hours of the third day with no data at all. Painting those the same grey as + * "nobody reported" claimed knowledge we do not have, so they get a lighter shade. + */ + private const val NO_DATA_GRAY = 0xFFE8E8E8 } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt index be354baf..6971f864 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt @@ -256,4 +256,77 @@ class AmSatSlotBuildTest { fun `an empty catalog yields no rows rather than a malformed grid`() { assertTrue(build(emptyList(), emptyList()).isEmpty()) } + + /** + * Slots older than the data we received must not claim nobody was listening. + * + * The API caps at 500 records however many hours are asked for. Measured live, a + * 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours + * of the third day had no data at all - 352 of 3168 cells were painting "nobody heard + * it" over "we never looked". + */ + @Test + fun `slots before the data starts are marked no-data, not no-report`() { + // The only report is midday yesterday, so nothing older than that was covered. + val oldestReport = utc(2026, 8, 21, 12) + val status = build( + listOf("AO-91"), + listOf(report("AO-91", "heard", oldestReport, id = "only")) + ).single() + + val noReport = 0xFFC0C0C0 + val noData = 0xFFE8E8E8 + + // The day before yesterday is entirely before the data begins. + assertTrue( + "every slot older than the data must read as no-data", + status.days[2].slots.all { it.statusColor == noData } + ) + + // Yesterday straddles it: bands after midday are covered, bands before are not. + val yesterday = status.days[1] + assertEquals("the report's own band", 1, yesterday.slots[5].count) + assertTrue( + "bands after the oldest report are covered, so silence there is real", + yesterday.slots.take(6).all { it.statusColor != noData } + ) + assertTrue( + "the earliest band of yesterday is before any data", + yesterday.slots[11].statusColor == noData + ) + + // Today is entirely after the data starts, so its silence is genuine. + assertTrue( + "today's empty slots mean nobody reported", + status.days[0].slots.all { it.statusColor == noReport } + ) + } + + @Test + fun `coverage is judged from all reports, not one satellite's`() { + // A satellite nobody reported must not show as no-data for the whole grid: the + // slots were covered, that satellite simply was not heard. + val statuses = build( + listOf("LOUD", "QUIET"), + listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early")) + ) + val quiet = statuses.first { it.name == "QUIET" } + val noData = 0xFFE8E8E8 + + assertTrue( + "coverage reaches back to the earliest report of any satellite", + quiet.days.all { day -> day.slots.none { it.statusColor == noData } } + ) + } + + @Test + fun `an empty response marks nothing as covered`() { + // With no reports at all there is no evidence about any slot. + val status = build(listOf("AO-91"), emptyList()).single() + val noData = 0xFFE8E8E8 + assertTrue( + "yesterday and earlier cannot be claimed as silent", + status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } } + ) + } } diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 63b77b52..d99318d5 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -171,4 +171,6 @@ Desplazar tonos CW fuera de rango DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica. + Sin informes + Sin datos diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml index 935f221f..33f25cec 100644 --- a/core/presentation/src/main/res/values-id/strings.xml +++ b/core/presentation/src/main/res/values-id/strings.xml @@ -300,4 +300,6 @@ Aplikasi ini hadir tanpa jaminan Geser nada CW di luar rentang DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah. + Tidak ada laporan + Tidak ada data diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index ee57779a..d790855e 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -300,4 +300,6 @@ Aplikasi ini hadir tanpa jaminan Geser nada CW di luar rentang DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah. + Tidak ada laporan + Tidak ada data diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 6bc0f3dc..0a166b52 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -171,4 +171,6 @@ Сдвигать CW-тоны вне диапазона DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется. + Нет отчётов + Нет данных diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index 66d0ad1a..05167d8d 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -171,4 +171,6 @@ පරාසයෙන් පිටත CW ස්වර මාරු කරන්න DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි. + වාර්තා නැත + දත්ත නැත diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index a07e2ae4..a9a23b18 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -312,4 +312,6 @@ Aralık dışı CW tonlarını kaydır DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez. + Rapor yok + Veri yok diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 2e5810fe..67605704 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -171,4 +171,6 @@ Зсувати CW-тони поза діапазоном DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється. + Немає звітів + Немає даних diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 366a0ad1..cc5733df 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -302,4 +302,6 @@ 雷达罗盘偏移 (仰角) 搬移超出范围的 CW 音调 DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。 + 无人上报 + 无数据 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 84a374a2..72de5412 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -335,4 +335,6 @@ Radar compass offset (elev) Shift out-of-range CW tones DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched. + No report + No data diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 45ff4318..e958490f 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -167,11 +167,16 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: /** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */ @Composable private fun LegendRow() { + // The two greys are listed because most of a typical grid is grey: measured live, 81% + // of cells were "nobody reported" and 11% were outside the data we received. Without + // the distinction a mostly-grey row reads as a dead satellite. val legend = listOf( stringResource(id = R.string.amsat_active) to Color(0xFF648FFF), stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000), stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), - stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100) + stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100), + stringResource(id = R.string.amsat_no_report_legend) to Color(0xFFC0C0C0), + stringResource(id = R.string.amsat_no_data_legend) to Color(0xFFE8E8E8) ) FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),